< Summary

Information
Class: ArturRios.Jwt.JwtHandler
Assembly: ArturRios.Jwt
File(s): /home/runner/work/dotnet-jwt/dotnet-jwt/src/JwtHandler.cs
Line coverage
98%
Covered lines: 98
Uncovered lines: 2
Coverable lines: 100
Total lines: 359
Line coverage: 98%
Branch coverage
95%
Covered branches: 42
Total branches: 44
Branch coverage: 95.4%
Method coverage
100%
Covered methods: 15
Fully covered methods: 14
Total methods: 15
Method coverage: 100%
Full method coverage: 93.3%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
CreateToken(...)100%11100%
GetUserIdFromToken(...)100%66100%
GetKeyIdFromToken(...)100%44100%
IsTokenValidAsync(...)100%11100%
IsTokenValidAsync()100%1414100%
ReadToken(...)100%2275%
ResolveSigningCredentials(...)100%44100%
ResolveSecretSigningKey(...)100%44100%
ResolveEcSigningKey(...)83.33%66100%
NamedSigningKey(...)100%22100%
KeyFrom(...)50%22100%
IsSignatureValidAsync(...)100%22100%
IsSignatureValidAsync()100%22100%
IsValidUnderAsync()100%11100%

File(s)

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

#LineLine coverage
 1using System.IdentityModel.Tokens.Jwt;
 2using System.Security.Claims;
 3using System.Text;
 4using Microsoft.IdentityModel.Tokens;
 5
 6namespace ArturRios.Jwt;
 7
 8/// <summary>
 9/// Creates and validates JSON Web Tokens (JWTs) based on a <see cref="JwtConfiguration"/>.
 10/// </summary>
 11public class JwtHandler
 12{
 8013    private readonly JwtSecurityTokenHandler _handler = new();
 14
 15    /// <summary>
 16    /// Creates a signed JWT from the given configuration, with the algorithm it names: HMAC-SHA256 by
 17    /// default, or ES256.
 18    /// </summary>
 19    /// <remarks>
 20    /// <para>
 21    /// <b>HS256.</b> Signs with the key <see cref="JwtConfiguration.SigningKeyId"/> names, when it names
 22    /// one, and stamps that identifier on the token's <c>kid</c> header so a validator can tell which key
 23    /// to check it against. Otherwise signs with <see cref="JwtConfiguration.Secret"/> and writes no
 24    /// <c>kid</c>, exactly as it did before rotation was supported.
 25    /// </para>
 26    /// <para>
 27    /// <b>ES256.</b> Signs with the private key <see cref="JwtConfiguration.SigningKeyId"/> names in
 28    /// <see cref="JwtConfiguration.Keys"/>, and stamps its thumbprint on the <c>kid</c> header.
 29    /// </para>
 30    /// </remarks>
 31    /// <param name="configuration">The issuer, audience, expiration, algorithm, signing key and claims to embed in the 
 32    /// <returns>The serialized, signed JWT.</returns>
 33    /// <exception cref="InvalidOperationException">
 34    /// <see cref="JwtConfiguration.SigningKeyId"/> names a key that is not in <see cref="JwtConfiguration.Keys"/>,
 35    /// names a key of the other algorithm, or — for ES256 — names nothing or a key without its private half; or
 36    /// <see cref="JwtConfiguration.Algorithm"/> is neither HS256 nor ES256. Signing with a key nothing will
 37    /// accept would produce a token that fails on its first use, so it fails here instead, where the cause is
 38    /// visible.
 39    /// </exception>
 40    public string CreateToken(JwtConfiguration configuration)
 41    {
 5742        var credentials = ResolveSigningCredentials(configuration);
 43
 10244        var claimsList = configuration.Claims.Select(c => new Claim(c.Key, c.Value)).ToList();
 45
 5146        ClaimsIdentity identity = new(claimsList);
 47
 5148        var creationDate = DateTime.UtcNow;
 5149        var expirationDate = creationDate + TimeSpan.FromSeconds(configuration.ExpirationInSeconds);
 50
 5151        var token = _handler.CreateToken(new SecurityTokenDescriptor
 5152        {
 5153            Issuer = configuration.Issuer,
 5154            Audience = configuration.Audience,
 5155            SigningCredentials = credentials,
 5156            Subject = identity,
 5157            NotBefore = creationDate,
 5158            Expires = expirationDate
 5159        });
 60
 5161        return _handler.WriteToken(token);
 62    }
 63
 64    /// <summary>
 65    /// Reads a JWT and extracts the user id from its "id" claim, without validating the token's signature.
 66    /// </summary>
 67    /// <param name="token">The JWT to read.</param>
 68    /// <returns>
 69    /// The user id from the token's "id" claim, or <see langword="null"/> when the token cannot be read,
 70    /// carries no "id" claim, or carries one that is not an integer.
 71    /// </returns>
 72    /// <remarks>
 73    /// The value is unverified: reading a claim proves nothing about who wrote it. Validate the token
 74    /// first if the id is going to decide anything.
 75    /// </remarks>
 76    public int? GetUserIdFromToken(string token)
 77    {
 1178        if (ReadToken(token) is not JwtSecurityToken jwtToken)
 79        {
 680            return null;
 81        }
 82
 1583        var claim = jwtToken.Claims.FirstOrDefault(x => x.Type == "id");
 84
 585        return claim is not null && int.TryParse(claim.Value, out var userId) ? userId : null;
 86    }
 87
 88    /// <summary>
 89    /// Reads the <c>kid</c> header naming the key that signed a JWT, without validating its signature.
 90    /// </summary>
 91    /// <param name="token">The JWT to read.</param>
 92    /// <returns>
 93    /// The key identifier, or <see langword="null"/> if the token cannot be read or carries no
 94    /// <c>kid</c>.
 95    /// </returns>
 96    /// <remarks>
 97    /// The value is unverified, because reading a header proves nothing about who wrote it. It says
 98    /// which key to check the signature against and must not be trusted for anything else.
 99    /// </remarks>
 100    public string? GetKeyIdFromToken(string token)
 101    {
 60102        if (ReadToken(token) is not JwtSecurityToken jwtToken)
 103        {
 5104            return null;
 105        }
 106
 55107        return string.IsNullOrWhiteSpace(jwtToken.Header.Kid) ? null : jwtToken.Header.Kid;
 108    }
 109
 110    /// <summary>
 111    /// Validates a JWT's signature and lifetime against the given secret.
 112    /// </summary>
 113    /// <param name="token">The JWT to validate.</param>
 114    /// <param name="secret">The secret key expected to have been used to sign the token.</param>
 115    /// <returns>
 116    /// <see langword="true"/> if the token is signed with HMAC-SHA256 under <paramref name="secret"/> and has
 117    /// not expired; otherwise — including when <paramref name="secret"/> is blank — <see langword="false"/>.
 118    /// </returns>
 119    /// <remarks>
 120    /// <para>
 121    /// The issuer and the audience are <b>not</b> checked. A token signed with the same secret by a
 122    /// different issuer, or minted for a different audience, passes here. Check those claims yourself
 123    /// when more than one party holds the secret.
 124    /// </para>
 125    /// <para>
 126    /// Only HMAC-SHA256 (<c>HS256</c>) is accepted. The <c>alg</c> header is chosen by whoever wrote the
 127    /// token, so it is pinned rather than trusted.
 128    /// </para>
 129    /// </remarks>
 130    public Task<bool> IsTokenValidAsync(string token, string secret) =>
 14131        IsSignatureValidAsync(token, secret);
 132
 133    /// <summary>
 134    /// Validates a JWT's signature against any of the given keys, so tokens signed with a key that is
 135    /// no longer the signing key remain valid until it is withdrawn.
 136    /// </summary>
 137    /// <param name="token">The JWT to validate.</param>
 138    /// <param name="keys">
 139    /// The keys whose signatures are accepted: HS256 secrets, or ES256 keys (public halves suffice), never
 140    /// both. A key with a blank secret accepts nothing.
 141    /// </param>
 142    /// <returns><see langword="true"/> if the token's signature is valid and it has not expired; otherwise, <see langwo
 143    /// <remarks>
 144    /// <para>
 145    /// The issuer and the audience are <b>not</b> checked, as with the single-secret overload.
 146    /// </para>
 147    /// <para>
 148    /// Each key validates its own algorithm only — a secret HS256, an EC key ES256 — whatever the
 149    /// token's <c>alg</c> header names, so an HS256 token keyed with a published public key is refused.
 150    /// </para>
 151    /// <para>
 152    /// A token carrying a <c>kid</c> is checked against that key alone. An unrecognised <c>kid</c> is
 153    /// refused without trying the others: the identifier came from the token, so an unknown one says
 154    /// the token was signed by something this configuration does not accept, and trying every key
 155    /// anyway would only spend work to reach the same answer.
 156    /// </para>
 157    /// <para>
 158    /// A token carrying no <c>kid</c> is tried against each key in turn. That is what lets a
 159    /// deployment adopt rotation without invalidating the tokens it issued before it did.
 160    /// </para>
 161    /// </remarks>
 162    /// <exception cref="ArgumentException">
 163    /// <paramref name="keys"/> mixes HS256 and ES256 keys. A key set validates one algorithm; validate
 164    /// against each set separately.
 165    /// </exception>
 166    public async Task<bool> IsTokenValidAsync(string token, IEnumerable<JwtKey> keys)
 167    {
 50168        var candidates = keys.ToList();
 169
 50170        if (candidates.Count == 0)
 171        {
 2172            return false;
 173        }
 174
 135175        if (candidates.Any(key => key.IsAsymmetric) && candidates.Any(key => !key.IsAsymmetric))
 176        {
 1177            throw new ArgumentException(
 1178                $"The key set mixes {JwtAlgorithms.HS256} and {JwtAlgorithms.ES256} keys. A key set validates one " +
 1179                "algorithm: validate against each set separately.",
 1180                nameof(keys));
 181        }
 182
 47183        if (GetKeyIdFromToken(token) is { } keyId)
 184        {
 88185            var named = candidates.FirstOrDefault(key => key.Id == keyId);
 186
 40187            return named is not null && await IsSignatureValidAsync(token, named);
 188        }
 189
 33190        foreach (var key in candidates)
 191        {
 11192            if (await IsSignatureValidAsync(token, key))
 193            {
 3194                return true;
 195            }
 196        }
 197
 4198        return false;
 49199    }
 200
 201    /// <summary>
 202    /// Reads a JWT without validating its signature, returning <see langword="null"/> if it cannot be read.
 203    /// </summary>
 204    /// <remarks>
 205    /// <see cref="JwtSecurityTokenHandler.CanReadToken"/> only checks the shape — three segments separated
 206    /// by dots — so it says yes to <c>"a.b.c"</c> and the read that follows then throws on the malformed
 207    /// base64url. Since the token is caller input, and the public readers document a <see langword="null"/>
 208    /// for anything unreadable, the failure is caught here rather than handed to the caller.
 209    /// </remarks>
 210    private SecurityToken? ReadToken(string token)
 211    {
 71212        if (!_handler.CanReadToken(token))
 213        {
 9214            return null;
 215        }
 216
 217        try
 218        {
 62219            return _handler.ReadToken(token);
 220        }
 2221        catch (ArgumentException)
 222        {
 2223            return null;
 224        }
 0225        catch (SecurityTokenException)
 226        {
 0227            return null;
 228        }
 62229    }
 230
 231    /// <summary>The credentials a configuration signs with, for the algorithm it names.</summary>
 232    private static SigningCredentials ResolveSigningCredentials(JwtConfiguration configuration) =>
 57233        configuration.Algorithm switch
 57234        {
 43235            JwtAlgorithms.HS256 => new SigningCredentials(ResolveSecretSigningKey(configuration),
 43236                SecurityAlgorithms.HmacSha256Signature),
 13237            JwtAlgorithms.ES256 => new SigningCredentials(ResolveEcSigningKey(configuration).ToSecurityKey(),
 13238                SecurityAlgorithms.EcdsaSha256),
 1239            _ => throw new InvalidOperationException(
 1240                $"'{configuration.Algorithm}' is not an algorithm this library signs with. Use " +
 1241                $"'{JwtAlgorithms.HS256}' or '{JwtAlgorithms.ES256}'.")
 57242        };
 243
 244    /// <summary>
 245    /// The HS256 key a configuration signs with: the one <see cref="JwtConfiguration.SigningKeyId"/> names,
 246    /// or <see cref="JwtConfiguration.Secret"/> when it names none.
 247    /// </summary>
 248    private static SymmetricSecurityKey ResolveSecretSigningKey(JwtConfiguration configuration)
 249    {
 43250        if (string.IsNullOrWhiteSpace(configuration.SigningKeyId))
 251        {
 22252            return KeyFrom(configuration.Secret);
 253        }
 254
 21255        var signingKey = NamedSigningKey(configuration);
 256
 19257        if (signingKey.IsAsymmetric)
 258        {
 1259            throw new InvalidOperationException(
 1260                $"The signing key '{signingKey.Id}' is an {JwtAlgorithms.ES256} key, but the configuration's " +
 1261                $"{nameof(JwtConfiguration.Algorithm)} is {JwtAlgorithms.HS256}. Set it to {JwtAlgorithms.ES256} to sign
 262        }
 263
 18264        return KeyFrom(signingKey.Secret, signingKey.Id);
 265    }
 266
 267    /// <summary>The ES256 private key a configuration signs with: the one <see cref="JwtConfiguration.SigningKeyId"/> n
 268    private static JwtKey ResolveEcSigningKey(JwtConfiguration configuration)
 269    {
 13270        if (string.IsNullOrWhiteSpace(configuration.SigningKeyId))
 271        {
 1272            throw new InvalidOperationException(
 1273                $"An {JwtAlgorithms.ES256} configuration signs with the key '{nameof(JwtConfiguration.SigningKeyId)}' " 
 1274                $"names in '{nameof(JwtConfiguration.Keys)}', and none is named.");
 275        }
 276
 12277        var signingKey = NamedSigningKey(configuration);
 278
 12279        if (!signingKey.IsAsymmetric || !signingKey.CanSign)
 280        {
 1281            throw new InvalidOperationException(
 1282                $"The signing key '{signingKey.Id}' is not an {JwtAlgorithms.ES256} private key. An {JwtAlgorithms.ES256
 1283                $"configuration signs with a key from '{nameof(JwtConfiguration.Keys)}' that holds its private half.");
 284        }
 285
 11286        return signingKey;
 287    }
 288
 289    /// <summary>The key <see cref="JwtConfiguration.SigningKeyId"/> names in <see cref="JwtConfiguration.Keys"/>.</summ
 290    private static JwtKey NamedSigningKey(JwtConfiguration configuration) =>
 73291        configuration.Keys.FirstOrDefault(key => key.Id == configuration.SigningKeyId)
 33292        ?? throw new InvalidOperationException(
 33293            $"The signing key id '{configuration.SigningKeyId}' names no key in {nameof(JwtConfiguration.Keys)}.");
 294
 295    /// <summary>
 296    /// Builds the HS256 signing key, carrying its identifier when it has one so that
 297    /// <see cref="JwtSecurityTokenHandler"/> writes the <c>kid</c> header.
 298    /// </summary>
 299    /// <remarks>
 300    /// <para>
 301    /// ASCII rather than UTF-8, matching what this class has always used. Reading a secret's bytes
 302    /// differently would silently invalidate every token signed with a secret outside ASCII, which is
 303    /// not a change to make while adding a feature.
 304    /// </para>
 305    /// <para>
 306    /// A consequence worth knowing: <see cref="Encoding.ASCII"/> replaces every character above U+007F
 307    /// with <c>?</c>, so a secret drawn from a wider alphabet contributes far less entropy than its
 308    /// length suggests, and two such secrets can collapse onto the same key. Keep secrets to printable
 309    /// ASCII, and at least 32 bytes of it, which is what HMAC-SHA256 needs.
 310    /// </para>
 311    /// </remarks>
 312    private static SymmetricSecurityKey KeyFrom(string secret, string? keyId = null)
 313    {
 79314        var bytes = string.IsNullOrWhiteSpace(secret) ? [] : Encoding.ASCII.GetBytes(secret);
 315
 79316        return new SymmetricSecurityKey(bytes) { KeyId = keyId };
 317    }
 318
 319    /// <summary>Checks a token against one key, under that key's algorithm and no other.</summary>
 320    private Task<bool> IsSignatureValidAsync(string token, JwtKey key) =>
 44321        key.IsAsymmetric
 44322            ? IsValidUnderAsync(token, key.ToSecurityKey(), SecurityAlgorithms.EcdsaSha256)
 44323            : IsSignatureValidAsync(token, key.Secret, key.Id);
 324
 325    /// <summary>
 326    /// Checks a token's signature and lifetime against one secret.
 327    /// </summary>
 328    /// <remarks>
 329    /// A blank secret is a refusal, not an error: <see cref="SymmetricSecurityKey"/> throws on a
 330    /// zero-length key, and the public validators promise a <see langword="false"/> for anything that
 331    /// does not validate — a missing secret included.
 332    /// </remarks>
 333    private async Task<bool> IsSignatureValidAsync(string token, string secret, string? keyId = null)
 334    {
 43335        if (string.IsNullOrWhiteSpace(secret))
 336        {
 4337            return false;
 338        }
 339
 39340        return await IsValidUnderAsync(token, KeyFrom(secret, keyId), SecurityAlgorithms.HmacSha256);
 43341    }
 342
 343    /// <summary>Checks a token's signature, under exactly one algorithm, and its lifetime.</summary>
 344    private async Task<bool> IsValidUnderAsync(string token, SecurityKey key, string algorithm)
 345    {
 54346        var output = await _handler.ValidateTokenAsync(token,
 54347            new TokenValidationParameters
 54348            {
 54349                ValidateIssuerSigningKey = true,
 54350                IssuerSigningKey = key,
 54351                ValidAlgorithms = [algorithm],
 54352                ValidateIssuer = false,
 54353                ValidateAudience = false,
 54354                ClockSkew = TimeSpan.Zero
 54355            });
 356
 54357        return output.IsValid;
 54358    }
 359}