using System.Globalization; using System.IdentityModel.Tokens.Jwt; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Tiku.Api.Options; using Tiku.Api.Security; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Domain.Tenancy; namespace Tiku.Api.Configuration; internal static class AuthenticationExtensions { internal static IServiceCollection AddApiAuthenticationAndAuthorization( this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) { services.AddOptions() .Bind(configuration.GetSection("Security:Jwt")) .ValidateDataAnnotations() .Validate( options => OptionsValidation.BeValidJwtOptions(options, environment.IsProduction()), "Production JWT signing key must be explicitly configured and cannot use the development key.") .ValidateOnStart(); var jwtOptions = configuration .GetSection("Security:Jwt") .Get() ?? new JwtOptions(); services.AddOptions() .Bind(configuration.GetSection(BrowserAuthOptions.SectionName)) .Validate( options => options.AllowedOrigins.All(origin => Uri.TryCreate(origin, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) && string.IsNullOrEmpty(uri.PathAndQuery.Trim('/'))), "BrowserAuth AllowedOrigins must contain only HTTP(S) origins without paths.") .ValidateOnStart(); services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => ConfigureJwtBearer(options, jwtOptions)); services.AddOptions(JwtBearerDefaults.AuthenticationScheme) .Configure((options, keyRing) => { options.TokenValidationParameters.IssuerSigningKeys = keyRing.ValidationKeys; options.TokenValidationParameters.TryAllIssuerSigningKeys = false; options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, kid, _) => string.IsNullOrWhiteSpace(kid) ? [] : keyRing.ValidationKeys.Where(key => string.Equals(key.KeyId, kid, StringComparison.Ordinal)); }); services.AddAuthorization(options => { options.FallbackPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build(); options.AddPolicy( TikuPolicies.AuthenticatedUser, policy => policy.RequireAuthenticatedUser()); options.AddPolicy( TikuPolicies.TenantAdmin, policy => policy.RequireAuthenticatedUser()); }); services.AddTikuRbacAuthorization(); services.AddSingleton(); return services; } private static void ConfigureJwtBearer(JwtBearerOptions options, JwtOptions jwtOptions) { options.MapInboundClaims = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = jwtOptions.Issuer, ValidateAudience = true, ValidAudience = jwtOptions.Audience, ValidateIssuerSigningKey = true, RequireSignedTokens = true, ValidAlgorithms = [SecurityAlgorithms.RsaSha256], ValidateLifetime = true, RequireExpirationTime = true, ClockSkew = TimeSpan.FromMinutes(1), NameClaimType = TikuClaimTypes.UserId }; options.Events = new JwtBearerEvents { OnMessageReceived = context => { if (string.IsNullOrWhiteSpace(context.Request.Headers.Authorization) && IsSameOriginBrowserRequest(context.Request) && context.Request.Cookies.TryGetValue(BrowserAuthOptions.AccessCookie, out var accessToken)) context.Token = accessToken; return Task.CompletedTask; }, OnTokenValidated = ValidateTokenAsync, OnChallenge = WriteTenantConflictChallengeAsync }; } private static bool IsSameOriginBrowserRequest(HttpRequest request) { var source = request.Headers.Origin.ToString(); if (string.IsNullOrWhiteSpace(source)) source = request.Headers.Referer.ToString(); if (Uri.TryCreate(source, UriKind.Absolute, out var uri)) return string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) && string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase); return string.Equals( request.Headers["Sec-Fetch-Site"].ToString(), "same-origin", StringComparison.OrdinalIgnoreCase); } private static async Task ValidateTokenAsync(TokenValidatedContext context) { var principal = context.Principal; if (!Guid.TryParse(principal?.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId) || !Guid.TryParse(principal?.FindFirst(TikuClaimTypes.SessionId)?.Value, out var sessionId) || string.IsNullOrWhiteSpace(principal?.FindFirst(JwtRegisteredClaimNames.Jti)?.Value) || !long.TryParse( principal?.FindFirst(JwtRegisteredClaimNames.Iat)?.Value, NumberStyles.None, CultureInfo.InvariantCulture, out _)) { context.Fail("Missing or invalid subject/session/jti/iat claim."); return; } var realmValue = principal.FindFirst(TikuClaimTypes.Realm)?.Value; var realm = string.Equals(realmValue, "tenant", StringComparison.Ordinal) ? AuthRealm.Tenant : string.Equals(realmValue, "platform", StringComparison.Ordinal) ? AuthRealm.Platform : (AuthRealm?)null; var tenantIdValue = principal.FindFirst(TikuClaimTypes.TenantId)?.Value; var tenantId = Guid.TryParse(tenantIdValue, out var parsedTenantId) ? parsedTenantId : (Guid?)null; if (realm is null || realm == AuthRealm.Tenant != tenantId.HasValue) { context.Fail("Token scope and tenant claims are inconsistent."); return; } var resolutionOptions = context.HttpContext.RequestServices .GetRequiredService>().Value; var requestHost = context.HttpContext.Request.Host.Host.Trim().TrimEnd('.'); var isPlatformHost = resolutionOptions.PlatformHosts.Any(host => string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase)); var resolvedTenantContext = context.HttpContext.RequestServices.GetRequiredService(); var tenantInitializer = context.HttpContext.RequestServices .GetRequiredService(); if (realm == AuthRealm.Platform) { if (!isPlatformHost || resolvedTenantContext.IsResolved) { context.HttpContext.Items["tenant_context_conflict"] = true; context.Fail("Platform tokens are only valid on a platform host."); return; } } else { if (isPlatformHost && !resolvedTenantContext.IsResolved) { context.HttpContext.Items["tenant_context_conflict"] = true; context.Fail("Tenant tokens on a platform host require a matching tenant code."); return; } try { tenantInitializer.Initialize(tenantId!.Value, null, TenantResolutionSource.Jwt); } catch (TenantContextConflictException) { context.HttpContext.Items["tenant_context_conflict"] = true; context.Fail("Authenticated tenant does not match the request host."); return; } } var accessValidator = context.HttpContext.RequestServices.GetRequiredService(); var session = await accessValidator.ValidateAsync( sessionId, userId, realm.Value, tenantId, context.HttpContext.RequestAborted); if (session is null) { context.Fail("Session, identity, membership, tenant or role state is no longer valid."); return; } context.HttpContext.RequestServices.GetRequiredService() .SetValidatedSession(session); } private static async Task WriteTenantConflictChallengeAsync(JwtBearerChallengeContext context) { if (!context.HttpContext.Items.ContainsKey("tenant_context_conflict")) return; context.HandleResponse(); context.Response.StatusCode = StatusCodes.Status403Forbidden; await context.Response.WriteAsJsonAsync(new ProblemDetails { Title = "Authenticated tenant does not match the request host.", Status = StatusCodes.Status403Forbidden, Extensions = { ["code"] = "tenant_context_conflict" } }); } }