< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.GoogleSignInCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/GoogleSignInCommandHandler.cs
Line coverage
100%
Covered lines: 99
Uncovered lines: 0
Coverable lines: 99
Total lines: 284
Line coverage: 100%
Branch coverage
95%
Covered branches: 42
Total branches: 44
Branch coverage: 95.4%
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()92.3%262698.27%
RefreshEmailVerifiedAsync()100%44100%
SignUpAsync()92.85%1414100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/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.Domain.Persistence;
 8using ArturRios.Heimdall.Shared.Messages;
 9using ArturRios.Mediator.Command.Interfaces;
 10using ArturRios.Output;
 11using Microsoft.EntityFrameworkCore;
 12
 13namespace ArturRios.Heimdall.Command.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="GoogleSignInCommand" /> (UC-25, FR-GO-03…FR-GO-13): verifies the Google ID
 17///     token, confirms the target scope allows Google sign-in, then either creates a Google User from
 18///     the token's claims (sign-up, FR-GO-09) or authenticates the one already registered for that
 19///     Google account in that scope (sign-in, FR-GO-10), and issues an authentication token claiming
 20///     the <c>User</c> role.
 21/// </summary>
 22/// <remarks>
 23///     The endpoint is anonymous, so it is careful about what a rejection reveals: AF-25a, AF-25d
 24///     and AF-25g share <see cref="AuthMessages.GoogleAuthenticationFailed" /> and AF-25b answers
 25///     alike for a missing, deleted, and disabled scope. AF-25c is named separately — the caller has
 26///     proved the address is theirs, so being told it is taken tells them only about themselves —
 27///     and that proof is why AF-25f comes first: a token whose address Google does not vouch for is
 28///     refused before the address is compared with anything.
 29/// </remarks>
 8730public class GoogleSignInCommandHandler(
 8731    IGoogleIdTokenVerifier tokenVerifier,
 8732    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 8733    IAsyncReadOnlyRepository<Person, long> personReader,
 8734    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 8735    IAsyncRepository<GoogleUser, long> googleUserWriter,
 8736    IAuthTokenIssuer tokenIssuer,
 8737    IAtomicWrites atomicWrites)
 38    : ICommandHandlerAsync<GoogleSignInCommand, GoogleSignInCommandOutput>
 39{
 40    public async Task<DataOutput<GoogleSignInCommandOutput?>> HandleAsync(GoogleSignInCommand command, CancellationToken
 41    {
 9142        var output = DataOutput<GoogleSignInCommandOutput?>.New;
 43
 44        // UC-25 step 3 (AF-25a, FR-GO-11, NFR-13): verify signature, issuer, audience, and expiry
 45        // before any claim is trusted. Runs before the scope is read, as the specification's sequence
 46        // diagram does — an unverified caller learns nothing about which scopes exist. This is also
 47        // what answers a request that omitted the token entirely, so UC-25 needs no 400 flow.
 9148        var payload = await tokenVerifier.VerifyAsync(command.IdToken);
 49
 9150        if (payload is null)
 51        {
 452            return output.WithError(AuthMessages.GoogleAuthenticationFailed);
 53        }
 54
 55        // AF-25g (FR-GO-20): a caller that names a nonce is owed a token issued for its own sign-in
 56        // attempt, so one carrying another nonce, or none, is refused as an unverifiable token is.
 57        // Ordinal: the nonce is an opaque value the client chose. A request without one compares
 58        // nothing; the server issued no nonce, so this binds the client's attempt, not the token.
 8759        if (!string.IsNullOrEmpty(command.Nonce) &&
 8760            !string.Equals(payload.Nonce, command.Nonce, StringComparison.Ordinal))
 61        {
 762            return output.WithError(AuthMessages.GoogleAuthenticationFailed);
 63        }
 64
 65        // FR-GO-21 (AF-25a): a Google ID token is accepted once. Its use is recorded now — once it is
 66        // known to be valid and before anything is read, created or issued on its strength — by one
 67        // insert that also finds an earlier use, so a second presentation, however soon or however
 68        // concurrent, is refused exactly as an invalid token is and leaves nothing behind.
 8069        if (!await atomicWrites.TryRecordGoogleIdTokenUseAsync(
 8070                GoogleIdTokenUse.KeyOf(payload.TokenId, command.IdToken), payload.ExpiresAt ?? DateTime.UtcNow))
 71        {
 772            return output.WithError(AuthMessages.GoogleAuthenticationFailed);
 73        }
 74
 75        // UC-25 step 4 (AF-25b, FR-GO-03/FR-GO-13): the scope must exist, be active, and have the
 76        // setting UC-24 writes switched on. The alternative flow names all three conditions as one
 77        // outcome, so the filter answers for all three.
 7378        var scope = await scopeReader.Query()
 7379            .FirstOrDefaultAsync(x =>
 7380                x.PublicId == command.ScopeId && !x.IsDeleted && x.GoogleSignInEnabled);
 81
 7382        if (scope is null)
 83        {
 684            return output.WithError(AuthMessages.GoogleSignInUnavailable);
 85        }
 86
 87        // UC-25 step 5: look the account up by Google's 'sub' within the scope (FR-GO-08). The lookup
 88        // deliberately omits an !IsDeleted filter: AF-25d exists to reject a logically deleted Google
 89        // User, so it must be found first — the same reason UC-11's person lookup omits one.
 6790        var googleUser = await googleUserReader.Query()
 6791            .FirstOrDefaultAsync(x => x.ScopeId == scope.Id && x.GoogleId == payload.Subject);
 92
 6793        if (googleUser is null)
 94        {
 95            // UC-25 step 6 (sign-up).
 4496            var signUp = await SignUpAsync(payload, scope, output);
 97
 4498            if (signUp.googleUser is null)
 99            {
 16100                return signUp.failure!;
 101            }
 102
 28103            googleUser = signUp.googleUser;
 104        }
 105        // NFR-20: an anonymised account is nobody's any more, and its Google subject is no longer
 106        // stored, so the lookup should never find one; this guards the day that assumption breaks.
 23107        else if (googleUser.AnonymisedAt is not null)
 108        {
 1109            return output.WithError(AuthMessages.GoogleAuthenticationFailed);
 110        }
 111        // NFR-24 (UC-44 step 4): processing is restricted, so the account may not authenticate for
 112        // anything but its subject's own rights over the record — the same subject-rights-only
 113        // token UC-11 issues a restricted person, for the same reason: without it nobody could lift
 114        // their own restriction (UC-45) once their earlier tokens had expired. Checked before
 115        // deletion, because a suspended subject keeps those rights until the record is anonymised.
 116        // Nothing is refreshed from the ID token first: that would be processing the record.
 22117        else if (googleUser.ProcessingRestrictedAt is not null)
 118        {
 3119            var rightsOnly = await tokenIssuer.IssueSubjectRightsOnlyAsync(
 3120                googleUser.PublicId, (int)Roles.User, securityStamp: null);
 121
 3122            return output
 3123                .WithData(new GoogleSignInCommandOutput
 3124                {
 3125                    Token = rightsOnly.Token,
 3126                    ExpiresAt = rightsOnly.ExpiresAt,
 3127                    EmailVerified = googleUser.EmailVerified,
 3128                    ProcessingRestricted = true
 3129                })
 3130                .WithMessage(AuthMessages.SubjectRightsOnlySignIn);
 131        }
 132        // UC-25 step 7 (AF-25d, FR-GO-12): the account exists but has been logically deleted.
 19133        else if (googleUser.IsDeleted)
 134        {
 3135            return output.WithError(AuthMessages.GoogleAuthenticationFailed);
 136        }
 137
 138        // FR-GO-19: the row was populated from the token at sign-up (FR-GO-09) and never touched
 139        // again, so a Google account verified after its first sign-in here kept a stale flag. The
 140        // token just verified is the fresher truth, so the column is brought back into line — a
 141        // no-op on a sign-up, where the two already agree by construction, and a no-op when the
 142        // token carries no email_verified claim at all.
 44143        if (!await RefreshEmailVerifiedAsync(googleUser, payload))
 144        {
 145            // Deleted, restricted or anonymised since the checks above: answered as AF-25d answers a
 146            // deleted account. A restricted one gets its subject-rights-only token on the next attempt.
 1147            return output.WithError(AuthMessages.GoogleAuthenticationFailed);
 148        }
 149
 150        // UC-25 step 8 (FR-GO-04): the token claims the Google User's PublicId, the User role — a
 151        // Google account is never a ScopeAdmin or SystemAdmin — and the one scope it belongs to.
 152        // Internal bigint Ids never reach a token (NFR-15), and OwnedScopeIds is empty because
 153        // ownership is a ScopeAdmin concept. No security stamp (FR-AU-11): a Google User has no
 154        // credential here that could change, so there is nothing for one to revoke on.
 43155        var token = await tokenIssuer.IssueAsync(new AuthTokenSubject(
 43156            googleUser.PublicId,
 43157            string.IsNullOrWhiteSpace(googleUser.Name) ? googleUser.Email : googleUser.Name,
 43158            (int)Roles.User,
 43159            scope.PublicId,
 43160            [],
 43161            SecurityStamp: null));
 162
 43163        return output
 43164            .WithData(new GoogleSignInCommandOutput
 43165            {
 43166                // The stored flag, which the refresh above has already brought into line with the
 43167                // token whenever the token had something to say. Reporting the row rather than the
 43168                // claim keeps the response answerable when the claim is absent, without the
 43169                // published field having to become nullable.
 43170                Token = token.Token, ExpiresAt = token.ExpiresAt, EmailVerified = googleUser.EmailVerified
 43171            })
 43172            .WithMessage(AuthMessages.GoogleSignInSuccessful);
 91173    }
 174
 175    /// <summary>
 176    ///     FR-GO-19: writes the verified token's <c>email_verified</c> back to the stored row when
 177    ///     the two disagree, so a returning Google User's flag does not stay frozen at whatever it
 178    ///     was on their first sign-in. Nothing is written when they already agree, keeping the
 179    ///     ordinary sign-in path read-only, and nothing is written when the token carries no such
 180    ///     claim — silence is not an assertion that the address is unverified, so a caller whose
 181    ///     client asked for a token without the <c>email</c> scope keeps whatever is stored.
 182    /// </summary>
 183    /// <remarks>
 184    ///     The flag and <c>UpdatedAt</c> alone, by a targeted write made only while the account may
 185    ///     still sign in as the checks before it found it — not deleted, restricted or anonymised
 186    ///     (#205). A whole-row save of the copy read would write those back as read, reviving an
 187    ///     account deleted in between. <c>false</c> means the account changed in between, and the
 188    ///     sign-in is refused.
 189    /// </remarks>
 190    private async Task<bool> RefreshEmailVerifiedAsync(GoogleUser googleUser, GoogleIdTokenPayload payload)
 191    {
 44192        if (payload.EmailVerified is not { } claimed || googleUser.EmailVerified == claimed)
 193        {
 38194            return true;
 195        }
 196
 6197        return await atomicWrites.TryRefreshEmailVerifiedAsync(googleUser, claimed, DateTime.UtcNow);
 44198    }
 199
 200    /// <summary>
 201    ///     UC-25 step 6 (FR-GO-09): first sign-in with this Google account in this scope, so the
 202    ///     Google User is created from the verified claims (FR-GO-05) once Google vouches for the
 203    ///     address (AF-25f) and it is confirmed free (AF-25c).
 204    /// </summary>
 205    /// <returns>
 206    ///     The created Google User, or the failure output to return instead. A tuple rather than an
 207    ///     exception because handlers in this project report failures on the
 208    ///     <see cref="DataOutput{T}" />.
 209    /// </returns>
 210    private async Task<(GoogleUser? googleUser, DataOutput<GoogleSignInCommandOutput?>? failure)>
 211        SignUpAsync(
 212            GoogleIdTokenPayload payload, Scope scope, DataOutput<GoogleSignInCommandOutput?> output)
 213    {
 214        // AF-25a: a token issued without the `email` scope carries no address to create the account
 215        // from. Answered as an invalid token, before the address is read for anything, and first of
 216        // all so that a token with no address cannot ask what AF-25f or AF-25c would say about one.
 44217        if (string.IsNullOrWhiteSpace(payload.Email))
 218        {
 3219            return (null, output.WithError(AuthMessages.GoogleAuthenticationFailed));
 220        }
 221
 222        // AF-25f (FR-GO-07): only an address Google vouches for may take a place in the scope. An
 223        // absent claim proves nothing, so it is refused with a false one. First, so that a token for
 224        // an unproven address cannot ask whether the address is taken either.
 41225        if (payload.EmailVerified is not true)
 226        {
 6227            return (null, output.WithError(AuthMessages.GoogleEmailNotVerified));
 228        }
 229
 35230        var email = payload.Email.ToLower();
 231
 232        // AF-25c (FR-GO-07): the address must be free within the scope, considered jointly with the
 233        // scope's User persons. Two reads rather than one because the rule spans two tables — the
 234        // unique index on (scope_id, lower(email)) only covers the Google User half. Compared
 235        // case-insensitively (LOWER() in SQL), matching CreateUserCommandHandler.
 236        //
 237        // Logically deleted Google Users count as taking the address, deliberately and unlike the
 238        // person half below. That index does not exclude them either, so a check that did would
 239        // answer "free" and then fail on the insert — a persistence error in place of AF-25c's 409,
 240        // for a caller who could never get past it. FR-GO-12 keeps a deleted row addressable rather
 241        // than erasing it, so the address it holds stays taken until UC-29 removes the row for good.
 35242        var takenByGoogleUser = await googleUserReader.Query()
 35243            .AnyAsync(x => x.ScopeId == scope.Id && x.Email.ToLower() == email);
 244
 35245        if (takenByGoogleUser)
 246        {
 5247            return (null, output.WithError(AuthMessages.EmailAlreadyExists));
 248        }
 249
 30250        var takenByPerson = await personReader.Query().AnyAsync(person =>
 30251            !person.IsDeleted && person.Email.ToLower() == email &&
 30252            person.ScopeMembership != null && person.ScopeMembership.ScopeId == scope.Id);
 253
 30254        if (takenByPerson)
 255        {
 2256            return (null, output.WithError(AuthMessages.EmailAlreadyExists));
 257        }
 258
 259        // FR-GO-05/06: every field comes from the verified token, and the row is bound to the one
 260        // scope the sign-in was initiated in. Name and picture are optional on the token — a caller
 261        // who granted only the email scope still gets an account, with the fields left empty. The
 262        // address is verified: AF-25f admitted nothing else.
 28263        var newGoogleUser = new GoogleUser
 28264        {
 28265            GoogleId = payload.Subject,
 28266            Name = payload.Name ?? string.Empty,
 28267            Email = payload.Email,
 28268            EmailVerified = true,
 28269            ProfilePictureUrl = payload.PictureUrl,
 28270            ScopeId = scope.Id
 28271        };
 272
 273        // NFR-23: recorded here as everywhere else. UC-25 creates an identity without any
 274        // administrator involved, so it is the path where an unrecorded basis would be easiest to
 275        // end up with and hardest to notice.
 28276        LegalBasisRecorder.Record(newGoogleUser, scope, DateTime.UtcNow);
 277
 28278        var creation = await googleUserWriter.CreateAsync(newGoogleUser);
 279
 28280        return creation.Success
 28281            ? (newGoogleUser, null)
 28282            : (null, output.WithErrors(creation.Errors));
 44283    }
 284}