using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OpenIddict.Abstractions; using Ablera.Serdica.Authentication.Constants; using Ablera.Serdica.Authentication.Models; using Ablera.Serdica.Authentication.Models.Oidc; using Ablera.Serdica.Authority.Extensions; using System.Net; using OpenIddict.Validation.AspNetCore; using Ablera.Serdica.Authentication.Extensions; using static Ablera.Serdica.Authentication.Constants.ConstantsClass; namespace Ablera.Serdica.Authentication.Services; public sealed class SerdicaJwtBearerAuthenticationHandler : AuthenticationHandler { private readonly OidcValidation oidcValidationSettings; private readonly ILogger logger; public SerdicaJwtBearerAuthenticationHandler( IOptionsMonitor jwtOptions, ILoggerFactory loggerFactory, ILogger logger, UrlEncoder encoder, IOptions oidcServerConnection) : base(jwtOptions, loggerFactory, encoder) { this.oidcValidationSettings = oidcServerConnection.Value; this.logger = logger; } protected override async Task HandleAuthenticateAsync() { // 1. Internal callers detected by bypass mask → Super user if (oidcValidationSettings.BypassValidationsMasks? .Any(m => m.MatchesRemote(Context)) == true) { return SuccessTicket(BuildDefaultRolePrincipal(SerdicaClaims.RoleSuperUser)); } // 2. What roles does the endpoint require? Context.Items.TryGetValue(ConstantsClass.HttpContextEndpointRequiredRoles, out var rolesObj); var requiredRoles = rolesObj as string[]; if (requiredRoles is { Length: 0 }) // empty means requirement for authentication claim { requiredRoles = [ SerdicaClaims.IsAuthenticated ]; } bool anonymousAllowed = requiredRoles == null || requiredRoles.Contains(SerdicaClaims.Anonymous, StringComparer.Ordinal); // 3. Decide whether we *need* to run AuthenticateAsync bool tokenPresent = Context.Request.Headers.TryGetValue("Authorization", out var authHeaders) && authHeaders.Any(h => h?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) == true); bool mustAuthenticate = tokenPresent || !anonymousAllowed; AuthenticateResult authResult = mustAuthenticate ? await Context.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme) : AuthenticateResult.NoResult(); // cheap placeholder; not succeeded, not failed logger.LogInformation( "Authorizing with following parameters authResult: {AuthResult}, anonymousAllowed: {anonymousAllowed}, tokenPresent: {tokenPresent}, requiredRoles: {requiredRoles}, roleClaims: {roleClaims}", authResult.Succeeded, anonymousAllowed, tokenPresent, string.Join(",", requiredRoles ?? []), string.Join(",", authResult?.Principal?.Claims?.Where(c => c.Type == ClaimTypes.Role)?.Select(c => c.Value) ?? []) ); // 4. Figure out whether roles are satisfied (only matters if authenticated) bool rolesSatisfied = authResult?.Succeeded == true && !anonymousAllowed && requiredRoles is { Length: > 0 } && (requiredRoles.Contains(SerdicaClaims.IsAuthenticated) || (authResult?.Principal?.Claims ?.Where(c => c.Type == ClaimTypes.Role) ?.Select(c => c.Value) ?.Intersect(requiredRoles!) ?.Any() ?? false) == true); // 5. Switch expression drives the outcome return (anonymousAllowed, authResult?.Succeeded ?? false, rolesSatisfied) switch { // Anonymous endpoint (true, true, _) => SuccessTicket(authResult!.Principal!), // token supplied (true, false, _) => SuccessTicket(BuildDefaultRolePrincipal( SerdicaClaims.Anonymous)), // no token // Protected endpoint but NOT authenticated (false, false, _) => AuthenticateResult.Fail( authResult!.Failure ?? new Exception("Token invalid.")), // Authenticated but lacks required roles (_, _, false) => AuthenticateResult.Fail("Insufficient privileges"), // Authenticated and authorised _ => SuccessTicket(authResult!.Principal!) }; } protected override async Task HandleChallengeAsync(AuthenticationProperties props) { var proxy = new ProxyResult { StatusCode = HttpStatusCode.Unauthorized, // 401 TraceId = Context.TraceIdentifier, Title = "Unauthorized", Type = "https://datatracker.ietf.org/doc/html/rfc9110#section-15.5.2", Errors = new Dictionary { ["authentication"] = "Missing or invalid credentials." } }; await proxy.ReturnHttpRessponse(Response); } protected override async Task HandleForbiddenAsync(AuthenticationProperties props) { var proxy = new ProxyResult { StatusCode = HttpStatusCode.Forbidden, // 403 TraceId = Context.TraceIdentifier, Title = "Forbidden", Type = "https://datatracker.ietf.org/doc/html/rfc9110#section-15.5.3", Errors = new Dictionary { ["authorization"] = "Insufficient privileges." } }; await proxy.ReturnHttpRessponse(Response); } // ────────────────────────────────────────────────────────────────── private ClaimsPrincipal BuildDefaultRolePrincipal(string role) => PrincipalBuilder.Build($"{DefaultRolePrincipalPrefix}_{role}", ConstantsClass.AuthenticationScheme) .AddClaim(ClaimTypes.NameIdentifier, $"{DefaultRolePrincipalPrefix}_{role}") .AddClaim(ClaimTypes.Role, role); private static AuthenticateResult SuccessTicket(ClaimsPrincipal principal) => AuthenticateResult.Success( new AuthenticationTicket( principal, principal.Identity!.AuthenticationType! ) ); }