< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.GoogleSignInCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/GoogleSignInCommandHandler.cs
Line coverage
100%
Covered lines: 93
Uncovered lines: 0
Coverable lines: 93
Total lines: 262
Line coverage: 100%
Branch coverage
97%
Covered branches: 35
Total branches: 36
Branch coverage: 97.2%
Method coverage
100%
Covered methods: 4
Fully covered methods: 4
Total methods: 4
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%2020100%
RefreshEmailVerifiedAsync()100%44100%
SignUpAsync()91.66%1212100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/GoogleSignInCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Command.Services;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Enums;
 7using ArturRios.Heimdall.Shared.Messages;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Command.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="GoogleSignInCommand" /> (UC-25, FR-GO-03…FR-GO-13): verifies the Google ID
 16///     token, confirms the target scope allows Google sign-in, then either creates a Google User from
 17///     the token's claims (sign-up, FR-GO-09) or authenticates the one already registered for that
 18///     Google account in that scope (sign-in, FR-GO-10), and issues an authentication token claiming
 19///     the <c>User</c> role.
 20/// </summary>
 21/// <remarks>
 22///     The endpoint is anonymous, so it is careful about what a rejection reveals: AF-25a, AF-25d
 23///     and AF-25g share <see cref="AuthMessages.GoogleAuthenticationFailed" /> and AF-25b answers
 24///     alike for a missing, deleted, and disabled scope. AF-25c is named separately — the caller has
 25///     proved the address is theirs, so being told it is taken tells them only about themselves —
 26///     and that proof is why AF-25f comes first: a token whose address Google does not vouch for is
 27///     refused before the address is compared with anything.
 28/// </remarks>
 6829public class GoogleSignInCommandHandler(
 6830    IGoogleIdTokenVerifier tokenVerifier,
 6831    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 6832    IAsyncReadOnlyRepository<Person, long> personReader,
 6833    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 6834    IAsyncRepository<GoogleUser, long> googleUserWriter,
 6835    IAuthTokenIssuer tokenIssuer)
 36    : ICommandHandlerAsync<GoogleSignInCommand, GoogleSignInCommandOutput>
 37{
 38    public async Task<DataOutput<GoogleSignInCommandOutput?>> HandleAsync(GoogleSignInCommand command, CancellationToken
 39    {
 6840        var output = DataOutput<GoogleSignInCommandOutput?>.New;
 41
 42        // UC-25 step 3 (AF-25a, FR-GO-11, NFR-13): verify signature, issuer, audience, and expiry
 43        // before any claim is trusted. Runs before the scope is read, as the specification's sequence
 44        // diagram does — an unverified caller learns nothing about which scopes exist. This is also
 45        // what answers a request that omitted the token entirely, so UC-25 needs no 400 flow.
 6846        var payload = await tokenVerifier.VerifyAsync(command.IdToken);
 47
 6848        if (payload is null)
 49        {
 450            return output.WithError(AuthMessages.GoogleAuthenticationFailed);
 51        }
 52
 53        // AF-25g (FR-GO-20): a caller that names a nonce is owed a token issued for its own sign-in
 54        // attempt, so one carrying another nonce, or none, is refused as an unverifiable token is.
 55        // Ordinal: the nonce is an opaque value the client chose. A request without one compares
 56        // nothing; the server issued no nonce, so this binds the client's attempt, not the token.
 6457        if (!string.IsNullOrEmpty(command.Nonce) &&
 6458            !string.Equals(payload.Nonce, command.Nonce, StringComparison.Ordinal))
 59        {
 660            return output.WithError(AuthMessages.GoogleAuthenticationFailed);
 61        }
 62
 63        // UC-25 step 4 (AF-25b, FR-GO-03/FR-GO-13): the scope must exist, be active, and have the
 64        // setting UC-24 writes switched on. The alternative flow names all three conditions as one
 65        // outcome, so the filter answers for all three.
 5866        var scope = await scopeReader.Query()
 5867            .FirstOrDefaultAsync(x =>
 5868                x.PublicId == command.ScopeId && !x.IsDeleted && x.GoogleSignInEnabled);
 69
 5870        if (scope is null)
 71        {
 672            return output.WithError(AuthMessages.GoogleSignInUnavailable);
 73        }
 74
 75        // UC-25 step 5: look the account up by Google's 'sub' within the scope (FR-GO-08). The lookup
 76        // deliberately omits an !IsDeleted filter: AF-25d exists to reject a logically deleted Google
 77        // User, so it must be found first — the same reason UC-11's person lookup omits one.
 5278        var googleUser = await googleUserReader.Query()
 5279            .FirstOrDefaultAsync(x => x.ScopeId == scope.Id && x.GoogleId == payload.Subject);
 80
 5281        if (googleUser is null)
 82        {
 83            // UC-25 step 6 (sign-up).
 3284            var signUp = await SignUpAsync(payload, scope, output);
 85
 3286            if (signUp.googleUser is null)
 87            {
 1388                return signUp.failure!;
 89            }
 90
 1991            googleUser = signUp.googleUser;
 92        }
 93        // NFR-20: an anonymised account is nobody's any more, and its Google subject is no longer
 94        // stored, so the lookup should never find one; this guards the day that assumption breaks.
 2095        else if (googleUser.AnonymisedAt is not null)
 96        {
 197            return output.WithError(AuthMessages.GoogleAuthenticationFailed);
 98        }
 99        // NFR-24 (UC-44 step 4): processing is restricted, so the account may not authenticate for
 100        // anything but its subject's own rights over the record — the same subject-rights-only
 101        // token UC-11 issues a restricted person, for the same reason: without it nobody could lift
 102        // their own restriction (UC-45) once their earlier tokens had expired. Checked before
 103        // deletion, because a suspended subject keeps those rights until the record is anonymised.
 104        // Nothing is refreshed from the ID token first: that would be processing the record.
 19105        else if (googleUser.ProcessingRestrictedAt is not null)
 106        {
 3107            var rightsOnly = await tokenIssuer.IssueSubjectRightsOnlyAsync(
 3108                googleUser.PublicId, (int)Roles.User, securityStamp: null);
 109
 3110            return output
 3111                .WithData(new GoogleSignInCommandOutput
 3112                {
 3113                    Token = rightsOnly.Token,
 3114                    ExpiresAt = rightsOnly.ExpiresAt,
 3115                    EmailVerified = googleUser.EmailVerified,
 3116                    ProcessingRestricted = true
 3117                })
 3118                .WithMessage(AuthMessages.SubjectRightsOnlySignIn);
 119        }
 120        // UC-25 step 7 (AF-25d, FR-GO-12): the account exists but has been logically deleted.
 16121        else if (googleUser.IsDeleted)
 122        {
 3123            return output.WithError(AuthMessages.GoogleAuthenticationFailed);
 124        }
 125
 126        // FR-GO-19: the row was populated from the token at sign-up (FR-GO-09) and never touched
 127        // again, so a Google account verified after its first sign-in here kept a stale flag. The
 128        // token just verified is the fresher truth, so the column is brought back into line — a
 129        // no-op on a sign-up, where the two already agree by construction, and a no-op when the
 130        // token carries no email_verified claim at all.
 32131        await RefreshEmailVerifiedAsync(googleUser, payload);
 132
 133        // UC-25 step 8 (FR-GO-04): the token claims the Google User's PublicId, the User role — a
 134        // Google account is never a ScopeAdmin or SystemAdmin — and the one scope it belongs to.
 135        // Internal bigint Ids never reach a token (NFR-15), and OwnedScopeIds is empty because
 136        // ownership is a ScopeAdmin concept. No security stamp (FR-AU-11): a Google User has no
 137        // credential here that could change, so there is nothing for one to revoke on.
 32138        var token = await tokenIssuer.IssueAsync(new AuthTokenSubject(
 32139            googleUser.PublicId,
 32140            string.IsNullOrWhiteSpace(googleUser.Name) ? googleUser.Email : googleUser.Name,
 32141            (int)Roles.User,
 32142            scope.PublicId,
 32143            [],
 32144            SecurityStamp: null));
 145
 32146        return output
 32147            .WithData(new GoogleSignInCommandOutput
 32148            {
 32149                // The stored flag, which the refresh above has already brought into line with the
 32150                // token whenever the token had something to say. Reporting the row rather than the
 32151                // claim keeps the response answerable when the claim is absent, without the
 32152                // published field having to become nullable.
 32153                Token = token.Token, ExpiresAt = token.ExpiresAt, EmailVerified = googleUser.EmailVerified
 32154            })
 32155            .WithMessage(AuthMessages.GoogleSignInSuccessful);
 68156    }
 157
 158    /// <summary>
 159    ///     FR-GO-19: writes the verified token's <c>email_verified</c> back to the stored row when
 160    ///     the two disagree, so a returning Google User's flag does not stay frozen at whatever it
 161    ///     was on their first sign-in. Nothing is written when they already agree, keeping the
 162    ///     ordinary sign-in path read-only, and nothing is written when the token carries no such
 163    ///     claim — silence is not an assertion that the address is unverified, so a caller whose
 164    ///     client asked for a token without the <c>email</c> scope keeps whatever is stored.
 165    /// </summary>
 166    /// <remarks>
 167    ///     A failed write does not fail the sign-in. The caller has proved the account is theirs and
 168    ///     the token is theirs to receive; a flag that could not be refreshed is a data-freshness
 169    ///     problem, not an authentication one — the same judgement <c>EmailVerificationService</c>
 170    ///     and <c>PasswordResetService</c> make about the writes they discard the result of. The
 171    ///     response reports the in-memory row either way, so the caller is told the value this
 172    ///     sign-in settled on regardless of whether the write landed.
 173    /// </remarks>
 174    private async Task RefreshEmailVerifiedAsync(GoogleUser googleUser, GoogleIdTokenPayload payload)
 175    {
 32176        if (payload.EmailVerified is not { } claimed || googleUser.EmailVerified == claimed)
 177        {
 27178            return;
 179        }
 180
 5181        googleUser.EmailVerified = claimed;
 182
 5183        await googleUserWriter.UpdateAsync(googleUser);
 32184    }
 185
 186    /// <summary>
 187    ///     UC-25 step 6 (FR-GO-09): first sign-in with this Google account in this scope, so the
 188    ///     Google User is created from the verified claims (FR-GO-05) once Google vouches for the
 189    ///     address (AF-25f) and it is confirmed free (AF-25c).
 190    /// </summary>
 191    /// <returns>
 192    ///     The created Google User, or the failure output to return instead. A tuple rather than an
 193    ///     exception because handlers in this project report failures on the
 194    ///     <see cref="DataOutput{T}" />.
 195    /// </returns>
 196    private async Task<(GoogleUser? googleUser, DataOutput<GoogleSignInCommandOutput?>? failure)>
 197        SignUpAsync(
 198            GoogleIdTokenPayload payload, Scope scope, DataOutput<GoogleSignInCommandOutput?> output)
 199    {
 200        // AF-25f (FR-GO-07): only an address Google vouches for may take a place in the scope. An
 201        // absent claim proves nothing, so it is refused with a false one. First, so that a token for
 202        // an unproven address cannot ask whether the address is taken either.
 32203        if (payload.EmailVerified is not true)
 204        {
 6205            return (null, output.WithError(AuthMessages.GoogleEmailNotVerified));
 206        }
 207
 26208        var email = payload.Email.ToLower();
 209
 210        // AF-25c (FR-GO-07): the address must be free within the scope, considered jointly with the
 211        // scope's User persons. Two reads rather than one because the rule spans two tables — the
 212        // unique index on (scope_id, lower(email)) only covers the Google User half. Compared
 213        // case-insensitively (LOWER() in SQL), matching CreateUserCommandHandler.
 214        //
 215        // Logically deleted Google Users count as taking the address, deliberately and unlike the
 216        // person half below. That index does not exclude them either, so a check that did would
 217        // answer "free" and then fail on the insert — a persistence error in place of AF-25c's 409,
 218        // for a caller who could never get past it. FR-GO-12 keeps a deleted row addressable rather
 219        // than erasing it, so the address it holds stays taken until UC-29 removes the row for good.
 26220        var takenByGoogleUser = await googleUserReader.Query()
 26221            .AnyAsync(x => x.ScopeId == scope.Id && x.Email.ToLower() == email);
 222
 26223        if (takenByGoogleUser)
 224        {
 5225            return (null, output.WithError(AuthMessages.EmailAlreadyExists));
 226        }
 227
 21228        var takenByPerson = await personReader.Query().AnyAsync(person =>
 21229            !person.IsDeleted && person.Email.ToLower() == email &&
 21230            person.ScopeMembership != null && person.ScopeMembership.ScopeId == scope.Id);
 231
 21232        if (takenByPerson)
 233        {
 2234            return (null, output.WithError(AuthMessages.EmailAlreadyExists));
 235        }
 236
 237        // FR-GO-05/06: every field comes from the verified token, and the row is bound to the one
 238        // scope the sign-in was initiated in. Name and picture are optional on the token — a caller
 239        // who granted only the email scope still gets an account, with the fields left empty. The
 240        // address is verified: AF-25f admitted nothing else.
 19241        var newGoogleUser = new GoogleUser
 19242        {
 19243            GoogleId = payload.Subject,
 19244            Name = payload.Name ?? string.Empty,
 19245            Email = payload.Email,
 19246            EmailVerified = true,
 19247            ProfilePictureUrl = payload.PictureUrl,
 19248            ScopeId = scope.Id
 19249        };
 250
 251        // NFR-23: recorded here as everywhere else. UC-25 creates an identity without any
 252        // administrator involved, so it is the path where an unrecorded basis would be easiest to
 253        // end up with and hardest to notice.
 19254        LegalBasisRecorder.Record(newGoogleUser, scope, DateTime.UtcNow);
 255
 19256        var creation = await googleUserWriter.CreateAsync(newGoogleUser);
 257
 19258        return creation.Success
 19259            ? (newGoogleUser, null)
 19260            : (null, output.WithErrors(creation.Errors));
 32261    }
 262}