< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Security.GoogleIdTokenClaims
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/GoogleIdTokenClaims.cs
Line coverage
100%
Covered lines: 8
Uncovered lines: 0
Coverable lines: 8
Total lines: 78
Line coverage: 100%
Branch coverage
83%
Covered branches: 5
Total branches: 6
Branch coverage: 83.3%
Method coverage
100%
Covered methods: 1
Fully covered methods: 1
Total methods: 1
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
EmailVerified(...)83.33%66100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/GoogleIdTokenClaims.cs

#LineLine coverage
 1using Google.Apis.Auth;
 2// Google.Apis.Auth has a JsonWebToken of its own; the alias names the one meant without hiding it.
 3using JsonWebToken = Microsoft.IdentityModel.JsonWebTokens.JsonWebToken;
 4
 5namespace 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>
 19public 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&lt;bool&gt;</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        {
 1064            var token = new JsonWebToken(idToken);
 65
 766            var present = token.TryGetPayloadValue<bool>(EmailVerifiedClaim, out var emailVerified);
 767            var explicitlyNull = token.TryGetPayloadValue<bool?>(EmailVerifiedClaim, out _);
 68
 769            return present && !explicitlyNull ? emailVerified : null;
 70        }
 371        catch (Exception exception)
 72        {
 373            logger?.LogDebug(exception, "Google ID token's email_verified claim could not be read");
 74
 375            return null;
 76        }
 1077    }
 78}