| | | 1 | | using System.Security.Cryptography; |
| | | 2 | | using System.Text; |
| | | 3 | | using ArturRios.Data.Relational.Core.Entities; |
| | | 4 | | |
| | | 5 | | namespace ArturRios.Heimdall.Domain.Entities; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// A Google ID token the system has accepted (FR-GO-21), kept so that the same token is refused if |
| | | 9 | | /// it is presented again. <see cref="Entity{TKey}.Id" /> is the token's key — see |
| | | 10 | | /// <see cref="KeyOf" /> — and nothing else about the token, or about who presented it, is stored. |
| | | 11 | | /// </summary> |
| | | 12 | | /// <remarks> |
| | | 13 | | /// Google signs a token for an hour, and until this table existed any copy of one — read from a |
| | | 14 | | /// log, a proxy or a client's storage — could sign its holder in for that long. Recording the first |
| | | 15 | | /// use, in the same statement that checks for an earlier one, makes every later presentation an |
| | | 16 | | /// invalid token. The row is needed only until the token expires and so could no longer be accepted |
| | | 17 | | /// anyway; it is purged with the single-use tokens after the same grace (NFR-19). |
| | | 18 | | /// </remarks> |
| | | 19 | | public class GoogleIdTokenUse : Entity<string> |
| | | 20 | | { |
| | | 21 | | /// <summary>The token's own expiry, its <c>exp</c> claim, which the purge counts from.</summary> |
| | 10 | 22 | | public DateTime ExpiresAt { get; set; } |
| | | 23 | | |
| | | 24 | | /// <summary> |
| | | 25 | | /// The key a token is recorded under: a lower-case hex SHA-256 digest of its <c>jti</c> claim, |
| | | 26 | | /// which Google sets on the tokens it issues, or, when it carries none, of its signed part — |
| | | 27 | | /// the <c>header.payload</c> before the last dot, surrounding whitespace aside. |
| | | 28 | | /// </summary> |
| | | 29 | | /// <remarks> |
| | | 30 | | /// <para> |
| | | 31 | | /// The signed part rather than the whole token, because the signature segment can be spelled |
| | | 32 | | /// more than one way. Its base64url text has unused trailing bits — four in an RS256 |
| | | 33 | | /// signature's 342 characters — which a lenient decoder ignores, so one token can verify |
| | | 34 | | /// under several spellings, and a key taken over the whole text would count each as a new |
| | | 35 | | /// token. |
| | | 36 | | /// The signed part cannot be respelled without breaking the signature, which covers its |
| | | 37 | | /// exact text, so every spelling of one token has one key. |
| | | 38 | | /// </para> |
| | | 39 | | /// <para> |
| | | 40 | | /// A digest rather than the value: the key is what the row is looked up by, never what it is |
| | | 41 | | /// read for, and a stored token, unlike its digest, would be a credential until it expired. |
| | | 42 | | /// </para> |
| | | 43 | | /// </remarks> |
| | | 44 | | public static string KeyOf(string? tokenId, string idToken) => |
| | 102 | 45 | | Convert.ToHexStringLower(SHA256.HashData( |
| | 102 | 46 | | Encoding.UTF8.GetBytes(string.IsNullOrEmpty(tokenId) ? SignedPartOf(idToken) : tokenId))); |
| | | 47 | | |
| | | 48 | | /// <summary> |
| | | 49 | | /// The part of a compact JWS its signature covers: everything before the last dot, or the whole |
| | | 50 | | /// trimmed value when there is no dot. |
| | | 51 | | /// </summary> |
| | | 52 | | private static string SignedPartOf(string idToken) |
| | | 53 | | { |
| | 20 | 54 | | var token = idToken.Trim(); |
| | 20 | 55 | | var signatureDot = token.LastIndexOf('.'); |
| | | 56 | | |
| | 20 | 57 | | return signatureDot < 0 ? token : token[..signatureDot]; |
| | | 58 | | } |
| | | 59 | | } |