Gate passkey removal on the same step-up as second-factor removal

DELETE /api/auth/passkeys/{id} took a plain cookie session or a wildcard
Bearer, while DELETE /api/auth/me/mfa/webauthn/{id} beside it demanded a
fresh factor proof. The softer gate sat on the primary sign-in credential:
the last-method check stops an account locking itself out, but nothing
stopped a hijacked session stripping the credentials the owner signs in
with.

The route now goes through requireMfaManagementAuth, the same gate the
security-key removal uses, in a new "if-enrolled" mode. An account with a
second factor pays the full price on either transport — a freshly verified
cookie session, or a factor-proved X-Step-Up elevation on Bearer, so the
native client keeps the capability rather than being sent to the website.
An account with no second factor keeps the contract it had, because a gate
it can never satisfy is not a safeguard but a locked door in front of its
own credential list.

The elevation is spent immediately before the delete, so a foreign id or a
refused last credential does not burn a proof that then has to be minted
again. The route is added to the elevation allowlist with its reason, and
the either-factor enrolment test that was written out three times in the
API kit is now one function.
This commit is contained in:
Marc-André Bombeck committed 2026-08-23 16:55:59 +02:00
1 parent fffdc83ca4
commit e3be71f0e5
7 files changed
+392 -42

No files matched your search

+19 -4
View File
@@ -2581,9 +2581,17 @@ paths:
the account has no password — an account must keep at least one way in.
No step-up: this endpoint takes a plain cookie session or a wildcard Bearer token, where removing a
second-factor security key (DELETE /api/auth/me/mfa/webauthn/{id}) requires a fresh factor proof. The asymmetry
is in the code as it stands, not a description of intent.
Step-up gated for accounts that can clear the gate, on the same mechanism as removing a second-factor security
key (DELETE /api/auth/me/mfa/webauthn/{id}). An account with a second factor enrolled — a confirmed TOTP secret
or a registered security key — must present a fresh factor proof: a cookie session that completed one within the
last five minutes, or, on Bearer, an `X-Step-Up` elevation minted at POST /api/auth/step-up against a re-proved
factor (a password-proved elevation is NOT enough). An account with no second factor enrolled is unaffected and
keeps the plain session-or-token contract, because a gate it could never satisfy would lock it out of its own
credential list.
The elevation is spent only when the deletion is about to happen. A 404 for an unknown id, or the
last-credential refusal, leaves it unconsumed and reusable.
parameters:
- in: path
name: id
@@ -2604,7 +2612,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
"401":
description: Not authenticated, or the account has a second factor and the request carried no proof of it fresh enough
(`meta.errorCode` = `auth.stepup.required`). Re-prove the factor and retry — on the web by re-verifying, on
a native client by minting an elevation at POST /api/auth/step-up. Nothing was removed.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"404":
description: No such passkey for this account.
content: