< Summary

Information
Class: ArturRios.Jwt.JwksDocument
Assembly: ArturRios.Jwt
File(s): /home/runner/work/dotnet-jwt/dotnet-jwt/src/JwksDocument.cs
Line coverage
100%
Covered lines: 80
Uncovered lines: 0
Coverable lines: 80
Total lines: 223
Line coverage: 100%
Branch coverage
93%
Covered branches: 43
Total branches: 46
Branch coverage: 93.4%
Method coverage
100%
Covered methods: 6
Fully covered methods: 6
Total methods: 6
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
From(...)100%88100%
Parse(...)100%1212100%
TryRead(...)100%11100%
ReadEntry(...)94.44%1818100%
Required(...)50%44100%
OptionalIs(...)100%44100%

File(s)

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

#LineLine coverage
 1using System.Text;
 2using System.Text.Json;
 3
 4namespace ArturRios.Jwt;
 5
 6/// <summary>
 7/// JSON Web Key Sets (RFC 7517) of ES256 public keys: what an issuer publishes so that others can verify
 8/// its tokens without holding anything that signs them, and what those others read.
 9/// </summary>
 10/// <remarks>
 11/// Every member written is a fixed ASCII name or a base64url value (a coordinate, or an id that is a
 12/// thumbprint, since <see cref="From"/> refuses any other id), so the default <see cref="Utf8JsonWriter"/>
 13/// encoder, which escapes <c>+ &lt; &gt; &amp; '</c> and non-ASCII characters, never changes any of them.
 14/// </remarks>
 15public static class JwksDocument
 16{
 17    private const string Kty = "kty";
 18    private const string Crv = "crv";
 19    private const string X = "x";
 20    private const string Y = "y";
 21    private const string Kid = "kid";
 22    private const string Use = "use";
 23    private const string Alg = "alg";
 24    private const string PrivateScalar = "d";
 25    private const string Signature = "sig";
 26
 27    /// <summary>
 28    /// The key set publishing the public halves of <paramref name="keys"/>, in order: <c>kty</c>,
 29    /// <c>crv</c>, <c>x</c>, <c>y</c>, <c>kid</c> (the key's id), <c>use</c> (<c>sig</c>) and <c>alg</c>
 30    /// (<c>ES256</c>) for each. A private half is never written, whether the key holds one or not.
 31    /// </summary>
 32    /// <remarks>
 33    /// A key's id must be its RFC 7638 <see cref="JwtKey.Thumbprint"/>, as it is for every key read with
 34    /// <see cref="JwtKey.FromEcPrivateKeyPem"/> or <see cref="JwtKey.FromEcPublicJwk"/>. One renamed with a
 35    /// <see langword="with"/> expression is refused rather than published, because <see cref="Parse"/>, and
 36    /// any consumer that checks thumbprints, would silently drop it.
 37    /// </remarks>
 38    /// <exception cref="ArgumentException">
 39    /// A key is <see langword="null"/>; a key is an HS256 secret, which has no public half to publish; or a
 40    /// key's id is not its thumbprint. The message never names the key.
 41    /// </exception>
 42    public static string From(IEnumerable<JwtKey> keys)
 43    {
 1344        ArgumentNullException.ThrowIfNull(keys);
 45
 1346        var published = keys.ToList();
 47
 3148        if (published.Any(key => key is null))
 49        {
 150            throw new ArgumentException("The keys to publish include a null key.", nameof(keys));
 51        }
 52
 2853        if (published.Any(key => !key.IsAsymmetric))
 54        {
 155            throw new ArgumentException(
 156                $"An {JwtAlgorithms.HS256} key is a shared secret with no public half: a key set publishes {JwtAlgorithm
 157                nameof(keys));
 58        }
 59
 2660        if (published.Any(key => key.Id != key.Thumbprint))
 61        {
 162            throw new ArgumentException(
 163                "A key's id is not its RFC 7638 thumbprint, so a key set reader would drop it. Publish keys as " +
 164                $"{nameof(JwtKey.FromEcPrivateKeyPem)} or {nameof(JwtKey.FromEcPublicJwk)} name them.",
 165                nameof(keys));
 66        }
 67
 1068        using var buffer = new MemoryStream();
 69
 1070        using (var writer = new Utf8JsonWriter(buffer))
 71        {
 1072            writer.WriteStartObject();
 1073            writer.WriteStartArray("keys");
 74
 4675            foreach (var key in published)
 76            {
 1377                writer.WriteStartObject();
 78
 1379                JwkThumbprint.WriteMembers(writer, new KeyValuePair<string, string>[]
 1380                {
 1381                    new(Kty, "EC"),
 1382                    new(Crv, P256.JwkCurveName),
 1383                    new(X, key.PublicX!),
 1384                    new(Y, key.PublicY!),
 1385                    new(Kid, key.Id),
 1386                    new(Use, Signature),
 1387                    new(Alg, JwtAlgorithms.ES256)
 1388                });
 89
 1390                writer.WriteEndObject();
 91            }
 92
 1093            writer.WriteEndArray();
 1094            writer.WriteEndObject();
 1095        }
 96
 1097        return Encoding.UTF8.GetString(buffer.ToArray());
 1098    }
 99
 100    /// <summary>
 101    /// Reads the ES256 verification keys a key set publishes.
 102    /// </summary>
 103    /// <remarks>
 104    /// <para>
 105    /// An entry is read when it is an EC key on P-256 whose <c>use</c>, if present, is <c>sig</c>, whose
 106    /// <c>alg</c>, if present, is <c>ES256</c>, whose point is on the curve, and whose <c>kid</c>, if
 107    /// present, is its RFC 7638 thumbprint. Any other entry is skipped, not an error: a key set may
 108    /// publish keys for other uses, and a key that cannot be used safely must not validate anything.
 109    /// </para>
 110    /// <para>
 111    /// An entry that carries a <c>d</c> member is skipped too: a published private part means the key is
 112    /// compromised, and trusting its public half would accept anyone's forgeries. Each key is returned
 113    /// once; a later entry with the same id (the thumbprint, so the same key) is skipped. A parsed key
 114    /// can verify but never sign.
 115    /// </para>
 116    /// <para>
 117    /// Text that JSON accepts but cannot be decoded, such as a lone surrogate escape in a name or value,
 118    /// skips its entry, or is a <see cref="FormatException"/> when it is in the key set's own members.
 119    /// </para>
 120    /// </remarks>
 121    /// <exception cref="FormatException">The text is not JSON, or not an object with a <c>keys</c> array.</exception>
 122    public static IReadOnlyList<JwtKey> Parse(string json)
 123    {
 25124        ArgumentNullException.ThrowIfNull(json);
 125
 126        JsonDocument document;
 127
 128        try
 129        {
 25130            document = JsonDocument.Parse(json);
 24131        }
 1132        catch (JsonException)
 133        {
 1134            throw new FormatException("The key set is not JSON.");
 135        }
 136
 24137        using (document)
 138        {
 24139            var root = document.RootElement;
 140
 24141            var entries = default(JsonElement);
 24142            var isKeySet = false;
 143
 144            try
 145            {
 24146                isKeySet = root.ValueKind == JsonValueKind.Object &&
 24147                           root.TryGetProperty("keys", out entries) &&
 24148                           entries.ValueKind == JsonValueKind.Array;
 23149            }
 1150            catch (InvalidOperationException)
 151            {
 1152                throw new FormatException("The key set has a member name that is not valid text.");
 153            }
 154
 23155            if (!isKeySet)
 156            {
 3157                throw new FormatException("A key set is a JSON object with a \"keys\" array.");
 158            }
 159
 20160            var keys = new List<JwtKey>();
 161
 100162            foreach (var entry in entries.EnumerateArray())
 163            {
 34164                if (TryRead(entry) is { } key && keys.All(read => read.Id != key.Id))
 165                {
 17166                    keys.Add(key);
 167                }
 168            }
 169
 20170            return keys;
 171        }
 20172    }
 173
 174    /// <summary>The verification key an entry describes, or <see langword="null"/> when it is not one to use.</summary>
 175    private static JwtKey? TryRead(JsonElement entry)
 176    {
 177        try
 178        {
 30179            return ReadEntry(entry);
 180        }
 3181        catch (InvalidOperationException)
 182        {
 3183            return null;
 184        }
 30185    }
 186
 187    private static JwtKey? ReadEntry(JsonElement entry)
 188    {
 30189        if (entry.ValueKind != JsonValueKind.Object ||
 30190            entry.TryGetProperty(PrivateScalar, out _) ||
 30191            Required(entry, Kty) != "EC" ||
 30192            Required(entry, Crv) != P256.JwkCurveName ||
 30193            !OptionalIs(entry, Use, Signature) ||
 30194            !OptionalIs(entry, Alg, JwtAlgorithms.ES256) ||
 30195            Required(entry, X) is not { } x ||
 30196            Required(entry, Y) is not { } y)
 197        {
 7198            return null;
 199        }
 200
 201        JwtKey key;
 202
 203        try
 204        {
 20205            key = JwtKey.FromEcPublicJwk(x, y);
 19206        }
 1207        catch (ArgumentException)
 208        {
 1209            return null;
 210        }
 211
 19212        return OptionalIs(entry, Kid, key.Id) ? key : null;
 1213    }
 214
 215    /// <summary>A member's string value, or <see langword="null"/> when it is absent or not a string.</summary>
 216    private static string? Required(JsonElement entry, string name) =>
 90217        entry.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
 218
 219    /// <summary>Whether a member is absent, or present as exactly <paramref name="expected"/>.</summary>
 220    private static bool OptionalIs(JsonElement entry, string name, string expected) =>
 63221        !entry.TryGetProperty(name, out var value) ||
 63222        (value.ValueKind == JsonValueKind.String && value.GetString() == expected);
 223}