/ GUNRPG.Infrastructure / Identity / JwtTokenService.cs
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  }