up
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

This commit is contained in:
root
2025-10-10 06:53:40 +00:00
parent 3aed135fb5
commit df5984d07e
1081 changed files with 97764 additions and 61389 deletions

View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Content Remove="NuGet.config" />
<None Include="NuGet.config">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenIddict.Client" Version="6.4.0" />
<PackageReference Include="OpenIddict.Client.AspNetCore" Version="6.4.0" />
<PackageReference Include="OpenIddict.Client.DataProtection" Version="6.4.0" />
<PackageReference Include="OpenIddict.Client.SystemNetHttp" Version="6.4.0" />
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="6.4.0" />
<PackageReference Include="OpenIddict.Validation.DataProtection" Version="6.4.0" />
<PackageReference Include="OpenIddict.Validation.SystemNetHttp" Version="6.4.0" />
<PackageReference Include="Polly" Version="8.6.1" />
<PackageReference Include="System.Text.Encodings.Web" Version="9.0.6" />
<PackageReference Include="OpenIddict.Validation.AspNetCore" Version="6.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.3.0" />
<PackageReference Include="OpenIddict.Abstractions" Version="6.4.0" />
<PackageReference Include="System.Text.Json" Version="9.0.6" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.12.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Cors" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Ablera.Serdica.Common.Tools/Ablera.Serdica.Common.Tools.csproj" />
<ProjectReference Include="../Ablera.Serdica.Extensions.Redis/Ablera.Serdica.Extensions.Redis.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
using System.Collections.Immutable;
namespace Ablera.Serdica.Authentication.Constants;
public static class ConstantsClass
{
public const string HttpContextItemsSession = "Session";
public const string HttpContextEndpoint = "Endpoint";
public const string HttpContextEndpointRequiredRoles = "EndpointRequiredRoles";
public const string RedisKeyPrefixKey = "serdica-session-dp";
public const string DataProtectionApplicationName = "SerdicaAuth";
public const string AuthenticationScheme = "SerdicaAuthentication"; // "SerdicaAuthentication"
public const string SerdicaAPIAudience = "SerdicaAPI";
public const string DefaultRolePrincipalPrefix = "__principal";
}

View File

@@ -0,0 +1,10 @@
namespace Ablera.Serdica.Authentication.Constants
{
public static class SerdicaClaims
{
public const string Anonymous = "__anonymous";
public const string IsAuthenticated = "__isAuthenticated";
public const string DefaultIdentity = "__default";
public const string RoleSuperUser = "DBA";
}
}

View File

