< Summary

Information
Class: ArturRios.Output.PageTokenPayload
Assembly: ArturRios.Output
File(s): /home/runner/work/dotnet-output/dotnet-output/src/PageToken.cs
Line coverage
100%
Covered lines: 6
Uncovered lines: 0
Coverable lines: 6
Total lines: 179
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
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
get_Version()100%11100%
get_Key()100%11100%
get_TieBreaker()100%11100%
get_PageNumber()100%11100%
get_IssuedAt()100%11100%
get_Fingerprint()100%11100%

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")]
 41916    public int Version { get; init; }
 17
 18    /// <summary>The last returned item's key.</summary>
 19    [JsonPropertyName("k")]
 40720    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)]
 30225    public JsonElement? TieBreaker { get; init; }
 26
 27    /// <summary>The page number the token leads to.</summary>
 28    [JsonPropertyName("p")]
 39929    public int PageNumber { get; init; }
 30
 31    /// <summary>Issue time, in Unix seconds.</summary>
 32    [JsonPropertyName("iat")]
 32633    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")]
 62637    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    {
 59        ArgumentNullException.ThrowIfNull(options);
 60
 61        if (options.SigningKey is null || options.SigningKey.Length < PageTokenOptions.MinimumSigningKeyLength)
 62        {
 63            throw new ArgumentException(
 64                $"The page token signing key must be at least {PageTokenOptions.MinimumSigningKeyLength} bytes long.",
 65                nameof(options));
 66        }
 67    }
 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.
 78        var parts = JsonSerializer.SerializeToUtf8Bytes(new[]
 79        {
 80            keySelector.ToString(),
 81            tieBreakerSelector?.ToString(),
 82            direction.ToString(),
 83            pageSize.ToString(System.Globalization.CultureInfo.InvariantCulture),
 84            options.Scope
 85        });
 86
 87        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    {
 93        var payload = JsonSerializer.SerializeToUtf8Bytes(new PageTokenPayload
 94        {
 95            Version = CurrentVersion,
 96            Key = key,
 97            TieBreaker = tieBreaker,
 98            PageNumber = pageNumber,
 99            IssuedAt = options.TimeProvider.GetUtcNow().ToUnixTimeSeconds(),
 100            Fingerprint = fingerprint
 101        });
 102
 103        return Base64Url.EncodeToString(payload) + "." +
 104               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    {
 114        payload = null;
 115        error = PageTokenErrors.Invalid;
 116
 117        var dot = token.IndexOf('.');
 118
 119        if (dot <= 0 || dot == token.Length - 1 || dot != token.LastIndexOf('.'))
 120        {
 121            return false;
 122        }
 123
 124        byte[] payloadBytes;
 125        byte[] signature;
 126
 127        try
 128        {
 129            payloadBytes = Base64Url.DecodeFromChars(token.AsSpan(0, dot));
 130            signature = Base64Url.DecodeFromChars(token.AsSpan(dot + 1));
 131        }
 132        catch (FormatException)
 133        {
 134            return false;
 135        }
 136
 137        // Nothing is parsed until the signature checks out.
 138        if (!CryptographicOperations.FixedTimeEquals(HMACSHA256.HashData(options.SigningKey, payloadBytes), signature))
 139        {
 140            return false;
 141        }
 142
 143        PageTokenPayload? read;
 144
 145        try
 146        {
 147            read = JsonSerializer.Deserialize<PageTokenPayload>(payloadBytes);
 148        }
 149        catch (JsonException)
 150        {
 151            return false;
 152        }
 153
 154        if (read is null || read.Version != CurrentVersion)
 155        {
 156            return false;
 157        }
 158
 159        if (options.Lifetime is { } lifetime &&
 160            DateTimeOffset.FromUnixTimeSeconds(read.IssuedAt) + lifetime < options.TimeProvider.GetUtcNow())
 161        {
 162            error = PageTokenErrors.Expired;
 163
 164            return false;
 165        }
 166
 167        if (read.Fingerprint != fingerprint)
 168        {
 169            error = PageTokenErrors.Mismatch;
 170
 171            return false;
 172        }
 173
 174        payload = read;
 175        error = null;
 176
 177        return true;
 178    }
 179}