Commit Graph
5 Commits
Author SHA1 Message Date
Marc-André Bombeck d8ddf97d9e test(step-up): wait for the audit breadcrumb instead of sleeping and hoping
Five assertions slept a fixed 100 ms and then read the audit ledger. The write is
fire-and-forget, so on a busy runner the read lands first and the assertion fails
with "expected [] to include 'consumed'" — which reads as a broken refusal when
the refusal worked and only its breadcrumb was still in flight. CI failed on
exactly that while nothing in the branch touches step-up or MFA.

Replaced with a poll that returns the moment the reason appears and, on timeout,
asserts against the real list so a genuine regression still says what WAS
written rather than reporting a bare timeout.
2026-07-31 01:12:22 +02:00
Marc-André Bombeck 4cd0adc46d docs(api): publish the factor-dependent reach of a step-up elevation
The contract now names which factors satisfy the fresh-factor routes and which
do not, carries `satisfiesFreshFactor` on the mint response so a client can pick
the ceremony before spending a proof, and describes the options endpoint's two
ceremony kinds. The second-factor status read is documented as plain
authentication — it carries no credential material, so it needs no elevation.
2026-07-19 14:29:34 +02:00
Marc-André Bombeck 0991d754e4 fix(auth): make the re-proved factor decide what an elevation reaches
A step-up elevation minted from the account password satisfied the fresh-factor
routes. That is a second-factor bypass: a stolen token plus a known password
could rotate the recovery codes — which take no factor in the body — and then
spend one of them to disable the second factor outright. The web has never
allowed that chain, because a password login does not stamp mfaVerifiedAt.

The method is now an authorisation input. Only totp, webauthn, and passkey
satisfy a fresh-factor gate, which is exactly the set of ceremonies the web
stamps a session for; password reaches what a plain cookie session reaches and
stops. The mint gains TOTP and security-key arms so a TOTP-only account can
still manage its factor natively, and the response says which reach it bought.

The gate also returns a discriminated context. session.id meant a session row on
one arm and a token row on the other, and the disable route passed the token id
to a session-scoped query — excluding nothing, so it deleted every browser
session and revoked the caller's own device login. The transport is now in the
type and the compiler refuses the confusion.

Redemption splits into validate-then-commit so a 429, a 422, or a wrong code no
longer burns a proof against a five-per-fifteen-minute mint ceiling. The claim
is UPDATE ... RETURNING, so the statement that decides is the one that reports.
The mint is transactional behind a partial unique index. Sign-out-everywhere and
both password-reset paths clear elevations. The status read drops to plain
requireAuth — it carries no credential material — and every mutation stays gated.
2026-07-19 13:48:46 +02:00
Marc-André Bombeck ba45a25cb0 test(auth): retarget the MFA route mocks at the shared gate
The three unit suites that stubbed `requireCookieAuth` / `requireFreshMfa`
directly now stub `requireMfaManagementAuth`, and the password-change suite
stubs the elevation revocation the route gained. Adds the unit checks that need
no database: the elevation TTL is pinned to the cookie path's step-up window so
the two cannot drift apart, the minted value is a prefixed CSPRNG secret whose
plaintext never reaches the row, and a value carrying another credential's
prefix is refused without costing a query.
2026-07-19 13:14:38 +02:00
Marc-André Bombeck 433d983e60 test(auth): pin the step-up elevation boundary and publish its contract
Adds the structural guard that freezes the exact route set an elevation can
unlock, plus the behavioural suite that drives each threat case against a real
Postgres: a token alone mints nothing, an elevation minted for one token is
refused for another (same account included), a second redemption fails, a
concurrent redemption lets exactly one winner through, an expired one is
refused, an elevation never reaches requireAdmin, a wrong password is throttled
and audited without saying what was wrong, and the cookie flow is untouched.

The OpenAPI registry carries both new routes and names the accepting set in
prose, so the client implements against the contract rather than against a
description of it.
2026-07-19 12:47:04 +02:00