using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using StellaOps.Determinism; using StellaOps.Registry.TokenService.Observability; using StellaOps.Registry.TokenService.Security; using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; namespace StellaOps.Registry.TokenService; public sealed class RegistryTokenIssuer { private readonly RegistryTokenServiceOptions _options; private readonly PlanRegistry _planRegistry; private readonly RegistryTokenMetrics _metrics; private readonly SigningCredentials _signingCredentials; private readonly JwtSecurityTokenHandler _tokenHandler = new(); private readonly TimeProvider _timeProvider; private readonly IGuidProvider _guidProvider; public RegistryTokenIssuer( IOptions options, PlanRegistry planRegistry, RegistryTokenMetrics metrics, TimeProvider timeProvider, IGuidProvider? guidProvider = null) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(planRegistry); ArgumentNullException.ThrowIfNull(metrics); ArgumentNullException.ThrowIfNull(timeProvider); _options = options.Value; _planRegistry = planRegistry; _metrics = metrics; _timeProvider = timeProvider; _guidProvider = guidProvider ?? SystemGuidProvider.Instance; _signingCredentials = SigningKeyLoader.Load(_options.Signing); } public RegistryTokenResponse IssueToken( ClaimsPrincipal principal, string service, IReadOnlyList requests) { var decision = _planRegistry.Authorize(principal, requests); if (!decision.Allowed) { _metrics.TokensRejected.Add(1, new KeyValuePair("reason", decision.FailureReason ?? "denied")); throw new RegistryTokenException(decision.FailureReason ?? "denied"); } var now = _timeProvider.GetUtcNow(); var expires = now + _options.Signing.Lifetime; var subject = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? principal.FindFirstValue("client_id") ?? principal.FindFirstValue("sub") ?? "anonymous"; var payload = new JwtPayload( issuer: _options.Signing.Issuer, audience: _options.Signing.Audience ?? service, claims: null, notBefore: now.UtcDateTime, expires: expires.UtcDateTime, issuedAt: now.UtcDateTime) { { JwtRegisteredClaimNames.Sub, subject }, { JwtRegisteredClaimNames.Jti, _guidProvider.NewGuid().ToString("n") }, { "service", service }, { "access", BuildAccessClaim(requests) } }; var licenseId = principal.FindFirstValue("stellaops:license"); if (!string.IsNullOrWhiteSpace(licenseId)) { payload["stellaops:license"] = licenseId; } var token = new JwtSecurityToken(new JwtHeader(_signingCredentials), payload); var serialized = _tokenHandler.WriteToken(token); var plan = principal.FindFirstValue("stellaops:plan") ?? _options.DefaultPlan ?? "unknown"; _metrics.TokensIssued.Add(1, new KeyValuePair("plan", plan)); return new RegistryTokenResponse( serialized, (int)_options.Signing.Lifetime.TotalSeconds, now); } private static object BuildAccessClaim(IReadOnlyList requests) { return requests .Select(request => new Dictionary { ["type"] = request.Type, ["name"] = request.Name, ["actions"] = request.Actions }) .ToArray(); } } public sealed class RegistryTokenResponse { public RegistryTokenResponse(string token, int expiresInSeconds, DateTimeOffset issuedAt) { Token = token; ExpiresIn = expiresInSeconds; IssuedAt = issuedAt; } public string Token { get; } public int ExpiresIn { get; } public DateTimeOffset IssuedAt { get; } } public sealed class RegistryTokenException : Exception { public RegistryTokenException(string reason) : base($"Token request denied: {reason}") { Reason = reason; } public string Reason { get; } }