| | | 1 | | using System.Text; |
| | | 2 | | using ArturRios.Heimdall.Command.Services; |
| | | 3 | | using Microsoft.IdentityModel.JsonWebTokens; |
| | | 4 | | using Microsoft.IdentityModel.Tokens; |
| | | 5 | | |
| | | 6 | | namespace ArturRios.Heimdall.WebApi.Security; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// A <see cref="IGoogleIdTokenVerifier" /> that validates ID tokens signed with a locally held |
| | | 10 | | /// secret instead of by Google, so the functional suite can exercise UC-25 end-to-end without |
| | | 11 | | /// reaching the network. Reads the same claims the real verifier does. |
| | | 12 | | /// </summary> |
| | | 13 | | /// <remarks> |
| | | 14 | | /// <para> |
| | | 15 | | /// <b>Why this exists.</b> <c>WebApiTest<T></c> keeps its <c>WebApplicationFactory</c> |
| | | 16 | | /// private and its <c>Gateway</c> protected-readonly, so a functional test cannot replace a |
| | | 17 | | /// DI registration; every substitution in this suite is therefore chosen at start-up from the |
| | | 18 | | /// environment, as <c>Startup.AddEmailSenders</c> does for Mailgun. Without a substitute only |
| | | 19 | | /// AF-25a and AF-25b would be reachable — the main flow, AF-25c, and AF-25d all sit behind |
| | | 20 | | /// token verification — and the Testing Specification §7.4 asks for every alternative flow. |
| | | 21 | | /// </para> |
| | | 22 | | /// <para> |
| | | 23 | | /// <b>What it is not.</b> Not a "trust anything" stub. A token still has to carry a valid |
| | | 24 | | /// HS256 signature, the expected issuer and audience, and an unexpired lifetime — the secret |
| | | 25 | | /// is simply one the test fixture holds rather than one Google publishes. A test cannot forge |
| | | 26 | | /// a token any more than a caller can; it can only mint one. |
| | | 27 | | /// </para> |
| | | 28 | | /// <para> |
| | | 29 | | /// <b>What keeps it out of production.</b> Two independent guards in |
| | | 30 | | /// <c>Startup.AddGoogleSignIn</c>: it is never registered in the Production environment, and |
| | | 31 | | /// it is never registered unless <see cref="GoogleSignInOptions.TestSigningSecretVariable" /> |
| | | 32 | | /// is explicitly set. That variable is absent from every <c>.env</c> file and is set only by |
| | | 33 | | /// the functional suite's <c>PostgresFixture</c>. |
| | | 34 | | /// </para> |
| | | 35 | | /// </remarks> |
| | 55 | 36 | | public class LocalGoogleIdTokenVerifier( |
| | 55 | 37 | | GoogleSignInOptions options, |
| | 55 | 38 | | ILogger<LocalGoogleIdTokenVerifier> logger) : IGoogleIdTokenVerifier |
| | | 39 | | { |
| | | 40 | | /// <summary>Issuer and audience the locally minted tokens must carry. Shared with the test fixture.</summary> |
| | | 41 | | public const string TestIssuer = "heimdall-google-test"; |
| | | 42 | | |
| | 2 | 43 | | private static readonly JsonWebTokenHandler Handler = new(); |
| | | 44 | | |
| | | 45 | | public async Task<GoogleIdTokenPayload?> VerifyAsync(string idToken) |
| | | 46 | | { |
| | 44 | 47 | | if (string.IsNullOrWhiteSpace(idToken)) |
| | | 48 | | { |
| | 3 | 49 | | return null; |
| | | 50 | | } |
| | | 51 | | |
| | 41 | 52 | | var result = await Handler.ValidateTokenAsync(idToken, new TokenValidationParameters |
| | 41 | 53 | | { |
| | 41 | 54 | | ValidIssuer = TestIssuer, |
| | 41 | 55 | | ValidAudience = TestIssuer, |
| | 41 | 56 | | IssuerSigningKey = new SymmetricSecurityKey( |
| | 41 | 57 | | Encoding.UTF8.GetBytes(options.TestSigningSecret)), |
| | 41 | 58 | | ValidateIssuer = true, |
| | 41 | 59 | | ValidateAudience = true, |
| | 41 | 60 | | ValidateIssuerSigningKey = true, |
| | 41 | 61 | | ValidateLifetime = true, |
| | 41 | 62 | | ClockSkew = TimeSpan.Zero |
| | 41 | 63 | | }); |
| | | 64 | | |
| | 41 | 65 | | if (!result.IsValid) |
| | | 66 | | { |
| | 7 | 67 | | logger.LogDebug(result.Exception, "Locally signed Google ID token rejected"); |
| | | 68 | | |
| | 7 | 69 | | return null; |
| | | 70 | | } |
| | | 71 | | |
| | 34 | 72 | | var subject = Claim(result, "sub"); |
| | 34 | 73 | | var email = Claim(result, "email"); |
| | | 74 | | |
| | | 75 | | // A token missing either identifier could not name a Google User, so it fails verification |
| | | 76 | | // rather than producing a payload the handler would have to second-guess. |
| | 34 | 77 | | if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(email)) |
| | | 78 | | { |
| | 1 | 79 | | return null; |
| | | 80 | | } |
| | | 81 | | |
| | 33 | 82 | | return new GoogleIdTokenPayload( |
| | 33 | 83 | | subject, |
| | 33 | 84 | | email, |
| | 33 | 85 | | EmailVerified(result), |
| | 33 | 86 | | Claim(result, "name"), |
| | 33 | 87 | | Claim(result, "picture"), |
| | 33 | 88 | | Claim(result, "nonce")); |
| | 44 | 89 | | } |
| | | 90 | | |
| | | 91 | | /// <summary> |
| | | 92 | | /// The <c>email_verified</c> claim, or <c>null</c> when the token carries none. Absence is |
| | | 93 | | /// read straight off the validated claim set, which simply has no entry for a claim the |
| | | 94 | | /// token never held; a value that will not parse as a boolean is treated as absent too — |
| | | 95 | | /// a claim present but JSON <c>null</c> among them — since it asserts nothing this API can |
| | | 96 | | /// act on, and FR-GO-19 must not downgrade a stored <c>true</c> on the strength of it. |
| | | 97 | | /// </summary> |
| | | 98 | | private static bool? EmailVerified(TokenValidationResult result) => |
| | 33 | 99 | | bool.TryParse(Claim(result, "email_verified"), out var verified) ? verified : null; |
| | | 100 | | |
| | | 101 | | private static string? Claim(TokenValidationResult result, string name) => |
| | 200 | 102 | | result.Claims.TryGetValue(name, out var value) ? value?.ToString() : null; |
| | | 103 | | } |