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 | 26 |
| Per-route cases | 279 |
| Per-route cases proven | 277 (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 | 10 (10 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 sends the verification code | 201 Created · { data: { identity, challengeDelivery: DELIVERED, challengeExpiresAt } } | test/e2e/sign-up.e2e-spec.ts › registers an email identity and answers 201 inside the envelope |
| Response never carries the code it just sent | 201 Created, body excludes the code | test/e2e/sign-up.e2e-spec.ts › never answers with the verification code it just sent |
| Idempotent repeat: registering the same still-unverified address again | 201 Created, same challengeDelivery, a fresh code is sent again (no 409) | test/e2e/sign-up.e2e-spec.ts › answers a second registration for an unverified address the same way, and sends again |
| Indistinguishable refusal: a squatter registers first, the real owner registers the same address with a different password | 201 Created both times, byte-identical challengeDelivery, 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 |
| 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 |
| Degraded happy path: identity is created but the outbound notifier fails to send the code | 201 Created · challengeDelivery: { status: NOT_DELIVERED, resendable: true }, registration still committed | src/modules/identity/application/registration/register-identity.use-case.spec.ts › keeps the registration when the challenge never left, and says it can be resent |
| Degraded happy path variant: delivery fails for an unexplained reason | 201 Created · challengeDelivery: { status: NOT_DELIVERED, resendable: false } | src/modules/identity/application/registration/register-identity.use-case.spec.ts › refuses to call an unexplained failure resendable, because nothing proves the code never left |
| Validation: password too short | 400 · 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 · 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 · 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 sent | 204 No Content | test/e2e/sign-up-confirmation.e2e-spec.ts › verifies the identifier with the code it was sent |
| 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 |
| Account lockout: trips once wrong guesses spread across this identity's several own challenges reach the threshold, but the owner still answers the correct code afterward | Account locks, the correct code still consumes the challenge normally | src/modules/identity/application/verification/verify-identifier.use-case.integration-spec.ts › locks the account once wrong guesses spread across its own several challenges reach the threshold, but still lets the owner in with the correct code |
| Validation: code is not exactly six digits, refused before touching the database | 400 · 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 |
| 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 · 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 · 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 · 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 |
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 |
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 · 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/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, 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 delivers a code to the verified identifier | 201 Created with challengeExpiresAt | test/e2e/step-up.e2e-spec.ts › proves the code sent to 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: identifier type found but nothing can notify it | 422 · identity.identifier_channel_unavailable | src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › refuses when nothing can reach the identifier type it found, before opening a challenge |
| 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/modules/identity/presentation/guards/session.guard.spec.ts › answers a dead session and a forged token with byte-identical refusals |
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 sent to 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 | 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 |
| 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 |
| 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/modules/identity/presentation/guards/session.guard.spec.ts › answers a dead session and a forged token with byte-identical refusals |
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/modules/identity/presentation/guards/session.guard.spec.ts › answers a dead session and a forged token with byte-identical refusals |
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-changes.controller.ts, password-recoveries.controller.ts, password-recovery-confirmations.controller.ts. Only password-changes carries a guard (SessionGuard, no ElevationGuard); the two recovery routes are public.
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 |
| 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/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, because $why |
| Validation: a body carrying a field the contract does not declare | 400 · 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 |
| 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 · 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 · 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 |
| 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 |
| 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. GET /sign-in-methods carries SessionGuard only; the three 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 |
| 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/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all |
| Guard — forged/unrecognized access token | 401 · identity.session_required | src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a token the reader 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/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, because $why |
| Guard — missing token and forged token answer byte-identically | 401 · identity.session_required | src/modules/identity/presentation/guards/session.guard.spec.ts › answers a missing header and an unrecognised token with the same refusal |
| Guard — revoked session and forged token answer byte-identically | 401 · identity.session_required | src/modules/identity/presentation/guards/session.guard.spec.ts › answers a dead session and a forged token with byte-identical refusals |
POST /sign-in-methods
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: claims a new identifier and sends its code | 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 resends a fresh code instead of erroring | 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 |
| 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/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all |
| Guard — forged/unrecognized access token | 401 · identity.session_required | src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a token the reader does not recognise, and leaves nobody on the request |
| Guard — revoked session | 401 · identity.session_required | src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, 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: after ACCOUNT_LOCKOUT_THRESHOLD failed confirmations, the account locks and is audited exactly once | 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/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all |
| Guard — forged/unrecognized access token | 401 · identity.session_required | src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a token the reader does not recognise, and leaves nobody on the request |
| Guard — revoked session | 401 · identity.session_required | src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, 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 |
| 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 |
Validation: a non-UUID :id fails before it reaches the domain | 400 · 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/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all |
| Guard — forged/unrecognized access token | 401 · identity.session_required | src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a token the reader does not recognise, and leaves nobody on the request |
| Guard — revoked session | 401 · identity.session_required | src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, 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 |
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 fifth consecutive failure (shared per-identity threshold) locks the account instead of refusing normally | 423 · identity.second_factor_locked | src/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › throws SecondFactorLockedError once the account is already locked |
| 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 |
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 · 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 |
| 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.