| | | 1 | | using Google.Apis.Auth; |
| | | 2 | | // Google.Apis.Auth has a JsonWebToken of its own; the alias names the one meant without hiding it. |
| | | 3 | | using JsonWebToken = Microsoft.IdentityModel.JsonWebTokens.JsonWebToken; |
| | | 4 | | |
| | | 5 | | namespace ArturRios.Heimdall.WebApi.Security; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Reads claims off a Google ID token that has <em>already</em> been validated, for the questions |
| | | 9 | | /// <see cref="GoogleJsonWebSignature.Payload" /> cannot answer. |
| | | 10 | | /// </summary> |
| | | 11 | | /// <remarks> |
| | | 12 | | /// Split out of <see cref="GoogleIdTokenVerifier" /> so it can be tested directly: the verifier |
| | | 13 | | /// itself calls the static, network-bound |
| | | 14 | | /// <see cref="GoogleJsonWebSignature.ValidateAsync(string, GoogleJsonWebSignature.ValidationSettings)" />, |
| | | 15 | | /// which offers no seam, and reaching it in a test would mean contacting Google or forging |
| | | 16 | | /// Google's signature. Everything here is a pure function of the token string, so |
| | | 17 | | /// <c>GoogleIdTokenClaimsTests</c> can pin the behaviour the verifier depends on without either. |
| | | 18 | | /// </remarks> |
| | | 19 | | public static class GoogleIdTokenClaims |
| | | 20 | | { |
| | | 21 | | private const string EmailVerifiedClaim = "email_verified"; |
| | | 22 | | |
| | | 23 | | /// <summary> |
| | | 24 | | /// The <c>email_verified</c> claim, or <c>null</c> when the token asserts nothing — the claim |
| | | 25 | | /// is absent, is JSON <c>null</c>, or holds something that is not a boolean. The strings |
| | | 26 | | /// <c>"true"</c> and <c>"false"</c> read as the booleans they name: measured, and pinned by |
| | | 27 | | /// <c>GoogleIdTokenClaimsTests</c>, because a sign-up needs the claim to read as <c>true</c> |
| | | 28 | | /// (FR-GO-07) and a token that writes it as a string would otherwise be refused. |
| | | 29 | | /// </summary> |
| | | 30 | | /// <remarks> |
| | | 31 | | /// <para> |
| | | 32 | | /// <see cref="GoogleJsonWebSignature.Payload.EmailVerified" /> is a non-nullable |
| | | 33 | | /// <c>bool</c>, so a token that omitted the claim deserializes to the same <c>false</c> a |
| | | 34 | | /// token asserting "not verified" does. The distinction matters to FR-GO-19, which must |
| | | 35 | | /// leave a stored <c>true</c> alone rather than downgrade it, so the claim is read off the |
| | | 36 | | /// token itself with <see cref="JsonWebToken.TryGetPayloadValue{T}(string, out T)" />, |
| | | 37 | | /// which answers presence and value in one call and leaves segment splitting and |
| | | 38 | | /// base64url decoding to the library. |
| | | 39 | | /// </para> |
| | | 40 | | /// <para> |
| | | 41 | | /// The second probe covers the one case the first gets wrong. Measured against |
| | | 42 | | /// Microsoft.IdentityModel.JsonWebTokens 8.19.2, <c>TryGetPayloadValue<bool></c> |
| | | 43 | | /// reports an explicit <c>"email_verified": null</c> as present-and-<c>false</c>, which |
| | | 44 | | /// would be exactly the silent downgrade FR-GO-19 forbids; the <c>bool?</c> overload |
| | | 45 | | /// succeeds only for that null-valued claim — an absent claim and a real <c>true</c> or |
| | | 46 | | /// <c>false</c> all make it fail — so it isolates it precisely. That discriminator is |
| | | 47 | | /// measured behaviour rather than a documented contract, which is exactly why |
| | | 48 | | /// <c>GoogleIdTokenClaimsTests</c> pins every shape: a package bump that changes it |
| | | 49 | | /// fails the suite instead of quietly downgrading someone's verified address. |
| | | 50 | | /// </para> |
| | | 51 | | /// <para> |
| | | 52 | | /// Anything thrown becomes "absent" rather than escaping. The caller's catch handles only |
| | | 53 | | /// <see cref="InvalidJwtException" />, so an exception from here would leave a token |
| | | 54 | | /// Google itself vouched for failing as a 500 instead of signing the caller in. "Absent" |
| | | 55 | | /// is the safe answer: it writes nothing. |
| | | 56 | | /// </para> |
| | | 57 | | /// </remarks> |
| | | 58 | | /// <param name="idToken">The token, already validated by the caller.</param> |
| | | 59 | | /// <param name="logger">Optional; records why a token's claim could not be read, at debug level.</param> |
| | | 60 | | public static bool? EmailVerified(string idToken, ILogger? logger = null) |
| | | 61 | | { |
| | | 62 | | try |
| | | 63 | | { |
| | 10 | 64 | | var token = new JsonWebToken(idToken); |
| | | 65 | | |
| | 7 | 66 | | var present = token.TryGetPayloadValue<bool>(EmailVerifiedClaim, out var emailVerified); |
| | 7 | 67 | | var explicitlyNull = token.TryGetPayloadValue<bool?>(EmailVerifiedClaim, out _); |
| | | 68 | | |
| | 7 | 69 | | return present && !explicitlyNull ? emailVerified : null; |
| | | 70 | | } |
| | 3 | 71 | | catch (Exception exception) |
| | | 72 | | { |
| | 3 | 73 | | logger?.LogDebug(exception, "Google ID token's email_verified claim could not be read"); |
| | | 74 | | |
| | 3 | 75 | | return null; |
| | | 76 | | } |
| | 10 | 77 | | } |
| | | 78 | | } |