Appearance
Identity Case Matrix
One row per behavioral case for every route src/modules/identity/presentation/controllers/ exposes. Each case names the test that proves it, quoted verbatim as a spec file path, then the separator "›", then the exact test title. A case with no proving test answers — and is listed under Gaps, together with the lowest layer that could prove it honestly.
npm run lint:case-matrix parses every such reference on this page and fails the build if the file does not exist or carries no it(/it.each( with that exact title. This table cannot silently drift from the suite it describes.
Totals
| Routes | 28 |
| Per-route cases | 346 |
| Per-route cases proven | 344 (the 2 unproven rows name a case with no failure mode to prove: issuance that never refuses, and a route with no guard) |
| Cross-route invariants | 11 (11 proven) |
| Gaps | 0 |
Registration and session entry
sign-ups.controller.ts, sign-up-confirmations.controller.ts, sign-ins.controller.ts, sign-in-second-factors.controller.ts, session-renewals.controller.ts, sign-outs.controller.ts. None of these seven routes carries ElevationGuard; only the two sign-out routes carry SessionGuard at all.
POST /sign-ups
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: registers a brand-new email/phone identity and queues the verification code for asynchronous delivery | 201 Created · { data: { challengeExpiresAt } }, no delivery field | test/e2e/sign-up.e2e-spec.ts › registers an email identity and answers 201 inside the envelope, accepted with only an expiry |
| Response never carries the code it just queued | 201 Created, body excludes the code | test/e2e/sign-up.e2e-spec.ts › never answers with the verification code it just queued |
| Idempotent repeat: registering the same still-unverified address again | 201 Created, same accepted shape, a fresh message is queued without superseding the older one — sign_up messages are never superseded across registrants, so both codes stay pending (OX-S8) | test/e2e/sign-up.e2e-spec.ts › answers a second registration for an unverified address the same way, queuing its own message without superseding the first (OX-S8) |
| Indistinguishable refusal: a squatter registers first, the real owner registers the same address with a different password | 201 Created both times, byte-identical accepted body, so a caller cannot tell whether they were first | test/e2e/sign-up.e2e-spec.ts › answers the real owner the same way as the squatter, so neither learns anything |
| Another account left the address unconfirmed (pending sign-in-method addition); its real owner signs up | The unconfirmed claim and its challenges are revoked; the owner gets a new identity with its own password; the other account is untouched | test/e2e/sign-in-methods.e2e-spec.ts › never lets the real owner of an address sign up into the account that left that address unconfirmed |
| Repository: an unconfirmed address held by an established account (verified identifier or active method) | Released; a new identity claims it | src/modules/identity/infrastructure/registration/identity-registration.repository.integration-spec.ts › gives the owner of an address its own identity when another account left that address unconfirmed |
| Refusal: identifier already verified by its owner | 409 · identity.identifier_taken | test/e2e/sign-up-confirmation.e2e-spec.ts › refuses a registration for an address whose owner verified it |
| Refusal: identifier fails normalization (bad email/phone shape) | 422 · identity.identifier_unusable | test/e2e/sign-up.e2e-spec.ts › answers an unusable identifier as unprocessable content |
| Refusal: no channel can notify the identifier (e.g. phone with nothing to reach it), refused before any write | 422 · identity.identifier_channel_unavailable, no transaction opened | src/modules/identity/application/registration/register-identity.use-case.spec.ts › refuses before writing anything when nothing could notify that identifier; test/e2e/sign-up.e2e-spec.ts › answers an unprocessable identifier when no configured channel could ever reach it |
| Validation: password too short | 400 · foundation.validation_failed, body never echoes the address or password | test/e2e/sign-up.e2e-spec.ts › never echoes the address or the password it refused |
| Validation: unknown field in body (strict schema) | 400 · foundation.validation_failed, no message sent | test/e2e/sign-up.e2e-spec.ts › refuses a body carrying a field the contract does not declare |
Validation: country supplied on an email registration | 400 · foundation.validation_failed, no message sent | test/e2e/sign-up.e2e-spec.ts › refuses a country on an email registration, because it means nothing there |
Rate limit: exceeds SENDS_A_MESSAGE_RATE_LIMIT | 429 · rate_limit.exceeded, retryable: true, no message sent for the refused attempt | test/e2e/rate-limit.e2e-spec.ts › refuses the registration that goes past the limit |
| Concurrency: five simultaneous registrations for the same address | Exactly one wins the claim, the rest see the losing branch (re-challenge), never two identities for one address | src/modules/identity/application/registration/register-identity.use-case.integration-spec.ts › lets exactly one of five concurrent registrations for one address win |
POST /sign-ups/confirmations
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: verifies the identifier with the code it was queued | 204 No Content | test/e2e/sign-up-confirmation.e2e-spec.ts › verifies the identifier with the code it was queued |
| Idempotent repeat: presenting the exact same already-spent code again | 422 · identity.verification_refused | test/e2e/sign-up-confirmation.e2e-spec.ts › spends the code, so the same one never verifies twice |
| Refusal: wrong code, body reveals nothing about the account | 422 · identity.verification_refused | test/e2e/sign-up-confirmation.e2e-spec.ts › refuses a wrong code with a body that says nothing about the account |
| Indistinguishable refusal: wrong code on a real address vs. an address nobody registered | Identical 422 · identity.verification_refused body | test/e2e/sign-up-confirmation.e2e-spec.ts › answers an address nobody registered exactly as it answers a wrong code |
| Refusal: wrong password paired with the correct code | 422 · identity.verification_refused | test/e2e/sign-up-confirmation.e2e-spec.ts › refuses the code when the password is not the one the registration carried |
| Refusal: identifier fails normalization | 422 · identity.identifier_unusable | src/modules/identity/application/verification/verify-identifier.use-case.spec.ts › refuses an unusable identifier before opening a transaction |
| Attempt budget: the challenge stops accepting attempts once its allowance is spent, even a correct code afterward | 422 · identity.verification_refused | test/e2e/sign-up-confirmation.e2e-spec.ts › stops accepting attempts once the challenge has none left |
Lockout: a wrong code on an already locked account answers the same ordinary refusal, never 423, and the lock is not audited again | 422 · identity.verification_refused, nothing audited | src/modules/identity/application/verification/verify-identifier.use-case.spec.ts › answers a wrong verification code on an already locked account with the ordinary refusal, never a locked answer, and does not audit the lock again |
| Account lockout: trips once wrong guesses across several requests reach the threshold, each request trying every one of this identity's own live challenges, but the owner still answers a fresh code afterward | Account locks, a freshly issued code still consumes a challenge normally | src/modules/identity/application/verification/verify-identifier.use-case.integration-spec.ts › locks the account once wrong guesses across several requests reach the threshold, each request trying every one of its own live challenges, but still lets the owner in afterward with a fresh code |
| One wrong-code request spends exactly one account failure, no matter how many of the registrant's own live challenges it tries; each of those challenges still gets its own attempt charged | Account failure count +1; every tried challenge's attempt_count +1 | src/modules/identity/application/verification/verify-identifier.use-case.integration-spec.ts › spends exactly one account failure per request, no matter how many of its own live challenges get a charged attempt; src/modules/identity/application/verification/verify-identifier.use-case.spec.ts › spends exactly one account failure for the whole request, no matter how many of its own live challenges it tried |
| Validation: code is not exactly six digits, refused before touching the database | 400 · foundation.validation_failed | test/e2e/sign-up-confirmation.e2e-spec.ts › refuses a code that is not six digits before touching the database |
| Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents | 204/422/400 per case, per configured format | test/e2e/out-of-band-code-formats.e2e-spec.ts › sign-up confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents |
Rate limit: exceeds ANSWERS_A_CHALLENGE_RATE_LIMIT | 429 · rate_limit.exceeded | test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone |
| Concurrency: a stranger re-registers the same address while the owner's code is still in flight | The owner's original code still verifies afterward; the stranger's competing claim gets its own, separately-verifiable code | test/e2e/sign-up-confirmation.e2e-spec.ts › lets a stranger change nothing about the code already in flight |
POST /sign-ins
Includes provider/OAuth sign-in — the same route and handler, discriminated by identifierType.
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: password sign-in opens a session | 201 Created · { data: { kind: 'session', accessToken, refreshToken, ... } } | test/e2e/sign-in.e2e-spec.ts › opens a session for an account that proved its identifier |
| Happy path: provider (Google/Apple) sign-in opens a session for a vouched-for token | 201 Created · session | test/e2e/provider-sign-in.e2e-spec.ts › opens a session like any other, for a token the provider vouches for |
| Happy-path variant: identity holds an active second factor — answers a ticket, never a token | 201 Created · { data: { kind: 'second_factor_required', secondFactorTicket, ticketExpiresAt } } | test/e2e/sign-in-second-factor.e2e-spec.ts › answers a ticket instead of a session when the identity holds an active second factor, and never a token |
| Never repeats a refresh token across sessions | Each session gets a distinct refresh token | test/e2e/sign-in.e2e-spec.ts › never repeats a refresh token across two sessions |
| Refusal: wrong password | 401 · identity.sign_in_refused | test/e2e/sign-in.e2e-spec.ts › refuses a password that is not the one on the account |
| Refusal: identifier never verified | 401 · identity.sign_in_refused | test/e2e/sign-in.e2e-spec.ts › refuses an identifier nobody has confirmed yet |
| Indistinguishable refusal: wrong password vs. unknown account | Byte-identical 401 body | test/e2e/sign-in.e2e-spec.ts › answers an unknown account exactly like a wrong password |
| Indistinguishable refusal: an already-locked account presenting a wrong password vs. an ordinary wrong password on any other account | Byte-identical 401 · identity.sign_in_refused body | test/e2e/account-lockout.e2e-spec.ts › locks a sign-in account after enough wrong passwords, and answers a further wrong password exactly like a wrong password on any other account |
| Indistinguishable refusal: a disabled/unconfigured provider vs. wrong password | Byte-identical 401 body, reveals nothing about which providers are configured | test/e2e/sign-in.e2e-spec.ts › answers a disabled provider exactly like a wrong password, revealing nothing about which providers are configured |
| Indistinguishable refusal: a provider token the verifier refuses (bad signature/audience) vs. wrong password | Byte-identical 401 body | test/e2e/provider-sign-in.e2e-spec.ts › answers a refused token exactly like a wrong password, so the discriminated body leaks nothing |
| Response never leaks the password or the id token | 401 response excludes both | test/e2e/sign-in.e2e-spec.ts › never says a word about the password in what it answers |
| Account lockout: the correct password still opens a session even once the account is locked — a lock only ever stops guessing, never checked before a proof | 201 Created session, lock state never consulted | test/e2e/account-lockout.e2e-spec.ts › still opens a session for the correct password once the account is locked, because a lock only ever stops guessing |
| The second-factor gate is checked only after the password is proven, and leaves clearing the account lock to that second step | Second factor checked strictly after password proof | src/modules/identity/application/sessions/sign-in.use-case.spec.ts › checks for a second factor only after the password is proven, and leaves the lock for the second step to clear |
| Idempotent repeat / replay: the exact same provider id token, replayed after it already opened a session | 401 · identity.sign_in_refused (single-use nonce already spent) | test/e2e/provider-sign-in.e2e-spec.ts › refuses the exact same token replayed after it already opened a session |
| Repeat visit with the same provider subject (a different token) signs the same identity in again, creating nothing new | 201 Created, no new identity | test/e2e/provider-sign-in.e2e-spec.ts › signs the same provider identity in again on a second visit |
| Race: a provider sign-in against the removal of that same provider, in either order | Never a live session from a removed method: a sign-in that committed first has its session revoked by the removal, a removal that committed first leaves the sign-in refused for the old identity; no deadlock over 20 rounds of each order | src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › leaves no live session when the removal starts after the holder was read and the sign-in commits first; src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › leaves no live session and refuses the old identity when the removal commits before the sign-in session insert; src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › never deadlocks and never leaves a live session when both race freely |
| Race: a provider sign-in against its method being marked compromised after the holder was read | The session the sign-in created is revoked | src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › leaves no live session when the method is marked compromised after the holder was read |
| Another identity removing its own provider while this provider sign-in is in flight | Neither waits nor revokes: the sign-in completes and its session stays live | src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › does not wait for, nor revoke, a sign-in when ANOTHER identity removes its provider |
| A provider sign-in on a provider whose removal already committed | The old identity is not signed in; no live session on it | src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › refuses a sign-in on a provider whose removal already committed |
Refusal: an unknown provider subject whose token vouches for (email_verified: true) an email that already belongs to an active, verified identifier on another identity — most often a Google/Apple account that was unlinked and is signing in again — never creates a second identity for the same person | 409 · identity.account_exists_for_provider_email, identity count unchanged | test/e2e/sign-in-method-providers.e2e-spec.ts › refuses signing in with an unlinked Google subject once its verified email already belongs to this account; src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › refuses to create a new identity when the verified email already belongs to an active identifier |
| That same refusal is case-insensitive on the email comparison | 409 · identity.account_exists_for_provider_email | src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › refuses regardless of the case the provider token used for the email |
| Unrelated case: an unknown provider subject whose vouched-for email matches no one on record still opens a brand-new identity, exactly like before | 201 Created, a new identity | test/e2e/provider-sign-in.e2e-spec.ts › opens a new identity for a brand-new subject whose verified email matches nobody; src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › creates a new identity when the email matches nothing on record |
| Unrelated case: an unknown provider subject whose token carries an email the provider never marked verified still opens a brand-new identity, even if that email matches an existing account | 201 Created, a new identity | test/e2e/provider-sign-in.e2e-spec.ts › opens a new identity when the provider never marked the matching email verified |
| Indistinguishable refusal: no nonce claim / an unissued nonce / an expired nonce | 401 · identity.sign_in_refused, identical across all three | test/e2e/provider-sign-in.e2e-spec.ts › refuses a token with $what, byte-identically to an unissued nonce |
| Validation: unsupported client type | 400 · foundation.validation_failed | test/e2e/sign-in.e2e-spec.ts › refuses a body that names a client nobody supports |
| Concurrency: password/method/identifier changes committed while password verification is in flight (revocation, disablement, block, password/method replacement) | Refused, byte-identical to a wrong password, no session created | test/integration/sign-in-credential-race.integration-spec.ts › refuses %s committed while password verification was pending |
| Concurrency: method/principal revoked concurrently, ordered after session issuance | Issuance still succeeds, but the freshly-opened session is automatically revoked by the concurrent revocation | test/integration/sign-in-credential-race.integration-spec.ts › serializes %s revocation after issuance and automatically revokes the new session |
| Concurrency baseline: credential remains eligible throughout | Exactly one session opened | test/integration/sign-in-credential-race.integration-spec.ts › opens exactly one session when the verified credential remains eligible |
Rate limit: exceeds PROVES_A_PASSWORD_RATE_LIMIT | 429 · rate_limit.exceeded | test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone |
POST /sign-ins/second-factors
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: current TOTP code completes the sign-in, opens a session usable on a guarded route | 201 Created · session; challenge consumed, lock cleared | test/e2e/sign-in-second-factor.e2e-spec.ts › completes the sign-in with the current code and opens a session that works on a guarded route |
| Happy-path variant: a live recovery code completes the sign-in without ever checking a TOTP method | 201 Created · session | src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › proves a live recovery code and opens a session, clearing the lock and consuming the challenge, without ever checking a TOTP method |
| Refusal: unknown/invented ticket | 401 · identity.second_factor_refused, untouched challenge/lockout | src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › refuses an unknown ticket without touching the challenge or the lockout |
| Refusal: wrong TOTP code | 401 · identity.second_factor_refused | src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › refuses a wrong code and records a failed attempt on the challenge and the lockout |
| Refusal: a code already spent on this exact time step (replay), exactly like a wrong code | 401 · identity.second_factor_refused | test/e2e/sign-in-second-factor.e2e-spec.ts › refuses the same code a second time presented with a fresh ticket, proving replay protection |
| Refusal: a wrong, spent, revoked or foreign recovery code, exactly like a wrong TOTP code | 401 · identity.second_factor_refused | src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › refuses a wrong, spent, revoked or foreign recovery code exactly like a wrong TOTP code, recording the failed attempt and the lockout |
| Indistinguishable refusal: a wrong code vs. an invented ticket | Byte-identical 401 body | test/e2e/sign-in-second-factor.e2e-spec.ts › refuses a wrong code, and an invented ticket answers the byte-identical refusal |
Indistinguishable refusal: wrong password on a second-factor account answers exactly like an ordinary sign-in refusal on /sign-ins | Byte-identical 401 · identity.sign_in_refused | test/e2e/sign-in-second-factor.e2e-spec.ts › answers a wrong password on an account with a second factor exactly like an ordinary sign-in refusal |
| Idempotent repeat / replay: the same ticket cannot be reused, even to answer with the previously-correct code | 401 · identity.second_factor_refused on the second use | test/e2e/sign-in-second-factor.e2e-spec.ts › cannot use the same ticket twice, even to answer with the same correct code |
Attempt budget (per-ticket, max_attempts = 5): five wrong attempts exhaust the ticket itself, so even a later correct code on that same ticket is refused | 401 · identity.second_factor_refused, never 423 | test/e2e/sign-in-second-factor.e2e-spec.ts › exhausts the attempt budget of the ticket, refusing even a later correct code |
| Account lockout (identity-wide, threshold 5, distinct from the per-ticket budget): wrong codes across several fresh tickets accumulate against the identity; a proven password never clears it | 6th cumulative failure (fresh ticket) answers 423 · identity.sign_in_second_factor_locked | test/e2e/sign-in-second-factor.e2e-spec.ts › keeps counting wrong codes across fresh tickets, because a proven password alone never clears the lock |
| The exact failure that trips the identity-wide lock still answers the ordinary refusal, not the locked response | 401 · identity.second_factor_refused (only the next attempt gets 423) | src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › records the account as locked when a wrong second-factor code is the failure that locks it |
| Already-locked account answers the locked response | 423 · identity.sign_in_second_factor_locked | src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › answers a locked account following the step-up precedent, once the lockout reports it was already locked |
| Lockout never gates a correct proof: the correct code still opens a session even while the account is already locked | 201 Created · session, lock never consulted | src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › opens a session with the correct code even though the account is already locked, without ever consulting the lock |
| The same lockout-bypass guarantee applies to the recovery-code path | 201 Created, lock never consulted before the recovery code is checked | src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › never consults the lockout before the recovery code is checked |
| Validation: body carries both a code and a recovery code | 400 · foundation.validation_failed | src/modules/identity/presentation/dtos/sign-in-second-factor-request.schema.spec.ts › refuses a body carrying both an authenticator code and a recovery code |
| Validation: body carries neither | 400 · foundation.validation_failed | src/modules/identity/presentation/dtos/sign-in-second-factor-request.schema.spec.ts › refuses a body carrying neither |
| A provider sign-in also requires this second step when a second factor is active | 201 Created (ticket first, then session) | test/e2e/sign-in-second-factor.e2e-spec.ts › requires the second step for a provider sign-in too, when the identity holds an active second factor |
Rate limit: exceeds ANSWERS_A_CHALLENGE_RATE_LIMIT | 429 · rate_limit.exceeded | test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone |
| Concurrency: two simultaneous completions racing to spend the same ticket | Exactly one session opened, the other refused | test/integration/sign-in-second-factor-ticket-race.integration-spec.ts › lets exactly one of two simultaneous completions spend the same ticket |
| Pending ticket versus credential change: a ticket opened before a password recovery, a password change, the removal of a linked provider or a method marked compromised is revoked in that same transaction, so completing it afterwards, with an authenticator code or a recovery code, is refused and opens no session | 401 · identity.second_factor_refused, the refusal an unknown ticket gets, no new live session | src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a password recovery cannot be completed with an %s; src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a password change cannot be completed with an %s; src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a linked provider is removed cannot be completed with an %s; src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a sign-in method is marked compromised cannot be completed with an %s |
| A ticket opened after the credential change still completes | 201 Created · session | src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened after the password recovery still completes; src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened after the password change still completes |
A stored hash rewritten without a real password change (no password_changed_at move) leaves a pending ticket alive | 201 Created · session | src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › rewriting the stored hash without a real password change leaves a pending ticket alive |
| What a caller from another identity sees: recovering one identity's password leaves another identity's pending ticket alive | 201 Created · session for the bystander | src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a password recovery for one identity leaves the pending ticket of another identity alive |
POST /sign-ins/renewals
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: exchanges the refresh token for a new pair | 201 Created · new accessToken/refreshToken | test/e2e/session-renewal.e2e-spec.ts › hands out a new pair for the token the session is holding |
| Renewing keeps the original session deadline (not a sliding extension) | sessionExpiresAt unchanged from the original sign-in | test/e2e/session-renewal.e2e-spec.ts › keeps the deadline the sign in set, because renewing is not extending |
| Idempotent repeat / replay: presenting the same (now-spent) refresh token again | 401 · identity.renewal_refused, and the whole session ends as a suspected leak | test/e2e/session-renewal.e2e-spec.ts › ends the session when a spent token comes back, so a leak costs the thief the account |
| Refusal: an unknown/invented token | 401 · identity.renewal_refused | test/e2e/session-renewal.e2e-spec.ts › refuses a token nobody ever issued |
| Refusal: a token spent several renewals ago (old-generation replay) ends the current session too | 401 · identity.renewal_refused, and the session's current token also stops working afterward | test/e2e/session-renewal.e2e-spec.ts › ends the current session when a token spent several renewals ago returns |
| Indistinguishable refusal: a replayed (already-spent) token vs. a wholly invented one | Byte-identical 401 body | src/modules/identity/application/sessions/renew-session.use-case.spec.ts › answers a replay and an invented token with the same refusal |
| An unrelated real session remains usable after an unknown token is refused | 201 Created for the still-valid session | test/e2e/session-renewal.e2e-spec.ts › keeps a real session usable after an unknown token is refused |
| Refuses when access-token signing fails, without starting the mutating transaction or rotating anything | Rejects, no rotation performed | src/modules/identity/application/sessions/renew-session.use-case.spec.ts › does not start mutation or rotate when access signing fails |
| Concurrency: a locked renewal candidate that changed shape (different session id, identity id, or expiry) between find and lock is refused without rotating | 401 · identity.renewal_refused, no rotation | src/modules/identity/application/sessions/renew-session.use-case.spec.ts › refuses a changed locked candidate without rotating: %o |
| Concurrency: replay revocation still commits even if the candidate rotates mid-signing | Revocation is not lost to the race | src/modules/identity/application/sessions/renew-session.use-case.spec.ts › commits replay revocation if the candidate rotates while signing |
| Concurrency (database-level race): an in-flight renewal beats an old-generation replay, which is then revoked once the new token is committed | New token survives, old-generation replay revokes it correctly | test/integration/refresh-token-replay.integration-spec.ts › waits for an in-flight renewal then revokes the newly issued token on old replay |
| Concurrency (database-level race): an in-flight old replay beats the current renewal, which is then refused post-revocation | Current token refused after the race resolves | test/integration/refresh-token-replay.integration-spec.ts › waits for an in-flight old replay then refuses the current token after revocation |
| Several honest generations renew in sequence without ever revoking the session | No false-positive replay revocation | test/integration/refresh-token-replay.integration-spec.ts › renews several generations without revoking an honest session |
Rate limit: exceeds PROVES_A_PASSWORD_RATE_LIMIT | 429 · rate_limit.exceeded | test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone |
POST /sign-outs
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: ends the session belonging to the presented access token; its refresh token stops working immediately | 204 No Content, refresh token then refused with 401 · identity.renewal_refused | test/e2e/sign-out.e2e-spec.ts › ends the session, so the refresh token it was holding stops working |
| Isolation: leaves this identity's sessions on other clients untouched | Other sessions of the same identity keep renewing | test/e2e/sign-out.e2e-spec.ts › leaves the sessions this caller opened on other clients alone |
| Idempotent repeat: signing out again with the same (now-dead) access token | 401 · identity.session_required — the token itself is refused because its session is no longer live, not a 204 no-op | test/e2e/sign-out.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it |
| Repeat at the domain layer keeps the original revocation reason rather than overwriting it | Reason field stable across a second call | test/integration/sign-out.integration-spec.ts › keeps the reason a session was already ended with |
| What a caller from another identity sees: a caller/session pair that does not match any owned session ends nothing | Targeted session's revoked_at stays NULL | test/integration/sign-out.integration-spec.ts › refuses a session that belongs to somebody else |
| Concurrency: two simultaneous sign-outs of the same session serialize on the row guard, revoking it exactly once | revoked_at set once, reason stable | test/integration/sign-out.integration-spec.ts › serializes two simultaneous sign-outs of the same session, revoking it exactly once |
Guard — no session: no Authorization header at all | 401 · identity.session_required | test/e2e/sign-out.e2e-spec.ts › refuses $what the same way |
| Guard — forged/invalid access token: non-Bearer scheme, a token nobody issued, or an empty bearer value, all indistinguishable | 401 · identity.session_required | test/e2e/sign-out.e2e-spec.ts › refuses $what the same way |
| Guard — revoked session: an access token whose session was already ended answers the same refusal (see the idempotent-repeat row above) | 401 · identity.session_required | test/e2e/sign-out.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it |
POST /sign-outs/everywhere
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: ends every session of this identity, so every client's refresh token stops renewing | 204 No Content, all refresh tokens refused afterward | test/e2e/sign-out-everywhere.e2e-spec.ts › ends every session of this identity, so refresh tokens from every client stop renewing |
| Happy-path effect: seals a platform Audit Record with the identity as both actor and target | Audit record identity.sessions.revoked_everywhere, outcome success | test/e2e/sign-out-everywhere.e2e-spec.ts › seals a platform Audit Record for the identity-wide revocation, with the identity as actor and target |
| What a caller from another identity sees: a second identity's live sessions are left completely untouched | Other identity's sessions stay live | test/integration/sign-out-everywhere.integration-spec.ts › leaves a second identity sessions untouched |
| Not an error for an identity with no live sessions left to end | Completes normally, no exception | test/integration/sign-out-everywhere.integration-spec.ts › is not an error for an identity with no live sessions |
| Idempotent repeat: repeating with the same (now-dead) access token | 401 · identity.session_required | test/e2e/sign-out-everywhere.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it |
| Repeat at the domain layer keeps the original revocation reason rather than overwriting it | Reason field stable across a second call | test/integration/sign-out-everywhere.integration-spec.ts › keeps the reason a session was already ended with, rather than overwriting it |
| Concurrency: a session still committing its sign-in when the everywhere-revocation runs is not left live | That session ends up revoked too, not orphaned as "live" | test/integration/sign-out-everywhere.integration-spec.ts › does not leave live a session whose sign-in was still committing when the revocation ran |
| Concurrency: a session created after the revocation already began is revoked too, instead of aborting the whole operation | New session is caught and revoked; the operation still succeeds | test/integration/sign-out-everywhere.integration-spec.ts › revokes a session created after the revocation began, instead of aborting the whole operation |
Guard — no session: no Authorization header at all | 401 · identity.session_required | test/e2e/sign-out-everywhere.e2e-spec.ts › refuses $what the same way |
| Guard — forged/invalid access token, indistinguishable across every sub-case | 401 · identity.session_required | test/e2e/sign-out-everywhere.e2e-spec.ts › refuses $what the same way |
| Guard — revoked session: same refusal as the idempotent-repeat row above | 401 · identity.session_required | test/e2e/sign-out-everywhere.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it |
Session surface, step-ups and provider nonces
session-context.controller.ts, session-history.controller.ts, step-ups.controller.ts, step-up-confirmations.controller.ts, step-up-providers.controller.ts, provider-nonces.controller.ts. All five session/step-up routes carry SessionGuard only; provider-nonces carries no guard at all.
GET /session/context
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: freshly confirmed identity | 200 OK with identityId, masked verified identifiers, active methods, empty organization context | test/e2e/session-context.e2e-spec.ts › answers the identity, its verified identifiers, active methods and organization state for a freshly confirmed account |
| Response never leaks the raw identifier | 200 OK, masked values only | test/e2e/session-context.e2e-spec.ts › never puts the full email address or an unmasked phone number in the response |
| What a caller from another identity sees: caller B never sees A's identifiers/methods | Query scoped by identityId; no leakage | src/modules/identity/infrastructure/session-context/session-context.repository.integration-spec.ts › never returns an identifier or a method belonging to another identity |
| Guard — no authorization header / non-Bearer scheme / unissued token, indistinguishable | 401 · identity.session_required | test/e2e/session-context.e2e-spec.ts › refuses $what the same way |
| Guard — revoked session, next request with the same token | 401 · identity.session_required | test/e2e/session-revocation.e2e-spec.ts › refuses the same access token on the very next request once its session signs out |
| Guard — revoked/dead session vs. a purely forged token, byte-identical | 401 · identity.session_required, identical body/headers | test/e2e/session-revocation.e2e-spec.ts › answers a dead session and an invented token with the exact same refusal body |
| Guard — an elevation token presented as the bearer credential | 401 · identity.session_required | test/e2e/step-up.e2e-spec.ts › refuses an elevation token presented as a bearer credential on an ordinary authenticated route |
| Organization selected, organization-wide owner: grouped access, capabilities, profile and workspace | 200 OK · access.organizationWide holds the owner permissions, access.scoped empty, permissions equals access.organizationWide, capabilities equal to GET /subscription, profile equal to the locale and currency of GET /organization, every active business unit and outlet, truncated: false, selection: null | test/e2e/session-context-workspace.e2e-spec.ts › answers the owner its organization-wide permissions, the capabilities of GET /subscription, the profile of GET /organization and every active business unit and outlet |
| Member scoped to one outlet: only that outlet and its business unit; the deprecated flat field stays honest | 200 OK · access.organizationWide: [], one access.scoped entry for the outlet with its permissions, permissions: [], workspace with that outlet (business unit and legal entity of today, site, timezone) and its business unit with its active verticals | test/e2e/session-context-workspace.e2e-spec.ts › answers a cashier scoped to one outlet only that outlet and its business unit, its permission only as scoped, and nothing in the deprecated flat permissions |
| Member scoped to one of two business units | 200 OK · only that business unit and its outlets in workspace, one business_unit entry in access.scoped | test/e2e/session-context-workspace.e2e-spec.ts › answers a manager scoped to one of two business units only that unit and its outlets |
| The scoped outlet is deactivated | 200 OK · the outlet leaves workspace and access.scoped | test/e2e/session-context-workspace.e2e-spec.ts › drops a deactivated outlet from the workspace and the scoped access of the cashier |
Valid selection: outletId, or businessUnitId alone | 200 OK · selection with the business unit, the outlet, the legal entity of the outlet today, its site, its timezone and the active verticals of the unit; a unit alone answers the outlet fields null | test/e2e/session-context-workspace.e2e-spec.ts › answers a valid outlet selection with its business unit, the legal entity that operates it today, its site, timezone and verticals |
| Selection of an outlet of another business unit, of another organization, unknown, inactive, an unreached unit, a unit of another organization or a unit that does not match the outlet, indistinguishable | 404 · organization.structure_not_found, byte-identical body | test/e2e/session-context-workspace.e2e-spec.ts › answers the same 404 for an outlet of another business unit, a foreign, an unknown and an inactive outlet, an unreached unit and a unit that does not match the outlet |
Guard and validation: no session; outletId that is not a UUID | 401 · identity.session_required; 400 · foundation.validation_failed | test/e2e/session-context-workspace.e2e-spec.ts › refuses a call without a session with 401 and a malformed selection with 400 |
| No organization selected; a selection sent without one | 200 OK with empty lists and profile/selection null; the selection alone answers 404 · organization.structure_not_found | test/e2e/session-context-workspace.e2e-spec.ts › answers empty lists and a null profile and selection without an organization, and the same 404 for a selection sent without one |
| Member whose roles grant no permission | 200 OK · empty access and empty workspace | test/e2e/session-context-workspace.e2e-spec.ts › answers a member whose role grants nothing empty access and an empty workspace |
| More than 500 reachable outlets (501 seeded with SQL) | 200 OK · 500 outlets in creation order, truncated: true | test/e2e/session-context-workspace.e2e-spec.ts › caps the workspace at 500 outlets and says it was truncated (501 outlets seeded with SQL) |
GET /sign-ins/history
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: lists open sessions, flags the caller's own | 200 OK, isCurrentSession true only for the presented token's session | test/e2e/session-history.e2e-spec.ts › lists every open session and flags the one the caller is using |
Pagination: meta.pageInfo shape, walking pages with limit/after | 200 OK, pageInfo: { hasNextPage, nextCursor }, no gaps or duplicates | test/e2e/session-history.e2e-spec.ts › walks every open session across pages with the limit and cursor query params |
Pagination: default limit (50) when limit is absent | Applies the documented default limit | src/foundation/pagination/collection-query.schema.spec.ts › defaults to the documented default limit when absent |
| Pagination: maximum limit (100) accepted | 200 OK at 100 | src/foundation/pagination/collection-query.schema.spec.ts › accepts the documented maximum limit |
| Pagination: limit above 100 rejected | 400 · foundation.validation_failed | src/foundation/pagination/collection-query.schema.spec.ts › rejects a limit above the documented maximum |
| Pagination: structurally malformed cursor | 400 · collection.cursor_invalid | test/e2e/session-history.e2e-spec.ts › refuses a structurally malformed cursor as collection.cursor_invalid |
| Pagination: tampered cursor signature | 400 · collection.cursor_tampered | src/modules/identity/infrastructure/sessions/identity-session.repository.integration-spec.ts › refuses a cursor whose signature was tampered with |
| Pagination: a cursor minted for a different identity | 400 · collection.cursor_incompatible | test/e2e/session-history.e2e-spec.ts › refuses a cursor minted for a different identity as collection.cursor_incompatible |
| Guard — no header / non-Bearer / unissued token / empty bearer value, all indistinguishable | 401 · identity.session_required | test/e2e/session-history.e2e-spec.ts › refuses $what the same way |
| Guard — revoked session / expired / session naming another identity / non-existent session, all indistinguishable from a missing token | 401 · identity.session_required (byte-identical) | src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why |
| What a caller from another identity sees: caller B never sees A's sessions | 200 OK, only the caller's own sessions returned | src/modules/identity/infrastructure/sessions/identity-session.repository.integration-spec.ts › never returns a session that belongs to another identity |
POST /step-ups
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: opens a challenge and queues a code for the verified identifier, inside the same transaction (fixes notify-before-commit) | 201 Created with challengeExpiresAt | test/e2e/step-up.e2e-spec.ts › proves the code queued for a verified identifier and hands back an elevation token bound to this session |
| Refusal: identity has no verified identifier to reach | 422 · identity.step_up_unreachable | src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › refuses an identity with no verified identifier to reach, without hashing anything |
| Refusal: none of the identity's verified, non-revoked identifiers can be notified | 422 · identity.identifier_channel_unavailable | src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › refuses when nothing can reach any candidate it found, before opening a challenge |
| Preference order: a primary identifier the notifier cannot reach is skipped for the next verified, non-revoked identifier in preference order that it can | Code goes to the first candidate stepUpNotifier.canNotify(...) accepts, not necessarily the primary one | src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › skips a preferred identifier the notifier cannot reach, and uses the next one in preference order |
| Idempotent repeat: a second open supersedes the first; only the newest challenge answers | Only the newest challenge is lockable/answerable | src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › locks the newest live challenge for the identity, ignoring a stale one |
Rate limit: exceeds SENDS_A_MESSAGE_RATE_LIMIT | 429 · rate_limit.exceeded | test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone |
| Guard — no header / unissued token, indistinguishable | 401 · identity.session_required | test/e2e/step-up.e2e-spec.ts › refuses opening a step-up with $what |
| Guard — revoked session / forged token, byte-identical to missing | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals; src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why |
POST /step-ups/confirmations
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: correct code mints an elevation bound to the calling session | 201 Created with elevationToken, expiresAt in the future | test/e2e/step-up.e2e-spec.ts › proves the code queued for a verified identifier and hands back an elevation token bound to this session |
| Refusal: wrong code, charges an attempt, session survives | 403 · identity.step_up_confirmation_refused | test/e2e/step-up.e2e-spec.ts › refuses a wrong code without ending the session, charging an attempt |
| Indistinguishable refusal: wrong code vs. no live challenge to answer | 403 · identity.step_up_confirmation_refused, byte-identical | src/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › answers a wrong code and a missing challenge with the same refusal |
| Attempt budget: 5 attempts per challenge before it is revoked | Attempts counted 0-4, challenge unlockable afterward | src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › holds the attempt budget, revoking the challenge once it is exhausted |
| Lockout: the wrong code that crosses the lock threshold is still answered as an ordinary refusal, not as locked | 403 · identity.step_up_confirmation_refused, not 423 | src/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › records the account as locked when a wrong step-up code is the failure that locks it |
| Lockout: a further wrong code once already locked | 423 · identity.step_up_locked, the lock is not audited a second time | test/e2e/account-lockout.e2e-spec.ts › locks a step-up account after wrong codes spread across several of its own challenges, and refuses a further wrong code with a distinct, informative response; src/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › answers a further wrong step-up code on an already locked account with the lock expiry, without auditing the lock again |
| Lockout: a wrong code below the threshold refuses and records no lock | 403 · identity.step_up_confirmation_refused, nothing audited | src/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › refuses a wrong step-up code below the threshold without recording any lock |
| Lockout: the correct code still mints an elevation even while locked | 201 Created | test/e2e/account-lockout.e2e-spec.ts › still mints the elevation for the correct code once the step-up account is locked, because the caller already proved who they are |
| Idempotent repeat: replaying the same already-consumed correct code | 403 · identity.step_up_confirmation_refused | src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › cannot reuse a spent challenge |
| What a caller from another identity sees: one identity's confirmation attempts never touch another identity's challenge | Second identity's challenge/attempt count untouched | src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › leaves a second identity completely untouched |
| Concurrent revocation: the identifier a live code was queued for gets revoked before confirmation; the challenge is simply not found, never a raw database error | 403 · identity.step_up_confirmation_refused, never 500 | test/e2e/step-up.e2e-spec.ts › refuses confirming a step-up once the identifier its code was queued for gets revoked mid-flight, instead of a raw database error; src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › excludes a challenge from the newest-live lookup once the identifier that received its code was revoked |
| Guard — no header / unissued token | 401 · identity.session_required | test/e2e/step-up.e2e-spec.ts › refuses confirming a step-up with $what |
| Guard — revoked session / forged token, byte-identical to missing | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals; src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why |
Rate limit: exceeds ANSWERS_A_CHALLENGE_RATE_LIMIT | 429 · rate_limit.exceeded | test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone |
| Concurrency: two simultaneous confirmations against the same challenge | Exactly one consumes it | src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › lets exactly one of two simultaneous confirmations lock and consume the same challenge |
| Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents | 201/403/400 per case, per configured format | test/e2e/out-of-band-code-formats.e2e-spec.ts › step-up confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents |
POST /step-ups/providers
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: fresh provider ID token proves a linked identity | 201 Created with elevationToken, expiresAt in the future | test/e2e/step-up-with-a-provider.e2e-spec.ts › steps up a provider-only identity with a fresh token and enrols an authenticator app with the elevation |
| Indistinguishable refusal: stale token / wrong subject / unlinked provider / forged signature | 403 · identity.step_up_confirmation_refused, byte-identical | test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a stale token, a token for another subject, a token for an unlinked provider, and a forged token with the byte-identical refusal |
| Refusal freshness boundary: more than 5 minutes stale refused, exactly 5 minutes accepted | 403 past the boundary; 201 at exactly 5:00 | src/modules/identity/application/step-ups/confirm-step-up-with-provider.use-case.spec.ts › refuses a token issued more than five minutes ago, without ever spending a nonce or reaching the repository |
| Indistinguishable refusal: no nonce claim / nonce nobody issued / nonce expired | 403 · identity.step_up_confirmation_refused, byte-identical | test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a step-up token with $what, byte-identically to an unissued nonce |
No dedicated attempt budget/lockout on this route: repeated refusals never escalate to 423 | 403 on every consecutive refusal | test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a stale token, a token for another subject, a token for an unlinked provider, and a forged token with the byte-identical refusal |
| Idempotent repeat: replaying the exact same ID token after it already minted an elevation | 403 · identity.step_up_confirmation_refused | test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a step-up token replayed after it already minted an elevation |
| What a caller from another identity sees: a token linked to a different identity than the caller | 403 · identity.step_up_confirmation_refused | src/modules/identity/application/step-ups/confirm-step-up-with-provider.use-case.spec.ts › refuses when the linked provider identity belongs to a different identity than the caller |
| Never leaks the presented ID token in the response | Response body excludes the raw token | test/e2e/step-up-with-a-provider.e2e-spec.ts › never lets the id token reach what it answers |
Cross-route invariant: a nonce spent by POST /sign-ins cannot be spent again here | 403 · identity.step_up_confirmation_refused | test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a nonce already spent by a sign-in when it is presented again to a step-up |
| Guard — no header / unissued token | 401 · identity.session_required | test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses stepping up with a provider with $what |
| Guard — revoked session / forged token, byte-identical to missing | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals; src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why |
Rate limit: exceeds ANSWERS_A_CHALLENGE_RATE_LIMIT | 429 · rate_limit.exceeded | test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone |
| Concurrency: simultaneous nonce-spend vs. confirm | Exactly one mints an elevation, the other refuses | test/e2e/step-up-with-a-provider.e2e-spec.ts › lets exactly one of two simultaneous step-ups spend the same nonce |
POST /provider-nonces
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: mints a nonce and a 10-minute expiry, exactly once per call | 201 Created with nonce, expiresAt = now + 10 min | src/modules/identity/application/provider-nonces/issue-provider-nonce.use-case.spec.ts › answers the raw nonce and an expiry ten minutes out, once |
| Storage: persists the hash, never the raw nonce | Stored row has nonce_hash, no plaintext nonce anywhere | src/modules/identity/application/provider-nonces/issue-provider-nonce.use-case.spec.ts › stores the hash of the nonce, never the nonce itself |
| No refusal cause: issuance never fails on domain grounds | Always 201 Created | — |
| Repeat: repeating the identical POST does not reuse anything; every call mints a materially distinct nonce | Two calls in a row yield two different ids/nonces | src/modules/identity/application/provider-nonces/issue-provider-nonce.use-case.spec.ts › gives every issued nonce a distinct id |
| No guard: route is intentionally public/unauthenticated | Reachable with or without any authorization header | — |
Rate limit: exceeds SENDS_A_MESSAGE_RATE_LIMIT | 429 · rate_limit.exceeded | test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone |
| Concurrency: simultaneous spend of the same nonce; exactly one wins | One true, one false | src/modules/identity/infrastructure/provider-nonces/identity-provider-nonce.repository.integration-spec.ts › lets exactly one of two simultaneous spends of the same nonce win |
Password lifecycle
password-methods.controller.ts, password-changes.controller.ts, password-recoveries.controller.ts, password-recovery-confirmations.controller.ts. password-methods and password-changes carry SessionGuard; only password-methods also carries ElevationGuard. The two recovery routes are public.
POST /password-methods
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: creates a password for an identity with none, and other sessions keep working | 204 No Content | test/e2e/password-methods.e2e-spec.ts › lets a provider-only identity create a password and sign in with it once it also has a verified email |
| Afterward, a verified identifier can sign in with the new password | 201 Created session | test/e2e/password-methods.e2e-spec.ts › lets a provider-only identity create a password and sign in with it once it also has a verified email |
| Refusal: an active password method already exists | 409 · identity.password_method_exists | test/e2e/password-methods.e2e-spec.ts › refuses cleanly when an active password already exists, leaving other sessions untouched; src/modules/identity/application/password-methods/create-password-method.use-case.spec.ts › refuses cleanly when an active password method already exists, without auditing anything |
| Refusing an existing password leaves every other session of this identity untouched | Other sessions' access tokens keep working | test/e2e/password-methods.e2e-spec.ts › refuses cleanly when an active password already exists, leaving other sessions untouched |
| Concurrency: two simultaneous claims for the same identity serialize on the identity lock; exactly one wins | One created, one already_exists | src/modules/identity/infrastructure/password-methods/identity-password-method.repository.integration-spec.ts › lets two simultaneous claims for the same identity serialize on the identity lock, exactly one winning |
| Guard — missing elevation | 403 · identity.elevation_required | test/e2e/password-methods.e2e-spec.ts › refuses without any elevation presented |
| Guard — elevation minted for a different session | 403 · identity.elevation_required | test/e2e/password-methods.e2e-spec.ts › refuses an elevation minted for a different session |
| Validation: new password shorter than the documented minimum | 400 · foundation.validation_failed | test/e2e/password-methods.e2e-spec.ts › refuses a new password shorter than the documented minimum |
POST /password-changes
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: current password proven, new password committed, every other session ends, the calling session keeps working | 204 No Content | test/e2e/password-change.e2e-spec.ts › replaces the password and keeps the calling session alive while every other session ends |
| Indistinguishable refusal: wrong current password vs. a new password identical to the current one | 403 · identity.password_change_refused, same code and detail for both causes | test/e2e/password-change.e2e-spec.ts › refuses a new password identical to the current one, the same way as a wrong current password |
| Indistinguishable refusal, edge case: identity has no active password credential at all — a decoy hash is still compared so timing leaks nothing | 403 · identity.password_change_refused | src/modules/identity/application/password-changes/password-change.use-case.spec.ts › refuses an identity with no password credential, after still comparing a password |
| Idempotent repeat: the exact same body replayed right after it already succeeded refuses, because the current password it names has already moved on | 403 · identity.password_change_refused | test/e2e/password-change.e2e-spec.ts › refuses the identical body replayed right after it already succeeded, because the current password it names has already moved on |
| Concurrency: the identity row is locked for the duration of the commit, serializing a change against other writers | 204 for the writer that wins the lock | test/integration/password-change.integration-spec.ts › does not leave live a session whose sign-in was still committing when the change ran |
| Concurrency: two simultaneous changes for the same identity serialize on the identity lock; exactly one wins | One committed: true, one committed: false, the winning hash persists | test/integration/password-change.integration-spec.ts › lets exactly one of two simultaneous changes for the same identity win, serialized by the identity lock |
| Downstream effect: a pending second-factor ticket opened before the change is revoked in the same transaction | 401 · identity.second_factor_refused on completing it; no new session | src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a password change cannot be completed with an %s |
| What a caller from another identity sees: changing identity A's password never touches identity B's sessions or credential | Identity B's password hash unchanged, sessions not revoked | test/integration/password-change.integration-spec.ts › leaves a second identity sessions and password untouched |
| Guard — no session at all | 401 · identity.session_required | test/e2e/password-change.e2e-spec.ts › refuses $what |
| Guard — forged/invalid access token | 401 · identity.session_required | test/e2e/password-change.e2e-spec.ts › refuses $what |
| Guard — revoked session, byte-identical to a missing token | 401 · identity.session_required | src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why |
| Validation: a body carrying a field the contract does not declare | 400 · foundation.validation_failed | test/e2e/password-change.e2e-spec.ts › refuses a body carrying a field the contract does not declare |
POST /password-recoveries
| Case | Expected answer | Proving test |
|---|---|---|
| Indistinguishable happy path: a registered identifier and an unregistered one answer with the exact same body shape | 201 Created with { data: { challengeExpiresAt } }, identical field set for both | test/e2e/password-recovery.e2e-spec.ts › answers an address nobody registered exactly like a registered one, field by field |
| Refusal: nothing can reach that identifier type | 422 · identity.identifier_channel_unavailable | src/modules/identity/application/password-recoveries/request-password-recovery.use-case.spec.ts › refuses before opening a transaction when nothing can notify that identifier type |
| Refusal: identifier fails normalization | 422 · identity.identifier_unusable | src/modules/identity/application/password-recoveries/request-password-recovery.use-case.spec.ts › refuses an unusable identifier before opening a transaction |
| Infrastructure failure before the account is known (transaction cannot begin, the account lookup throws, or a transaction that found no account fails to commit): reported, identical for a registered and an unregistered address | 503 · Retry-After for a transient database failure, 500 otherwise; never the 201 used for the happy path | test/e2e/password-recovery-infrastructure-failure.e2e-spec.ts › answers a transient database failure with the same 503 and Retry-After for a registered and an unregistered address; test/e2e/password-recovery-infrastructure-failure.e2e-spec.ts › answers any other database failure with the same 500 for a registered and an unregistered address; src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › reports the failure of the account lookup instead of answering accepted |
Infrastructure failure that can only happen for an existing account (the challenge insert, the enqueue, or the commit of a transaction that wrote a challenge): hidden so existence is not revealed, logged at error level with only the error name and code | 201 Created with { data: { challengeExpiresAt } }, the same as an unregistered identifier | src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › hides an enqueue failure for an existing account behind the exact outcome of an unknown one; src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › raises the hidden enqueue failure to an error carrying only its name and code; src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › hides a failure of the challenge insert for an existing account behind the exact outcome of an unknown one; src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › hides a commit failure of a transaction that wrote a challenge, and raises it to an error |
| Idempotent repeat: requesting recovery again for the same identifier opens a second live challenge alongside the first; consuming one later revokes the sibling | 201 Created again, a new challengeExpiresAt | test/integration/password-recovery.integration-spec.ts › revokes sibling live challenges once one is consumed |
| Validation: a body carrying a field the contract does not declare | 400 · foundation.validation_failed | test/e2e/password-recovery.e2e-spec.ts › refuses a recovery request carrying a field the contract does not declare |
POST /password-recoveries/confirmations
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: correct code replaces the password; every session of that identity ends, including the one that requested the recovery | 204 No Content | test/e2e/password-recovery.e2e-spec.ts › walks the whole recovery flow: lose it, recover it, sign in with the new one, fail with the old one |
| Indistinguishable refusal: wrong code, an already-consumed/reused code, and an unregistered address all answer identically | 422 · identity.password_recovery_confirmation_refused, same code and detail, body never contains the identifier | test/e2e/password-recovery.e2e-spec.ts › answers a wrong code, a reused code and an unregistered address with the same refusal |
| Refusal: identifier fails normalization | 422 · identity.identifier_unusable | src/modules/identity/application/password-recoveries/confirm-password-recovery.use-case.spec.ts › refuses an unusable identifier before opening a transaction |
| Validation: malformed code shape (not exactly six digits) rejected before touching the database | 400 · foundation.validation_failed | test/e2e/password-recovery.e2e-spec.ts › refuses a code that is not six digits before touching the database |
| Attempt budget: the challenge's own attempt cap is spent — answers exactly like a missing/wrong code, no distinct locked status | 422 · identity.password_recovery_confirmation_refused | test/integration/password-recovery.integration-spec.ts › holds the attempt budget and stops accepting attempts once it is spent |
Attempt-budget side effect: the failure that crosses the separate account-lockout threshold still answers the ordinary refusal, never 423 | 422 · identity.password_recovery_confirmation_refused (audited as accountLocked) | src/modules/identity/application/password-recoveries/confirm-password-recovery.use-case.spec.ts › records the account as locked when the wrong recovery code is the failure that locks it |
Lockout: a wrong code on an already locked account answers the same ordinary refusal, never 423, and the lock is not audited again | 422 · identity.password_recovery_confirmation_refused, nothing audited | src/modules/identity/application/password-recoveries/confirm-password-recovery.use-case.spec.ts › answers a wrong recovery code on an already locked account with the ordinary refusal, never a locked answer, and does not audit the lock again |
| Idempotent repeat: replaying the same confirmation after the code already recovered the password | 422 · identity.password_recovery_confirmation_refused | test/e2e/password-recovery.e2e-spec.ts › cannot reuse a code once it has recovered the password |
| Concurrency: the identity row is locked for the commit, serializing recovery against other writers | 204 for the winner | test/integration/password-recovery.integration-spec.ts › does not leave live a session whose sign-in was still committing when the recovery ran |
| Concurrency: two simultaneous confirmations of the same code serialize on the challenge lock; exactly one wins | One succeeds, the other refuses | test/integration/password-recovery.integration-spec.ts › lets exactly one of two simultaneous confirmations for the same code win, serialized by the challenge lock |
| Downstream effect: a pending second-factor ticket opened before the recovery is revoked in the same transaction | 401 · identity.second_factor_refused on completing it; no new session | src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a password recovery cannot be completed with an %s |
| The code is the only credential: the confirm request carries no session, token or caller; anyone holding identifier and code completes it | 204, no authentication required or checked | test/e2e/password-recovery.e2e-spec.ts › walks the whole recovery flow: lose it, recover it, sign in with the new one, fail with the old one |
| Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents | 204/422/400 per case, per configured format | test/e2e/out-of-band-code-formats.e2e-spec.ts › password recovery confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents |
Sign-in methods
sign-in-methods.controller.ts, sign-in-method-confirmations.controller.ts, sign-in-method-removals.controller.ts, sign-in-method-providers.controller.ts. GET /sign-in-methods carries SessionGuard only; the four mutating routes carry SessionGuard and ElevationGuard.
GET /sign-in-methods
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: lists every way in (email, password, phone) after add and confirm | 200 OK | test/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in |
| Happy path: a linked Google/Apple provider is listed alongside the password, with its provider email masked as a safe display value | 200 OK, entry kind: 'google'/'apple', masked maskedValue | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lists a linked provider alongside the password, masking its provider email |
A linked provider with no provider email lists a safe null display value instead of guessing one | 200 OK, maskedValue: null | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lists a linked provider with no provider email as a safe null display value |
| What a caller from another identity sees: identity B's list never contains identity A's sign-in methods | 200 OK (own methods only) | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › never lists, never removes, and leaves untouched a real stranger identity |
| Guard — no session presented | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator |
| Guard — forged/unrecognized access token | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request |
| Guard — revoked session, byte-identical to a missing token | 401 · identity.session_required | src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why |
| Guard — missing token and forged token answer byte-identically | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals |
| Guard — revoked session and forged token answer byte-identically | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals; src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why |
POST /sign-in-methods
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: claims a new identifier and queues its code, inside the same transaction as the claim | 201 Created | test/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in |
| Refusal: nothing can reach that identifier kind | 422 · identity.identifier_channel_unavailable | src/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › refuses when nothing can reach that kind of identifier, without ever claiming it |
| Refusal: identifier already verified by a different identity | 409 · identity.identifier_taken | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses an identifier already verified by a different identity |
| Refusal: identifier is mid-claim (pending, unconfirmed) by a different identity | 409 · identity.identifier_taken | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses an identifier a different identity is still mid-way through claiming |
| Refusal: identifier fails normalization | 422 · identity.identifier_unusable | src/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › refuses an unusable identifier before ever checking whether it can be reached |
| Self-collision: re-adding an identifier the same caller already has verified | 409 · identity.identifier_taken | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses re-adding an identifier the same caller has already verified |
| Idempotent repeat: re-adding the same still-pending identifier queues a fresh code that supersedes the older one instead of erroring (OX-S8) | 201 Created, new code, new expiry | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › still resends its own challenge when the same identity retries its own pending claim, superseding the older message (OX-S8) |
| Concurrency: a rival identity claiming the same identifier while the first claim is pending loses under the per-identifier advisory lock | 409 · identity.identifier_taken | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lets exactly one of two identities simultaneously claiming the same identifier win, serialized by the per-identifier advisory lock |
| Guard — no session presented | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator |
| Guard — forged/unrecognized access token | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request |
| Guard — revoked session | 401 · identity.session_required | src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why |
| Guard — missing elevation | 403 · identity.elevation_required | test/e2e/sign-in-methods.e2e-spec.ts › refuses to add a sign-in method without any elevation presented |
| Guard — elevation minted for a different session | 403 · identity.elevation_required | test/e2e/sign-in-methods.e2e-spec.ts › refuses to add a sign-in method backed by an elevation minted for a different session |
POST /sign-in-methods/confirmations
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: code matches its own live challenge, verifies the identifier, audits it | 204 No Content | test/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in |
| Refusal: wrong code, tried against every live challenge (each charged) | 422 · identity.sign_in_method_confirmation_refused | src/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › refuses and charges every live challenge once none of them match, but only charges the account lockout once for the whole request |
| Refusal: no live challenge exists to answer | 422 · identity.sign_in_method_confirmation_refused | src/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › refuses without charging anything when there is no live challenge to answer |
| Indistinguishable refusal: wrong code and no live challenge | 422 · identity.sign_in_method_confirmation_refused, byte-identical | src/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › answers a wrong code and a missing challenge with the same refusal |
Attempt budget/lockout: the request whose failure reaches ACCOUNT_LOCKOUT_THRESHOLD locks the account and audits it exactly once but still answers the ordinary refusal; only the next attempt answers locked | 422 · identity.sign_in_method_confirmation_refused on the crossing request, then 423 · identity.sign_in_method_locked | src/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › locks the account once wrong codes accumulate one request at a time across several of its own live challenges, none of them exhausted individually, and records the lock exactly once |
| Lockout: the correct code still succeeds while the account is locked, and clears the lock | 204 No Content | src/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › does not refuse the correct code once the account is locked; it succeeds and clears the lock |
| Idempotent repeat: replaying an already-consumed code | 422 · identity.sign_in_method_confirmation_refused | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses replaying an already-consumed confirmation code |
| Concurrency: two simultaneous confirm attempts with the same code | Exactly one succeeds, the other refuses cleanly | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lets at most one of two simultaneous confirmations with the same code consume the challenge |
| What a caller from another identity sees: identity B's code cannot confirm identity A's pending identifier | 422 · identity.sign_in_method_confirmation_refused | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › never lets identity B's code confirm identity A's pending identifier |
| Guard — no session presented | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator |
| Guard — forged/unrecognized access token | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request |
| Guard — revoked session | 401 · identity.session_required | src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why |
| Guard — missing elevation | 403 · identity.elevation_required | test/e2e/sign-in-methods.e2e-spec.ts › refuses to confirm a sign-in method without any elevation presented |
| Guard — elevation minted for a different session | 403 · identity.elevation_required | test/e2e/sign-in-methods.e2e-spec.ts › refuses to confirm a sign-in method backed by an elevation minted for a different session |
| Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents | 204/422/400 per case, per configured format | test/e2e/out-of-band-code-formats.e2e-spec.ts › sign-in-method confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents |
POST /sign-in-methods/:id/removals
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: removes one of two verified identifiers, leaving the other listed | 204 No Content | test/e2e/sign-in-methods.e2e-spec.ts › removes a method once a live elevation proves this exact session |
| Session contract: removing a password method ends every other session of this identity while the calling session keeps working, the same contract password change already documents | 204 No Content, calling session's revoked_at IS NULL, every other session's revoked_at IS NOT NULL | src/modules/identity/application/sign-in-methods/remove-sign-in-method-sessions.integration-spec.ts › removing the password method ends every other session while the calling session survives; src/modules/identity/application/sign-in-methods/remove-sign-in-method.use-case.spec.ts › revokes the target method with the calling session id, so the caller session can survive |
| Session contract: removing a linked provider method ends every other session while the calling session keeps working | 204 No Content, calling session's revoked_at IS NULL, every other session's revoked_at IS NOT NULL | src/modules/identity/application/sign-in-methods/remove-sign-in-method-sessions.integration-spec.ts › removing a linked provider method ends every other session while the calling session survives |
| Refusal: id names nothing this identity currently holds | 404 · identity.sign_in_method_not_found | src/modules/identity/application/sign-in-methods/remove-sign-in-method.use-case.spec.ts › refuses an id that names nothing this identity currently holds, without locking anything else |
| Idempotent repeat: removing an already-removed method by the same id again | 404 · identity.sign_in_method_not_found | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses removing an already-removed method a second time, exactly like an id that never existed |
| Concurrency: two concurrent removals of different targets on the same identity serialize instead of deadlocking, both succeed | 204 No Content (both) | test/integration/sign-in-method-removal-lock-order.integration-spec.ts › serialises two concurrent removals of different targets on the same identity instead of deadlocking |
| What a caller from another identity sees: an id belonging to another identity answers exactly like one that does not exist, leaving it untouched | 404 · identity.sign_in_method_not_found | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › never lists, never removes, and leaves untouched a real stranger identity |
| Last-way-in invariant: refuses removing the only verified identifier, leaving the password stranded | 409 · identity.last_sign_in_method | test/e2e/sign-in-methods.e2e-spec.ts › refuses to remove the last way in |
| Last-way-in invariant: refuses removing the password when nothing else can prove the owner | 409 · identity.last_sign_in_method | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses to remove the password when nothing else can prove the owner |
| Last-way-in invariant boundary: removing the password is allowed once a self-sufficient method (external provider/passkey) still proves the owner | 204 No Content | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › removes the password once a self-sufficient method can still prove the owner |
| Happy path: removes a linked Google/Apple provider, leaving another self-sufficient method reachable | 204 No Content | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › removes a linked provider once another self-sufficient method can still prove the owner |
| Last-way-in invariant: refuses removing the only linked provider, leaving no way in | 409 · identity.last_sign_in_method | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses removing the only linked provider, leaving no way in |
| Last-way-in invariant boundary: unlinking Google from a password identity is allowed and still leaves email+password reachable, whichever kind is removed | 204 No Content; 201 Created afterward with email+password | test/e2e/sign-in-method-providers.e2e-spec.ts › unlinks Google from a password identity, still signs in with email and password, and treats the unlinked Google subject as unclaimed everywhere else |
| Last-way-in invariant boundary: unlinking one of two linked providers is allowed, unlinking the very last one is refused | 204 No Content, then 409 · identity.last_sign_in_method | test/e2e/sign-in-method-providers.e2e-spec.ts › unlinks a provider once another self-sufficient method still proves the owner, but refuses removing the very last one |
| Domain invariant: a way in remains exactly when a self-sufficient method survives, or a verified identifier and an active password both survive — evaluated the same way whichever kind (identifier, password or provider) is removed | true/false per table row | src/modules/identity/domain/sign-in-methods/last-way-in-policy.spec.ts › $name |
Downstream effect: unlinking a provider frees that exact (provider, subject) — a step-up-with-provider from the original session is refused, since it is no longer linked to that identity | 403 · identity.step_up_confirmation_refused | test/e2e/sign-in-method-providers.e2e-spec.ts › unlinks Google from a password identity, still signs in with email and password, and treats the unlinked Google subject as unclaimed everywhere else |
| Downstream effect: an unlinked provider subject is unclaimed, not permanently blocked — signing in with it again opens a session for a fresh identity, never the original one | 201 Created, a different identity/session than the original | test/e2e/sign-in-method-providers.e2e-spec.ts › unlinks Google from a password identity, still signs in with email and password, and treats the unlinked Google subject as unclaimed everywhere else |
| Downstream effect: an unlinked provider subject can be linked again, to a different identity | 204 No Content | src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › lets a provider subject be relinked, to a different identity, once it was unlinked |
| Downstream effect: an unlinked provider subject can be linked again, to the same identity that unlinked it | 204 No Content | test/e2e/sign-in-method-providers.e2e-spec.ts › lets the same Google subject be linked again after being unlinked |
| Downstream effect: removing a linked provider, or marking a method compromised, revokes every pending second-factor ticket of that identity in the same transaction | 401 · identity.second_factor_refused on completing it; no new session | src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a linked provider is removed cannot be completed with an %s; src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a sign-in method is marked compromised cannot be completed with an %s |
Validation: a non-UUID :id fails before it reaches the domain | 400 · foundation.validation_failed | test/e2e/sign-in-methods.e2e-spec.ts › refuses a method id that is not a UUID as a validation failure, before it reaches the database |
| Guard — no session presented | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator |
| Guard — forged/unrecognized access token | 401 · identity.session_required | src/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request |
| Guard — revoked session | 401 · identity.session_required | src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why |
| Guard — missing elevation | 403 · identity.elevation_required | test/e2e/sign-in-methods.e2e-spec.ts › refuses the same removal without any elevation presented |
| Guard — elevation minted for a different session | 403 · identity.elevation_required | test/e2e/sign-in-methods.e2e-spec.ts › refuses a removal backed by an elevation minted for a different session |
POST /sign-in-methods/providers
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: links a Google/Apple identity to a signed-in identity, listed and usable to sign in afterward | 204 No Content | test/e2e/sign-in-method-providers.e2e-spec.ts › lets a password identity link Google and sign in with it afterward |
| Refusal: that provider subject already belongs to a different identity, never revealing whose | 409 · identity.provider_link_refused | test/e2e/sign-in-method-providers.e2e-spec.ts › refuses cleanly when that provider subject already belongs to a different identity, never revealing whose |
| Refusal: that provider subject is already linked to the caller identity itself, the same way | 409 · identity.provider_link_refused | test/e2e/sign-in-method-providers.e2e-spec.ts › refuses relinking a provider subject already linked to the caller itself; src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › refuses re-linking a subject already linked to the caller identity itself |
| Never links automatically by matching a provider email address; only the (provider, subject) pair decides | Linking never consults the presented email | src/modules/identity/application/sign-in-methods/link-provider.use-case.spec.ts › links exactly the presented provider, verified subject and email, onto the caller identity |
| Idempotent repeat / replay: the exact same ID token replayed after it already linked a provider | 409 · identity.provider_link_refused (single-use nonce already spent) | test/e2e/sign-in-method-providers.e2e-spec.ts › refuses the exact same token replayed after it already linked a provider |
| Refusal: the verifier could not vouch for the token, without ever spending a nonce or reaching the repository | 409 · identity.provider_link_refused | src/modules/identity/application/sign-in-methods/link-provider.use-case.spec.ts › refuses a token the verifier could not vouch for, without ever spending a nonce or reaching the repository |
| Cross-route invariant: the nonce is spent in the same transaction that links the identity, the same binding used by provider sign-in | 409 · identity.provider_link_refused when unspendable, before the repository is ever reached | src/modules/identity/application/sign-in-methods/link-provider.use-case.spec.ts › refuses when the nonce is unknown, expired or already spent, without ever reaching the repository |
| Concurrency: two simultaneous links of the same provider subject onto two different identities; exactly one wins | One linked, one already_linked | src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › lets exactly one of two simultaneous links of the same subject win |
| Validation: a body naming a provider nobody supports | 400 · foundation.validation_failed | test/e2e/sign-in-method-providers.e2e-spec.ts › refuses a body naming a provider nobody supports |
| Guard — missing elevation | 403 · identity.elevation_required | test/e2e/sign-in-method-providers.e2e-spec.ts › refuses without any elevation presented |
| Guard — elevation minted for a different session | 403 · identity.elevation_required | test/e2e/sign-in-method-providers.e2e-spec.ts › refuses an elevation minted for a different session |
Second factors and recovery codes
second-factors.controller.ts, second-factor-confirmations.controller.ts, second-factor-removals.controller.ts, recovery-codes.controller.ts. GET /second-factors, POST /second-factors/confirmations and GET /recovery-codes carry SessionGuard only; POST /second-factors, POST /second-factors/:id/removals and POST /recovery-codes carry SessionGuard and ElevationGuard.
GET /second-factors
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: lists only verified, non-revoked methods; secret/pending enrolment never surface | 200 OK | test/e2e/second-factors.e2e-spec.ts › lists a verified authenticator app without any secret material, and never a pending enrolment |
| What a caller from another identity sees: identity B never sees identity A's verified methods | 200 OK with an empty/foreign-free list | src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › never lists another identity method |
| Guard — no session / forged or unrecognised token, indistinguishable | 401 · identity.session_required | test/e2e/second-factors.e2e-spec.ts › refuses listing with $what |
| Guard — revoked/dead session presenting an otherwise well-signed token | 401 · identity.session_required | test/e2e/second-factors.e2e-spec.ts › refuses listing with a session that has since signed out |
POST /second-factors
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: enrols, returns secret and otpauth URI once, confirmable with the current code | 201 Created | test/e2e/second-factors.e2e-spec.ts › enrols an authenticator app and confirms it with the current code computed from the returned secret |
| Configuration: a configured eight-digit SHA256 policy is enrolled, confirmed and signed in with end to end, and the otpauth URI carries that exact algorithm/digits/period | 201 Created; otpauth URI contains algorithm=SHA256, digits=8, period=60 | test/e2e/totp-policy.e2e-spec.ts › enrols, confirms and signs in with an authenticator app under a configured eight-digit SHA256 policy, and the otpauth URI carries that exact configuration |
| Configuration: the default six-digit SHA1 policy keeps working end to end under an unset configuration | 201 Created; otpauth URI contains algorithm=SHA1, digits=6, period=30 | test/e2e/totp-policy.e2e-spec.ts › leaves an authenticator app enrolled under the default six-digit SHA1 policy working end to end under an unset (default) configuration |
| Configuration: a global TOTP policy change never invalidates an already-enrolled authenticator, and a new enrolment after the change gets the new policy | 201 Created sign-in with the pre-change authenticator after the application restarts under a different policy; new enrolment's otpauth URI carries the new policy | test/e2e/totp-policy.e2e-spec.ts › never locks out an authenticator enrolled before a global TOTP policy change, while a new enrolment after the change gets the new policy |
| Idempotent repeat: enrolling again before confirming revokes the prior pending enrolment (documented rotation, not a distinct error) | 201 Created; old secret's code no longer confirms | test/e2e/second-factors.e2e-spec.ts › enrolling twice leaves only the newest secret confirmable, and the old secret no longer works |
| Concurrency: the unique-pending invariant holds across repeated enrolments | Only the newest pending row survives | src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › holds the unique pending index: re-enrolling three times in a row still leaves only one live pending row; src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › lets two simultaneous enrolments for the same identity serialize on the advisory lock, leaving one live pending row |
| Refusal: no live elevation presented | 403 · identity.elevation_required | test/e2e/second-factors.e2e-spec.ts › refuses enrolment without any elevation presented |
| Guard — no session / forged or unrecognised token | 401 · identity.session_required | test/e2e/second-factors.e2e-spec.ts › refuses enrolling with $what |
| Guard — revoked/dead session | 401 · identity.session_required | test/e2e/second-factors.e2e-spec.ts › refuses enrolling with a session that has since signed out |
POST /second-factors/confirmations
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: proves a current code, verifies the pending method | 204 No Content | test/e2e/second-factors.e2e-spec.ts › enrols an authenticator app and confirms it with the current code computed from the returned secret |
| Indistinguishable refusal: wrong code, no pending enrolment, and an already-verified pending step all collapse to the same refusal | 403 · identity.second_factor_confirmation_refused | test/e2e/second-factors.e2e-spec.ts › refuses a wrong code without consuming the pending enrolment, so the real code still confirms afterward |
| Idempotent repeat: confirming again once already verified answers the same refusal as above | 403 · identity.second_factor_confirmation_refused | test/e2e/second-factors.e2e-spec.ts › refuses confirmation once the pending enrolment is already verified |
| Attempt budget/lockout: the failure that reaches the shared per-identity threshold locks the account and audits it but still refuses normally; only a later wrong code on the locked account answers locked, without a second audit | 403 · identity.second_factor_confirmation_refused on the crossing code, then 423 · identity.second_factor_locked | src/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › records the account as locked when a wrong second-factor code is the failure that locks it; src/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › throws SecondFactorLockedError once the account is already locked |
| Lockout: a wrong code below the threshold refuses and records no lock | 403 · identity.second_factor_confirmation_refused, nothing audited | src/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › refuses a wrong code below the threshold without recording any lock |
| Lockout does not gate a genuinely correct code; success never consults or clears the lock | 204 No Content even while locked | src/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › confirms a correct code while the account is locked, without consulting or clearing the lock |
| Concurrency: double-confirming the same enrolment at once, via a compare-and-set on the last used step | Second attempt refused | src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › the compare-and-set refuses a step equal to the one already spent; src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › lets exactly one of two simultaneous confirmations with the same step win the compare-and-set |
| Guard — no session / forged or unrecognised token | 401 · identity.session_required | test/e2e/second-factors.e2e-spec.ts › refuses confirming with $what |
| Guard — revoked/dead session | 401 · identity.session_required | test/e2e/second-factors.e2e-spec.ts › refuses confirming with a session that has since signed out |
POST /second-factors/:id/removals
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: removes a verified method with a live elevation; list becomes empty | 204 No Content | test/e2e/second-factors.e2e-spec.ts › removes a verified second factor once a live elevation proves it is safe, and the list becomes empty |
| Every second factor can be removed freely; the primary sign-in method still exists, so there is no last-way-in gate here | 204 No Content down to zero second factors | src/modules/identity/application/second-factors/remove-second-factor.use-case.spec.ts › revokes the method scoped by both the caller identity and the target id, and records the removal |
| Removing an authenticator ends every other live session while the calling session survives | 204 No Content; the other session's revoked_at is set, the caller's stays NULL | src/modules/identity/application/second-factors/remove-second-factor.use-case.spec.ts › ends every other live session of the identity while sparing the calling session; src/modules/identity/application/second-factors/remove-second-factor-sessions.integration-spec.ts › ends every other session while the calling session survives |
Indistinguishable refusal: an id belonging to another identity, an unknown id, and an already-removed id are all 404, so this route is deliberately not an existence oracle | 404 · identity.second_factor_not_found | test/e2e/second-factors.e2e-spec.ts › answers 404 for a method id belonging to another identity, leaving it intact for its owner |
| Idempotent repeat: removing an already-removed id | 404 · identity.second_factor_not_found | test/e2e/second-factors.e2e-spec.ts › answers the same 404 for an unknown id and for an id already removed |
| Refusal: no live elevation | 403 · identity.elevation_required | test/e2e/second-factors.e2e-spec.ts › refuses removal without a live elevation |
| Validation: id not a UUID, refused before the database is touched | 400 · foundation.validation_failed | test/e2e/second-factors.e2e-spec.ts › refuses a method id that is not a UUID as a validation failure, before it reaches the database |
Concurrency: removing the same factor twice at once, via a conditional update guarded by revoked_at IS NULL (proven sequentially, not as a true race) | One winner, one 404 | src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › affects zero rows the second time it revokes the same method |
| Guard — revoked/dead session (the only removal route with a route-specific proof of this sub-case) | 401 · identity.session_required | test/e2e/session-revocation.e2e-spec.ts › refuses an elevation token minted for a session that is later signed out, because ElevationGuard never runs without a session SessionGuard already admitted |
| Guard — no session / forged or unrecognised token | 401 · identity.session_required | test/e2e/second-factors.e2e-spec.ts › refuses removal with $what |
GET /recovery-codes
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: reports the live batch's remaining count and issuedAt | 200 OK | test/e2e/recovery-codes.e2e-spec.ts › issues a batch of ten codes in the documented four-groups-of-four format, and GET reports ten remaining |
Zero/never-issued: remaining: 0, no issuedAt, before any batch was ever issued | 200 OK, { remaining: 0 }, no issuedAt key | test/e2e/recovery-codes.e2e-spec.ts › answers remaining: 0 and no issuedAt before any batch was ever issued |
| Live count reflects a spend | 200 OK, { remaining: 9 } | test/e2e/recovery-codes.e2e-spec.ts › signs in with a recovery code when the authenticator app is unreachable, opening a working session, and GET now reports nine |
| Guard — no session / forged or unrecognised token / revoked session | 401 · identity.session_required | test/e2e/recovery-codes.e2e-spec.ts › refuses reading the status with $what; test/e2e/recovery-codes.e2e-spec.ts › refuses reading the status with a session that has since signed out |
| What a caller from another identity sees: does B's GET ever reflect A's batch/count | 200 OK, never (scoped by identity_id) | src/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › never reflects a stranger's live batch or its issuedAt |
POST /recovery-codes
| Case | Expected answer | Proving test |
|---|---|---|
Happy path: issues 10 fresh codes in xxxx-xxxx-xxxx-xxxx shape, returned once | 201 Created, 10 unique codes | test/e2e/recovery-codes.e2e-spec.ts › issues a batch of ten codes in the documented four-groups-of-four format, and GET reports ten remaining |
| Re-issuing is documented rotation (happy path, not idempotency-preservation): every issue revokes the prior live batch, retiring its codes | 201 Created; old batch's codes stop redeeming | test/e2e/recovery-codes.e2e-spec.ts › refuses a code from a retired batch once a fresh batch has been issued, and GET reports the new batch of ten |
| A spent code from the retired batch stays untouched by a later re-issue | Spent code keeps used_at set, revoked_at null | src/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › leaves a used code out of the replaced batch untouched by a later issuance |
| What a caller from another identity sees: issuing for A never revokes or otherwise touches B's live batch | B's batch unaffected | src/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › leaves a second identity completely untouched |
| Concurrency: two concurrent issues for the same identity, via a per-identity advisory transaction lock | Last write wins, exactly one live batch survives | src/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › lets two simultaneous issuances for the same identity serialize on the advisory lock, leaving exactly one live batch |
| Refusal: no live elevation, and no session is ever opened for that request | 403 · identity.elevation_required | test/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch without a live elevation, and never opens a session for that request |
| Guard — no session at all | 401 · identity.session_required | test/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch with no session at all |
| Guard — forged/unrecognised token / revoked session | 401 · identity.session_required | test/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch with a token nobody ever issued; test/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch with a session that has since signed out |
Cross-route invariants
| Invariant | Expected answer | Proving test |
|---|---|---|
| Signing out closes renewal | 401 · identity.renewal_refused | test/e2e/sign-out.e2e-spec.ts › ends the session, so the refresh token it was holding stops working |
| Changing the password closes other sessions, the calling one keeps working | 401 · identity.session_required on every other session's access token; the calling session keeps working | test/e2e/session-revocation.e2e-spec.ts › refuses every other access token once the password changes, while the calling one keeps working |
| Recovering the password closes every session, with no exception for the one that requested it | 401 · identity.session_required on every session's access token | test/e2e/session-revocation.e2e-spec.ts › refuses every access token, including the one that requested it, once the password is recovered |
Removing the last way in is refused, and only on POST /sign-in-methods/:id/removals — POST /second-factors/:id/removals carries no equivalent guard, and every second factor can be removed freely | 409 · identity.last_sign_in_method on the sign-in-methods route; 204 No Content down to zero on the second-factors route | test/e2e/sign-in-methods.e2e-spec.ts › refuses to remove the last way in; test/e2e/second-factors.e2e-spec.ts › removes a verified second factor once a live elevation proves it is safe, and the list becomes empty |
| A lock set by one route refuses the next wrong guess on every route; a correct secret is still admitted everywhere, because a lock only ever stops guessing | 201 Created for a correct credential/code on a locked account, on every route a test exercises; 423 on the very first wrong guess a different route sees afterward | test/e2e/account-lockout.e2e-spec.ts › still opens a session for the correct password once the account is locked, because a lock only ever stops guessing; test/e2e/account-lockout.e2e-spec.ts › still mints the elevation for the correct code once the step-up account is locked, because the caller already proved who they are; test/e2e/account-lockout.e2e-spec.ts › locks a step-up on its very first wrong code once wrong passwords already locked the account on sign-in |
The failure that crosses the lock threshold locks the account and audits it exactly once but answers the route's ordinary refusal, on every route that records a lockout failure; only a later wrong guess answers 423, and only on the four routes that expose a locked answer (sign-in second factor, step-up, sign-in method confirmation, second-factor confirmation) | Crossing failure: the route's ordinary refusal, one accountLocked audit; later wrong guess: 423 (or the ordinary refusal on the routes that never answer 423), no second audit | src/modules/identity/application/lockouts/record-lockout-failure.spec.ts › locks the account on the failure that crosses the threshold and audits it once, still answering the ordinary refusal; src/modules/identity/application/lockouts/record-lockout-failure.spec.ts › answers the lock expiry on every later attempt and never audits the lock a second time |
| A revoked session's access token is refused on the next request | 401 · identity.session_required | test/e2e/session-revocation.e2e-spec.ts › refuses the same access token on the very next request once its session signs out |
| A spent provider nonce cannot be spent on the other provider route | 403 · identity.step_up_confirmation_refused when a nonce spent by POST /sign-ins is replayed at POST /step-ups/providers | test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a nonce already spent by a sign-in when it is presented again to a step-up |
| The same cross-route invariant holds in the other direction | 401 · identity.sign_in_refused when a nonce spent by POST /step-ups/providers is replayed at POST /sign-ins | test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a nonce already spent by a step-up when it is presented again to a sign-in |
| An out-of-band code issued under one configured policy still confirms after the application restarts under a different one, before it expires | 204 No Content | test/e2e/verification-code-policy.e2e-spec.ts › confirms a code issued under the default policy after the application restarts under a different one, before it expires |
| A challenge's attempt budget and the identity's account lockout counter are not reset by an out-of-band policy change across a restart | 422 · identity.verification_refused for the correct code, unchanged even after the restart | test/e2e/verification-code-policy.e2e-spec.ts › does not reset the challenge attempt budget or the account lockout counter across a restart that changes the policy |
Gaps
None open. Every case a proving test could reach names one; the two rows that still answer — describe a route with no failure mode to prove (an issuance that never refuses, and a route deliberately carrying no guard), not an untested case.