Api Resources seed

This commit is contained in:
Paul Schneider 2026-06-25 20:22:41 +01:00
commit 99f4361e2d
5 changed files with 75 additions and 40 deletions

View file

@ -10,6 +10,7 @@
"DESTDIR", "DESTDIR",
"dotnet", "dotnet",
"DOTNET", "DOTNET",
"ecdsa",
"envsubst", "envsubst",
"Newtonsoft", "Newtonsoft",
"Npgsql", "Npgsql",

View file

@ -64,8 +64,6 @@ public partial class App : Application
DataContext = new MainPageViewModel(client, settings) DataContext = new MainPageViewModel(client, settings)
}; };
} }
else
throw new NotSupportedException("ApplicationLifetime not supported.");
} }

View file

@ -96,6 +96,20 @@ public partial class Settings : ObservableObject
internal void Load() internal void Load()
{ {
if (Loaded) return; if (Loaded) return;
// Trust an already-populated Authority: tests pre-fill Settings
// with the OIDC stub's random loopback port, and programmatic
// callers (CLI flags, integration tests) wire their own. If we
// fall through to the disk / embedded read here we'd silently
// overwrite their value with the bundled default
// (yavsc.pschneider.fr), break the stubbed discovery URL, and
// turn a passing login into an invalid_grant.
if (!string.IsNullOrWhiteSpace(Authentication?.Authority))
{
Loaded = true;
return;
}
string configDir = Path.Combine( string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt" "PostIt"
@ -109,16 +123,14 @@ public partial class Settings : ObservableObject
if (!configFileInfo.Exists) if (!configFileInfo.Exists)
{ {
Console.Error.WriteLine($"🩎 Settings file not found at {configFileInfo.FullName}"); Console.Error.WriteLine($"🩎 Settings file not found at {configFileInfo.FullName}");
// Only fall back to the embedded default when the in-memory // No user-level config: fall back to the embedded default.
// settings haven't been populated yet. This protects callers // We only get here when Authentication.Authority is empty
// (notably tests) that pre-load Settings with explicit values // (the early-return above) so the redundant guard is gone.
// from being silently overwritten by the bundled default. if (!TryLoadEmbeddedFallback())
if (string.IsNullOrWhiteSpace(this.Authentication?.Authority)
&& !TryLoadEmbeddedFallback())
{ {
Console.Error.WriteLine("🩎 No embedded default settings; running with empty configuration."); Console.Error.WriteLine("🩎 No embedded default settings; running with empty configuration.");
} }
return; // no user settings file return;
} }
Console.WriteLine($"🔎 Loading settings from {configFileInfo.FullName}"); Console.WriteLine($"🔎 Loading settings from {configFileInfo.FullName}");

View file

@ -1,7 +1,25 @@
using IdentityServer8.EntityFramework.Entities;
public static class Constants public static class Constants
{ {
public static readonly string[] BuildInApiScopes = { public static readonly string[] BuildInApiScopes = {
"profile", "openid", "offline_access", "profile", "openid", "offline_access",
"blogs", "admin", "moderation", "performer", "client" }; "admin", "moderation", "performer", "client" };
public static readonly ApiResourceScopeSpecification[] ApiResourcesScopes = {
new ApiResourceScopeSpecification { ScopeName = "admin", Description = "Admin access" },
new ApiResourceScopeSpecification { ScopeName = "moderation", Description = "Moderation access" },
new ApiResourceScopeSpecification { ScopeName = "performer", Description = "Performer access" },
new ApiResourceScopeSpecification { ScopeName = "client", Description = "Client access" },
new ApiResourceScopeSpecification { ScopeName = "blogs", Description = "Blogs access" }
};
}
public class ApiResourceScopeSpecification
{
public string ScopeName { get; set; }
public string Description { get; set; }
} }

View file

@ -102,10 +102,10 @@ public static class HostingExtensions
options.ResourcesPath = "Resources"; options.ResourcesPath = "Resources";
}).AddDataAnnotationsLocalization(); }).AddDataAnnotationsLocalization();
services.AddTransient<ITrueEmailSender, MailSender>() services.AddTransient<ITrueEmailSender, MailSender>()
.AddTransient<Microsoft.AspNetCore.Identity.UI.Services.IEmailSender, MailSender>(); .AddTransient<Microsoft.AspNetCore.Identity.UI.Services.IEmailSender, MailSender>();
services.TryAddSingleton<ISmtpClientFactory, SmtpClientFactory>(); services.TryAddSingleton<ISmtpClientFactory, SmtpClientFactory>();
services.AddTransient<IYavscMessageSender, YavscMessageSender>() services.AddTransient<IYavscMessageSender, YavscMessageSender>()
@ -342,7 +342,7 @@ public static class HostingExtensions
}); });
// Skip the production signing-cert requirement when running with // Skip the production signing-cert requirement when running with
// an in-memory database (test fixtures) or in the Development // an in-memory database (test fixtures) or in the Development
// environment. In those cases IdentityServer8 falls back to // environment. In those cases IdentityServer8 falls back to
// AddDeveloperSigningCredential which mints an ephemeral key // AddDeveloperSigningCredential which mints an ephemeral key
@ -438,7 +438,7 @@ public static class HostingExtensions
{ {
// Validate the cert is readable (used downstream for token // Validate the cert is readable (used downstream for token
// audience/subject validation; signing itself uses the key). // audience/subject validation; signing itself uses the key).
_ = new X509Certificate2(certPath);
string keyPem = File.ReadAllText(keyPath); string keyPem = File.ReadAllText(keyPath);
// BouncyCastle's PemReader accepts every flavour of unencrypted // BouncyCastle's PemReader accepts every flavour of unencrypted
@ -471,7 +471,9 @@ public static class HostingExtensions
{ {
case RsaPrivateCrtKeyParameters rsa: case RsaPrivateCrtKeyParameters rsa:
{ {
#pragma warning disable CA1416 // Valider la compatibilité de la plateforme
var rsaDotNet = DotNetUtilities.ToRSA(rsa); var rsaDotNet = DotNetUtilities.ToRSA(rsa);
#pragma warning restore CA1416 // Valider la compatibilité de la plateforme
var key = new RsaSecurityKey(rsaDotNet); var key = new RsaSecurityKey(rsaDotNet);
return new SigningCredentials(key, SecurityAlgorithms.RsaSha256); return new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
} }
@ -534,32 +536,36 @@ public static class HostingExtensions
context.SaveChanges(); context.SaveChanges();
} }
} }
var identityResources = context.Set<IdentityServer8.EntityFramework.Entities.IdentityResource>(); var identityResources = context.Set<IdentityServer8.EntityFramework.Entities.IdentityResource>();
var apiScopes = context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>(); var apiScopes = context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>();
// IdentityResources standards // IdentityResources standards
if (!identityResources.Any(r => r.Name == "openid")) if (!identityResources.Any(r => r.Name == "openid"))
{
var openid = new IdentityResources.OpenId().ToEntity();
identityResources.Add(openid);
}
if (!identityResources.Any(r => r.Name == "profile"))
{
var profile = new IdentityResources.Profile().ToEntity();
identityResources.Add(profile);
}
// ApiScope custom
if (!apiScopes.Any(s => s.Name == "blogs"))
{
apiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope
{ {
Name = "blogs", var openid = new IdentityResources.OpenId().ToEntity();
DisplayName = "Yavsc Blogs API", identityResources.Add(openid);
Enabled = true }
});
} if (!identityResources.Any(r => r.Name == "profile"))
{
var profile = new IdentityResources.Profile().ToEntity();
identityResources.Add(profile);
}
foreach (var scope in Constants.ApiResourcesScopes)
{
if (!identityResources.Any(s => s.Name == scope.ScopeName))
{
identityResources.Add(
new IdentityResources.Profile()
{
Name = scope.ScopeName,
DisplayName = scope.Description,
Enabled = true
}.ToEntity());
}
}
context.SaveChanges();
}; };
} }
@ -720,7 +726,7 @@ public static class HostingExtensions
} }
public async static Task<WebApplication> ConfigurePipeline(this WebApplication app, string staticAssetsManifestPath=null) public async static Task<WebApplication> ConfigurePipeline(this WebApplication app, string staticAssetsManifestPath = null)
{ {
ILoggerFactory loggerFactory = app.Services.GetRequiredService<ILoggerFactory>(); ILoggerFactory loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<Program>(); var logger = loggerFactory.CreateLogger<Program>();