JwtTokenService.cs
1 using System.IdentityModel.Tokens.Jwt; 2 using System.Security.Claims; 3 using System.Security.Cryptography; 4 using System.Text; 5 using GUNRPG.Application.Identity; 6 using GUNRPG.Application.Identity.Dtos; 7 using GUNRPG.Application.Results; 8 using GUNRPG.Core.Identity; 9 using GUNRPG.Security; 10 using LiteDB; 11 using Microsoft.Extensions.Options; 12 13 namespace GUNRPG.Infrastructure.Identity; 14 15 /// <summary> 16 /// JWT token service using Ed25519 (EdDSA) signing. 17 /// Ed25519 is a modern, compact, and high-performance signing algorithm. 18 /// The key pair is generated on first run and persisted to the LiteDB metadata collection. 19 /// 20 /// Each issued token includes a <c>kid</c> (key ID) header — a SHA-256 thumbprint of the 21 /// Ed25519 public key bytes — enabling future key rotation: validators can select the correct 22 /// public key by looking up the <c>kid</c> in a published JWKS-like endpoint. 23 /// </summary> 24 public sealed class JwtTokenService : ITokenService, IPublicKeyProvider 25 { 26 private const string MetaCollection = "identity_meta"; 27 private const string PrivateKeyField = "ed25519_private_key"; 28 private const string PublicKeyField = "ed25519_public_key"; 29 30 private readonly JwtOptions _options; 31 private readonly ILiteCollection<BsonDocument> _meta; 32 private readonly ILiteCollection<RefreshToken> _refreshTokens; 33 private readonly ILiteCollection<ApplicationUser> _users; 34 35 private readonly byte[] _privateKey; 36 private readonly byte[] _publicKey; 37 /// <summary>SHA-256 thumbprint of the public key, used as the JWT <c>kid</c> header.</summary> 38 private readonly string _keyId; 39 40 public JwtTokenService(IOptions<JwtOptions> options, ILiteDatabase db) 41 { 42 _options = options.Value; 43 _meta = db.GetCollection<BsonDocument>(MetaCollection); 44 _refreshTokens = db.GetCollection<RefreshToken>("identity_refresh_tokens"); 45 _users = db.GetCollection<ApplicationUser>(LiteDbUserStore.CollectionName); 46 _refreshTokens.EnsureIndex(t => t.UserId); 47 _refreshTokens.EnsureIndex(t => t.Token, unique: true); 48 49 (_privateKey, _publicKey) = LoadOrGenerateKeyPair(); 50 _keyId = ComputeKeyId(_publicKey); 51 } 52 53 // ── ITokenService ──────────────────────────────────────────────────────── 54 55 public async Task<TokenResponse> IssueTokensAsync( 56 string userId, 57 string? username, 58 Guid? accountId, 59 CancellationToken ct = default) 60 { 61 var accessToken = BuildAccessToken(userId, username, accountId); 62 var refresh = await CreateRefreshTokenAsync(userId, username, accountId, ct); 63 return new TokenResponse( 64 accessToken, 65 refresh.Token, 66 DateTimeOffset.UtcNow.AddMinutes(_options.AccessTokenExpiryMinutes), 67 refresh.ExpiresAt); 68 } 69 70 public async Task<ServiceResult<TokenResponse>> RefreshAsync(string refreshToken, CancellationToken ct = default) 71 { 72 var existing = _refreshTokens.FindOne(t => t.Token == refreshToken); 73 if (existing is null || !existing.IsActive) 74 return ServiceResult<TokenResponse>.InvalidState("Refresh token is invalid, expired, or already consumed."); 75 76 var accountId = existing.AccountId; 77 if (!accountId.HasValue || accountId.Value == Guid.Empty) 78 { 79 var accountProvisioning = await AccountIdProvisioning.EnsureAssignedAsync(_users, existing.UserId, ct); 80 if (!accountProvisioning.Result.Succeeded) 81 { 82 return ServiceResult<TokenResponse>.InvalidState( 83 string.Join("; ", accountProvisioning.Result.Errors.Select(e => e.Description))); 84 } 85 86 accountId = accountProvisioning.AccountId; 87 } 88 89 // Rotate: consume old, issue new (preserving original username/accountId claims) 90 existing.IsConsumed = true; 91 existing.AccountId = accountId; 92 var newRefresh = await CreateRefreshTokenAsync(existing.UserId, existing.Username, accountId, ct); 93 existing.ReplacedByToken = newRefresh.Token; 94 _refreshTokens.Update(existing); 95 96 var accessToken = BuildAccessToken(existing.UserId, existing.Username, accountId); 97 98 return ServiceResult<TokenResponse>.Success(new TokenResponse( 99 accessToken, 100 newRefresh.Token, 101 DateTimeOffset.UtcNow.AddMinutes(_options.AccessTokenExpiryMinutes), 102 newRefresh.ExpiresAt)); 103 } 104 105 public Task RevokeAllAsync(string userId, CancellationToken ct = default) 106 { 107 var tokens = _refreshTokens.Find(t => t.UserId == userId && !t.IsConsumed && !t.IsRevoked).ToList(); 108 foreach (var t in tokens) 109 { 110 t.IsRevoked = true; 111 _refreshTokens.Update(t); 112 } 113 return Task.CompletedTask; 114 } 115 116 // ── Helpers ────────────────────────────────────────────────────────────── 117 118 private string BuildAccessToken(string userId, string? username, Guid? accountId) 119 { 120 var now = DateTime.UtcNow; 121 var claims = new List<Claim> 122 { 123 new(JwtRegisteredClaimNames.Sub, userId), 124 new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), 125 new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), 126 }; 127 128 if (username is not null) 129 claims.Add(new(JwtRegisteredClaimNames.PreferredUsername, username)); 130 131 if (accountId.HasValue) 132 claims.Add(new("account_id", accountId.Value.ToString())); 133 134 var payload = BuildPayload(claims, now, now.AddMinutes(_options.AccessTokenExpiryMinutes)); 135 return SignToken(payload); 136 } 137 138 private string BuildPayload(IEnumerable<Claim> claims, DateTime notBefore, DateTime expires) 139 { 140 // Build header manually for EdDSA (alg=EdDSA) + key ID for future key rotation. 141 var header = new JwtHeader 142 { 143 { JwtHeaderParameterNames.Alg, "EdDSA" }, 144 { JwtHeaderParameterNames.Typ, "JWT" }, 145 { JwtHeaderParameterNames.Kid, _keyId }, 146 }; 147 148 var payload = new JwtPayload( 149 issuer: _options.Issuer, 150 audience: _options.Audience, 151 claims: claims, 152 notBefore: notBefore, 153 expires: expires); 154 155 return $"{Base64UrlEncode(Encoding.UTF8.GetBytes(header.SerializeToJson()))}.{Base64UrlEncode(Encoding.UTF8.GetBytes(payload.SerializeToJson()))}"; 156 } 157 158 private string SignToken(string headerDotPayload) 159 { 160 var data = Encoding.UTF8.GetBytes(headerDotPayload); 161 var signature = AuthorityCrypto.SignPayload(_privateKey, data); 162 return $"{headerDotPayload}.{Base64UrlEncode(signature)}"; 163 } 164 165 private Task<RefreshToken> CreateRefreshTokenAsync(string userId, string? username, Guid? accountId, CancellationToken _) 166 { 167 var tokenBytes = RandomNumberGenerator.GetBytes(32); 168 var token = new RefreshToken 169 { 170 UserId = userId, 171 Token = Base64UrlEncode(tokenBytes), 172 ExpiresAt = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenExpiryDays), 173 Username = username, 174 AccountId = accountId, 175 }; 176 _refreshTokens.Insert(token); 177 return Task.FromResult(token); 178 } 179 180 private (byte[] PrivateKey, byte[] PublicKey) LoadOrGenerateKeyPair() 181 { 182 var existingDoc = _meta.FindOne(d => d["_id"] == PrivateKeyField); 183 if (existingDoc is not null) 184 { 185 var privateBytes = AuthorityCrypto.CloneAndValidatePrivateKey(existingDoc[PrivateKeyField].AsBinary); 186 var publicBytes = AuthorityCrypto.CloneAndValidatePublicKey(existingDoc[PublicKeyField].AsBinary); 187 var derivedPublicBytes = AuthorityCrypto.GetPublicKey(privateBytes); 188 189 if (!CryptographicOperations.FixedTimeEquals(publicBytes, derivedPublicBytes)) 190 { 191 existingDoc[PublicKeyField] = derivedPublicBytes; 192 _meta.Upsert(existingDoc); 193 return (privateBytes, derivedPublicBytes); 194 } 195 196 return (privateBytes, publicBytes); 197 } 198 199 var priv = AuthorityCrypto.GeneratePrivateKey(); 200 var pub = AuthorityCrypto.GetPublicKey(priv); 201 202 _meta.Upsert(new BsonDocument 203 { 204 ["_id"] = PrivateKeyField, 205 [PrivateKeyField] = priv, 206 [PublicKeyField] = pub, 207 }); 208 209 return (priv, pub); 210 } 211 212 /// <summary>Exposes the Ed25519 public key bytes for node-to-node trust exchange.</summary> 213 public byte[] GetPublicKeyBytes() => (byte[])_publicKey.Clone(); 214 215 /// <summary> 216 /// Returns the JWT <c>kid</c> (key ID) — a SHA-256 thumbprint of the public key bytes. 217 /// Future validators can use this to select the correct public key from a JWKS-like endpoint 218 /// when key rotation is implemented. 219 /// </summary> 220 public string GetKeyId() => _keyId; 221 222 private static string ComputeKeyId(byte[] publicKeyBytes) 223 { 224 var hash = SHA256.HashData(publicKeyBytes); 225 return Base64UrlEncode(hash[..16]); // 16 bytes = 128-bit ID, compact but unique 226 } 227 228 private static string Base64UrlEncode(byte[] data) => 229 Convert.ToBase64String(data) 230 .TrimEnd('=') 231 .Replace('+', '-') 232 .Replace('/', '_'); 233 }