@@ -0,0 +1,130 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Validation.AspNetCore;
using OpenIddict.Validation.SystemNetHttp;
using StackExchange.Redis;
using Ablera.Serdica.Authentication.Models;
using Ablera.Serdica.Authentication.Models.Oidc;
using Ablera.Serdica.Authentication.Utilities;
using Microsoft.AspNetCore.DataProtection;
using Ablera.Serdica.Authentication.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Ablera.Serdica.Authentication.Constants;
using OpenIddict.Client;
using OpenIddict.Validation;
using System.Linq;
using System.Collections.Generic;
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using System.Security.Principal;
using OpenIddict.Client.AspNetCore;
using Microsoft.AspNetCore.Authorization;
using Ablera.Serdica.DependencyInjection;
using static Ablera.Serdica.Authentication.Constants.ConstantsClass;
using static OpenIddict.Abstractions.OpenIddictConstants;
using System.IdentityModel.Tokens.Jwt;
using static OpenIddict.Client.OpenIddictClientEvents;
namespace Ablera.Serdica.DependencyInjection;
public sealed class AcceptAnyIssuer :
IOpenIddictClientHandler<OpenIddict.Client.OpenIddictClientEvents.HandleConfigurationResponseContext>
{
public ValueTask HandleAsync(HandleConfigurationResponseContext ctx)
{
// Short-circuit the built-in ValidateIssuer handler.
ctx.SkipRequest();
return default;
}
}
public static class JwtBearerWithSessionAuthenticationExtensions
{
public static IServiceCollection AddDataProtection(this IServiceCollection services, IConfiguration configuration)
{
//------------------------------------------------------------------
// 1) read configuration
//------------------------------------------------------------------
var redisConfiguration = RedisConfigurationGetter.GetRedisConfiguration(configuration);
var multiplexer = ConnectionMultiplexer.Connect(redisConfiguration);
services.AddSingleton<IConnectionMultiplexer>(multiplexer);
//------------------------------------------------------------------
// 2) Data-Protection (encrypt/sign cookies) keys stored in Redis
//------------------------------------------------------------------
var xmlRepo = new RedisAndFileSystemXmlRepository(
multiplexer.GetDatabase(), RedisKeyPrefixKey);
services.AddDataProtection()
.SetApplicationName(DataProtectionApplicationName)
.PersistKeysToStackExchangeRedis(multiplexer, RedisKeyPrefixKey)
.AddKeyManagementOptions(o => o.XmlRepository = xmlRepo)
.SetDefaultKeyLifetime(TimeSpan.FromDays(30));
return services;
}
public static IServiceCollection AddMicroserviceAuthentication(
this IServiceCollection services,
IConfiguration cfg,
IHostEnvironment env)
{
// ---------------------------------------------------------------------
// 1) Read and validate the OIDC client settings
// ---------------------------------------------------------------------
var oidc = cfg.GetSection(nameof(OidcValidation)).Get<OidcValidation>()
?? throw new InvalidOperationException($"{nameof(OidcValidation)} section is missing.");
if (string.IsNullOrWhiteSpace(oidc.EncryptionKey))
throw new InvalidOperationException($"{nameof(oidc.EncryptionKey)} is not defined.");
// Issuer value found in the `iss` claim of the tokens (HTTPS as issued by the IdP)
var issuerUrl = new Uri(oidc.IssuerUrl
?? throw new InvalidOperationException($"{nameof(oidc.IssuerUrl)} is not defined."));
services.Configure<OidcValidation>(cfg.GetSection(nameof(OidcValidation)));
services
.AddDataProtection(cfg)
.AddOpenIddict()
.AddValidation(opt =>
{
opt.UseSystemNetHttp();
opt.UseAspNetCore();
opt.SetIssuer(issuerUrl);
if (!string.IsNullOrWhiteSpace(oidc.ConfigurationUrl))
{
opt.Configure(x =>
{
x.ConfigurationEndpoint = new Uri(oidc.ConfigurationUrl);
});
}
opt.AddEncryptionKey(
new SymmetricSecurityKey(Convert.FromBase64String(oidc.EncryptionKey)));
});
services.AddAuthorization(options =>
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build())
.AddAuthentication(options =>
{
options.DefaultScheme = ConstantsClass.AuthenticationScheme;
options.DefaultChallengeScheme = ConstantsClass.AuthenticationScheme;
})
.AddScheme<JwtBearerOptions, SerdicaJwtBearerAuthenticationHandler>(
ConstantsClass.AuthenticationScheme, _ => { });
return services;
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Http;
using NetTools;
using System;
using System.Linq;
using System.Net;
using System.Collections.Generic;
using Ablera.Serdica.Authentication.Utilities;
using Ablera.Serdica.Authentication.Models.Oidc;
using Ablera.Serdica.Common.Tools.Utilities;
namespace Ablera.Serdica.Authority.Extensions;
public static class AllowedMaskExtensions
{
// Lazily built the first time AllowedMaskExtensions is referenced.
private static readonly IReadOnlyCollection<IPAddressRange> AssociatedNetworks = ListeningNetworksRetriever.Retrieve();
public static AllowedMask? MergeWith(this AllowedMask? client, AllowedMask? global)
=> (client, global) switch
{
(null, null) => null,
(null, _) => global,
_ => new()
{
SameNetworks = client.SameNetworks ?? global?.SameNetworks,
Networks = client.Networks ?? global?.Networks,
Hosts = client.Hosts ?? global?.Hosts,
Ports = client.Ports ?? global?.Ports
}
};
public static bool MatchesRemote(this AllowedMask allow, HttpContext http)
{
var remoteIp = http.Connection.RemoteIpAddress ?? IPAddress.None;
var host = http.Request.Host.Host;
var port = http.Request.Host.Port ?? 0;
bool ipOk = allow.Networks == null ||
allow.Networks.Any(net => IPAddressRange.Parse(net).Contains(remoteIp));
bool hostOk = allow.Hosts == null ||
allow.Hosts.Any(h => StringComparer.OrdinalIgnoreCase.Equals(h, host));
bool portOk = allow.Ports == null || allow.Ports.Contains(port);
// Same-network rule: only enforced when SameNetwork == true
bool sameNetworkOk =
allow.SameNetworks != true || // Flag not enabled → no restriction
AssociatedNetworks == null || // Could not determine our own network
AssociatedNetworks.Any(network => network.Contains(remoteIp));
return ipOk && hostOk && portOk && sameNetworkOk;
}
}

View File

@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Identity;
using OpenIddict.Abstractions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Ablera.Serdica.Authentication.Extensions
{
public static class ClaimExtensions
{
public static IReadOnlyCollection<Claim> BuildClaims<TKeyType>(
this IdentityUser<TKeyType> identity,
string? userName = null, string? givenName = null, string? surname = null)
where TKeyType : IEquatable<TKeyType> => new[]
{
new Claim(ClaimTypes.NameIdentifier, identity.Id?.ToString() ?? string.Empty),
new Claim(Claims.Subject, identity.Id?.ToString() ?? string.Empty),
new Claim(ClaimTypes.Name, userName ?? identity.UserName ?? string.Empty),
new Claim(ClaimTypes.GivenName, givenName ?? string.Empty),
new Claim(ClaimTypes.Surname, surname ?? string.Empty),
new Claim(ClaimTypes.Email, identity.Email ?? string.Empty)
};
public static IEnumerable<string> DestinationsSelector(this Claim c) => c.Type switch
{
Claims.Name or Claims.PreferredUsername
=> new[] { Destinations.AccessToken, Destinations.IdentityToken },
Claims.Email when c.Subject?.HasScope(Scopes.Email) == true
=> new[] { Destinations.AccessToken, Destinations.IdentityToken },
Claims.Role when c.Subject?.HasScope(Scopes.Roles) == true
=> new[] { Destinations.AccessToken, Destinations.IdentityToken },
_ => new[] { Destinations.AccessToken }
};
public static string? GetUserId(this ClaimsPrincipal user)
=> user.Claims.GetUserId() ?? Guid.Empty.ToString();
public static string? GetUserEmail(this ClaimsPrincipal user)
=> user.Claims
.FirstOrDefault(x => x.Type == ClaimTypes.Email)
?.Value?.ToString();
private static string? GetUserId(this IEnumerable<Claim> claims)
=> claims
.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier)
?.Value?.ToString()
?? claims
.FirstOrDefault(x => x.Type == ClaimTypes.Name)
?.Value?.ToString();
public static string? GetClientApplicationId(this ClaimsPrincipal user)
=> user.Claims.GetClientApplicationId();
private static string? GetClientApplicationId(this IEnumerable<Claim> claims)
=> claims
.FirstOrDefault(x => x.Type == Claims.Subject)
?.Value?.ToString()
?? claims
.FirstOrDefault(x => x.Type == Claims.ClientId)
?.Value?.ToString();
}
}

View File

@@ -0,0 +1,16 @@
using System.Security.Claims;
using OpenIddict.Abstractions;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Ablera.Serdica.Authentication.Extensions;
public static class PrincipalBuilder
{
public static ClaimsPrincipal Build(string clientId, string authenticationSchema)
{
var claimsIdentity = new ClaimsIdentity(authenticationSchema);
claimsIdentity.AddClaim(Claims.Subject, clientId, Destinations.AccessToken);
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
return claimsPrincipal;
}
}

View File

@@ -0,0 +1,18 @@
using Ablera.Serdica.Authentication.Models;
using Ablera.Serdica.Common.Tools.Extensions;
using Microsoft.AspNetCore.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authentication.Extensions;
public static class ProxyResultExtension
{
public static async Task ReturnHttpRessponse(this ProxyResult proxyResult, HttpResponse httpResponse)
{
if (httpResponse.HasStarted) return;
httpResponse.StatusCode = (int)proxyResult.StatusCode;
httpResponse.ContentType = "application/json";
await JsonSerializer.SerializeAsync(httpResponse.Body, proxyResult, proxyResult.GetType(), GlobalJsonSerializerOptions.JsonSerializerOptions);
}
}

View File

@@ -0,0 +1,10 @@
namespace Ablera.Serdica.Authentication.Models.Oidc;
public record AllowedMask
{
public bool? SameNetworks { get; init; }
public string[]? Hosts { get; init; }
public string[]? Networks { get; init; }
public int[]? Ports { get; init; }
public string[]? ClientIds { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace Ablera.Serdica.Authentication.Models.Oidc;
public record ClaimTypeAndValue
{
public required string Type { get; init; } = null!;
public required string Value { get; init; } = null!;
}

View File

@@ -0,0 +1,8 @@
namespace Ablera.Serdica.Authentication.Models.Oidc;
public record ClientCredentials : ConnectionSettingsBase
{
public required string[] Scopes { get; init; }
public required string[] Claims { get; init; }
public bool RequireHttps { get; init; } = true;
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Text.Json;
namespace Ablera.Serdica.Authentication.Models.Oidc;
public abstract record ConnectionSettingsBase
{
public required string[] GrantTypes { get; set; }
public required string ClientId { get; init; }
public string? ClientSecret { get; init; }
public required string ClientType { get; init; } = "public";
public required string DisplayName { get; init; }
public string[]? RedirectUris { get; init; }
public string[]? PostLogoutRedirectUris { get; init; }
public Dictionary<string, JsonElement>? Properties { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace Ablera.Serdica.Authority.Models;
public record Endpoints
{
public required string Authorization { get; init; } = "/connect/authorize";
public required string Introspection { get; init; } = "/connect/introspect";
public required string Token { get; init; } = "/connect/token";
public required string Userinfo { get; init; } = "/connect/userinfo";
public required string EndUserVerification { get; init; } = "/connect/verification";
public required string Revocation { get; init; } = "/connect/revocation";
public required string Logout { get; init; } = "/connect/endsession";
public required string CheckSession { get; init; } = "/connect/checksession";
public required string Device { get; init; } = "/connect/device";
public required string Jwks { get; init; } = "/connect/jwks";
public required string Configuration { get; init; } = "/.well-known/openid-configuration";
}

View File

@@ -0,0 +1,15 @@
using Ablera.Serdica.Authority.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authentication.Models.Oidc;
public record OidcValidation : OidcSettingsBase
{
public required string IssuerUrl { get; set; }
public required string? ConfigurationUrl { get; set; }
public AllowedMask[] BypassValidationsMasks { get; init; } = Array.Empty<AllowedMask>();
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Ablera.Serdica.Authentication.Models.Oidc;
namespace Ablera.Serdica.Authority.Models;
public record OidcServerSettings : OidcSettingsBase
{
public Endpoints Endpoints { get; init; } = null!;
public required string IssuerUrl { get; init; } = null!;
public bool? RequireHttps { get; set; } = false;
public required string CookieName { get; init; } = "oauth2-authorization";
public required int CookieExpirationInMinutes { get; init; } = 2;
public required int AuthorizationTokenDurationInMinutes { get; init; } = 5;
public RegisteredClient[] RegisteredClients { get; init; } = Array.Empty<RegisteredClient>();
public string[] Claims { get; init; } = Array.Empty<string>();
public string[] Scopes { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,7 @@
namespace Ablera.Serdica.Authentication.Models.Oidc;
public abstract record OidcSettingsBase
{
public string? EncryptionKey { get; init; }
public AllowedMask[]? AllowedMasks { get; init; }
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace Ablera.Serdica.Authentication.Models.Oidc;
public record RegisteredClient : ConnectionSettingsBase
{
public string[]? Permissions { get; init; }
public string[]? Requirements { get; init; }
public AllowedMask[]? AllowedMasks { get; init; }
public ClaimTypeAndValue[]? BuiltinClaims { get; init; } = [];
public Dictionary<string, string?>? Settings { get; init; }
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Net;
using System.Text.Json.Nodes;
namespace Ablera.Serdica.Authentication.Models;
public sealed class ProxyResult
{
public HttpStatusCode StatusCode { get; init; } = HttpStatusCode.OK;
public JsonNode? Data { get; init; } // null ⇒ no body
public IDictionary<string, string>? Errors { get; init; }
public string? TraceId { get; init; }
public string? Title { get; init; }
public string? Type { get; init; }
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget-mirror" value="https://mirrors.ablera.dev/nuget/nuget-mirror/v3/index.json" />
<add key="GitlabSerdicaBackend" value="https://gitlab.ablera.dev/api/v4/projects/92/packages/nuget/index.json" />
</packageSources>
<packageSourceCredentials>
<GitlabSerdicaBackend>
<add key="Username" value="gitlab+deploy-token-3" />
<add key="ClearTextPassword" value="osdy7Ec2sVoSJC2Kaxvr" />
</GitlabSerdicaBackend>
</packageSourceCredentials>
</configuration>

View File

@@ -0,0 +1,163 @@
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!
)
);
}

View File

@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.DataProtection.Repositories;
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Linq;
namespace Ablera.Serdica.Authentication.Utilities;
// Move this to ...Authentication.Redis or something
public sealed class RedisAndFileSystemXmlRepository : IXmlRepository
{
private readonly IDatabase _db;
private readonly string _prefix;
public RedisAndFileSystemXmlRepository(IDatabase db, string prefix)
{
_db = db;
_prefix = prefix;
}
public IReadOnlyCollection<XElement> GetAllElements()
{
var keys = _db.SetMembers(_prefix);
var list = new List<XElement>();
foreach (var redisValue in keys)
{
var xml = redisValue.ToString();
try { list.Add(XElement.Parse(xml)); }
catch { /* ignore corrupted entry */ }
}
return list;
}
public void StoreElement(XElement element, string friendlyName)
{
var xml = element.ToString(SaveOptions.DisableFormatting);
/* 1) write to Redis (set-add = idempotent) */
_db.SetAdd(_prefix, xml);
}
}