Skip to content

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

Routes26
Per-route cases279
Per-route cases proven277 (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 invariants10 (10 proven)
Gaps0

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

CaseExpected answerProving test
Happy path: registers a brand-new email/phone identity and sends the verification code201 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 sent201 Created, body excludes the codetest/e2e/sign-up.e2e-spec.ts › never answers with the verification code it just sent
Idempotent repeat: registering the same still-unverified address again201 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 password201 Created both times, byte-identical challengeDelivery, so a caller cannot tell whether they were firsttest/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 owner409 · identity.identifier_takentest/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_unusabletest/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 write422 · identity.identifier_channel_unavailable, no transaction openedsrc/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 code201 Created · challengeDelivery: { status: NOT_DELIVERED, resendable: true }, registration still committedsrc/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 reason201 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 short400 · validation.failed, body never echoes the address or passwordtest/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 senttest/e2e/sign-up.e2e-spec.ts › refuses a body carrying a field the contract does not declare
Validation: country supplied on an email registration400 · validation.failed, no message senttest/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_LIMIT429 · rate_limit.exceeded, retryable: true, no message sent for the refused attempttest/e2e/rate-limit.e2e-spec.ts › refuses the registration that goes past the limit
Concurrency: five simultaneous registrations for the same addressExactly one wins the claim, the rest see the losing branch (re-challenge), never two identities for one addresssrc/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

CaseExpected answerProving test
Happy path: verifies the identifier with the code it was sent204 No Contenttest/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 again422 · identity.verification_refusedtest/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 account422 · identity.verification_refusedtest/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 registeredIdentical 422 · identity.verification_refused bodytest/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 code422 · identity.verification_refusedtest/e2e/sign-up-confirmation.e2e-spec.ts › refuses the code when the password is not the one the registration carried
Refusal: identifier fails normalization422 · identity.identifier_unusablesrc/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 afterward422 · identity.verification_refusedtest/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 afterwardAccount locks, the correct code still consumes the challenge normallysrc/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 database400 · validation.failedtest/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 documents204/422/400 per case, per configured formattest/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_LIMIT429 · rate_limit.exceededtest/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 flightThe owner's original code still verifies afterward; the stranger's competing claim gets its own, separately-verifiable codetest/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.

CaseExpected answerProving test
Happy path: password sign-in opens a session201 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 token201 Created · sessiontest/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 token201 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 sessionsEach session gets a distinct refresh tokentest/e2e/sign-in.e2e-spec.ts › never repeats a refresh token across two sessions
Refusal: wrong password401 · identity.sign_in_refusedtest/e2e/sign-in.e2e-spec.ts › refuses a password that is not the one on the account
Refusal: identifier never verified401 · identity.sign_in_refusedtest/e2e/sign-in.e2e-spec.ts › refuses an identifier nobody has confirmed yet
Indistinguishable refusal: wrong password vs. unknown accountByte-identical 401 bodytest/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 accountByte-identical 401 · identity.sign_in_refused bodytest/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 passwordByte-identical 401 body, reveals nothing about which providers are configuredtest/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 passwordByte-identical 401 bodytest/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 token401 response excludes bothtest/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 proof201 Created session, lock state never consultedtest/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 stepSecond factor checked strictly after password proofsrc/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 session401 · 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 new201 Created, no new identitytest/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 nonce401 · identity.sign_in_refused, identical across all threetest/e2e/provider-sign-in.e2e-spec.ts › refuses a token with $what, byte-identically to an unissued nonce
Validation: unsupported client type400 · validation.failedtest/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 createdtest/integration/sign-in-credential-race.integration-spec.ts › refuses %s committed while password verification was pending
Concurrency: method/principal revoked concurrently, ordered after session issuanceIssuance still succeeds, but the freshly-opened session is automatically revoked by the concurrent revocationtest/integration/sign-in-credential-race.integration-spec.ts › serializes %s revocation after issuance and automatically revokes the new session
Concurrency baseline: credential remains eligible throughoutExactly one session openedtest/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_LIMIT429 · rate_limit.exceededtest/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

CaseExpected answerProving test
Happy path: current TOTP code completes the sign-in, opens a session usable on a guarded route201 Created · session; challenge consumed, lock clearedtest/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 method201 Created · sessionsrc/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 ticket401 · identity.second_factor_refused, untouched challenge/lockoutsrc/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 code401 · identity.second_factor_refusedsrc/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 code401 · identity.second_factor_refusedtest/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 code401 · identity.second_factor_refusedsrc/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 ticketByte-identical 401 bodytest/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-insByte-identical 401 · identity.sign_in_refusedtest/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 code401 · identity.second_factor_refused on the second usetest/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 refused401 · identity.second_factor_refused, never 423test/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 it6th cumulative failure (fresh ticket) answers 423 · identity.sign_in_second_factor_lockedtest/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 response401 · 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 response423 · identity.sign_in_second_factor_lockedsrc/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 locked201 Created · session, lock never consultedsrc/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 path201 Created, lock never consulted before the recovery code is checkedsrc/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 code400 · validation.failedsrc/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 neither400 · validation.failedsrc/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 active201 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_LIMIT429 · rate_limit.exceededtest/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 ticketExactly one session opened, the other refusedtest/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

CaseExpected answerProving test
Happy path: exchanges the refresh token for a new pair201 Created · new accessToken/refreshTokentest/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-intest/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 again401 · identity.renewal_refused, and the whole session ends as a suspected leaktest/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 token401 · identity.renewal_refusedtest/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 too401 · identity.renewal_refused, and the session's current token also stops working afterwardtest/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 oneByte-identical 401 bodysrc/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 refused201 Created for the still-valid sessiontest/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 anythingRejects, no rotation performedsrc/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 rotating401 · identity.renewal_refused, no rotationsrc/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-signingRevocation is not lost to the racesrc/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 committedNew token survives, old-generation replay revokes it correctlytest/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-revocationCurrent token refused after the race resolvestest/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 sessionNo false-positive replay revocationtest/integration/refresh-token-replay.integration-spec.ts › renews several generations without revoking an honest session
Rate limit: exceeds PROVES_A_PASSWORD_RATE_LIMIT429 · rate_limit.exceededtest/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone

POST /sign-outs

CaseExpected answerProving test
Happy path: ends the session belonging to the presented access token; its refresh token stops working immediately204 No Content, refresh token then refused with 401 · identity.renewal_refusedtest/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 untouchedOther sessions of the same identity keep renewingtest/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 token401 · identity.session_required — the token itself is refused because its session is no longer live, not a 204 no-optest/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 itReason field stable across a second calltest/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 nothingTargeted session's revoked_at stays NULLtest/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 oncerevoked_at set once, reason stabletest/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 all401 · identity.session_requiredtest/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 indistinguishable401 · identity.session_requiredtest/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_requiredtest/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

CaseExpected answerProving test
Happy path: ends every session of this identity, so every client's refresh token stops renewing204 No Content, all refresh tokens refused afterwardtest/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 targetAudit record identity.sessions.revoked_everywhere, outcome successtest/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 untouchedOther identity's sessions stay livetest/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 endCompletes normally, no exceptiontest/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 token401 · identity.session_requiredtest/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 itReason field stable across a second calltest/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 liveThat 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 operationNew session is caught and revoked; the operation still succeedstest/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 all401 · identity.session_requiredtest/e2e/sign-out-everywhere.e2e-spec.ts › refuses $what the same way
Guard — forged/invalid access token, indistinguishable across every sub-case401 · identity.session_requiredtest/e2e/sign-out-everywhere.e2e-spec.ts › refuses $what the same way
Guard — revoked session: same refusal as the idempotent-repeat row above401 · identity.session_requiredtest/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

CaseExpected answerProving test
Happy path: freshly confirmed identity200 OK with identityId, masked verified identifiers, active methods, empty organization contexttest/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 identifier200 OK, masked values onlytest/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/methodsQuery scoped by identityId; no leakagesrc/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, indistinguishable401 · identity.session_requiredtest/e2e/session-context.e2e-spec.ts › refuses $what the same way
Guard — revoked session, next request with the same token401 · identity.session_requiredtest/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-identical401 · identity.session_required, identical body/headerstest/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 credential401 · identity.session_requiredtest/e2e/step-up.e2e-spec.ts › refuses an elevation token presented as a bearer credential on an ordinary authenticated route

GET /sign-ins/history

CaseExpected answerProving test
Happy path: lists open sessions, flags the caller's own200 OK, isCurrentSession true only for the presented token's sessiontest/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/after200 OK, pageInfo: { hasNextPage, nextCursor }, no gaps or duplicatestest/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 absentApplies the documented default limitsrc/foundation/pagination/collection-query.schema.spec.ts › defaults to the documented default limit when absent
Pagination: maximum limit (100) accepted200 OK at 100src/foundation/pagination/collection-query.schema.spec.ts › accepts the documented maximum limit
Pagination: limit above 100 rejected400 · validation.failedsrc/foundation/pagination/collection-query.schema.spec.ts › rejects a limit above the documented maximum
Pagination: structurally malformed cursor400 · collection.cursor_invalidtest/e2e/session-history.e2e-spec.ts › refuses a structurally malformed cursor as collection.cursor_invalid
Pagination: tampered cursor signature400 · collection.cursor_tamperedsrc/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 identity400 · collection.cursor_incompatibletest/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 indistinguishable401 · identity.session_requiredtest/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 token401 · 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 sessions200 OK, only the caller's own sessions returnedsrc/modules/identity/infrastructure/sessions/identity-session.repository.integration-spec.ts › never returns a session that belongs to another identity

POST /step-ups

CaseExpected answerProving test
Happy path: opens a challenge and delivers a code to the verified identifier201 Created with challengeExpiresAttest/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 reach422 · identity.step_up_unreachablesrc/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 it422 · identity.identifier_channel_unavailablesrc/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 answersOnly the newest challenge is lockable/answerablesrc/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_LIMIT429 · rate_limit.exceededtest/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, indistinguishable401 · identity.session_requiredtest/e2e/step-up.e2e-spec.ts › refuses opening a step-up with $what
Guard — revoked session / forged token, byte-identical to missing401 · identity.session_requiredsrc/modules/identity/presentation/guards/session.guard.spec.ts › answers a dead session and a forged token with byte-identical refusals

POST /step-ups/confirmations

CaseExpected answerProving test
Happy path: correct code mints an elevation bound to the calling session201 Created with elevationToken, expiresAt in the futuretest/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 survives403 · identity.step_up_confirmation_refusedtest/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 answer403 · identity.step_up_confirmation_refused, byte-identicalsrc/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 revokedAttempts counted 0-4, challenge unlockable afterwardsrc/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 locked403 · identity.step_up_confirmation_refused, not 423src/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 locked423 · identity.step_up_lockedtest/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 locked201 Createdtest/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 code403 · identity.step_up_confirmation_refusedsrc/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 challengeSecond identity's challenge/attempt count untouchedsrc/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › leaves a second identity completely untouched
Guard — no header / unissued token401 · identity.session_requiredtest/e2e/step-up.e2e-spec.ts › refuses confirming a step-up with $what
Guard — revoked session / forged token, byte-identical to missing401 · identity.session_requiredsrc/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_LIMIT429 · rate_limit.exceededtest/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 challengeExactly one consumes itsrc/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 documents201/403/400 per case, per configured formattest/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

CaseExpected answerProving test
Happy path: fresh provider ID token proves a linked identity201 Created with elevationToken, expiresAt in the futuretest/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 signature403 · identity.step_up_confirmation_refused, byte-identicaltest/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 accepted403 past the boundary; 201 at exactly 5:00src/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 expired403 · identity.step_up_confirmation_refused, byte-identicaltest/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 423403 on every consecutive refusaltest/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 elevation403 · identity.step_up_confirmation_refusedtest/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 caller403 · identity.step_up_confirmation_refusedsrc/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 responseResponse body excludes the raw tokentest/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 here403 · identity.step_up_confirmation_refusedtest/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 token401 · identity.session_requiredtest/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 missing401 · identity.session_requiredsrc/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_LIMIT429 · rate_limit.exceededtest/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. confirmExactly one mints an elevation, the other refusestest/e2e/step-up-with-a-provider.e2e-spec.ts › lets exactly one of two simultaneous step-ups spend the same nonce

POST /provider-nonces

CaseExpected answerProving test
Happy path: mints a nonce and a 10-minute expiry, exactly once per call201 Created with nonce, expiresAt = now + 10 minsrc/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 nonceStored row has nonce_hash, no plaintext nonce anywheresrc/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 groundsAlways 201 Created
Repeat: repeating the identical POST does not reuse anything; every call mints a materially distinct nonceTwo calls in a row yield two different ids/noncessrc/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/unauthenticatedReachable with or without any authorization header
Rate limit: exceeds SENDS_A_MESSAGE_RATE_LIMIT429 · rate_limit.exceededtest/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 winsOne true, one falsesrc/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

CaseExpected answerProving test
Happy path: current password proven, new password committed, every other session ends, the calling session keeps working204 No Contenttest/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 one403 · identity.password_change_refused, same code and detail for both causestest/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 nothing403 · identity.password_change_refusedsrc/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 on403 · identity.password_change_refusedtest/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 writers204 for the writer that wins the locktest/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 winsOne committed: true, one committed: false, the winning hash persiststest/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 credentialIdentity B's password hash unchanged, sessions not revokedtest/integration/password-change.integration-spec.ts › leaves a second identity sessions and password untouched
Guard — no session at all401 · identity.session_requiredtest/e2e/password-change.e2e-spec.ts › refuses $what
Guard — forged/invalid access token401 · identity.session_requiredtest/e2e/password-change.e2e-spec.ts › refuses $what
Guard — revoked session, byte-identical to a missing token401 · identity.session_requiredsrc/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 declare400 · validation.failedtest/e2e/password-change.e2e-spec.ts › refuses a body carrying a field the contract does not declare

POST /password-recoveries

CaseExpected answerProving test
Indistinguishable happy path: a registered identifier and an unregistered one answer with the exact same body shape201 Created with { data: { challengeExpiresAt } }, identical field set for bothtest/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 type422 · identity.identifier_channel_unavailablesrc/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 normalization422 · identity.identifier_unusablesrc/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 sibling201 Created again, a new challengeExpiresAttest/integration/password-recovery.integration-spec.ts › revokes sibling live challenges once one is consumed
Validation: a body carrying a field the contract does not declare400 · validation.failedtest/e2e/password-recovery.e2e-spec.ts › refuses a recovery request carrying a field the contract does not declare

POST /password-recoveries/confirmations

CaseExpected answerProving test
Happy path: correct code replaces the password; every session of that identity ends, including the one that requested the recovery204 No Contenttest/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 identically422 · identity.password_recovery_confirmation_refused, same code and detail, body never contains the identifiertest/e2e/password-recovery.e2e-spec.ts › answers a wrong code, a reused code and an unregistered address with the same refusal
Refusal: identifier fails normalization422 · identity.identifier_unusablesrc/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 database400 · validation.failedtest/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 status422 · identity.password_recovery_confirmation_refusedtest/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 423422 · 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 password422 · identity.password_recovery_confirmation_refusedtest/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 writers204 for the winnertest/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 winsOne succeeds, the other refusestest/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 it204, no authentication required or checkedtest/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 documents204/422/400 per case, per configured formattest/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

CaseExpected answerProving test
Happy path: lists every way in (email, password, phone) after add and confirm200 OKtest/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 methods200 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 presented401 · identity.session_requiredsrc/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all
Guard — forged/unrecognized access token401 · identity.session_requiredsrc/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 token401 · identity.session_requiredsrc/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-identically401 · identity.session_requiredsrc/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-identically401 · identity.session_requiredsrc/modules/identity/presentation/guards/session.guard.spec.ts › answers a dead session and a forged token with byte-identical refusals

POST /sign-in-methods

CaseExpected answerProving test
Happy path: claims a new identifier and sends its code201 Createdtest/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 kind422 · identity.identifier_channel_unavailablesrc/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 identity409 · identity.identifier_takensrc/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 identity409 · identity.identifier_takensrc/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 normalization422 · identity.identifier_unusablesrc/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 verified409 · identity.identifier_takensrc/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 erroring201 Created, new code, new expirysrc/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 lock409 · identity.identifier_takensrc/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 presented401 · identity.session_requiredsrc/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all
Guard — forged/unrecognized access token401 · identity.session_requiredsrc/modules/identity/presentation/guards/session.guard.spec.ts › refuses a token the reader does not recognise, and leaves nobody on the request
Guard — revoked session401 · identity.session_requiredsrc/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 elevation403 · identity.elevation_requiredtest/e2e/sign-in-methods.e2e-spec.ts › refuses to add a sign-in method without any elevation presented
Guard — elevation minted for a different session403 · identity.elevation_requiredtest/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

CaseExpected answerProving test
Happy path: code matches its own live challenge, verifies the identifier, audits it204 No Contenttest/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_refusedsrc/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 answer422 · identity.sign_in_method_confirmation_refusedsrc/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 challenge422 · identity.sign_in_method_confirmation_refused, byte-identicalsrc/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 once423 · identity.sign_in_method_lockedsrc/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 lock204 No Contentsrc/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 code422 · identity.sign_in_method_confirmation_refusedsrc/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 codeExactly one succeeds, the other refuses cleanlysrc/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 identifier422 · identity.sign_in_method_confirmation_refusedsrc/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 presented401 · identity.session_requiredsrc/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all
Guard — forged/unrecognized access token401 · identity.session_requiredsrc/modules/identity/presentation/guards/session.guard.spec.ts › refuses a token the reader does not recognise, and leaves nobody on the request
Guard — revoked session401 · identity.session_requiredsrc/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 elevation403 · identity.elevation_requiredtest/e2e/sign-in-methods.e2e-spec.ts › refuses to confirm a sign-in method without any elevation presented
Guard — elevation minted for a different session403 · identity.elevation_requiredtest/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 documents204/422/400 per case, per configured formattest/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

CaseExpected answerProving test
Happy path: removes one of two verified identifiers, leaving the other listed204 No Contenttest/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 holds404 · identity.sign_in_method_not_foundsrc/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 again404 · identity.sign_in_method_not_foundsrc/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 succeed204 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 untouched404 · identity.sign_in_method_not_foundsrc/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 stranded409 · identity.last_sign_in_methodtest/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 owner409 · identity.last_sign_in_methodsrc/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 owner204 No Contentsrc/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 domain400 · validation.failedtest/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 presented401 · identity.session_requiredsrc/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all
Guard — forged/unrecognized access token401 · identity.session_requiredsrc/modules/identity/presentation/guards/session.guard.spec.ts › refuses a token the reader does not recognise, and leaves nobody on the request
Guard — revoked session401 · identity.session_requiredsrc/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 elevation403 · identity.elevation_requiredtest/e2e/sign-in-methods.e2e-spec.ts › refuses the same removal without any elevation presented
Guard — elevation minted for a different session403 · identity.elevation_requiredtest/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

CaseExpected answerProving test
Happy path: lists only verified, non-revoked methods; secret/pending enrolment never surface200 OKtest/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 methods200 OK with an empty/foreign-free listsrc/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › never lists another identity method
Guard — no session / forged or unrecognised token, indistinguishable401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses listing with $what
Guard — revoked/dead session presenting an otherwise well-signed token401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses listing with a session that has since signed out

POST /second-factors

CaseExpected answerProving test
Happy path: enrols, returns secret and otpauth URI once, confirmable with the current code201 Createdtest/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/period201 Created; otpauth URI contains algorithm=SHA256, digits=8, period=60test/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 configuration201 Created; otpauth URI contains algorithm=SHA1, digits=6, period=30test/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 policy201 Created sign-in with the pre-change authenticator after the application restarts under a different policy; new enrolment's otpauth URI carries the new policytest/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 confirmstest/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 enrolmentsOnly the newest pending row survivessrc/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 presented403 · identity.elevation_requiredtest/e2e/second-factors.e2e-spec.ts › refuses enrolment without any elevation presented
Guard — no session / forged or unrecognised token401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses enrolling with $what
Guard — revoked/dead session401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses enrolling with a session that has since signed out

POST /second-factors/confirmations

CaseExpected answerProving test
Happy path: proves a current code, verifies the pending method204 No Contenttest/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 refusal403 · identity.second_factor_confirmation_refusedtest/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 above403 · identity.second_factor_confirmation_refusedtest/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 normally423 · identity.second_factor_lockedsrc/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 lock204 No Content even while lockedsrc/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 stepSecond attempt refusedsrc/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 token401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses confirming with $what
Guard — revoked/dead session401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses confirming with a session that has since signed out

POST /second-factors/:id/removals

CaseExpected answerProving test
Happy path: removes a verified method with a live elevation; list becomes empty204 No Contenttest/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 here204 No Content down to zero second factorssrc/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 oracle404 · identity.second_factor_not_foundtest/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 id404 · identity.second_factor_not_foundtest/e2e/second-factors.e2e-spec.ts › answers the same 404 for an unknown id and for an id already removed
Refusal: no live elevation403 · identity.elevation_requiredtest/e2e/second-factors.e2e-spec.ts › refuses removal without a live elevation
Validation: id not a UUID, refused before the database is touched400 · validation.failedtest/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 404src/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_requiredtest/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 token401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses removal with $what

GET /recovery-codes

CaseExpected answerProving test
Happy path: reports the live batch's remaining count and issuedAt200 OKtest/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 issued200 OK, { remaining: 0 }, no issuedAt keytest/e2e/recovery-codes.e2e-spec.ts › answers remaining: 0 and no issuedAt before any batch was ever issued
Live count reflects a spend200 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 session401 · identity.session_requiredtest/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/count200 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

CaseExpected answerProving test
Happy path: issues 10 fresh codes in xxxx-xxxx-xxxx-xxxx shape, returned once201 Created, 10 unique codestest/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 codes201 Created; old batch's codes stop redeemingtest/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-issueSpent code keeps used_at set, revoked_at nullsrc/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 batchB's batch unaffectedsrc/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 lockLast write wins, exactly one live batch survivessrc/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 request403 · identity.elevation_requiredtest/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 all401 · identity.session_requiredtest/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch with no session at all
Guard — forged/unrecognised token / revoked session401 · identity.session_requiredtest/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

InvariantExpected answerProving test
Signing out closes renewal401 · identity.renewal_refusedtest/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 working401 · identity.session_required on every other session's access token; the calling session keeps workingtest/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 it401 · identity.session_required on every session's access tokentest/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/removalsPOST /second-factors/:id/removals carries no equivalent guard, and every second factor can be removed freely409 · identity.last_sign_in_method on the sign-in-methods route; 204 No Content down to zero on the second-factors routetest/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 guessing201 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 afterwardtest/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 request401 · identity.session_requiredtest/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 route403 · identity.step_up_confirmation_refused when a nonce spent by POST /sign-ins is replayed at POST /step-ups/providerstest/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 direction401 · identity.sign_in_refused when a nonce spent by POST /step-ups/providers is replayed at POST /sign-instest/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 expires204 No Contenttest/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 restart422 · identity.verification_refused for the correct code, unchanged even after the restarttest/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.

Application Foundation in progress. Tracked in issue #13.