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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 14:29:34 +02:00
1 parent 0991d754e4
commit 4cd0adc46d
3 files changed
+198 -32

No files matched your search

+122 -22
View File
@@ -1605,11 +1605,18 @@ paths:
post:
tags:
- Auth
summary: Begin a passkey re-proof for a step-up elevation (Bearer only)
description: "Returns SimpleWebAuthn assertion options scoped to the calling account's primary passkeys, plus a
challenge id to present at POST /api/auth/step-up. Bearer-only: a cookie session is refused, because a browser
re-proves its factor at login and carries the result on its session row. Returns 409 when the account has no
passkey registered — use the password arm of the mint instead."
summary: Begin a WebAuthn re-proof for a step-up elevation (Bearer only)
description: "Returns SimpleWebAuthn assertion options plus a challenge id to present at POST /api/auth/step-up.
`method: \"passkey\"` scopes the assertion to the account's primary passkeys; `method: \"webauthn\"` to its
registered second-factor security keys. Bearer-only: a cookie session is refused, because a browser re-proves
its factor at login and carries the result on its session row. Returns 409 when the account has no credential of
the requested kind — fall back to another arm of the mint."
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/StepUpOptionsRequest"
responses:
"200":
description: Assertion options issued.
@@ -1632,26 +1639,34 @@ paths:
- Auth
summary: Mint a single-use step-up elevation (Bearer only)
description: >-
Re-prove the account password or a primary passkey and receive an opaque elevation that authorises exactly ONE
second-factor-management call.
Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call.
The elevation is the Bearer transport's equivalent of the web's fresh-second-factor session stamp. It is bound
to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use,
and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside
the normal `Authorization: Bearer` header.
WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie
session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable,
recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web
marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the
right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT
accepted here; an account that has lost its authenticator manages its second factor on the web.
The elevation is bound to the exact token that minted it (another token, including the same account's, cannot
redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as
`X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route
is about to act, so a 429, a 422, or a wrong code does not burn it.
Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong
password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge —
returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and
per source address.
password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a
replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per
account (5 / 15 min) and per source address.
Accepting routes (the complete set): GET /api/auth/me/mfa; POST /api/auth/me/mfa/totp/setup; POST
/api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate;
POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and
DELETE /api/auth/me/mfa/webauthn/{id}. Nothing else accepts one — admin endpoints stay cookie-only.
Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST
/api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST
/api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE
/api/auth/me/mfa/webauthn/{id}. The last three of those and disable require a fresh-factor proof. GET
/api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.
requestBody:
required: true
content:
@@ -1774,10 +1789,11 @@ paths:
get:
tags:
- Auth
summary: Second-factor status (cookie session or step-up elevation)
summary: Second-factor status (cookie session or Bearer token)
description: Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata
only — no secret, code, or public key. Accepts a cookie session, or a Bearer token presenting a single-use
elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.
only — no secret, no code, no public key, no credential id. Plain authentication (cookie session or
cookie-equivalent token); unlike every mutation on this surface it needs no step-up elevation, because the
payload carries no credential material.
responses:
"200":
description: Second-factor status.
@@ -11472,6 +11488,18 @@ components:
- code
- codeVerifier
description: Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle.
StepUpOptionsRequest:
type: object
properties:
method:
type: string
enum:
- passkey
- webauthn
description: "`passkey` begins an assertion against the account's primary passkeys; `webauthn` against its registered
second-factor security keys."
required:
- method
StepUpMintRequest:
oneOf:
- type: object
@@ -11486,6 +11514,60 @@ components:
required:
- method
- password
- type: object
properties:
method:
type: string
const: totp
code:
type: string
pattern: ^\d{6}$
required:
- method
- code
- type: object
properties:
method:
type: string
const: webauthn
challengeId:
type: string
minLength: 1
credential:
type: object
properties:
id:
type: string
rawId:
type: string
type:
type: string
const: public-key
response:
type: object
propertyNames:
type: string
additionalProperties: {}
authenticatorAttachment:
type: string
enum:
- platform
- cross-platform
clientExtensionResults:
type: object
propertyNames:
type: string
additionalProperties: {}
required:
- id
- rawId
- type
- response
description: SimpleWebAuthn ceremony response payload.
required:
- method
- challengeId
- credential
- type: object
properties:
method:
@@ -11529,7 +11611,10 @@ components:
- method
- challengeId
- credential
description: Re-prove the account password or a primary passkey to mint a single-use, token-bound step-up elevation.
description: Re-prove a factor to mint a single-use, token-bound step-up elevation. `password` reaches the same routes a
plain cookie session reaches; `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes
(disable, recovery-code rotation, security-key removal), matching exactly the ceremonies for which the web
stamps a session second-factor-verified.
type: object
MfaDisableRequest:
type: object
@@ -15473,10 +15558,25 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
method:
type: string
enum:
- password
- totp
- webauthn
- passkey
description: The factor that was re-proved.
satisfiesFreshFactor:
type: boolean
description: Whether this elevation reaches the fresh-factor routes (MFA disable, recovery-code regeneration,
security-key removal). False for a password proof — mirroring the web, where a password login never marks a
session second-factor-verified.
required:
- elevation
- expiresAt
- expiresInSeconds
- method
- satisfiesFreshFactor
additionalProperties: false
TotpSetupEnvelope:
type: object
+26 -10
View File
@@ -18,7 +18,10 @@ import {
mfaWebauthnLoginVerifySchema,
} from "@/lib/validations/mfa";
import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
import { stepUpMintSchema } from "@/lib/validations/step-up";
import {
stepUpMintSchema,
stepUpOptionsSchema,
} from "@/lib/validations/step-up";
import { dataEnvelope, stdResponses, errorEnvelope } from "./shared";
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
@@ -312,6 +315,14 @@ const stepUpMintResponse = z
),
expiresAt: z.iso.datetime({ offset: true }),
expiresInSeconds: z.number().int(),
method: z
.enum(["password", "totp", "webauthn", "passkey"])
.describe("The factor that was re-proved."),
satisfiesFreshFactor: z
.boolean()
.describe(
"Whether this elevation reaches the fresh-factor routes (MFA disable, recovery-code regeneration, security-key removal). False for a password proof — mirroring the web, where a password login never marks a session second-factor-verified.",
),
})
.meta({ id: "StepUpMintResponse" });
@@ -426,9 +437,14 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/step-up/options": {
post: {
tags: ["Auth"],
summary: "Begin a passkey re-proof for a step-up elevation (Bearer only)",
summary:
"Begin a WebAuthn re-proof for a step-up elevation (Bearer only)",
description:
"Returns SimpleWebAuthn assertion options scoped to the calling account's primary passkeys, plus a challenge id to present at POST /api/auth/step-up. Bearer-only: a cookie session is refused, because a browser re-proves its factor at login and carries the result on its session row. Returns 409 when the account has no passkey registered — use the password arm of the mint instead.",
'Returns SimpleWebAuthn assertion options plus a challenge id to present at POST /api/auth/step-up. `method: "passkey"` scopes the assertion to the account\'s primary passkeys; `method: "webauthn"` to its registered second-factor security keys. Bearer-only: a cookie session is refused, because a browser re-proves its factor at login and carries the result on its session row. Returns 409 when the account has no credential of the requested kind — fall back to another arm of the mint.',
requestBody: {
required: true,
content: { "application/json": { schema: stepUpOptionsSchema } },
},
responses: {
"200": {
description: "Assertion options issued.",
@@ -454,10 +470,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Mint a single-use step-up elevation (Bearer only)",
description:
"Re-prove the account password or a primary passkey and receive an opaque elevation that authorises exactly ONE second-factor-management call.\n\n" +
"The elevation is the Bearer transport's equivalent of the web's fresh-second-factor session stamp. It is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header.\n\n" +
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge — returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and per source address.\n\n" +
"Accepting routes (the complete set): GET /api/auth/me/mfa; POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}. Nothing else accepts one — admin endpoints stay cookie-only.",
"Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call.\n\n" +
"WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" +
"The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" +
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and per source address.\n\n" +
"Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}. The last three of those and disable require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.",
requestBody: {
required: true,
content: { "application/json": { schema: stepUpMintSchema } },
@@ -581,10 +598,9 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/me/mfa": {
get: {
tags: ["Auth"],
summary: "Second-factor status (cookie session or step-up elevation)",
summary: "Second-factor status (cookie session or Bearer token)",
description:
"Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, code, or public key." +
MFA_MANAGEMENT_AUTH_NOTE,
"Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, no code, no public key, no credential id. Plain authentication (cookie session or cookie-equivalent token); unlike every mutation on this surface it needs no step-up elevation, because the payload carries no credential material.",
responses: {
"200": {
description: "Second-factor status.",
@@ -817,6 +817,25 @@ describe("E10 — a password proof cannot reach the destructive routes", () => {
expect(await getPrismaClient().webauthnMfaCredential.count()).toBe(1);
});
it("refuses at the CLAIM too, not only at the validation", async () => {
// Defence in depth, and it needs its own test: the gate validates before it
// claims, so removing the check inside `claimStepUpElevation` leaves every
// route-level case above green. Driven straight at the primitive.
const { raw, id } = await mintToken("e10i");
useToken(raw);
const elevation = elevationOf(await mintElevation(PASSWORD));
const { claimStepUpElevation } = await import("@/lib/auth/step-up");
const claimed = await claimStepUpElevation({
rawToken: elevation,
userId: USER_ID,
apiTokenId: id,
requireFreshFactor: true,
});
expect(claimed).toEqual({ ok: false, reason: "insufficient_factor" });
});
it("does NOT burn the elevation when the factor is too weak", async () => {
// The refusal is about reach, not validity. The same elevation must still
// work on the routes it was always entitled to.
@@ -1035,6 +1054,37 @@ describe("E12 — a rejected request keeps the elevation spendable", () => {
expect(good.status).toBe(200);
});
it("IS spent once the route actually acts", async () => {
// The other half of the two-phase split. Deferring the claim buys nothing if
// the route then forgets to make it — the elevation would stay redeemable
// for the rest of its five minutes.
//
// Driven through recovery-code regeneration rather than disable: disable
// calls `destroyOtherSessions`, which deletes the account's elevations
// outright, so the row is gone afterwards either way and the assertion
// would pass whether or not the route committed. Regeneration leaves the
// row in place, so `consumedAt` is a real observation.
const secret = await enrolTotp();
const { raw } = await mintToken("e12c");
useToken(raw);
const elevation = elevationOf(await mintTotpElevation(secret));
useElevation(elevation);
const { POST } =
await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route");
const ok = await POST(
req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"),
);
expect(ok.status).toBe(200);
const row = await getPrismaClient().stepUpElevation.findFirst();
expect(row).not.toBeNull();
expect(row?.consumedAt).not.toBeNull();
// And the spent value opens nothing else.
expect((await callMfaSetup()).status).toBe(401);
});
it("survives a malformed body", async () => {
const { raw } = await mintToken("e12b");
useToken(raw);