diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 292d33f..159b125 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: 🚀 Build RyzeSpace Auth Microservice (Dev Accounts) +name: Build AuthKit Server on: push: @@ -8,12 +8,12 @@ on: env: DOTNET_VERSION: '10.0.x' - SOLUTION_FILE: 'RyzeSDK.AuthKit.sln' - HOST_PROJECT: 'Host/Host.csproj' + SOLUTION_FILE: 'AuthKit.slnx' + HOST_PROJECT: 'src/Host/Host.csproj' jobs: build: - name: 🔨 Build on ${{ matrix.os }} (${{ matrix.runtime }}) + name: Build on ${{ matrix.os }} (${{ matrix.runtime }}) runs-on: ${{ matrix.os }} strategy: matrix: @@ -61,10 +61,10 @@ jobs: uses: actions/upload-artifact@v4 with: name: auth-service-${{ matrix.runtime }} - path: ./publish/${{ matrix.runtime }} + path: ./publish/${{ matrix.runtime }} attest: - name: 🛡️ Attest Build Provenance + name: Attest Build Provenance needs: build runs-on: ubuntu-latest permissions: @@ -80,4 +80,4 @@ jobs: - name: Attest build provenance uses: actions/attest-build-provenance@v3 with: - subject-path: ./publish \ No newline at end of file + subject-path: ./publish diff --git a/.gitignore b/.gitignore index 4480c94..c6484b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /certs/devcert.pfx bin/ obj/ +/plugins/ .idea/ .env /net-commander diff --git a/Application/Features/DeveloperTokens/DTO/DeveloperTokenCreated.cs b/Application/Features/DeveloperTokens/DTO/DeveloperTokenCreated.cs deleted file mode 100644 index 5de9b3c..0000000 --- a/Application/Features/DeveloperTokens/DTO/DeveloperTokenCreated.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Domain.Features.DeveloperTokens; - -namespace Application.Features.DeveloperTokens.DTO; - -/// -/// Represents the result of a developer token creation. -/// -/// -/// -/// Contains the signed JWT string and short API key for the created token. -/// Includes the entity with all details. -/// -/// -public class DeveloperTokenCreated -{ - /// - /// Short API key used as a public identifier (e.g. "rk_live_xxx"). - /// - public string ShortKey { get; set; } = null!; - - /// - /// Signed JWT containing developer claims and expiration info. - /// - public string Jwt { get; set; } = null!; - - public DeveloperToken Token { get; set; } = null!; -} \ No newline at end of file diff --git a/Application/Features/DeveloperTokens/DTO/DeveloperTokenDto.cs b/Application/Features/DeveloperTokens/DTO/DeveloperTokenDto.cs deleted file mode 100644 index 527c8f5..0000000 --- a/Application/Features/DeveloperTokens/DTO/DeveloperTokenDto.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Domain.Features.DeveloperTokens; - -namespace Application.Features.DeveloperTokens.DTO; - -public record DeveloperTokenDto -{ - public Guid Id { get; init; } - public Guid DeveloperId { get; init; } - public string Name { get; init; } = null!; - public IReadOnlyList Scopes { get; init; } = []; - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? ExpiresAt { get; init; } - - public bool IsExpired => ExpiresAt.HasValue && DateTimeOffset.UtcNow > ExpiresAt.Value; - - public static DeveloperTokenDto FromDomain(DeveloperToken token) => - new() - { - Id = token.Id, - DeveloperId = token.DeveloperId, - Name = token.Name.ToString(), - Scopes = token.Scopes.Select(s => s.Value).ToList().AsReadOnly(), - CreatedAt = token.Lifetime.CreatedAt, - ExpiresAt = token.Lifetime.ExpiresAt - }; -} \ No newline at end of file diff --git a/Application/Features/DeveloperTokens/DTO/DeveloperTokenPairDto.cs b/Application/Features/DeveloperTokens/DTO/DeveloperTokenPairDto.cs deleted file mode 100644 index cec03e8..0000000 --- a/Application/Features/DeveloperTokens/DTO/DeveloperTokenPairDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Application.Features.DeveloperTokens.DTO; - -/// -/// Data transfer object representing a generated developer token pair. -/// -/// -/// -/// ShortKey – public identifier used as API key in requests. -/// Jwt – signed JSON Web Token containing developer claims. -/// -/// -public record DeveloperTokenPairDto(string ShortKey, string Jwt); \ No newline at end of file diff --git a/Application/Features/DeveloperTokens/Services/DeveloperTokenManager.cs b/Application/Features/DeveloperTokens/Services/DeveloperTokenManager.cs deleted file mode 100644 index cbfae07..0000000 --- a/Application/Features/DeveloperTokens/Services/DeveloperTokenManager.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Application.Features.DeveloperTokens.DTO; -using Application.Features.DeveloperTokens.Interfaces; -using Domain.Features.DeveloperTokens; -using Domain.Features.DeveloperTokens.Repositories; -using Domain.Features.DeveloperTokens.ValueObject; - -namespace Application.Features.DeveloperTokens.Services; - -/// -/// Manager responsible for handling developer token operations. -/// -/// -/// -/// Provides creation, deletion, and retrieval of developer tokens. -/// Delegates JWT generation to . -/// Persists tokens using . -/// -/// -public class DeveloperTokenManager( - IDeveloperTokenService tokenService, - IDeveloperTokenRepository repository - ) : IDeveloperTokenManager -{ - /// - public async Task CreateAsync(Guid developerId, string name, IEnumerable scopes, TimeSpan? lifetime = null, - CancellationToken ct = default) - { - Console.WriteLine("Debug DeveloperTokenManager"); - var token = DeveloperToken.Create( - developerId, - name, - scopes.Select(s => (TokenScope)s), - lifetime - ); - - //await repository.SaveAsync(token, ct); - var tokenPair = await tokenService.GenerateToken(token); - - return new DeveloperTokenCreated - { - Token = token, - ShortKey = tokenPair.ShortKey, - Jwt = tokenPair.Jwt - }; - } - - /// - public async Task DeleteAsync(Guid tokenId, CancellationToken ct = default) => - await repository.DeleteAsync(tokenId, ct); - - /// - public async Task> GetByDeveloperAsync(Guid developerId, CancellationToken ct = default) => - await repository.GetByDeveloperIdAsync(developerId, ct); - - /// - public async Task GetByIdAsync(Guid tokenId, CancellationToken ct = default) => - await repository.GetByIdAsync(tokenId, ct); - -} \ No newline at end of file diff --git a/Application/Features/DeveloperTokens/Services/DeveloperTokenService.cs b/Application/Features/DeveloperTokens/Services/DeveloperTokenService.cs deleted file mode 100644 index 5469d71..0000000 --- a/Application/Features/DeveloperTokens/Services/DeveloperTokenService.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Security.Cryptography; -using Application.Features.DeveloperTokens.DTO; -using Application.Features.DeveloperTokens.Interfaces; -using Application.Features.KeyManagement.Interfaces; -using Application.Features.TokenKeyBindings.Interfaces; -using Domain.Features.DeveloperTokens; - -namespace Application.Features.DeveloperTokens.Services; - -/// -/// Service responsible for generating and managing JWTs. -/// -/// -/// -/// Generates JWTs for developer tokens using the active key from . -/// Creates key bindings between tokens and RSA signing keys via . -/// Supports short-lived ("temp") and permanent ("live") token formats. -/// Encapsulates JWT claim construction and secure random short key generation. -/// -/// -public class DeveloperTokenService(IJwtKeyStore jwtKeyStore, IKeyBindingService keyBindingService) - : IDeveloperTokenService -{ - /// - public async Task GenerateToken(DeveloperToken token) - { - var creds = GetSigningCredentials(); - var claims = BuildClaims(token); - var jwt = CreateJwtToken(claims, token.Lifetime.ExpiresAt, creds); - - await CreateKeyBinding(token.Id, creds.Key.KeyId!); - - var handler = new JwtSecurityTokenHandler(); - return new DeveloperTokenPairDto(GenerateShortKey(token), handler.WriteToken(jwt)); - } - - private Microsoft.IdentityModel.Tokens.SigningCredentials GetSigningCredentials() - { - var creds = jwtKeyStore.GetActiveSigningCredentials(); - return string.IsNullOrEmpty(creds.Key.KeyId) - ? throw new InvalidOperationException("SigningCredentials must have a KeyId set.") : creds; - } - - private async Task CreateKeyBinding(Guid tokenId, string signingKeyId) - { - var jwk = jwtKeyStore.GetPublicJwks() - .FirstOrDefault(k => k.Kid == signingKeyId) - ?? throw new InvalidOperationException($"No JWK for kid '{signingKeyId}'."); - - await keyBindingService.CreateBindingAsync(tokenId, signingKeyId, jwk.N); - } - - private static JwtSecurityToken CreateJwtToken(IEnumerable claims, DateTimeOffset? expiresAt, - Microsoft.IdentityModel.Tokens.SigningCredentials creds) - { - var expires = expiresAt?.UtcDateTime ?? DateTime.UtcNow.AddYears(100); - return new JwtSecurityToken(claims: claims, expires: expires, signingCredentials: creds); - } - - private static IEnumerable BuildClaims(DeveloperToken token) - { - yield return new Claim(JwtRegisteredClaimNames.Sub, token.DeveloperId.ToString()); - yield return new Claim(JwtRegisteredClaimNames.Jti, token.Id.ToString()); - yield return new Claim("name", token.Name); - yield return new Claim("type", token.Lifetime.ExpiresAt.HasValue ? "temp" : "live"); - - foreach (var scope in token.Scopes) - yield return new Claim("scope", scope.Value); - } - - private static string GenerateShortKey(DeveloperToken token) - { - var prefix = token.Lifetime.ExpiresAt.HasValue ? "rk_temp_" : "rk_live_"; - var randomHex = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); - return $"{prefix}{randomHex}"; - } -} diff --git a/Application/Features/DeveloperTokens/Services/DeveloperTokenValidatorService.cs b/Application/Features/DeveloperTokens/Services/DeveloperTokenValidatorService.cs deleted file mode 100644 index 8ac09e3..0000000 --- a/Application/Features/DeveloperTokens/Services/DeveloperTokenValidatorService.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using Application.Features.DeveloperTokens.Interfaces; -using Application.Features.KeyManagement.Interfaces; -using Application.Features.TokenKeyBindings.Interfaces; -using Domain.Features.DeveloperTokens; -using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.Tokens; - -namespace Application.Features.DeveloperTokens.Services; - -/// -/// Validates JWTs using the active JWT keystore and token-key bindings. -/// -/// -/// -/// Reads the JWT token and extracts KID and JTI. -/// Checks that a binding exists for the token ID and signing key. -/// Validates the token signature and lifetime. -/// Builds a with developer ID, name, and scopes. -/// -/// -public class DeveloperTokenValidatorService( - IJwtKeyStore jwtKeyStore, - IKeyBindingService keyBinding, - ILogger logger -) : IDeveloperTokenValidator -{ - private readonly JwtSecurityTokenHandler _handler = new(); - - /// - public async Task ValidateAsync(string jwtToken) - { - if (string.IsNullOrWhiteSpace(jwtToken)) - return null; - - var jwt = ReadJwtToken(jwtToken); - if (jwt == null) - return null; - - if (!TryGetTokenId(jwt, out var tokenId)) - { - logger.LogWarning("[Validator] Missing or invalid jti"); - return null; - } - - var kid = jwt.Header.Kid; - if (string.IsNullOrEmpty(kid)) - { - logger.LogWarning("[Validator] Missing kid in token header"); - return null; - } - - if (!await IsBindingValid(tokenId, kid)) - return null; - - var signingCreds = jwtKeyStore.GetSigningCredentialsByKid(kid); - if (signingCreds == null) - { - logger.LogWarning("[Validator] No signing creds for kid={Kid}", kid); - return null; - } - - return ValidateSignatureAndBuildPrincipal(jwtToken, signingCreds); - } - - private JwtSecurityToken? ReadJwtToken(string token) - { - try { return _handler.ReadJwtToken(token); } - catch (Exception ex) - { - logger.LogWarning(ex, "[Validator] Token read failed"); - return null; - } - } - - private static bool TryGetTokenId(JwtSecurityToken jwt, out Guid tokenId) - { - var jti = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Jti)?.Value; - return Guid.TryParse(jti, out tokenId); - } - - private async Task IsBindingValid(Guid tokenId, string kid) - { - var binding = await keyBinding.GetBindingAsync(tokenId, kid); - if (binding == null) - { - logger.LogWarning("[Validator] No binding found for TokenId={TokenId}, Kid={Kid}", tokenId, kid); - return false; - } - return true; - } - - private DeveloperTokenPrincipal? ValidateSignatureAndBuildPrincipal(string jwtToken, SigningCredentials creds) - { - try - { - var principal = _handler.ValidateToken(jwtToken, new TokenValidationParameters - { - RequireExpirationTime = true, - ValidateLifetime = true, - ValidateIssuer = false, - ValidateAudience = false, - IssuerSigningKey = creds.Key - }, out _); - - return new DeveloperTokenPrincipal( - GetClaimValue(principal, "sub"), - GetClaimValue(principal, "name"), - principal.Claims.Where(c => c.Type == "scope").Select(c => c.Value) - ); - } - catch (Exception ex) - { - logger.LogWarning(ex, "[Validator] Signature/lifetime validation failed"); - return null; - } - } - - private static T GetClaimValue(System.Security.Claims.ClaimsPrincipal principal, string type) - { - var value = principal.Claims.First(c => c.Type == type).Value; - return (T)Convert.ChangeType(value, typeof(T)); - } -} diff --git a/Application/Features/KeyManagement/DTO/KeyEntry.cs b/Application/Features/KeyManagement/DTO/KeyEntry.cs deleted file mode 100644 index a0c8272..0000000 --- a/Application/Features/KeyManagement/DTO/KeyEntry.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.IdentityModel.Tokens; - -namespace Application.Features.KeyManagement.DTO; - -/// -/// Represents a single RSA key entry used for JWT signing. -/// -/// -/// -/// Holds the instance. -/// Includes for JWT issuance. -/// Contains associated (creation time, status, etc.). -/// Exposes raw RSA parameters N (modulus) and E (exponent) for JWKS export. -/// -/// -public record KeyEntry( - RsaSecurityKey Key, - SigningCredentials Signing, - KeyMetadata Meta, - string N, - string E -); diff --git a/Application/Features/KeyManagement/DTO/KeyMetadata.cs b/Application/Features/KeyManagement/DTO/KeyMetadata.cs deleted file mode 100644 index 37186bf..0000000 --- a/Application/Features/KeyManagement/DTO/KeyMetadata.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Application.Features.KeyManagement.DTO; - -/// -/// Represents metadata describing a cryptographic signing key. -/// -/// -/// -/// Contains key identifier (kid), creation date, algorithm, and revocation status. -/// Used to manage rotation and public JWTS publication. -/// -/// -public sealed record KeyMetadata( - string Kid, - DateTimeOffset CreatedAt, - bool Revoked, - string Algorithm = "RS256", - string Purpose = "JWT signing" -); \ No newline at end of file diff --git a/Application/Features/KeyManagement/DTO/KeystoreOnDisk.cs b/Application/Features/KeyManagement/DTO/KeystoreOnDisk.cs deleted file mode 100644 index ba98731..0000000 --- a/Application/Features/KeyManagement/DTO/KeystoreOnDisk.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Application.Features.KeyManagement.DTO; - -/// -/// Represents the serialized keystore persisted on disk (before encryption). -/// -/// -/// -/// Contains a collection of all RSA key records used for JWT signing. -/// Defines which key is currently active for signing new tokens. -/// -/// -public sealed record KeystoreOnDisk( - string ActiveKid, - List Records -); \ No newline at end of file diff --git a/Application/Features/KeyManagement/DTO/KeystoreRecordOnDisk.cs b/Application/Features/KeyManagement/DTO/KeystoreRecordOnDisk.cs deleted file mode 100644 index 9800374..0000000 --- a/Application/Features/KeyManagement/DTO/KeystoreRecordOnDisk.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Application.Features.KeyManagement.DTO; - -/// -/// Represents a single stored RSA key entry within the serialized keystore. -/// -/// -/// -/// Contains metadata such as KID, creation timestamp, and revocation state. -/// Holds the private RSA key encoded in Base64 for persistence. -/// -/// -public sealed record KeystoreRecordOnDisk( - KeyMetadata Metadata, - string PrivateKeyBase64 -); \ No newline at end of file diff --git a/Application/Features/KeyManagement/Interfaces/IJwtKeyStore.cs b/Application/Features/KeyManagement/Interfaces/IJwtKeyStore.cs deleted file mode 100644 index 330630f..0000000 --- a/Application/Features/KeyManagement/Interfaces/IJwtKeyStore.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Application.Features.KeyManagement.DTO; -using Microsoft.IdentityModel.Tokens; - -namespace Application.Features.KeyManagement.Interfaces; - -/// -/// Defines an asynchronous contract for managing JWT signing keys with lifecycle operations such as rotation and revocation. -/// -/// -/// -/// Maintains the active signing key used for issuing JWT tokens. -/// Supports key lookup by KID (Key Identifier) for signature validation. -/// Exposes a JWKS endpoint-compatible collection of public keys. -/// Provides secure key rotation and revocation management asynchronously. -/// -/// -public interface IJwtKeyStore -{ - Task InitializeAsync(); - - /// - /// Retrieves the currently active signing credentials used for JWT issuance. - /// - /// The active instance. - SigningCredentials GetActiveSigningCredentials(); - - /// - /// Retrieves signing credentials for a specific key identifier (KID). - /// - /// The unique key identifier. - /// The matching or null if not found. - SigningCredentials? GetSigningCredentialsByKid(string kid); - - /// - /// Returns the collection of public keys in JWKS (JSON Web Key Set) format. - /// - /// An enumerable of representing the public keys. - IEnumerable GetPublicJwks(); - - /// - /// Asynchronously rotates the active RSA signing key and returns metadata for the newly generated key. - /// - /// The RSA key size in bits (default is 4096). - /// A representing the newly generated key metadata. - Task RotateAsync(int rsaBits = 4096); - - /// - /// Asynchronously revokes the signing key associated with the specified key identifier (KID). - /// - /// The unique key identifier of the key to revoke. - /// A indicating whether the key was successfully revoked. - Task RevokeAsync(string kid); - - /// - /// Retrieves metadata for a specific key identifier (KID). - /// - /// The unique key identifier. - /// The of the key, or null if not found. - KeyMetadata? GetMetadata(string kid); -} diff --git a/Application/Features/KeyManagement/Interfaces/IKeyEncryptor.cs b/Application/Features/KeyManagement/Interfaces/IKeyEncryptor.cs deleted file mode 100644 index f1c0f0e..0000000 --- a/Application/Features/KeyManagement/Interfaces/IKeyEncryptor.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Application.Features.KeyManagement.Interfaces; - -/// -/// Defines a contract for encrypting and decrypting keystore data using symmetric or asymmetric algorithms. -/// -/// -/// -/// Implemented by concrete providers such as AesKeyEncryptor for AES-based encryption. -/// Responsible for securing sensitive key material, configuration values, or tokens at rest. -/// Ensures reversible protection through and operations. -/// -/// -public interface IKeyEncryptor -{ - /// - /// Encrypts the provided plaintext into a binary ciphertext representation. - /// - /// The UTF-8 encoded string to encrypt. - /// A byte array containing the encrypted data. - byte[] Encrypt(string plaintext); - - /// - /// Decrypts the provided ciphertext back into its original plaintext form. - /// - /// The byte array containing encrypted data. - /// The decrypted UTF-8 string. - string Decrypt(byte[] ciphertext); -} \ No newline at end of file diff --git a/Application/Features/KeyManagement/Interfaces/IKeyGenerator.cs b/Application/Features/KeyManagement/Interfaces/IKeyGenerator.cs deleted file mode 100644 index 38ad329..0000000 --- a/Application/Features/KeyManagement/Interfaces/IKeyGenerator.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Application.Features.KeyManagement.DTO; -using Microsoft.IdentityModel.Tokens; - -namespace Application.Features.KeyManagement.Interfaces; - -/// -/// Defines a contract for generating RSA keys used in JWT signing and encryption. -/// -/// -/// -/// Creates new instances with related metadata for identification and management. -/// Supports use in key rotation, initialization, or cryptographic provisioning workflows. -/// Allows configurable RSA key size (default 4096 bits) for enhanced security flexibility. -/// -/// -public interface IKeyGenerator -{ - /// - /// Generates a new RSA key pair along with its associated metadata. - /// - /// The RSA key size in bits (default is 4096). - /// - /// A tuple containing: - /// - /// representing the generated RSA key. - /// containing metadata such as key identifier and creation time. - /// - /// - (RsaSecurityKey Key, KeyMetadata Meta) Generate(int rsaBits = 4096); -} \ No newline at end of file diff --git a/Application/Features/KeyManagement/Interfaces/IKeyStoreRepository.cs b/Application/Features/KeyManagement/Interfaces/IKeyStoreRepository.cs deleted file mode 100644 index ec2e451..0000000 --- a/Application/Features/KeyManagement/Interfaces/IKeyStoreRepository.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Application.Features.KeyManagement.Interfaces; - -/// -/// Provides abstraction for persisting and loading encrypted keystore data. -/// -/// -/// -/// Supports asynchronous I/O operations for high-performance scenarios. -/// The storage medium can be a file system, database, or any other persistent store. -/// The data is expected to be already encrypted by before saving. -/// -/// -public interface IKeyStoreRepository -{ - /// - /// Asynchronously loads the persisted keystore bytes. - /// - /// A containing the persisted encrypted keystore data. - /// Returns empty memory if no data is found. - Task> LoadAsync(); - - /// - /// Asynchronously saves the provided encrypted keystore bytes to persistent storage. - /// - /// Encrypted keystore data to persist. - Task SaveAsync(ReadOnlyMemory data); -} \ No newline at end of file diff --git a/Application/Features/KeyManagement/Services/AesKeyEncryptor.cs b/Application/Features/KeyManagement/Services/AesKeyEncryptor.cs deleted file mode 100644 index 5606bee..0000000 --- a/Application/Features/KeyManagement/Services/AesKeyEncryptor.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Application.Features.KeyManagement.Interfaces; - -namespace Application.Features.KeyManagement.Services; - -/// -/// AES-256-CBC-based implementation of for securing keystore data. -/// -/// -/// -/// Uses a 256-bit symmetric key derived from a Base64-encoded master key. -/// Encrypts and decrypts UTF-8 encoded text using AES in CBC mode with a random IV. -/// Ensures that ciphertext includes the IV as a prefix for later decryption. -/// -/// -public class AesKeyEncryptor : IKeyEncryptor -{ - private readonly byte[] _key; - - /// - /// Creates an encryptor with a Base64-encoded 256-bit key. - /// - /// Base64-encoded AES key (32 bytes). - public AesKeyEncryptor(string masterKeyBase64) - { - _key = Convert.FromBase64String(masterKeyBase64); - if (_key.Length < 32) - throw new InvalidOperationException("Master key must be 32 bytes (Base64)."); - } - - /// - public byte[] Encrypt(string plaintext) - { - using var aes = Aes.Create(); - aes.Key = _key; - aes.GenerateIV(); - - var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); - var ciphertext = Transform(aes.CreateEncryptor(), plaintextBytes); - - return Combine(aes.IV, ciphertext); - } - - /// - public string Decrypt(byte[] blob) - { - using var aes = Aes.Create(); - aes.Key = _key; - - var ivLength = aes.BlockSize / 8; - var iv = blob[..ivLength]; - var ciphertext = blob[ivLength..]; - aes.IV = iv; - - var plaintextBytes = Transform(aes.CreateDecryptor(), ciphertext); - return Encoding.UTF8.GetString(plaintextBytes); - } - - private static byte[] Transform(ICryptoTransform transform, byte[] data) => - transform.TransformFinalBlock(data, 0, data.Length); - - private static byte[] Combine(byte[] first, byte[] second) - { - var result = new byte[first.Length + second.Length]; - Buffer.BlockCopy(first, 0, result, 0, first.Length); - Buffer.BlockCopy(second, 0, result, first.Length, second.Length); - return result; - } -} diff --git a/Application/Features/KeyManagement/Services/JwtKeyStore.cs b/Application/Features/KeyManagement/Services/JwtKeyStore.cs deleted file mode 100644 index 3f51dea..0000000 --- a/Application/Features/KeyManagement/Services/JwtKeyStore.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System.Buffers; -using System.Buffers.Text; -using System.Collections.Concurrent; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Application.Features.KeyManagement.DTO; -using Application.Features.KeyManagement.Interfaces; -using Microsoft.IdentityModel.Tokens; - -namespace Application.Features.KeyManagement.Services; - -/// -/// Manages RSA keys for JWT signing, including rotation, revocation, and JWKS exposure. -/// -/// -/// -/// Maintains in-memory key entries with metadata. -/// Supports key rotation and marking keys as revoked. -/// Provides JWKS-compliant public keys for signature verification. -/// Persists encrypted key material using and . -/// Designed for asynchronous initialization and disposal of cryptographic resources. -/// -/// -public class JwtKeyStore(IKeyStoreRepository repository, IKeyEncryptor encryptor, IKeyGenerator generator) - : IJwtKeyStore, IAsyncDisposable -{ - private readonly ConcurrentDictionary _keys = new(); - private volatile string _activeKid = null!; - private volatile PublicJwkDto[]? _cachedJwks; - private bool _disposed; - - public async Task InitializeAsync() - { - var enc = await repository.LoadAsync().ConfigureAwait(false); - if (enc.Length > 0) - { - var decrypted = encryptor.Decrypt(enc.ToArray()); - var ser = JsonSerializer.Deserialize(decrypted)!; - - Parallel.ForEach(ser.Records, rec => - { - var rsa = RSA.Create(); - rsa.ImportRSAPrivateKey(Convert.FromBase64String(rec.PrivateKeyBase64), out _); - var key = new RsaSecurityKey(rsa) { KeyId = rec.Metadata.Kid }; - - var pubParams = rsa.ExportParameters(false); - var n = Base64UrlEncode(pubParams.Modulus!); - var e = Base64UrlEncode(pubParams.Exponent!); - - _keys[rec.Metadata.Kid] = new KeyEntry( - key, - new SigningCredentials(key, SecurityAlgorithms.RsaSha256), - rec.Metadata, - n, - e - ); - }); - - _activeKid = ser.ActiveKid; - RebuildJwksCache(); - } - else - { - await RotateAsync().ConfigureAwait(false); - } - } - - /// - public SigningCredentials GetActiveSigningCredentials() => _keys[_activeKid].Signing; - - /// - public SigningCredentials? GetSigningCredentialsByKid(string kid) - => _keys.TryGetValue(kid, out var e) && !e.Meta.Revoked ? e.Signing : null; - - /// - public IEnumerable GetPublicJwks() - { - var cache = _cachedJwks; - if (cache != null) return cache; - RebuildJwksCache(); - return _cachedJwks!; - } - - private void RebuildJwksCache() - { - var list = new List(_keys.Count); - list.AddRange(from e in _keys.Values - where !e.Meta.Revoked - select new PublicJwkDto - { - Kty = "RSA", - Use = "sig", - Kid = e.Meta.Kid, - Alg = "RS256", - N = e.N, - E = e.E - }); - _cachedJwks = list.ToArray(); - } - - /// - public async Task RotateAsync(int rsaBits = 4096) - { - var (key, meta) = generator.Generate(rsaBits); - - if (string.IsNullOrWhiteSpace(key.KeyId)) - key.KeyId = meta.Kid; - - var signing = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); - - var pubParams = key.Rsa.ExportParameters(false); - var n = Base64UrlEncode(pubParams.Modulus!); - var e = Base64UrlEncode(pubParams.Exponent!); - - var entry = new KeyEntry( - key, - signing, - meta, - n, - e - ); - - _keys[meta.Kid] = entry; - _activeKid = meta.Kid; - _cachedJwks = null; - await PersistAsync().ConfigureAwait(false); - return meta; - } - - /// - public async Task RevokeAsync(string kid) - { - if (!_keys.TryGetValue(kid, out var e)) return false; - - _keys[kid] = e with - { - Meta = e.Meta with - { - Revoked = true - } - }; - _cachedJwks = null; - await PersistAsync().ConfigureAwait(false); - return true; - } - - /// - public KeyMetadata? GetMetadata(string kid) => _keys.TryGetValue(kid, out var e) ? e.Meta : null; - - private async Task PersistAsync() - { - var records = new List(_keys.Count); - records.AddRange( - from e in _keys.Values - let privKey = e.Key.Rsa.ExportRSAPrivateKey() - let base64 = Convert.ToBase64String(privKey) - select new KeystoreRecordOnDisk(e.Meta, base64) - ); - - var data = new KeystoreOnDisk(_activeKid, records); - var json = JsonSerializer.Serialize(data); - var enc = encryptor.Encrypt(json); - await repository.SaveAsync(enc).ConfigureAwait(false); - } - - private static string Base64UrlEncode(byte[] input) - { - if (input == null) - throw new ArgumentNullException(nameof(input), "RSA parameter cannot be null"); - - Span buffer = stackalloc byte[Base64.GetMaxEncodedToUtf8Length(input.Length)]; - var status = Base64.EncodeToUtf8(input, buffer, out _, out int bytesWritten); - if (status != OperationStatus.Done) - throw new InvalidOperationException($"Base64 encoding failed: {status}"); - - for (var i = 0; i < bytesWritten; i++) - { - buffer[i] = buffer[i] switch - { - (byte)'+' => (byte)'-', - (byte)'/' => (byte)'_', - _ => buffer[i] - }; - } - - var end = bytesWritten; - while (end > 0 && buffer[end - 1] == (byte)'=') end--; - - return Encoding.ASCII.GetString(buffer[..end]); - } - - /// - public async ValueTask DisposeAsync() - { - if (_disposed) return; - foreach (var e in _keys.Values) - e.Key.Rsa.Dispose(); - _disposed = true; - await Task.CompletedTask; - } -} diff --git a/Application/Features/KeyManagement/Services/RsaKeyGenerator.cs b/Application/Features/KeyManagement/Services/RsaKeyGenerator.cs deleted file mode 100644 index e600b50..0000000 --- a/Application/Features/KeyManagement/Services/RsaKeyGenerator.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Security.Cryptography; -using Application.Features.KeyManagement.DTO; -using Application.Features.KeyManagement.Interfaces; -using Microsoft.IdentityModel.Tokens; - -namespace Application.Features.KeyManagement.Services; - -/// -/// Generates new RSA key pairs with unique identifiers and creation metadata. -/// -/// -/// -/// Uses 4096-bit RSA by default for strong security. -/// Assigns a random GUID-based Key ID (KID) to each key. -/// Returns both the key and its associated metadata for persistence or rotation. -/// -/// -public class RsaKeyGenerator : IKeyGenerator -{ - /// - public (RsaSecurityKey Key, KeyMetadata Meta) Generate(int rsaBits = 4096) - { - var rsa = RSA.Create(rsaBits); - var kid = Guid.NewGuid().ToString("N"); - - var key = new RsaSecurityKey(rsa) - // var key = new RsaSecurityKey(rsa.ExportParameters(includePrivateParameters: true)) - { - KeyId = kid - }; - - var meta = new KeyMetadata(kid, DateTime.UtcNow, false); - return (key, meta); - } -} \ No newline at end of file diff --git a/Application/Features/TokenKeyBindings/Interfaces/IKeyBindingService.cs b/Application/Features/TokenKeyBindings/Interfaces/IKeyBindingService.cs deleted file mode 100644 index 3508e79..0000000 --- a/Application/Features/TokenKeyBindings/Interfaces/IKeyBindingService.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Domain.Features.DeveloperTokens; -using Domain.Features.TokenKeyBindings; - -namespace Application.Features.TokenKeyBindings.Interfaces; - -/// -/// Abstraction for managing key bindings between and RSA signing keys. -/// -/// -/// -/// Creates, updates, rebinds, and revokes token-to-key bindings. -/// Provides retrieval and listing of key bindings by token or signing key. -/// Designed for asynchronous operations in a DDD-compliant context. -/// -/// -public interface IKeyBindingService -{ - /// - /// Creates a new key binding for a given developer token. - /// - /// The unique identifier of the developer token. - /// The ID of the signing key to bind. - /// The public key associated with the signing key. - /// The newly created . - Task CreateBindingAsync(Guid tokenId, string signingKeyId, string publicKey); - - /// - /// Rebinds an existing key binding to a new signing key. - /// - /// The unique identifier of the developer token. - /// The current signing key ID to rebind from. - /// The new signing key ID to bind to. - /// The public key of the new signing key. - /// The updated , or null if the original binding does not exist. - Task RebindAsync(Guid tokenId, string signingKeyId, string newSigningKeyId, string newPublicKey); - - /// - /// Updates the public key for an existing key binding without changing the signing key ID. - /// - /// The unique identifier of the developer token. - /// The signing key ID whose public key is being updated. - /// The new public key value. - /// The updated , or null if no binding exists. - Task UpdatePublicKeyAsync(Guid tokenId, string signingKeyId, string newPublicKey); - - /// - /// Revokes a key binding by marking it as inactive or revoked. - /// - /// The unique identifier of the developer token. - /// true if revocation succeeded; otherwise false. - Task RevokeAsync(string tokenId); - - /// - /// Lists all key bindings associated with a given developer token. - /// - /// The unique identifier of the developer token. - /// A collection of entities. - Task> ListBindingsAsync(Guid tokenId); - - /// - /// Gets a specific key binding by token ID and signing key ID. - /// - /// The unique identifier of the developer token. - /// The signing key ID to retrieve. - /// The if found; otherwise null. - Task GetBindingAsync(Guid tokenId, string signingKeyId); -} diff --git a/Application/Features/TokenKeyBindings/Services/KeyBindingService.cs b/Application/Features/TokenKeyBindings/Services/KeyBindingService.cs deleted file mode 100644 index 828558c..0000000 --- a/Application/Features/TokenKeyBindings/Services/KeyBindingService.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Application.Features.TokenKeyBindings.Interfaces; -using Domain.Features.DeveloperTokens; -using Domain.Features.TokenKeyBindings; - -namespace Application.Features.TokenKeyBindings.Services; - -/// -/// Service responsible for managing key bindings between instances and RSA signing keys. -/// -/// -/// -/// Creates new key bindings linking a developer token to a signing key. -/// Supports rebinding an existing token to a new signing key. -/// Allows updating public keys without changing the signing key ID. -/// Handles revocation of key bindings and listing or retrieving bindings by token ID. -/// -/// -public class KeyBindingService(IKeyBindingRepository repository) : IKeyBindingService -{ - /// - public Task CreateBindingAsync(Guid tokenId, string signingKeyId, string publicKey) - { - var binding = new TokenKeyBinding(tokenId, signingKeyId, publicKey); - return repository.AddAsync(binding); - } - - /// - public async Task RebindAsync(Guid tokenId, string signingKeyId, string newSigningKeyId, string newPublicKey) - { - var binding = await repository.GetAsync(tokenId, signingKeyId); - if (binding == null) return null; - - var updated = binding.Rebind(newSigningKeyId, newPublicKey); - await repository.UpdateAsync(updated); - return updated; - } - - /// - public async Task UpdatePublicKeyAsync(Guid tokenId, string signingKeyId, string newPublicKey) - { - var binding = await repository.GetAsync(tokenId, signingKeyId); - if (binding == null) return null; - - var updated = binding.UpdatePublicKey(newPublicKey); - await repository.UpdateAsync(updated); - return updated; - } - /// - public async Task RevokeAsync(string tokenId) - { - var bindings = await repository.ListByTokenAsync(Guid.Parse(tokenId)); - var anyUpdated = false; - - foreach (var b in bindings) - { - if (b.Revoked) continue; - var revoked = b.Revoke(); - await repository.UpdateAsync(revoked); - anyUpdated = true; - } - - return anyUpdated; - } - - - /// - public Task> ListBindingsAsync(Guid tokenId) - => repository.ListByTokenAsync(tokenId); - - /// - public Task GetBindingAsync(Guid tokenId, string signingKeyId) - => repository.GetAsync(tokenId, signingKeyId); -} \ No newline at end of file diff --git a/Application/Options/ErrorMetadataOptions.cs b/Application/Options/ErrorMetadataOptions.cs deleted file mode 100644 index 47f863a..0000000 --- a/Application/Options/ErrorMetadataOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Application.Options; - -/// -/// Configuration options for error documentation metadata. -/// -public record ErrorMetadataOptions -{ - public string DocsBaseUrl { get; init; } = "https://localhost:8080/errors"; -} \ No newline at end of file diff --git a/AuthKit.sln.DotSettings b/AuthKit.sln.DotSettings new file mode 100644 index 0000000..5aebc62 --- /dev/null +++ b/AuthKit.sln.DotSettings @@ -0,0 +1,3 @@ + + True + True \ No newline at end of file diff --git a/AuthKit.slnx b/AuthKit.slnx new file mode 100644 index 0000000..1b00431 --- /dev/null +++ b/AuthKit.slnx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c9c6aba --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,82 @@ +# Code of Conduct + +AuthKit is committed to providing respectful, professional, practical, and safe environment for everyone who contributes to or participates in the project. + +## Our Standards + +Examples of behavior that contribute to a positive environment include: + +* using respectful language and tone +* focusing on technical issues rather than individuals +* assuming good intent while asking for clarification when necessary +* giving and receiving constructive feedback +* keeping discussions relevant to the project +* respecting different technical opinions and approaches +* accepting decisions made by maintainers in good faith + +Examples of unacceptable behavior include: + +* harassment, discrimination, or intimidation +* personal attacks, threats, or abusive behavior +* deliberately insulting or humiliating other participants +* doxxing or sharing private or personally identifying information without permission +* sustained or repeated disruption of project discussions +* unwanted sexual or otherwise inappropriate conduct +* any other behavior that creates an unsafe or hostile environment + +## Scope + +This Code of Conduct applies to all project spaces and interactions related to AuthKit, including: + +* issues +* pull requests +* code reviews +* GitHub discussions +* project-related public channels +* other spaces where participants are representing or directly interacting with the AuthKit project + +The same standards are expected to apply regardless of a person's role, including contributors, maintainers, reviewers, and community members. + +## Reporting + +If you experience or witness unacceptable behavior, report it privately to the project maintainers. + +When possible, include: + +* a description of what happened +* relevant links, screenshots, or timestamps +* the people involved +* any other context that may help with the investigation + +Please avoid publicly discussing reports involving personal or sensitive information. + +Reports will be reviewed as promptly as reasonably possible and handled with appropriate confidentiality. + +If report concerns maintainer, that maintainer should not participate in reviewing or deciding the outcome of the report. + +## Enforcement + +Maintainers are responsible for interpreting and enforcing this Code of Conduct. + +Depending on the severity, frequency, and impact of the behavior, possible actions include: + +* a private or public warning +* requesting that a participant stop specific behavior +* temporary restriction from project spaces or interactions +* removal of comments or other project content +* temporary suspension of participation +* permanent removal from project spaces + +Enforcement decisions will be proportionate to the circumstances and may take into account previous incidents. + +Maintainers may also take immediate action when necessary to protect participants or the project environment. + +## False or Malicious Reports + +Reports should be made in good faith. + +Knowingly submitting false allegations, fabricating evidence, or using the reporting process to harass or target another participant may itself constitute violation of this Code of Conduct. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c7feb8e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,148 @@ +# Contributing to AuthKit + +Thank you for contributing to AuthKit. + +Small, focused changes are easier to review, test, and maintain. Contributions should aim to improve the project without introducing unnecessary complexity or unrelated changes. + +Please read and follow the [Code of Conduct](CODE_OF_CONDUCT.md) before contributing. + +## Before You Start + +Before opening an issue or pull request: + +* check existing issues and pull requests +* make sure the change is not already being worked on +* keep the change focused on one problem or feature +* explain why a behavioral change is needed when it is not self-evident +* avoid unrelated refactoring or cleanup + +For larger changes, it is recommended to open an issue first to discuss the proposed approach. + +## Local Setup + +AuthKit uses the standard .NET toolchain. + +Build the solution with: + +```bash +dotnet build AuthKit.slnx +``` + +Run the test suite with: + +```bash +dotnet test AuthKit.slnx +``` + +If your change affects token issuance, validation, authentication, authorization, persistence, or integration behavior, make sure the relevant tests are run and the affected flow is verified locally. + +## Branches + +Create branches from `main`. + +Use short, descriptive branch names that indicate the purpose of the change. + +Examples: + +```bash +git checkout -b fix/auth-store +git checkout -b feat/token-rotation +git checkout -b refactor/security-middleware +``` + +Avoid mixing unrelated changes in the same branch. + +## Code Style + +Follow the existing conventions in the surrounding code. + +In particular: + +* keep the style already used in the file +* prefer small, incremental changes over broad rewrites +* match existing naming and project structure +* keep APIs and abstractions as simple as possible +* introduce abstractions only when they solve a real problem +* avoid speculative features or infrastructure +* preserve existing behavior unless the change explicitly requires otherwise + +When adding new security-sensitive behavior, favor explicit and auditable code over unnecessary abstraction. + +## Tests + +Changes should include appropriate tests when practical. + +Tests are especially important for changes involving: + +* authentication +* authorization +* token issuance +* token validation +* token storage +* token expiration or revocation +* cryptographic operations +* Keycloak integration +* middleware and access enforcement + +Bug fixes should preferably include regression test demonstrating the original problem. + +## Pull Requests + +A pull request should clearly describe: + +* what changed +* why it changed +* how it was tested +* any relevant design decisions +* linked issues or discussions, if applicable + +For behavioral or security-sensitive changes, explain the relevant flow and any assumptions that reviewers should be aware of. + +Keep pull requests small and focused whenever possible. Smaller changes are easier to review, test, and merge. + +Pull requests may be requested to include additional tests, documentation, or design changes before they are merged. + +## Security Sensitive Changes + +Do not disclose security vulnerabilities through normal issues or pull requests. + +If you discover potential security vulnerability in AuthKit, follow the process described in [SECURITY.md](SECURITY.md). + +Do not commit: + +* passwords +* API keys +* access tokens +* private keys +* credentials +* production configuration containing secrets +* other sensitive authentication material + +Use local configuration or environment variables for development secrets. + +## Reporting Issues + +For normal bugs, open a GitHub issue and include: + +* what you expected to happen +* what happened instead +* steps to reproduce the issue +* relevant logs or error messages +* your operating system and .NET version when relevant +* the affected AuthKit version or commit when known + +Please remove secrets, credentials, tokens, and other sensitive information before posting logs or configuration. + +**Do not use public issues to report security vulnerabilities.** See [SECURITY.md](SECURITY.md) instead. + +## Documentation + +Changes that modify public APIs, configuration, authentication flows, or user facing behavior should include corresponding documentation updates when appropriate. + +Documentation should remain consistent with the actual implementation. + +## License + +By contributing to AuthKit, you agree that your contribution will be licensed under the project's [MIT License + Commons Clause](../LICENSE). + +Please make sure you have the right to submit the contribution under these terms. diff --git a/keycloak/realm-authz.json b/Deploy/Keycloak/realms/realm-authz.json similarity index 100% rename from keycloak/realm-authz.json rename to Deploy/Keycloak/realms/realm-authz.json diff --git a/keycloak/workspace-authz-authz-config.json b/Deploy/Keycloak/realms/workspace-authz-authz-config.json similarity index 100% rename from keycloak/workspace-authz-authz-config.json rename to Deploy/Keycloak/realms/workspace-authz-authz-config.json diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..67c0ae6 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,29 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Dockerfile b/Dockerfile index bf169d6..d1c00dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,22 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app EXPOSE 80 +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src -COPY ["RyzeSDK.AuthKit.sln", "."] +COPY ["AuthKit.slnx", "."] +COPY ["Directory.Packages.props", "."] -COPY ["Host/Host.csproj", "Host/"] -COPY ["Application/Application.csproj", "Application/"] -COPY ["Domain/Domain.csproj", "Domain/"] -COPY ["Infrastructure/Infrastructure.csproj", "Infrastructure/"] +COPY ["src/Host/Host.csproj", "src/Host/"] +COPY ["Core/Core.csproj", "Core/"] +COPY ["src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj", "src/Plugins/Abstractions/"] +COPY ["src/Plugins/Solutions/DevTokens/DevTokens.csproj", "src/Plugins/Solutions/DevTokens/"] -RUN dotnet restore "RyzeSDK.AuthKit.sln" +RUN dotnet restore "AuthKit.slnx" COPY . . RUN mkdir /root/certs @@ -21,7 +25,9 @@ WORKDIR "/src/Host" RUN dotnet build "Host.csproj" -c Release -o /app/build FROM build AS publish -RUN dotnet publish "Host.csproj" -c Release -o /app/publish +WORKDIR /src +RUN dotnet publish "src/Host/Host.csproj" -c Release -o /app/publish +RUN dotnet publish "src/Plugins/Solutions/DevTokens/DevTokens.csproj" -c Release -o /app/publish/plugins/DevTokens FROM base AS final WORKDIR /app diff --git a/Docs/README.md b/Docs/README.md new file mode 100644 index 0000000..165fd20 --- /dev/null +++ b/Docs/README.md @@ -0,0 +1,9 @@ +# AuthKit Documentation + +This is the documentation index for AuthKit. + +| Topic | Link | +| --- | --- | +| Schemas & Diagrams | [Schemas](Schemas.md) | +| Contributing | [Contributing](../CONTRIBUTING.md) | +| Security | [Security](../SECURITY.md) | diff --git a/Docs/Schemas.md b/Docs/Schemas.md new file mode 100644 index 0000000..e3d160b --- /dev/null +++ b/Docs/Schemas.md @@ -0,0 +1,92 @@ +# AuthKit Schemas + +This document contains the Mermaid diagrams describing AuthKit's token flows and architecture. + +## Token Issuance & SDK Request Flow +```mermaid +sequenceDiagram + participant Dev as Developer + participant Keycloak as Keycloak + participant AuthKit as AuthKit API + participant SDK as AuthKitSdkClient + participant API as Target API (e.g. Marketplace) + + Dev->>Keycloak: Authenticate via Keycloak (JWT access token) + Keycloak-->>Dev: Returns access_token (Keycloak JWT) + + Dev->>AuthKit: Request Developer Token
Authorization: Bearer + AuthKit->>AuthKit: Validate Keycloak token
and create DeveloperToken (JWT) + AuthKit-->>Dev: Returns X-Developer-Token (AuthKit JWT) + + SDK->>API: Request with
Authorization: Bearer
X-Developer-Token: + API->>AuthKit: Validate developer token via REST + AuthKit->>Keycloak: Validate user session & roles + AuthKit-->>API: DeveloperToken valid ✅ + API-->>SDK: 200 OK — Operation authorized +``` + +## External REST vs Internal gRPC Call Flow +```mermaid +flowchart TD + Dev[Developer] -->|Authenticate| Keycloak[Keycloak JWT] + Keycloak --> Dev + + Dev -->|Request Dev Token| AuthKit[AuthKit API] + AuthKit -->|Validate Keycloak token| Keycloak + AuthKit -->|Return Dev Token| Dev + + Dev -->|Request Service Token| AuthKitService[AuthKit API] + AuthKitService -->|Validate Dev Token| AuthKit + AuthKitService -->|Return Service Token| Dev + + Dev -->|Configure SDK| SDK[AuthKitSdkClient] + + SDK -->|REST request| API_REST[Target API REST] + SDK -->|gRPC request| API_GRPC[Target API gRPC] + + subgraph REST_Flow + API_REST -->|Pass tokens to middleware| AuthKit_REST[AuthKit Middleware REST] + AuthKit_REST -->|Validate Dev & Service Tokens| TokenDB[Token Database] + AuthKit_REST -->|Validate Keycloak JWT| Keycloak + AuthKit_REST -->|Return auth result| API_REST + API_REST -->|200 OK / 403 Forbidden| SDK + end + + subgraph gRPC_Flow + API_GRPC -->|Pass tokens to middleware| AuthKit_GRPC[AuthKit Middleware gRPC] + AuthKit_GRPC -->|Validate Dev & Service Tokens| TokenDB + AuthKit_GRPC -->|Validate Keycloak JWT| Keycloak + AuthKit_GRPC -->|Return auth result| API_GRPC + API_GRPC -->|200 OK / 403 Forbidden| SDK + end +``` + +## SDK Function Call Flow (AuthKit → gRPC Microservices) +```mermaid +flowchart TD + classDef token fill:#fef3c7,stroke:#f59e0b,stroke-width:1px,color:#b45309; + classDef service fill:#fef3c7,stroke:#fef3c7,stroke-width:1px,color:#92400e; + classDef internal fill:#dbeafe,stroke:#3b82f6,stroke-width:1px,color:#1e40af; + +%% Actors + Dev[Developer] -->|Has Keycloak JWT,
DeveloperToken,
ServiceToken| SDK[AuthKitSdkClient] + class Dev,SDK token; + +%% REST request from external SDK + SDK -->|REST request with tokens| API_Controller[API Controller] + API_Controller -->|Validate tokens internally via AuthKit| AuthKit[AuthKit API] + +%% gRPC requests + API_Controller -->|gRPC call| GRPC_Service[gRPC Service] + GRPC_Service -->|gRPC call to other microservice| AnotherService_GRPC[Another gRPC Service] + +%% Responses + GRPC_Service -->|Return result| API_Controller + AnotherService_GRPC -->|Return result| GRPC_Service + API_Controller -->|Return response| SDK + +%% Styling + class Dev,SDK,AuthKit token; + class API_Controller,GRPC_Service service; + class AnotherService_GRPC internal; +``` diff --git a/Domain/DomainException.cs b/Domain/DomainException.cs deleted file mode 100644 index 1ba1968..0000000 --- a/Domain/DomainException.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Domain; - -/// -/// Base class for all domain-specific exceptions. -/// -public abstract class DomainException(string message) : Exception(message); \ No newline at end of file diff --git a/Domain/Features/DeveloperTokens/DeveloperToken.cs b/Domain/Features/DeveloperTokens/DeveloperToken.cs deleted file mode 100644 index db85464..0000000 --- a/Domain/Features/DeveloperTokens/DeveloperToken.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Domain.Features.DeveloperTokens.ValueObject; -using Domain.Features.TokenKeyBindings; - -namespace Domain.Features.DeveloperTokens; - -/// -/// Represents a developer-issued token with a name, scopes, and lifetime. -/// -/// -/// -/// Each token has a unique . -/// Associated with a specific developer via . -/// Contains a and a list of . -/// Has a defining creation and optional expiration. -/// Supports creation, renewal, and adding scopes immutably. -/// -/// -public record DeveloperToken -{ - public Guid Id { get; } = Guid.NewGuid(); - public Guid DeveloperId { get; init; } - - public TokenName Name { get; init; } = new("default"); - public IReadOnlyList Scopes { get; init; } = []; - public IReadOnlyList KeyBindings { get; init; } = []; - public TokenLifetime Lifetime { get; init; } = new(DateTimeOffset.UtcNow); - - public static DeveloperToken Create( - Guid developerId, - TokenName name, - IEnumerable scopes, - TimeSpan? lifetime = null) - { - var now = DateTimeOffset.UtcNow; - - var tokenLifetime = new TokenLifetime( - now, - lifetime.HasValue ? now.Add(lifetime.Value) : null - ); - - return new DeveloperToken - { - DeveloperId = developerId, - Name = name, - Scopes = scopes.ToList().AsReadOnly(), - Lifetime = tokenLifetime - }; - } - - public DeveloperToken Renew(TimeSpan extension) - { - var newLifetime = new TokenLifetime(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.Add(extension)); - return this with { Lifetime = newLifetime }; - } - - public DeveloperToken AddScope(TokenScope scope) - { - var newScopes = Scopes.Append(scope).ToList().AsReadOnly(); - return this with { Scopes = newScopes }; - } - - /// - /// Adds a new signing key binding to this token. - /// - public DeveloperToken AddKeyBinding(TokenKeyBinding binding) - { - var newBindings = KeyBindings.Append(binding).ToList().AsReadOnly(); - return this with { KeyBindings = newBindings }; - } - - /// - /// Replaces an existing key binding by SigningKeyId. - /// - public DeveloperToken ReplaceKeyBinding(string signingKeyId, TokenKeyBinding newBinding) - { - var newBindings = KeyBindings - .Select(k => k.SigningKeyId == signingKeyId ? newBinding : k) - .ToList() - .AsReadOnly(); - return this with { KeyBindings = newBindings }; - } -} \ No newline at end of file diff --git a/Domain/Features/DeveloperTokens/ValueObject/TokenLifetime.cs b/Domain/Features/DeveloperTokens/ValueObject/TokenLifetime.cs deleted file mode 100644 index bf3b5d4..0000000 --- a/Domain/Features/DeveloperTokens/ValueObject/TokenLifetime.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace Domain.Features.DeveloperTokens.ValueObject; - -/// -/// Represents the lifetime of a token, including its creation and optional expiration dates. -/// -/// -/// -/// indicates when the token was created. -/// indicates when the token will expire. Null means the token never expires. -/// checks if the token is expired based on the current UTC time. -/// returns the remaining time until expiration, or null if the token never expires. -/// -/// -public sealed record TokenLifetime -{ - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? ExpiresAt { get; init; } - - public TokenLifetime(DateTimeOffset createdAt, DateTimeOffset? expiresAt = null) - { - if (expiresAt.HasValue && expiresAt.Value < createdAt) - throw new ArgumentException("Expiration date cannot be earlier than creation date.", nameof(expiresAt)); - - CreatedAt = createdAt; - ExpiresAt = expiresAt; - } - - public int? Days => ExpiresAt.HasValue - ? (int?)(ExpiresAt.Value - CreatedAt).TotalDays - : null; - - public bool IsExpired => ExpiresAt.HasValue && DateTimeOffset.UtcNow > ExpiresAt.Value; - - public TimeSpan? Remaining => ExpiresAt.HasValue - ? ExpiresAt.Value - DateTimeOffset.UtcNow - : null; - - public override string ToString() => ExpiresAt.HasValue - ? $"{CreatedAt:u} - {ExpiresAt:u}" - : $"{CreatedAt:u} - never"; -} \ No newline at end of file diff --git a/Domain/Features/DeveloperTokens/ValueObject/TokenName.cs b/Domain/Features/DeveloperTokens/ValueObject/TokenName.cs deleted file mode 100644 index 208bd80..0000000 --- a/Domain/Features/DeveloperTokens/ValueObject/TokenName.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Domain.Features.DeveloperTokens.ValueObject; - -/// -/// Represents the name of a token. -/// -/// -/// -/// The value cannot be null, empty, or consist only of whitespace. -/// The length of the value is limited to 100 characters. -/// The value is automatically trimmed during object creation. -/// Supports implicit conversion from to . -/// -/// -public readonly record struct TokenName(string Value) -{ - public override string ToString() => Value; - public static implicit operator string(TokenName t) => t.Value; - public static implicit operator TokenName(string s) => new(s); -} diff --git a/Domain/Features/DeveloperTokens/ValueObject/TokenScope.cs b/Domain/Features/DeveloperTokens/ValueObject/TokenScope.cs deleted file mode 100644 index e272822..0000000 --- a/Domain/Features/DeveloperTokens/ValueObject/TokenScope.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Domain.Features.DeveloperTokens.ValueObject; - -/// -/// Represents a scope assigned to a token. -/// -/// -/// -/// The scope name cannot be null, empty, or whitespace. -/// The name is automatically trimmed and converted to lowercase invariant. -/// Supports implicit conversion from to . -/// -/// -public readonly record struct TokenScope -{ - public string Value { get; } - - [JsonConstructor] - public TokenScope(string value) - { - if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentException("Scope name cannot be null or empty.", nameof(value)); - - Value = value.Trim().ToLowerInvariant(); - } - - public static implicit operator TokenScope(string s) => new(s); - public static implicit operator string(TokenScope s) => s.Value; - - public override string ToString() => Value; -} \ No newline at end of file diff --git a/Domain/Features/KeyManagement/Entity/SigningKey.cs b/Domain/Features/KeyManagement/Entity/SigningKey.cs deleted file mode 100644 index b4c8d05..0000000 --- a/Domain/Features/KeyManagement/Entity/SigningKey.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace Domain.Features.KeyManagement.Entity; - -public sealed record SigningKey( - Guid Id, // kid - string PublicKeyPem, - string PrivateKeyEncrypted, - string Algorithm, - DateTime CreatedAt, - DateTime? NotBefore, - DateTime? ExpiresAt, - DateTime? RevokedAt, - bool IsActive -) -{ - public bool IsValidForSigning(DateTime now) - => IsActive && - RevokedAt is null && - (NotBefore is null || now >= NotBefore) && - (ExpiresAt is null || now < ExpiresAt); - - public SigningKey Activate(DateTime now) - => this with - { - IsActive = true, - NotBefore = now, - RevokedAt = null - }; - - public SigningKey Revoke(DateTime now) - => this with - { - IsActive = false, - RevokedAt = now - }; - - public SigningKey Expire(DateTime now) - => this with - { - IsActive = false, - ExpiresAt = now - }; -} \ No newline at end of file diff --git a/Domain/Features/TokenKeyBindings/TokenKeyBinding.cs b/Domain/Features/TokenKeyBindings/TokenKeyBinding.cs deleted file mode 100644 index 8238ecc..0000000 --- a/Domain/Features/TokenKeyBindings/TokenKeyBinding.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Domain.Features.DeveloperTokens; - -namespace Domain.Features.TokenKeyBindings; - -/// -/// Represents a binding between a and the RSA signing key used to sign its JWT. -/// -/// -/// -/// Each binding associates a developer token with a specific signing key (identified by ). -/// Contains the public key used for verification in JWKS endpoints. -/// Supports re-binding in case of key rotation or revocation. -/// -/// -public record TokenKeyBinding -{ - public Guid TokenId { get; init; } - public string SigningKeyId { get; private set; } = null!; - public string PublicKey { get; private set; } = null!; - public DateTimeOffset BoundAt { get; private set; } - public bool Revoked { get; private set; } - - public TokenKeyBinding(Guid tokenId, string signingKeyId, string publicKey) - { - TokenId = tokenId; - SigningKeyId = signingKeyId ?? throw new ArgumentNullException(nameof(signingKeyId)); - PublicKey = publicKey ?? throw new ArgumentNullException(nameof(publicKey)); - BoundAt = DateTimeOffset.UtcNow; - } - - /// - /// Rebinds this token to a new RSA signing key. - /// - public TokenKeyBinding Rebind(string newSigningKeyId, string newPublicKey) - => this with { SigningKeyId = newSigningKeyId, PublicKey = newPublicKey, BoundAt = DateTimeOffset.UtcNow }; - - /// - /// Updates the public key without changing the key ID. - /// - public TokenKeyBinding UpdatePublicKey(string updatedPublicKey) - => this with { PublicKey = updatedPublicKey, BoundAt = DateTimeOffset.UtcNow }; - - public TokenKeyBinding Revoke() - => this with { Revoked = true, BoundAt = DateTimeOffset.UtcNow }; -} diff --git a/Host/Configuration/AppMiddlewareConfiguration.cs b/Host/Configuration/AppMiddlewareConfiguration.cs deleted file mode 100644 index 7ee6bb4..0000000 --- a/Host/Configuration/AppMiddlewareConfiguration.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Infrastructure.Restful.Middleware; -using Infrastructure.Restful.Middleware.Exceptions; -using ExceptionHandlingMiddleware = Infrastructure.Restful.Middleware.Exceptions.ExceptionHandlingMiddleware; - -namespace Host.Configuration; - -/// -/// Provides extension methods to configure the application's middleware pipeline. -/// -/// -/// -/// Configures routing, authentication, and authorization middleware. -/// Enables Swagger and SwaggerUI in development environment for API documentation. -/// -/// -public static class AppMiddlewareConfiguration -{ - /// - /// Configures routing, authentication, authorization, and Swagger (in development) for the application. - /// - /// The instance to configure. - /// The configured instance for method chaining. - public static WebApplication ConfigureMiddleware(this WebApplication app) - { - app.UseRouting(); - app.UseMiddleware(); - app.UseMiddleware(); - app.UseMiddleware(); - app.UseAuthentication(); - app.UseAuthorization(); - - - if (!app.Environment.IsDevelopment()) return app; - - app.UseSwagger(); - app.UseSwaggerUI(); - return app; - } -} \ No newline at end of file diff --git a/Host/Configuration/ApplicationInitialization.cs b/Host/Configuration/ApplicationInitialization.cs deleted file mode 100644 index 89cc214..0000000 --- a/Host/Configuration/ApplicationInitialization.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.Reflection; -using Application.Features.DeveloperTokens.UseCase.Commands.Requests; -using Application.Features.KeyManagement.Services; -using Application.Options; -using Domain.Features.DeveloperTokens.ValueObject; -using FluentValidation; -using Infrastructure.Repositories.KeyManagement; -using Infrastructure.Restful.DeveloperTokens; - -namespace Host.Configuration; - -/// -/// Initializes application services, logging, options, HTTP context, validators, and custom dependencies. -/// -/// -/// -/// Registers logging configuration from and console logging. -/// Adds options and for application-wide access. -/// Registers controllers from host and infrastructure assemblies. -/// Scans assemblies to automatically register FluentValidation validators. -/// Registers custom dependency injection for application services, domain interfaces, and Keycloak infrastructure. -/// -/// -public static class ApplicationInitialization -{ - /// - /// Configures the application with logging, options, HTTP context, validators, and custom DI. - /// - /// The to configure. - /// The application for settings. - /// - /// The configured for method chaining. - public static IServiceCollection ConfigureApp( - this IServiceCollection services, - IConfiguration configuration) - { - services.AddLogging(logging => - { - logging.AddConsole(); - logging.AddConfiguration(configuration.GetSection("Logging")); - }); - - services.AddOptions(); - services.AddHttpContextAccessor(); - - services.AddControllers() - .AddApplicationPart(typeof(DeveloperTokensController).Assembly); - - var discoveryLogger = LoggerFactory.Create(builder => - { - builder.AddConsole(); - builder.SetMinimumLevel(LogLevel.Debug); - }).CreateLogger("ServiceDiscovery"); - - services.AddDiscoveredServices(GetRelevantAssemblies(), opts => - { - configuration.GetSection("ServiceDiscovery").Bind(opts); - - opts.ExcludedTypes.Add(typeof(AesKeyEncryptor)); - opts.ExcludedTypes.Add(typeof(RsaKeyGenerator)); - opts.ExcludedTypes.Add(typeof(JwtKeyStore)); - opts.ExcludedTypes.Add(typeof(KeyStoreRepository)); - }, discoveryLogger) - .AddFluentValidation(); - - services.ConfigureAppOptions(configuration); - - return services; - } - - #region Private Options FluentValidation - - extension(IServiceCollection services) - { - private void ConfigureAppOptions(IConfiguration configuration) - { - services.Configure(configuration.GetSection("AuthKit")); - services.Configure(configuration.GetSection("ErrorMetadata")); - } - - private void AddFluentValidation() - { - services.Scan(scan => scan - .FromAssemblies(GetRelevantAssemblies()) - .AddClasses(classes => classes.AssignableTo(typeof(AbstractValidator<>))) - .AsImplementedInterfaces() - .WithScopedLifetime()); - } - } - - #endregion - - #region Private Helpers - private static Assembly[] GetRelevantAssemblies() => - new[] - { - typeof(ApplicationInitialization).Assembly, // Host - typeof(TokenName).Assembly, // Domain - typeof(DeveloperTokensController).Assembly, // Infrastructure - typeof(CreateDeveloperTokenCommand).Assembly // Application - }.Distinct().ToArray(); - #endregion -} \ No newline at end of file diff --git a/Host/Configuration/AuthKitConfiguration.cs b/Host/Configuration/AuthKitConfiguration.cs deleted file mode 100644 index 43cc824..0000000 --- a/Host/Configuration/AuthKitConfiguration.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Application.Features.DeveloperTokens.Interfaces; -using Application.Features.DeveloperTokens.Services; -using Application.Features.KeyManagement.Interfaces; -using Application.Features.KeyManagement.Services; -using Infrastructure.Repositories.KeyManagement; -using Infrastructure.Restful.Middleware.Exceptions; -using Infrastructure.Security; -using Infrastructure.Security.DeveloperScope; -using Microsoft.AspNetCore.Authorization; - -namespace Host.Configuration; - -public static class AuthKitConfiguration -{ - public static void AddAuthKitDeveloperToken(this IServiceCollection services) - { - services.AddHttpContextAccessor(); - - // Authorization pipeline - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - services.AddScoped(); - services.AddScoped(); - - services.AddSingleton(sp => - { - var config = sp.GetRequiredService(); - var key = config["Encryption:AES_MASTER_KEY"] - ?? throw new InvalidOperationException("Missing AES_MASTER_KEY in configuration."); - return new AesKeyEncryptor(key); - }); - - // RSA / JWT key stores - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddHostedService(); - } -} \ No newline at end of file diff --git a/Host/Configuration/Factory/EventHandlerRegistrationFactory.cs b/Host/Configuration/Factory/EventHandlerRegistrationFactory.cs deleted file mode 100644 index 9b51055..0000000 --- a/Host/Configuration/Factory/EventHandlerRegistrationFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Application.Features.DeveloperTokens.UseCase.Commands.Handlers; -using Wolverine; - -namespace Host.Configuration.Factory; - -/// -/// Factory class responsible for registering event handlers with Wolverine. -/// -/// -/// -/// Includes assemblies containing command and event handlers. -/// Enables Wolverine to discover and wire up handlers automatically. -/// Centralizes handler registration to simplify maintenance and discovery. -/// -/// -public static class EventHandlerRegistrationFactory -{ - public static void IncludeEventHandlers(this WolverineOptions opts) - { - opts.Discovery.IncludeAssembly(typeof(CreateDeveloperTokenHandler).Assembly); - } -} \ No newline at end of file diff --git a/Host/Configuration/KeycloakConfiguration.cs b/Host/Configuration/KeycloakConfiguration.cs deleted file mode 100644 index f1a01b1..0000000 --- a/Host/Configuration/KeycloakConfiguration.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using Infrastructure.Security; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization; -using Newtonsoft.Json.Linq; - -namespace Host.Configuration; - -public static class KeycloakConfiguration -{ - public static void AddKeycloakServices(this IServiceCollection services) - { - var baseUrl = Environment.GetEnvironmentVariable("KEYCLOAK_URL") ?? "http://keycloak:8080"; - var realm = Environment.GetEnvironmentVariable("KEYCLOAK_REALM") ?? "authz"; - var clientId = Environment.GetEnvironmentVariable("KEYCLOAK_CLIENT_ID") ?? "workspace-authz"; - - JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); - - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = $"{baseUrl}/realms/{realm}"; - options.Audience = clientId; - options.RequireHttpsMetadata = false; - - options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuers = - [ - "http://localhost:8081/realms/authz", - "http://keycloak:8080/realms/authz" - ], - ValidateAudience = true, - ValidAudience = clientId, - NameClaimType = "preferred_username", - RoleClaimType = ClaimTypes.Role - }; - - options.Events = new JwtBearerEvents - { - OnTokenValidated = ctx => - { - if (ctx.Principal?.Identity is ClaimsIdentity identity) - { - var resourceAccessClaim = identity.FindFirst("resource_access")?.Value; - if (!string.IsNullOrEmpty(resourceAccessClaim)) - { - var resourceAccess = JObject.Parse(resourceAccessClaim); - if (resourceAccess.TryGetValue("workspace-authz", out var workspaceClient)) - { - var roles = workspaceClient["roles"]?.ToObject(); - if (roles != null) - { - var roleClaims = roles - .Select(role => new Claim(ClaimTypes.Role, role)) - .ToList(); - identity.AddClaims(roleClaims); - } - } - } - } - return Task.CompletedTask; - } - }; - - options.BackchannelHttpHandler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = - HttpClientHandler.DangerousAcceptAnyServerCertificateValidator - }; - }); - } -} diff --git a/Host/Configuration/RestfulConfiguration.cs b/Host/Configuration/RestfulConfiguration.cs deleted file mode 100644 index 4605612..0000000 --- a/Host/Configuration/RestfulConfiguration.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Infrastructure.Restful.Controllers; -using Infrastructure.Restful.DeveloperTokens; -using Microsoft.OpenApi.Models; - -namespace Host.Configuration; - -/// -/// Provides extension methods to register RESTful services, controllers, and Swagger in the application. -/// -/// -/// -/// Registers controllers from the assembly. -/// Adds API explorer support for endpoint metadata. -/// Registers Swagger generator for API documentation. -/// -/// -public static class RestfulConfiguration -{ - /// - /// Adds RESTful services and Swagger generation to the provided . - /// - /// The to configure. - /// The configured for method chaining. - public static IServiceCollection AddRestfulServices(this IServiceCollection services) - { - services.AddControllers().AddApplicationPart(typeof(DeveloperTokensController).Assembly); - services.AddEndpointsApiExplorer(); - - services.AddSwaggerGen(c => - { - c.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" }); - c.EnableAnnotations(); - - c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { - Name = "Authorization", - Type = SecuritySchemeType.Http, - Scheme = "Bearer", - BearerFormat = "JWT", - In = ParameterLocation.Header, - Description = "Insert JWT token in the format: Bearer {token}" - }); - - c.AddSecurityDefinition("X-Developer-Token", new OpenApiSecurityScheme - { - Name = "X-Developer-Token", - Type = SecuritySchemeType.ApiKey, - In = ParameterLocation.Header, - Description = "AuthKit developer JWT token" - }); - - c.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } - }, - Array.Empty() - }, - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "X-Developer-Token" } - }, - Array.Empty() - } - }); - }); - - return services; - } -} \ No newline at end of file diff --git a/Host/Host.csproj b/Host/Host.csproj deleted file mode 100644 index b28a769..0000000 --- a/Host/Host.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - net10.0 - 14.0 - enable - enable - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Host/Program.cs b/Host/Program.cs deleted file mode 100644 index 884ede5..0000000 --- a/Host/Program.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Application.Features.TokenKeyBindings.Interfaces; -using Application.Features.TokenKeyBindings.Services; -using Domain.Features.TokenKeyBindings; -using Host.Configuration; -using Infrastructure.Cli; -using Infrastructure.Persistence.KeyBinding; - -var builder = WebApplication.CreateBuilder(args); - -// === Core Config === -builder.Services.AddAuthKitDeveloperToken(); - -builder.Services.ConfigureApp(builder.Configuration) - .AddGrpcServices() - .AddRestfulServices() - .AddKeycloakServices(); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -builder.ConfigureWolverine(); -builder.Services.ConfigureMarten(builder.Configuration); -builder.WebHost.ConfigureKestrelServer(); - -builder.Services.Configure( - builder.Configuration.GetSection("Server") -); - -builder.Services.AddHostedService(); - - -// === Pipeline === -var app = builder.Build(); - -app.ConfigureMiddleware() - .MapAppEndpoints() - .MapGrpcEndpoints(); - -app.Run(); \ No newline at end of file diff --git a/Host/ServiceDiscoveryExtensions.cs b/Host/ServiceDiscoveryExtensions.cs deleted file mode 100644 index bfa3931..0000000 --- a/Host/ServiceDiscoveryExtensions.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Reflection; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Host; - -/// -/// Provides extension methods for automatic discovery and registration of services into the DI container. -/// -public static class ServiceDiscoveryExtensions -{ - /// - /// Scans the specified assemblies for service classes according to - /// and registers them into the dependency injection container. - /// - /// The to register discovered services into. - /// Assemblies to scan for service classes. - /// - /// - /// The original to allow method chaining. - /// - /// - /// Filters types based on allowed or excluded namespaces and types defined in . - /// Skips registration of interfaces or exception types if specified in - /// or . - /// Registers discovered concrete classes as all of their implemented interfaces with the configured lifetime. - /// Automatically adds a to trigger registration during app startup. - /// - /// - public static IServiceCollection AddDiscoveredServices( - this IServiceCollection services, - Assembly[] assemblies, - Action? configure = null, - ILogger? logger = null) - { - var opts = new ServiceDiscoveryOptions(); - configure?.Invoke(opts); - - logger ??= NullLogger.Instance; - - services.Scan(scan => scan - .FromAssemblies(assemblies) - .AddClasses(classes => classes - .Where(type => ServiceDiscoveryFilter.IsValidServiceType(type, logger, opts))) - .AsImplementedInterfaces() - .WithLifetime(opts.Lifetime) - ); - - return services; - } - -} diff --git a/Infrastructure/Cli/AuthKitServerOptions.cs b/Infrastructure/Cli/AuthKitServerOptions.cs deleted file mode 100644 index 98cddd9..0000000 --- a/Infrastructure/Cli/AuthKitServerOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Infrastructure.Cli; - -public sealed class AuthKitServerOptions -{ - public string Host { get; set; } = "http://0.0.0.0:7070"; - public string Issuer { get; set; } = "authkit.local"; - - public StorageOptions Storage { get; set; } = new(); - - public LoggingOptions Logging { get; set; } = new(); - - public sealed class StorageOptions - { - public string Provider { get; set; } = "marten"; - public string ConnectionString { get; set; } = ""; - } - - public sealed class LoggingOptions - { - public bool Enabled { get; set; } = true; - public string Level { get; set; } = "Information"; - } -} \ No newline at end of file diff --git a/Infrastructure/Cli/ServerHost.cs b/Infrastructure/Cli/ServerHost.cs deleted file mode 100644 index 9a78d6f..0000000 --- a/Infrastructure/Cli/ServerHost.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; -using Spectre.Console; - -namespace Infrastructure.Cli; - -public sealed class ServerHost(IOptions opts) : BackgroundService -{ - private readonly AuthKitServerOptions _opts = opts.Value; - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - await Task.Yield(); - - // Panel powitalny - var panel = new Panel($"[bold green]AuthKit Server[/]\n[bold blue]Listening on:[/] {_opts.Host}\n[bold yellow]Issuer:[/] {_opts.Issuer}") - { - Border = BoxBorder.Double, - Padding = new Padding(1, 1), - Header = new PanelHeader("🚀 Server Started") - }; - AnsiConsole.Write(panel); - - // Stworzenie tabeli statusu - var table = new Table(); - table.AddColumn("[green]Service[/]"); - table.AddColumn("[yellow]Status[/]"); - table.AddRow("JWT KeyStore", "Initializing..."); - table.AddRow("Developer Tokens", "Initializing..."); - AnsiConsole.Write(table); - - // Pętla serwera - while (!stoppingToken.IsCancellationRequested) - { - // Można tu np. aktualizować statusy usług - table.Rows.Clear(); - table.AddRow("JWT KeyStore", "[green]Ready[/]"); - table.AddRow("Developer Tokens", "[green]Ready[/]"); - AnsiConsole.Write(new Panel(table) { Border = BoxBorder.Rounded }); - - await Task.Delay(2000, stoppingToken); // odświeżanie co 2 sekundy - } - - AnsiConsole.MarkupLine("[red]Server shutting down...[/]"); - } -} \ No newline at end of file diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj deleted file mode 100644 index bb2ed08..0000000 --- a/Infrastructure/Infrastructure.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - net10.0 - preview - enable - enable - - - - - - - - - all - - - - - - - - - - - - - \ No newline at end of file diff --git a/Infrastructure/Repositories/DeveloperTokens/DeveloperTokenRepository.cs b/Infrastructure/Repositories/DeveloperTokens/DeveloperTokenRepository.cs deleted file mode 100644 index 22f717c..0000000 --- a/Infrastructure/Repositories/DeveloperTokens/DeveloperTokenRepository.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Domain.Features.DeveloperTokens; -using Domain.Features.DeveloperTokens.Repositories; -using Marten; - -namespace Infrastructure.Repositories.DeveloperTokens; - -/// -/// Repository for managing persistence using Marten. -/// -/// -/// -/// Stores, deletes, and retrieves developer tokens from the document database. -/// Implements . -/// -/// -public class DeveloperTokenRepository(IDocumentSession session) : IDeveloperTokenRepository -{ - /// - public async Task SaveAsync(DeveloperToken token, CancellationToken ct = default) - { - session.Store(token); - await session.SaveChangesAsync(ct); - } - - /// - public async Task DeleteAsync(Guid id, CancellationToken ct = default) - { - session.Delete(id); - await session.SaveChangesAsync(ct); - } - - /// - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - => await session.LoadAsync(id, ct); - - /// - public async Task> GetByDeveloperIdAsync(Guid developerId, CancellationToken ct = default) - => await session.Query() - .Where(x => x.DeveloperId == developerId) - .ToListAsync(ct); -} \ No newline at end of file diff --git a/Infrastructure/Repositories/KeyBinding/InMemoryKeyBindingRepository.cs b/Infrastructure/Repositories/KeyBinding/InMemoryKeyBindingRepository.cs deleted file mode 100644 index b3e42f9..0000000 --- a/Infrastructure/Repositories/KeyBinding/InMemoryKeyBindingRepository.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Diagnostics; -using Domain.Features.TokenKeyBindings; - -namespace Infrastructure.Persistence.KeyBinding; - -/// -/// In-memory repository for entities. -/// -/// -/// -/// Primarily used for testing, local development, or scenarios without a persistent database. -/// Thread-safe via internal locking on all operations. -/// Logs all operations for debugging and traceability. -/// -/// -public class InMemoryKeyBindingRepository : IKeyBindingRepository -{ - private readonly List _store = []; - private readonly Lock _lock = new(); - - private static void DebugLog(string message) - { - Debug.WriteLine($"[KeyBindingRepo] {message}"); - } - - public Task AddAsync(TokenKeyBinding binding) - { - lock (_lock) - { - _store.Add(binding); - } - DebugLog($"Added binding: TokenId={binding.TokenId}, SigningKeyId={binding.SigningKeyId}"); - return Task.FromResult(binding); - } - - public Task GetAsync(Guid tokenId, string signingKeyId) - { - TokenKeyBinding? found; - lock (_lock) - { - found = _store.FirstOrDefault(b => b.TokenId == tokenId && b.SigningKeyId == signingKeyId); - } - DebugLog(found != null - ? $"Found binding for TokenId={tokenId}, SigningKeyId={signingKeyId}" - : $"No binding found for TokenId={tokenId}, SigningKeyId={signingKeyId}"); - return Task.FromResult(found); - } - - public Task UpdateAsync(TokenKeyBinding binding) - { - lock (_lock) - { - var index = _store.FindIndex(b => b.TokenId == binding.TokenId && b.SigningKeyId == binding.SigningKeyId); - if (index >= 0) - { - _store[index] = binding; - DebugLog($"Updated binding: TokenId={binding.TokenId}, SigningKeyId={binding.SigningKeyId}"); - } - else - { - DebugLog($"Attempted to update non-existing binding: TokenId={binding.TokenId}, SigningKeyId={binding.SigningKeyId}"); - } - } - return Task.CompletedTask; - } - - public Task> ListByTokenAsync(Guid tokenId) - { - IEnumerable result; - lock (_lock) - { - result = _store.Where(b => b.TokenId == tokenId).ToList(); - } - DebugLog($"Listed {result.Count()} bindings for TokenId={tokenId}"); - return Task.FromResult(result); - } -} \ No newline at end of file diff --git a/Infrastructure/Repositories/KeyManagement/KeyStoreRepository.cs b/Infrastructure/Repositories/KeyManagement/KeyStoreRepository.cs deleted file mode 100644 index ddd3c84..0000000 --- a/Infrastructure/Repositories/KeyManagement/KeyStoreRepository.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Application.Features.KeyManagement.Interfaces; -using Marten; - -namespace Infrastructure.Repositories.KeyManagement; - -public sealed class KeyStoreRepository(IDocumentSession session) : IKeyStoreRepository -{ - private const string DocumentId = "singleton"; - - public async Task> LoadAsync() - { - var doc = await session.LoadAsync(DocumentId); - if (doc == null || doc.EncryptedData.Length == 0) - return Memory.Empty; - - return doc.EncryptedData; - } - - public async Task SaveAsync(ReadOnlyMemory data) - { - var doc = await session.LoadAsync(DocumentId); - if (doc == null) - { - doc = new KeystoreDocument { Id = DocumentId, EncryptedData = data.ToArray() }; - session.Store(doc); - } - else - { - doc.EncryptedData = data.ToArray(); - } - - await session.SaveChangesAsync(); - } - - public sealed class KeystoreDocument - { - public string Id { get; set; } = null!; - public byte[] EncryptedData { get; set; } = null!; - } -} \ No newline at end of file diff --git a/Infrastructure/Restful/Controllers/TokenLifecycleController.cs b/Infrastructure/Restful/Controllers/TokenLifecycleController.cs deleted file mode 100644 index 6ae5feb..0000000 --- a/Infrastructure/Restful/Controllers/TokenLifecycleController.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Application.Features.DeveloperTokens.DTO; -using Infrastructure.Restful.DTO; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Swashbuckle.AspNetCore.Annotations; - -namespace Infrastructure.Restful.Controllers; - -[ApiController] -[Route("sdk/tokens")] -[SwaggerTag("Developer token lifecycle operations: rotate, revoke, verify")] -public class TokenLifecycleController : ControllerBase -{ - /// - /// Revoke an existing token and generate a new one (rotate). - /// - /// ID of the token to revoke and rotate. - /// New JWT and developer-friendly short key. - [HttpPost("{tokenId:guid}/revoke-rotate")] - [Authorize(Roles = "User")] - [SwaggerOperation( - Summary = "Revoke and rotate token", - Description = "Revokes the specified token and generates a new JWT and shortKey.")] - [SwaggerResponse(200, "Token rotated successfully", typeof(DeveloperTokenCreated))] - [SwaggerResponse(401, "Unauthorized")] - [SwaggerResponse(404, "Token not found")] - public async Task RevokeAndRotate(Guid tokenId, CancellationToken ct) - { - return null; - } - - /// - /// Verify a JWT against a stored developer token. - /// - /// Contains JWT and shortKey to verify. - /// True if the token is valid, false otherwise. - [HttpPost("verify")] - [SwaggerOperation( - Summary = "Verify token validity", - Description = "Verifies that the provided JWT matches the stored token hash.")] - [SwaggerResponse(200, "Verification result", typeof(bool))] - [SwaggerResponse(401, "Unauthorized")] - public IActionResult Verify([FromBody] VerifyTokenRequest request) - { - return null; - } -} \ No newline at end of file diff --git a/Infrastructure/Restful/DTO/VerifyTokenRequest.cs b/Infrastructure/Restful/DTO/VerifyTokenRequest.cs deleted file mode 100644 index 41384f7..0000000 --- a/Infrastructure/Restful/DTO/VerifyTokenRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Infrastructure.Restful.DTO; - -/// -/// Request DTO for verifying a developer token using its key. -/// -public record VerifyTokenRequest( - string Key -); \ No newline at end of file diff --git a/Infrastructure/Restful/DeveloperTokens/DTO/CreateTokenRequest.cs b/Infrastructure/Restful/DeveloperTokens/DTO/CreateTokenRequest.cs deleted file mode 100644 index f8b9f73..0000000 --- a/Infrastructure/Restful/DeveloperTokens/DTO/CreateTokenRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Infrastructure.Restful.DeveloperTokens.DTO; - -public record CreateTokenRequest( - string Name, - string Description, - IEnumerable Scopes, - int? LifetimeDays -); \ No newline at end of file diff --git a/Infrastructure/Restful/DeveloperTokens/DeveloperTokensController.cs b/Infrastructure/Restful/DeveloperTokens/DeveloperTokensController.cs deleted file mode 100644 index 2265fd0..0000000 --- a/Infrastructure/Restful/DeveloperTokens/DeveloperTokensController.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System.Security.Claims; -using Application.Features.DeveloperTokens.DTO; -using Application.Features.DeveloperTokens.UseCase.Commands.Requests; -using Application.Features.DeveloperTokens.UseCase.Queries.Requests; -using Infrastructure.Restful.DeveloperTokens.DTO; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Swashbuckle.AspNetCore.Annotations; -using Wolverine; - -namespace Infrastructure.Restful.DeveloperTokens; - -/// -/// REST API controller for managing developer tokens. -/// -/// -/// -/// Supports creation, deletion, and retrieval of developer tokens. -/// Integrates with Wolverine message bus for CQRS command/query handling. -/// Requires authenticated users with role User. -/// -/// -[ApiController] -[Route("sdk/developer-tokens")] -[SwaggerTag("Operations related to users")] -public class DeveloperTokensController(IMessageBus messageBus, ILogger logger) : ControllerBase -{ - private Guid? GetUserId() => - Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; - - /// - /// Registers a new developer token. - /// - /// The request containing token name, description, scopes, and optional lifetime. - /// HTTP 200 with created token info, or 401 if unauthorized. - [HttpPost] - [Authorize(Roles = "User")] - [SwaggerOperation( - Summary = "Registers a new developer token", - Description = "Creates a developer token for the authenticated user. Requires token name, description, scopes, and optional lifetime in days.")] - [SwaggerResponse(200, "Returns the created developer token", typeof(DeveloperTokenCreated))] - [SwaggerResponse(401, "Unauthorized")] - public async Task Register([FromBody] CreateTokenRequest request) - { - var userId = GetUserId(); - if (userId is null) - return Unauthorized(); - - var lifetime = request.LifetimeDays.HasValue - ? TimeSpan.FromDays(request.LifetimeDays.Value) - : (TimeSpan?)null; - - var cmd = new CreateDeveloperTokenCommand( - (Guid)userId, - request.Name, - request.Description, - request.Scopes, - lifetime - ); - var result = await messageBus.InvokeAsync(cmd); - - logger.LogInformation("Token created: TokenId={TokenId}, DeveloperId={DeveloperId}", - result.Token.Id, result.Token.DeveloperId); - - return Ok(new - { - jwt = result.Jwt, - key = result.ShortKey, - id = result.Token.Id, - developerId = result.Token.DeveloperId, - scopes = result.Token.Scopes, - lifetime = result.Token.Lifetime, - }); - } - - /// - /// Deletes an existing developer token by ID. - /// - /// The unique identifier of the token to delete. - /// HTTP 200 if deletion succeeded. - [HttpDelete("{tokenId:guid}")] - [Authorize(Roles = "User")] - [SwaggerOperation( - Summary = "Deletes a developer token", - Description = "Deletes the specified developer token by its unique ID.")] - [SwaggerResponse(200, "Token deleted successfully")] - [SwaggerResponse(401, "Unauthorized")] - [SwaggerResponse(404, "Token not found")] - public async Task Delete(Guid tokenId) - { - var cmd = new DeleteTokenCommand(tokenId); - await messageBus.InvokeAsync(cmd); - - logger.LogInformation("Token deleted successfully: TokenId={TokenId}", tokenId); - - return Ok("Token deleted successfully"); - } - - /// - /// Retrieves all developer tokens for the current user. - /// - /// HTTP 200 with a read-only list of or 401 if unauthorized. - [HttpGet] - [Authorize(Roles = "User")] - [SwaggerOperation( - Summary = "Retrieves all developer tokens", - Description = "Returns a read-only list of developer tokens for the authenticated user.")] - [SwaggerResponse(200, "List of developer tokens", typeof(IReadOnlyList))] - [SwaggerResponse(401, "Unauthorized")] - public async Task GetAll() - { - var userId = GetUserId(); - if (userId is null) - return Unauthorized(); - - var query = new GetDeveloperTokensQuery((Guid)userId); - var result = await messageBus.InvokeAsync>(query); - - logger.LogInformation("Retrieved {Count} tokens for DeveloperId={DeveloperId}", result.Count, userId); - return Ok(result); - } - - /// - /// Retrieves a specific developer token by ID. - /// - /// The token ID to retrieve. - /// HTTP 200 with if found, 404 if not found. - [HttpGet("{tokenId:guid}")] - [Authorize(Roles = "User")] - [SwaggerOperation( - Summary = "Retrieves a developer token by ID", - Description = "Returns a single developer token by its unique ID.")] - [SwaggerResponse(200, "Developer token details", typeof(DeveloperTokenDto))] - [SwaggerResponse(401, "Unauthorized")] - [SwaggerResponse(404, "Token not found")] - public async Task GetById(Guid tokenId) - { - var query = new GetDeveloperTokenByIdQuery(tokenId); - var result = await messageBus.InvokeAsync(query); - - if (result is null) - return NotFound(); - - logger.LogInformation("Token retrieved successfully: TokenId={TokenId}", tokenId); - return Ok(result); - } -} \ No newline at end of file diff --git a/Infrastructure/Restful/Middleware/Exceptions/CustomAuthorizationMiddlewareResultHandler .cs b/Infrastructure/Restful/Middleware/Exceptions/CustomAuthorizationMiddlewareResultHandler .cs deleted file mode 100644 index 2ce5749..0000000 --- a/Infrastructure/Restful/Middleware/Exceptions/CustomAuthorizationMiddlewareResultHandler .cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Net; -using System.Text.Json; -using Application.Options; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Authorization.Policy; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Restful.Middleware.Exceptions; - -/// -/// Handles authorization results and returns standardized RFC 7807 ProblemDetails responses for 401 and 403 errors. -/// -/// -/// -/// Returns consistent JSON ProblemDetails for unauthorized and forbidden responses. -/// Leverages for dynamic error documentation URLs. -/// Logs security-related access issues for audit purposes. -/// -/// -public sealed class CustomAuthorizationMiddlewareResultHandler( - IOptions options) : IAuthorizationMiddlewareResultHandler -{ - private readonly AuthorizationMiddlewareResultHandler _defaultHandler = new(); - private readonly string _baseUrl = options.Value.DocsBaseUrl.TrimEnd('/'); - - /// - public async Task HandleAsync( - RequestDelegate next, - HttpContext context, - AuthorizationPolicy policy, - PolicyAuthorizationResult authorizeResult) - { - if (authorizeResult.Challenged) - { - await WriteProblemAsync(context, - status: HttpStatusCode.Unauthorized, - code: "unauthorized", - title: "Unauthorized", - detail: "Authentication required to access this resource."); - return; - } - - if (authorizeResult.Forbidden) - { - await WriteProblemAsync(context, - status: HttpStatusCode.Forbidden, - code: "forbidden", - title: "Forbidden", - detail: "You do not have permission to access this resource."); - return; - } - - await _defaultHandler.HandleAsync(next, context, policy, authorizeResult); - } - - private async Task WriteProblemAsync( - HttpContext context, - HttpStatusCode status, - string code, - string title, - string detail) - { - var logger = context.RequestServices.GetRequiredService>(); - logger.LogWarning("{Code} access attempt at {Path}", code, context.Request.Path); - - var problem = new ProblemDetails - { - Type = $"{_baseUrl}/{code}", - Title = title, - Detail = detail, - Status = (int)status, - Instance = context.TraceIdentifier, - Extensions = - { - ["error_code"] = code, - ["trace_id"] = context.TraceIdentifier - } - }; - - context.Response.StatusCode = problem.Status.Value; - context.Response.ContentType = "application/problem+json"; - - var json = JsonSerializer.Serialize(problem, new JsonSerializerOptions(JsonSerializerDefaults.Web)); - await context.Response.WriteAsync(json); - } -} \ No newline at end of file diff --git a/Infrastructure/Restful/Middleware/Exceptions/ExceptionHandlingMiddleware.cs b/Infrastructure/Restful/Middleware/Exceptions/ExceptionHandlingMiddleware.cs deleted file mode 100644 index 4e9b2be..0000000 --- a/Infrastructure/Restful/Middleware/Exceptions/ExceptionHandlingMiddleware.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Net; -using System.Text.Json; -using Application.Options; -using Domain; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Restful.Middleware.Exceptions; - -/// -/// Middleware for handling exceptions and returning standardized RFC 7807 ProblemDetails responses. -/// -/// -/// -/// Handles with 409 Conflict and domain-specific codes. -/// Handles unknown exceptions with 500 Internal Server Error. -/// Generates a problem type URI (e.g., https://authdev.ryzespace.com/errors/developer_token_limit_exceeded). -/// -/// -public class ExceptionHandlingMiddleware( - RequestDelegate next, - ILogger logger, - IOptions options) -{ - private readonly string _baseUrl = options.Value.DocsBaseUrl.TrimEnd('/'); - - /// - /// Invokes the middleware and handles any unhandled exceptions. - /// - public async Task InvokeAsync(HttpContext context) - { - try - { - await next(context); - } - catch (Exception ex) - { - await HandleAsync(context, ex); - } - } - - /// - /// Handles exceptions and writes standardized ProblemDetails response. - /// - private async Task HandleAsync(HttpContext context, Exception ex) - { - var (status, code, message, isDomainError) = MapException(ex); - - context.Response.ContentType = "application/problem+json"; - context.Response.StatusCode = (int)status; - - var problem = new ProblemDetails - { - Type = $"{_baseUrl}/{code}", - Title = code.Replace('_', ' '), - Detail = message, - Status = (int)status, - Instance = context.TraceIdentifier, - Extensions = - { - ["error_code"] = code, - ["trace_id"] = context.TraceIdentifier - } - }; - - if (isDomainError) - logger.LogWarning("Handled domain exception {ErrorCode}: {Message}", code, message); - else - logger.LogError(ex, "Unhandled exception: {Message}", message); - - var json = JsonSerializer.Serialize(problem, new JsonSerializerOptions(JsonSerializerDefaults.Web)); - await context.Response.WriteAsync(json); - } - - /// - /// Maps a given exception to its corresponding HTTP status and error code. - /// - private static (HttpStatusCode Status, string Code, string Message, bool IsDomainError) MapException(Exception ex) - { - if (ex is not DomainException domainEx) - return (HttpStatusCode.InternalServerError, "internal_error", "An unexpected error occurred.", false); - - var code = ToSnakeCase(domainEx.GetType().Name.Replace("Exception", "")); - return (HttpStatusCode.Conflict, code, domainEx.Message, true); - } - - /// - /// Converts a PascalCase or camelCase string to snake_case. - /// - private static string ToSnakeCase(string input) => - string.Concat(input.Select((ch, i) => - i > 0 && char.IsUpper(ch) - ? "_" + char.ToLower(ch) - : char.ToLower(ch).ToString())); -} diff --git a/Infrastructure/Restful/Middleware/Exceptions/ValidationExceptionMiddleware.cs b/Infrastructure/Restful/Middleware/Exceptions/ValidationExceptionMiddleware.cs deleted file mode 100644 index 2de31bd..0000000 --- a/Infrastructure/Restful/Middleware/Exceptions/ValidationExceptionMiddleware.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Net; -using System.Text.Json; -using Application.Options; -using FluentValidation; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Restful.Middleware.Exceptions; - -/// -/// Middleware that handles and returns standardized RFC 7807 ProblemDetails responses. -/// -/// -/// -/// Returns 400 Bad Request with validation errors in structured JSON format. -/// Uses for consistent error documentation links. -/// Ensures consistent error response structure across the entire REST layer. -/// -/// -public sealed class ValidationExceptionMiddleware( - RequestDelegate next, - ILogger logger, - IOptions options) -{ - private readonly string _baseUrl = options.Value.DocsBaseUrl.TrimEnd('/'); - - public async Task InvokeAsync(HttpContext context) - { - try - { - await next(context); - } - catch (ValidationException ex) - { - await HandleValidationAsync(context, ex); - } - } - - private async Task HandleValidationAsync(HttpContext context, ValidationException ex) - { - var code = "validation_failed"; - var status = HttpStatusCode.BadRequest; - - var errors = ex.Errors - .GroupBy(e => e.PropertyName) - .ToDictionary( - g => g.Key, - g => g.Select(e => e.ErrorMessage).ToArray() - ); - - var problem = new ProblemDetails - { - Type = $"{_baseUrl}/{code}", - Title = "Validation failed", - Detail = "One or more validation errors occurred.", - Status = (int)status, - Instance = context.TraceIdentifier, - Extensions = - { - ["error_code"] = code, - ["trace_id"] = context.TraceIdentifier, - ["errors"] = errors - } - }; - - logger.LogWarning("Validation failed on {Path}: {@Errors}", context.Request.Path, errors); - - context.Response.StatusCode = problem.Status.Value; - context.Response.ContentType = "application/problem+json"; - - var json = JsonSerializer.Serialize(problem, new JsonSerializerOptions(JsonSerializerDefaults.Web)); - await context.Response.WriteAsync(json); - } -} \ No newline at end of file diff --git a/Infrastructure/Security/DeveloperScope/DeveloperScopePolicyProvider.cs b/Infrastructure/Security/DeveloperScope/DeveloperScopePolicyProvider.cs deleted file mode 100644 index be1a571..0000000 --- a/Infrastructure/Security/DeveloperScope/DeveloperScopePolicyProvider.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Security.DeveloperScope; - -/// -/// Custom that dynamically creates policies -/// based on developer token scopes. -/// -/// -/// -/// If the policy name starts with "DeveloperScope:", extracts the scope and creates -/// a policy with . -/// Otherwise, falls back to the default . -/// Supports retrieval of default and fallback policies via the underlying provider. -/// -/// -public class DeveloperScopePolicyProvider : IAuthorizationPolicyProvider -{ - private readonly DefaultAuthorizationPolicyProvider _fallbackPolicyProvider; - - public DeveloperScopePolicyProvider(IOptions options) - { - _fallbackPolicyProvider = new DefaultAuthorizationPolicyProvider(options); - } - - public Task GetPolicyAsync(string policyName) - { - if (!policyName.StartsWith("DeveloperScope:")) - return _fallbackPolicyProvider.GetPolicyAsync(policyName); - - var scope = policyName.Split(':')[1]; - - var policy = new AuthorizationPolicyBuilder() - .AddRequirements(new DeveloperScopeRequirement { RequiredScope = scope }) - .Build(); - - return Task.FromResult(policy); - } - - public Task GetDefaultPolicyAsync() - => _fallbackPolicyProvider.GetDefaultPolicyAsync(); - - public Task GetFallbackPolicyAsync() - => _fallbackPolicyProvider.GetFallbackPolicyAsync(); -} diff --git a/Infrastructure/Security/DeveloperScope/DeveloperScopeRequirement.cs b/Infrastructure/Security/DeveloperScope/DeveloperScopeRequirement.cs deleted file mode 100644 index 19a41f5..0000000 --- a/Infrastructure/Security/DeveloperScope/DeveloperScopeRequirement.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Infrastructure.Security.DeveloperScope; - -/// -/// Represents a developer token scope requirement for authorization. -/// -/// -/// -/// Used by to verify if the user has the required scope. -/// The property specifies the scope that must be present in the developer token. -/// Can be dynamically assigned in policies via . -/// -/// -public class DeveloperScopeRequirement : IAuthorizationRequirement -{ - public string? RequiredScope { get; set; } -} \ No newline at end of file diff --git a/Infrastructure/Security/JwtKeyStoreInitializer.cs b/Infrastructure/Security/JwtKeyStoreInitializer.cs deleted file mode 100644 index c24c45b..0000000 --- a/Infrastructure/Security/JwtKeyStoreInitializer.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Diagnostics; -using Application.Features.KeyManagement.Interfaces; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Infrastructure.Security; - -public class JwtKeyStoreInitializer(IServiceProvider provider, ILogger logger) - : IHostedService, IAsyncDisposable -{ - public async Task StartAsync(CancellationToken cancellationToken) - { - await using var scope = provider.CreateAsyncScope(); - var store = scope.ServiceProvider.GetRequiredService(); - - var sw = Stopwatch.StartNew(); - await store.InitializeAsync(); - sw.Stop(); - - logger.LogInformation( - "JWT Keystore initialized in {ElapsedMilliseconds} ms | Active KID: {ActiveKid} | Total Keys: {TotalKeys}", - sw.ElapsedMilliseconds, - store.GetMetadata(store.GetActiveSigningCredentials().Key.KeyId)?.Kid ?? "N/A", - store.GetPublicJwks().Count() - ); - } - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public async ValueTask DisposeAsync() - { - await using var scope = provider.CreateAsyncScope(); - if (scope.ServiceProvider.GetService() is IAsyncDisposable asyncStore) - { - await asyncStore.DisposeAsync(); - } - } -} \ No newline at end of file diff --git a/README.md b/README.md index cd33c7b..cf871b5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# RyzeSDK.AuthKit +# AuthKit ### Developer Authentication & SDK Access Service @@ -9,141 +9,142 @@
-![banners](banners.png) --- ## Overview -RyzeSDK.AuthKit is the **centralized microservice** for handling **developer authentication, SDK token issuance, and access verification** across the RyzeSpace SDK ecosystem. -It ensures that only authorized developers can access SDK methods and provides a secure, auditable token-based authentication mechanism. +AuthKit is **plugin based service** for handling **developer authentication, SDK token issuance, and access verification**. It ensures that only authorized developers can access SDK methods and provides secure, auditable token-based authentication mechanism. -## Why AuthKit Matters +AuthKit validates user identity through **Keycloak**, issues signed developer tokens (JWT), and exposes both **RESTful** and **gRPC** endpoints so SDK clients can request, verify, and manage tokens. -In a distributed SDK ecosystem, AuthKit provides: +## Features -- **Secure Access** — Issue and validate dev tokens for SDK usage -- **Role Enforcement** — Restrict SDK methods to authorized roles (SDK_Dev) -- **Audit & Traceability** — Track issued tokens and access events -- **Extensibility** — Easy to add policies, limits, or new developer roles +- **Secure Access** - Issue and validate developer tokens for SDK usage +- **Role & Scope Enforcement** - Restrict SDK methods to authorized roles and token scopes +- **Key Management** - RSA signing-key generation, AES-encrypted key storage, JWKS discovery, and key rotation +- **Plugin Architecture** - Functionality is delivered through dynamically discovered plugins (`IAuthKitPlugin`) +- **Extensible** - Add new token types, policies, or SDK solutions without modifying the host +- **Dual Transport** - Same services exposed over REST (HTTP/2) and gRPC -## Token Flow Example +## Architecture -- **Developer registers → DevAccessService** issues a token -- **SDK client** (RyzeSdkClient) stores token -- **SDK methods** attach token to REST/gRPC requests -- **AuthKit** verifies token & role → executes method if authorized +AuthKit is composed of three layers: -## Token Issuance & SDK Request Flow -```mermaid -sequenceDiagram - participant Dev as Developer - participant Keycloak as Keycloak - participant AuthKit as AuthKit API - participant SDK as RyzeSdkClient - participant API as Target API (e.g. Marketplace) +- **Core** - Shared domain, JWT signing-key management (RSA key generation, AES encryption, on-disk keystore), token key bindings, and core options. +- **Host** - ASP.NET Core host running on Kestrel. Wires up **Wolverine** - (command/query handling), **Marten** (PostgreSQL event/document store), RESTful and gRPC endpoints, Keycloak integration, CLI, and dynamic plugin loading. +- **Plugins** - Extensions discovered and loaded dynamically from the `plugins/` directory at startup. A plugin contributes services, middleware, health checks, and OpenAPI security schemes through the `IAuthKitPlugin` contract without being referenced by the host. - Dev->>Keycloak: Authenticate via Keycloak (JWT access token) - Keycloak-->>Dev: Returns access_token (Keycloak JWT) +``` +src/ +├── Core/ # Domain, key management, options +├── Host/ # Web host, REST/gRPC, CLI, plugin loader +│ ├── Configuration/ # Auth, Keycloak, Kestrel, Marten, ServiceDiscovery +│ ├── Grpc/ # gRPC services and protos +│ ├── KeyManagement/ # JWKS endpoint, key store initializer +│ ├── Restful/ # Host-level middleware +│ └── ServiceDiscovery/ # Automatic DI registration +└── Plugins/ + ├── Abstractions/ # IAuthKitPlugin contract + └── Solutions/ # Plugin implementations (e.g. DevTokens) +``` - Dev->>AuthKit: Request Developer Token
Authorization: Bearer - AuthKit->>AuthKit: Validate Keycloak token
and create DeveloperToken (JWT) - AuthKit-->>Dev: Returns X-Developer-Token (AuthKit JWT) +## Plugin Model - SDK->>API: Request with
Authorization: Bearer
X-Developer-Token: - API->>AuthKit: Validate developer token via REST - AuthKit->>Keycloak: Validate user session & roles - AuthKit-->>API: DeveloperToken valid ✅ - API-->>SDK: 200 OK — Operation authorized -``` +Plugins implement `IAuthKitPlugin` and are loaded from the directory configured by `AuthKit:PluginsPath` (defaults to `/plugins`). At startup the host: + +1. Discovers and loads plugin assemblies. +2. Calls `ConfigureServices` to register each plugin's dependencies. +3. Inserts any contributed `MiddlewareType` into the pipeline. +4. Exposes contributed OpenAPI security schemes. +5. Reports plugin health through `CheckHealthAsync`. + +This lets new SDK solutions ship as self-contained packages without changing the host project. + +## Built-in Plugins + +### DevTokens + +Issues and validates developer tokens used for SDK access. Exposed REST endpoints: + +| Method | Route | Description | +| --- | --- | --- | +| `POST` | `sdk/developer-tokens` | Create a developer token | +| `GET` | `sdk/developer-tokens` | List developer tokens | +| `GET` | `sdk/developer-tokens/{tokenId}` | Get a token by id | +| `DELETE` | `sdk/developer-tokens/{tokenId}` | Delete a token | +| `POST` | `sdk/tokens/verify` | Verify a developer token | +| `POST` | `sdk/tokens/{tokenId}/revoke-rotate` | Revoke and rotate a token | + +Tokens are passed via the `X-Developer-Token` API-key header and enforced by the `DeveloperTokenMiddleware` and scope-based authorization (`DeveloperScopeRequirement`). + +## JWT & Key Management + +- Signing keys are generated as RSA keys, encrypted with the AES master key (`Encryption:AES_MASTER_KEY`), and persisted in the key store. +- Public keys are published at `.well-known/jwks.json` for external signature verification. +- Multiple active keys are supported simultaneously to allow seamless key rotation. +- Token key bindings associate issued tokens with the signing key used to protect them. + +## Authentication + +User identity is validated with **Keycloak** JWT bearer authentication. Keycloak is configured through environment variables: + +| Variable | Description | Default | +| --- | --- | --- | +| `KEYCLOAK_URL` | Keycloak base URL | `http://keycloak:8080` | +| `KEYCLOAK_REALM` | Keycloak realm | `authz` | +| `KEYCLOAK_CLIENT_ID` | Keycloak client id | `workspace-authz` | + +Client roles from the `resource_access` claim are mapped to ASP.NET Core role claims for authorization. + +## Configuration + +Key `appsettings.json` sections: -## External REST vs Internal gRPC Call Flow -```mermaid -flowchart TD - Dev[Developer] -->|Authenticate| Keycloak[Keycloak JWT] - Keycloak --> Dev - - Dev -->|Request Dev Token| AuthKit[AuthKit API] - AuthKit -->|Validate Keycloak token| Keycloak - AuthKit -->|Return Dev Token| Dev - - Dev -->|Request Service Token| AuthKitService[AuthKit API] - AuthKitService -->|Validate Dev Token| AuthKit - AuthKitService -->|Return Service Token| Dev - - Dev -->|Configure SDK| SDK[RyzeSdkClient] - - SDK -->|REST request| API_REST[Target API REST] - SDK -->|gRPC request| API_GRPC[Target API gRPC] - - subgraph REST_Flow - API_REST -->|Pass tokens to middleware| AuthKit_REST[AuthKit Middleware REST] - AuthKit_REST -->|Validate Dev & Service Tokens| TokenDB[Token Database] - AuthKit_REST -->|Validate Keycloak JWT| Keycloak - AuthKit_REST -->|Return auth result| API_REST - API_REST -->|200 OK / 403 Forbidden| SDK - end - - subgraph gRPC_Flow - API_GRPC -->|Pass tokens to middleware| AuthKit_GRPC[AuthKit Middleware gRPC] - AuthKit_GRPC -->|Validate Dev & Service Tokens| TokenDB - AuthKit_GRPC -->|Validate Keycloak JWT| Keycloak - AuthKit_GRPC -->|Return auth result| API_GRPC - API_GRPC -->|200 OK / 403 Forbidden| SDK - end +| Section | Purpose | +| --- | --- | +| `ConnectionStrings:Marten` | PostgreSQL connection for Marten | +| `AuthKit:MaxDeveloperTokens` | Max tokens per developer (default `3`) | +| `Encryption:AES_MASTER_KEY` | Master key for encrypting signing keys | +| `Server:Issuer` | JWT issuer (`https://authkit.local`) | +| `ServiceDiscovery` | Automatic DI registration rules (namespaces, layers, lifetime) | + +## Getting Started + +### Run with Docker + +```bash +docker compose up --build ``` -## SDK Function Call Flow (AuthKit → gRPC Microservices) -```mermaid -flowchart TD - classDef token fill:#fef3c7,stroke:#f59e0b,stroke-width:1px,color:#b45309; - classDef service fill:#fef3c7,stroke:#fef3c7,stroke-width:1px,color:#92400e; - classDef internal fill:#dbeafe,stroke:#3b82f6,stroke-width:1px,color:#1e40af; - -%% Actors - Dev[Developer] -->|Has Keycloak JWT,
DeveloperToken,
ServiceToken| SDK[RyzeSdkClient] - class Dev,SDK token; - -%% REST request from external SDK - SDK -->|REST request with tokens| API_Controller[API Controller] - API_Controller -->|Validate tokens internally via AuthKit| AuthKit[AuthKit API] - -%% gRPC requests - API_Controller -->|gRPC call| GRPC_Service[gRPC Service] - GRPC_Service -->|gRPC call to other microservice| AnotherService_GRPC[Another gRPC Service] - -%% Responses - GRPC_Service -->|Return result| API_Controller - AnotherService_GRPC -->|Return result| GRPC_Service - API_Controller -->|Return response| SDK - -%% Styling - class Dev,SDK,AuthKit token; - class API_Controller,GRPC_Service service; - class AnotherService_GRPC internal; +This starts AuthKit (REST on `5000`, gRPC on `5001`), a PostgreSQL database, and Keycloak. Configuration is provided via `.env` (see `.env.example`). + +### Run locally + +```bash +dotnet build AuthKit.slnx +dotnet run --project src/Host/Host.csproj ``` -## Contributing +The host listens on the address configured in `Server:Host` (default `http://0.0.0.0:8080`, HTTP/2). -We welcome contributions that improve contract clarity, expand integration patterns, or enhance type safety. +## Documentation -### Development Guidelines +| Topic | Link | +| --- | --- | +| Documentation index | [Docs](Docs/README.md) | +| Schemas & Diagrams | [Schemas](Docs/Schemas.md) | + +## Contributing -1. **Branch Strategy** — Create feature branches from `main` - ```bash - git checkout -b feature/token-policies - ``` -2. **Versioning** — Use semantic versioning (MAJOR.MINOR.PATCH) - - MAJOR: Breaking changes - - MINOR: New contracts (backward compatible) - - PATCH: Bug fixes, documentation +We welcome contributions that improve contract clarity, expand integration patterns, or enhance type safety. -3. **Pull Request** — Provide clear descriptions of changes and impact +Please read the [Contributing Guide](CONTRIBUTING.md) for setup, workflow, and pull request guidelines. ## License **MIT License + Commons Clause** -The RyzeSpace.Contracts library is open source for personal, educational, and research purposes. Commercial use requires explicit permission. +AuthKit is open source for personal, educational, and research purposes. Commercial use requires explicit permission. ### Permitted Use ✓ @@ -161,22 +162,10 @@ The RyzeSpace.Contracts library is open source for personal, educational, and re See [LICENSE](LICENSE) for complete terms. ---- - -
- -### Part of the RyzeSpace Ecosystem - -**Democratizing access to computational resources through decentralized sharing** - -*Every idle GPU, every spare CPU cycle — unlocking potential in the RyzeSpace network* - -
- -**[Documentation](https://docs.ryzespace.com)** • **[Platform](https://ryzespace.com)** • **[Community](https://discord.gg/JsQx8cQ5yp)** +## Code of Conduct -
+Please read and follow the [Code of Conduct](CODE_OF_CONDUCT.md) when participating in this project. -Built with precision by the RyzeSpace team +## Security -
\ No newline at end of file +For vulnerability reporting, see the [Security Policy](SECURITY.md). diff --git a/RyzeSDK.AuthKit.sln b/RyzeSDK.AuthKit.sln deleted file mode 100644 index 581b3f5..0000000 --- a/RyzeSDK.AuthKit.sln +++ /dev/null @@ -1,36 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "Application\Application.csproj", "{149D2A8E-3A09-4F95-A38E-269C0F425BCB}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "Domain\Domain.csproj", "{B979757A-4A8D-4C83-B231-521000A955C3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Host", "Host\Host.csproj", "{58EDDF51-DD85-4A87-92CF-BBAC50262996}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{76085586-8F87-4012-AFC5-69EDF057E6B6}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {149D2A8E-3A09-4F95-A38E-269C0F425BCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {149D2A8E-3A09-4F95-A38E-269C0F425BCB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {149D2A8E-3A09-4F95-A38E-269C0F425BCB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {149D2A8E-3A09-4F95-A38E-269C0F425BCB}.Release|Any CPU.Build.0 = Release|Any CPU - {B979757A-4A8D-4C83-B231-521000A955C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B979757A-4A8D-4C83-B231-521000A955C3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B979757A-4A8D-4C83-B231-521000A955C3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B979757A-4A8D-4C83-B231-521000A955C3}.Release|Any CPU.Build.0 = Release|Any CPU - {58EDDF51-DD85-4A87-92CF-BBAC50262996}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {58EDDF51-DD85-4A87-92CF-BBAC50262996}.Debug|Any CPU.Build.0 = Debug|Any CPU - {58EDDF51-DD85-4A87-92CF-BBAC50262996}.Release|Any CPU.ActiveCfg = Release|Any CPU - {58EDDF51-DD85-4A87-92CF-BBAC50262996}.Release|Any CPU.Build.0 = Release|Any CPU - {76085586-8F87-4012-AFC5-69EDF057E6B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {76085586-8F87-4012-AFC5-69EDF057E6B6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {76085586-8F87-4012-AFC5-69EDF057E6B6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {76085586-8F87-4012-AFC5-69EDF057E6B6}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - EndGlobalSection -EndGlobal diff --git a/RyzeSDK.AuthKit.sln.DotSettings.user b/RyzeSDK.AuthKit.sln.DotSettings.user deleted file mode 100644 index 5fe9564..0000000 --- a/RyzeSDK.AuthKit.sln.DotSettings.user +++ /dev/null @@ -1,75 +0,0 @@ - - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - ForceIncluded - <AssemblyExplorer> - <Assembly Path="C:\Users\Lunix\.nuget\packages\keycloak.authservices.sdk\2.7.0\lib\net8.0\Keycloak.AuthServices.Sdk.dll" /> - <Assembly Path="C:\Users\Lunix\.nuget\packages\keycloak.netcore.client\1.0.2\lib\net8.0\NETCore.Keycloak.Client.dll" /> - <Assembly Path="C:\Users\Lunix\.nuget\packages\system.commandline\2.0.0\lib\net8.0\System.CommandLine.dll" /> - <Assembly Path="C:\Users\Lunix\.nuget\packages\commandlineparser\2.9.1\lib\netstandard2.0\CommandLine.dll" /> -</AssemblyExplorer> \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8dd1061 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,125 @@ +# Security Policy + +AuthKit provides developer authentication, SDK token issuance, token validation, and access enforcement. Because these components directly participate in authentication and authorization, security issues affecting them may have a significant impact on applications integrating AuthKit. + +Token issuance, storage, validation, session handling, and authorization logic are considered security-sensitive components. + +> [!IMPORTANT] +> **Do not publicly disclose security vulnerabilities or exploit details.** +> Please report security issues privately so they can be investigated and addressed before public disclosure. + +> [!WARNING] +> AuthKit is under active development. The security model and implementation are still being hardened, and breaking changes may be introduced as security improvements are made. + +## Supported Versions + +AuthKit is currently in active development. + +Security fixes are applied to the `main` branch first. Where appropriate, security fixes may also be backported to supported stable releases. + +Because AuthKit is under active development, users should generally run the latest available version or commit when possible. + +## Reporting a Vulnerability + +Please report suspected security vulnerabilities directly to the maintainers through a private communication channel. + +A useful report should include: + +* a clear description of the vulnerability +* affected component or functionality +* steps required to reproduce the issue +* security impact and potential attack scenario +* affected version, release, or commit +* proof of concept, if available +* any known prerequisites or limitations + +Please avoid including credentials, private keys, real access tokens, personal data, or other sensitive information in the report. + +### Public Issues + +If no private reporting channel is currently available, open a minimal public issue **without exploit details or a working proof of concept**. + +The issue should only indicate that a potential security vulnerability exists and request private follow-up from the maintainers. + +## Response Process + +The maintainers aim to: + +1. acknowledge receipt of a security report within **7 days** +2. reproduce and validate the reported issue +3. assess its severity and potential impact +4. determine affected versions and components +5. prepare and implement an appropriate fix +6. verify the fix through testing and security review where appropriate +7. coordinate disclosure after a fix or mitigation is available + +Response times may vary depending on the complexity and severity of the issue. + +## Scope + +This security policy covers vulnerabilities affecting AuthKit components, including: + +* AuthKit REST API +* AuthKit gRPC API +* developer authentication +* SDK token issuance +* token validation and verification +* token storage and handling +* token revocation and lifecycle management +* authorization and access enforcement +* role and permission checks +* Keycloak integration +* session handling +* authentication and authorization middleware +* security-sensitive configuration and key management + +Issues in dependencies may also be considered when AuthKit's integration or configuration introduces or materially contributes to the vulnerability. + +## Out of Scope + +The following are generally considered out of scope unless they result in a demonstrable security impact: + +* formatting, style, or documentation issues +* theoretical vulnerabilities without a reproducible attack path +* vulnerabilities requiring unrealistic or unavailable assumptions +* issues in unsupported environments +* denial-of-service caused solely by intentionally exhausting resources available to the attacker +* reports that only describe best-practice improvements without demonstrating a security impact + +Out of scope issues may still be considered at the maintainers' discretion. + +## Severity + +Security reports are evaluated based on factors such as: + +* whether authentication can be bypassed +* whether authorization can be bypassed +* whether tokens can be forged, stolen, or replayed +* whether sensitive credentials or cryptographic material can be exposed +* the privileges required to exploit the issue +* whether user or system interaction is required +* the potential confidentiality, integrity, and availability impact + +The maintainers may use established severity frameworks such as **CVSS** when appropriate. + +## Disclosure + +Please allow the maintainers reasonable time to investigate, reproduce, and remediate a reported vulnerability before publicly disclosing technical details. + +After a fix or effective mitigation is available, the maintainers may coordinate public disclosure with the reporter. + +Disclosure timing may depend on: + +* severity and exploitability +* availability of a fix or mitigation +* affected versions +* whether the vulnerability is already being actively exploited +* coordination with affected users or downstream projects + +The maintainers reserve the right to delay disclosure when immediate publication could materially increase risk to users. + +## Security Updates + +Security fixes may be included in regular releases or published as dedicated security releases when appropriate. + +Users are encouraged to keep AuthKit and its security-sensitive dependencies up to date. diff --git a/appicon.png b/appicon.png deleted file mode 100644 index bab370c..0000000 Binary files a/appicon.png and /dev/null differ diff --git a/banners.png b/banners.png deleted file mode 100644 index 1a78ce7..0000000 Binary files a/banners.png and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml index a5f402f..7990af4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,7 @@ services: start --http-enabled=true --hostname-strict=false + --import-realm environment: KC_FEATURES: scripts KC_BOOTSTRAP_ADMIN_USERNAME: admin @@ -70,9 +71,8 @@ services: KC_DB_URL: jdbc:postgresql://keycloak-db:5432/Auth KC_DB_USERNAME: postgres KC_DB_PASSWORD: postgres - KEYCLOAK_IMPORT: /tmp/keycloak/realm-authz.json volumes: - - ./keycloak:/tmp/keycloak + - ./keycloak/realm-authz.json:/opt/keycloak/data/import/realm-authz.json:ro ports: - "8081:8080" depends_on: diff --git a/Application/Application.csproj b/src/Core/Core.csproj similarity index 54% rename from Application/Application.csproj rename to src/Core/Core.csproj index 18eace3..6cdb7dc 100644 --- a/Application/Application.csproj +++ b/src/Core/Core.csproj @@ -6,16 +6,13 @@ enable - - - - - - - - + + + + + + + + - - - - \ No newline at end of file + diff --git a/src/Core/DomainException.cs b/src/Core/DomainException.cs new file mode 100644 index 0000000..69dbb7b --- /dev/null +++ b/src/Core/DomainException.cs @@ -0,0 +1,10 @@ +namespace Core; + +/// +/// Represents the base exception type for domain specific errors. +/// +/// +/// Domain exceptions describe business rule violations or other errors +/// originating from the application domain. +/// +public abstract class DomainException(string message) : Exception(message); \ No newline at end of file diff --git a/Application/ErrorResponse.cs b/src/Core/ErrorResponse.cs similarity index 96% rename from Application/ErrorResponse.cs rename to src/Core/ErrorResponse.cs index 70ef4e0..189946c 100644 --- a/Application/ErrorResponse.cs +++ b/src/Core/ErrorResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Application; +namespace Core; /// /// Standardized error response DTO for RESTful endpoints. diff --git a/src/Core/KeyManagement/DTO/KeyEntry.cs b/src/Core/KeyManagement/DTO/KeyEntry.cs new file mode 100644 index 0000000..9edb64d --- /dev/null +++ b/src/Core/KeyManagement/DTO/KeyEntry.cs @@ -0,0 +1,56 @@ +using Microsoft.IdentityModel.Tokens; + +namespace Core.KeyManagement.DTO; + +/// +/// Represents single RSA key entry used for JWT signing. +/// +/// +/// +/// A key entry combines the RSA security key used for cryptographic operations, +/// the signing credentials used for JWT issuance, associated key metadata, +/// and the public RSA parameters required for JWKS representation. +/// +/// +/// The RSA modulus and exponent are exposed as encoded strings so that the +/// public key material can be published through a JWKS endpoint without +/// exposing private RSA parameters. +/// +/// +public sealed record KeyEntry +{ + /// + /// Gets the RSA security key used for JWT signing operations. + /// + public required RsaSecurityKey Key { get; init; } + + /// + /// Gets the signing credentials used when issuing JWTs with this key. + /// + public required SigningCredentials Signing { get; init; } + + /// + /// Gets the metadata associated with the RSA key. + /// + public required KeyMetadata Meta { get; init; } + + /// + /// Gets the Base64URL-encoded RSA modulus. + /// + /// + /// The modulus corresponds to the n parameter defined by the + /// JSON Web Key (JWK) specification and is used when exporting the + /// public key as JWKS. + /// + public required string N { get; init; } + + /// + /// Gets the Base64URL-encoded RSA public exponent. + /// + /// + /// The exponent corresponds to the e parameter defined by the + /// JSON Web Key (JWK) specification and is used when exporting the + /// public key as JWKS. + /// + public required string E { get; init; } +} \ No newline at end of file diff --git a/src/Core/KeyManagement/DTO/KeyMetadata.cs b/src/Core/KeyManagement/DTO/KeyMetadata.cs new file mode 100644 index 0000000..48d0b2e --- /dev/null +++ b/src/Core/KeyManagement/DTO/KeyMetadata.cs @@ -0,0 +1,55 @@ +namespace Core.KeyManagement.DTO; + +/// +/// Represents metadata describing cryptographic signing key. +/// +/// +/// +/// The metadata identifies signing key and describes its lifecycle, +/// cryptographic algorithm, and intended purpose. +/// +/// +/// This information is used to manage key rotation, revocation, and +/// publication of public key metadata through JWKS. +/// +/// +public sealed record KeyMetadata +{ + /// + /// Gets the unique key identifier (kid). + /// + /// + /// The identifier is used to associate JWT with the public key + /// required to validate its signature. + /// + public required string Kid { get; init; } + + /// + /// Gets the date and time at which the key was created. + /// + public required DateTimeOffset CreatedAt { get; init; } + + /// + /// Gets value indicating whether the key has been revoked. + /// + /// + /// A revoked key should no longer be used for signing new tokens. + /// Depending on the key management policy, its public material may + /// remain available for validating tokens issued before revocation. + /// + public bool Revoked { get; init; } + + /// + /// Gets the cryptographic algorithm used by the key. + /// + /// Defaults to RS256. + public string Algorithm { get; init; } = "RS256"; + + /// + /// Gets the intended purpose of the key. + /// + /// + /// Defaults to JWT signing. + /// + public string Purpose { get; init; } = "JWT signing"; +} \ No newline at end of file diff --git a/src/Core/KeyManagement/DTO/KeystoreOnDisk.cs b/src/Core/KeyManagement/DTO/KeystoreOnDisk.cs new file mode 100644 index 0000000..43f688c --- /dev/null +++ b/src/Core/KeyManagement/DTO/KeystoreOnDisk.cs @@ -0,0 +1,32 @@ +namespace Core.KeyManagement.DTO; + +/// +/// Represents serialized keystore persisted on disk before encryption. +/// +/// +/// +/// The keystore contains the RSA key records used for JWT signing together +/// with the identifier of the key currently active for issuing new tokens. +/// +/// +/// The serialized representation is intended for persistence and is encrypted +/// before being written to disk by the key management infrastructure. +/// +/// +public sealed record KeystoreOnDisk +{ + /// + /// Gets the identifier of the key currently active for signing new tokens. + /// + /// The value corresponds to the Kid of one of the records + public required string ActiveKid { get; init; } + + /// + /// Gets the RSA key records contained in the keystore. + /// + /// + /// The collection contains the persisted representation of all keys + /// managed by the key store, including active, inactive, and revoked keys. + /// + public required List Records { get; init; } +} \ No newline at end of file diff --git a/src/Core/KeyManagement/DTO/KeystoreRecordOnDisk.cs b/src/Core/KeyManagement/DTO/KeystoreRecordOnDisk.cs new file mode 100644 index 0000000..20a2393 --- /dev/null +++ b/src/Core/KeyManagement/DTO/KeystoreRecordOnDisk.cs @@ -0,0 +1,32 @@ +namespace Core.KeyManagement.DTO; + +/// +/// Represents single stored RSA key entry within the serialized keystore. +/// +/// +/// +/// The record contains the metadata required to identify and manage the key +/// together with its private RSA key material in serialized representation. +/// +/// +/// The private key is encoded as Base64 string for persistence and should +/// only be handled by the key management infrastructure. It must not be +/// exposed through public key endpoints such as JWKS. +/// +/// +public sealed record KeystoreRecordOnDisk +{ + /// + /// Gets the metadata associated with the stored RSA key. + /// + public required KeyMetadata Metadata { get; init; } + + /// + /// Gets the Base64 encoded private RSA key material. + /// + /// + /// This value contains sensitive cryptographic material and must be + /// protected from unauthorized access. + /// + public required string PrivateKeyBase64 { get; init; } +} \ No newline at end of file diff --git a/Application/Features/KeyManagement/DTO/PublicJwkDto.cs b/src/Core/KeyManagement/DTO/PublicJwkDto.cs similarity index 81% rename from Application/Features/KeyManagement/DTO/PublicJwkDto.cs rename to src/Core/KeyManagement/DTO/PublicJwkDto.cs index 32bbf7c..bf57e3c 100644 --- a/Application/Features/KeyManagement/DTO/PublicJwkDto.cs +++ b/src/Core/KeyManagement/DTO/PublicJwkDto.cs @@ -1,9 +1,9 @@ using System.Text.Json.Serialization; -namespace Application.Features.KeyManagement.DTO; +namespace Core.KeyManagement.DTO; /// -/// Represents a public JSON Web Key (JWK) exposed in the JWKS endpoint. +/// Represents public JSON Web Key (JWK) exposed in the JWKS endpoint. /// /// /// @@ -22,7 +22,7 @@ public record PublicJwkDto( [property: JsonPropertyName("x5c")] string[] X5c ) { /// - /// Initializes a new with standard defaults (RSA/RS256). + /// Initializes new with standard defaults (RSA/RS256). /// public PublicJwkDto() : this("RSA", "sig", string.Empty, "RS256", string.Empty, string.Empty, []) {} diff --git a/src/Core/KeyManagement/Entity/SigningKey.cs b/src/Core/KeyManagement/Entity/SigningKey.cs new file mode 100644 index 0000000..01b1ff6 --- /dev/null +++ b/src/Core/KeyManagement/Entity/SigningKey.cs @@ -0,0 +1,151 @@ +namespace Core.KeyManagement.Entity; + +/// +/// Represents cryptographic signing key managed by the key management system. +/// +/// +/// +/// A signing key contains both its public representation and protected private +/// key material together with lifecycle information used to determine whether +/// the key may be used to sign new tokens. +/// +/// +/// The key lifecycle is represented by its activation, validity, expiration, +/// and revocation state. State transitions are performed through the methods +/// exposed by this entity. +/// +/// +public sealed record SigningKey +{ + /// + /// Gets the unique identifier of the signing key. + /// + /// + /// This identifier is used as the JWT kid value to associate a + /// token with the public key required to verify its signature. + /// + public required Guid Id { get; init; } + + /// + /// Gets the public RSA key encoded as PEM. + /// + /// + /// This value contains public key material and may be exposed to + /// consumers through the appropriate public key representation. + /// + public required string PublicKeyPem { get; init; } + + /// + /// Gets the encrypted private key material encoded for persistence. + /// + /// + /// The value contains sensitive cryptographic material and must be + /// protected from unauthorized access. + /// + public required string PrivateKeyEncrypted { get; init; } + + /// + /// Gets the cryptographic algorithm used when signing tokens. + /// + /// For RSA signing keys this will typically be an algorithm such as RS256. + public required string Algorithm { get; init; } + + /// + /// Gets the date and time at which the signing key was created. + /// + public required DateTime CreatedAt { get; init; } + + /// + /// Gets the date and time before which the key must not be used for signing. + /// + /// + /// null when the key has no activation restriction. + /// + public DateTime? NotBefore { get; init; } + + /// + /// Gets the date and time after which the key must no longer be used for signing. + /// + /// + /// null when the key does not have an expiration time. + /// + public DateTime? ExpiresAt { get; init; } + + /// + /// Gets the date and time at which the key was revoked. + /// + /// + /// null when the key has not been revoked. + /// + public DateTime? RevokedAt { get; init; } + + /// + /// Gets value indicating whether the key is currently active. + /// + public bool IsActive { get; init; } + + /// + /// Determines whether the signing key is currently valid for signing tokens. + /// + /// + /// The current date and time used to evaluate the key lifecycle. + /// + /// + /// true when the key is active, has not been revoked, is past its + /// activation time, and has not expired; otherwise, false. + /// + public bool IsValidForSigning(DateTime now) + => IsActive && + RevokedAt is null && + (NotBefore is null || now >= NotBefore) && + (ExpiresAt is null || now < ExpiresAt); + + /// + /// Activates the signing key. + /// + /// The date and time at which the key becomes active. + /// A new instance representing the activated key. + /// + /// Activating key sets its timestamp to + /// and clears any previous revocation timestamp. + /// + public SigningKey Activate(DateTime now) + => this with + { + IsActive = true, + NotBefore = now, + RevokedAt = null + }; + + /// + /// Revokes the signing key. + /// + /// The date and time at which the key is revoked. + /// A new instance representing the revoked key. + /// + /// A revoked key is immediately marked as inactive and cannot be used + /// for signing new tokens. + /// + public SigningKey Revoke(DateTime now) + => this with + { + IsActive = false, + RevokedAt = now + }; + + /// + /// Marks the signing key as expired. + /// + /// The date and time at which the key expires. + /// A new instance representing the expired key. + /// + /// Expiring key marks it as inactive and sets its expiration timestamp + /// to . + /// + public SigningKey Expire(DateTime now) + => this with + { + IsActive = false, + ExpiresAt = now + }; +} diff --git a/src/Core/KeyManagement/Interfaces/IJwtKeyStore.cs b/src/Core/KeyManagement/Interfaces/IJwtKeyStore.cs new file mode 100644 index 0000000..ecb4dc8 --- /dev/null +++ b/src/Core/KeyManagement/Interfaces/IJwtKeyStore.cs @@ -0,0 +1,114 @@ +using Core.KeyManagement.DTO; +using Microsoft.IdentityModel.Tokens; + +namespace Core.KeyManagement.Interfaces; + +/// +/// Defines the contract for managing JWT signing keys and their lifecycle. +/// +/// +/// +/// The key store maintains the signing keys used by AuthKit to issue and +/// validate JSON Web Tokens. +/// +/// +/// It provides access to the active signing credentials, lookup of keys +/// by their key identifier (kid), public key discovery through JWKS, +/// and lifecycle operations such as rotation and revocation. +/// +/// +/// Implementations are responsible for securely storing and managing +/// private key material and for ensuring that revoked or otherwise invalid +/// keys are not used to sign new tokens. +/// +/// +public interface IJwtKeyStore +{ + /// + /// Initializes the key store and loads the persisted signing key state. + /// + /// + /// This method should be called before the key store is used for signing, + /// validation, rotation, or key discovery operations. + /// + Task InitializeAsync(); + + /// + /// Gets the signing credentials for the key currently active for JWT issuance. + /// + /// The active instance. + /// + /// Thrown when no valid active signing key is available. + /// + SigningCredentials GetActiveSigningCredentials(); + + /// + /// Gets the signing credentials associated with a specific key identifier. + /// + /// The unique key identifier kid). + /// + /// The matching , or null when + /// no key with the specified identifier exists. + /// + SigningCredentials? GetSigningCredentialsByKid(string kid); + + /// + /// Gets the public signing keys in JWKS-compatible representation. + /// + /// + /// An enumerable collection of containing + /// the public key material required to verify JWT signatures. + /// + /// + /// The returned collection must contain public key material only. + /// Private key material must never be exposed through this method. + /// + IEnumerable GetPublicJwks(); + + /// + /// Rotates the active RSA signing key. + /// + /// The RSA key size in bits. Defaults to 4096. + /// + /// A task representing the asynchronous rotation operation and containing + /// metadata for the newly generated signing key. + /// + /// + /// + /// Rotation creates a new signing key and makes it the active key for + /// issuing new JWTs. + /// + /// + /// Previously active keys may remain available for signature validation + /// so that tokens issued before rotation can continue to be validated, + /// subject to the key lifecycle and retention policy. + /// + /// + Task RotateAsync(int rsaBits = 4096); + + /// + /// Revokes the signing key associated with specific key identifier. + /// + /// The unique key identifier (kid) of the key to revoke. + /// + /// A task representing the asynchronous revocation operation and + /// containing true when the key was successfully revoked; + /// otherwise, false. + /// + /// + /// A revoked key must not be used to sign new JWTs. + /// Implementations may retain the public key for validation of + /// previously issued tokens according to their key retention policy. + /// + Task RevokeAsync(string kid); + + /// + /// Gets metadata associated with specific key identifier. + /// + /// The unique key identifier (kid). + /// + /// The corresponding , or null when + /// no key with the specified identifier exists. + /// + KeyMetadata? GetMetadata(string kid); +} diff --git a/src/Core/KeyManagement/Interfaces/IKeyEncryptor.cs b/src/Core/KeyManagement/Interfaces/IKeyEncryptor.cs new file mode 100644 index 0000000..f8cd1cc --- /dev/null +++ b/src/Core/KeyManagement/Interfaces/IKeyEncryptor.cs @@ -0,0 +1,47 @@ +namespace Core.KeyManagement.Interfaces; + +/// +/// Defines contract for encrypting and decrypting sensitive data. +/// +/// +/// +/// Implementations provide reversible protection for sensitive data stored +/// at rest, such as private cryptographic key material, configuration +/// secrets, or tokens. +/// +/// +/// The encryption mechanism and key management strategy are implementation +/// details of the concrete encryptor. The contract guarantees only that data +/// encrypted by can subsequently be restored using +/// with the corresponding protected data. +/// +/// +/// Implementations should use authenticated encryption or another mechanism +/// that provides integrity protection in addition to confidentiality. +/// +/// +public interface IKeyEncryptor +{ + /// + /// Encrypts plaintext into protected binary representation. + /// + /// The plaintext string to encrypt. + /// A byte array containing the encrypted representation of the plaintext. + /// + /// Implementations are responsible for selecting an appropriate encoding + /// for the plaintext and for including any metadata required to decrypt + /// the resulting ciphertext. + /// + byte[] Encrypt(string plaintext); + + /// + /// Decrypts encrypted data and restores its original plaintext representation. + /// + /// The encrypted binary representation produced by . + /// The decrypted plaintext string. + /// + /// The method should reject ciphertext that is malformed, corrupted, + /// tampered with, or cannot be decrypted using the configured key. + /// + string Decrypt(byte[] ciphertext); +} diff --git a/src/Core/KeyManagement/Interfaces/IKeyGenerator.cs b/src/Core/KeyManagement/Interfaces/IKeyGenerator.cs new file mode 100644 index 0000000..03fe2c3 --- /dev/null +++ b/src/Core/KeyManagement/Interfaces/IKeyGenerator.cs @@ -0,0 +1,34 @@ +using Core.KeyManagement.DTO; +using Microsoft.IdentityModel.Tokens; + +namespace Core.KeyManagement.Interfaces; + +/// +/// Defines contract for generating RSA key pairs used by the JWT key management system. +/// +/// +/// +/// generate RSA key pairs together with the metadata required +/// to identify and manage the generated keys throughout their lifecycle. +/// keys can be used during initial key store provisioning, key +/// rotation, or other cryptographic key management operations. +/// The RSA key size can be configured by the caller. Implementations should +/// enforce any minimum or maximum key size required by their security policy. +/// +/// +public interface IKeyGenerator +{ + /// + /// Generates new RSA key pair and its associated metadata. + /// + /// The RSA key size in bits. Defaults to 4096. + /// + /// A tuple containing the generated and + /// its associated . + /// + /// + /// The generated key should contain both public and private key material + /// and must be protected appropriately when persisted. + /// + (RsaSecurityKey Key, KeyMetadata Meta) Generate(int rsaBits = 4096); +} diff --git a/src/Core/KeyManagement/Interfaces/IKeyStoreRepository.cs b/src/Core/KeyManagement/Interfaces/IKeyStoreRepository.cs new file mode 100644 index 0000000..38d852a --- /dev/null +++ b/src/Core/KeyManagement/Interfaces/IKeyStoreRepository.cs @@ -0,0 +1,43 @@ +namespace Core.KeyManagement.Interfaces; + +/// +/// Defines an abstraction for persisting and loading protected keystore data. +/// +/// +/// +/// The repository is responsible only for persistence. It does not perform +/// encryption or decryption of keystore contents. +/// +/// Keystore data should be encrypted by before +/// being passed to and decrypted after being returned +/// by . +/// +/// +public interface IKeyStoreRepository +{ + /// + /// Loads the persisted keystore data. + /// + /// + /// The returned data is expected to contain the encrypted representation + /// produced by the configured . + /// + /// + /// A containing the encrypted keystore data. + /// Returns an empty memory region when no persisted keystore exists. + /// + Task> LoadAsync(); + + /// + /// Persists encrypted keystore data. + /// + /// + /// The repository stores the provided data as-is and does not perform + /// encryption itself. + /// + /// The encrypted keystore data to persist. + /// + /// A task representing the asynchronous persistence operation. + /// + Task SaveAsync(ReadOnlyMemory data); +} diff --git a/src/Core/KeyManagement/Services/AesKeyEncryptor.cs b/src/Core/KeyManagement/Services/AesKeyEncryptor.cs new file mode 100644 index 0000000..39cbec7 --- /dev/null +++ b/src/Core/KeyManagement/Services/AesKeyEncryptor.cs @@ -0,0 +1,126 @@ +using System.Security.Cryptography; +using System.Text; +using Core.KeyManagement.Interfaces; + +namespace Core.KeyManagement.Services; + +/// +/// Provides AES256-CBC encryption and decryption for persisted keystore data. +/// +/// +/// +/// The encryptor derives its symmetric encryption key from Base64 encoded +/// 256 bit master key supplied during construction. +/// +/// Each encryption operation generates cryptographically secure random +/// initialization vector (IV). The IV is prefixed to the ciphertext so that +/// it can be recovered during decryption. +/// +/// +/// AES-CBC provides confidentiality but does not provide authenticated +/// integrity protection. The encrypted representation must therefore be +/// protected against tampering by another mechanism, or this implementation +/// should be replaced with an authenticated encryption mode such as AES-GCM. +/// +/// +public sealed class AesKeyEncryptor : IKeyEncryptor +{ + private readonly byte[] _key; + + /// + /// Initializes new instance of the class. + /// + /// Base64 encoded 256 bit AES key. The decoded value must contain exactly 32 bytes. + /// Thrown when is not a valid Base64 string. + /// Thrown when the decoded key does not contain exactly 32 bytes. + public AesKeyEncryptor(string masterKeyBase64) + { + _key = Convert.FromBase64String(masterKeyBase64); + + if (_key.Length != 32) + { + throw new InvalidOperationException( + "Master key must be exactly 32 bytes (256 bits) when decoded from Base64."); + } + } + + /// + /// Encrypts plaintext using AES-256-CBC with a randomly generated IV. + /// + /// The UTF-8 text to encrypt. + /// + /// A byte array containing the randomly generated IV followed by the + /// encrypted ciphertext. + /// + public byte[] Encrypt(string plaintext) + { + using var aes = Aes.Create(); + + aes.Key = _key; + aes.GenerateIV(); + + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var ciphertext = Transform(aes.CreateEncryptor(), plaintextBytes); + + return Combine(aes.IV, ciphertext); + } + + /// + /// Decrypts an AES256-CBC encrypted payload. + /// + /// + /// A byte array containing the IV followed by the ciphertext produced + /// by . + /// + /// The decrypted UTF-8 plaintext. + /// Thrown when the encrypted payload does not contain a complete IV. + /// Thrown when the ciphertext cannot be decrypted using the configured key. + public string Decrypt(byte[] blob) + { + using var aes = Aes.Create(); + + aes.Key = _key; + + var ivLength = aes.BlockSize / 8; + + if (blob.Length < ivLength) + { + throw new ArgumentException( + "Encrypted payload does not contain a complete initialization vector.", + nameof(blob)); + } + + var iv = blob[..ivLength]; + var ciphertext = blob[ivLength..]; + + aes.IV = iv; + + var plaintextBytes = Transform( + aes.CreateDecryptor(), + ciphertext); + + return Encoding.UTF8.GetString(plaintextBytes); + } + + private static byte[] Transform( + ICryptoTransform transform, + byte[] data) => + transform.TransformFinalBlock(data, 0, data.Length); + + private static byte[] Combine( + byte[] first, + byte[] second) + { + var result = new byte[first.Length + second.Length]; + + Buffer.BlockCopy(first, 0, result, 0, first.Length); + Buffer.BlockCopy( + second, + 0, + result, + first.Length, + second.Length); + + return result; + } +} diff --git a/src/Core/KeyManagement/Services/JwtKeyStore.cs b/src/Core/KeyManagement/Services/JwtKeyStore.cs new file mode 100644 index 0000000..1a28883 --- /dev/null +++ b/src/Core/KeyManagement/Services/JwtKeyStore.cs @@ -0,0 +1,378 @@ +using System.Buffers; +using System.Buffers.Text; +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Core.KeyManagement.DTO; +using Core.KeyManagement.Interfaces; +using Microsoft.IdentityModel.Tokens; + +namespace Core.KeyManagement.Services; + +/// +/// Manages RSA signing keys used for JWT issuance and signature verification. +/// +/// +/// +/// Maintains RSA signing keys in memory together with their metadata, +/// signing credentials, and public JWK representation. +/// +/// Supports key store initialization, key rotation, key revocation, signing +/// key lookup, and JWKS public key discovery. +/// +/// +public sealed class JwtKeyStore( + IKeyStoreRepository repository, + IKeyEncryptor encryptor, + IKeyGenerator generator) + : IJwtKeyStore, IAsyncDisposable +{ + private readonly ConcurrentDictionary _keys = new(); + private volatile string? _activeKid; + private volatile PublicJwkDto[]? _cachedJwks; + private int _disposed; + + /// + /// Initializes the key store by loading persisted encrypted key material. + /// + /// + /// If no persisted key store exists, new RSA signing key is generated + /// and persisted automatically. + /// + public async Task InitializeAsync() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposed) != 0, + this); + + var encrypted = await repository.LoadAsync().ConfigureAwait(false); + + if (encrypted.Length == 0) + { + await RotateAsync().ConfigureAwait(false); + return; + } + + var decrypted = encryptor.Decrypt(encrypted.ToArray()); + + var keystore = JsonSerializer.Deserialize(decrypted) + ?? throw new InvalidOperationException( + "The persisted keystore could not be deserialized."); + + foreach (var record in keystore.Records) + { + var rsa = RSA.Create(); + + try + { + rsa.ImportRSAPrivateKey( + Convert.FromBase64String(record.PrivateKeyBase64), + out _); + + var key = new RsaSecurityKey(rsa) + { + KeyId = record.Metadata.Kid + }; + + var publicParameters = rsa.ExportParameters(false); + + var entry = new KeyEntry + { + Key = key, + Signing = new SigningCredentials( + key, + SecurityAlgorithms.RsaSha256), + Meta = record.Metadata, + N = Base64UrlEncode(publicParameters.Modulus!), + E = Base64UrlEncode(publicParameters.Exponent!) + }; + + _keys[record.Metadata.Kid] = entry; + } + catch + { + rsa.Dispose(); + throw; + } + } + + if (!_keys.ContainsKey(keystore.ActiveKid)) + { + throw new InvalidOperationException( + $"The persisted keystore references unknown active key '{keystore.ActiveKid}'."); + } + + _activeKid = keystore.ActiveKid; + RebuildJwksCache(); + } + + /// + /// Retrieves the signing credentials associated with the currently active key. + /// + /// The used to sign newly issued JWTs. + /// Thrown when no active signing key is available or the active key has been revoked. + public SigningCredentials GetActiveSigningCredentials() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposed) != 0, + this); + + var activeKid = _activeKid; + + if (string.IsNullOrWhiteSpace(activeKid) || + !_keys.TryGetValue(activeKid, out var entry)) + { + throw new InvalidOperationException( + "No active signing key is available."); + } + + if (entry.Meta.Revoked) + { + throw new InvalidOperationException( + $"The active signing key '{activeKid}' has been revoked."); + } + + return entry.Signing; + } + + /// + /// Retrieves signing credentials associated with specific key identifier. + /// + /// The unique key identifier. + /// + /// The matching , or null when + /// the key does not exist or has been revoked. + /// + public SigningCredentials? GetSigningCredentialsByKid(string kid) + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposed) != 0, + this); + + return _keys.TryGetValue(kid, out var entry) && + !entry.Meta.Revoked + ? entry.Signing + : null; + } + + /// + /// Returns the public signing keys exposed by the key store in JWKS-compatible format. + /// + /// + /// A collection of instances representing + /// all non revoked public signing keys. + /// + public IEnumerable GetPublicJwks() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposed) != 0, + this); + + var cache = _cachedJwks; + + if (cache is not null) + return cache; + + RebuildJwksCache(); + + return _cachedJwks!; + } + + private void RebuildJwksCache() + { + var keys = _keys.Values + .Where(entry => !entry.Meta.Revoked) + .Select(entry => new PublicJwkDto + { + Kty = "RSA", + Use = "sig", + Kid = entry.Meta.Kid, + Alg = entry.Meta.Algorithm, + N = entry.N, + E = entry.E + }) + .ToArray(); + + _cachedJwks = keys; + } + + /// + /// Generates and activates a new RSA signing key. + /// + /// The RSA key size in bits. Defaults to 4096 bits. + /// Metadata describing the newly generated signing key. + public async Task RotateAsync(int rsaBits = 4096) + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposed) != 0, + this); + + var (key, metadata) = generator.Generate(rsaBits); + + key.KeyId ??= metadata.Kid; + + var publicParameters = key.Rsa.ExportParameters(false); + + var entry = new KeyEntry + { + Key = key, + Signing = new SigningCredentials( + key, + SecurityAlgorithms.RsaSha256), + Meta = metadata, + N = Base64UrlEncode(publicParameters.Modulus!), + E = Base64UrlEncode(publicParameters.Exponent!) + }; + + _keys[metadata.Kid] = entry; + _activeKid = metadata.Kid; + _cachedJwks = null; + + await PersistAsync().ConfigureAwait(false); + + return metadata; + } + + /// + /// Revokes the signing key associated with the specified key identifier. + /// + /// + /// The unique key identifier of the key to revoke. + /// + /// + /// true when the key was found and revoked; otherwise, false. + /// + /// + /// Thrown when attempting to revoke the currently active signing key. + /// + public async Task RevokeAsync(string kid) + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposed) != 0, + this); + + if (!_keys.TryGetValue(kid, out var entry)) + return false; + + if (string.Equals(_activeKid, kid, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The active signing key cannot be revoked."); + } + + _keys[kid] = entry with + { + Meta = entry.Meta with + { + Revoked = true + } + }; + + _cachedJwks = null; + + await PersistAsync().ConfigureAwait(false); + + return true; + } + + /// + /// Retrieves metadata associated with a specific signing key. + /// + /// The unique key identifier. + /// + /// The associated with the specified key, + /// or null when the key does not exist. + /// + public KeyMetadata? GetMetadata(string kid) + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposed) != 0, + this); + + return _keys.TryGetValue(kid, out var entry) + ? entry.Meta + : null; + } + + private async Task PersistAsync() + { + var records = _keys.Values.Select(entry => new KeystoreRecordOnDisk + { + Metadata = entry.Meta, + PrivateKeyBase64 = Convert.ToBase64String( + entry.Key.Rsa.ExportRSAPrivateKey()) + }) + .ToList(); + + var keystore = new KeystoreOnDisk + { + ActiveKid = _activeKid + ?? throw new InvalidOperationException( + "Cannot persist a keystore without an active key."), + Records = records + }; + + var json = JsonSerializer.Serialize(keystore); + var encrypted = encryptor.Encrypt(json); + + await repository + .SaveAsync(encrypted) + .ConfigureAwait(false); + } + + private static string Base64UrlEncode(byte[] input) + { + ArgumentNullException.ThrowIfNull(input); + + Span buffer = stackalloc byte[ + Base64.GetMaxEncodedToUtf8Length(input.Length)]; + + var status = Base64.EncodeToUtf8( + input, + buffer, + out _, + out var bytesWritten); + + if (status != OperationStatus.Done) + { + throw new InvalidOperationException( + $"Base64 encoding failed: {status}"); + } + + for (var i = 0; i < bytesWritten; i++) + { + buffer[i] = buffer[i] switch + { + (byte)'+' => (byte)'-', + (byte)'/' => (byte)'_', + _ => buffer[i] + }; + } + + var end = bytesWritten; + + while (end > 0 && buffer[end - 1] == (byte)'=') + end--; + + return Encoding.ASCII.GetString(buffer[..end]); + } + + /// + /// Releases all cryptographic resources owned by the key store. + /// + public ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return ValueTask.CompletedTask; + + foreach (var entry in _keys.Values) + entry.Key.Rsa.Dispose(); + + _keys.Clear(); + _cachedJwks = null; + _activeKid = null; + + return ValueTask.CompletedTask; + } +} diff --git a/src/Core/KeyManagement/Services/RsaKeyGenerator.cs b/src/Core/KeyManagement/Services/RsaKeyGenerator.cs new file mode 100644 index 0000000..b7dcc18 --- /dev/null +++ b/src/Core/KeyManagement/Services/RsaKeyGenerator.cs @@ -0,0 +1,50 @@ +using System.Security.Cryptography; +using Core.KeyManagement.DTO; +using Core.KeyManagement.Interfaces; +using Microsoft.IdentityModel.Tokens; + +namespace Core.KeyManagement.Services; + +/// +/// Generates RSA key pairs with unique identifiers and associated metadata. +/// +/// +/// +/// Uses RSA keys for JWT signing and generates a unique key identifier (KID) +/// for every newly created key. +/// +/// The generated retains the RSA private key +/// material and can therefore be used for signing and secure persistence. +/// The default key size is 4096 bits and can be configured when generating a new key. +/// +/// +public sealed class RsaKeyGenerator : IKeyGenerator +{ + /// + /// Generates new RSA key pair and its associated metadata. + /// + /// The RSA key size in bits. Defaults to 4096 bits. + /// + /// A tuple containing the generated and + /// its associated . + /// + public (RsaSecurityKey Key, KeyMetadata Meta) Generate(int rsaBits = 4096) + { + var rsa = RSA.Create(rsaBits); + var kid = Guid.NewGuid().ToString("N"); + + var key = new RsaSecurityKey(rsa) + { + KeyId = kid + }; + + var meta = new KeyMetadata + { + Kid = kid, + CreatedAt = DateTimeOffset.UtcNow, + Revoked = false + }; + + return (key, meta); + } +} diff --git a/src/Core/Options/ErrorMetadataOptions.cs b/src/Core/Options/ErrorMetadataOptions.cs new file mode 100644 index 0000000..3bd2ecf --- /dev/null +++ b/src/Core/Options/ErrorMetadataOptions.cs @@ -0,0 +1,12 @@ +namespace Core.Options; + +/// +/// Represents configuration options for error documentation metadata. +/// +public sealed record ErrorMetadataOptions +{ + /// + /// Gets the base URL used to reference error documentation. + /// + public string DocsBaseUrl { get; init; } = "https://localhost:8080/errors"; +} \ No newline at end of file diff --git a/Domain/Features/TokenKeyBindings/IKeyBindingRepository.cs b/src/Core/TokenKeyBindings/Interfaces/IKeyBindingRepository.cs similarity index 77% rename from Domain/Features/TokenKeyBindings/IKeyBindingRepository.cs rename to src/Core/TokenKeyBindings/Interfaces/IKeyBindingRepository.cs index d83eb63..037a0bb 100644 --- a/Domain/Features/TokenKeyBindings/IKeyBindingRepository.cs +++ b/src/Core/TokenKeyBindings/Interfaces/IKeyBindingRepository.cs @@ -1,14 +1,17 @@ -namespace Domain.Features.TokenKeyBindings; +using Core.TokenKeyBindings.Services; + +namespace Core.TokenKeyBindings.Interfaces; /// -/// Repository abstraction for managing entities. +/// Defines repository abstraction for persisting and retrieving +/// entities. /// /// -/// -/// Adds, retrieves, updates, and lists key bindings for developer tokens. -/// Supports queries by token ID and signing key ID. -/// Designed for asynchronous persistence operations in a DDD context. -/// +/// +/// Provides asynchronous persistence operations for token to signing key bindings. +/// Supports adding, retrieving, updating, and listing bindings associated +/// with developer tokens and signing keys. +/// /// public interface IKeyBindingRepository { @@ -39,4 +42,4 @@ public interface IKeyBindingRepository /// The unique identifier of the developer token. /// A collection of entities. Task> ListByTokenAsync(Guid tokenId); -} \ No newline at end of file +} diff --git a/src/Core/TokenKeyBindings/Interfaces/IKeyBindingService.cs b/src/Core/TokenKeyBindings/Interfaces/IKeyBindingService.cs new file mode 100644 index 0000000..38e0baa --- /dev/null +++ b/src/Core/TokenKeyBindings/Interfaces/IKeyBindingService.cs @@ -0,0 +1,95 @@ +using Core.TokenKeyBindings.Services; + +namespace Core.TokenKeyBindings.Interfaces; + +/// +/// Defines contract for managing bindings between developer tokens and RSA signing keys. +/// +/// +/// +/// Provides operations for creating, updating, rebinding, retrieving, listing, +/// and revoking token to key bindings. +/// +/// Key bindings associate developer token with specific signing key and +/// its corresponding public key material. +/// +/// +public interface IKeyBindingService +{ + /// + /// Creates new key binding for developer token. + /// + /// The unique identifier of the developer token. + /// The unique identifier of the signing key to bind. + /// The public key associated with the signing key. + /// The newly created . + Task CreateBindingAsync( + Guid tokenId, + string signingKeyId, + string publicKey); + + /// + /// Rebinds an existing key binding to different signing key. + /// + /// The unique identifier of the developer token. + /// The identifier of the signing key currently associated with the binding. + /// The identifier of the new signing key. + /// The public key associated with the new signing key. + /// + /// The updated , or null if the + /// existing binding could not be found. + /// + Task RebindAsync( + Guid tokenId, + string signingKeyId, + string newSigningKeyId, + string newPublicKey); + + /// + /// Updates the public key associated with an existing key binding. + /// + /// The unique identifier of the developer token. + /// The identifier of the signing key whose public key is being updated. + /// The new public key associated with the signing key. + /// + /// The updated , or null if the + /// binding could not be found. + /// + Task UpdatePublicKeyAsync( + Guid tokenId, + string signingKeyId, + string newPublicKey); + + /// + /// Revokes all key bindings associated with developer token. + /// + /// The unique identifier of the developer token. + /// + /// true if one or more bindings were successfully revoked; + /// otherwise, false. + /// + Task RevokeAsync(Guid tokenId); + + /// + /// Lists all key bindings associated with a developer token. + /// + /// The unique identifier of the developer token. + /// + /// A collection of entities associated + /// with the specified developer token. + /// + Task> ListBindingsAsync(Guid tokenId); + + /// + /// Retrieves specific key binding for developer token and signing key. + /// + /// The unique identifier of the developer token. + /// The identifier of the signing key to retrieve. + /// + /// The matching , or null if no + /// matching binding exists. + /// + Task GetBindingAsync( + Guid tokenId, + string signingKeyId); +} diff --git a/src/Core/TokenKeyBindings/Services/KeyBindingService.cs b/src/Core/TokenKeyBindings/Services/KeyBindingService.cs new file mode 100644 index 0000000..1c9dece --- /dev/null +++ b/src/Core/TokenKeyBindings/Services/KeyBindingService.cs @@ -0,0 +1,150 @@ +using Core.TokenKeyBindings.Interfaces; + +namespace Core.TokenKeyBindings.Services; + +/// +/// Provides application level operations for managing bindings between +/// developer tokens and RSA signing keys. +/// +/// +/// +/// Coordinates key binding operations through +/// and applies the domain behavior exposed by . +/// +/// +public sealed class KeyBindingService(IKeyBindingRepository repository) : IKeyBindingService +{ + /// + /// Creates new key binding for the specified developer token. + /// + /// The unique identifier of the developer token. + /// The unique identifier of the RSA signing key to bind. + /// The public key associated with the signing key. + /// The newly persisted . + public Task CreateBindingAsync( + Guid tokenId, + string signingKeyId, + string publicKey) + { + var binding = new TokenKeyBinding( + tokenId, + signingKeyId, + publicKey); + + return repository.AddAsync(binding); + } + + /// + /// Rebinds an existing token key binding to a different signing key. + /// + /// The unique identifier of the developer token. + /// The identifier of the signing key currently associated with the binding. + /// The identifier of the new signing key. + /// The public key associated with the new signing key. + /// + /// The updated , or null if the + /// existing binding could not be found. + /// + public async Task RebindAsync( + Guid tokenId, + string signingKeyId, + string newSigningKeyId, + string newPublicKey) + { + var binding = await repository.GetAsync( + tokenId, + signingKeyId); + + if (binding is null) + return null; + + var updated = binding.Rebind( + newSigningKeyId, + newPublicKey); + + await repository.UpdateAsync(updated); + + return updated; + } + + /// + /// Updates the public key of an existing token key binding. + /// + /// The unique identifier of the developer token. + /// The identifier of the signing key associated with the binding. + /// The new public key associated with the signing key. + /// + /// The updated , or null if the + /// binding could not be found. + /// + public async Task UpdatePublicKeyAsync( + Guid tokenId, + string signingKeyId, + string newPublicKey) + { + var binding = await repository.GetAsync( + tokenId, + signingKeyId); + + if (binding is null) + return null; + + var updated = binding.UpdatePublicKey(newPublicKey); + + await repository.UpdateAsync(updated); + return updated; + } + + /// + /// Revokes all active key bindings associated with a developer token. + /// + /// The unique identifier of the developer token. + /// + /// true if at least one binding was revoked; otherwise, + /// false when no active bindings were found. + /// + public async Task RevokeAsync(Guid tokenId) + { + var bindings = await repository.ListByTokenAsync(tokenId); + var anyUpdated = false; + + foreach (var binding in bindings) + { + if (binding.Revoked) + continue; + + var revoked = binding.Revoke(); + + await repository.UpdateAsync(revoked); + + anyUpdated = true; + } + + return anyUpdated; + } + + /// + /// Retrieves all key bindings associated with a developer token. + /// + /// + /// The unique identifier of the developer token. + /// + /// + /// A collection of entities associated + /// with the specified token. + /// + public Task> ListBindingsAsync(Guid tokenId) => + repository.ListByTokenAsync(tokenId); + + /// + /// Retrieves specific key binding for developer token and signing key. + /// + /// The unique identifier of the developer token. + /// The identifier of the signing key to retrieve. + /// + /// The matching , or null if no + /// matching binding exists. + /// + public Task GetBindingAsync(Guid tokenId, string signingKeyId) => + repository.GetAsync(tokenId, signingKeyId); +} \ No newline at end of file diff --git a/src/Core/TokenKeyBindings/Services/TokenKeyBinding.cs b/src/Core/TokenKeyBindings/Services/TokenKeyBinding.cs new file mode 100644 index 0000000..11a80ef --- /dev/null +++ b/src/Core/TokenKeyBindings/Services/TokenKeyBinding.cs @@ -0,0 +1,105 @@ +namespace Core.TokenKeyBindings.Services; + +/// +/// Represents binding between developer token and the RSA signing key +/// used to sign its JWTs. +/// +/// +/// +/// Each binding associates a developer token with a specific signing key +/// identified by . +/// The binding stores the corresponding public key used for signature +/// verification and supports key rotation, public key updates, and revocation. +/// +/// +public sealed record TokenKeyBinding +{ + /// + /// Gets the unique identifier of the developer token associated with this binding. + /// + public Guid TokenId { get; init; } + + /// + /// Gets the unique identifier of the RSA signing key associated with this binding. + /// + public string SigningKeyId { get; private set; } = null!; + + /// + /// Gets the public key associated with the signing key. + /// + public string PublicKey { get; private set; } = null!; + + /// + /// Gets the date and time at which the current key binding was established + /// or last changed. + /// + public DateTimeOffset BoundAt { get; private set; } + + /// + /// Gets value indicating whether this key binding has been revoked. + /// + public bool Revoked { get; private set; } + + /// + /// Initializes new instance of the record. + /// + /// The unique identifier of the developer token. + /// The unique identifier of the RSA signing key. + /// The public key associated with the signing key. + /// + /// Thrown when or + /// is null. + /// + public TokenKeyBinding( + Guid tokenId, + string signingKeyId, + string publicKey) + { + TokenId = tokenId; + SigningKeyId = signingKeyId + ?? throw new ArgumentNullException(nameof(signingKeyId)); + PublicKey = publicKey + ?? throw new ArgumentNullException(nameof(publicKey)); + BoundAt = DateTimeOffset.UtcNow; + } + + /// + /// Creates a new binding state associated with a different RSA signing key. + /// + /// The unique identifier of the new signing key. + /// The public key associated with the new signing key. + /// containing the new signing key and public key information. + public TokenKeyBinding Rebind( + string newSigningKeyId, + string newPublicKey) + => this with + { + SigningKeyId = newSigningKeyId, + PublicKey = newPublicKey, + BoundAt = DateTimeOffset.UtcNow + }; + + /// + /// Creates a new binding state with an updated public key. + /// + /// The new public key associated with the current signing key. + /// A new containing the updated public key. + public TokenKeyBinding UpdatePublicKey(string updatedPublicKey) + => this with + { + PublicKey = updatedPublicKey, + BoundAt = DateTimeOffset.UtcNow + }; + + /// + /// Creates a new binding state with the binding marked as revoked. + /// + /// A new + /// with set to true. + public TokenKeyBinding Revoke() + => this with + { + Revoked = true, + BoundAt = DateTimeOffset.UtcNow + }; +} \ No newline at end of file diff --git a/src/Host/Cli/AuthKitServerOptions.cs b/src/Host/Cli/AuthKitServerOptions.cs new file mode 100644 index 0000000..8ec6467 --- /dev/null +++ b/src/Host/Cli/AuthKitServerOptions.cs @@ -0,0 +1,65 @@ +namespace Host.Cli; + +/// +/// Represents configuration options for the AuthKit server. +/// +/// +/// +/// Contains the network endpoint, token issuer, storage configuration, +/// and logging configuration used by the server. +/// +/// +public sealed class AuthKitServerOptions +{ + /// + /// Gets or sets the HTTP endpoint on which the AuthKit server listens to. + /// + public string Host { get; set; } = "http://0.0.0.0:7070"; + + /// + /// Gets or sets the issuer identifier used by AuthKit when issuing tokens. + /// + public string Issuer { get; set; } = "authkit.local"; + + /// + /// Gets or sets the storage configuration used by the AuthKit server. + /// + public StorageOptions Storage { get; set; } = new(); + + /// + /// Gets or sets the logging configuration used by the AuthKit server. + /// + public LoggingOptions Logging { get; set; } = new(); + + /// + /// Represents configuration options for AuthKit server persistence. + /// + public sealed class StorageOptions + { + /// + /// Gets or sets the storage provider used to persist AuthKit data. + /// + public string Provider { get; set; } = "marten"; + + /// + /// Gets or sets the connection string used by the configured storage provider. + /// + public string ConnectionString { get; set; } = ""; + } + + /// + /// Represents logging configuration options for the AuthKit server. + /// + public sealed class LoggingOptions + { + /// + /// Gets or sets value indicating whether server logging is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the minimum logging level. + /// + public string Level { get; set; } = "Information"; + } +} \ No newline at end of file diff --git a/src/Host/Cli/ServerHost.cs b/src/Host/Cli/ServerHost.cs new file mode 100644 index 0000000..52cd45b --- /dev/null +++ b/src/Host/Cli/ServerHost.cs @@ -0,0 +1,102 @@ +using Host.Plugins; +using Microsoft.Extensions.Options; +using Spectre.Console; + +namespace Host.Cli; + +/// +/// Background service responsible for displaying AuthKit server startup information +/// and maintaining the server host lifetime. +/// +/// +/// +/// Displays the configured environment, listening endpoint, ports, issuer, +/// and loaded plugin information when the server starts. +/// The service remains active until the host requests shutdown through the +/// provided . +/// +/// +public sealed class ServerHost( + IOptions opts, + IHostEnvironment env, + IReadOnlyList plugins) : BackgroundService +{ + private readonly AuthKitServerOptions _opts = opts.Value; + + /// + /// Executes the server host background service. + /// + /// Token that is signaled when the host is shutting down. + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Yield(); + + AnsiConsole.Write(new FigletText("AuthKit") + .Color(Color.Green) + .Centered()); + + var restPort = Environment.GetEnvironmentVariable("DEV_CERT_PORT_REST") ?? "5000"; + var grpcPort = Environment.GetEnvironmentVariable("DEV_CERT_PORT_GRPC") ?? "5001"; + + var info = new Panel( + $""" + [bold blue]Environment:[/] {env.EnvironmentName} + [bold blue]Listening on:[/] {_opts.Host} + [bold blue]REST port:[/] {restPort} [bold blue]gRPC port:[/] {grpcPort} + [bold yellow]Issuer:[/] {_opts.Issuer} + [grey]Started at {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}[/] + """) + { + Border = BoxBorder.Double, + Padding = new Padding(1, 1), + Header = new PanelHeader("Server Started") + }; + AnsiConsole.Write(info); + + var table = new Table().Border(TableBorder.Rounded); + table.AddColumn("[green]Plugin[/]"); + table.AddColumn("[yellow]Version[/]"); + table.AddColumn("[grey]Description[/]"); + table.AddColumn("[cyan]Middleware[/]"); + table.AddColumn("[magenta]Security schemes[/]"); + + if (plugins.Count == 0) + { + table.AddRow("[grey]none loaded[/]", "-", "-", "-", "-"); + } + else + { + foreach (var lp in plugins) + { + var description = lp.Plugin.Description ?? "-"; + var middleware = lp.Plugin.MiddlewareType?.Name ?? "-"; + var schemes = lp.Plugin.GetSecuritySchemes(); + var schemeNames = schemes.Count > 0 ? string.Join(", ", schemes.Keys) : "-"; + + table.AddRow( + $"[bold]{lp.Plugin.Name}[/]", + $"v{lp.Plugin.Version}", + description, + middleware, + schemeNames); + } + } + + AnsiConsole.Write(new Panel(table) + { + Border = BoxBorder.Rounded, + Header = new PanelHeader($"Plugins ({plugins.Count})") + }); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken); + } + catch (OperationCanceledException) + { + // Expected during shutdown. + } + + AnsiConsole.MarkupLine("[red]Server shutting down...[/]"); + } +} diff --git a/src/Host/Configuration/AppMiddlewareConfiguration.cs b/src/Host/Configuration/AppMiddlewareConfiguration.cs new file mode 100644 index 0000000..ad8d632 --- /dev/null +++ b/src/Host/Configuration/AppMiddlewareConfiguration.cs @@ -0,0 +1,58 @@ +using Host.Plugins; +using Host.Restful.Middleware.Exceptions; + +namespace Host.Configuration; + +/// +/// Provides extension methods for configuring the application's HTTP middleware +/// pipeline. +/// +/// +/// +/// Configures routing, validation and exception handling, plugin-provided +/// middleware, authentication, authorization, and development-only API +/// documentation middleware. +/// +/// +/// Plugin middleware is inserted after the host exception handling middleware +/// and before authentication so plugins can participate in request processing +/// before the authenticated endpoint pipeline is reached. +/// +/// +public static class AppMiddlewareConfiguration +{ + /// + /// Configures the applications HTTP middleware pipeline. + /// + /// + /// The plugins loaded during application startup. Plugins may optionally + /// contribute middleware through their configured middleware type. + /// + /// The configured instance. + public static WebApplication ConfigureMiddleware( + this WebApplication app, + IReadOnlyList plugins) + { + app.UseRouting(); + + app.UseMiddleware(); + app.UseMiddleware(); + + foreach (var plugin in plugins) + { + if (plugin.Plugin.MiddlewareType is { } middlewareType) + app.UseMiddleware(middlewareType); + } + + app.UseAuthentication(); + app.UseAuthorization(); + + if (!app.Environment.IsDevelopment()) + return app; + + app.UseSwagger(); + app.UseSwaggerUI(); + + return app; + } +} diff --git a/src/Host/Configuration/ApplicationInitialization.cs b/src/Host/Configuration/ApplicationInitialization.cs new file mode 100644 index 0000000..ac645f0 --- /dev/null +++ b/src/Host/Configuration/ApplicationInitialization.cs @@ -0,0 +1,120 @@ +using System.Reflection; +using Core.KeyManagement.Services; +using Core.Options; +using Core.KeyManagement.Entity; +using FluentValidation; +using Host.Plugins; +using Host.KeyManagement.Repositories; +using Host.ServiceDiscovery; +using Host.TokenKeyBindings.Repositories; + +namespace Host.Configuration; + +/// +/// Provides application service registration and dependency injection configuration. +/// +/// +/// +/// Configures logging, options, HTTP context access, MVC controllers, service +/// discovery, FluentValidation validators, and application-specific options. +/// Controllers contributed by dynamically loaded plugins are registered as MVC +/// application parts during application initialization. +/// +/// +/// Service discovery scans the relevant Host and Core assemblies while excluding +/// infrastructure services that require explicit registration or configuration. +/// +/// +public static class ApplicationInitialization +{ + /// + /// Configures application services and registers dependencies with the + /// dependency injection container. + /// + /// The service collection used to register application dependencies. + /// The application configuration used to configure registered servicesand options. + /// + /// The plugins loaded during application startup. Each plugin assembly is + /// registered as an MVC application part to expose its controllers. + /// + /// The configured instance. + public static IServiceCollection ConfigureApp( + this IServiceCollection services, + IConfiguration configuration, + IReadOnlyList plugins) + { + services.AddLogging(logging => + { + logging.AddConsole(); + logging.AddConfiguration(configuration.GetSection("Logging")); + }); + + services.AddOptions(); + services.AddHttpContextAccessor(); + + var mvcBuilder = services.AddControllers(); + foreach (var lp in plugins) + mvcBuilder.AddApplicationPart(lp.Assembly); + + var discoveryLogger = LoggerFactory.Create(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Debug); + }).CreateLogger("ServiceDiscovery"); + + services.AddDiscoveredServices(GetRelevantAssemblies(), opts => + { + configuration.GetSection("ServiceDiscovery").Bind(opts); + + opts.ExcludedTypes.Add(typeof(AesKeyEncryptor)); + opts.ExcludedTypes.Add(typeof(RsaKeyGenerator)); + opts.ExcludedTypes.Add(typeof(JwtKeyStore)); + opts.ExcludedTypes.Add(typeof(KeyStoreRepository)); + opts.ExcludedTypes.Add(typeof(InMemoryKeyBindingRepository)); + }, discoveryLogger) + .AddFluentValidation(); + + services.ConfigureAppOptions(configuration); + + return services; + } + + #region Private Options FluentValidation + + extension(IServiceCollection services) + { + /// + /// Registers application option classes and binds them to their + /// corresponding configuration sections. + /// + /// The application configuration containing option values. + private void ConfigureAppOptions(IConfiguration configuration) + { + services.Configure(configuration.GetSection("ErrorMetadata")); + } + + /// + /// Scans the relevant application assemblies and registers all + /// FluentValidation validators with a scoped lifetime. + /// + private void AddFluentValidation() + { + services.Scan(scan => scan + .FromAssemblies(GetRelevantAssemblies()) + .AddClasses(classes => classes.AssignableTo(typeof(AbstractValidator<>))) + .AsImplementedInterfaces() + .WithScopedLifetime()); + } + } + + #endregion + + #region Private Helpers + private static Assembly[] GetRelevantAssemblies() => + [.. new[] + { + typeof(ApplicationInitialization).Assembly, // Host + typeof(SigningKey).Assembly // Core + }.Distinct()]; + #endregion +} diff --git a/src/Host/Configuration/AuthKitConfiguration.cs b/src/Host/Configuration/AuthKitConfiguration.cs new file mode 100644 index 0000000..dcac060 --- /dev/null +++ b/src/Host/Configuration/AuthKitConfiguration.cs @@ -0,0 +1,75 @@ +using Core.KeyManagement.Interfaces; +using Core.KeyManagement.Services; +using Core.TokenKeyBindings.Interfaces; +using Core.TokenKeyBindings.Services; +using Host.KeyManagement.Repositories; +using Host.KeyManagement.Security; +using Host.Restful.Middleware.Exceptions; +using Host.TokenKeyBindings.Repositories; +using Microsoft.AspNetCore.Authorization; + +namespace Host.Configuration; + +/// +/// Provides dependency injection configuration for AuthKit core services. +/// +/// +/// +/// Registers plugin-independent services used by AuthKit for JWT signing key +/// management and token-to-signing-key bindings. +/// +/// +/// These services can be shared by multiple authentication plugins that need +/// to issue, sign, validate, or associate tokens with cryptographic keys. +/// +/// +/// The configuration also registers a custom authorization middleware result +/// handler responsible for producing consistent authorization responses. +/// +/// +public static class AuthKitConfiguration +{ + /// + /// Registers AuthKit core services with the dependency injection container. + /// + /// The service collection used to register AuthKit dependencies. + /// + /// + /// Registers the HTTP context accessor, authorization result handler, JWT + /// signing key infrastructure, encrypted key store services, and token key + /// binding services. + /// + /// + /// The AES master key used to protect persisted key material is read from the + /// Encryption:AES_MASTER_KEY configuration value. + /// + /// + public static void AddAuthKitCore( + this IServiceCollection services) + { + services.AddHttpContextAccessor(); + + services.AddSingleton< + IAuthorizationMiddlewareResultHandler, + CustomAuthorizationMiddlewareResultHandler>(); + + services.AddSingleton(sp => + { + var configuration = sp.GetRequiredService(); + + var key = configuration["Encryption:AES_MASTER_KEY"] + ?? throw new InvalidOperationException( + "Missing AES_MASTER_KEY in configuration."); + + return new AesKeyEncryptor(key); + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddHostedService(); + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/Host/Configuration/EndpointConfiguration.cs b/src/Host/Configuration/EndpointConfiguration.cs similarity index 50% rename from Host/Configuration/EndpointConfiguration.cs rename to src/Host/Configuration/EndpointConfiguration.cs index 6f44a26..b40387f 100644 --- a/Host/Configuration/EndpointConfiguration.cs +++ b/src/Host/Configuration/EndpointConfiguration.cs @@ -1,25 +1,28 @@ using System.Diagnostics; +using Core.KeyManagement.Interfaces; +using Host.Plugins; namespace Host.Configuration; /// -/// Provides extension methods to map application endpoints, including REST, gRPC, health checks, and metrics. +/// Provides extension methods for mapping AuthKit application endpoints. /// /// -/// -/// Maps root ("/") endpoint to a JSON object describing the service, version, environment, and available endpoints. -/// Maps controller routes via MapControllers. -/// Maps health check endpoint ("/health") for monitoring service availability. -/// Maps metrics endpoint ("/metrics") returning service uptime in seconds. -/// +/// +/// Configures the root service information endpoint, controller routes, +/// health monitoring, and basic process uptime metrics. +/// +/// +/// The health endpoint verifies the availability of the JWT key store and +/// executes health checks contributed by loaded authentication plugins. +/// /// public static class EndpointConfiguration { /// - /// Maps application-specific endpoints to the provided . + /// Maps AuthKit application endpoints to the specified web application. /// - /// The to configure endpoints for. - /// The configured instance for method chaining. + /// The configured instance. public static WebApplication MapAppEndpoints(this WebApplication app) { app.MapGet("/", () => Results.Json(new @@ -44,14 +47,31 @@ public static WebApplication MapAppEndpoints(this WebApplication app) })); app.MapControllers(); - app.MapGet("/health", () => Results.Ok(new { status = "Healthy", time = DateTime.UtcNow })) + app.MapGet("/health", async (HttpContext context, IJwtKeyStore keyStore, IReadOnlyList plugins) => + { + var keyStoreHealthy = keyStore.GetPublicJwks().Any(); + + var pluginResults = new Dictionary(); + foreach (var lp in plugins) + pluginResults[lp.Plugin.Name] = await lp.Plugin.CheckHealthAsync(context.RequestServices); + + var healthy = keyStoreHealthy && pluginResults.Values.All(ok => ok); + + return Results.Json(new + { + status = healthy ? "Healthy" : "Unhealthy", + time = DateTime.UtcNow, + jwtKeyStore = keyStoreHealthy ? "Healthy" : "Unhealthy", + plugins = pluginResults + }, statusCode: healthy ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable); + }) .WithName("HealthCheck") .WithTags("Monitoring"); app.MapGet("/metrics", () => Results.Json(new { uptime = (DateTime.UtcNow - Process.GetCurrentProcess().StartTime).TotalSeconds })) .WithName("Metrics") .WithTags("Monitoring"); - + return app; } -} \ No newline at end of file +} diff --git a/src/Host/Configuration/EventHandlerRegistrationFactory.cs b/src/Host/Configuration/EventHandlerRegistrationFactory.cs new file mode 100644 index 0000000..735781d --- /dev/null +++ b/src/Host/Configuration/EventHandlerRegistrationFactory.cs @@ -0,0 +1,38 @@ +using Core.KeyManagement.Services; +using Host.Plugins; +using Wolverine; + +namespace Host.Configuration; + +/// +/// Provides extension methods for registering Wolverine event handlers. +/// +/// +/// +/// Registers the Core assembly for Wolverine handler discovery and includes +/// assemblies contributed by dynamically loaded AuthKit plugins. +/// +/// +/// This allows plugin provided command and query handlers to be discovered +/// without requiring the Host project to reference plugin assemblies directly. +/// +/// +public static class EventHandlerRegistrationFactory +{ + /// + /// Registers application and plugin assemblies for Wolverine handler discovery. + /// + /// The Wolverine configuration options to modify. + /// + /// The plugins loaded during application startup whose assemblies may + /// contain Wolverine handlers. + /// + public static void IncludeEventHandlers( + this WolverineOptions opts, + IReadOnlyList plugins) + { + opts.Discovery.IncludeAssembly(typeof(JwtKeyStore).Assembly); + foreach (var plugin in plugins) + opts.Discovery.IncludeAssembly(plugin.Assembly); + } +} diff --git a/Host/Configuration/GrpcConfiguration.cs b/src/Host/Configuration/GrpcConfiguration.cs similarity index 56% rename from Host/Configuration/GrpcConfiguration.cs rename to src/Host/Configuration/GrpcConfiguration.cs index 30a4549..6406ac3 100644 --- a/Host/Configuration/GrpcConfiguration.cs +++ b/src/Host/Configuration/GrpcConfiguration.cs @@ -1,24 +1,27 @@ -using Host.Services; +using Host.Grpc; namespace Host.Configuration; /// -/// Provides extension methods to configure gRPC services and map gRPC endpoints in the application. +/// Provides extension methods for configuring gRPC services and endpoints. /// /// -/// -/// Registers gRPC services in the DI container. -/// Supports adding global interceptors for exception handling or logging (commented placeholder included). -/// Maps gRPC service endpoints to the application's request pipeline. -/// +/// +/// Registers gRPC services with the applications dependency injection +/// container and maps the available gRPC service implementations to the +/// request pipeline. +/// Global gRPC interceptors can be registered through the gRPC configuration +/// when cross cutting concerns such as exception handling or request logging +/// are required. +/// /// public static class GrpcConfiguration { /// - /// Adds gRPC services to the . + /// Registers gRPC services with the dependency injection container. /// - /// The to configure. - /// The configured for method chaining. + /// The service collection used to register gRPC services. + /// The configured instance. public static IServiceCollection AddGrpcServices(this IServiceCollection services) { services.AddGrpc(options => @@ -39,4 +42,4 @@ public static WebApplication MapGrpcEndpoints(this WebApplication app) app.MapGrpcService(); return app; } -} \ No newline at end of file +} diff --git a/Host/Configuration/InfrastructureConfiguration.cs b/src/Host/Configuration/InfrastructureConfiguration.cs similarity index 63% rename from Host/Configuration/InfrastructureConfiguration.cs rename to src/Host/Configuration/InfrastructureConfiguration.cs index 29b213f..2ab457b 100644 --- a/Host/Configuration/InfrastructureConfiguration.cs +++ b/src/Host/Configuration/InfrastructureConfiguration.cs @@ -1,4 +1,4 @@ -using Host.Configuration.Factory; +using Host.Plugins; using JasperFx; using Marten; using Wolverine; @@ -8,23 +8,36 @@ namespace Host.Configuration; /// -/// Provides infrastructure-level configuration for the application. +/// Provides extension methods for configuring application infrastructure. /// /// -/// -/// Registers Wolverine -/// +/// +/// Configures Wolverine messaging and Marten document persistence used by the +/// AuthKit host. +/// Wolverine is configured to discover handlers from the Core assembly and +/// dynamically loaded plugin assemblies, while FluentValidation is integrated +/// into message processing. +/// /// public static class InfrastructureConfiguration { + /// + /// Configures Wolverine for the application host. + /// + /// web application builder used to configure Wolverine and logging. + /// + /// The plugins loaded during application startup whose assemblies may + /// contain Wolverine message handlers. + /// public static void ConfigureWolverine( - this WebApplicationBuilder builder) + this WebApplicationBuilder builder, + IReadOnlyList plugins) { builder.UseWolverine(opts => { opts.UseFluentValidation(); - opts.IncludeEventHandlers(); - + opts.IncludeEventHandlers(plugins); + opts.Policies.MessageExecutionLogLevel(LogLevel.None); opts.Policies.MessageSuccessLogLevel(LogLevel.None); }); @@ -55,4 +68,4 @@ public static IServiceCollection ConfigureMarten( return services; } -} \ No newline at end of file +} diff --git a/Host/Configuration/KestrelConfiguration.cs b/src/Host/Configuration/KestrelConfiguration.cs similarity index 56% rename from Host/Configuration/KestrelConfiguration.cs rename to src/Host/Configuration/KestrelConfiguration.cs index baa22be..85470df 100644 --- a/Host/Configuration/KestrelConfiguration.cs +++ b/src/Host/Configuration/KestrelConfiguration.cs @@ -3,22 +3,27 @@ namespace Host.Configuration; /// -/// Configures Kestrel server with HTTPS endpoints and environment-based certificate settings. +/// Provides configuration helpers for the Kestrel web server. /// /// -/// -/// Reads certificate path, password, and REST/gRPC ports from environment variables. -/// Falls back to default values for local development if environment variables are missing. -/// Validates certificate existence and warns if the password is missing. -/// Sets REST endpoint with HTTP/1 + HTTP/2 and gRPC endpoint with HTTP/2 only. -/// +/// +/// Configures HTTPS listeners for the REST and gRPC endpoints using a +/// certificate and connection settings supplied through environment variables. +/// +/// REST supports both HTTP/1.1 and HTTP/2, while the gRPC endpoint is restrictedto HTTP/2. +/// When configuration environment variables are not provided, development defaults are used. /// public static class KestrelConfiguration { /// - /// Configures Kestrel server with HTTPS listeners for REST and gRPC using the provided certificate settings. + /// Configures Kestrel with HTTPS listeners for the REST and gRPC endpoints. /// - /// The to configure. + /// The web host builder used to configure Kestrel. + /// + /// Reads the certificate path, certificate password, and REST and gRPC ports + /// from the corresponding environment variables. Missing values fall back to + /// development defaults. + /// public static void ConfigureKestrelServer(this IWebHostBuilder webHost) { var (certPath, certPassword, portRest, portGrpc) = LoadSettings(); @@ -32,6 +37,13 @@ public static void ConfigureKestrelServer(this IWebHostBuilder webHost) #region Helper Methods + /// + /// Loads Kestrel certificate and endpoint settings from environment variables. + /// + /// + /// A tuple containing the certificate path, certificate password, + /// REST port, and gRPC port. + /// private static (string certPath, string certPassword, int portRest, int portGrpc) LoadSettings() { var certPath = Environment.GetEnvironmentVariable("DEV_CERT_PATH") ?? "/root/certs/devcert.pfx"; @@ -42,6 +54,12 @@ private static (string certPath, string certPassword, int portRest, int portGrpc return (certPath, certPassword, portRest, portGrpc); } + /// + /// Validates the configured HTTPS certificate settings and writes warnings + /// for missing certificate files or passwords. + /// + /// Path to the HTTPS certificate file. + /// Password used to load the certificate. private static void ValidateCertificate(string certPath, string certPassword) { if (!File.Exists(certPath)) @@ -59,11 +77,19 @@ private static void ValidateCertificate(string certPath, string certPassword) } } + /// + /// Configures the Kestrel listeners for REST and gRPC traffic. + /// + /// The web host builder used to configure Kestrel. + /// Path to the HTTPS certificate file. + /// Password used to load the HTTPS certificate. + /// Port used by the REST endpoint. + /// Port used by the gRPC endpoint. private static void ConfigureListeners( - IWebHostBuilder webHost, - string certPath, - string certPassword, - int portRest, + IWebHostBuilder webHost, + string certPath, + string certPassword, + int portRest, int portGrpc) { webHost.ConfigureKestrel(options => @@ -85,4 +111,4 @@ private static void ConfigureListeners( } #endregion -} \ No newline at end of file +} diff --git a/src/Host/Configuration/KeycloakConfiguration.cs b/src/Host/Configuration/KeycloakConfiguration.cs new file mode 100644 index 0000000..e935e01 --- /dev/null +++ b/src/Host/Configuration/KeycloakConfiguration.cs @@ -0,0 +1,120 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Newtonsoft.Json.Linq; + +namespace Host.Configuration; + +/// +/// Provides configuration for Keycloak-based JWT authentication. +/// +/// +/// +/// Configures ASP.NET Core JWT bearer authentication using a Keycloak realm +/// as the token authority. +/// Keycloak configuration is read from environment variables and defaults to +/// the local AuthKit development environment when values are not provided. +/// +/// +/// Keycloak client roles contained in the resource_access claim are +/// converted into standard claims so they can +/// be consumed by ASP.NET Core authorization policies. +/// +/// +public static class KeycloakConfiguration +{ + /// + /// Registers and configures Keycloak JWT bearer authentication. + /// + /// The service collection used to register authentication services. + /// + /// Reads the Keycloak URL, realm, and client identifier from the + /// KEYCLOAK_URL, KEYCLOAK_REALM, and + /// KEYCLOAK_CLIENT_ID environment variables respectively. + /// + public static void AddKeycloakServices(this IServiceCollection services) + { + var baseUrl = Environment.GetEnvironmentVariable("KEYCLOAK_URL") ?? "http://keycloak:8080"; + var realm = Environment.GetEnvironmentVariable("KEYCLOAK_REALM") ?? "authz"; + var clientId = Environment.GetEnvironmentVariable("KEYCLOAK_CLIENT_ID") ?? "workspace-authz"; + + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = $"{baseUrl}/realms/{realm}"; + options.Audience = clientId; + options.RequireHttpsMetadata = false; + + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuers = + [ + "http://localhost:8081/realms/authz", + "http://keycloak:8080/realms/authz" + ], + ValidateAudience = true, + ValidAudience = clientId, + NameClaimType = "preferred_username", + RoleClaimType = ClaimTypes.Role + }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + if (context.Principal?.Identity + is ClaimsIdentity identity) + { + AddKeycloakClientRoles(identity); + } + return Task.CompletedTask; + } + }; + + options.BackchannelHttpHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + }); + } + + /// + /// Extracts roles assigned to the configured Keycloak client from the + /// resource_access token claim and adds them as ASP.NET Core role + /// claims. + /// + /// + /// The authenticated claims identity to which role claims are added. + /// + private static void AddKeycloakClientRoles(ClaimsIdentity identity) + { + var resourceAccessClaim = identity.FindFirst("resource_access")?.Value; + + if (string.IsNullOrWhiteSpace(resourceAccessClaim)) + return; + + var resourceAccess = JObject.Parse(resourceAccessClaim); + + if (!resourceAccess.TryGetValue( + "workspace-authz", + out var workspaceClient)) + { + return; + } + + var roles = workspaceClient["roles"]?.ToObject(); + + if (roles is null) + return; + + identity.AddClaims( + roles.Select(role => + new Claim( + ClaimTypes.Role, + role))); + } +} diff --git a/src/Host/Configuration/RestfulConfiguration.cs b/src/Host/Configuration/RestfulConfiguration.cs new file mode 100644 index 0000000..c020451 --- /dev/null +++ b/src/Host/Configuration/RestfulConfiguration.cs @@ -0,0 +1,115 @@ +using AuthKit.Plugins.Abstractions; +using Host.Plugins; +using Microsoft.OpenApi.Models; + +namespace Host.Configuration; + +/// +/// Provides extension methods for configuring AuthKit RESTful services and +/// OpenAPI/Swagger documentation. +/// +/// +/// +/// Registers API explorer services required for endpoint metadata discovery. +/// Registers Swagger generation and configures the application's OpenAPI document. +/// Registers the built-in JWT bearer security scheme. +/// Registers additional security schemes contributed by loaded AuthKit plugins. +/// Converts AuthKit security scheme descriptors into OpenAPI security scheme definitions. +/// +/// +public static class RestfulConfiguration +{ + /// + /// Registers RESTful API services and configures Swagger/OpenAPI generation. + /// + /// The to configure. + /// + /// The plugins loaded by the Host. Each plugin may contribute one or more + /// OpenAPI security scheme definitions. + /// + /// The configured instance. + /// + /// The method registers API explorer support and creates Swagger document named v1. + /// + /// A built-in HTTP Bearer authentication scheme is registered for JWT + /// authentication. Every security scheme exposed by a loaded plugin is also + /// added to the generated OpenAPI document together with a corresponding + /// security requirement. + /// + /// + public static IServiceCollection AddRestfulServices(this IServiceCollection services, IReadOnlyList plugins) + { + services.AddEndpointsApiExplorer(); + + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" }); + c.EnableAnnotations(); + + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Insert JWT token in the format: Bearer {token}" + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } + }, + Array.Empty() + } + }); + + foreach (var lp in plugins) + { + foreach (var (name, descriptor) in lp.Plugin.GetSecuritySchemes()) + { + c.AddSecurityDefinition(name, ToOpenApiSecurityScheme(descriptor)); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = name } + }, + Array.Empty() + } + }); + } + } + }); + + return services; + } + + private static OpenApiSecurityScheme ToOpenApiSecurityScheme(AuthKitSecuritySchemeDescriptor descriptor) => + new() + { + Name = descriptor.Name, + Type = descriptor.Type switch + { + AuthKitSecuritySchemeType.ApiKey => SecuritySchemeType.ApiKey, + AuthKitSecuritySchemeType.Http => SecuritySchemeType.Http, + AuthKitSecuritySchemeType.OAuth2 => SecuritySchemeType.OAuth2, + AuthKitSecuritySchemeType.OpenIdConnect => SecuritySchemeType.OpenIdConnect, + _ => throw new ArgumentOutOfRangeException(nameof(descriptor)) + }, + In = descriptor.In switch + { + AuthKitApiKeyLocation.Header => ParameterLocation.Header, + AuthKitApiKeyLocation.Query => ParameterLocation.Query, + AuthKitApiKeyLocation.Cookie => ParameterLocation.Cookie, + _ => throw new ArgumentOutOfRangeException(nameof(descriptor)) + }, + Scheme = descriptor.Scheme, + BearerFormat = descriptor.BearerFormat, + Description = descriptor.Description + }; +} diff --git a/Host/Services/GreeterService.cs b/src/Host/Grpc/GreeterService.cs similarity index 94% rename from Host/Services/GreeterService.cs rename to src/Host/Grpc/GreeterService.cs index 5f949a6..cf65dee 100644 --- a/Host/Services/GreeterService.cs +++ b/src/Host/Grpc/GreeterService.cs @@ -1,6 +1,6 @@ using Grpc.Core; -namespace Host.Services; +namespace Host.Grpc; public class GreeterService(ILogger logger) : Greeter.GreeterBase { diff --git a/Infrastructure/Grpc/protos/greet.proto b/src/Host/Grpc/protos/greet.proto similarity index 100% rename from Infrastructure/Grpc/protos/greet.proto rename to src/Host/Grpc/protos/greet.proto diff --git a/src/Host/Host.csproj b/src/Host/Host.csproj new file mode 100644 index 0000000..a098d35 --- /dev/null +++ b/src/Host/Host.csproj @@ -0,0 +1,33 @@ + + + net10.0 + 14.0 + enable + enable + + + + + + + + + + + + + + + + + all + + + + + + + + + + diff --git a/src/Host/KeyManagement/Repositories/KeyStoreRepository.cs b/src/Host/KeyManagement/Repositories/KeyStoreRepository.cs new file mode 100644 index 0000000..99c5312 --- /dev/null +++ b/src/Host/KeyManagement/Repositories/KeyStoreRepository.cs @@ -0,0 +1,112 @@ +using Core.KeyManagement.Interfaces; +using Marten; + +namespace Host.KeyManagement.Repositories; + +/// +/// Provides Marten based repository for persisting the encrypted AuthKit keystore. +/// +/// +/// +/// The repository stores the keystore as a single document identified by a fixed +/// document identifier. Only the encrypted representation of the keystore is +/// persisted; encryption and decryption are handled by the key management layer. +/// +/// +/// The repository uses lightweight Marten sessions for both read and write +/// operations and persists changes asynchronously. +/// +/// +public sealed class KeyStoreRepository(IDocumentStore store) : IKeyStoreRepository +{ + /// + /// The fixed Marten document identifier used for the singleton keystore document. + /// + private const string DocumentId = "singleton"; + + /// + /// Asynchronously loads the encrypted keystore from Marten. + /// + /// + /// A containing the encrypted keystore data, + /// or when the keystore does not exist + /// or contains no encrypted data. + /// + public async Task> LoadAsync() + { + await using var session = store.LightweightSession(); + var document = await session.LoadAsync(DocumentId); + + if (document is null || document.EncryptedData.Length == 0) + return Memory.Empty; + + return document.EncryptedData; + } + + /// + /// Asynchronously persists the encrypted keystore to Marten. + /// + /// + /// The encrypted keystore data to persist. + /// + /// + /// + /// If the singleton keystore document does not exist, a new document is + /// created. Otherwise, the existing document is updated with the supplied + /// encrypted data. + /// + /// + /// The supplied data is copied into a new byte array before being stored, + /// ensuring that the persisted document does not reference the caller's + /// directly. + /// + /// + public async Task SaveAsync(ReadOnlyMemory data) + { + await using var session = store.LightweightSession(); + var document = await session.LoadAsync(DocumentId); + + if (document is null) + { + document = new KeystoreDocument + { + Id = DocumentId, + EncryptedData = data.ToArray() + }; + + session.Store(document); + } + else + { + document.EncryptedData = data.ToArray(); + } + + await session.SaveChangesAsync(); + } + + /// + /// Represents the persisted Marten document containing the encrypted keystore. + /// + /// + /// + /// The document intentionally contains only the encrypted keystore payload. + /// The repository does not perform encryption or decryption itself. + /// + /// + /// The document uses as its Marten identity and + /// as the encrypted key material. + /// + /// + public sealed class KeystoreDocument + { + /// + /// Gets or sets the Marten document identifier. + /// + public string Id { get; set; } = null!; + + /// + /// Gets or sets the encrypted keystore payload. + /// + public byte[] EncryptedData { get; set; } = null!; + } +} \ No newline at end of file diff --git a/Infrastructure/Restful/Jwks/DTO/Response/HealthResponse.cs b/src/Host/KeyManagement/Restful/DTO/Response/HealthResponse.cs similarity index 94% rename from Infrastructure/Restful/Jwks/DTO/Response/HealthResponse.cs rename to src/Host/KeyManagement/Restful/DTO/Response/HealthResponse.cs index 8fe4dcb..94353e3 100644 --- a/Infrastructure/Restful/Jwks/DTO/Response/HealthResponse.cs +++ b/src/Host/KeyManagement/Restful/DTO/Response/HealthResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Infrastructure.Restful.Jwks.DTO.Response; +namespace Host.KeyManagement.Restful.DTO.Response; /// /// Represents the current health status of the key management subsystem. diff --git a/Infrastructure/Restful/Jwks/DTO/Response/JwksResponse.cs b/src/Host/KeyManagement/Restful/DTO/Response/JwksResponse.cs similarity index 57% rename from Infrastructure/Restful/Jwks/DTO/Response/JwksResponse.cs rename to src/Host/KeyManagement/Restful/DTO/Response/JwksResponse.cs index 9533dce..5934c10 100644 --- a/Infrastructure/Restful/Jwks/DTO/Response/JwksResponse.cs +++ b/src/Host/KeyManagement/Restful/DTO/Response/JwksResponse.cs @@ -1,15 +1,15 @@ using System.Text.Json.Serialization; -using Application.Features.KeyManagement.DTO; +using Core.KeyManagement.DTO; -namespace Infrastructure.Restful.Jwks.DTO.Response; +namespace Host.KeyManagement.Restful.DTO.Response; /// -/// Represents a JSON Web Key Set (JWKS) response containing all active public keys. +/// Represents JSON Web Key Set (JWKS) response containing all active public keys. /// /// /// /// Compliant with RFC 7517 (JSON Web Key Set). -/// Contains only non-revoked, public RSA keys used for signature verification. +/// Contains only non revoked, public RSA keys used for signature verification. /// /// public record JwksResponse( diff --git a/Infrastructure/Restful/Jwks/DTO/Response/KeyStoreStatsResponse.cs b/src/Host/KeyManagement/Restful/DTO/Response/KeyStoreStatsResponse.cs similarity index 93% rename from Infrastructure/Restful/Jwks/DTO/Response/KeyStoreStatsResponse.cs rename to src/Host/KeyManagement/Restful/DTO/Response/KeyStoreStatsResponse.cs index ffed0b9..c38a0d3 100644 --- a/Infrastructure/Restful/Jwks/DTO/Response/KeyStoreStatsResponse.cs +++ b/src/Host/KeyManagement/Restful/DTO/Response/KeyStoreStatsResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Infrastructure.Restful.Jwks.DTO.Response; +namespace Host.KeyManagement.Restful.DTO.Response; /// /// Represents statistical information about the key store state. diff --git a/Infrastructure/Restful/Jwks/DTO/Response/SingleKeyResponse.cs b/src/Host/KeyManagement/Restful/DTO/Response/SingleKeyResponse.cs similarity index 73% rename from Infrastructure/Restful/Jwks/DTO/Response/SingleKeyResponse.cs rename to src/Host/KeyManagement/Restful/DTO/Response/SingleKeyResponse.cs index ae2a9a4..df765d0 100644 --- a/Infrastructure/Restful/Jwks/DTO/Response/SingleKeyResponse.cs +++ b/src/Host/KeyManagement/Restful/DTO/Response/SingleKeyResponse.cs @@ -1,10 +1,10 @@ using System.Text.Json.Serialization; -using Application.Features.KeyManagement.DTO; +using Core.KeyManagement.DTO; -namespace Infrastructure.Restful.Jwks.DTO.Response; +namespace Host.KeyManagement.Restful.DTO.Response; /// -/// Represents a single JSON Web Key (JWK) with its associated metadata. +/// Represents single JSON Web Key (JWK) with its associated metadata. /// /// /// diff --git a/Infrastructure/Restful/Jwks/JwksController.cs b/src/Host/KeyManagement/Restful/JwksController.cs similarity index 85% rename from Infrastructure/Restful/Jwks/JwksController.cs rename to src/Host/KeyManagement/Restful/JwksController.cs index f76f39b..6d6e297 100644 --- a/Infrastructure/Restful/Jwks/JwksController.cs +++ b/src/Host/KeyManagement/Restful/JwksController.cs @@ -1,13 +1,11 @@ -using Application; -using Application.Features.KeyManagement.DTO; -using Application.Features.KeyManagement.Interfaces; -using Infrastructure.Restful.Jwks.DTO.Response; +using Core; +using Core.KeyManagement.DTO; +using Core.KeyManagement.Interfaces; +using Host.KeyManagement.Restful.DTO.Response; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -namespace Infrastructure.Restful.Jwks; +namespace Host.KeyManagement.Restful; /// /// Exposes the JSON Web Key Set (JWKS) endpoint for public key discovery. @@ -26,9 +24,31 @@ public class JwksController(IJwtKeyStore keyStore, ILogger logge #region Public JWKS Endpoints /// - /// Returns all currently active public keys in JWKS format. + /// Returns all currently available public signing keys in JWKS format. /// - /// A containing all active public keys. + /// + /// A containing the public JWK representations + /// of all non revoked signing keys. + /// + /// + /// + /// This endpoint is intended for JWT consumers that need to discover + /// the public keys required to validate token signatures. + /// + /// + /// Multiple keys may be returned simultaneously to support signing key + /// rotation while tokens signed with previously active keys remain + /// verifiable. + /// + /// + /// The response is publicly accessible and cached for one hour. + /// The X-Key-Count response header contains the number of + /// published keys and X-Generated-At contains the response + /// generation timestamp. + /// + /// + /// The JWKS document was successfully generated. + /// The public key set could not be retrieved. [AllowAnonymous] [HttpGet] [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByHeader = "Accept")] @@ -93,7 +113,7 @@ public IActionResult GetKeyByKid([FromRoute] string kid) Alg: k.Alg, N: k.N, E: k.E, - X5c: k.X5c?.ToArray() ?? [] + X5c: k.X5c.ToArray() )) .FirstOrDefault(); diff --git a/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs b/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs new file mode 100644 index 0000000..a066f4e --- /dev/null +++ b/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs @@ -0,0 +1,94 @@ +using System.Diagnostics; +using Core.KeyManagement.Interfaces; +using Spectre.Console; + +namespace Host.KeyManagement.Security; + +/// +/// Initializes and disposes the JWT key store as part of the application host lifecycle. +/// +/// +/// +/// The initializer resolves from a scoped service +/// provider and initializes its persisted key material before the application +/// begins serving requests. +/// +/// +/// Initialization progress is displayed using a Spectre.Console status spinner, +/// while the resulting initialization duration, active key identifier, and +/// number of available public keys are written to the application logger. +/// +/// +/// During application shutdown, the hosted service disposes the key store when +/// it implements , allowing cryptographic resources +/// such as RSA instances to be released safely. +/// +/// +public sealed class JwtKeyStoreInitializer( + IServiceProvider provider, + ILogger logger) + : IHostedService, IAsyncDisposable +{ + /// + /// Initializes the JWT key store during application startup. + /// + /// Token that can be used to signal cancellation of the startup operation. + /// + /// + /// A temporary asynchronous service scope is created to resolve the + /// instance. + /// + /// + /// The initialization duration is measured using + /// and reported after the key store has been + /// successfully initialized. + /// + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = provider.CreateAsyncScope(); + + var store = + scope.ServiceProvider.GetRequiredService(); + + var stopwatch = Stopwatch.StartNew(); + + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync( + "Initializing JWT KeyStore...", + async _ => await store.InitializeAsync()); + + stopwatch.Stop(); + + var activeCredentials = + store.GetActiveSigningCredentials(); + + var activeKid = + store.GetMetadata(activeCredentials.Key.KeyId)?.Kid ?? "N/A"; + + var totalKeys = + store.GetPublicJwks().Count(); + + logger.LogInformation( + "JWT Keystore initialized in {ElapsedMilliseconds} ms | Active KID: {ActiveKid} | Total Keys: {TotalKeys}", + stopwatch.ElapsedMilliseconds, + activeKid, + totalKeys); + } + + public Task StopAsync(CancellationToken cancellationToken) + => Task.CompletedTask; + + public async ValueTask DisposeAsync() + { + await using var scope = provider.CreateAsyncScope(); + + if (scope.ServiceProvider.GetService() + is IAsyncDisposable asyncStore) + { + await asyncStore.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/src/Host/Plugins/LoadedPlugin.cs b/src/Host/Plugins/LoadedPlugin.cs new file mode 100644 index 0000000..2f69cd3 --- /dev/null +++ b/src/Host/Plugins/LoadedPlugin.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using AuthKit.Plugins.Abstractions; + +namespace Host.Plugins; + +/// +/// Represents plugin loaded from disk together with its contract instance, +/// assembly, and source directory. +/// +/// +/// +/// The host uses the loaded assembly to register plugin provided +/// Wolverine handlers and MVC application parts. +/// +/// +/// The plugin directory is retained for diagnostics and identifying the +/// location from which the plugin was loaded. +/// +/// +/// The loaded AuthKit plugin contract instance. +/// The assembly containing the loaded plugin. +/// The directory from which the plugin was loaded. +public sealed record LoadedPlugin( + IAuthKitPlugin Plugin, + Assembly Assembly, + string PluginDirectory); \ No newline at end of file diff --git a/src/Host/Plugins/PluginLoader.cs b/src/Host/Plugins/PluginLoader.cs new file mode 100644 index 0000000..452be07 --- /dev/null +++ b/src/Host/Plugins/PluginLoader.cs @@ -0,0 +1,99 @@ +using System.Runtime.Loader; +using AuthKit.Plugins.Abstractions; + +namespace Host.Plugins; + +/// +/// Discovers and loads AuthKit plugins from specified directory during host startup. +/// +/// +/// +/// Plugins are loaded before +/// is called, allowing infrastructure such as Wolverine, Marten, and MVC to discover +/// plugin assemblies while their configuration is being built. +/// +/// +/// Plugins are loaded into rather than an +/// isolated load context. This ensures that shared framework and package types, such +/// as Wolverine IMessageBus, Marten IDocumentSession, and ASP.NET Core +/// MVC types, resolve to the same runtime types on both sides of the plugin boundary. +/// +/// +/// The loader does not support hot unloading or hot swapping of plugins. Plugins are +/// expected to remain loaded for the lifetime of the host process. +/// +/// +public static class PluginLoader +{ + /// + /// Discovers and loads all valid AuthKit plugins from the specified root directory. + /// + /// The root directory containing one subdirectory per plugin. + /// The logger used to report plugin discovery, loading, and validation results. + /// A readonly collection containing all successfully loaded plugins. + /// + /// Each plugin directory is expected to contain an entry assembly whose file name + /// matches the directory name. Directories without matching assembly or assemblies + /// without valid implementation are skipped. + /// + public static IReadOnlyList LoadPlugins(string pluginsRootPath, ILogger logger) + { + if (!Directory.Exists(pluginsRootPath)) + { + logger.LogWarning("Plugins path '{Path}' does not exist — starting with zero plugins.", pluginsRootPath); + return []; + } + + var loaded = new List(); + + foreach (var pluginDir in Directory.GetDirectories(pluginsRootPath)) + { + var pluginName = Path.GetFileName(pluginDir); + var entryDllPath = Path.Combine(pluginDir, $"{pluginName}.dll"); + + if (!File.Exists(entryDllPath)) + { + logger.LogError( + "Skipping plugin folder '{Dir}': expected entry assembly '{Dll}' not found.", + pluginDir, entryDllPath); + continue; + } + + try + { + var resolver = new AssemblyDependencyResolver(entryDllPath); + AssemblyLoadContext.Default.Resolving += (context, name) => + { + var path = resolver.ResolveAssemblyToPath(name); + return path is not null ? context.LoadFromAssemblyPath(path) : null; + }; + + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(entryDllPath); + + var pluginType = assembly.GetTypes() + .FirstOrDefault(t => t is { IsPublic: true, IsAbstract: false } + && typeof(IAuthKitPlugin).IsAssignableFrom(t) + && t.GetConstructor(Type.EmptyTypes) is not null); + + if (pluginType is null) + { + logger.LogError( + "Skipping plugin assembly '{Dll}': no public, non-abstract IAuthKitPlugin implementation with a parameterless constructor found.", + entryDllPath); + continue; + } + + var plugin = (IAuthKitPlugin)Activator.CreateInstance(pluginType)!; + loaded.Add(new LoadedPlugin(plugin, assembly, pluginDir)); + + logger.LogInformation("Loaded plugin '{Name}' v{Version} from {Dir}", plugin.Name, plugin.Version, pluginDir); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + } + + return loaded; + } +} diff --git a/src/Host/Program.cs b/src/Host/Program.cs new file mode 100644 index 0000000..7446931 --- /dev/null +++ b/src/Host/Program.cs @@ -0,0 +1,44 @@ +using Host.Configuration; +using Host.Plugins; +using Host.Cli; + +var builder = WebApplication.CreateBuilder(args); + +// === Plugin discovery (before DI/Wolverine/Marten are configured) === +var pluginsPath = builder.Configuration["AuthKit:PluginsPath"] + ?? Path.Combine(AppContext.BaseDirectory, "plugins"); + +var pluginLogger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("PluginLoader"); +var plugins = PluginLoader.LoadPlugins(pluginsPath, pluginLogger); + +// === Core Config === +builder.Services.AddSingleton(plugins); +builder.Services.AddAuthKitCore(); + +builder.Services.ConfigureApp(builder.Configuration, plugins) + .AddGrpcServices() + .AddRestfulServices(plugins) + .AddKeycloakServices(); + +foreach (var lp in plugins) + lp.Plugin.ConfigureServices(builder.Services, builder.Configuration); + +builder.ConfigureWolverine(plugins); +builder.Services.ConfigureMarten(builder.Configuration); +builder.WebHost.ConfigureKestrelServer(); + +builder.Services.Configure( + builder.Configuration.GetSection("Server") +); + +builder.Services.AddHostedService(); + + +// === Pipeline === +var app = builder.Build(); + +app.ConfigureMiddleware(plugins) + .MapAppEndpoints() + .MapGrpcEndpoints(); + +app.Run(); diff --git a/Host/Properties/launchSettings.json b/src/Host/Properties/launchSettings.json similarity index 100% rename from Host/Properties/launchSettings.json rename to src/Host/Properties/launchSettings.json diff --git a/src/Host/Restful/Middleware/Exceptions/CustomAuthorizationMiddlewareResultHandler.cs b/src/Host/Restful/Middleware/Exceptions/CustomAuthorizationMiddlewareResultHandler.cs new file mode 100644 index 0000000..8a2fcf5 --- /dev/null +++ b/src/Host/Restful/Middleware/Exceptions/CustomAuthorizationMiddlewareResultHandler.cs @@ -0,0 +1,122 @@ +using System.Net; +using System.Text.Json; +using Core.Options; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Host.Restful.Middleware.Exceptions; + +/// +/// Handles authorization results and returns standardized RFC 7807 +/// responses for unauthorized and forbidden requests. +/// +/// +/// +/// Converts authorization challenges and forbidden results into consistent +/// JSON responses. +/// +/// +/// Uses to generate documentation URLs +/// for authorization errors. +/// +/// Logs security related authorization failures for auditing and diagnostics. +/// +public sealed class CustomAuthorizationMiddlewareResultHandler( + IOptions options) : IAuthorizationMiddlewareResultHandler +{ + private readonly AuthorizationMiddlewareResultHandler _defaultHandler = new(); + private readonly string _baseUrl = options.Value.DocsBaseUrl.TrimEnd('/'); + + /// + /// Handles the result of an authorization policy evaluation. + /// + /// The next middleware in the request pipeline. + /// The current HTTP request context. + /// The authorization policy that was evaluated. + /// The result of the authorization policy evaluation. + /// A task representing the asynchronous operation. + public async Task HandleAsync( + RequestDelegate next, + HttpContext context, + AuthorizationPolicy policy, + PolicyAuthorizationResult authorizeResult) + { + if (authorizeResult.Challenged) + { + await WriteProblemAsync( + context, + status: HttpStatusCode.Unauthorized, + code: "unauthorized", + title: "Unauthorized", + detail: "Authentication required to access this resource."); + + return; + } + + if (authorizeResult.Forbidden) + { + await WriteProblemAsync( + context, + status: HttpStatusCode.Forbidden, + code: "forbidden", + title: "Forbidden", + detail: "You do not have permission to access this resource."); + + return; + } + + await _defaultHandler.HandleAsync( + next, + context, + policy, + authorizeResult); + } + + /// + /// Writes standardized RFC 7807 response + /// for an authorization failure. + /// + /// The current HTTP request context. + /// The HTTP status code returned to the client. + /// The application-specific error code. + /// The human-readable error title. + /// The human-readable error description. + /// A task representing the asynchronous response-writing operation. + private async Task WriteProblemAsync( + HttpContext context, + HttpStatusCode status, + string code, + string title, + string detail) + { + var logger = context.RequestServices + .GetRequiredService< + ILogger>(); + + logger.LogWarning("{Code} access attempt at {Path}", + code, + context.Request.Path); + + var problem = new ProblemDetails + { + Type = $"{_baseUrl}/{code}", + Title = title, + Detail = detail, + Status = (int)status, + Instance = context.TraceIdentifier, + Extensions = + { + ["error_code"] = code, + ["trace_id"] = context.TraceIdentifier + } + }; + + context.Response.StatusCode = problem.Status!.Value; + context.Response.ContentType = "application/problem+json"; + + var json = JsonSerializer.Serialize(problem, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + await context.Response.WriteAsync(json); + } +} diff --git a/src/Host/Restful/Middleware/Exceptions/ExceptionHandlingMiddleware.cs b/src/Host/Restful/Middleware/Exceptions/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..28ea0d1 --- /dev/null +++ b/src/Host/Restful/Middleware/Exceptions/ExceptionHandlingMiddleware.cs @@ -0,0 +1,144 @@ +using System.Net; +using System.Text.Json; +using Core; +using Core.Options; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Host.Restful.Middleware.Exceptions; + +/// +/// Middleware that handles unhandled exceptions and returns standardized +/// RFC 7807 responses. +/// +/// +/// +/// Domain specific exceptions represented by +/// are returned as 409 Conflict responses with an error code derived +/// from the exception type name. +/// +/// +/// Unexpected exceptions are returned as 500 Internal Server Error +/// responses without exposing internal exception details to the client. +/// +/// +/// Problem type URLs are generated using +/// and the corresponding error code. +/// +/// +public class ExceptionHandlingMiddleware( + RequestDelegate next, + ILogger logger, + IOptions options) +{ + private readonly string _baseUrl = options.Value.DocsBaseUrl.TrimEnd('/'); + + /// + /// Invokes the next middleware and handles any unhandled exception + /// produced during request processing. + /// + /// The current HTTP request context. + /// A task representing the asynchronous middleware operation. + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception ex) + { + await HandleAsync(context, ex); + } + } + + /// + /// Maps an exception to standardized + /// response and writes it to the HTTP response. + /// + /// The current HTTP request context. + /// The exception that was raised during request processing. + /// A task representing the asynchronous response-writing operation. + private async Task HandleAsync(HttpContext context, Exception ex) + { + var (status, code, message, isDomainError) = MapException(ex); + + context.Response.ContentType = "application/problem+json"; + context.Response.StatusCode = (int)status; + + var problem = new ProblemDetails + { + Type = $"{_baseUrl}/{code}", + Title = code.Replace('_', ' '), + Detail = message, + Status = (int)status, + Instance = context.TraceIdentifier, + Extensions = + { + ["error_code"] = code, + ["trace_id"] = context.TraceIdentifier + } + }; + + if (isDomainError) + { + logger.LogWarning( + "Handled domain exception {ErrorCode}: {Message}", + code, + message); + } + else + { + logger.LogError( + ex, + "Unhandled exception: {Message}", + message); + } + + var json = JsonSerializer.Serialize(problem, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + await context.Response.WriteAsync(json); + } + + /// + /// Maps an exception to its corresponding HTTP status code, error code, + /// client-safe message, and domain-error indicator. + /// + /// The exception to map. + /// + /// A tuple containing the HTTP status, error code, response message, + /// and a value indicating whether the exception is a domain error. + /// + private static ( + HttpStatusCode Status, + string Code, + string Message, + bool IsDomainError) MapException(Exception ex) + { + if (ex is not DomainException domainEx) + { + return( + HttpStatusCode.InternalServerError, + "internal_error", + "An unexpected error occurred.", + false); + } + + var code = ToSnakeCase(domainEx.GetType().Name.Replace("Exception", "")); + return( + HttpStatusCode.Conflict, + code, + domainEx.Message, + true); + } + + /// + /// Converts PascalCase or camelCase string to snake_case. + /// + /// The string to convert. + /// The converted snake_case string. + private static string ToSnakeCase(string input) => + string.Concat(input.Select((ch, i) => + i > 0 && char.IsUpper(ch) + ? "_" + char.ToLower(ch) + : char.ToLower(ch).ToString())); +} diff --git a/src/Host/Restful/Middleware/Exceptions/ValidationExceptionMiddleware.cs b/src/Host/Restful/Middleware/Exceptions/ValidationExceptionMiddleware.cs new file mode 100644 index 0000000..2e73368 --- /dev/null +++ b/src/Host/Restful/Middleware/Exceptions/ValidationExceptionMiddleware.cs @@ -0,0 +1,101 @@ +using System.Net; +using System.Text.Json; +using Core.Options; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Host.Restful.Middleware.Exceptions; + +/// +/// Middleware that handles instances and returns +/// standardized RFC 7807 responses. +/// +/// +/// +/// Validation failures are returned as 400 Bad Request responses containing +/// structured validation errors grouped by property name. +/// +/// +/// Uses to generate the problem +/// documentation URL. +/// +/// +/// Validation failures are logged together with the request path and validation +/// error details for diagnostics. +/// +/// +public sealed class ValidationExceptionMiddleware( + RequestDelegate next, + ILogger logger, + IOptions options) +{ + private readonly string _baseUrl = options.Value.DocsBaseUrl.TrimEnd('/'); + + /// + /// Invokes the next middleware and handles any + /// raised during request processing. + /// + /// The current HTTP request context. + /// A task representing the asynchronous middleware operation. + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (ValidationException ex) + { + await HandleValidationAsync(context, ex); + } + } + + /// + /// Creates and writes standardized response + /// containing the validation errors. + /// + /// The current HTTP request context. + /// The validation exception containing the validation failures. + /// A task representing the asynchronous response-writing operation. + private async Task HandleValidationAsync( + HttpContext context, + ValidationException ex) + { + const string code = "validation_failed"; + const HttpStatusCode status = HttpStatusCode.BadRequest; + + var errors = ex.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary( + group => group.Key, + group => group + .Select(e => e.ErrorMessage) + .ToArray()); + + var problem = new ProblemDetails + { + Type = $"{_baseUrl}/{code}", + Title = "Validation failed", + Detail = "One or more validation errors occurred.", + Status = (int)status, + Instance = context.TraceIdentifier, + Extensions = + { + ["error_code"] = code, + ["trace_id"] = context.TraceIdentifier, + ["errors"] = errors + } + }; + + logger.LogWarning( + "Validation failed on {Path}: {@Errors}", + context.Request.Path, + errors); + + context.Response.StatusCode = problem.Status!.Value; + context.Response.ContentType = "application/problem+json"; + + var json = JsonSerializer.Serialize(problem, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + await context.Response.WriteAsync(json); + } +} diff --git a/src/Host/ServiceDiscovery/ServiceDiscoveryExtensions.cs b/src/Host/ServiceDiscovery/ServiceDiscoveryExtensions.cs new file mode 100644 index 0000000..734067d --- /dev/null +++ b/src/Host/ServiceDiscovery/ServiceDiscoveryExtensions.cs @@ -0,0 +1,67 @@ +using System.Reflection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Host.ServiceDiscovery; + +/// +/// Provides extension methods for automatically discovering and registering +/// application services in the dependency injection container. +/// +/// +/// +/// Services are discovered by scanning the specified assemblies and filtering +/// types according to . +/// +/// +/// Discovered concrete service types are registered against their implemented +/// interfaces using the configured service lifetime. +/// +/// +public static class ServiceDiscoveryExtensions +{ + /// + /// Scans the specified assemblies for service types and registers the + /// discovered services in the dependency injection container. + /// + /// The to register discovered services into. + /// The assemblies to scan for service implementations. + /// An optional callback used to configure . + /// An optional logger used to report service discovery and filtering information. + /// The original instance for method chaining. + /// + /// + /// Types are filtered using + /// and the configured . + /// Interfaces and exception types can be excluded according to the configured + /// discovery options. + /// + /// + /// Valid concrete service types are registered as their implemented interfaces + /// using the configured . + /// + /// + public static IServiceCollection AddDiscoveredServices( + this IServiceCollection services, + Assembly[] assemblies, + Action? configure = null, + ILogger? logger = null) + { + var opts = new ServiceDiscoveryOptions(); + configure?.Invoke(opts); + + logger ??= NullLogger.Instance; + + services.Scan(scan => scan + .FromAssemblies(assemblies) + .AddClasses(classes => classes + .Where(type => + ServiceDiscoveryFilter.IsValidServiceType( + type, + logger, + opts))) + .AsImplementedInterfaces() + .WithLifetime(opts.Lifetime)); + + return services; + } +} diff --git a/Host/ServiceDiscoveryFilter.cs b/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs similarity index 76% rename from Host/ServiceDiscoveryFilter.cs rename to src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs index c8a339a..83e8032 100644 --- a/Host/ServiceDiscoveryFilter.cs +++ b/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs @@ -1,4 +1,4 @@ -namespace Host; +namespace Host.ServiceDiscovery; /// /// Filters types during service discovery for automatic DI registration. @@ -6,10 +6,10 @@ namespace Host; /// /// /// Detects type category based on namespace markers (DTO, Entity, Repository, Services, etc.). -/// Extracts a feature name from ".Features.{FeatureName}". +/// Extracts feature name from the namespace segment right after the layer root. /// Extracts layer from the namespace root (Application, Domain, Infrastructure, etc.). /// Applies including allowed/excluded namespaces, layers, interfaces, exceptions, and types. -/// Logs a clear debug message indicating decision, layer, type category, feature, and full type name. +/// Logs clear debug message indicating decision, layer, type category, feature, and full type name. /// /// public static class ServiceDiscoveryFilter @@ -28,7 +28,7 @@ private static readonly (string Key, string Name)[] NamespaceTypes = ]; /// - /// Determines if a type is valid for automatic DI registration. + /// Determines if type is valid for automatic DI registration. /// /// Type to evaluate. /// Logger for debug output. @@ -45,7 +45,8 @@ public static bool IsValidServiceType(Type type, ILogger logger, ServiceDiscover if (opts.EnableLogging) { - logger.LogDebug("{Decision} Layer({Layer}) Type({TypeKind}) Feature({Feature}): {Type}", + logger.LogDebug( + "{Decision} Layer({Layer}) Type({TypeKind}) Feature({Feature}): {Type}", include ? "Including" : "Skipping", layer, detectedType, @@ -56,42 +57,37 @@ public static bool IsValidServiceType(Type type, ILogger logger, ServiceDiscover return include; } - - - - /// /// Returns the first namespace segment (layer), e.g. "Application", "Domain". /// private static string GetLayer(string ns) - => ns.Split('.', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "Unknown"; + => ns.Split('.', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault() ?? "Unknown"; /// - /// Detects a type group (DTO, Entity, Service, etc.) based on namespace markers. + /// Detects type group (DTO, Entity, Service, etc.) based on namespace markers. /// private static string GetDetectedType(string ns) { foreach (var (key, name) in NamespaceTypes) + { if (ns.Contains(key)) return name; + } return "Allowed"; } /// - /// Extracts feature (the part after ".Features.") or returns "Global". + /// Extracts feature (the namespace segment right after the layer root, e.g. + /// "KeyManagement" from "Core.KeyManagement.Services") or returns "Global". /// private static string GetFeature(string ns) { - const string segment = ".Features."; - var idx = ns.IndexOf(segment, StringComparison.Ordinal); - if (idx < 0) return "Global"; - - var start = idx + segment.Length; - var end = ns.IndexOf('.', start); - return end > 0 ? ns[start..end] : ns[start..]; + var parts = ns.Split('.', StringSplitOptions.RemoveEmptyEntries); + return parts.Length > 1 ? parts[1] : "Global"; } - + /// /// Applies rules to decide inclusion. /// @@ -102,4 +98,4 @@ private static bool ShouldInclude(Type type, string ns, string layer, ServiceDis && !opts.ExcludedNamespaces.Any(ns.Contains) && (opts.AllowedNamespaces.Count == 0 || opts.AllowedNamespaces.Any(ns.Contains)) && (opts.AllowedLayers.Count == 0 || opts.AllowedLayers.Contains(layer)); -} \ No newline at end of file +} diff --git a/Host/ServiceDiscoveryOptions.cs b/src/Host/ServiceDiscovery/ServiceDiscoveryOptions.cs similarity index 97% rename from Host/ServiceDiscoveryOptions.cs rename to src/Host/ServiceDiscovery/ServiceDiscoveryOptions.cs index d1749cc..d42881e 100644 --- a/Host/ServiceDiscoveryOptions.cs +++ b/src/Host/ServiceDiscovery/ServiceDiscoveryOptions.cs @@ -1,4 +1,4 @@ -namespace Host; +namespace Host.ServiceDiscovery; /// /// Configuration options for automatic service discovery and DI registration. @@ -26,7 +26,7 @@ public sealed class ServiceDiscoveryOptions /// Specific types to ignore during service registration. /// public List ExcludedTypes { get; set; } = []; - + public List AllowedLayers { get; set; } = []; /// @@ -43,9 +43,9 @@ public sealed class ServiceDiscoveryOptions /// If false, ServiceDiscovery will not log debug messages. /// public bool EnableLogging { get; set; } = true; - + /// /// Default service lifetime for all discovered types. /// public ServiceLifetime Lifetime { get; set; } = ServiceLifetime.Scoped; -} \ No newline at end of file +} diff --git a/src/Host/TokenKeyBindings/Repositories/InMemoryKeyBindingRepository.cs b/src/Host/TokenKeyBindings/Repositories/InMemoryKeyBindingRepository.cs new file mode 100644 index 0000000..c174bea --- /dev/null +++ b/src/Host/TokenKeyBindings/Repositories/InMemoryKeyBindingRepository.cs @@ -0,0 +1,141 @@ +using System.Diagnostics; +using Core.TokenKeyBindings; +using Core.TokenKeyBindings.Interfaces; +using Core.TokenKeyBindings.Services; + +namespace Host.TokenKeyBindings.Repositories; + +/// +/// Provides an in-memory implementation of +/// for storing entities. +/// +/// +/// +/// Stores key bindings in process memory and is primarily intended for testing, +/// local development, and scenarios that do not require persistent storage. +/// +/// +/// All repository operations are synchronized using an internal lock to ensure +/// thread-safe access to the in-memory collection. +/// +/// +/// Repository operations are written to the debug output for diagnostics and +/// development purposes. +/// +/// +public class InMemoryKeyBindingRepository : IKeyBindingRepository +{ + private readonly List _store = []; + private readonly Lock _lock = new(); + + /// + /// Writes repository diagnostic message to the debug output. + /// + /// The diagnostic message to write. + private static void DebugLog(string message) + => Debug.WriteLine($"[KeyBindingRepo] {message}"); + + /// + /// Adds new key binding to the in-memory store. + /// + /// The key binding to store. + /// + /// A task containing the stored . + /// + public Task AddAsync(TokenKeyBinding binding) + { + lock (_lock) + { + _store.Add(binding); + } + + DebugLog( + $"Added binding: TokenId={binding.TokenId}, SigningKeyId={binding.SigningKeyId}"); + + return Task.FromResult(binding); + } + + /// + /// Retrieves key binding by developer token ID and signing key ID. + /// + /// The unique identifier of the developer token. + /// The identifier of the signing key. + /// + /// A task containing the matching , + /// or null when no matching binding exists. + /// + public Task GetAsync( + Guid tokenId, + string signingKeyId) + { + TokenKeyBinding? found; + + lock (_lock) + { + found = _store.FirstOrDefault( + binding => + binding.TokenId == tokenId && + binding.SigningKeyId == signingKeyId); + } + + DebugLog( + found != null + ? $"Found binding for TokenId={tokenId}, SigningKeyId={signingKeyId}" + : $"No binding found for TokenId={tokenId}, SigningKeyId={signingKeyId}"); + + return Task.FromResult(found); + } + + /// + /// Updates an existing key binding in the in-memory store. + /// + /// The updated key binding. + /// A task representing the asynchronous update operation. + public Task UpdateAsync(TokenKeyBinding binding) + { + lock (_lock) + { + var index = _store.FindIndex( + existing => + existing.TokenId == binding.TokenId && + existing.SigningKeyId == binding.SigningKeyId); + + if (index >= 0) + { + _store[index] = binding; + + DebugLog( + $"Updated binding: TokenId={binding.TokenId}, SigningKeyId={binding.SigningKeyId}"); + } + else + { + DebugLog( + $"Attempted to update non-existing binding: TokenId={binding.TokenId}, SigningKeyId={binding.SigningKeyId}"); + } + } + + return Task.CompletedTask; + } + + /// + /// Lists all key bindings associated with a developer token. + /// + /// The unique identifier of the developer token. + /// + /// A task containing all entities associated + /// with the specified developer token. + /// + public Task> ListByTokenAsync(Guid tokenId) + { + IEnumerable result; + + lock (_lock) + { + result = [.. _store.Where(binding => binding.TokenId == tokenId)]; + } + + DebugLog($"Listed {result.Count()} bindings for TokenId={tokenId}"); + + return Task.FromResult(result); + } +} diff --git a/Host/appsettings.Development.json b/src/Host/appsettings.Development.json similarity index 92% rename from Host/appsettings.Development.json rename to src/Host/appsettings.Development.json index f026755..85ebada 100644 --- a/Host/appsettings.Development.json +++ b/src/Host/appsettings.Development.json @@ -23,9 +23,7 @@ ".ValueObject" ], "AllowedLayers": [ - "Application", - "Domain", - "Infrastructure", + "Core", "Host" ], "SkipInterfaces": true, diff --git a/Host/appsettings.json b/src/Host/appsettings.json similarity index 94% rename from Host/appsettings.json rename to src/Host/appsettings.json index a3b4f21..b7cf050 100644 --- a/Host/appsettings.json +++ b/src/Host/appsettings.json @@ -29,9 +29,7 @@ ".ValueObject" ], "AllowedLayers": [ - "Application", - "Domain", - "Infrastructure", + "Core", "Host" ], "SkipInterfaces": true, diff --git a/Domain/Domain.csproj b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj similarity index 52% rename from Domain/Domain.csproj rename to src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj index 33b5e3b..d327ce9 100644 --- a/Domain/Domain.csproj +++ b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj @@ -5,4 +5,8 @@ enable enable - \ No newline at end of file + + + + + diff --git a/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs b/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs new file mode 100644 index 0000000..dcc9c01 --- /dev/null +++ b/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs @@ -0,0 +1,65 @@ +namespace AuthKit.Plugins.Abstractions; + +/// +/// Specifies the transport location from which an API key should be retrieved. +/// +/// +/// +/// The API key can be supplied through different metadata locations depending +/// on the transport used by the incoming request, such as HTTP or gRPC. +/// +/// +/// For HTTP requests, API keys may be provided through request headers, query +/// string parameters, or cookies. For gRPC requests, API keys are typically +/// provided through request metadata, which is represented by +/// . +/// +/// +/// The selected location determines where the AuthKit authentication pipeline +/// searches for the API key before attempting validation. +/// +/// +public enum AuthKitApiKeyLocation +{ + /// + /// Retrieves the API key from request headers or transport metadata. + /// + /// + /// + /// For HTTP requests, the API key is retrieved from configured request + /// header, such as X-Api-Key. + /// + /// + /// For gRPC requests, the API key is retrieved from the request metadata. + /// + /// + Header, + + /// + /// Retrieves the API key from request query parameter. + /// + /// + /// The API key is expected to be supplied as part of the HTTP request URL, + /// for example ?api_key=.... + /// + /// + /// This location is only applicable to HTTP-based requests and is not + /// available for native gRPC calls. + /// + Query, + + /// + /// Retrieves the API key from a request cookie. + /// + /// + /// + /// The API key is expected to be stored in an HTTP cookie sent with the + /// incoming request. + /// + /// + /// This location is primarily intended for HTTP browser-based scenarios + /// and is not available for native gRPC calls. + /// + /// + Cookie +} diff --git a/src/Plugins/Abstractions/AuthKitSecuritySchemeDescriptor.cs b/src/Plugins/Abstractions/AuthKitSecuritySchemeDescriptor.cs new file mode 100644 index 0000000..0167bec --- /dev/null +++ b/src/Plugins/Abstractions/AuthKitSecuritySchemeDescriptor.cs @@ -0,0 +1,62 @@ +namespace AuthKit.Plugins.Abstractions; + +/// +/// Describes security scheme exposed by an AuthKit authentication plugin. +/// +/// +/// +/// provides transport-agnostic +/// metadata describing how client authenticates when communicating with +/// service protected by AuthKit. +/// +/// +/// Depending on , the descriptor can represent authentication +/// mechanisms such as API keys, HTTP authentication schemes, or bearer tokens. +/// +/// +/// For API key schemes, determines where AuthKit should look +/// for the credential. The selected location may apply differently depending +/// on the underlying transport, such as HTTP or gRPC. +/// +/// +public sealed record AuthKitSecuritySchemeDescriptor +{ + /// + /// The unique name used to identify the security scheme. + /// + public required string Name { get; init; } + + /// + /// The type of authentication mechanism implemented by the security scheme. + /// + public required AuthKitSecuritySchemeType Type { get; init; } + + /// + /// Specifies where the API key should be retrieved from when + /// represents an API key authentication scheme. + /// + public AuthKitApiKeyLocation In { get; init; } + + /// + /// The authentication scheme name. + /// + /// + /// For HTTP authentication schemes, this can specify the scheme used + /// by the security mechanism, such as Bearer. + /// + public string? Scheme { get; init; } + + /// + /// An optional hint describing the format of a bearer token. + /// + /// + /// This value is intended primarily for documentation and client + /// generation purposes and does not affect token validation. + /// + public string? BearerFormat { get; init; } + + /// + /// An optional description of the security scheme. + /// + public string? Description { get; init; } +} \ No newline at end of file diff --git a/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs b/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs new file mode 100644 index 0000000..ca82ca3 --- /dev/null +++ b/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs @@ -0,0 +1,59 @@ +namespace AuthKit.Plugins.Abstractions; + +/// +/// Specifies the authentication mechanism represented by an AuthKit security scheme. +/// +/// +/// +/// The selected value determines how the corresponding +/// describes the credentials +/// and authentication flow exposed by an AuthKit plugin. +/// +/// +/// This enumeration describes the authentication mechanism at the metadata +/// level. It does not itself perform authentication, validate credentials, +/// or establish an authentication session. +/// +/// +public enum AuthKitSecuritySchemeType +{ + /// + /// Authentication using an API key supplied through a configured location. + /// + /// + /// The credential location is specified by + /// . + /// Common locations include an HTTP header, query parameter, or cookie. + /// + ApiKey, + + /// + /// Authentication using an HTTP authentication scheme. + /// + /// + /// The HTTP authentication scheme is specified by + /// . + /// Examples include Basic, Bearer, and other HTTP + /// authentication schemes. + /// + Http, + + /// + /// Authentication using the OAuth 2.0 authorization framework. + /// + /// + /// OAuth 2.0 schemes describe authorization flows in which a client + /// obtains an access token from an authorization server and presents + /// that token when accessing protected resources. + /// + OAuth2, + + /// + /// Authentication using the OpenID Connect identity layer. + /// + /// + /// OpenID Connect extends OAuth 2.0 with an identity layer and is used + /// to authenticate users through an OpenID Connect identity provider. + /// + OpenIdConnect +} diff --git a/src/Plugins/Abstractions/IAuthKitPlugin.cs b/src/Plugins/Abstractions/IAuthKitPlugin.cs new file mode 100644 index 0000000..f20698f --- /dev/null +++ b/src/Plugins/Abstractions/IAuthKitPlugin.cs @@ -0,0 +1,141 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace AuthKit.Plugins.Abstractions; + +/// +/// Defines the contract implemented by an AuthKit plugin. +/// +/// +/// +/// AuthKit plugins are discovered and loaded dynamically by the host at startup. +/// A plugin does not need to be directly referenced by the host project. +/// +/// +/// The plugin contract allows an extension to contribute services, middleware, +/// health checks, and OpenAPI security scheme metadata to the host application. +/// +/// +/// Plugin implementations should keep their integration with the host limited +/// to the abstractions exposed by this contract and should register any +/// plugin-specific dependencies through . +/// +/// +public interface IAuthKitPlugin +{ + /// + /// Gets the unique name of the plugin. + /// + /// + /// The name is used to identify the plugin in host diagnostics, + /// startup output, and other plugin-related metadata. + /// + string Name { get; } + + /// + /// Gets the version of the plugin. + /// + /// + /// The version is exposed as plugin metadata and may be used by the host + /// for diagnostics, compatibility checks, or administrative surfaces. + /// + string Version { get; } + + /// + /// Gets an optional human-readable description of the plugin. + /// + /// + /// The description may be displayed by the host in startup output, + /// diagnostics, administrative interfaces, or other status surfaces. + /// + string? Description => null; + + /// + /// Registers the plugin's services in the host dependency injection container. + /// + /// The host's dependency injection service collection. + /// The host application configuration. + /// + /// + /// This method is called while the host application is being configured, + /// before the application is built. + /// + /// + /// Plugins should register all services required by their functionality + /// through this method rather than creating their own dependency injection + /// container. + /// + /// + void ConfigureServices( + IServiceCollection services, + IConfiguration configuration); + + /// + /// Performs an optional health check for the plugin. + /// + /// The root service provider of the host application. + /// + /// true when the plugin is currently able to serve requests; + /// otherwise, false. + /// + /// + /// + /// The host may invoke this method as part of its health endpoint. + /// Plugins can resolve the services they require from + /// to verify the availability of their + /// dependencies. + /// + /// + /// A plugin should return false when a required dependency is + /// unavailable, such as when its database or external service cannot + /// currently be reached. + /// + /// + /// The default implementation reports the plugin as healthy. Plugins + /// that do not require custom health validation therefore do not need + /// to implement this member. + /// + /// + Task CheckHealthAsync(IServiceProvider services) => + Task.FromResult(true); + + /// + /// Gets the optional ASP.NET Core middleware type contributed by the plugin. + /// + /// + /// + /// When specified, the host inserts the middleware into its request + /// processing pipeline at the plugin middleware slot. + /// + /// + /// The middleware type must follow the conventional ASP.NET Core middleware + /// pattern, including a constructor accepting + /// and an InvokeAsync method accepting . + /// Additional dependencies may be supplied through dependency injection. + /// + /// + /// The default value is null, indicating that the plugin does not + /// contribute middleware. + /// + /// + Type? MiddlewareType => null; + + /// + /// Gets the OpenAPI security schemes contributed by the plugin. + /// + /// + /// A read-only dictionary keyed by security scheme name. + /// + /// + /// + /// Security schemes returned by this method are exposed by the host as + /// part of its Swagger/OpenAPI security metadata. + /// + /// + /// Plugins that do not contribute security schemes can rely on the default + /// empty collection. + /// + /// + IReadOnlyDictionary GetSecuritySchemes() => + new Dictionary(); +} diff --git a/src/Plugins/Solutions/DevTokens/DevTokens.csproj b/src/Plugins/Solutions/DevTokens/DevTokens.csproj new file mode 100644 index 0000000..34c0378 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/DevTokens.csproj @@ -0,0 +1,22 @@ + + + net10.0 + preview + enable + enable + true + + + + + + + + + + + + + + + diff --git a/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs new file mode 100644 index 0000000..a2023f4 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs @@ -0,0 +1,104 @@ +using DevTokens.DeveloperTokens; +using DevTokens.Options; +using AuthKit.Plugins.Abstractions; +using FluentValidation; +using DevTokens.Interfaces; +using DevTokens.Middleware; +using DevTokens.Repositories; +using DevTokens.Security; +using DevTokens.Services; +using DevTokens.UseCase.Commands.Requests; +using DevTokens.UseCase.Commands.Validations; +using Marten; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace DevTokens; + +/// +/// AuthKit plugin responsible for issuing, validating, and authorizing developer tokens +/// used for SDK access. +/// +/// +/// +/// The plugin provides the application services required to manage the developer token +/// lifecycle, including token creation, validation, persistence, and scope-based authorization. +/// +/// +/// Signing keys and token to key bindings are provided by the Host core through +/// dependency injection. The plugin does not own or manage the underlying signing-key +/// infrastructure. +/// +/// +public sealed class DevTokensPlugin : IAuthKitPlugin +{ + /// + /// Gets the unique name of the plugin. + /// + public string Name => "DevTokens"; + + /// + /// Gets the current version of the plugin. + /// + public string Version => "1.0.0"; + + /// + /// Gets a description of the functionality provided by the plugin. + /// + public string Description => + "Issues and validates developer tokens for SDK access."; + + /// + /// Registers developer token services, repositories, validators, + /// and authorization components in the dependency injection container. + /// + /// The used to register plugin services. + /// Application configuration used to configure . + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("AuthKit")); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddScoped, CreateDeveloperTokenValidator>(); + services.AddScoped, DeleteTokenCommandValidator>(); + } + + public Type MiddlewareType => typeof(DeveloperTokenMiddleware); + + public async Task CheckHealthAsync(IServiceProvider services) + { + var store = services.GetService(); + if (store is null) return false; + + try + { + await using var session = store.LightweightSession(); + await session.Query().Take(1).ToListAsync(); + return true; + } + catch + { + return false; + } + } + + public IReadOnlyDictionary GetSecuritySchemes() => + new Dictionary + { + ["X-Developer-Token"] = new() + { + Name = "X-Developer-Token", + Type = AuthKitSecuritySchemeType.ApiKey, + In = AuthKitApiKeyLocation.Header, + Description = "AuthKit developer JWT token" + } + }; +} diff --git a/Application/Options/AuthKitOptions.cs b/src/Plugins/Solutions/DevTokens/Options/AuthKitOptions.cs similarity index 91% rename from Application/Options/AuthKitOptions.cs rename to src/Plugins/Solutions/DevTokens/Options/AuthKitOptions.cs index 50202bf..43664cb 100644 --- a/Application/Options/AuthKitOptions.cs +++ b/src/Plugins/Solutions/DevTokens/Options/AuthKitOptions.cs @@ -1,4 +1,4 @@ -namespace Application.Options; +namespace DevTokens.Options; /// /// Configuration options for the AuthKit module. diff --git a/src/Plugins/Solutions/DevTokens/src/DTO/CreateTokenRequest.cs b/src/Plugins/Solutions/DevTokens/src/DTO/CreateTokenRequest.cs new file mode 100644 index 0000000..608383a --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/DTO/CreateTokenRequest.cs @@ -0,0 +1,18 @@ +namespace DevTokens.DTO; + +/// +/// Represents request to create new developer token. +/// +/// The name assigned to the developer token. +/// A description of the developer token and its intended use. +/// The scopes granted to the developer token. +/// +/// The optional lifetime of the developer token in days. +/// A null value indicates that the default lifetime should be used. +/// +public record CreateTokenRequest( + string Name, + string Description, + IEnumerable Scopes, + int? LifetimeDays +); \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenCreated.cs b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenCreated.cs new file mode 100644 index 0000000..b3b6291 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenCreated.cs @@ -0,0 +1,36 @@ +namespace DevTokens.DTO; + +/// +/// Represents the result of creating a developer token. +/// +/// +/// +/// Contains the credentials generated for the newly created developer token, +/// including its short API key and signed JWT. +/// +/// +/// Includes the entity containing the persisted +/// token metadata and configuration. +/// +/// +public class DeveloperTokenCreated +{ + /// + /// Gets or sets the short API key used as a public identifier + /// for the developer token. + /// + /// rk_live_xxx + public string ShortKey { get; set; } = null!; + + /// + /// Gets or sets the signed JWT containing developer token claims + /// and expiration information. + /// + public string Jwt { get; set; } = null!; + + /// + /// Gets or sets the developer token entity associated with the + /// generated credentials. + /// + public DeveloperToken Token { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenDto.cs b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenDto.cs new file mode 100644 index 0000000..63f89ce --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenDto.cs @@ -0,0 +1,73 @@ +namespace DevTokens.DTO; + +/// +/// Represents the data transfer object for developer token. +/// +/// +/// +/// Exposes the public token metadata required by API consumers without +/// exposing the underlying domain model. +/// +/// +/// Provides calculated expiration state and a factory method for mapping +/// domain entity to its DTO representation. +/// +/// +public record DeveloperTokenDto +{ + /// + /// Gets the unique identifier of the developer token. + /// + public Guid Id { get; init; } + + /// + /// Gets the unique identifier of the developer that owns the token. + /// + public Guid DeveloperId { get; init; } + + /// + /// Gets the display name of the developer token. + /// + public string Name { get; init; } = null!; + + /// + /// Gets the scopes granted to the developer token. + /// + public IReadOnlyList Scopes { get; init; } = []; + + /// + /// Gets the date and time when the developer token was created. + /// + public DateTimeOffset CreatedAt { get; init; } + + /// + /// Gets the date and time when the developer token expires, + /// or null when the token does not expire. + /// + public DateTimeOffset? ExpiresAt { get; init; } + + /// + /// Gets value indicating whether the developer token has expired. + /// + public bool IsExpired => + ExpiresAt.HasValue && DateTimeOffset.UtcNow > ExpiresAt.Value; + + /// + /// Creates from domain token. + /// + /// The developer token domain entity to map. + /// A DTO containing the public representation of the developer token. + public static DeveloperTokenDto FromDomain(DeveloperToken token) => + new() + { + Id = token.Id, + DeveloperId = token.DeveloperId, + Name = token.Name.ToString(), + Scopes = token.Scopes + .Select(s => s.Value) + .ToList() + .AsReadOnly(), + CreatedAt = token.Lifetime.CreatedAt, + ExpiresAt = token.Lifetime.ExpiresAt + }; +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenPairDto.cs b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenPairDto.cs new file mode 100644 index 0000000..2bf2d73 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenPairDto.cs @@ -0,0 +1,17 @@ +namespace DevTokens.DTO; + +/// +/// Represents the generated credentials for developer token. +/// +/// +/// +/// Contains both the short API key used to identify the token and the +/// signed JWT used for authenticated requests. +/// +/// +/// The public identifier used as an API key in requests. +/// The signed JSON Web Token containing the developer token claims. +public record DeveloperTokenPairDto( + string ShortKey, + string Jwt +); \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/src/DTO/VerifyTokenRequest.cs b/src/Plugins/Solutions/DevTokens/src/DTO/VerifyTokenRequest.cs new file mode 100644 index 0000000..eb54533 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/DTO/VerifyTokenRequest.cs @@ -0,0 +1,11 @@ +namespace DevTokens.DTO; + +/// +/// Represents a request to verify the developer token using its short key. +/// +/// +/// The developer token key to verify. +/// +public record VerifyTokenRequest( + string Key +); \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/src/DeveloperToken.cs b/src/Plugins/Solutions/DevTokens/src/DeveloperToken.cs new file mode 100644 index 0000000..d707b3e --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/DeveloperToken.cs @@ -0,0 +1,144 @@ +using Core.TokenKeyBindings.Services; +using DevTokens.ValueObject; + +namespace DevTokens; + +/// +/// Represents a developer issued token with name, scopes, signing key bindings, and lifetime. +/// +/// +/// +/// A developer token identifies a specific developer and contains the scopes +/// and signing key bindings associated with that token. +/// The token lifetime defines when the token was created and, optionally, when it expires. +/// +/// +/// Token modifications return new instances instead of mutating the existing token, +/// allowing token state to be treated immutably. +/// +/// +public record DeveloperToken +{ + /// + /// Gets the unique identifier of the developer token. + /// + public Guid Id { get; } = Guid.NewGuid(); + + /// + /// Gets the unique identifier of the developer who owns the token. + /// + public Guid DeveloperId { get; init; } + + /// + /// Gets the name assigned to the token. + /// + public TokenName Name { get; init; } = new("default"); + + /// + /// Gets the scopes granted to the token. + /// + public IReadOnlyList Scopes { get; init; } = []; + + /// + /// Gets the signing key bindings associated with the token. + /// + public IReadOnlyList KeyBindings { get; init; } = []; + + /// + /// Gets the lifetime information for the token. + /// + public TokenLifetime Lifetime { get; init; } = new(DateTimeOffset.UtcNow); + + /// + /// Creates new developer token. + /// + /// The unique identifier of the developer who owns the token. + /// The name assigned to the token. + /// The scopes granted to the token. + /// + /// Optional token lifetime. If null, the token does not expire. + /// + /// A newly created . + public static DeveloperToken Create( + Guid developerId, + TokenName name, + IEnumerable scopes, + TimeSpan? lifetime = null) + { + var now = DateTimeOffset.UtcNow; + + var tokenLifetime = new TokenLifetime( + now, + lifetime.HasValue ? now.Add(lifetime.Value) : null + ); + + return new DeveloperToken + { + DeveloperId = developerId, + Name = name, + Scopes = scopes.ToList().AsReadOnly(), + Lifetime = tokenLifetime + }; + } + + /// + /// Renews the token with a new lifetime starting from the current time. + /// + /// The duration for which the renewed token remains valid. + /// A new with the renewed lifetime. + public DeveloperToken Renew(TimeSpan extension) + { + var now = DateTimeOffset.UtcNow; + var newLifetime = new TokenLifetime(now, now.Add(extension)); + + return this with { Lifetime = newLifetime }; + } + + /// + /// Adds scope to the token. + /// + /// The scope to add. + /// A new containing the additional scope. + public DeveloperToken AddScope(TokenScope scope) + { + var newScopes = Scopes + .Append(scope) + .ToList() + .AsReadOnly(); + + return this with { Scopes = newScopes }; + } + + /// + /// Adds signing key binding to the token. + /// + /// The signing key binding to add. + /// A new containing the additional key binding. + public DeveloperToken AddKeyBinding(TokenKeyBinding binding) + { + var newBindings = KeyBindings + .Append(binding) + .ToList() + .AsReadOnly(); + + return this with { KeyBindings = newBindings }; + } + + /// + /// Replaces an existing signing key binding with new binding. + /// + /// The identifier of the signing key binding to replace. + /// The replacement signing key binding. + /// A new with the matching key binding replaced. + public DeveloperToken ReplaceKeyBinding( + string signingKeyId, + TokenKeyBinding newBinding) + { + var newBindings = KeyBindings + .Select(k => k.SigningKeyId == signingKeyId ? newBinding : k) + .ToList() + .AsReadOnly(); + + return this with { KeyBindings = newBindings }; + } +} \ No newline at end of file diff --git a/Application/Features/DeveloperTokens/DeveloperTokenPrincipal.cs b/src/Plugins/Solutions/DevTokens/src/DeveloperTokenPrincipal.cs similarity index 92% rename from Application/Features/DeveloperTokens/DeveloperTokenPrincipal.cs rename to src/Plugins/Solutions/DevTokens/src/DeveloperTokenPrincipal.cs index 0d1b16f..91de162 100644 --- a/Application/Features/DeveloperTokens/DeveloperTokenPrincipal.cs +++ b/src/Plugins/Solutions/DevTokens/src/DeveloperTokenPrincipal.cs @@ -1,4 +1,4 @@ -namespace Application.Features.DeveloperTokens; +namespace DevTokens; /// /// Represents the authenticated principal of a developer token. diff --git a/Domain/Features/DeveloperTokens/DeveloperTokenLimitExceededException.cs b/src/Plugins/Solutions/DevTokens/src/Exceptions/DeveloperTokenLimitExceededException.cs similarity index 70% rename from Domain/Features/DeveloperTokens/DeveloperTokenLimitExceededException.cs rename to src/Plugins/Solutions/DevTokens/src/Exceptions/DeveloperTokenLimitExceededException.cs index 8f6b43f..d0be691 100644 --- a/Domain/Features/DeveloperTokens/DeveloperTokenLimitExceededException.cs +++ b/src/Plugins/Solutions/DevTokens/src/Exceptions/DeveloperTokenLimitExceededException.cs @@ -1,7 +1,10 @@ -namespace Domain.Features.DeveloperTokens; +using Core; + +namespace DevTokens.Exceptions; /// -/// Exception thrown when a developer exceeds the allowed number of tokens. +/// Represents domain exception raised when developer exceeds the +/// maximum number of developer tokens allowed. /// /// The unique identifier of the developer who exceeded the limit. /// The maximum number of tokens allowed per developer. diff --git a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenManager.cs b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenManager.cs similarity index 93% rename from Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenManager.cs rename to src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenManager.cs index 8086a95..4ccd93b 100644 --- a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenManager.cs +++ b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenManager.cs @@ -1,7 +1,6 @@ -using Application.Features.DeveloperTokens.DTO; -using Domain.Features.DeveloperTokens; +using DevTokens.DTO; -namespace Application.Features.DeveloperTokens.Interfaces; +namespace DevTokens.Interfaces; /// /// Provides operations for managing developer tokens. diff --git a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenService.cs b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenService.cs similarity index 73% rename from Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenService.cs rename to src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenService.cs index 038b33b..8eef4c1 100644 --- a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenService.cs +++ b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenService.cs @@ -1,7 +1,7 @@ -using Application.Features.DeveloperTokens.DTO; -using Domain.Features.DeveloperTokens; +using DevTokens.DeveloperTokens; +using DevTokens.DTO; -namespace Application.Features.DeveloperTokens.Interfaces; +namespace DevTokens.Interfaces; /// /// Service responsible for generating JWT tokens for developer tokens. @@ -15,7 +15,7 @@ namespace Application.Features.DeveloperTokens.Interfaces; public interface IDeveloperTokenService { /// - /// Generates a signed JWT string for the specified . + /// Generates signed JWT string for the specified . /// /// The developer token entity for which to generate a JWT. /// A containing the signed JWT string. diff --git a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenValidator.cs b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenValidator.cs similarity index 92% rename from Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenValidator.cs rename to src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenValidator.cs index a4d39c2..11f75a5 100644 --- a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenValidator.cs +++ b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenValidator.cs @@ -1,4 +1,4 @@ -namespace Application.Features.DeveloperTokens.Interfaces; +namespace DevTokens.Interfaces; /// /// Interface for validating developer JWT tokens. diff --git a/Infrastructure/Restful/Middleware/DeveloperTokenMiddleware.cs b/src/Plugins/Solutions/DevTokens/src/Middleware/DeveloperTokenMiddleware.cs similarity index 88% rename from Infrastructure/Restful/Middleware/DeveloperTokenMiddleware.cs rename to src/Plugins/Solutions/DevTokens/src/Middleware/DeveloperTokenMiddleware.cs index 1f4c32e..575a58b 100644 --- a/Infrastructure/Restful/Middleware/DeveloperTokenMiddleware.cs +++ b/src/Plugins/Solutions/DevTokens/src/Middleware/DeveloperTokenMiddleware.cs @@ -1,9 +1,9 @@ using System.Security.Claims; -using Application.Features.DeveloperTokens.Interfaces; +using DevTokens.Interfaces; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Infrastructure.Restful.Middleware; +namespace DevTokens.Middleware; /// /// Middleware responsible for validating "DeveloperToken" headers and enriching . @@ -12,9 +12,9 @@ namespace Infrastructure.Restful.Middleware; /// /// Checks for the presence of the "X-Developer-Token" header. /// Validates the token using . -/// Adds a to containing the developer ID, name, and scopes. +/// Adds to containing the developer ID, name, and scopes. /// Logs validation success or failure for observability. -/// Does not short-circuit the request; the pipeline continues regardless of token validity. +/// Does not short circuit the request, the pipeline continues regardless of token validity. /// /// public class DeveloperTokenMiddleware(RequestDelegate next, ILogger logger) diff --git a/src/Plugins/Solutions/DevTokens/src/Repositories/DeveloperTokenRepository.cs b/src/Plugins/Solutions/DevTokens/src/Repositories/DeveloperTokenRepository.cs new file mode 100644 index 0000000..f302d07 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/Repositories/DeveloperTokenRepository.cs @@ -0,0 +1,74 @@ +using Marten; + +namespace DevTokens.Repositories; + +/// +/// Repository for managing persistence using Marten. +/// +/// +/// +/// Provides persistence operations for developer tokens through a Marten +/// . +/// +/// +/// Stores and updates developer tokens in the document database. +/// Deletes developer tokens by their unique identifier. +/// Retrieves individual tokens by ID. +/// Retrieves all tokens belonging to a specific developer. +/// Supports cancellation for all asynchronous database operations. +/// +/// +public class DeveloperTokenRepository(IDocumentSession session) : IDeveloperTokenRepository +{ + /// + /// Saves the specified developer token to the document database. + /// + /// The developer token to persist. + /// A token that can be used to cancel the operation. + /// A task representing the asynchronous persistence operation. + public async Task SaveAsync( + DeveloperToken token, + CancellationToken ct = default) + { + session.Store(token); + await session.SaveChangesAsync(ct); + } + + /// + /// Deletes the developer token with the specified identifier. + /// + /// The unique identifier of the token to delete. + /// A token that can be used to cancel the operation. + /// A task representing the asynchronous deletion operation. + public async Task DeleteAsync( + Guid id, + CancellationToken ct = default) + { + session.Delete(id); + await session.SaveChangesAsync(ct); + } + + /// + /// Retrieves developer token by its unique identifier. + /// + /// The unique identifier of the token. + /// A token that can be used to cancel the operation. + /// The matching when found otherwise, null. + public async Task GetByIdAsync( + Guid id, + CancellationToken ct = default) + => await session.LoadAsync(id, ct); + + /// + /// Retrieves all developer tokens associated with the specified developer. + /// + /// The unique identifier of the developer. + /// A token that can be used to cancel the operation. + /// A readonly list containing all tokens associated with the specified developer. + public async Task> GetByDeveloperIdAsync( + Guid developerId, + CancellationToken ct = default) + => await session.Query() + .Where(x => x.DeveloperId == developerId) + .ToListAsync(ct); +} \ No newline at end of file diff --git a/Domain/Features/DeveloperTokens/Repositories/IDeveloperTokenRepository.cs b/src/Plugins/Solutions/DevTokens/src/Repositories/IDeveloperTokenRepository.cs similarity index 97% rename from Domain/Features/DeveloperTokens/Repositories/IDeveloperTokenRepository.cs rename to src/Plugins/Solutions/DevTokens/src/Repositories/IDeveloperTokenRepository.cs index f92642d..a06ad82 100644 --- a/Domain/Features/DeveloperTokens/Repositories/IDeveloperTokenRepository.cs +++ b/src/Plugins/Solutions/DevTokens/src/Repositories/IDeveloperTokenRepository.cs @@ -1,4 +1,4 @@ -namespace Domain.Features.DeveloperTokens.Repositories; +namespace DevTokens.Repositories; /// /// Repository interface for managing entities. diff --git a/src/Plugins/Solutions/DevTokens/src/Restful/DeveloperTokensController.cs b/src/Plugins/Solutions/DevTokens/src/Restful/DeveloperTokensController.cs new file mode 100644 index 0000000..431121a --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/Restful/DeveloperTokensController.cs @@ -0,0 +1,201 @@ +using System.Security.Claims; +using DevTokens.DTO; +using DevTokens.UseCase.Commands.Requests; +using DevTokens.UseCase.Queries.Requests; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Swashbuckle.AspNetCore.Annotations; +using Wolverine; + +namespace DevTokens.Restful; + +/// +/// Provides REST API endpoints for managing developer tokens. +/// +/// +/// +/// The controller exposes operations for creating, deleting, and retrieving +/// developer tokens belonging to the authenticated user. +/// +/// +/// Uses Wolverine to dispatch commands and queries to the corresponding application handlers. +/// Requires an authenticated user with the User role for all endpoints. +/// Extracts the authenticated developer identifier from the claim. +/// Documents the available operations and responses using Swagger annotations. +/// +/// +[ApiController] +[Route("sdk/developer-tokens")] +[SwaggerTag("Operations related to developer tokens")] +public class DeveloperTokensController( + IMessageBus messageBus, + ILogger logger) : ControllerBase +{ + /// + /// Gets the unique identifier of the currently authenticated user. + /// + /// + /// The parsed user identifier, or null when the + /// claim is missing or invalid. + /// + private Guid? GetUserId() => + Guid.TryParse( + User.FindFirstValue(ClaimTypes.NameIdentifier), + out var id) + ? id + : null; + + /// + /// Registers a new developer token for the authenticated user. + /// + /// + /// The request containing the token name, description, scopes, + /// and optional lifetime in days. + /// + /// + /// An HTTP 200 response containing the generated JWT, short API key, + /// token identifier, developer identifier, scopes, and lifetime. + /// Returns HTTP 401 when the authenticated user identifier cannot be resolved. + /// + [HttpPost] + [Authorize(Roles = "User")] + [SwaggerOperation( + Summary = "Registers new developer token", + Description = "Creates developer token for the authenticated user. Requires token name, description, scopes, and optional lifetime in days.")] + [SwaggerResponse( + 200, + "Returns the created developer token", + typeof(DeveloperTokenCreated))] + [SwaggerResponse(401, "Unauthorized")] + public async Task Register( + [FromBody] CreateTokenRequest request) + { + var userId = GetUserId(); + + if (userId is null) + return Unauthorized(); + + var lifetime = request.LifetimeDays.HasValue + ? TimeSpan.FromDays(request.LifetimeDays.Value) + : (TimeSpan?)null; + + var command = new CreateDeveloperTokenCommand( + userId.Value, + request.Name, + request.Description, + request.Scopes, + lifetime); + + var result = + await messageBus.InvokeAsync(command); + + logger.LogInformation( + "Token created: TokenId={TokenId}, DeveloperId={DeveloperId}", + result.Token.Id, + result.Token.DeveloperId); + + return Ok(new + { + jwt = result.Jwt, + key = result.ShortKey, + id = result.Token.Id, + developerId = result.Token.DeveloperId, + scopes = result.Token.Scopes, + lifetime = result.Token.Lifetime + }); + } + + /// + /// Deletes an existing developer token by its unique identifier. + /// + /// The unique identifier of the developer token to delete. + /// + /// An HTTP 200 response when the token is successfully deleted. + /// + [HttpDelete("{tokenId:guid}")] + [Authorize(Roles = "User")] + [SwaggerOperation( + Summary = "Deletes a developer token", + Description = "Deletes the specified developer token by its unique ID.")] + [SwaggerResponse(200, "Token deleted successfully")] + [SwaggerResponse(401, "Unauthorized")] + [SwaggerResponse(404, "Token not found")] + public async Task Delete(Guid tokenId) + { + var command = new DeleteTokenCommand(tokenId); + + await messageBus.InvokeAsync(command); + + logger.LogInformation( + "Token deleted successfully: TokenId={TokenId}", + tokenId); + + return Ok("Token deleted successfully"); + } + + /// + /// Retrieves all developer tokens belonging to the authenticated user. + /// + /// + /// An HTTP 200 response containing a read-only list of + /// instances. + /// Returns HTTP 401 when the authenticated user identifier cannot be resolved. + /// + [HttpGet] + [Authorize(Roles = "User")] + [SwaggerOperation( + Summary = "Retrieves all developer tokens", + Description = "Returns a read-only list of developer tokens for the authenticated user.")] + [SwaggerResponse( + 200, + "List of developer tokens", + typeof(IReadOnlyList))] + [SwaggerResponse(401, "Unauthorized")] + public async Task GetAll() + { + var userId = GetUserId(); + + if (userId is null) + return Unauthorized(); + + var query = new GetDeveloperTokensQuery(userId.Value); + var result = await messageBus.InvokeAsync>(query); + + logger.LogInformation("Retrieved {Count} tokens for DeveloperId={DeveloperId}", result.Count, userId); + return Ok(result); + } + + /// + /// Retrieves specific developer token by its unique identifier. + /// + /// The unique identifier of the developer token to retrieve. + /// + /// An HTTP 200 response containing the requested + /// , or HTTP 404 when the token does not exist. + /// + [HttpGet("{tokenId:guid}")] + [Authorize(Roles = "User")] + [SwaggerOperation( + Summary = "Retrieves a developer token by ID", + Description = "Returns a single developer token by its unique ID.")] + [SwaggerResponse( + 200, + "Developer token details", + typeof(DeveloperTokenDto))] + [SwaggerResponse(401, "Unauthorized")] + [SwaggerResponse(404, "Token not found")] + public async Task GetById(Guid tokenId) + { + var query = new GetDeveloperTokenByIdQuery(tokenId); + + var result = + await messageBus.InvokeAsync(query); + + if (result is null) + return NotFound(); + + logger.LogInformation("Token retrieved successfully: TokenId={TokenId}", tokenId); + return Ok(result); + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/src/Restful/TokenLifecycleController.cs b/src/Plugins/Solutions/DevTokens/src/Restful/TokenLifecycleController.cs new file mode 100644 index 0000000..59b6dd5 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/Restful/TokenLifecycleController.cs @@ -0,0 +1,69 @@ +using DevTokens.DTO; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace DevTokens.Restful; + +/// +/// Provides REST API endpoints for developer token lifecycle operations. +/// +/// +/// Exposes operations for rotating, revoking, and verifying developer tokens. +/// +/// Supports revoking an existing token and generating a replacement token. +/// Supports verification of developer token credentials against persisted token data. +/// Requires the User role for token rotation and revocation operations. +/// +/// +[ApiController] +[Route("sdk/tokens")] +[SwaggerTag("Developer token lifecycle operations: rotate, revoke, verify")] +public class TokenLifecycleController : ControllerBase +{ + /// + /// Revokes an existing developer token and generates a replacement token. + /// + /// The unique identifier of the developer token to revoke and rotate. + /// A token that can be used to cancel the asynchronous operation. + /// + /// An HTTP 200 response containing the newly generated developer token, + /// including its JWT and short API key. + /// + [HttpPost("{tokenId:guid}/revoke-rotate")] + [Authorize(Roles = "User")] + [SwaggerOperation( + Summary = "Revoke and rotate token", + Description = "Revokes the specified token and generates a new JWT and shortKey.")] + [SwaggerResponse( + 200, + "Token rotated successfully", + typeof(DeveloperTokenCreated))] + [SwaggerResponse(401, "Unauthorized")] + [SwaggerResponse(404, "Token not found")] + public Task RevokeAndRotate(Guid tokenId, CancellationToken ct) + { + return Task.FromResult(null!); + } + + /// + /// Verifies a developer token against its persisted token data. + /// + /// + /// The request containing the JWT and short API key to verify. + /// + /// + /// An HTTP 200 response containing true when the supplied token + /// is valid; otherwise false. + /// + [HttpPost("verify")] + [SwaggerOperation( + Summary = "Verify token validity", + Description = "Verifies that the provided JWT matches the stored token hash.")] + [SwaggerResponse(200, "Verification result", typeof(bool))] + [SwaggerResponse(401, "Unauthorized")] + public IActionResult Verify([FromBody] VerifyTokenRequest request) + { + return null; + } +} \ No newline at end of file diff --git a/Infrastructure/Restful/Controllers/VirtualMachineController.cs b/src/Plugins/Solutions/DevTokens/src/Restful/VirtualMachineController.cs similarity index 94% rename from Infrastructure/Restful/Controllers/VirtualMachineController.cs rename to src/Plugins/Solutions/DevTokens/src/Restful/VirtualMachineController.cs index 479078b..fd7f491 100644 --- a/Infrastructure/Restful/Controllers/VirtualMachineController.cs +++ b/src/Plugins/Solutions/DevTokens/src/Restful/VirtualMachineController.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Infrastructure.Restful.Controllers; +namespace DevTokens.DeveloperTokens.Restful; [ApiController] [Route("sdk/v1/function/vm")] diff --git a/Infrastructure/Security/DeveloperScope/DeveloperScopeHandler.cs b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeHandler.cs similarity index 94% rename from Infrastructure/Security/DeveloperScope/DeveloperScopeHandler.cs rename to src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeHandler.cs index 2ca9294..d24cdd6 100644 --- a/Infrastructure/Security/DeveloperScope/DeveloperScopeHandler.cs +++ b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeHandler.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Authorization; -namespace Infrastructure.Security.DeveloperScope; +namespace DevTokens.Security; /// -/// Authorization handler that verifies if the user possesses the required scope in a developer token. +/// Authorization handler that verifies if the user possesses the required scope in developer token. /// /// /// @@ -28,9 +28,9 @@ protected override Task HandleRequirementAsync(AuthorizationHandlerContext conte if (devIdentity == null) return Task.CompletedTask; var scopes = devIdentity.FindAll("scope").Select(c => c.Value).ToList(); - + if (requirement.RequiredScope != null && scopes.Contains(requirement.RequiredScope)) context.Succeed(requirement); return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopePolicyProvider.cs b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopePolicyProvider.cs new file mode 100644 index 0000000..53d45e5 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopePolicyProvider.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace DevTokens.Security; + +/// +/// Provides authorization policies dynamically based on developer token scopes. +/// +/// +/// +/// Policies whose names start with DeveloperScope: are generated dynamically +/// and require the corresponding developer token scope. +/// +/// +/// Extracts the required scope from policy names using the DeveloperScope: prefix. +/// Creates an containing for the requested scope. +/// Delegates unknown policy names to the default . +/// Delegates default and fallback policy resolution to the underlying provider. +/// +/// +public class DeveloperScopePolicyProvider( + IOptions options) : IAuthorizationPolicyProvider +{ + private readonly DefaultAuthorizationPolicyProvider _fallbackPolicyProvider = + new(options); + + /// + /// Retrieves an authorization policy by name. + /// + /// The name of the authorization policy to retrieve. + /// + /// A task containing the resolved , + /// or null when no matching policy exists. + /// + public Task GetPolicyAsync(string policyName) + { + if (!policyName.StartsWith("DeveloperScope:")) + return _fallbackPolicyProvider.GetPolicyAsync(policyName); + + var scope = policyName.Split(':')[1]; + + var policy = new AuthorizationPolicyBuilder() + .AddRequirements(new DeveloperScopeRequirement + { + RequiredScope = scope + }) + .Build(); + + return Task.FromResult(policy); + } + + /// + /// Retrieves the application default authorization policy. + /// + /// A task containing the default . + public Task GetDefaultPolicyAsync() + => _fallbackPolicyProvider.GetDefaultPolicyAsync(); + + /// + /// Retrieves the application's fallback authorization policy. + /// + /// + /// A task containing the fallback , + /// or null when no fallback policy is configured. + /// + public Task GetFallbackPolicyAsync() + => _fallbackPolicyProvider.GetFallbackPolicyAsync(); +} diff --git a/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeRequirement.cs b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeRequirement.cs new file mode 100644 index 0000000..5710c66 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeRequirement.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authorization; + +namespace DevTokens.Security; + +/// +/// Represents an authorization requirement for specific developer token scope. +/// +/// +/// +/// The requirement is evaluated by , which +/// determines whether the current identity contains the required developer token scope. +/// +/// +/// The required scope is assigned dynamically by +/// when creating scope based authorization policies. +/// +/// +public class DeveloperScopeRequirement : IAuthorizationRequirement +{ + /// + /// Gets or sets the scope that must be present in the developer token + /// for authorization to succeed. + /// + public string? RequiredScope { get; set; } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenManager.cs b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenManager.cs new file mode 100644 index 0000000..1cabd66 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenManager.cs @@ -0,0 +1,93 @@ +using DevTokens.DTO; +using DevTokens.Interfaces; +using DevTokens.Repositories; +using DevTokens.ValueObject; + +namespace DevTokens.Services; + +/// +/// Manages the lifecycle of developer tokens. +/// +/// +/// +/// Provides operations for creating, deleting, and retrieving developer tokens. +/// Delegates token generation to +/// and token persistence to . +/// +/// +public class DeveloperTokenManager( + IDeveloperTokenService tokenService, + IDeveloperTokenRepository repository) : IDeveloperTokenManager +{ + /// + /// Creates a new developer token and generates its token credentials. + /// + /// The unique identifier of the developer who owns the token. + /// The name assigned to the token. + /// The scopes granted to the token. + /// + /// Optional duration for which the token remains valid. If null, + /// the token does not expire. + /// + /// Cancellation token for the asynchronous operation. + /// + /// A containing the created token, + /// its short API key, and signed JWT. + /// + public async Task CreateAsync( + Guid developerId, + string name, + IEnumerable scopes, + TimeSpan? lifetime = null, + CancellationToken ct = default) + { + var token = DeveloperToken.Create( + developerId, + name, + scopes.Select(s => (TokenScope)s), + lifetime + ); + + var tokenPair = await tokenService.GenerateToken(token); + + return new DeveloperTokenCreated + { + Token = token, + ShortKey = tokenPair.ShortKey, + Jwt = tokenPair.Jwt + }; + } + + /// + /// Deletes developer token by its unique identifier. + /// + /// The unique identifier of the token to delete. + /// Cancellation token for the asynchronous operation. + /// A task representing the asynchronous deletion operation. + public async Task DeleteAsync( + Guid tokenId, + CancellationToken ct = default) => + await repository.DeleteAsync(tokenId, ct); + + /// + /// Retrieves all developer tokens belonging to the specified developer. + /// + /// The unique identifier of the developer. + /// Cancellation token for the asynchronous operation. + /// A readonly list containing the developer's tokens. + public async Task> GetByDeveloperAsync( + Guid developerId, + CancellationToken ct = default) => + await repository.GetByDeveloperIdAsync(developerId, ct); + + /// + /// Retrieves developer token by its unique identifier. + /// + /// The unique identifier of the token. + /// Cancellation token for the asynchronous operation. + /// The matching , or null if the token does not exist. + public async Task GetByIdAsync( + Guid tokenId, + CancellationToken ct = default) => + await repository.GetByIdAsync(tokenId, ct); +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenService.cs b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenService.cs new file mode 100644 index 0000000..9a00024 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenService.cs @@ -0,0 +1,149 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using Core.KeyManagement.Interfaces; +using Core.TokenKeyBindings.Interfaces; +using DevTokens.DTO; +using DevTokens.Interfaces; +using Microsoft.IdentityModel.Tokens; + +namespace DevTokens.Services; + +/// +/// Service responsible for generating JWT credentials for developer tokens. +/// +/// +/// +/// Uses the active signing key from to create +/// signed JSON Web Tokens for developer tokens. +/// +/// +/// Creates signing key binding between the developer token and the RSA key +/// used to sign its JWT through . +/// +/// +/// Generates both the signed JWT and short API key identifier. Tokens are +/// distinguished as temporary or permanent based on whether an expiration +/// date is configured. +/// +/// +public class DeveloperTokenService( + IJwtKeyStore jwtKeyStore, + IKeyBindingService keyBindingService) : IDeveloperTokenService +{ + /// + /// Generates JWT credentials for the specified developer token. + /// + /// The developer token for which credentials are generated. + /// A containing the generated short API key and signed JWT. + public async Task GenerateToken(DeveloperToken token) + { + var creds = GetSigningCredentials(); + var claims = BuildClaims(token); + + var jwt = CreateJwtToken(claims, token.Lifetime.ExpiresAt, creds); + await CreateKeyBinding(token.Id, creds.Key.KeyId!); + + var handler = new JwtSecurityTokenHandler(); + return new DeveloperTokenPairDto( + GenerateShortKey(token), + handler.WriteToken(jwt)); + } + + /// + /// Retrieves the active JWT signing credentials. + /// + /// The active signing credentials with a valid key identifier. + /// Thrown when the active signing key does not have a key identifier. + private SigningCredentials GetSigningCredentials() + { + var creds = jwtKeyStore.GetActiveSigningCredentials(); + return string.IsNullOrEmpty(creds.Key.KeyId) + ? throw new InvalidOperationException( + "SigningCredentials must have a KeyId set.") + : creds; + } + + /// + /// Creates binding between the developer token and its signing key. + /// + /// The unique identifier of the developer token. + /// The identifier of the signing key. + /// A task representing the asynchronous binding operation. + /// Thrown when the public JWK corresponding to the signing key cannot be found. + private async Task CreateKeyBinding(Guid tokenId, string signingKeyId) + { + var jwk = jwtKeyStore.GetPublicJwks() + .FirstOrDefault(k => k.Kid == signingKeyId) + ?? throw new InvalidOperationException( + $"No JWK for kid '{signingKeyId}'."); + + await keyBindingService.CreateBindingAsync( + tokenId, + signingKeyId, + jwk.N); + } + + /// + /// Creates a signed JWT for the specified claims and expiration time. + /// + /// Claims to include in the JWT. + /// Optional expiration date of the token. + /// Credentials used to sign the JWT. + /// A signed . + private static JwtSecurityToken CreateJwtToken( + IEnumerable claims, + DateTimeOffset? expiresAt, + SigningCredentials creds) + { + var expires = expiresAt?.UtcDateTime + ?? DateTime.UtcNow.AddYears(100); + + return new JwtSecurityToken( + claims: claims, + expires: expires, + signingCredentials: creds); + } + + /// + /// Builds the claims included in a developer token JWT. + /// + /// The developer token used as the source of the claims. + /// A sequence of JWT claims. + private static IEnumerable BuildClaims(DeveloperToken token) + { + yield return new Claim(JwtRegisteredClaimNames.Sub, token.DeveloperId.ToString()); + yield return new Claim(JwtRegisteredClaimNames.Jti, token.Id.ToString()); + yield return new Claim("name", token.Name); + yield return new Claim("type", + token.Lifetime.ExpiresAt.HasValue ? "temp" : "live"); + + foreach (var scope in token.Scopes) + { + yield return new Claim( + "scope", + scope.Value); + } + } + + /// + /// Generates a short API key for the specified developer token. + /// + /// The developer token for which the key is generated. + /// + /// A randomly generated API key prefixed with rk_temp_ for + /// temporary tokens or rk_live_ for permanent tokens. + /// + private static string GenerateShortKey(DeveloperToken token) + { + var prefix = token.Lifetime.ExpiresAt.HasValue + ? "rk_temp_" + : "rk_live_"; + + var randomHex = Convert + .ToHexString(RandomNumberGenerator.GetBytes(16)) + .ToLowerInvariant(); + + return $"{prefix}{randomHex}"; + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenValidatorService.cs b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenValidatorService.cs new file mode 100644 index 0000000..0b34b60 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenValidatorService.cs @@ -0,0 +1,195 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Core.KeyManagement.Interfaces; +using Core.TokenKeyBindings.Interfaces; +using DevTokens.Interfaces; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace DevTokens.Services; + +/// +/// Validates developer token JWTs using the configured JWT key store +/// and token to signing key bindings. +/// +/// +/// +/// Extracts the token identifier (jti) and signing key identifier +/// (kid) from the JWT and verifies that corresponding key binding exists. +/// +/// +/// Validates the JWT signature and lifetime using the signing credentials +/// associated with the specified key identifier. +/// +/// +/// When validation succeeds, creates +/// containing the developer identifier, token name, and assigned scopes. +/// +/// +public class DeveloperTokenValidatorService( + IJwtKeyStore jwtKeyStore, + IKeyBindingService keyBinding, + ILogger logger +) : IDeveloperTokenValidator +{ + private readonly JwtSecurityTokenHandler _handler = new(); + + /// + /// Validates developer token JWT and builds its authenticated principal. + /// + /// The JWT to validate. + /// + /// A when the token is valid; + /// otherwise, null. + /// + public async Task ValidateAsync(string jwtToken) + { + if (string.IsNullOrWhiteSpace(jwtToken)) + return null; + + var jwt = ReadJwtToken(jwtToken); + if (jwt == null) + return null; + + if (!TryGetTokenId(jwt, out var tokenId)) + { + logger.LogWarning("[Validator] Missing or invalid jti"); + return null; + } + + var kid = jwt.Header.Kid; + if (string.IsNullOrEmpty(kid)) + { + logger.LogWarning("[Validator] Missing kid in token header"); + return null; + } + + if (!await IsBindingValid(tokenId, kid)) + return null; + + var signingCreds = jwtKeyStore.GetSigningCredentialsByKid(kid); + if (signingCreds != null) + return ValidateSignatureAndBuildPrincipal(jwtToken, signingCreds); + + logger.LogWarning("[Validator] No signing creds for kid={Kid}", kid); + return null; + + } + + /// + /// Attempts to parse the supplied string as JWT. + /// + /// The JWT string to parse. + /// + /// The parsed , or null if parsing fails. + /// + private JwtSecurityToken? ReadJwtToken(string token) + { + try + { + return _handler.ReadJwtToken(token); + } + catch (Exception ex) + { + logger.LogWarning(ex, "[Validator] Token read failed"); + return null; + } + } + + /// + /// Attempts to extract the developer token identifier from the JWT jti claim. + /// + /// The parsed JWT. + /// The parsed token identifier. + /// + /// true when the jti claim contains a valid ; + /// otherwise, false. + /// + private static bool TryGetTokenId( + JwtSecurityToken jwt, + out Guid tokenId) + { + var jti = jwt.Claims + .FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Jti) + ?.Value; + + return Guid.TryParse(jti, out tokenId); + } + + /// + /// Verifies that a token-to-signing-key binding exists. + /// + /// The unique identifier of the developer token. + /// The signing key identifier from the JWT header. + /// + /// true when a matching binding exists; otherwise, false. + /// + private async Task IsBindingValid( + Guid tokenId, + string kid) + { + var binding = await keyBinding.GetBindingAsync(tokenId, kid); + + if (binding != null) return true; + logger.LogWarning("[Validator] No binding found for TokenId={TokenId}, Kid={Kid}", tokenId, kid); + return false; + + } + + /// + /// Validates the JWT signature and lifetime and creates the corresponding principal. + /// + /// The JWT to validate. + /// The signing credentials associated with the token's signing key. + /// + /// A when validation succeeds; + /// otherwise, null. + /// + private DeveloperTokenPrincipal? ValidateSignatureAndBuildPrincipal( + string jwtToken, + SigningCredentials creds) + { + try + { + var principal = _handler.ValidateToken( + jwtToken, + new TokenValidationParameters + { + RequireExpirationTime = true, + ValidateLifetime = true, + ValidateIssuer = false, + ValidateAudience = false, + IssuerSigningKey = creds.Key + }, + out _); + + return new DeveloperTokenPrincipal( + GetClaimValue(principal, "sub"), + GetClaimValue(principal, "name"), + principal.Claims + .Where(c => c.Type == "scope") + .Select(c => c.Value) + ); + } + catch (Exception ex) + { + logger.LogWarning(ex, "[Validator] Signature/lifetime validation failed"); + return null; + } + } + + /// + /// Retrieves and converts a claim value from the specified principal. + /// + /// The expected claim value type. + /// The claims principal containing the claim. + /// The claim type to retrieve. + /// The converted claim value. + private static T GetClaimValue( + ClaimsPrincipal principal, + string type) + { + var value = principal.Claims.First(c => c.Type == type).Value; + return (T)Convert.ChangeType(value, typeof(T)); + } +} \ No newline at end of file diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs similarity index 74% rename from Application/Features/DeveloperTokens/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs index 34501f1..cd432b2 100644 --- a/Application/Features/DeveloperTokens/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs +++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs @@ -1,12 +1,13 @@ -using Application.Features.DeveloperTokens.DTO; -using Application.Features.DeveloperTokens.Interfaces; -using Application.Features.DeveloperTokens.UseCase.Commands.Requests; -using Application.Options; -using Domain.Features.DeveloperTokens; -using Domain.Features.DeveloperTokens.Repositories; +using DevTokens.DeveloperTokens; +using DevTokens.DTO; +using DevTokens.Exceptions; +using DevTokens.Interfaces; +using DevTokens.Options; +using DevTokens.Repositories; +using DevTokens.UseCase.Commands.Requests; using Microsoft.Extensions.Options; -namespace Application.Features.DeveloperTokens.UseCase.Commands.Handlers; +namespace DevTokens.UseCase.Commands.Handlers; /// /// Handles the command. diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs similarity index 72% rename from Application/Features/DeveloperTokens/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs index 13faff2..e51fb9e 100644 --- a/Application/Features/DeveloperTokens/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs +++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs @@ -1,7 +1,7 @@ -using Application.Features.DeveloperTokens.Interfaces; -using Application.Features.DeveloperTokens.UseCase.Commands.Requests; +using DevTokens.Interfaces; +using DevTokens.UseCase.Commands.Requests; -namespace Application.Features.DeveloperTokens.UseCase.Commands.Handlers; +namespace DevTokens.UseCase.Commands.Handlers; /// /// Handles the command. diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs similarity index 89% rename from Application/Features/DeveloperTokens/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs index 28111d1..43cbc71 100644 --- a/Application/Features/DeveloperTokens/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs +++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs @@ -1,4 +1,4 @@ -namespace Application.Features.DeveloperTokens.UseCase.Commands.Requests; +namespace DevTokens.UseCase.Commands.Requests; /// /// Command for creating a new developer token. diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Requests/DeleteTokenCommand.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/DeleteTokenCommand.cs similarity index 83% rename from Application/Features/DeveloperTokens/UseCase/Commands/Requests/DeleteTokenCommand.cs rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/DeleteTokenCommand.cs index 1b66d4c..4b7d378 100644 --- a/Application/Features/DeveloperTokens/UseCase/Commands/Requests/DeleteTokenCommand.cs +++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/DeleteTokenCommand.cs @@ -1,4 +1,4 @@ -namespace Application.Features.DeveloperTokens.UseCase.Commands.Requests; +namespace DevTokens.UseCase.Commands.Requests; /// /// Represents a command to delete a specific developer token. diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs similarity index 90% rename from Application/Features/DeveloperTokens/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs index 678763e..81ea0ba 100644 --- a/Application/Features/DeveloperTokens/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs +++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs @@ -1,7 +1,7 @@ -using Application.Features.DeveloperTokens.UseCase.Commands.Requests; +using DevTokens.UseCase.Commands.Requests; using FluentValidation; -namespace Application.Features.DeveloperTokens.UseCase.Commands.Validations; +namespace DevTokens.UseCase.Commands.Validations; /// /// Validator for . diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs similarity index 77% rename from Application/Features/DeveloperTokens/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs index 51ac9d7..a320470 100644 --- a/Application/Features/DeveloperTokens/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs +++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs @@ -1,7 +1,7 @@ -using Application.Features.DeveloperTokens.UseCase.Commands.Requests; +using DevTokens.UseCase.Commands.Requests; using FluentValidation; -namespace Application.Features.DeveloperTokens.UseCase.Commands.Validations; +namespace DevTokens.UseCase.Commands.Validations; /// /// Validator for . diff --git a/Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs similarity index 79% rename from Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs rename to src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs index 923157a..f4184c3 100644 --- a/Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs +++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs @@ -1,8 +1,8 @@ -using Application.Features.DeveloperTokens.DTO; -using Application.Features.DeveloperTokens.Interfaces; -using Application.Features.DeveloperTokens.UseCase.Queries.Requests; +using DevTokens.DTO; +using DevTokens.Interfaces; +using DevTokens.UseCase.Queries.Requests; -namespace Application.Features.DeveloperTokens.UseCase.Queries.Handlers; +namespace DevTokens.UseCase.Queries.Handlers; /// /// Handles the query. diff --git a/Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs similarity index 74% rename from Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs rename to src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs index 172dc08..e07cd9a 100644 --- a/Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs +++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs @@ -1,8 +1,8 @@ -using Application.Features.DeveloperTokens.DTO; -using Application.Features.DeveloperTokens.Interfaces; -using Application.Features.DeveloperTokens.UseCase.Queries.Requests; +using DevTokens.DTO; +using DevTokens.Interfaces; +using DevTokens.UseCase.Queries.Requests; -namespace Application.Features.DeveloperTokens.UseCase.Queries.Handlers; +namespace DevTokens.UseCase.Queries.Handlers; /// /// Handles the query. diff --git a/Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs similarity index 76% rename from Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs rename to src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs index 2566f3e..1e1b26d 100644 --- a/Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs +++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs @@ -1,4 +1,4 @@ -namespace Application.Features.DeveloperTokens.UseCase.Queries.Requests; +namespace DevTokens.UseCase.Queries.Requests; /// /// Query for retrieving a specific developer token by its unique identifier. diff --git a/Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs similarity index 79% rename from Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs rename to src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs index 59c8084..fca707c 100644 --- a/Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs +++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs @@ -1,4 +1,4 @@ -namespace Application.Features.DeveloperTokens.UseCase.Queries.Requests; +namespace DevTokens.UseCase.Queries.Requests; /// /// Query for retrieving all developer tokens associated with a specific developer. diff --git a/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenLifetime.cs b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenLifetime.cs new file mode 100644 index 0000000..3e588f7 --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenLifetime.cs @@ -0,0 +1,96 @@ +namespace DevTokens.ValueObject; + +/// +/// Represents the lifetime of developer token, including its creation time +/// and optional expiration time. +/// +/// +/// +/// A token lifetime may be finite or unlimited. When +/// is null, the token does not have an expiration time. +/// +/// +/// specifies when the token was created. +/// specifies when the token expires, or null when the token never expires. +/// returns the lifetime duration in whole days when an expiration time is configured. +/// indicates whether the token has passed its expiration time. +/// returns the time remaining until expiration, or null for tokens without an expiration time. +/// +/// +public sealed record TokenLifetime +{ + /// + /// Gets the date and time when the token was created. + /// + public DateTimeOffset CreatedAt { get; init; } + + /// + /// Gets the date and time when the token expires. + /// A null value indicates that the token never expires. + /// + public DateTimeOffset? ExpiresAt { get; init; } + + /// + /// Initializes new instance of the record. + /// + /// The date and time when the token was created. + /// The optional expiration date and time of the token. + /// Thrown when is earlier than + public TokenLifetime( + DateTimeOffset createdAt, + DateTimeOffset? expiresAt = null) + { + if (expiresAt.HasValue && expiresAt.Value < createdAt) + { + throw new ArgumentException( + "Expiration date cannot be earlier than creation date.", + nameof(expiresAt)); + } + + CreatedAt = createdAt; + ExpiresAt = expiresAt; + } + + /// + /// Gets the configured lifetime in days. + /// + /// + /// The number of days between creation and expiration, or null + /// when the token does not expire. + /// + public int? Days => ExpiresAt.HasValue + ? (int?)(ExpiresAt.Value - CreatedAt).TotalDays + : null; + + /// + /// Gets value indicating whether the token has expired. + /// + /// + /// true when the token has an expiration time that has passed; + /// otherwise, false. + /// + public bool IsExpired => + ExpiresAt.HasValue && + DateTimeOffset.UtcNow > ExpiresAt.Value; + + /// + /// Gets the remaining time until the token expires. + /// + /// The remaining duration until expiration, or null when the token does not expire. + public TimeSpan? Remaining => + ExpiresAt.HasValue + ? ExpiresAt.Value - DateTimeOffset.UtcNow + : null; + + /// + /// Returns string representation of the token lifetime. + /// + /// + /// Formatted range containing the creation and expiration times, + /// or never when no expiration time is configured. + /// + public override string ToString() => + ExpiresAt.HasValue + ? $"{CreatedAt:u} - {ExpiresAt:u}" + : $"{CreatedAt:u} - never"; +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenName.cs b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenName.cs new file mode 100644 index 0000000..8a993ad --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenName.cs @@ -0,0 +1,34 @@ +namespace DevTokens.ValueObject; + +/// +/// Represents the name assigned to a developer token. +/// +/// +/// +/// The value cannot be null, empty, or consist only of whitespace. +/// The value is limited to a maximum of 100 characters. +/// Leading and trailing whitespace is removed during object creation. +/// Supports implicit conversion from to . +/// Supports implicit conversion from to . +/// +/// +public readonly record struct TokenName(string Value) +{ + /// + /// Returns the token name as a string. + /// + /// The underlying token name value. + public override string ToString() => Value; + + /// + /// Implicitly converts to . + /// + /// The token name to convert. + public static implicit operator string(TokenName t) => t.Value; + + /// + /// Implicitly converts to . + /// + /// The string value to convert. + public static implicit operator TokenName(string s) => new(s); +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenScope.cs b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenScope.cs new file mode 100644 index 0000000..dfd157b --- /dev/null +++ b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenScope.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; + +namespace DevTokens.ValueObject; + +/// +/// Represents a normalized scope assigned to the developer token. +/// +/// +/// +/// The scope name cannot be null, empty, or consist only of whitespace. +/// Leading and trailing whitespace is removed during object creation. +/// The scope name is normalized to lowercase using the invariant culture. +/// Supports implicit conversion from to . +/// Supports implicit conversion from to . +/// +/// +public readonly record struct TokenScope +{ + /// + /// Gets the normalized scope name. + /// + public string Value { get; } + + /// + /// Initializes new instance of the struct. + /// + /// The scope name to validate and normalize. + /// + /// Thrown when is null, empty, + /// or consists only of whitespace. + /// + [JsonConstructor] + public TokenScope(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException( + "Scope name cannot be null or empty.", + nameof(value)); + + Value = value.Trim().ToLowerInvariant(); + } + + /// + /// Implicitly converts to . + /// + /// The scope name to convert. + public static implicit operator TokenScope(string s) => new(s); + + /// + /// Implicitly converts to . + /// + /// The scope to convert. + public static implicit operator string(TokenScope s) => s.Value; + + /// + /// Returns the normalized scope name. + /// + /// The underlying scope value. + public override string ToString() => Value; +} \ No newline at end of file