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