| | | 1 | | using ArturRios.Heimdall.Command.Services; |
| | | 2 | | using ArturRios.Heimdall.Shared.Security; |
| | | 3 | | using ArturRios.Jwt; |
| | | 4 | | using Microsoft.IdentityModel.JsonWebTokens; |
| | | 5 | | using Microsoft.IdentityModel.Tokens; |
| | | 6 | | |
| | | 7 | | namespace ArturRios.Heimdall.WebApi.Security; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// Issues and validates application tokens (UC-56, UC-57, FR-AP-13): ES256, signed with the same |
| | | 11 | | /// key as person tokens, under the application audience. |
| | | 12 | | /// </summary> |
| | | 13 | | /// <remarks> |
| | | 14 | | /// <para> |
| | | 15 | | /// <b>Signing</b> goes through <see cref="JwtHandler" /> with the person configuration, so the |
| | | 16 | | /// current key signs and a rotation (<c>HEIMDALL_AUTH_SIGNING_KEY_PREVIOUS</c>) covers |
| | | 17 | | /// application tokens exactly as it covers person tokens. Only the audience, the lifetime and |
| | | 18 | | /// the claims differ. No <c>roleId</c> is written: an application holds no Heimdall role. |
| | | 19 | | /// </para> |
| | | 20 | | /// <para> |
| | | 21 | | /// <b>Validating</b> cannot go through <see cref="JwtHandler.IsTokenValidAsync(string, IEnumerable{JwtKey})" /> |
| | | 22 | | /// which checks only the signature and lifetime. Here the issuer, the audience and the kind |
| | | 23 | | /// are the point (TH-35), so the token is validated with every parameter set and no clock |
| | | 24 | | /// skew, under the configured EC keys and ES256 alone. The standard issuer check runs only |
| | | 25 | | /// when one is configured: <c>HEIMDALL_AUTH_TOKEN_ISSUER</c> defaults to empty, and an empty |
| | | 26 | | /// expected issuer would refuse every token. <see cref="TokenOrigin" /> then holds the token to |
| | | 27 | | /// exactly what Heimdall issues — the configured issuer, or none when none is configured, and |
| | | 28 | | /// the application audience as its only audience — so a token signed under another issuer is |
| | | 29 | | /// refused either way (TB-5). An HS256 token is refused, whatever it is signed with: no HMAC |
| | | 30 | | /// key is held, and HS256 is not a valid algorithm (Threat Model TH-43). |
| | | 31 | | /// </para> |
| | | 32 | | /// </remarks> |
| | 147 | 33 | | public class JwtApplicationTokenService( |
| | 147 | 34 | | JwtConfiguration configuration, |
| | 147 | 35 | | JwtHandler jwtHandler, |
| | 147 | 36 | | ApplicationTokenOptions options) : IApplicationTokenIssuer, IApplicationTokenValidator |
| | | 37 | | { |
| | 2 | 38 | | private static readonly JsonWebTokenHandler Reader = new(); |
| | | 39 | | |
| | | 40 | | // The configured ES256 keys, the current and any previous one, as IdentityModel holds them. |
| | 351 | 41 | | private readonly IReadOnlyList<SecurityKey> _keys = [.. configuration.Keys.Select(key => key.ToSecurityKey())]; |
| | | 42 | | |
| | | 43 | | public Task<AuthToken> IssueAsync(ApplicationTokenSubject subject) |
| | | 44 | | { |
| | 15 | 45 | | var claims = new Dictionary<string, string> |
| | 15 | 46 | | { |
| | 15 | 47 | | [ApplicationTokenClaims.TokenKind] = ApplicationTokenClaims.ApplicationKind, |
| | 15 | 48 | | [ApplicationTokenClaims.Id] = subject.ApplicationId.ToString(), |
| | 15 | 49 | | [ApplicationTokenClaims.ScopeId] = subject.ScopeId.ToString(), |
| | 15 | 50 | | [ApplicationTokenClaims.CredentialId] = subject.CredentialId.ToString(), |
| | 15 | 51 | | [ApplicationTokenClaims.Name] = subject.Name |
| | 15 | 52 | | }; |
| | | 53 | | |
| | 15 | 54 | | var tokenConfiguration = configuration with |
| | 15 | 55 | | { |
| | 15 | 56 | | Audience = options.Audience, |
| | 15 | 57 | | ExpirationInSeconds = options.Lifetime.TotalSeconds, |
| | 15 | 58 | | Claims = claims |
| | 15 | 59 | | }; |
| | | 60 | | |
| | 15 | 61 | | return Task.FromResult(new AuthToken( |
| | 15 | 62 | | jwtHandler.CreateToken(tokenConfiguration), |
| | 15 | 63 | | DateTime.UtcNow.Add(options.Lifetime))); |
| | | 64 | | } |
| | | 65 | | |
| | | 66 | | public async Task<ApplicationTokenPrincipal?> ValidateAsync(string? token) |
| | | 67 | | { |
| | 42 | 68 | | if (string.IsNullOrWhiteSpace(token)) |
| | | 69 | | { |
| | 3 | 70 | | return null; |
| | | 71 | | } |
| | | 72 | | |
| | 39 | 73 | | var result = await Reader.ValidateTokenAsync(token, new TokenValidationParameters |
| | 39 | 74 | | { |
| | 39 | 75 | | ValidIssuer = configuration.Issuer, |
| | 39 | 76 | | ValidateIssuer = !string.IsNullOrWhiteSpace(configuration.Issuer), |
| | 39 | 77 | | ValidAudience = options.Audience, |
| | 39 | 78 | | ValidateAudience = true, |
| | 39 | 79 | | IssuerSigningKeys = _keys, |
| | 39 | 80 | | ValidateIssuerSigningKey = true, |
| | 39 | 81 | | // Defence in depth, kept on purpose: the keys are EC keys alone, so an HS256, HS384 or |
| | 39 | 82 | | // HS512 token cannot verify against any of them, and this pin would refuse one even if an |
| | 39 | 83 | | // HMAC key were ever held. |
| | 39 | 84 | | ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256], |
| | 39 | 85 | | ValidateLifetime = true, |
| | 39 | 86 | | RequireExpirationTime = true, |
| | 39 | 87 | | ClockSkew = TimeSpan.Zero |
| | 39 | 88 | | }); |
| | | 89 | | |
| | 39 | 90 | | if (!result.IsValid || |
| | 39 | 91 | | !TokenOrigin.IsIssuedFor(token, configuration.Issuer, options.Audience) || |
| | 39 | 92 | | Claim(result, ApplicationTokenClaims.TokenKind) != ApplicationTokenClaims.ApplicationKind || |
| | 39 | 93 | | !Guid.TryParse(Claim(result, ApplicationTokenClaims.Id), out var applicationId) || |
| | 39 | 94 | | !Guid.TryParse(Claim(result, ApplicationTokenClaims.ScopeId), out var scopeId) || |
| | 39 | 95 | | !Guid.TryParse(Claim(result, ApplicationTokenClaims.CredentialId), out var credentialId)) |
| | | 96 | | { |
| | 26 | 97 | | return null; |
| | | 98 | | } |
| | | 99 | | |
| | 13 | 100 | | return new ApplicationTokenPrincipal(applicationId, scopeId, credentialId, result.SecurityToken.ValidTo); |
| | 42 | 101 | | } |
| | | 102 | | |
| | | 103 | | private static string? Claim(TokenValidationResult result, string name) => |
| | 57 | 104 | | result.Claims.TryGetValue(name, out var value) ? value?.ToString() : null; |
| | | 105 | | } |