< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Security.LocalGoogleIdTokenVerifier
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/LocalGoogleIdTokenVerifier.cs
Line coverage
100%
Covered lines: 35
Uncovered lines: 0
Coverable lines: 35
Total lines: 103
Line coverage: 100%
Branch coverage
92%
Covered branches: 13
Total branches: 14
Branch coverage: 92.8%
Method coverage
100%
Covered methods: 5
Fully covered methods: 5
Total methods: 5
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
VerifyAsync()100%88100%
EmailVerified(...)100%22100%
Claim(...)75%44100%

File(s)

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

#LineLine coverage
 1using System.Text;
 2using ArturRios.Heimdall.Command.Services;
 3using Microsoft.IdentityModel.JsonWebTokens;
 4using Microsoft.IdentityModel.Tokens;
 5
 6namespace 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&lt;T&gt;</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>
 5536public class LocalGoogleIdTokenVerifier(
 5537    GoogleSignInOptions options,
 5538    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
 243    private static readonly JsonWebTokenHandler Handler = new();
 44
 45    public async Task<GoogleIdTokenPayload?> VerifyAsync(string idToken)
 46    {
 4447        if (string.IsNullOrWhiteSpace(idToken))
 48        {
 349            return null;
 50        }
 51
 4152        var result = await Handler.ValidateTokenAsync(idToken, new TokenValidationParameters
 4153        {
 4154            ValidIssuer = TestIssuer,
 4155            ValidAudience = TestIssuer,
 4156            IssuerSigningKey = new SymmetricSecurityKey(
 4157                Encoding.UTF8.GetBytes(options.TestSigningSecret)),
 4158            ValidateIssuer = true,
 4159            ValidateAudience = true,
 4160            ValidateIssuerSigningKey = true,
 4161            ValidateLifetime = true,
 4162            ClockSkew = TimeSpan.Zero
 4163        });
 64
 4165        if (!result.IsValid)
 66        {
 767            logger.LogDebug(result.Exception, "Locally signed Google ID token rejected");
 68
 769            return null;
 70        }
 71
 3472        var subject = Claim(result, "sub");
 3473        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.
 3477        if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(email))
 78        {
 179            return null;
 80        }
 81
 3382        return new GoogleIdTokenPayload(
 3383            subject,
 3384            email,
 3385            EmailVerified(result),
 3386            Claim(result, "name"),
 3387            Claim(result, "picture"),
 3388            Claim(result, "nonce"));
 4489    }
 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) =>
 3399        bool.TryParse(Claim(result, "email_verified"), out var verified) ? verified : null;
 100
 101    private static string? Claim(TokenValidationResult result, string name) =>
 200102        result.Claims.TryGetValue(name, out var value) ? value?.ToString() : null;
 103}