diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f3cf43e20..11374f413 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 2384a7ffd..ac408777c 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -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 = { "/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 = { 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 = { "/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.", diff --git a/tests/integration/step-up-elevation.test.ts b/tests/integration/step-up-elevation.test.ts index d62ac379e..7f528a4d8 100644 --- a/tests/integration/step-up-elevation.test.ts +++ b/tests/integration/step-up-elevation.test.ts @@ -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);