Files
git.stella-ops.org/inspiration/Ablera.Serdica.Authentication/Services/SerdicaJwtBearerAuthenticationHandler.cs
root df5984d07e
Some checks failed
Build Test Deploy / build-test (push) Has been cancelled
Build Test Deploy / authority-container (push) Has been cancelled
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
up
2025-10-10 06:53:40 +00:00

163 lines
7.3 KiB
C#

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<JwtBearerOptions>
{
private readonly OidcValidation oidcValidationSettings;
private readonly ILogger<SerdicaJwtBearerAuthenticationHandler> logger;
public SerdicaJwtBearerAuthenticationHandler(
IOptionsMonitor<JwtBearerOptions> jwtOptions,
ILoggerFactory loggerFactory,
ILogger<SerdicaJwtBearerAuthenticationHandler> logger,
UrlEncoder encoder,
IOptions<OidcValidation> oidcServerConnection)
: base(jwtOptions, loggerFactory, encoder)
{
this.oidcValidationSettings = oidcServerConnection.Value;
this.logger = logger;
}
protected override async Task<AuthenticateResult> 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<string, string>
{
["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<string, string>
{
["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!
)
);
}