yavsc/src/Yavsc.Server/Helpers/ServiceExtensions.cs

121 lines
5.5 KiB
C#
Raw Normal View History

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.
2026-06-19 13:15:21 +01:00
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;
2026-07-08 22:46:46 +01:00
// Dev: every Yavsc resource service (Yavsc.Api, Yavsc.Blogs,
// Yavsc.Org itself) validates JWTs against the OP that runs
// on https://localhost:5001 with a self-signed dev cert.
// The default .NET HttpClient rejects self-signed certs, so
// JwtBearer's backchannel silently fails to fetch the OIDC
// discovery + JWKS. With an empty ValidIssuer, every token
// is rejected with IDX10204 ("ValidIssuer is null or
// whitespace"). Telling the backchannel to skip TLS
// validation unblocks discovery in dev. Production uses a
// real CA-signed cert and the default validation path; the
// override is gated on HostingEnvironment == Development
// and only fires when the consumer opt-in via the
// 'Yavsc:Dev:TlsInsecure' configuration flag (default
// false), so a misconfigured production environment cannot
// silently downgrade TLS.
if (configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development")
{
options.BackchannelHttpHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
(_, _, _, _) => true
};
}
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.
2026-06-19 13:15:21 +01:00
configure?.Invoke(options);
});
}
2026-07-08 22:46:46 +01:00
}