Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions apps/web/app/access/mfa/form.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Response>((resolve) => {
complete = resolve;
}),
);
vi.stubGlobal("fetch", request);
const { container } = render(<MfaForm returnTo="/internal/team" />);
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(
<StrictMode>
<MfaForm />
</StrictMode>,
);
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(<MfaForm />);
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(<MfaForm />);
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(<MfaForm />);
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(<MfaForm />);
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(<MfaForm error="preparation" />);
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(<MfaForm />);
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();
}
});
134 changes: 134 additions & 0 deletions apps/web/app/access/mfa/form.tsx
Original file line number Diff line number Diff line change
@@ -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<Promise<Response> | 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<typeof setTimeout> | 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 (
<form
className="access-form"
action="/access/mfa/verify"
method="post"
onSubmit={(event) => {
if (!proof || submitting.current) {
event.preventDefault();
return;
}
submitting.current = true;
setPending(true);
}}
>
{returnTo && <input type="hidden" name="returnTo" value={returnTo} />}
<input type="hidden" name="proof" value={proof} />
<p>{t("platform.mfa.prompt")}</p>
{preparing && <p role="status">{t("platform.mfa.preparing")}</p>}
{problem && <p role="alert">{t(errorKey)}</p>}
<Input
label={t("platform.mfa.code")}
name="code"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
required
disabled={!proof}
readOnly={pending}
/>
<Button type="submit" disabled={!proof || pending}>
{t("platform.mfa.submit")}
</Button>
{!preparing && !proof && (
<Button type="button" onClick={() => window.location.reload()}>
{t("platform.mfa.restart")}
</Button>
)}
<Link href="/sign-in">{t("session.expired.action")}</Link>
</form>
);
}
13 changes: 11 additions & 2 deletions apps/web/app/access/mfa/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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");
Expand Down
41 changes: 6 additions & 35 deletions apps/web/app/access/mfa/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata> {
const t = await getTranslations();
Expand Down Expand Up @@ -36,40 +37,10 @@ export default async function Page({
<h1>{t("session.mfa.title")}</h1>
<p>{t("session.mfa.description")}</p>
{process.env.WORKOS_CLIENT_ID ? (
<form
className="access-form"
action="/access/mfa/verify"
method="post"
>
{returnTo && (
<input type="hidden" name="returnTo" value={returnTo} />
)}
<p>{t("platform.mfa.prompt")}</p>
{error && (
<p role="alert">
{t(
error === "limited"
? "platform.mfa.error.limited"
: error === "invalid"
? "platform.mfa.error.invalid"
: error === "expired"
? "platform.mfa.error.expired"
: "platform.mfa.error.unavailable",
)}
</p>
)}
<Input
label={t("platform.mfa.code")}
name="code"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
required
/>
<Button type="submit">{t("platform.mfa.submit")}</Button>
<Link href="/sign-in">{t("session.expired.action")}</Link>
</form>
<MfaForm
returnTo={returnTo}
error={typeof error === "string" ? error : undefined}
/>
) : (
<Link
className={buttonClassName({ variant: "primary" })}
Expand Down
Loading
Loading