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<string>). - 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.
This commit is contained in:
parent
b72fff9034
commit
dcf2a93ad0
11 changed files with 125 additions and 84 deletions
|
|
@ -64,28 +64,12 @@ internal class Program
|
||||||
.RequireClaim(JwtClaimTypes.Scope, new string[] { "com" });
|
.RequireClaim(JwtClaimTypes.Scope, new string[] { "com" });
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.AddCors(options =>
|
.AddYavscCors(builder.Configuration)
|
||||||
{
|
|
||||||
// this defines a CORS policy called "default"
|
|
||||||
options.AddPolicy("default", policy =>
|
|
||||||
{
|
|
||||||
policy.WithOrigins("https://localhost:5003")
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowAnyMethod();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.AddControllers();
|
.AddControllers();
|
||||||
|
|
||||||
// accepts any access token issued by identity server
|
// accepts any access token issued by identity server
|
||||||
var authenticationBuilder = services.AddAuthentication("Bearer")
|
services.AddAuthentication("Bearer")
|
||||||
.AddJwtBearer("Bearer", options =>
|
.AddYavscJwtBearer(builder.Configuration);
|
||||||
{
|
|
||||||
options.IncludeErrorDetails = true;
|
|
||||||
options.Authority = "https://localhost:5001";
|
|
||||||
options.TokenValidationParameters =
|
|
||||||
new() { ValidateAudience = false, RoleClaimType = YavscConstants.RoleClaimType };
|
|
||||||
options.MapInboundClaims = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddDbContext<ApplicationDbContext>(options =>
|
services.AddDbContext<ApplicationDbContext>(options =>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,6 @@ internal class Program
|
||||||
|
|
||||||
var services = builder.Services;
|
var services = builder.Services;
|
||||||
|
|
||||||
var authority = builder.GetAuthority();
|
|
||||||
var audience = builder.GetAudience();
|
|
||||||
|
|
||||||
// builder.Services.AddDistributedMemoryCache();
|
// builder.Services.AddDistributedMemoryCache();
|
||||||
|
|
||||||
// accepts any access token issued by identity server
|
// accepts any access token issued by identity server
|
||||||
|
|
@ -48,31 +45,15 @@ internal class Program
|
||||||
{
|
{
|
||||||
policy
|
policy
|
||||||
.RequireAuthenticatedUser()
|
.RequireAuthenticatedUser()
|
||||||
.RequireClaim(JwtClaimTypes.Scope, new string[] { "blog" });
|
.RequireClaim(JwtClaimTypes.Scope, new string[] { "blogs" });
|
||||||
});
|
|
||||||
})
|
|
||||||
.AddCors(options =>
|
|
||||||
{
|
|
||||||
// this defines a CORS policy called "default"
|
|
||||||
options.AddPolicy("default", policy =>
|
|
||||||
{
|
|
||||||
policy.WithOrigins(audience)
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowAnyMethod();
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
.AddYavscCors(builder.Configuration)
|
||||||
.AddControllers();
|
.AddControllers();
|
||||||
|
|
||||||
// accepts any access token issued by identity server
|
// accepts any access token issued by identity server
|
||||||
var authenticationBuilder = services.AddAuthentication("Bearer")
|
services.AddAuthentication("Bearer")
|
||||||
.AddJwtBearer("Bearer", options =>
|
.AddYavscJwtBearer(builder.Configuration);
|
||||||
{
|
|
||||||
options.IncludeErrorDetails = true;
|
|
||||||
options.Authority = authority;
|
|
||||||
options.TokenValidationParameters =
|
|
||||||
new() { ValidateAudience = false, RoleClaimType = YavscConstants.RoleClaimType };
|
|
||||||
options.MapInboundClaims = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddDbContext<ApplicationDbContext>(options =>
|
services.AddDbContext<ApplicationDbContext>(options =>
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString(YavscConstants.YavscConnectionStringName)));
|
options.UseNpgsql(builder.Configuration.GetConnectionString(YavscConstants.YavscConnectionStringName)));
|
||||||
|
|
|
||||||
|
|
@ -590,7 +590,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
|
||||||
_siteSettings.Title,
|
_siteSettings.Title,
|
||||||
callbackUrl,
|
callbackUrl,
|
||||||
_siteSettings.Slogan,
|
_siteSettings.Slogan,
|
||||||
_siteSettings.Audience));
|
_siteSettings.ExternalUrl));
|
||||||
// No, wait for more than a login pass submission:
|
// No, wait for more than a login pass submission:
|
||||||
// do not await _signInManager.SignInAsync(user, isPersistent: false);
|
// do not await _signInManager.SignInAsync(user, isPersistent: false);
|
||||||
|
|
||||||
|
|
@ -641,7 +641,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
|
||||||
this._localizer["ConfirmYourAccountTitle"],
|
this._localizer["ConfirmYourAccountTitle"],
|
||||||
string.Format(this._localizer["ConfirmYourAccountBody"],
|
string.Format(this._localizer["ConfirmYourAccountBody"],
|
||||||
_siteSettings.Title, callbackUrl, _siteSettings.Slogan,
|
_siteSettings.Title, callbackUrl, _siteSettings.Slogan,
|
||||||
_siteSettings.Audience));
|
_siteSettings.ExternalUrl));
|
||||||
return new EmailSentViewModel { EMail = user.Email, Sent = true, MessageId = res };
|
return new EmailSentViewModel { EMail = user.Email, Sent = true, MessageId = res };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -654,7 +654,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
|
||||||
this._localizer["AccountEmailFactorTitle"],
|
this._localizer["AccountEmailFactorTitle"],
|
||||||
string.Format(this._localizer["AccountEmailFactorBody"],
|
string.Format(this._localizer["AccountEmailFactorBody"],
|
||||||
_siteSettings.Title, callbackUrl, _siteSettings.Slogan,
|
_siteSettings.Title, callbackUrl, _siteSettings.Slogan,
|
||||||
_siteSettings.Audience, code));
|
_siteSettings.ExternalUrl, code));
|
||||||
return new EmailSentViewModel { EMail = user.Email, Sent = true, MessageId = res }; ;
|
return new EmailSentViewModel { EMail = user.Email, Sent = true, MessageId = res }; ;
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -87,13 +87,13 @@ namespace Yavsc.Controllers
|
||||||
dbContext.ClientRedirectUris.Add(new ClientRedirectUri
|
dbContext.ClientRedirectUris.Add(new ClientRedirectUri
|
||||||
{
|
{
|
||||||
ClientId = client.Id,
|
ClientId = client.Id,
|
||||||
RedirectUri = siteSettings.Audience
|
RedirectUri = siteSettings.ExternalUrl
|
||||||
|
|
||||||
});
|
});
|
||||||
dbContext.ClientCorsOrigins.Add(new ClientCorsOrigin
|
dbContext.ClientCorsOrigins.Add(new ClientCorsOrigin
|
||||||
{
|
{
|
||||||
ClientId = client.Id,
|
ClientId = client.Id,
|
||||||
Origin = siteSettings.Audience
|
Origin = siteSettings.ExternalUrl
|
||||||
|
|
||||||
});
|
});
|
||||||
foreach (String credType in new String[] { "code", "client_credentials", "password" })
|
foreach (String credType in new String[] { "code", "client_credentials", "password" })
|
||||||
|
|
|
||||||
|
|
@ -111,26 +111,15 @@ public static class HostingExtensions
|
||||||
// Add session related services.
|
// Add session related services.
|
||||||
|
|
||||||
services.AddDataProtection().PersistKeysToFileSystem(dataDir);
|
services.AddDataProtection().PersistKeysToFileSystem(dataDir);
|
||||||
AddYavscPolicies(services);
|
AddYavscPolicies(services, builder.Configuration);
|
||||||
|
|
||||||
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||||
services.AddTransient<IExternalIdentityManager, ExternalIdentityManager>();
|
services.AddTransient<IExternalIdentityManager, ExternalIdentityManager>();
|
||||||
|
|
||||||
|
|
||||||
services.AddAuthentication("Bearer")
|
services.AddAuthentication("Bearer")
|
||||||
.AddJwtBearer("Bearer", options =>
|
.AddYavscJwtBearer(builder.Configuration,
|
||||||
{
|
configure: o => o.Audience = builder.Configuration.GetSection("Site")["ExternalUrl"]);
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddTransient<RoleManager<IdentityRole>>();
|
services.AddTransient<RoleManager<IdentityRole>>();
|
||||||
services.AddTransient<IRoleStore<IdentityRole>, RoleStore<IdentityRole, ApplicationDbContext>>();
|
services.AddTransient<IRoleStore<IdentityRole>, RoleStore<IdentityRole, ApplicationDbContext>>();
|
||||||
|
|
@ -191,7 +180,7 @@ public static class HostingExtensions
|
||||||
return identityBuilder;
|
return identityBuilder;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddYavscPolicies(IServiceCollection services)
|
private static void AddYavscPolicies(IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddAuthorization(options =>
|
services.AddAuthorization(options =>
|
||||||
{
|
{
|
||||||
|
|
@ -222,17 +211,9 @@ public static class HostingExtensions
|
||||||
// options.AddPolicy("BuildingEntry", policy => policy.Requirements.Add(new OfficeEntryRequirement()));
|
// options.AddPolicy("BuildingEntry", policy => policy.Requirements.Add(new OfficeEntryRequirement()));
|
||||||
options.AddPolicy("Authenticated", policy => policy.RequireAuthenticatedUser());
|
options.AddPolicy("Authenticated", policy => policy.RequireAuthenticatedUser());
|
||||||
options.AddPolicy("TheAuthor", policy => policy.Requirements.Add(new EditPermission()));
|
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)
|
public static IServiceCollection LoadConfiguration(this WebApplicationBuilder builder)
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,4 @@ public static class ConfigurationHelpers
|
||||||
return builder.Configuration.GetSection("Site")
|
return builder.Configuration.GetSection("Site")
|
||||||
.GetValue<string>("Authority");
|
.GetValue<string>("Authority");
|
||||||
}
|
}
|
||||||
public static string GetAudience(this WebApplicationBuilder builder)
|
|
||||||
{
|
|
||||||
return builder.Configuration.GetSection("Site")
|
|
||||||
.GetValue<string>("Audience");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
src/Yavsc.Server/Helpers/ServiceExtensions.cs
Normal file
97
src/Yavsc.Server/Helpers/ServiceExtensions.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared service registration helpers for Yavsc runtime services (Api, Blogs, Org, ...).
|
||||||
|
///
|
||||||
|
/// Conventions: every service reads its CORS origin allow-list from
|
||||||
|
/// <c>Site:CorsAllowedOrigins</c> as a JSON array, and its JWT Bearer authority
|
||||||
|
/// from <c>Site:Authority</c>. These helpers enforce that contract so a service
|
||||||
|
/// only needs to opt-in via a single line.
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Default policy name used across all Yavsc runtime services.
|
||||||
|
/// </summary>
|
||||||
|
public const string DefaultCorsPolicyName = "default";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register the shared <c>"default"</c> CORS policy, sourcing the allow-list
|
||||||
|
/// from <c>Site:CorsAllowedOrigins</c>. Fails closed (no origins registered)
|
||||||
|
/// when the array is missing or empty.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection to add CORS to.</param>
|
||||||
|
/// <param name="configuration">Configuration root, used to read <c>Site:CorsAllowedOrigins</c>.</param>
|
||||||
|
/// <param name="policyName">Optional policy name override (defaults to <see cref="DefaultCorsPolicyName"/>).</param>
|
||||||
|
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
|
||||||
|
public static IServiceCollection AddYavscCors(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration,
|
||||||
|
string policyName = DefaultCorsPolicyName)
|
||||||
|
{
|
||||||
|
var allowedOrigins = configuration
|
||||||
|
.GetSection("Site:CorsAllowedOrigins")
|
||||||
|
.Get<string[]>() ?? Array.Empty<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register the standard Yavsc JWT Bearer authentication scheme, sourcing
|
||||||
|
/// the authority from <c>Site:Authority</c>. Throws at startup if the
|
||||||
|
/// configuration is missing — this is intentional, we'd rather fail to
|
||||||
|
/// boot than accept tokens from an unconfigured issuer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">The authentication builder to extend.</param>
|
||||||
|
/// <param name="configuration">Configuration root, used to read <c>Site:Authority</c>.</param>
|
||||||
|
/// <param name="configure">Optional callback for service-specific options
|
||||||
|
/// (e.g. setting <c>options.Audience</c> in <c>Yavsc.Org</c>).</param>
|
||||||
|
/// <param name="schemeName">Optional scheme name override (defaults to <c>"Bearer"</c>).</param>
|
||||||
|
/// <returns>The same authentication builder, for chaining.</returns>
|
||||||
|
public static AuthenticationBuilder AddYavscJwtBearer(
|
||||||
|
this AuthenticationBuilder builder,
|
||||||
|
IConfiguration configuration,
|
||||||
|
Action<JwtBearerOptions>? 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -97,7 +97,7 @@ namespace Yavsc.Services
|
||||||
|
|
||||||
public async Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode)
|
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);
|
HttpUtility.UrlEncode(user.Id) + "/" + HttpUtility.UrlEncode(resetCode);
|
||||||
|
|
||||||
await SendEmailAsync(user.UserName, user.Email,
|
await SendEmailAsync(user.UserName, user.Email,
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,13 @@ namespace Yavsc
|
||||||
public string FavIcon { get; set; } = "favicon.ico";
|
public string FavIcon { get; set; } = "favicon.ico";
|
||||||
public string Logo { get; set; } = "logo.png";
|
public string Logo { get; set; } = "logo.png";
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Origins to allow via the CORS "default" policy.
|
||||||
|
/// Each entry must be a full origin (scheme + host [+ port]), e.g. "https://app.example.com".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
public IList<string> CorsAllowedOrigins { get; set; } = new List<string>
|
||||||
public string Audience { get; set; } = "lua.pschneider.fr";
|
{
|
||||||
|
"https://localhost:5001"
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// External Url
|
/// External Url
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
<PackageReference Include="HigginsSoft.IdentityServer8" />
|
<PackageReference Include="HigginsSoft.IdentityServer8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Http.Features" />
|
<PackageReference Include="Microsoft.AspNetCore.Http.Features" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"Site": {
|
"Site": {
|
||||||
"Audience": "https://localhost",
|
|
||||||
"Authority": "https://mercure.pschneider.fr",
|
"Authority": "https://mercure.pschneider.fr",
|
||||||
"Title": "Yavsc dev",
|
"Title": "Yavsc dev",
|
||||||
"Slogan": "Yavsc : WIP.",
|
"Slogan": "Yavsc : WIP.",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue