From dcf2a93ad0aea696f2cb20e7c2df6958f4c772db Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 19 Jun 2026 13:15:21 +0100 Subject: [PATCH] Split Site:Audience into Site:ExternalUrl + Site:CorsAllowedOrigins The Site:Audience setting was conflating two distinct concepts: an OAuth JWT audience (a single resource identifier) and a CORS allow-list (an array of origins). Collapsing them caused several latent bugs: - OAuth/JWT validation expected a single string while CORS WithOrigins accepts an array. - Password-reset callback URLs and OAuth client RedirectUri/Origin were being built from what was meant to be an audience identifier, not a base URL. - Yavsc.Org's main CORS policy was hardcoded to '*', with no way to restrict it without code changes. Changes: - SiteSettings.Audience (string) replaced with CorsAllowedOrigins (IList). - OAuth JWT Authority still reads Site:Authority; Audience now reads Site:ExternalUrl (Org only; Api/Blogs use ValidateAudience=false). - MailSender and AccountController build reset-callback URLs from Site:ExternalUrl. - ClientController uses Site:ExternalUrl for OAuth RedirectUri/Origin defaults on newly created clients. - Yavsc.Api and Yavsc.Blogs now read CORS origins from Site:CorsAllowedOrigins instead of hardcoded URLs. Add shared AddYavscCors / AddYavscJwtBearer extension methods in Yavsc.Server/Helpers/ServiceExtensions.cs to enforce a single configuration contract across all runtime services (Api, Blogs, Org). Fails closed when CorsAllowedOrigins is empty; fails fast at startup when Site:Authority is missing. Remove obsolete ConfigurationHelpers.GetAudience (no remaining callers). Local appsettings-*.json files (which carry deployment-specific values and are gitignored) must be updated to add Site:CorsAllowedOrigins. --- src/Yavsc.Api/Program.cs | 22 +---- src/Yavsc.Blogs/Program.cs | 29 +----- .../Accounting/AccountController.cs | 6 +- .../Administration/ClientController.cs | 4 +- src/Yavsc.Org/Extensions/HostingExtensions.cs | 31 ++---- src/Yavsc.Server/Helpers/Configuration.cs | 6 -- src/Yavsc.Server/Helpers/ServiceExtensions.cs | 97 +++++++++++++++++++ src/Yavsc.Server/Services/MailSender.cs | 2 +- src/Yavsc.Server/Settings/SiteSettings.cs | 10 +- src/Yavsc.Server/Yavsc.Server.csproj | 1 + test/yavscTests/appsettings.json | 1 - 11 files changed, 125 insertions(+), 84 deletions(-) create mode 100644 src/Yavsc.Server/Helpers/ServiceExtensions.cs diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs index 1eda67bf..103d1773 100644 --- a/src/Yavsc.Api/Program.cs +++ b/src/Yavsc.Api/Program.cs @@ -64,28 +64,12 @@ internal class Program .RequireClaim(JwtClaimTypes.Scope, new string[] { "com" }); }); }) - .AddCors(options => - { - // this defines a CORS policy called "default" - options.AddPolicy("default", policy => - { - policy.WithOrigins("https://localhost:5003") - .AllowAnyHeader() - .AllowAnyMethod(); - }); - }) + .AddYavscCors(builder.Configuration) .AddControllers(); // accepts any access token issued by identity server - var authenticationBuilder = services.AddAuthentication("Bearer") - .AddJwtBearer("Bearer", options => - { - options.IncludeErrorDetails = true; - options.Authority = "https://localhost:5001"; - options.TokenValidationParameters = - new() { ValidateAudience = false, RoleClaimType = YavscConstants.RoleClaimType }; - options.MapInboundClaims = true; - }); + services.AddAuthentication("Bearer") + .AddYavscJwtBearer(builder.Configuration); services.AddDbContext(options => diff --git a/src/Yavsc.Blogs/Program.cs b/src/Yavsc.Blogs/Program.cs index ee35e4ed..673fff20 100644 --- a/src/Yavsc.Blogs/Program.cs +++ b/src/Yavsc.Blogs/Program.cs @@ -30,11 +30,8 @@ internal class Program var builder = WebApplication.CreateBuilder(args); builder.AddConfiguration("blogs"); - - var services = builder.Services; - var authority = builder.GetAuthority(); - var audience = builder.GetAudience(); + var services = builder.Services; // builder.Services.AddDistributedMemoryCache(); @@ -48,31 +45,15 @@ internal class Program { policy .RequireAuthenticatedUser() - .RequireClaim(JwtClaimTypes.Scope, new string[] { "blog" }); - }); - }) - .AddCors(options => - { - // this defines a CORS policy called "default" - options.AddPolicy("default", policy => - { - policy.WithOrigins(audience) - .AllowAnyHeader() - .AllowAnyMethod(); + .RequireClaim(JwtClaimTypes.Scope, new string[] { "blogs" }); }); }) + .AddYavscCors(builder.Configuration) .AddControllers(); // accepts any access token issued by identity server - var authenticationBuilder = services.AddAuthentication("Bearer") - .AddJwtBearer("Bearer", options => - { - options.IncludeErrorDetails = true; - options.Authority = authority; - options.TokenValidationParameters = - new() { ValidateAudience = false, RoleClaimType = YavscConstants.RoleClaimType }; - options.MapInboundClaims = true; - }); + services.AddAuthentication("Bearer") + .AddYavscJwtBearer(builder.Configuration); services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString(YavscConstants.YavscConnectionStringName))); diff --git a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs index 72f99b4f..e8a63163 100644 --- a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs +++ b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs @@ -590,7 +590,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory, _siteSettings.Title, callbackUrl, _siteSettings.Slogan, - _siteSettings.Audience)); + _siteSettings.ExternalUrl)); // No, wait for more than a login pass submission: // do not await _signInManager.SignInAsync(user, isPersistent: false); @@ -641,7 +641,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory, this._localizer["ConfirmYourAccountTitle"], string.Format(this._localizer["ConfirmYourAccountBody"], _siteSettings.Title, callbackUrl, _siteSettings.Slogan, - _siteSettings.Audience)); + _siteSettings.ExternalUrl)); return new EmailSentViewModel { EMail = user.Email, Sent = true, MessageId = res }; } @@ -654,7 +654,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory, this._localizer["AccountEmailFactorTitle"], string.Format(this._localizer["AccountEmailFactorBody"], _siteSettings.Title, callbackUrl, _siteSettings.Slogan, - _siteSettings.Audience, code)); + _siteSettings.ExternalUrl, code)); return new EmailSentViewModel { EMail = user.Email, Sent = true, MessageId = res }; ; } // diff --git a/src/Yavsc.Org/Controllers/Administration/ClientController.cs b/src/Yavsc.Org/Controllers/Administration/ClientController.cs index 459d1857..9ee20a87 100644 --- a/src/Yavsc.Org/Controllers/Administration/ClientController.cs +++ b/src/Yavsc.Org/Controllers/Administration/ClientController.cs @@ -87,13 +87,13 @@ namespace Yavsc.Controllers dbContext.ClientRedirectUris.Add(new ClientRedirectUri { ClientId = client.Id, - RedirectUri = siteSettings.Audience + RedirectUri = siteSettings.ExternalUrl }); dbContext.ClientCorsOrigins.Add(new ClientCorsOrigin { ClientId = client.Id, - Origin = siteSettings.Audience + Origin = siteSettings.ExternalUrl }); foreach (String credType in new String[] { "code", "client_credentials", "password" }) diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index de86b012..036d4d5b 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -111,26 +111,15 @@ public static class HostingExtensions // Add session related services. services.AddDataProtection().PersistKeysToFileSystem(dataDir); - AddYavscPolicies(services); + AddYavscPolicies(services, builder.Configuration); services.AddScoped(); services.AddTransient(); services.AddAuthentication("Bearer") - .AddJwtBearer("Bearer", options => - { - options.IncludeErrorDetails = true; - options.Authority = builder.Configuration.GetSection("Site")["Authority"]; - options.Audience = builder.Configuration.GetSection("Site")["Audience"]; - options.TokenValidationParameters = - new() - { - ValidateAudience = false, - RoleClaimType = YavscConstants.RoleClaimType - }; - options.MapInboundClaims = true; - }); + .AddYavscJwtBearer(builder.Configuration, + configure: o => o.Audience = builder.Configuration.GetSection("Site")["ExternalUrl"]); services.AddTransient>(); services.AddTransient, RoleStore>(); @@ -191,7 +180,7 @@ public static class HostingExtensions return identityBuilder; } - private static void AddYavscPolicies(IServiceCollection services) + private static void AddYavscPolicies(IServiceCollection services, IConfiguration configuration) { services.AddAuthorization(options => { @@ -222,17 +211,9 @@ public static class HostingExtensions // options.AddPolicy("BuildingEntry", policy => policy.Requirements.Add(new OfficeEntryRequirement())); options.AddPolicy("Authenticated", policy => policy.RequireAuthenticatedUser()); options.AddPolicy("TheAuthor", policy => policy.Requirements.Add(new EditPermission())); - }) - .AddCors(options => - { - options.AddPolicy("default", builder => - { - _ = builder.WithOrigins("*") - .AllowAnyHeader() - .AllowAnyMethod(); - }); - }); + + services.AddYavscCors(configuration); } public static IServiceCollection LoadConfiguration(this WebApplicationBuilder builder) diff --git a/src/Yavsc.Server/Helpers/Configuration.cs b/src/Yavsc.Server/Helpers/Configuration.cs index 1ab79caf..ecf11869 100644 --- a/src/Yavsc.Server/Helpers/Configuration.cs +++ b/src/Yavsc.Server/Helpers/Configuration.cs @@ -12,10 +12,4 @@ public static class ConfigurationHelpers return builder.Configuration.GetSection("Site") .GetValue("Authority"); } - public static string GetAudience(this WebApplicationBuilder builder) - { - return builder.Configuration.GetSection("Site") - .GetValue("Audience"); - } - } diff --git a/src/Yavsc.Server/Helpers/ServiceExtensions.cs b/src/Yavsc.Server/Helpers/ServiceExtensions.cs new file mode 100644 index 00000000..57e9f770 --- /dev/null +++ b/src/Yavsc.Server/Helpers/ServiceExtensions.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace Yavsc.Server.Helpers; + +/// +/// Shared service registration helpers for Yavsc runtime services (Api, Blogs, Org, ...). +/// +/// Conventions: every service reads its CORS origin allow-list from +/// Site:CorsAllowedOrigins as a JSON array, and its JWT Bearer authority +/// from Site:Authority. These helpers enforce that contract so a service +/// only needs to opt-in via a single line. +/// +public static class ServiceExtensions +{ + /// + /// Default policy name used across all Yavsc runtime services. + /// + public const string DefaultCorsPolicyName = "default"; + + /// + /// Register the shared "default" CORS policy, sourcing the allow-list + /// from Site:CorsAllowedOrigins. Fails closed (no origins registered) + /// when the array is missing or empty. + /// + /// The service collection to add CORS to. + /// Configuration root, used to read Site:CorsAllowedOrigins. + /// Optional policy name override (defaults to ). + /// The same instance for chaining. + public static IServiceCollection AddYavscCors( + this IServiceCollection services, + IConfiguration configuration, + string policyName = DefaultCorsPolicyName) + { + var allowedOrigins = configuration + .GetSection("Site:CorsAllowedOrigins") + .Get() ?? Array.Empty(); + + services.AddCors(options => + { + options.AddPolicy(policyName, policy => + { + if (allowedOrigins.Length == 0) + { + // Fail closed: with no origins configured, don't fall back to "*". + // The policy ends up effectively denying cross-origin requests, + // which is the safe default. + return; + } + policy.WithOrigins(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod(); + }); + }); + + return services; + } + + /// + /// Register the standard Yavsc JWT Bearer authentication scheme, sourcing + /// the authority from Site:Authority. Throws at startup if the + /// configuration is missing — this is intentional, we'd rather fail to + /// boot than accept tokens from an unconfigured issuer. + /// + /// The authentication builder to extend. + /// Configuration root, used to read Site:Authority. + /// Optional callback for service-specific options + /// (e.g. setting options.Audience in Yavsc.Org). + /// Optional scheme name override (defaults to "Bearer"). + /// The same authentication builder, for chaining. + public static AuthenticationBuilder AddYavscJwtBearer( + this AuthenticationBuilder builder, + IConfiguration configuration, + Action? configure = null, + string schemeName = "Bearer") + { + var authority = configuration.GetSection("Site")["Authority"] + ?? throw new InvalidOperationException( + "Site:Authority is required to configure Yavsc JWT Bearer authentication."); + + return builder.AddJwtBearer(schemeName, options => + { + options.IncludeErrorDetails = true; + options.Authority = authority; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateAudience = false, + RoleClaimType = YavscConstants.RoleClaimType + }; + options.MapInboundClaims = true; + configure?.Invoke(options); + }); + } +} \ No newline at end of file diff --git a/src/Yavsc.Server/Services/MailSender.cs b/src/Yavsc.Server/Services/MailSender.cs index 14cb17ba..98f7e35d 100644 --- a/src/Yavsc.Server/Services/MailSender.cs +++ b/src/Yavsc.Server/Services/MailSender.cs @@ -97,7 +97,7 @@ namespace Yavsc.Services public async Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) { - var callbackUrl = siteSettings.Audience + "/Account/ResetPassword/" + + var callbackUrl = siteSettings.ExternalUrl + "/Account/ResetPassword/" + HttpUtility.UrlEncode(user.Id) + "/" + HttpUtility.UrlEncode(resetCode); await SendEmailAsync(user.UserName, user.Email, diff --git a/src/Yavsc.Server/Settings/SiteSettings.cs b/src/Yavsc.Server/Settings/SiteSettings.cs index 2a73b737..1f63ea2c 100644 --- a/src/Yavsc.Server/Settings/SiteSettings.cs +++ b/src/Yavsc.Server/Settings/SiteSettings.cs @@ -14,10 +14,14 @@ namespace Yavsc public string FavIcon { get; set; } = "favicon.ico"; public string Logo { get; set; } = "logo.png"; /// + /// Origins to allow via the CORS "default" policy. + /// Each entry must be a full origin (scheme + host [+ port]), e.g. "https://app.example.com". /// - /// - public string Audience { get; set; } = "lua.pschneider.fr"; - + public IList CorsAllowedOrigins { get; set; } = new List + { + "https://localhost:5001" + }; + /// /// External Url /// diff --git a/src/Yavsc.Server/Yavsc.Server.csproj b/src/Yavsc.Server/Yavsc.Server.csproj index 6308d93c..ceaf4322 100644 --- a/src/Yavsc.Server/Yavsc.Server.csproj +++ b/src/Yavsc.Server/Yavsc.Server.csproj @@ -15,6 +15,7 @@ + diff --git a/test/yavscTests/appsettings.json b/test/yavscTests/appsettings.json index d49b688b..bf8599e0 100644 --- a/test/yavscTests/appsettings.json +++ b/test/yavscTests/appsettings.json @@ -1,6 +1,5 @@ { "Site": { - "Audience": "https://localhost", "Authority": "https://mercure.pschneider.fr", "Title": "Yavsc dev", "Slogan": "Yavsc : WIP.",