135 lines
4.5 KiB
C#
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; }
|
|
}
|