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
				
			
		
		
	
	
				
					
				
			
		
			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:
		| @@ -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> | ||||
| @@ -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"; | ||||
| } | ||||
| @@ -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"; | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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!; | ||||
| } | ||||
| @@ -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; | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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"; | ||||
| } | ||||
| @@ -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>(); | ||||
| } | ||||
| @@ -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>(); | ||||
| } | ||||
| @@ -0,0 +1,7 @@ | ||||
| namespace Ablera.Serdica.Authentication.Models.Oidc; | ||||
|  | ||||
| public abstract record OidcSettingsBase | ||||
| { | ||||
|     public string? EncryptionKey { get; init; } | ||||
|     public AllowedMask[]? AllowedMasks { get; init; } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
							
								
								
									
										13
									
								
								inspiration/Ablera.Serdica.Authentication/NuGet.config
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								inspiration/Ablera.Serdica.Authentication/NuGet.config
									
									
									
									
									
										Normal 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> | ||||
| @@ -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! | ||||
|                 ) | ||||
|             ); | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
|  | ||||
		Reference in New Issue
	
	Block a user