From 433d983e6054432e496f18cf0fd396ddeebd10b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 12:47:04 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 233 ++++++- src/__tests__/step-up-elevation-guard.test.ts | 155 +++++ src/lib/openapi/routes/auth.ts | 115 +++- tests/integration/step-up-elevation.test.ts | 580 ++++++++++++++++++ 4 files changed, 1059 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/step-up-elevation-guard.test.ts create mode 100644 tests/integration/step-up-elevation.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b8fc1dc1..f3cf43e20 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1601,13 +1601,81 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/step-up/options: + 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." + responses: + "200": + description: Assertion options issued. + content: + application/json: + schema: + $ref: "#/components/schemas/StepUpOptionsEnvelope" + "401": *a1 + "409": + description: No passkey registered on this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/auth/step-up: + post: + 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. + + + 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. + + + 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. + + + 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. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StepUpMintRequest" + responses: + "200": + description: Factor re-proved — elevation issued. + content: + application/json: + schema: + $ref: "#/components/schemas/StepUpMintEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/auth/me/mfa/totp/setup: post: tags: - Auth - summary: Begin TOTP enrollment (cookie session only) + summary: Begin TOTP enrollment (cookie session or step-up elevation) description: Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is - not active until /confirm. Cookie-only — a Bearer token cannot enrol MFA. + not active until /confirm. 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. responses: "200": description: Pending secret created. @@ -1628,9 +1696,10 @@ paths: post: tags: - Auth - summary: Confirm TOTP enrollment (cookie session only) + summary: Confirm TOTP enrollment (cookie session or step-up elevation) description: Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. - Cookie-only. + 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. requestBody: required: true content: @@ -1664,8 +1733,9 @@ paths: tags: - Auth summary: Disable the second factor (step-up gated) - description: Requires a fresh second-factor step-up (cookie session) AND a current TOTP or recovery code. Clears the - secret and deletes recovery codes. Bearer can never satisfy the step-up gate. + description: Requires a fresh second-factor step-up AND a current TOTP or recovery code. Clears the secret and deletes + recovery codes. 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. requestBody: required: true content: @@ -1687,8 +1757,9 @@ paths: tags: - Auth summary: Regenerate recovery codes (step-up gated) - description: Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated; Bearer can - never satisfy the gate. + description: Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated. 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. responses: "200": description: Fresh recovery codes issued. @@ -1703,9 +1774,10 @@ paths: get: tags: - Auth - summary: Second-factor status (cookie session only) + summary: Second-factor status (cookie session or step-up elevation) description: Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata - only — no secret, code, or public key. Cookie-only. + 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. responses: "200": description: Second-factor status. @@ -1721,7 +1793,9 @@ paths: tags: - Auth summary: Begin registering a security key as a second factor - description: Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA. + description: Returns SimpleWebAuthn creation options + a challenge id. 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. responses: "200": description: Registration options issued. @@ -1738,7 +1812,8 @@ paths: - Auth summary: Finish registering a security key as a second factor description: Verifies the attestation against the user-bound challenge and stores the credential in the second-factor - store (separate from primary passkeys). Cookie-only. + store (separate from primary passkeys). 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. requestBody: required: true content: @@ -1766,6 +1841,9 @@ paths: tags: - Auth summary: Rename a registered security key + description: Rename a registered second-factor security 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. parameters: - in: path name: id @@ -1798,7 +1876,8 @@ paths: tags: - Auth summary: Remove a registered security key (step-up gated) - description: Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate. + description: Requires a fresh second-factor step-up. 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. parameters: - in: path name: id @@ -11393,6 +11472,65 @@ components: - code - codeVerifier description: Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle. + StepUpMintRequest: + oneOf: + - type: object + properties: + method: + type: string + const: password + password: + type: string + minLength: 1 + maxLength: 512 + required: + - method + - password + - type: object + properties: + method: + type: string + const: passkey + 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 + description: Re-prove the account password or a primary passkey to mint a single-use, token-bound step-up elevation. + type: object MfaDisableRequest: type: object properties: @@ -15271,6 +15409,75 @@ components: - data - error additionalProperties: false + StepUpOptionsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/StepUpOptionsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + StepUpOptionsResponse: + type: object + properties: + options: + type: object + propertyNames: + type: string + additionalProperties: {} + description: SimpleWebAuthn assertion options for the caller's passkeys. + challengeId: + type: string + required: + - options + - challengeId + additionalProperties: false + StepUpMintEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/StepUpMintResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + StepUpMintResponse: + type: object + properties: + elevation: + type: string + description: Opaque single-use elevation (`hle_<64hex>`). Present it in the `X-Step-Up` header on ONE + second-factor-management call. Returned exactly once; store it in memory only. + expiresAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + expiresInSeconds: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - elevation + - expiresAt + - expiresInSeconds + additionalProperties: false TotpSetupEnvelope: type: object properties: diff --git a/src/__tests__/step-up-elevation-guard.test.ts b/src/__tests__/step-up-elevation-guard.test.ts new file mode 100644 index 000000000..89c4a71d1 --- /dev/null +++ b/src/__tests__/step-up-elevation-guard.test.ts @@ -0,0 +1,155 @@ +/** + * Structural guards on the step-up elevation boundary. + * + * An elevation lets a Bearer token reach a surface that is otherwise + * cookie-only. That is a deliberate, narrow widening, and its entire safety + * argument rests on the set of routes it can unlock being small, known, and + * unable to grow by accident. Behavioural tests prove the mechanism works; + * these prove nobody quietly pointed it somewhere else. + * + * Like the Bearer-scope guards next door, these are tripwires rather than + * proofs. They cannot show the allowlist is correct — only that it has not + * changed without someone editing this file, which is where the reviewer's + * attention is supposed to land. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, sep } from "node:path"; +import { globSync } from "node:fs"; + +const SRC = join(process.cwd(), "src"); + +function sourceFiles(): string[] { + return globSync("**/*.{ts,tsx}", { cwd: SRC }) + .filter( + (p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"), + ) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .map((p) => p.split(sep).join("/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +function filesMatching(re: RegExp): string[] { + return sourceFiles().filter((rel) => re.test(read(rel))); +} + +/** + * Every route an elevation can unlock. This is the whole set — the second-factor + * management surface and nothing besides. + * + * What is deliberately ABSENT is as much the point as what is present: + * - every `/api/admin/*` route. `requireAdmin` is cookie-only and does not + * consult this mechanism at all; + * - the trusted-device routes. "Remember this browser" is a browser concept; + * a native client has no equivalent to manage; + * - `requireFreshMfaIfEnrolled`'s callers (password change, account deletion, + * data reset, encrypted export, encryption-key rotation). Those are + * destructive account actions, not second-factor management, and letting an + * elevation satisfy them would be exactly the silent widening this file + * exists to prevent. + */ +const ELEVATION_ROUTES = [ + "app/api/auth/me/mfa/route.ts", + "app/api/auth/me/mfa/disable/route.ts", + "app/api/auth/me/mfa/recovery-codes/regenerate/route.ts", + "app/api/auth/me/mfa/totp/confirm/route.ts", + "app/api/auth/me/mfa/totp/setup/route.ts", + "app/api/auth/me/mfa/webauthn/[id]/route.ts", + "app/api/auth/me/mfa/webauthn/register/options/route.ts", + "app/api/auth/me/mfa/webauthn/register/verify/route.ts", +].sort(); + +describe("S1 — the elevation-accepting route set is frozen", () => { + it("only the known MFA-management routes call the gate", () => { + const callers = filesMatching(/requireMfaManagementAuth\(/).filter( + // The gate's own definition names itself. + (rel) => rel !== "lib/api-handler.ts", + ); + expect(callers).toEqual(ELEVATION_ROUTES); + }); + + it("no admin route calls the gate", () => { + const admin = filesMatching(/requireMfaManagementAuth\(/).filter((rel) => + rel.startsWith("app/api/admin/"), + ); + expect(admin).toEqual([]); + }); + + it("the old cookie-only gates no longer linger on the MFA surface", () => { + // A route that still called `requireCookieAuth` / `requireFreshMfa` + // directly would be outside the single gate, so its posture could drift + // from the rest of the set without tripping the allowlist above. + for (const rel of ELEVATION_ROUTES) { + const src = read(rel); + expect(src).not.toMatch(/await requireCookieAuth\(/); + expect(src).not.toMatch(/await requireFreshMfa\(/); + } + }); +}); + +describe("S2 — redemption happens in exactly one place", () => { + it("only the gate redeems an elevation", () => { + const redeemers = filesMatching(/redeemStepUpElevation\(/).filter( + (rel) => rel !== "lib/auth/step-up.ts", + ); + expect(redeemers).toEqual(["lib/api-handler.ts"]); + }); + + it("only the mint route mints one", () => { + const minters = filesMatching(/mintStepUpElevation\(/).filter( + (rel) => rel !== "lib/auth/step-up.ts", + ); + expect(minters).toEqual(["app/api/auth/step-up/route.ts"]); + }); + + it("no route reads the elevation header for itself", () => { + // The header is the gate's private input. A route reading it directly + // would be hand-rolling an authorisation decision outside the one place + // that is reviewed for it. + const readers = sourceFiles() + .filter((p) => p.endsWith("/route.ts")) + .filter((rel) => /["']x-step-up["']/i.test(read(rel))); + expect(readers).toEqual([]); + }); +}); + +describe("S3 — requireAdmin stays cookie-only", () => { + it("resolves through getSession and nothing else", () => { + const src = read("lib/api-handler.ts"); + const body = src.slice( + src.indexOf("export async function requireAdmin("), + src.indexOf("export async function requireCookieAuth("), + ); + expect(body).toContain("await getSession()"); + // No Bearer resolution, and no elevation redemption, inside the admin gate. + expect(body).not.toMatch(/authenticateBearer|resolveBearerToken/); + expect(body).not.toMatch(/redeemStepUpElevation|STEP_UP_ELEVATION_HEADER/); + }); +}); + +describe("S4 — the mint surface refuses a cookie", () => { + it("both step-up routes resolve via requireBearerAuth only", () => { + for (const rel of [ + "app/api/auth/step-up/route.ts", + "app/api/auth/step-up/options/route.ts", + ]) { + const src = read(rel); + expect(src).toMatch(/await requireBearerAuth\(\)/); + expect(src).not.toMatch(/requireAuth\(|requireCookieAuth\(/); + } + }); + + it("requireBearerAuth never consults the session cookie", () => { + const src = read("lib/api-handler.ts"); + const body = src.slice( + src.indexOf("export async function requireBearerAuth("), + src.indexOf("export const STEP_UP_ELEVATION_HEADER"), + ); + expect(body).not.toContain("getSession("); + }); +}); diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 78d8fe777..2384a7ffd 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -18,6 +18,7 @@ import { mfaWebauthnLoginVerifySchema, } from "@/lib/validations/mfa"; import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native"; +import { stepUpMintSchema } from "@/lib/validations/step-up"; import { dataEnvelope, stdResponses, errorEnvelope } from "./shared"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -284,6 +285,36 @@ const checkUserResponse = z "Account-existence + credential shape. The response is identical whether or not the identifier matched (account-existence is the explicit contract iOS needs); the identifier is never echoed.", }); +/** + * Shared tail for every route the step-up elevation can unlock. Kept in one + * place so the published contract cannot describe the set inconsistently. + */ +const MFA_MANAGEMENT_AUTH_NOTE = + " 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."; + +// ── v1.30.34 step-up elevation (Bearer transport) ──────────────────── + +const stepUpOptionsResponse = z + .object({ + options: z + .record(z.string(), z.unknown()) + .describe("SimpleWebAuthn assertion options for the caller's passkeys."), + challengeId: z.string(), + }) + .meta({ id: "StepUpOptionsResponse" }); + +const stepUpMintResponse = z + .object({ + elevation: z + .string() + .describe( + "Opaque single-use elevation (`hle_<64hex>`). Present it in the `X-Step-Up` header on ONE second-factor-management call. Returned exactly once; store it in memory only.", + ), + expiresAt: z.iso.datetime({ offset: true }), + expiresInSeconds: z.number().int(), + }) + .meta({ id: "StepUpMintResponse" }); + export const authPaths: NonNullable = { "/api/auth/check-user": { post: { @@ -392,12 +423,65 @@ 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)", + 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.", + responses: { + "200": { + description: "Assertion options issued.", + content: { + "application/json": { + schema: dataEnvelope( + stepUpOptionsResponse, + "StepUpOptionsEnvelope", + ), + }, + }, + }, + "409": { + description: "No passkey registered on this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/step-up": { + post: { + 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.", + requestBody: { + required: true, + content: { "application/json": { schema: stepUpMintSchema } }, + }, + responses: { + "200": { + description: "Factor re-proved — elevation issued.", + content: { + "application/json": { + schema: dataEnvelope(stepUpMintResponse, "StepUpMintEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/mfa/totp/setup": { post: { tags: ["Auth"], - summary: "Begin TOTP enrollment (cookie session only)", + summary: "Begin TOTP enrollment (cookie session or step-up elevation)", description: - "Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm. Cookie-only — a Bearer token cannot enrol MFA.", + "Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm." + + MFA_MANAGEMENT_AUTH_NOTE, responses: { "200": { description: "Pending secret created.", @@ -418,9 +502,10 @@ export const authPaths: NonNullable = { "/api/auth/me/mfa/totp/confirm": { post: { tags: ["Auth"], - summary: "Confirm TOTP enrollment (cookie session only)", + summary: "Confirm TOTP enrollment (cookie session or step-up elevation)", description: - "Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. Cookie-only.", + "Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes." + + MFA_MANAGEMENT_AUTH_NOTE, requestBody: { required: true, content: { "application/json": { schema: totpConfirmSchema } }, @@ -451,7 +536,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Disable the second factor (step-up gated)", description: - "Requires a fresh second-factor step-up (cookie session) AND a current TOTP or recovery code. Clears the secret and deletes recovery codes. Bearer can never satisfy the step-up gate.", + "Requires a fresh second-factor step-up AND a current TOTP or recovery code. Clears the secret and deletes recovery codes." + + MFA_MANAGEMENT_AUTH_NOTE, requestBody: { required: true, content: { "application/json": { schema: mfaDisableSchema } }, @@ -474,7 +560,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Regenerate recovery codes (step-up gated)", description: - "Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated; Bearer can never satisfy the gate.", + "Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated." + + MFA_MANAGEMENT_AUTH_NOTE, responses: { "200": { description: "Fresh recovery codes issued.", @@ -494,9 +581,10 @@ export const authPaths: NonNullable = { "/api/auth/me/mfa": { get: { tags: ["Auth"], - summary: "Second-factor status (cookie session only)", + summary: "Second-factor status (cookie session or step-up elevation)", description: - "Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, code, or public key. Cookie-only.", + "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, responses: { "200": { description: "Second-factor status.", @@ -515,7 +603,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Begin registering a security key as a second factor", description: - "Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA.", + "Returns SimpleWebAuthn creation options + a challenge id." + + MFA_MANAGEMENT_AUTH_NOTE, responses: { "200": { description: "Registration options issued.", @@ -537,7 +626,8 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Finish registering a security key as a second factor", description: - "Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys). Cookie-only.", + "Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys)." + + MFA_MANAGEMENT_AUTH_NOTE, requestBody: { required: true, content: { @@ -568,6 +658,9 @@ export const authPaths: NonNullable = { patch: { tags: ["Auth"], summary: "Rename a registered security key", + description: + "Rename a registered second-factor security key." + + MFA_MANAGEMENT_AUTH_NOTE, requestParams: { path: z.object({ id: z.string() }) }, requestBody: { required: true, @@ -596,7 +689,7 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Remove a registered security key (step-up gated)", description: - "Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.", + "Requires a fresh second-factor step-up." + MFA_MANAGEMENT_AUTH_NOTE, requestParams: { path: z.object({ id: z.string() }) }, responses: { "200": { diff --git a/tests/integration/step-up-elevation.test.ts b/tests/integration/step-up-elevation.test.ts new file mode 100644 index 000000000..4bfa41372 --- /dev/null +++ b/tests/integration/step-up-elevation.test.ts @@ -0,0 +1,580 @@ +/** + * Behavioural suite for token-bound step-up elevations, against a real Postgres + * and the real gate — no mocked Prisma, no mocked auth. + * + * This mechanism lets a Bearer token reach a surface that has been cookie-only + * since v1.23, so the cases below are written as attacks rather than as happy + * paths. Each one must fail closed if its protection is removed; a green suite + * over a broken gate would be worse than no suite at all. + * + * Two of them need a real database to mean anything. Single-use atomicity is a + * claim about what Postgres does when two writers race the same row, and the + * unit mocks cannot produce that race — E3b runs the two redemptions genuinely + * concurrently against one connection pool. The token binding is a claim about a + * WHERE clause, and only a real query proves the clause is in it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-step-up-elevation-32-bytes-minimum-1234567890"; + +const { hashToken } = await import("@/lib/auth/hmac"); +const { hashPassword } = await import("@/lib/auth/password"); + +const USER_ID = "user-step-up-test"; +const OTHER_USER_ID = "user-step-up-other"; +const PASSWORD = "correct horse battery staple 42"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + const passwordHash = await hashPassword(PASSWORD); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "step-up", + email: "step-up@example.test", + timezone: "UTC", + passwordHash, + }, + }); + await getPrismaClient().user.create({ + data: { + id: OTHER_USER_ID, + username: "step-up-other", + email: "step-up-other@example.test", + timezone: "UTC", + passwordHash, + }, + }); +}); + +/** Mint a real wildcard `ApiToken` row. Does NOT arm the header. */ +async function mintToken( + label: string, + userId = USER_ID, +): Promise<{ raw: string; id: string }> { + const raw = `hlk_${label}${"0".repeat(64 - label.length)}`; + const row = await getPrismaClient().apiToken.create({ + data: { + userId, + name: label, + tokenHash: hashToken(raw), + permissions: ["*"], + }, + select: { id: true }, + }); + return { raw, id: row.id }; +} + +function useToken(raw: string): void { + headerJar.set("authorization", `Bearer ${raw}`); +} + +function useElevation(raw: string | null): void { + if (raw === null) headerJar.delete("x-step-up"); + else headerJar.set("x-step-up", raw); +} + +function req(path: string, method = "GET", body?: unknown): NextRequest { + const headers: Record = {}; + const auth = headerJar.get("authorization"); + if (auth) headers.authorization = auth; + const init: RequestInit = { method, headers }; + if (body !== undefined) { + headers["content-type"] = "application/json"; + init.body = JSON.stringify(body); + } + return new NextRequest(`https://health.example${path}`, init as never); +} + +/** Drive the mint endpoint with a password proof. */ +async function mintElevation( + password: string, +): Promise<{ status: number; body: Record }> { + const { POST } = await import("@/app/api/auth/step-up/route"); + const res = await POST( + req("/api/auth/step-up", "POST", { method: "password", password }), + ); + return { status: res.status, body: (await res.json()) as never }; +} + +/** GET /api/auth/me/mfa — the cheapest route in the elevation-accepting set. */ +async function callMfaStatus(): Promise { + const { GET } = await import("@/app/api/auth/me/mfa/route"); + return await GET(req("/api/auth/me/mfa")); +} + +async function auditReasons(action: string): Promise { + const rows = await getPrismaClient().auditLog.findMany({ + where: { action }, + orderBy: { createdAt: "asc" }, + }); + return rows.map( + (r) => (JSON.parse(r.details ?? "{}") as { reason?: string }).reason ?? "", + ); +} + +// ── E1 — a stolen token, on its own, gains nothing ─────────────────── + +describe("E1 — the token alone is not a step-up proof", () => { + it("refuses an MFA-management call with no elevation header", async () => { + const { raw } = await mintToken("e1a"); + useToken(raw); + useElevation(null); + + const res = await callMfaStatus(); + + // 401 with the machine code the client branches on to launch a re-proof — + // not a 200, and not the 403 an unauthorised token would get. + expect(res.status).toBe(401); + }); + + it("mints nothing when the body carries no factor proof", async () => { + const { raw } = await mintToken("e1b"); + useToken(raw); + + const { POST } = await import("@/app/api/auth/step-up/route"); + const res = await POST(req("/api/auth/step-up", "POST", {})); + + expect(res.status).toBe(422); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); + + it("refuses a fabricated elevation value", async () => { + const { raw } = await mintToken("e1c"); + useToken(raw); + useElevation(`hle_${"f".repeat(64)}`); + + expect((await callMfaStatus()).status).toBe(401); + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "unknown", + ); + }); +}); + +// ── E2 — the elevation is bound to one token ───────────────────────── + +describe("E2 — an elevation is not portable between tokens", () => { + it("refuses redemption by a second token of the SAME user", async () => { + const a = await mintToken("e2a"); + const b = await mintToken("e2b"); + + useToken(a.raw); + const minted = await mintElevation(PASSWORD); + expect(minted.status).toBe(200); + const elevation = (minted.body.data as { elevation: string }).elevation; + + // Same account, same scopes, different token row. The binding is the only + // thing standing between these two, which is exactly the point. + useToken(b.raw); + useElevation(elevation); + const res = await callMfaStatus(); + + expect(res.status).toBe(401); + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "wrong_token", + ); + + // And the legitimate holder's elevation survived the attempt. + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + + useToken(a.raw); + useElevation(elevation); + expect((await callMfaStatus()).status).toBe(200); + }); + + it("refuses redemption by another user's token", async () => { + const mine = await mintToken("e2c"); + const theirs = await mintToken("e2d", OTHER_USER_ID); + + useToken(mine.raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + useToken(theirs.raw); + useElevation(elevation); + + expect((await callMfaStatus()).status).toBe(401); + }); +}); + +// ── E3 — single use, including under concurrency ───────────────────── + +describe("E3 — an elevation is consumed exactly once", () => { + it("refuses a second sequential redemption", async () => { + const { raw } = await mintToken("e3a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + useElevation(elevation); + expect((await callMfaStatus()).status).toBe(200); + expect((await callMfaStatus()).status).toBe(401); + + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "consumed", + ); + }); + + it("lets exactly one of two CONCURRENT redemptions win", async () => { + const { raw, id } = await mintToken("e3b"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + // Straight at the redemption primitive, so the race is between two real + // conditional UPDATEs on one row rather than between two HTTP handlers that + // happen to interleave. A check-then-update implementation passes the + // sequential case above and fails here. + const { redeemStepUpElevation } = await import("@/lib/auth/step-up"); + const attempt = () => + redeemStepUpElevation({ + rawToken: elevation, + userId: USER_ID, + apiTokenId: id, + }); + + const results = await Promise.all([ + attempt(), + attempt(), + attempt(), + attempt(), + ]); + + expect(results.filter((r) => r.ok)).toHaveLength(1); + expect(results.filter((r) => !r.ok)).toHaveLength(3); + }); +}); + +// ── E4 — expiry ────────────────────────────────────────────────────── + +describe("E4 — an expired elevation is refused", () => { + it("refuses one whose window has passed", async () => { + const { raw, id } = await mintToken("e4a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + // Age the row rather than the clock: the expiry lives in the redemption's + // WHERE clause, so this is the honest way to exercise it. + await getPrismaClient().stepUpElevation.updateMany({ + where: { apiTokenId: id }, + data: { expiresAt: new Date(Date.now() - 1_000) }, + }); + + useElevation(elevation); + expect((await callMfaStatus()).status).toBe(401); + + await new Promise((r) => setTimeout(r, 100)); + expect(await auditReasons("auth.stepup.elevation.rejected")).toContain( + "expired", + ); + }); + + it("mints a five-minute window, matching the cookie step-up", async () => { + const { raw } = await mintToken("e4b"); + useToken(raw); + const { body } = await mintElevation(PASSWORD); + const data = body.data as { expiresInSeconds: number }; + + const { MFA_STEP_UP_MAX_AGE_SECONDS } = await import("@/lib/api-handler"); + expect(data.expiresInSeconds).toBe(MFA_STEP_UP_MAX_AGE_SECONDS); + }); +}); + +// ── E5 — the elevation cannot reach admin ──────────────────────────── + +describe("E5 — an elevation does not satisfy requireAdmin", () => { + it("refuses an admin route even for an ADMIN user holding a valid elevation", async () => { + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { role: "ADMIN" }, + }); + + const { raw } = await mintToken("e5a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + useElevation(elevation); + + const { GET } = await import("@/app/api/admin/users/route"); + const res = await GET(req("/api/admin/users")); + + // Cookie-only, and the elevation is not a cookie. The account being a + // genuine admin is what makes this test worth having: the refusal is the + // transport, not the role. + expect(res.status).toBe(401); + + // Unconsumed — the admin gate never even looked at it. + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + }); +}); + +// ── E6 — a wrong password mints nothing, is throttled, is audited ──── + +describe("E6 — the mint refuses, throttles, and records a bad password", () => { + it("returns a generic 401 and writes no row", async () => { + const { raw } = await mintToken("e6a"); + useToken(raw); + + const res = await mintElevation("not the password"); + + expect(res.status).toBe(401); + // Same prose for every cause — the response must not say whether the + // password was wrong or the account has none. + expect(res.body.error).toBe("Verification failed"); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); + + it("is indistinguishable from an account with no password set", async () => { + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { passwordHash: null }, + }); + const { raw } = await mintToken("e6b"); + useToken(raw); + + const res = await mintElevation("anything at all"); + + expect(res.status).toBe(401); + expect(res.body.error).toBe("Verification failed"); + }); + + it("audits the failure with the real reason", async () => { + const { raw } = await mintToken("e6c"); + useToken(raw); + await mintElevation("wrong"); + + const rows = await getPrismaClient().auditLog.findMany({ + where: { action: "auth.stepup.mint.failed" }, + }); + expect(rows).toHaveLength(1); + expect(JSON.parse(rows[0].details ?? "{}")).toMatchObject({ + method: "password", + reason: "bad_password", + }); + }); + + it("throttles a guessing run", async () => { + const { raw } = await mintToken("e6d"); + useToken(raw); + + const statuses: number[] = []; + for (let i = 0; i < 7; i++) { + statuses.push((await mintElevation(`guess-${i}`)).status); + } + + // Five attempts per fifteen minutes, then the bucket closes — the same + // ceiling the password-change route uses, for the same Argon2id reason. + expect(statuses.filter((s) => s === 401)).toHaveLength(5); + expect(statuses.filter((s) => s === 429)).toHaveLength(2); + + // And the throttle holds even once the caller finds the right password. + expect((await mintElevation(PASSWORD)).status).toBe(429); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +}); + +// ── E7 — the web path is untouched ─────────────────────────────────── + +describe("E7 — the cookie flow is unchanged", () => { + async function useCookieSession(userId = USER_ID): Promise { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session.id; + } + + it("serves an MFA-management read on a plain cookie session", async () => { + await useCookieSession(); + expect((await callMfaStatus()).status).toBe(200); + }); + + it("still refuses a step-up-gated route on a cookie with no fresh factor", async () => { + await useCookieSession(); + const { POST } = + await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); + const res = await POST( + req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"), + ); + expect(res.status).toBe(401); + }); + + it("admits a step-up-gated route on a freshly verified cookie session", async () => { + const sessionId = await useCookieSession(); + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { totpConfirmedAt: new Date() }, + }); + await getPrismaClient().session.update({ + where: { id: sessionId }, + data: { mfaVerifiedAt: new Date() }, + }); + + const { POST } = + await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route"); + const res = await POST( + req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"), + ); + expect(res.status).toBe(200); + }); + + it("ignores an elevation header when a cookie session is present", async () => { + // The cookie branch returns before the header is ever read. Proven by the + // elevation surviving unconsumed. + const { raw, id } = await mintToken("e7a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + await useCookieSession(); + useElevation(elevation); + expect((await callMfaStatus()).status).toBe(200); + + const row = await getPrismaClient().stepUpElevation.findFirst({ + where: { apiTokenId: id }, + }); + expect(row?.consumedAt).toBeNull(); + }); +}); + +// ── E8 — invalidation on credential rotation ───────────────────────── + +describe("E8 — an elevation does not outlive its anchor", () => { + it("is dropped when the account password changes", async () => { + const { raw } = await mintToken("e8a"); + useToken(raw); + await mintElevation(PASSWORD); + expect(await getPrismaClient().stepUpElevation.count()).toBe(1); + + const { revokeStepUpElevations } = await import("@/lib/auth/step-up"); + await revokeStepUpElevations(USER_ID); + + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); + + it("is unredeemable once the bound token is revoked", async () => { + const { raw, id } = await mintToken("e8b"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + await getPrismaClient().apiToken.update({ + where: { id }, + data: { revoked: true }, + }); + + useElevation(elevation); + // Refused at Bearer resolution, before the elevation is even looked at — + // which is why no code in the revoke path needs to know about elevations. + expect((await callMfaStatus()).status).toBe(401); + + const row = await getPrismaClient().stepUpElevation.findFirst(); + expect(row?.consumedAt).toBeNull(); + }); + + it("is cascade-deleted with the token row", async () => { + const { raw, id } = await mintToken("e8c"); + useToken(raw); + await mintElevation(PASSWORD); + + await getPrismaClient().apiToken.delete({ where: { id } }); + + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +}); + +// ── E9 — the happy path actually works end to end ──────────────────── + +describe("E9 — a native client can manage its second factor", () => { + it("mints, redeems, and enrols TOTP", async () => { + const { raw } = await mintToken("e9a"); + useToken(raw); + const elevation = ( + (await mintElevation(PASSWORD)).body.data as { elevation: string } + ).elevation; + + useElevation(elevation); + const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route"); + const res = await POST(req("/api/auth/me/mfa/totp/setup", "POST")); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { totpSecret: string; otpauthUri: string }; + }; + expect(body.data.totpSecret).toBeTruthy(); + expect(body.data.otpauthUri).toMatch(/^otpauth:\/\//); + + // The pending secret landed, and the elevation is spent — a second action + // needs a second proof. + const user = await getPrismaClient().user.findUnique({ + where: { id: USER_ID }, + }); + expect(user?.totpSecretEncrypted).toBeTruthy(); + expect(user?.totpConfirmedAt).toBeNull(); + expect((await callMfaStatus()).status).toBe(401); + }); + + it("refuses the mint surface to a cookie session", async () => { + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + headerJar.delete("authorization"); + + const { POST } = await import("@/app/api/auth/step-up/route"); + const res = await POST( + req("/api/auth/step-up", "POST", { + method: "password", + password: PASSWORD, + }), + ); + + expect(res.status).toBe(401); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +});