< Summary

Information
Class: ArturRios.Jwt.JwtConfigurationValidator
Assembly: ArturRios.Jwt
File(s): /home/runner/work/dotnet-jwt/dotnet-jwt/src/JwtConfigurationValidator.cs
Line coverage
100%
Covered lines: 77
Uncovered lines: 0
Coverable lines: 77
Total lines: 139
Line coverage: 100%
Branch coverage
87%
Covered branches: 7
Total branches: 8
Branch coverage: 87.5%
Method coverage
100%
Covered methods: 4
Fully covered methods: 4
Total methods: 4
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%44100%
HmacRules()100%22100%
EcdsaRules()50%22100%
IsLongEnough(...)100%11100%

File(s)

/home/runner/work/dotnet-jwt/dotnet-jwt/src/JwtConfigurationValidator.cs

#LineLine coverage
 1using System.Text;
 2using FluentValidation;
 3
 4namespace 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>
 15public 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>
 4426    public JwtConfigurationValidator()
 27    {
 4428        RuleFor(config => config.Audience).NotEmpty();
 4429        RuleFor(config => config.Issuer).NotEmpty();
 4430        RuleFor(config => config.ExpirationInSeconds).NotEmpty().GreaterThan(0);
 4431        RuleFor(config => config.Claims).NotEmpty();
 32
 4433        RuleFor(config => config.Algorithm)
 4734            .Must(algorithm => algorithm is JwtAlgorithms.HS256 or JwtAlgorithms.ES256)
 4435            .WithMessage(
 4436                $"'{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.
 4442        RuleFor(config => config.Keys)
 7443            .Must(keys => keys.Select(key => key.Id).Distinct().Count() == keys.Count)
 4744            .When(config => config.Keys.Count > 0)
 4445            .WithMessage($"'{nameof(JwtConfiguration.Keys)}' must not contain two keys with the same id.");
 46
 4447        RuleFor(config => config.Keys)
 7448            .Must(keys => keys.All(key => !string.IsNullOrWhiteSpace(key.Id)))
 4749            .When(config => config.Keys.Count > 0)
 4450            .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.
 4456        RuleFor(config => config.SigningKeyId)
 4557            .Must((config, signingKeyId) => config.Keys.Any(key => key.Id == signingKeyId))
 4758            .When(config => !string.IsNullOrWhiteSpace(config.SigningKeyId))
 4459            .WithMessage($"'{nameof(JwtConfiguration.SigningKeyId)}' must name a key in '{nameof(JwtConfiguration.Keys)}
 60
 9161        When(config => config.Algorithm == JwtAlgorithms.HS256, HmacRules);
 9162        When(config => config.Algorithm == JwtAlgorithms.ES256, EcdsaRules);
 4463    }
 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.
 7972        When(config => string.IsNullOrWhiteSpace(config.SigningKeyId), () =>
 4473        {
 4474            RuleFor(config => config.Secret)
 4475                .Cascade(CascadeMode.Stop)
 4476                .NotEmpty()
 4477                .WithMessage($"'{nameof(JwtConfiguration.Secret)}' must not be empty when no '{nameof(JwtConfiguration.S
 4478                .Must(IsLongEnough)
 4479                .WithMessage(
 4480                    $"'{nameof(JwtConfiguration.Secret)}' must be at least {MinimumSecretBytes} bytes long, which HMAC-S
 4481                // The same reason SigningKeyId must name a key in Keys: once Keys is set it is the set
 4482                // of accepted keys, and a token signed with anything else fails on its first use.
 2783                .Must((config, secret) => config.Keys.Count == 0 || config.Keys.Any(key => key.Secret == secret))
 4484                .WithMessage(
 4485                    $"'{nameof(JwtConfiguration.Secret)}' signs the tokens when no '{nameof(JwtConfiguration.SigningKeyI
 8886        });
 87
 4488        RuleFor(config => config.Keys)
 4989            .Must(keys => keys.All(key => !key.IsAsymmetric))
 3590            .When(config => config.Keys.Count > 0)
 4491            .WithMessage(
 4492                $"An {JwtAlgorithms.HS256} configuration accepts only {JwtAlgorithms.HS256} keys, but '{nameof(JwtConfig
 93
 4494        RuleFor(config => config.Keys)
 4995            .Must(keys => keys.All(key => !string.IsNullOrWhiteSpace(key.Secret)))
 3596            .When(config => config.Keys.Count > 0)
 4497            .WithMessage($"Every key in '{nameof(JwtConfiguration.Keys)}' must have a secret.");
 98
 4499        RuleFor(config => config.Keys)
 74100            .Must(keys => keys.Where(key => !string.IsNullOrWhiteSpace(key.Secret)).All(key => IsLongEnough(key.Secret))
 35101            .When(config => config.Keys.Count > 0)
 44102            .WithMessage(
 44103                $"Every key in '{nameof(JwtConfiguration.Keys)}' must have a secret of at least {MinimumSecretBytes} byt
 44104    }
 105
 106    /// <summary>The rules of an ES256 configuration.</summary>
 107    private void EcdsaRules()
 108    {
 44109        RuleFor(config => config.Secret)
 44110            .Empty()
 44111            .WithMessage(
 44112                $"'{nameof(JwtConfiguration.Secret)}' must be empty in an {JwtAlgorithms.ES256} configuration, which sig
 113
 44114        RuleFor(config => config.SigningKeyId)
 44115            .NotEmpty()
 44116            .WithMessage(
 44117                $"An {JwtAlgorithms.ES256} configuration signs with the key '{nameof(JwtConfiguration.SigningKeyId)}' na
 118
 44119        RuleFor(config => config.Keys)
 25120            .Must(keys => keys.All(key => key.IsAsymmetric))
 44121            .WithMessage(
 44122                $"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.
 44126        RuleFor(config => config.Keys)
 38127            .Must(keys => keys.Where(key => key.IsAsymmetric).All(key => key.Id == key.Thumbprint))
 44128            .WithMessage(
 44129                $"Every {JwtAlgorithms.ES256} key in '{nameof(JwtConfiguration.Keys)}' must have its RFC 7638 thumbprint
 130
 44131        RuleFor(config => config.SigningKeyId)
 20132            .Must((config, signingKeyId) => config.Keys.FirstOrDefault(key => key.Id == signingKeyId) is not { } key || 
 11133            .When(config => !string.IsNullOrWhiteSpace(config.SigningKeyId))
 44134            .WithMessage(
 44135                $"The key '{nameof(JwtConfiguration.SigningKeyId)}' names must hold its private half: a public key valid
 44136    }
 137
 44138    private static bool IsLongEnough(string secret) => Encoding.ASCII.GetByteCount(secret) >= MinimumSecretBytes;
 139}