diff --git a/apps/web/app/access/mfa/form.test.tsx b/apps/web/app/access/mfa/form.test.tsx new file mode 100644 index 00000000..6d897620 --- /dev/null +++ b/apps/web/app/access/mfa/form.test.tsx @@ -0,0 +1,166 @@ +import { StrictMode } from "react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { MfaForm } from "./form"; +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); +const ready = () => + Response.json({ proof: "bound-proof", validForMs: 300_000 }); +it("enables code entry only after preparation, preserving the native form binding", async () => { + let complete: ((value: Response) => void) | undefined; + const request = vi.fn( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + vi.stubGlobal("fetch", request); + const { container } = render(); + expect(screen.getByLabelText(/code/i)).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Verify and continue" }), + ).toBeDisabled(); + expect(request).toHaveBeenCalledWith( + "/access/mfa/prepare", + expect.objectContaining({ + method: "POST", + body: "", + credentials: "same-origin", + }), + ); + act(() => { + complete?.(ready()); + }); + await waitFor(() => expect(screen.getByLabelText(/code/i)).toBeEnabled()); + expect(container.querySelector('input[name="proof"]')).toHaveValue( + "bound-proof", + ); + expect(container.querySelector('input[name="returnTo"]')).toHaveValue( + "/internal/team", + ); + expect(container.querySelector("form")).toHaveAttribute( + "action", + "/access/mfa/verify", + ); +}); +it("does not spend a second preparation during Strict Mode effect replay", async () => { + const request = vi.fn().mockResolvedValue(ready()); + vi.stubGlobal("fetch", request); + render( + + + , + ); + await waitFor(() => expect(screen.getByLabelText(/code/i)).toBeEnabled()); + expect(request).toHaveBeenCalledOnce(); +}); +it("keeps entry disabled when preparation is limited and offers a manual restart", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue(Response.json({ error: "limited" }, { status: 429 })), + ); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Too many attempts", + ); + expect(screen.getByLabelText(/code/i)).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Prepare a new verification" }), + ).toBeEnabled(); +}); +it("prevents duplicate native submissions without dropping the code field", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(ready())); + const { container } = render(); + await waitFor(() => expect(screen.getByLabelText(/code/i)).toBeEnabled()); + const form = container.querySelector("form"); + if (!form) throw new Error("Expected form"); + fireEvent.change(screen.getByLabelText(/code/i), { + target: { value: "123456" }, + }); + expect(fireEvent.submit(form)).toBe(true); + expect(screen.getByLabelText(/code/i)).not.toBeDisabled(); + expect(screen.getByLabelText(/code/i)).toHaveAttribute("readonly"); + expect(fireEvent.submit(form)).toBe(false); + expect(new FormData(form).get("code")).toBe("123456"); +}); +it("clears an expired preparation without guessing the authenticator countdown", async () => { + vi.useFakeTimers(); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + Response.json({ proof: "bound-proof", validForMs: 1000 }), + ), + ); + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(screen.getByLabelText(/code/i)).toBeEnabled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(screen.getByLabelText(/code/i)).toBeDisabled(); + expect(screen.getByRole("alert")).toHaveTextContent("expired"); +}); + +it("preserves the expired-session explanation when preparation fails", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue(Response.json({ error: "expired" }, { status: 503 })), + ); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Your session expired", + ); + expect(screen.getByLabelText(/code/i)).toBeDisabled(); +}); +it("clears an old preparation error after a fresh challenge is ready", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(ready())); + render(); + await waitFor(() => expect(screen.getByLabelText(/code/i)).toBeEnabled()); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); +}); +it("bounds a stalled preparation and offers recovery", async () => { + const controller = new AbortController(); + const timeout = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValue(controller.signal); + vi.stubGlobal( + "fetch", + vi.fn( + (_url, options: RequestInit) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener("abort", () => + reject(new Error("timeout")), + ); + }), + ), + ); + try { + render(); + expect(timeout).toHaveBeenCalledWith(20_000); + act(() => controller.abort()); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Verification is unavailable", + ); + expect( + screen.getByRole("button", { name: "Prepare a new verification" }), + ).toBeEnabled(); + } finally { + timeout.mockRestore(); + } +}); diff --git a/apps/web/app/access/mfa/form.tsx b/apps/web/app/access/mfa/form.tsx new file mode 100644 index 00000000..3807f5fc --- /dev/null +++ b/apps/web/app/access/mfa/form.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { Button, Input } from "@clockwork/ui"; +import { useTranslations } from "@/src/i18n/client"; + +export function MfaForm({ + returnTo, + error, +}: { + returnTo?: string | undefined; + error?: string | undefined; +}) { + const t = useTranslations(); + const preparation = useRef | null>(null); + const submitting = useRef(false); + const [proof, setProof] = useState(""); + const [problem, setProblem] = useState(error); + const [preparing, setPreparing] = useState(true); + const [pending, setPending] = useState(false); + + useEffect(() => { + // Share the request across Strict Mode's effect replay, not across mounts + // or sessions. A link prefetch/server render never creates a challenge. + preparation.current ??= fetch("/access/mfa/prepare", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "", + cache: "no-store", + credentials: "same-origin", + signal: AbortSignal.timeout(20_000), + }); + let active = true; + let timer: ReturnType | undefined; + void preparation.current + .then(async (response) => { + const value: unknown = await response.clone().json(); + if (!active) return; + if (!value || typeof value !== "object") + throw new Error("Invalid preparation"); + if ( + !response.ok || + !("proof" in value) || + typeof value.proof !== "string" || + !("validForMs" in value) || + typeof value.validForMs !== "number" || + value.validForMs <= 0 || + value.validForMs > 300_000 + ) { + setProblem( + "error" in value && value.error === "limited" + ? "limited" + : "error" in value && value.error === "expired" + ? "expired" + : "unavailable", + ); + return; + } + setProof(value.proof); + setProblem((current) => + current === "preparation" ? undefined : current, + ); + timer = setTimeout(() => { + if (active) { + setProof(""); + setProblem("preparation"); + } + }, value.validForMs); + }) + .catch(() => { + if (active) setProblem("unavailable"); + }) + .finally(() => { + if (active) setPreparing(false); + }); + return () => { + active = false; + if (timer) clearTimeout(timer); + }; + }, []); + + const errorKey = + problem === "limited" + ? "platform.mfa.error.limited" + : problem === "invalid" + ? "platform.mfa.error.invalid" + : problem === "expired" + ? "platform.mfa.error.expired" + : problem === "preparation" + ? "platform.mfa.error.preparation" + : "platform.mfa.error.unavailable"; + return ( +
{ + if (!proof || submitting.current) { + event.preventDefault(); + return; + } + submitting.current = true; + setPending(true); + }} + > + {returnTo && } + +

{t("platform.mfa.prompt")}

+ {preparing &&

{t("platform.mfa.preparing")}

} + {problem &&

{t(errorKey)}

} + + + {!preparing && !proof && ( + + )} + {t("session.expired.action")} +
+ ); +} diff --git a/apps/web/app/access/mfa/page.test.tsx b/apps/web/app/access/mfa/page.test.tsx index 48e4ae2e..d61d946a 100644 --- a/apps/web/app/access/mfa/page.test.tsx +++ b/apps/web/app/access/mfa/page.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@testing-library/react"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import Page from "./page"; @@ -8,7 +8,16 @@ vi.mock("@/src/i18n/server", async () => { return { getTranslations: () => Promise.resolve(translatorFor("en")) }; }); -afterEach(() => vi.unstubAllEnvs()); +beforeEach(() => + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ), +); +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); async function renderWith(error?: string) { vi.stubEnv("WORKOS_CLIENT_ID", "client_test"); diff --git a/apps/web/app/access/mfa/page.tsx b/apps/web/app/access/mfa/page.tsx index 872731c4..d66e0ae1 100644 --- a/apps/web/app/access/mfa/page.tsx +++ b/apps/web/app/access/mfa/page.tsx @@ -2,10 +2,11 @@ import { getTranslations } from "@/src/i18n/server"; import type { Metadata } from "next"; import Link from "next/link"; -import { BrandLogo, Button, Input, buttonClassName } from "@clockwork/ui"; +import { BrandLogo, buttonClassName } from "@clockwork/ui"; import { brandAsset } from "@/src/features/shell/brand-assets"; import { mfaReturnTo } from "./return-to"; +import { MfaForm } from "./form"; export async function generateMetadata(): Promise { const t = await getTranslations(); @@ -36,40 +37,10 @@ export default async function Page({

{t("session.mfa.title")}

{t("session.mfa.description")}

{process.env.WORKOS_CLIENT_ID ? ( -
- {returnTo && ( - - )} -

{t("platform.mfa.prompt")}

- {error && ( -

- {t( - error === "limited" - ? "platform.mfa.error.limited" - : error === "invalid" - ? "platform.mfa.error.invalid" - : error === "expired" - ? "platform.mfa.error.expired" - : "platform.mfa.error.unavailable", - )} -

- )} - - - {t("session.expired.action")} -
+ ) : ( ({ + session: vi.fn(), + claim: vi.fn(), + factors: vi.fn(), + challenge: vi.fn(), + pages: vi.fn(), +})); +vi.mock("@clockwork/db", () => ({ claimMfaPreparationAttempt: mocks.claim })); +vi.mock("@/src/auth/session", () => ({ + getVerifiedWorkosSession: mocks.session, + SessionExpiredError: class SessionExpiredError extends Error {}, +})); +vi.mock("@/src/db/service", () => ({ getServiceDatabase: () => ({}) })); +vi.mock("@workos-inc/authkit-nextjs", () => ({ + getWorkOS: () => ({ + multiFactorAuth: { + listUserAuthFactors: mocks.factors, + challengeFactor: mocks.challenge, + }, + }), +})); +import { POST } from "./route"; +import { readMfaProof } from "../prepared-challenge"; +const binding = { + sessionId: "session", + workosUserId: "user", + workosOrganizationId: "org", +}; +function request(origin = "https://example.com") { + return new Request("https://example.com/access/mfa/prepare", { + method: "POST", + headers: { origin, "content-type": "application/x-www-form-urlencoded" }, + body: "", + }); +} +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv("APP_ORIGIN", "https://example.com"); + vi.stubEnv( + "WORKOS_COOKIE_PASSWORD", + "test-cookie-key-with-at-least-32-bytes", + ); + mocks.session.mockResolvedValue({ + sessionId: "session", + organizationId: "org", + user: { id: "user" }, + }); + mocks.claim.mockResolvedValue(true); + mocks.pages.mockResolvedValue([ + { id: "factor", type: "totp", userId: "user" }, + ]); + mocks.factors.mockResolvedValue({ autoPagination: mocks.pages }); + mocks.challenge.mockResolvedValue({ + id: "challenge", + authenticationFactorId: "factor", + code: "never-expose", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); +}); +it("prepares an owned factor using every page without returning a provider code", async () => { + const response = await POST(request()); + const body = (await response.json()) as { proof: string; validForMs: number }; + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(readMfaProof(body.proof, binding)).toMatchObject({ + ...binding, + factorId: "factor", + challengeId: "challenge", + }); + expect(JSON.stringify(body)).not.toContain("never-expose"); + expect(mocks.pages).toHaveBeenCalledOnce(); + expect(mocks.claim).toHaveBeenCalledWith({}, "user"); +}); +it("refuses cross-origin preparation before session/provider calls", async () => { + expect((await POST(request("https://other.example"))).status).toBe(403); + expect(mocks.session).not.toHaveBeenCalled(); +}); +it("bounds preparation separately before provider requests", async () => { + mocks.claim.mockResolvedValue(false); + expect((await POST(request())).status).toBe(429); + expect(mocks.factors).not.toHaveBeenCalled(); +}); +it.each( + [ + [], + [{ id: "factor", type: "totp", userId: "other" }], + [ + { id: "a", type: "totp", userId: "user" }, + { id: "b", type: "totp", userId: "user" }, + ], + ].map((factors) => ({ factors })), +)("rejects unavailable or ambiguous factor ownership", async ({ factors }) => { + mocks.pages.mockResolvedValue(factors); + expect((await POST(request())).status).toBe(409); + expect(mocks.challenge).not.toHaveBeenCalled(); +}); +it("refuses a mismatched provider challenge", async () => { + mocks.challenge.mockResolvedValue({ + id: "challenge", + authenticationFactorId: "other", + }); + expect((await POST(request())).status).toBe(503); +}); +it("sanitizes provider failure", async () => { + mocks.challenge.mockRejectedValue(new Error("private provider error")); + expect(await (await POST(request())).json()).toEqual({ + error: "unavailable", + }); +}); +it("refuses impersonation before preparation", async () => { + mocks.session.mockResolvedValue({ + sessionId: "session", + organizationId: "org", + user: { id: "user" }, + impersonator: {}, + }); + expect((await POST(request())).status).toBe(403); + expect(mocks.claim).not.toHaveBeenCalled(); +}); + +it.each([ + { sessionId: "", organizationId: "org" }, + { sessionId: "session", organizationId: "" }, +])("requires a complete authenticated session binding", async (binding) => { + mocks.session.mockResolvedValue({ ...binding, user: { id: "user" } }); + expect((await POST(request())).status).toBe(403); + expect(mocks.claim).not.toHaveBeenCalled(); + expect(mocks.factors).not.toHaveBeenCalled(); +}); +it("reports an expired session without spending preparation budget", async () => { + const { SessionExpiredError } = await import("@/src/auth/session"); + mocks.session.mockRejectedValue(new SessionExpiredError()); + expect(await (await POST(request())).json()).toEqual({ error: "expired" }); + expect(mocks.claim).not.toHaveBeenCalled(); +}); +it("exports no GET handler for render or prefetch preparation", async () => { + expect(await import("./route")).not.toHaveProperty("GET"); +}); diff --git a/apps/web/app/access/mfa/prepare/route.ts b/apps/web/app/access/mfa/prepare/route.ts new file mode 100644 index 00000000..48f0fa18 --- /dev/null +++ b/apps/web/app/access/mfa/prepare/route.ts @@ -0,0 +1,72 @@ +import { claimMfaPreparationAttempt } from "@clockwork/db"; +import { getWorkOS } from "@workos-inc/authkit-nextjs"; +import { + getVerifiedWorkosSession, + SessionExpiredError, +} from "@/src/auth/session"; +import { getServiceDatabase } from "@/src/db/service"; +import { prepareMfaProof } from "../prepared-challenge"; + +const reply = (body: object, status = 200) => + Response.json(body, { + status, + headers: { "cache-control": "private, no-store" }, + }); + +/** Only an intentional same-origin POST creates a challenge; GET/prefetch cannot. */ +export async function POST(request: Request) { + if ( + !process.env.APP_ORIGIN || + request.headers.get("origin") !== process.env.APP_ORIGIN + ) + return reply({ error: "unavailable" }, 403); + if ( + !request.headers + .get("content-type") + ?.startsWith("application/x-www-form-urlencoded") + ) + return reply({ error: "unavailable" }, 415); + if ((await request.text()).length > 0) + return reply({ error: "unavailable" }, 400); + try { + const session = await getVerifiedWorkosSession(); + if (!session.organizationId || !session.sessionId || session.impersonator) + return reply({ error: "unavailable" }, 403); + if ( + !(await claimMfaPreparationAttempt(getServiceDatabase(), session.user.id)) + ) + return reply({ error: "limited" }, 429); + const provider = getWorkOS().multiFactorAuth; + const factors = await ( + await provider.listUserAuthFactors({ userId: session.user.id }) + ).autoPagination(); + const owned = factors.filter( + (factor) => factor.type === "totp" && factor.userId === session.user.id, + ); + const factor = owned[0]; + if (owned.length !== 1 || !factor) + return reply({ error: "unavailable" }, 409); + const challenge = await provider.challengeFactor({ + authenticationFactorId: factor.id, + }); + if (challenge.authenticationFactorId !== factor.id) + return reply({ error: "unavailable" }, 503); + return reply( + prepareMfaProof({ + challengeId: challenge.id, + factorId: factor.id, + sessionId: session.sessionId, + workosUserId: session.user.id, + workosOrganizationId: session.organizationId, + providerExpiresAt: challenge.expiresAt, + }), + ); + } catch (error) { + return reply( + { + error: error instanceof SessionExpiredError ? "expired" : "unavailable", + }, + 503, + ); + } +} diff --git a/apps/web/app/access/mfa/prepared-challenge.test.ts b/apps/web/app/access/mfa/prepared-challenge.test.ts new file mode 100644 index 00000000..4febeed5 --- /dev/null +++ b/apps/web/app/access/mfa/prepared-challenge.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { prepareMfaProof, readMfaProof } from "./prepared-challenge"; +const now = 1_800_000_000_000; +const binding = { + sessionId: "session", + workosUserId: "user_owner", + workosOrganizationId: "org_owner", +}; +const input = { ...binding, challengeId: "challenge", factorId: "factor" }; +beforeEach(() => { + vi.stubEnv( + "WORKOS_COOKIE_PASSWORD", + "test-cookie-key-with-at-least-32-bytes", + ); +}); +it("binds only challenge identifiers and never grants verification itself", () => { + const prepared = prepareMfaProof(input, now); + expect(prepared.validForMs).toBe(300_000); + expect(readMfaProof(prepared.proof, binding, now + 1)).toMatchObject(input); + expect( + Object.keys( + JSON.parse( + Buffer.from(prepared.proof.split(".")[0] ?? "", "base64url").toString(), + ) as Record, + ), + ).toEqual([ + "version", + "challengeId", + "factorId", + "workosUserId", + "workosOrganizationId", + "sessionId", + "issuedAt", + "expiresAt", + ]); +}); +it.each(["sessionId", "workosUserId", "workosOrganizationId"] as const)( + "refuses another %s", + (field) => { + expect(() => + readMfaProof( + prepareMfaProof(input, now).proof, + { ...binding, [field]: "other" }, + now, + ), + ).toThrow(); + }, +); +it.each([ + "version", + "challengeId", + "factorId", + "workosUserId", + "workosOrganizationId", + "sessionId", + "issuedAt", + "expiresAt", +])("refuses tampered %s", (field) => { + const [encoded, signature] = prepareMfaProof(input, now).proof.split("."); + const payload = JSON.parse( + Buffer.from(encoded ?? "", "base64url").toString(), + ) as Record; + payload[field] = + typeof payload[field] === "number" ? payload[field] + 1 : "other"; + expect(() => + readMfaProof( + `${Buffer.from(JSON.stringify(payload)).toString("base64url")}.${signature}`, + binding, + now, + ), + ).toThrow(); +}); +it.each([-1, 300_000, 900_000])( + "refuses a future or expired proof at offset %s", + (offset) => { + expect(() => + readMfaProof(prepareMfaProof(input, now).proof, binding, now + offset), + ).toThrow(); + }, +); +it("honors the earlier provider expiry", () => { + const prepared = prepareMfaProof( + { ...input, providerExpiresAt: new Date(now + 10_000).toISOString() }, + now, + ); + expect(prepared.validForMs).toBe(10_000); + expect(() => readMfaProof(prepared.proof, binding, now + 10_000)).toThrow(); +}); +it.each(["", "bad", new Date(now).toISOString()])( + "refuses invalid or elapsed provider expiry %s", + (providerExpiresAt) => { + expect(() => + prepareMfaProof({ ...input, providerExpiresAt }, now), + ).toThrow(); + }, +); +it("refuses weak keys and key rotation", () => { + const proof = prepareMfaProof(input, now).proof; + vi.stubEnv( + "WORKOS_COOKIE_PASSWORD", + "another-cookie-key-with-at-least-32-bytes", + ); + expect(() => readMfaProof(proof, binding, now)).toThrow(); + vi.stubEnv("WORKOS_COOKIE_PASSWORD", "short"); + expect(() => prepareMfaProof(input, now)).toThrow(); +}); +it.each(["", "bad", "a.b.c", "a".repeat(2401)])( + "refuses malformed proof", + (proof) => { + expect(() => readMfaProof(proof, binding, now)).toThrow(); + }, +); diff --git a/apps/web/app/access/mfa/prepared-challenge.ts b/apps/web/app/access/mfa/prepared-challenge.ts new file mode 100644 index 00000000..6c04e937 --- /dev/null +++ b/apps/web/app/access/mfa/prepared-challenge.ts @@ -0,0 +1,104 @@ +import "server-only"; + +import { createHmac, timingSafeEqual } from "node:crypto"; +import { z } from "zod"; + +const lifetimeMs = 5 * 60 * 1000; +const identifier = z.string().min(1).max(200); +const schema = z + .object({ + version: z.literal(1), + challengeId: identifier, + factorId: identifier, + workosUserId: identifier, + workosOrganizationId: identifier, + sessionId: identifier, + issuedAt: z.number().int().nonnegative(), + expiresAt: z.number().int().positive(), + }) + .strict(); +export interface MfaBinding { + workosUserId: string; + workosOrganizationId: string; + sessionId: string; +} + +function key() { + const secret = process.env.WORKOS_COOKIE_PASSWORD; + if (!secret || Buffer.byteLength(secret) < 32) + throw new Error("MFA_PREPARATION_UNAVAILABLE"); + return secret; +} +function signature(encoded: string) { + return createHmac("sha256", key()) + .update("clockwork:mfa-preparation:v1\0") + .update(encoded) + .digest(); +} + +/** A challenge binding, never an MFA receipt or proof of successful verification. */ +export function prepareMfaProof( + input: MfaBinding & { + challengeId: string; + factorId: string; + providerExpiresAt?: string | null | undefined; + }, + now = Date.now(), +) { + const providerExpiry = + input.providerExpiresAt == null + ? now + lifetimeMs + : Date.parse(input.providerExpiresAt); + if (!Number.isFinite(providerExpiry) || providerExpiry <= now) + throw new Error("MFA_PREPARATION_UNAVAILABLE"); + const payload = schema.parse({ + version: 1, + challengeId: input.challengeId, + factorId: input.factorId, + workosUserId: input.workosUserId, + workosOrganizationId: input.workosOrganizationId, + sessionId: input.sessionId, + issuedAt: now, + expiresAt: Math.min(now + lifetimeMs, providerExpiry), + }); + const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return { + proof: `${encoded}.${signature(encoded).toString("base64url")}`, + validForMs: payload.expiresAt - now, + }; +} + +export function readMfaProof( + proof: string, + binding: MfaBinding, + now = Date.now(), +) { + if ( + proof.length > 2400 || + !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/u.test(proof) + ) + throw new Error("MFA_PREPARATION_INVALID"); + const [encoded, supplied] = proof.split("."); + if (!encoded || !supplied) throw new Error("MFA_PREPARATION_INVALID"); + const expected = signature(encoded); + const received = Buffer.from(supplied, "base64url"); + if ( + received.length !== expected.length || + !timingSafeEqual(received, expected) + ) + throw new Error("MFA_PREPARATION_INVALID"); + const payload = schema.parse( + JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")), + ); + if ( + payload.workosUserId !== binding.workosUserId || + payload.workosOrganizationId !== binding.workosOrganizationId || + payload.sessionId !== binding.sessionId || + payload.issuedAt > now || + payload.expiresAt <= now || + payload.expiresAt <= payload.issuedAt || + payload.expiresAt - payload.issuedAt > lifetimeMs + ) + throw new Error("MFA_PREPARATION_INVALID"); + return payload; +} diff --git a/apps/web/app/access/mfa/verify/route.test.ts b/apps/web/app/access/mfa/verify/route.test.ts index b674c681..836c07d4 100644 --- a/apps/web/app/access/mfa/verify/route.test.ts +++ b/apps/web/app/access/mfa/verify/route.test.ts @@ -27,23 +27,43 @@ vi.mock("@workos-inc/authkit-nextjs", () => ({ })); import { SessionExpiredError } from "@/src/auth/session"; import { POST } from "./route"; +import { prepareMfaProof } from "../prepared-challenge"; function request(origin = "https://example.com", body = "code=123456") { + const form = new URLSearchParams(body); + if (!form.has("proof")) + form.set( + "proof", + prepareMfaProof({ + sessionId: "session", + workosUserId: "user", + workosOrganizationId: "org", + challengeId: "challenge", + factorId: "factor", + }).proof, + ); return new Request("https://example.com/access/mfa/verify", { method: "POST", headers: { origin, "content-type": "application/x-www-form-urlencoded" }, - body, + body: form.toString(), }); } beforeEach(() => { vi.resetAllMocks(); vi.stubEnv("APP_ORIGIN", "https://example.com"); + vi.stubEnv( + "WORKOS_COOKIE_PASSWORD", + "test-cookie-key-with-at-least-32-bytes", + ); mocks.session.mockResolvedValue({ sessionId: "session", organizationId: "org", user: { id: "user" }, }); mocks.claim.mockResolvedValue(true); - mocks.factors.mockResolvedValue({ data: [{ id: "factor", type: "totp" }] }); + mocks.factors.mockResolvedValue({ + autoPagination: () => + Promise.resolve([{ id: "factor", type: "totp", userId: "user" }]), + }); mocks.challenge.mockResolvedValue({ id: "challenge" }); mocks.verify.mockResolvedValue({ valid: true, @@ -211,3 +231,95 @@ it("ignores duplicate return destinations", async () => { ), ).toBe("/"); }); + +it("checks the code before any factor lookup and never creates a challenge after code entry", async () => { + mocks.verify.mockImplementation(() => { + expect(mocks.factors).not.toHaveBeenCalled(); + expect(mocks.challenge).not.toHaveBeenCalled(); + return Promise.resolve({ + valid: true, + challenge: { id: "challenge", authenticationFactorId: "factor" }, + }); + }); + expect((await POST(request())).headers.get("location")).toBe("/"); + expect(mocks.challenge).not.toHaveBeenCalled(); + expect(mocks.record).toHaveBeenCalledOnce(); +}); +it.each( + [ + [], + [{ id: "reset", type: "totp", userId: "user" }], + [{ id: "factor", type: "totp", userId: "other" }], + [ + { id: "factor", type: "totp", userId: "user" }, + { id: "other", type: "totp", userId: "user" }, + ], + ].map((factors) => ({ factors })), +)( + "refuses a deleted, reset, foreign or ambiguous current factor", + async ({ factors }) => { + mocks.factors.mockResolvedValue({ + autoPagination: () => Promise.resolve(factors), + }); + expect((await POST(request())).headers.get("location")).toBe( + "/access/mfa?error=unavailable", + ); + expect(mocks.verify).toHaveBeenCalledOnce(); + expect(mocks.record).not.toHaveBeenCalled(); + }, +); +it("does not grant assurance when a challenge receipt is replayed", async () => { + mocks.record.mockRejectedValue(new Error("duplicate receipt")); + expect((await POST(request())).headers.get("location")).toBe( + "/access/mfa?error=unavailable", + ); + expect(mocks.verify).toHaveBeenCalledOnce(); +}); +it.each(["", "tampered"])( + "refuses an absent or invalid binding before any provider call", + async (proof) => { + expect( + ( + await POST( + request( + "https://example.com", + new URLSearchParams({ code: "123456", proof }).toString(), + ), + ) + ).headers.get("location"), + ).toBe("/access/mfa?error=preparation"); + expect(mocks.claim).not.toHaveBeenCalled(); + expect(mocks.verify).not.toHaveBeenCalled(); + }, +); +it("refuses a proof after switching the signed-in session", async () => { + mocks.session.mockResolvedValue({ + sessionId: "other", + organizationId: "org", + user: { id: "user" }, + }); + expect((await POST(request())).headers.get("location")).toBe( + "/access/mfa?error=preparation", + ); + expect(mocks.verify).not.toHaveBeenCalled(); +}); + +it("rejects duplicate proof and code fields before a provider verification", async () => { + const proof = prepareMfaProof({ + sessionId: "session", + workosUserId: "user", + workosOrganizationId: "org", + challengeId: "challenge", + factorId: "factor", + }).proof; + for (const duplicate of ["code", "proof"]) { + const form = new URLSearchParams({ code: "123456", proof }); + form.append(duplicate, form.get(duplicate) ?? ""); + expect( + (await POST(request("https://example.com", form.toString()))).headers.get( + "location", + ), + ).toBe("/access/mfa?error=preparation"); + } + expect(mocks.verify).not.toHaveBeenCalled(); +}); diff --git a/apps/web/app/access/mfa/verify/route.ts b/apps/web/app/access/mfa/verify/route.ts index bb6d9ffe..cca42a5a 100644 --- a/apps/web/app/access/mfa/verify/route.ts +++ b/apps/web/app/access/mfa/verify/route.ts @@ -6,6 +6,7 @@ import { } from "@/src/auth/session"; import { getServiceDatabase } from "@/src/db/service"; import { mfaReturnTo } from "../return-to"; +import { readMfaProof } from "../prepared-challenge"; function redirect(location: string) { return new Response(null, { @@ -27,7 +28,7 @@ export async function POST(request: Request): Promise { ) return new Response(null, { status: 415 }); const body = await request.text(); - if (body.length > 1024) return new Response(null, { status: 413 }); + if (body.length > 4096) return new Response(null, { status: 413 }); const form = new URLSearchParams(body); const code = form.get("code")?.trim() ?? ""; const returnTo = @@ -44,34 +45,50 @@ export async function POST(request: Request): Promise { const session = await getVerifiedWorkosSession(); if (!session.organizationId || !session.sessionId || session.impersonator) return new Response(null, { status: 403 }); + if (form.getAll("proof").length !== 1 || form.getAll("code").length !== 1) + return failure("preparation"); + let prepared; + try { + prepared = readMfaProof(form.get("proof") ?? "", { + sessionId: session.sessionId, + workosUserId: session.user.id, + workosOrganizationId: session.organizationId, + }); + } catch { + return failure("preparation"); + } const database = getServiceDatabase(); if (!(await claimMfaAttempt(database, session.user.id))) return failure("limited"); const provider = getWorkOS().multiFactorAuth; - const factors = await provider.listUserAuthFactors({ - userId: session.user.id, - }); - const factor = factors.data.find((candidate) => candidate.type === "totp"); - if (!factor) return failure("unavailable"); - const challenge = await provider.challengeFactor({ - authenticationFactorId: factor.id, - }); + // Preparation is complete before code entry. No factor-list or challenge + // request consumes the code's remaining lifetime before this verification. const result = await provider.verifyChallenge({ - authenticationChallengeId: challenge.id, + authenticationChallengeId: prepared.challengeId, code, }); if ( !result.valid || - result.challenge.id !== challenge.id || - result.challenge.authenticationFactorId !== factor.id + result.challenge.id !== prepared.challengeId || + result.challenge.authenticationFactorId !== prepared.factorId ) return failure("invalid"); + // A reset/deletion since preparation must not grant an old factor a receipt. + // This provider read happens AFTER the time-sensitive code verification. + const factors = await ( + await provider.listUserAuthFactors({ userId: session.user.id }) + ).autoPagination(); + const owned = factors.filter( + (factor) => factor.type === "totp" && factor.userId === session.user.id, + ); + if (owned.length !== 1 || owned[0]?.id !== prepared.factorId) + return failure("unavailable"); await recordMfaReceipt(database, { sessionId: session.sessionId, workosUserId: session.user.id, workosOrganizationId: session.organizationId, - challengeId: challenge.id, - factorId: factor.id, + challengeId: prepared.challengeId, + factorId: prepared.factorId, }); return redirect(returnTo ?? "/"); } catch (error) { diff --git a/apps/web/src/features/shell/handler-guards.test.ts b/apps/web/src/features/shell/handler-guards.test.ts index 8aa5cc42..55c17212 100644 --- a/apps/web/src/features/shell/handler-guards.test.ts +++ b/apps/web/src/features/shell/handler-guards.test.ts @@ -57,6 +57,11 @@ const routeExemptions: Readonly< "The MCP endpoint: a personal access token authenticates each request as its owner, and every tool runs the pages' own actions and loaders, which check permissions (mndaStaff, contractStaff, contractReader).", mustContain: "handleMcpRequest", }, + "app/access/mfa/prepare/route.ts": { + reason: + "Prepares only the signed-in person’s own second-factor challenge; no assurance or role is granted.", + mustContain: "getVerifiedWorkosSession(", + }, "app/access/mfa/verify/route.ts": { reason: "Verifies the signed-in person's own second factor; there is no role to check yet.", diff --git a/apps/web/src/i18n/messages/platform.ts b/apps/web/src/i18n/messages/platform.ts index f6f77378..9fbf9054 100644 --- a/apps/web/src/i18n/messages/platform.ts +++ b/apps/web/src/i18n/messages/platform.ts @@ -1673,6 +1673,36 @@ export const platformMessages = defineMessages({ zh: "{text}。", ar: "{text}.", }, + "platform.mfa.preparing": { + en: "Preparing verification…", + es: "Preparando la verificación…", + fr: "Préparation de la vérification…", + de: "Überprüfung wird vorbereitet…", + ja: "確認を準備しています…", + pt: "Preparando a verificação…", + zh: "正在准备验证…", + ar: "جارٍ إعداد التحقق…", + }, + "platform.mfa.restart": { + en: "Prepare a new verification", + es: "Preparar una nueva verificación", + fr: "Préparer une nouvelle vérification", + de: "Neue Überprüfung vorbereiten", + ja: "新しい確認を準備", + pt: "Preparar nova verificação", + zh: "准备新的验证", + ar: "إعداد تحقق جديد", + }, + "platform.mfa.error.preparation": { + en: "This verification expired or belongs to another session. Prepare a new verification and enter the current code.", + es: "Esta verificación caducó o pertenece a otra sesión. Prepare una nueva verificación e introduzca el código actual.", + fr: "Cette vérification a expiré ou appartient à une autre session. Préparez une nouvelle vérification et saisissez le code actuel.", + de: "Diese Überprüfung ist abgelaufen oder gehört zu einer anderen Sitzung. Bereiten Sie eine neue Überprüfung vor und geben Sie den aktuellen Code ein.", + ja: "この確認は期限切れか、別のセッション用です。新しい確認を準備し、現在のコードを入力してください。", + pt: "Esta verificação expirou ou pertence a outra sessão. Prepare uma nova verificação e digite o código atual.", + zh: "此验证已过期或属于其他会话。请准备新的验证并输入当前验证码。", + ar: "انتهت صلاحية هذا التحقق أو أنه يخص جلسة أخرى. أعدّ تحققًا جديدًا وأدخل الرمز الحالي.", + }, "platform.mfa.prompt": { en: "Enter the code from your authenticator app to continue.", es: "Introduzca el código de su aplicación de autenticación para continuar.", diff --git a/docs/operations/production-bootstrap.md b/docs/operations/production-bootstrap.md index 5886340e..5d4c1466 100644 --- a/docs/operations/production-bootstrap.md +++ b/docs/operations/production-bootstrap.md @@ -134,3 +134,21 @@ never treats their session as verified. For bootstrap accounts enrolled under an operator's authority, transfer the authenticator setup material privately to that operator before handoff. Never commit QR codes, factor secrets, email codes, or session credentials. + +### In-app authenticator challenge preparation + +The MFA form prepares a challenge through an authenticated, same-origin POST +before enabling code entry. Its signed, short-lived binding names the exact +WorkOS user, organization, session, factor and challenge; it is not evidence of +successful MFA. Preparation has a separate durable limit of five requests per +ten minutes, so refreshing the form does not spend the verification budget. Each +code verification still uses the existing five-attempt limit. + +On submission, Commerce checks that binding and sends the code directly for +verification. It then reads the current factors before writing the successful +receipt, refusing a deleted or replaced authenticator. The receipt's unique +challenge ID also prevents replay from granting another confirmation. Provider +code acceptance windows are unchanged. Preparation and factor lookup no longer +consume code lifetime before verification; this reduces a timing risk without +claiming that every rejected code was caused by expiry. No code or factor secret +is stored in the browser binding or logged. diff --git a/packages/db/src/repositories/experience/mfa-attempts.integration.test.ts b/packages/db/src/repositories/experience/mfa-attempts.integration.test.ts new file mode 100644 index 00000000..2aa4fdff --- /dev/null +++ b/packages/db/src/repositories/experience/mfa-attempts.integration.test.ts @@ -0,0 +1,72 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, expect, it } from "vitest"; +import { createRuntimeDatabase } from "../../client"; +import { claimMfaAttempt, claimMfaPreparationAttempt } from "./mfa"; + +const { db, client } = createRuntimeDatabase({ + url: + process.env.DIRECT_DATABASE_URL ?? + "postgresql://postgres:postgres@127.0.0.1:54322/postgres?sslmode=disable", + role: "clockwork_service", + ssl: false, +}); +const keys: string[] = []; +function user() { + const id = `user_${randomUUID().replaceAll("-", "")}`; + keys.push(id, `mfa-prepare:${id}`); + return id; +} +afterAll(async () => { + await client`delete from experience_mfa_attempts where workos_user_id=any(${keys})`; + await client.end(); +}); +async function budget(key: string) { + const [row] = await client<{ attempts: number; window_started_at: Date }[]>` + select attempts,window_started_at from experience_mfa_attempts where workos_user_id=${key}`; + if (!row) throw new Error("Expected durable budget"); + return row; +} + +it("keeps preparation independent without spending or resetting an existing verification window", async () => { + const id = user(); + expect(await claimMfaAttempt(db, id)).toBe(true); + expect(await claimMfaAttempt(db, id)).toBe(true); + const original = await budget(id); + for (let attempt = 0; attempt < 5; attempt += 1) + expect(await claimMfaPreparationAttempt(db, id)).toBe(true); + expect(await claimMfaPreparationAttempt(db, id)).toBe(false); + expect(await budget(id)).toEqual(original); + for (let attempt = 0; attempt < 3; attempt += 1) + expect(await claimMfaAttempt(db, id)).toBe(true); + expect(await claimMfaAttempt(db, id)).toBe(false); + expect((await budget(id)).attempts).toBe(5); + expect((await budget(`mfa-prepare:${id}`)).attempts).toBe(5); +}); + +it("admits at most five concurrent claims in each independent budget", async () => { + const id = user(); + const [preparation, verification] = await Promise.all([ + Promise.all( + Array.from({ length: 20 }, () => claimMfaPreparationAttempt(db, id)), + ), + Promise.all(Array.from({ length: 20 }, () => claimMfaAttempt(db, id))), + ]); + expect(preparation.filter(Boolean)).toHaveLength(5); + expect(verification.filter(Boolean)).toHaveLength(5); + expect((await budget(`mfa-prepare:${id}`)).attempts).toBe(5); + expect((await budget(id)).attempts).toBe(5); +}); + +it("renews an expired preparation window without reopening exhausted code verification", async () => { + const id = user(); + for (let attempt = 0; attempt < 5; attempt += 1) { + await claimMfaPreparationAttempt(db, id); + await claimMfaAttempt(db, id); + } + const original = await budget(id); + await client`update experience_mfa_attempts set window_started_at=now()-interval '11 minutes' where workos_user_id=${`mfa-prepare:${id}`}`; + expect(await claimMfaPreparationAttempt(db, id)).toBe(true); + expect((await budget(`mfa-prepare:${id}`)).attempts).toBe(1); + expect(await claimMfaAttempt(db, id)).toBe(false); + expect(await budget(id)).toEqual(original); +}); diff --git a/packages/db/src/repositories/experience/mfa.ts b/packages/db/src/repositories/experience/mfa.ts index 3bba7a01..b39dc9ce 100644 --- a/packages/db/src/repositories/experience/mfa.ts +++ b/packages/db/src/repositories/experience/mfa.ts @@ -84,10 +84,27 @@ export async function recordHostedMfaEnrollment( export async function claimMfaAttempt( db: RuntimeDatabase, workosUserId: string, -) { +): Promise { + return claimMfaBudget(db, workosUserId); +} + +/** Preparation has its own fixed budget and cannot spend or reset code attempts. */ +export async function claimMfaPreparationAttempt( + db: RuntimeDatabase, + workosUserId: string, +): Promise { + // Provider user IDs start with user_, so this internal namespace cannot + // overlap the existing verification keys. Callers cannot choose a purpose. + return claimMfaBudget(db, `mfa-prepare:${workosUserId}`); +} + +async function claimMfaBudget( + db: RuntimeDatabase, + key: string, +): Promise { const rows = await db.execute<{ attempts: number }>(sql` insert into public.experience_mfa_attempts(workos_user_id, window_started_at, attempts) - values (${workosUserId}, now(), 1) + values (${key}, now(), 1) on conflict (workos_user_id) do update set window_started_at = case when experience_mfa_attempts.window_started_at <= now() - interval '10 minutes' then now() else experience_mfa_attempts.window_started_at end, attempts = case when experience_mfa_attempts.window_started_at <= now() - interval '10 minutes' then 1 else experience_mfa_attempts.attempts + 1 end