| | | 1 | | using System.Text; |
| | | 2 | | using FluentValidation; |
| | | 3 | | |
| | | 4 | | namespace ArturRios.Jwt; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Validates <see cref="JwtConfiguration"/> instances, ensuring they carry the values required to create a valid token. |
| | | 8 | | /// </summary> |
| | | 9 | | /// <remarks> |
| | | 10 | | /// A configuration is held to its <see cref="JwtConfiguration.Algorithm"/>. An HS256 one keeps every rule it has |
| | | 11 | | /// always had. An ES256 one holds EC keys only, each named by its RFC 7638 thumbprint, signs with one that has its |
| | | 12 | | /// private half, and has no <see cref="JwtConfiguration.Secret"/>. The two kinds of key never share a configuration, |
| | | 13 | | /// because a key set validates one algorithm. |
| | | 14 | | /// </remarks> |
| | | 15 | | public class JwtConfigurationValidator : AbstractValidator<JwtConfiguration> |
| | | 16 | | { |
| | | 17 | | /// <summary> |
| | | 18 | | /// The shortest secret accepted, in bytes: HMAC-SHA256 needs a key at least as long as its 256-bit |
| | | 19 | | /// output (RFC 7518, section 3.2). Secrets are read as ASCII, one byte per character. |
| | | 20 | | /// </summary> |
| | | 21 | | private const int MinimumSecretBytes = 32; |
| | | 22 | | |
| | | 23 | | /// <summary> |
| | | 24 | | /// Initializes a new instance of the <see cref="JwtConfigurationValidator"/> class and configures its validation ru |
| | | 25 | | /// </summary> |
| | 44 | 26 | | public JwtConfigurationValidator() |
| | | 27 | | { |
| | 44 | 28 | | RuleFor(config => config.Audience).NotEmpty(); |
| | 44 | 29 | | RuleFor(config => config.Issuer).NotEmpty(); |
| | 44 | 30 | | RuleFor(config => config.ExpirationInSeconds).NotEmpty().GreaterThan(0); |
| | 44 | 31 | | RuleFor(config => config.Claims).NotEmpty(); |
| | | 32 | | |
| | 44 | 33 | | RuleFor(config => config.Algorithm) |
| | 47 | 34 | | .Must(algorithm => algorithm is JwtAlgorithms.HS256 or JwtAlgorithms.ES256) |
| | 44 | 35 | | .WithMessage( |
| | 44 | 36 | | $"'{nameof(JwtConfiguration.Algorithm)}' must be '{JwtAlgorithms.HS256}' or '{JwtAlgorithms.ES256}'."); |
| | | 37 | | |
| | | 38 | | // A duplicate id makes the kid header ambiguous: two keys answer to it, and which one |
| | | 39 | | // validates a token would come down to ordering. The signature would still have to match, so |
| | | 40 | | // it is not a way in — but "it worked until we reordered the configuration" is not a failure |
| | | 41 | | // anyone should have to debug. |
| | 44 | 42 | | RuleFor(config => config.Keys) |
| | 74 | 43 | | .Must(keys => keys.Select(key => key.Id).Distinct().Count() == keys.Count) |
| | 47 | 44 | | .When(config => config.Keys.Count > 0) |
| | 44 | 45 | | .WithMessage($"'{nameof(JwtConfiguration.Keys)}' must not contain two keys with the same id."); |
| | | 46 | | |
| | 44 | 47 | | RuleFor(config => config.Keys) |
| | 74 | 48 | | .Must(keys => keys.All(key => !string.IsNullOrWhiteSpace(key.Id))) |
| | 47 | 49 | | .When(config => config.Keys.Count > 0) |
| | 44 | 50 | | .WithMessage($"Every key in '{nameof(JwtConfiguration.Keys)}' must have an id."); |
| | | 51 | | |
| | | 52 | | // The signing key must already be accepted. This is the rule that makes a rotation safe to |
| | | 53 | | // perform in the documented order: the new key is added to Keys everywhere first, and only |
| | | 54 | | // then does anything start signing with it. A configuration that signs with a key nothing |
| | | 55 | | // accepts would issue tokens that fail on their first use. |
| | 44 | 56 | | RuleFor(config => config.SigningKeyId) |
| | 45 | 57 | | .Must((config, signingKeyId) => config.Keys.Any(key => key.Id == signingKeyId)) |
| | 47 | 58 | | .When(config => !string.IsNullOrWhiteSpace(config.SigningKeyId)) |
| | 44 | 59 | | .WithMessage($"'{nameof(JwtConfiguration.SigningKeyId)}' must name a key in '{nameof(JwtConfiguration.Keys)} |
| | | 60 | | |
| | 91 | 61 | | When(config => config.Algorithm == JwtAlgorithms.HS256, HmacRules); |
| | 91 | 62 | | When(config => config.Algorithm == JwtAlgorithms.ES256, EcdsaRules); |
| | 44 | 63 | | } |
| | | 64 | | |
| | | 65 | | /// <summary>The rules every configuration had before ES256 existed, plus: no EC key among its keys.</summary> |
| | | 66 | | private void HmacRules() |
| | | 67 | | { |
| | | 68 | | // Secret signs whenever SigningKeyId names no key — JwtHandler.CreateToken falls back to it — |
| | | 69 | | // so it is required exactly then, and a configuration that signs with a key from Keys has no |
| | | 70 | | // use for it. Requiring it only when Keys was empty let a configuration with keys but no |
| | | 71 | | // SigningKeyId through with nothing to sign with, and CreateToken threw on it. |
| | 79 | 72 | | When(config => string.IsNullOrWhiteSpace(config.SigningKeyId), () => |
| | 44 | 73 | | { |
| | 44 | 74 | | RuleFor(config => config.Secret) |
| | 44 | 75 | | .Cascade(CascadeMode.Stop) |
| | 44 | 76 | | .NotEmpty() |
| | 44 | 77 | | .WithMessage($"'{nameof(JwtConfiguration.Secret)}' must not be empty when no '{nameof(JwtConfiguration.S |
| | 44 | 78 | | .Must(IsLongEnough) |
| | 44 | 79 | | .WithMessage( |
| | 44 | 80 | | $"'{nameof(JwtConfiguration.Secret)}' must be at least {MinimumSecretBytes} bytes long, which HMAC-S |
| | 44 | 81 | | // The same reason SigningKeyId must name a key in Keys: once Keys is set it is the set |
| | 44 | 82 | | // of accepted keys, and a token signed with anything else fails on its first use. |
| | 27 | 83 | | .Must((config, secret) => config.Keys.Count == 0 || config.Keys.Any(key => key.Secret == secret)) |
| | 44 | 84 | | .WithMessage( |
| | 44 | 85 | | $"'{nameof(JwtConfiguration.Secret)}' signs the tokens when no '{nameof(JwtConfiguration.SigningKeyI |
| | 88 | 86 | | }); |
| | | 87 | | |
| | 44 | 88 | | RuleFor(config => config.Keys) |
| | 49 | 89 | | .Must(keys => keys.All(key => !key.IsAsymmetric)) |
| | 35 | 90 | | .When(config => config.Keys.Count > 0) |
| | 44 | 91 | | .WithMessage( |
| | 44 | 92 | | $"An {JwtAlgorithms.HS256} configuration accepts only {JwtAlgorithms.HS256} keys, but '{nameof(JwtConfig |
| | | 93 | | |
| | 44 | 94 | | RuleFor(config => config.Keys) |
| | 49 | 95 | | .Must(keys => keys.All(key => !string.IsNullOrWhiteSpace(key.Secret))) |
| | 35 | 96 | | .When(config => config.Keys.Count > 0) |
| | 44 | 97 | | .WithMessage($"Every key in '{nameof(JwtConfiguration.Keys)}' must have a secret."); |
| | | 98 | | |
| | 44 | 99 | | RuleFor(config => config.Keys) |
| | 74 | 100 | | .Must(keys => keys.Where(key => !string.IsNullOrWhiteSpace(key.Secret)).All(key => IsLongEnough(key.Secret)) |
| | 35 | 101 | | .When(config => config.Keys.Count > 0) |
| | 44 | 102 | | .WithMessage( |
| | 44 | 103 | | $"Every key in '{nameof(JwtConfiguration.Keys)}' must have a secret of at least {MinimumSecretBytes} byt |
| | 44 | 104 | | } |
| | | 105 | | |
| | | 106 | | /// <summary>The rules of an ES256 configuration.</summary> |
| | | 107 | | private void EcdsaRules() |
| | | 108 | | { |
| | 44 | 109 | | RuleFor(config => config.Secret) |
| | 44 | 110 | | .Empty() |
| | 44 | 111 | | .WithMessage( |
| | 44 | 112 | | $"'{nameof(JwtConfiguration.Secret)}' must be empty in an {JwtAlgorithms.ES256} configuration, which sig |
| | | 113 | | |
| | 44 | 114 | | RuleFor(config => config.SigningKeyId) |
| | 44 | 115 | | .NotEmpty() |
| | 44 | 116 | | .WithMessage( |
| | 44 | 117 | | $"An {JwtAlgorithms.ES256} configuration signs with the key '{nameof(JwtConfiguration.SigningKeyId)}' na |
| | | 118 | | |
| | 44 | 119 | | RuleFor(config => config.Keys) |
| | 25 | 120 | | .Must(keys => keys.All(key => key.IsAsymmetric)) |
| | 44 | 121 | | .WithMessage( |
| | 44 | 122 | | $"An {JwtAlgorithms.ES256} configuration accepts only {JwtAlgorithms.ES256} keys: every key in '{nameof( |
| | | 123 | | |
| | | 124 | | // The kid is derived, never configured: it is what a published key set names a key by, so a key |
| | | 125 | | // renamed with a with-expression would sign tokens no consumer can match to a key. |
| | 44 | 126 | | RuleFor(config => config.Keys) |
| | 38 | 127 | | .Must(keys => keys.Where(key => key.IsAsymmetric).All(key => key.Id == key.Thumbprint)) |
| | 44 | 128 | | .WithMessage( |
| | 44 | 129 | | $"Every {JwtAlgorithms.ES256} key in '{nameof(JwtConfiguration.Keys)}' must have its RFC 7638 thumbprint |
| | | 130 | | |
| | 44 | 131 | | RuleFor(config => config.SigningKeyId) |
| | 20 | 132 | | .Must((config, signingKeyId) => config.Keys.FirstOrDefault(key => key.Id == signingKeyId) is not { } key || |
| | 11 | 133 | | .When(config => !string.IsNullOrWhiteSpace(config.SigningKeyId)) |
| | 44 | 134 | | .WithMessage( |
| | 44 | 135 | | $"The key '{nameof(JwtConfiguration.SigningKeyId)}' names must hold its private half: a public key valid |
| | 44 | 136 | | } |
| | | 137 | | |
| | 44 | 138 | | private static bool IsLongEnough(string secret) => Encoding.ASCII.GetByteCount(secret) >= MinimumSecretBytes; |
| | | 139 | | } |