< Summary

Information
Class: ArturRios.Output.PageToken
Assembly: ArturRios.Output
File(s): /home/runner/work/dotnet-output/dotnet-output/src/PageToken.cs
Line coverage
100%
Covered lines: 55
Uncovered lines: 0
Coverable lines: 55
Total lines: 179
Line coverage: 100%
Branch coverage
100%
Covered branches: 24
Total branches: 24
Branch coverage: 100%
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
ValidateOptions(...)100%44100%
CreateFingerprint(...)100%22100%
Create(...)100%11100%
TryRead(...)100%1818100%

File(s)

/home/runner/work/dotnet-output/dotnet-output/src/PageToken.cs

#LineLine coverage
 1using System.Buffers.Text;
 2using System.Linq.Expressions;
 3using System.Security.Cryptography;
 4using System.Text;
 5using System.Text.Json;
 6using System.Text.Json.Serialization;
 7
 8namespace ArturRios.Output;
 9
 10/// <summary>
 11/// What a page token carries: where the previous page ended and which page the token leads to.
 12/// </summary>
 13internal sealed class PageTokenPayload
 14{
 15    [JsonPropertyName("v")]
 16    public int Version { get; init; }
 17
 18    /// <summary>The last returned item's key.</summary>
 19    [JsonPropertyName("k")]
 20    public JsonElement Key { get; init; }
 21
 22    /// <summary>The last returned item's tiebreaker, when the listing has one.</summary>
 23    [JsonPropertyName("t")]
 24    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 25    public JsonElement? TieBreaker { get; init; }
 26
 27    /// <summary>The page number the token leads to.</summary>
 28    [JsonPropertyName("p")]
 29    public int PageNumber { get; init; }
 30
 31    /// <summary>Issue time, in Unix seconds.</summary>
 32    [JsonPropertyName("iat")]
 33    public long IssuedAt { get; init; }
 34
 35    /// <summary>Hash of the request the token was issued for; see <see cref="PageToken.CreateFingerprint"/>.</summary>
 36    [JsonPropertyName("f")]
 37    public string Fingerprint { get; init; } = string.Empty;
 38}
 39
 40/// <summary>
 41/// Issues and checks page tokens: <c>base64url(payload JSON) "." base64url(HMAC-SHA256(key, payload))</c>.
 42/// </summary>
 43/// <remarks>
 44/// Tokens are signed, not encrypted: anyone can base64-decode the payload and read the key values in it. The
 45/// signature is what stops a caller from editing the page number or key to skip ahead.
 46/// </remarks>
 47internal static class PageToken
 48{
 49    internal const int CurrentVersion = 1;
 50
 51    private const int FingerprintLength = 16;
 52
 53    /// <summary>
 54    /// Throws when the options cannot sign a token: a programmer error, as opposed to a bad token, which a caller
 55    /// supplies.
 56    /// </summary>
 57    internal static void ValidateOptions(PageTokenOptions options)
 58    {
 13359        ArgumentNullException.ThrowIfNull(options);
 60
 13061        if (options.SigningKey is null || options.SigningKey.Length < PageTokenOptions.MinimumSigningKeyLength)
 62        {
 463            throw new ArgumentException(
 464                $"The page token signing key must be at least {PageTokenOptions.MinimumSigningKeyLength} bytes long.",
 465                nameof(options));
 66        }
 12667    }
 68
 69    /// <summary>
 70    /// Hashes everything a token is only valid for — the key and tiebreaker selectors, the direction, the page size
 71    /// and the scope — so a token cannot be replayed against another listing. Only a truncated hash goes into the
 72    /// token, so the scope is never readable from it.
 73    /// </summary>
 74    internal static string CreateFingerprint(PageTokenOptions options, LambdaExpression keySelector,
 75        LambdaExpression? tieBreakerSelector, SortDirection direction, int pageSize)
 76    {
 77        // A JSON array keeps the parts apart, so no two different requests serialize to the same bytes.
 13578        var parts = JsonSerializer.SerializeToUtf8Bytes(new[]
 13579        {
 13580            keySelector.ToString(),
 13581            tieBreakerSelector?.ToString(),
 13582            direction.ToString(),
 13583            pageSize.ToString(System.Globalization.CultureInfo.InvariantCulture),
 13584            options.Scope
 13585        });
 86
 13587        return Base64Url.EncodeToString(SHA256.HashData(parts).AsSpan(0, FingerprintLength));
 88    }
 89
 90    internal static string Create(PageTokenOptions options, string fingerprint, int pageNumber, JsonElement key,
 91        JsonElement? tieBreaker)
 92    {
 11393        var payload = JsonSerializer.SerializeToUtf8Bytes(new PageTokenPayload
 11394        {
 11395            Version = CurrentVersion,
 11396            Key = key,
 11397            TieBreaker = tieBreaker,
 11398            PageNumber = pageNumber,
 11399            IssuedAt = options.TimeProvider.GetUtcNow().ToUnixTimeSeconds(),
 113100            Fingerprint = fingerprint
 113101        });
 102
 113103        return Base64Url.EncodeToString(payload) + "." +
 113104               Base64Url.EncodeToString(HMACSHA256.HashData(options.SigningKey, payload));
 105    }
 106
 107    /// <summary>
 108    /// Checks a token and, when it is valid for this request, returns its payload. Otherwise <paramref name="error"/>
 109    /// is one of the <see cref="PageTokenErrors"/> constants.
 110    /// </summary>
 111    internal static bool TryRead(string token, PageTokenOptions options, string fingerprint,
 112        out PageTokenPayload? payload, out string? error)
 113    {
 117114        payload = null;
 117115        error = PageTokenErrors.Invalid;
 116
 117117        var dot = token.IndexOf('.');
 118
 117119        if (dot <= 0 || dot == token.Length - 1 || dot != token.LastIndexOf('.'))
 120        {
 7121            return false;
 122        }
 123
 124        byte[] payloadBytes;
 125        byte[] signature;
 126
 127        try
 128        {
 110129            payloadBytes = Base64Url.DecodeFromChars(token.AsSpan(0, dot));
 108130            signature = Base64Url.DecodeFromChars(token.AsSpan(dot + 1));
 108131        }
 2132        catch (FormatException)
 133        {
 2134            return false;
 135        }
 136
 137        // Nothing is parsed until the signature checks out.
 108138        if (!CryptographicOperations.FixedTimeEquals(HMACSHA256.HashData(options.SigningKey, payloadBytes), signature))
 139        {
 8140            return false;
 141        }
 142
 143        PageTokenPayload? read;
 144
 145        try
 146        {
 100147            read = JsonSerializer.Deserialize<PageTokenPayload>(payloadBytes);
 97148        }
 3149        catch (JsonException)
 150        {
 3151            return false;
 152        }
 153
 97154        if (read is null || read.Version != CurrentVersion)
 155        {
 2156            return false;
 157        }
 158
 95159        if (options.Lifetime is { } lifetime &&
 95160            DateTimeOffset.FromUnixTimeSeconds(read.IssuedAt) + lifetime < options.TimeProvider.GetUtcNow())
 161        {
 2162            error = PageTokenErrors.Expired;
 163
 2164            return false;
 165        }
 166
 93167        if (read.Fingerprint != fingerprint)
 168        {
 6169            error = PageTokenErrors.Mismatch;
 170
 6171            return false;
 172        }
 173
 87174        payload = read;
 87175        error = null;
 176
 87177        return true;
 5178    }
 179}