Files
git.stella-ops.org/src/Registry/StellaOps.Registry.TokenService/RegistryTokenIssuer.cs
2026-02-01 21:37:40 +02:00

135 lines
4.5 KiB
C#

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<RegistryTokenServiceOptions> 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<RegistryAccessRequest> requests)
{
var decision = _planRegistry.Authorize(principal, requests);
if (!decision.Allowed)
{
_metrics.TokensRejected.Add(1, new KeyValuePair<string, object?>("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<string, object?>("plan", plan));
return new RegistryTokenResponse(
serialized,
(int)_options.Signing.Lifetime.TotalSeconds,
now);
}
private static object BuildAccessClaim(IReadOnlyList<RegistryAccessRequest> requests)
{
return requests
.Select(request => new Dictionary<string, object>
{
["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; }
}