| | | 1 | | using System.Buffers.Text; |
| | | 2 | | using System.Linq.Expressions; |
| | | 3 | | using System.Security.Cryptography; |
| | | 4 | | using System.Text; |
| | | 5 | | using System.Text.Json; |
| | | 6 | | using System.Text.Json.Serialization; |
| | | 7 | | |
| | | 8 | | namespace 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> |
| | | 13 | | internal sealed class PageTokenPayload |
| | | 14 | | { |
| | | 15 | | [JsonPropertyName("v")] |
| | 419 | 16 | | public int Version { get; init; } |
| | | 17 | | |
| | | 18 | | /// <summary>The last returned item's key.</summary> |
| | | 19 | | [JsonPropertyName("k")] |
| | 407 | 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)] |
| | 302 | 25 | | public JsonElement? TieBreaker { get; init; } |
| | | 26 | | |
| | | 27 | | /// <summary>The page number the token leads to.</summary> |
| | | 28 | | [JsonPropertyName("p")] |
| | 399 | 29 | | public int PageNumber { get; init; } |
| | | 30 | | |
| | | 31 | | /// <summary>Issue time, in Unix seconds.</summary> |
| | | 32 | | [JsonPropertyName("iat")] |
| | 326 | 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")] |
| | 626 | 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> |
| | | 47 | | internal 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 | | } |