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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 12:47:04 +02:00
1 parent e557d9e9e6
commit 433d983e60
4 files changed
+1059 -24

No files matched your search

@@ -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(");
});
});
+104 -11
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/api/auth/check-user": {
post: {
@@ -392,12 +423,65 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/auth/step-up/options": {
post: {
tags: ["Auth"],
summary: "Begin a passkey re-proof for a step-up elevation (Bearer only)",
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<ZodOpenApiObject["paths"]> = {
"/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<ZodOpenApiObject["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.",
"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<ZodOpenApiObject["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.",
"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<ZodOpenApiObject["paths"]> = {
"/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<ZodOpenApiObject["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.",
"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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["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.",
"Requires a fresh second-factor step-up." + MFA_MANAGEMENT_AUTH_NOTE,
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {