using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; using FictionArchive.Service.Shared.Constants; using FictionArchive.Service.Shared.Models.Authentication; using System.Linq; namespace FictionArchive.Service.Shared.Extensions; public static class AuthenticationExtensions { public static IServiceCollection AddOidcAuthentication(this IServiceCollection services, IConfiguration configuration) { var oidcConfig = configuration.GetSection("OIDC").Get(); if (oidcConfig == null) { throw new InvalidOperationException("OIDC configuration is required but not found in app settings"); } ValidateOidcConfiguration(oidcConfig); services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = oidcConfig.Authority; options.Audience = oidcConfig.Audience; options.RequireHttpsMetadata = !string.IsNullOrEmpty(oidcConfig.Authority) && oidcConfig.Authority.StartsWith("https://"); options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = oidcConfig.ValidateIssuer, ValidIssuer = oidcConfig.ValidIssuer, ValidateAudience = oidcConfig.ValidateAudience, ValidateLifetime = oidcConfig.ValidateLifetime, ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey, ClockSkew = TimeSpan.FromMinutes(5) }; options.Events = CreateLoggingJwtBearerEvents(); }); return services; } private static JwtBearerEvents CreateLoggingJwtBearerEvents(JwtBearerEvents? existingEvents = null) { return new JwtBearerEvents { OnMessageReceived = existingEvents?.OnMessageReceived ?? (_ => Task.CompletedTask), OnAuthenticationFailed = context => { var logger = context.HttpContext.RequestServices.GetRequiredService() .CreateLogger("JwtBearerAuthentication"); logger.LogWarning(context.Exception, "JWT authentication failed: {Message}", context.Exception.Message); return existingEvents?.OnAuthenticationFailed?.Invoke(context) ?? Task.CompletedTask; }, OnChallenge = context => { var logger = context.HttpContext.RequestServices.GetRequiredService() .CreateLogger("JwtBearerAuthentication"); logger.LogDebug( "JWT challenge issued. Error: {Error}, ErrorDescription: {ErrorDescription}", context.Error, context.ErrorDescription); return existingEvents?.OnChallenge?.Invoke(context) ?? Task.CompletedTask; }, OnTokenValidated = context => { var logger = context.HttpContext.RequestServices.GetRequiredService() .CreateLogger("JwtBearerAuthentication"); logger.LogDebug( "JWT token validated for subject: {Subject}", context.Principal?.FindFirst("sub")?.Value ?? "unknown"); return existingEvents?.OnTokenValidated?.Invoke(context) ?? Task.CompletedTask; } }; } public static IServiceCollection AddOidcCookieAuthentication(this IServiceCollection services, IConfiguration configuration, string cookieName = "fa_session") { var oidcConfig = configuration.GetSection("OIDC").Get(); if (oidcConfig == null) { throw new InvalidOperationException("OIDC configuration is required but not found in app settings"); } ValidateOidcConfiguration(oidcConfig); services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = oidcConfig.Authority; options.Audience = oidcConfig.Audience; options.RequireHttpsMetadata = !string.IsNullOrEmpty(oidcConfig.Authority) && oidcConfig.Authority.StartsWith("https://"); var cookieEvents = new JwtBearerEvents { OnMessageReceived = context => { // Try to get token from cookie first, then from Authorization header if (context.Request.Cookies.ContainsKey(cookieName)) { context.Token = context.Request.Cookies[cookieName]; } return Task.CompletedTask; } }; options.Events = CreateLoggingJwtBearerEvents(cookieEvents); options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = oidcConfig.ValidateIssuer, ValidIssuer = oidcConfig.ValidIssuer, ValidateAudience = oidcConfig.ValidateAudience, ValidateLifetime = oidcConfig.ValidateLifetime, ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey, ClockSkew = TimeSpan.FromMinutes(5) }; }); return services; } public static IServiceCollection AddFictionArchiveAuthorization(this IServiceCollection services) { services.AddAuthorizationBuilder() .AddPolicy(AuthorizationConstants.Policies.Admin, policy => policy.RequireRole(AuthorizationConstants.Roles.Admin)) .AddPolicy(AuthorizationConstants.Policies.User, policy => policy.RequireAuthenticatedUser()); return services; } private static void ValidateOidcConfiguration(OidcConfiguration config) { var errors = new List(); if (string.IsNullOrWhiteSpace(config.Authority)) errors.Add("OIDC Authority is required"); if (string.IsNullOrWhiteSpace(config.ClientId)) errors.Add("OIDC ClientId is required"); if (string.IsNullOrWhiteSpace(config.Audience)) errors.Add("OIDC Audience is required"); if (!Uri.TryCreate(config.Authority, UriKind.Absolute, out var authorityUri)) errors.Add($"OIDC Authority '{config.Authority}' is not a valid URI"); else if (!authorityUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && !authorityUri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) errors.Add("OIDC Authority must use HTTPS unless running on localhost"); if (errors.Any()) { throw new InvalidOperationException($"OIDC configuration validation failed:{Environment.NewLine}{string.Join(Environment.NewLine, errors)}"); } } }