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); + }); +});