diff --git a/src/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts index e47fc51fd..6e65e27eb 100644 --- a/src/__tests__/api/auth/login/mfa-branch.test.ts +++ b/src/__tests__/api/auth/login/mfa-branch.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ - prisma: { user: { findFirst: vi.fn() } }, + prisma: { + user: { findFirst: vi.fn() }, + webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) }, + }, })); vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn().mockResolvedValue(true), @@ -75,6 +78,8 @@ const BASE_USER = { beforeEach(() => { vi.clearAllMocks(); + // Default: no registered security key. Individual tests override as needed. + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); }); describe("login MFA branch", () => { @@ -99,6 +104,22 @@ describe("login MFA branch", () => { expect(finishLogin).not.toHaveBeenCalled(); }); + it("WebAuthn-only user (no TOTP) → returns mfaRequired with webauthn method", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...BASE_USER, + totpConfirmedAt: null, + } as never); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never); + + const res = await POST(loginRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.meta.mfaRequired).toBe(true); + expect(body.meta.methods).toEqual(["webauthn"]); + expect(finishLogin).not.toHaveBeenCalled(); + }); + it("non-MFA user → issues the session via finishLogin, no challenge", async () => { vi.mocked(prisma.user.findFirst).mockResolvedValue({ ...BASE_USER, diff --git a/src/app/api/auth/login/__tests__/native-token.test.ts b/src/app/api/auth/login/__tests__/native-token.test.ts index 6661fe644..24abf1edf 100644 --- a/src/app/api/auth/login/__tests__/native-token.test.ts +++ b/src/app/api/auth/login/__tests__/native-token.test.ts @@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({ user: { findFirst: vi.fn() }, apiToken: { create: vi.fn() }, refreshToken: { create: vi.fn() }, + // v1.23 — login checks for a registered second-factor security key. + webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) }, }, })); @@ -97,6 +99,7 @@ function makeRequest(headers: Record = {}): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); vi.mocked(verifyPassword).mockResolvedValue(true); vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never); vi.mocked(prisma.refreshToken.create).mockResolvedValue({ diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index d271cfd31..359d81777 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -96,17 +96,30 @@ export const POST = apiHandler(async (request: NextRequest) => { }); // v1.23 — second-factor gate. The password is correct, but an account with - // a confirmed TOTP factor is NOT yet authenticated: no `Session` row and no - // token bundle is minted here. Instead a single-use, ~5-minute MFA ticket is + // a confirmed second factor (a TOTP secret and/or a registered WebAuthn + // security key) is NOT yet authenticated: no `Session` row and no token + // bundle is minted here. Instead a single-use, ~5-minute MFA ticket is // returned (the partial state lives in the ticket, never in a half-built - // session) and the client completes the login at `/api/auth/mfa/verify`. + // session) and the client completes the login at `/api/auth/mfa/verify` + // (TOTP / recovery) or `/api/auth/mfa/webauthn/verify` (security key). // // Enumeration note: `meta.mfaRequired` is only ever returned AFTER a valid // password, and the invalid-credentials response above is identical // regardless of MFA state — an attacker without the password cannot learn // whether an account has MFA. - if (user.totpConfirmedAt) { + const hasTotp = Boolean(user.totpConfirmedAt); + const webauthnKeyCount = await prisma.webauthnMfaCredential.count({ + where: { userId: user.id }, + }); + const hasWebauthn = webauthnKeyCount > 0; + + if (hasTotp || hasWebauthn) { const challenge = await createMfaChallenge(user.id, "login"); + // Recovery codes are only ever issued alongside TOTP enrollment, so the + // recovery method is offered exactly when TOTP is active. + const methods: ("totp" | "recovery" | "webauthn")[] = []; + if (hasTotp) methods.push("totp", "recovery"); + if (hasWebauthn) methods.push("webauthn"); await auditLog("auth.mfa.challenge", { userId: user.id, ipAddress: ip, @@ -123,7 +136,7 @@ export const POST = apiHandler(async (request: NextRequest) => { meta: { mfaRequired: true, mfaTicket: challenge.ticket, - methods: ["totp", "recovery"], + methods, }, }, { status: 200 }, diff --git a/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts b/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts new file mode 100644 index 000000000..e53875cb5 --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: unknown) => fn, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/auth/mfa/challenge", () => ({ + loadActiveChallenge: vi.fn(), + recordChallengeFailure: vi.fn().mockResolvedValue({ exhausted: false }), + claimChallenge: vi.fn(), +})); + +vi.mock("@/lib/auth/mfa/webauthn", () => ({ + verifyMfaAuthentication: vi.fn(), +})); + +vi.mock("@/lib/auth/login-response", () => ({ + finishLogin: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { + loadActiveChallenge, + claimChallenge, + recordChallengeFailure, +} from "@/lib/auth/mfa/challenge"; +import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn"; +import { finishLogin } from "@/lib/auth/login-response"; + +function makeRequest(body: Record): NextRequest { + return new NextRequest("http://localhost/api/auth/mfa/webauthn/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const VALID_BODY = { + mfaTicket: "ticket-1", + challengeId: "ch-1", + credential: { + id: "cred-1", + rawId: "cred-1", + type: "public-key", + response: {}, + }, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + ip: "203.0.113.1", + } as never); + vi.mocked(loadActiveChallenge).mockResolvedValue({ + id: "mfa-1", + userId: "user-1", + kind: "login", + attempts: 0, + expiresAt: new Date(Date.now() + 60_000), + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user-1", + username: "u", + onboardingCompletedAt: new Date(), + } as never); + vi.mocked(recordChallengeFailure).mockResolvedValue({ + exhausted: false, + attempts: 1, + } as never); + vi.mocked(finishLogin).mockResolvedValue( + new Response(JSON.stringify({ data: { user: { id: "user-1" } } }), { + status: 200, + }), + ); +}); + +describe("POST /api/auth/mfa/webauthn/verify", () => { + it("finishes login when the assertion verifies and the ticket is claimed", async () => { + vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never); + vi.mocked(claimChallenge).mockResolvedValue(true as never); + + const res = await POST(makeRequest(VALID_BODY)); + + expect(res.status).toBe(200); + expect(verifyMfaAuthentication).toHaveBeenCalledWith( + "ch-1", + "user-1", + VALID_BODY.credential, + ); + expect(claimChallenge).toHaveBeenCalledWith("mfa-1"); + expect(finishLogin).toHaveBeenCalledWith( + expect.objectContaining({ + mfaVerified: true, + source: "mfa.webauthn.verify", + }), + ); + }); + + it("rejects with 401 and burns an attempt when the assertion fails", async () => { + vi.mocked(verifyMfaAuthentication).mockResolvedValue(false as never); + + const res = await POST(makeRequest(VALID_BODY)); + + expect(res.status).toBe(401); + expect(recordChallengeFailure).toHaveBeenCalledWith("mfa-1"); + expect(claimChallenge).not.toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("rejects with 401 for an unknown / expired ticket", async () => { + vi.mocked(loadActiveChallenge).mockResolvedValue(null as never); + + const res = await POST(makeRequest(VALID_BODY)); + + expect(res.status).toBe(401); + expect(verifyMfaAuthentication).not.toHaveBeenCalled(); + }); + + it("does not mint a session if the ticket claim is lost (concurrent)", async () => { + vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never); + vi.mocked(claimChallenge).mockResolvedValue(false as never); + + const res = await POST(makeRequest(VALID_BODY)); + + expect(res.status).toBe(401); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("returns 429 when rate limited", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + ip: "203.0.113.1", + } as never); + + const res = await POST(makeRequest(VALID_BODY)); + expect(res.status).toBe(429); + }); +}); diff --git a/src/app/api/auth/mfa/webauthn/verify/options/route.ts b/src/app/api/auth/mfa/webauthn/verify/options/route.ts new file mode 100644 index 000000000..4f7a569d8 --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/verify/options/route.ts @@ -0,0 +1,70 @@ +/** + * POST /api/auth/mfa/webauthn/verify/options + * + * Begin a mid-login second-factor security-key assertion. The caller presents + * the opaque `mfaTicket` from the `meta.mfaRequired` login response; the server + * resolves the password-identified user and returns assertion options scoped to + * that user's registered MFA security keys (never an empty allow-list — a + * non-resident credential is not discoverable). + * + * Anonymous surface (no session yet), rate-limited per IP. A single generic + * 401 covers every unknown / expired / consumed ticket so a guesser learns + * nothing. + */ +import { NextRequest } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { ensureDbCompatibility } from "@/lib/db-compat"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { loadActiveChallenge } from "@/lib/auth/mfa/challenge"; +import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn"; +import { mfaWebauthnLoginOptionsSchema } from "@/lib/validations/mfa"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:mfa-verify", + 10, + 15 * 60 * 1000, + ); + if (!rl.allowed) { + return apiError("Too many attempts. Please try again later.", 429, { + headers: rateLimitHeaders(rl), + }); + } + + await ensureDbCompatibility(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + + const challenge = await loadActiveChallenge(parsed.data.mfaTicket); + if (!challenge) { + annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } }); + return apiError("Invalid or expired challenge", 401); + } + + const result = await createMfaAuthenticationOptions(challenge.userId); + if (!result) { + // No registered security key for this account — the client should fall + // back to a TOTP / recovery code. + annotate({ action: { name: "auth.mfa.webauthn.no_key" } }); + return apiError("No security key registered", 409); + } + + annotate({ action: { name: "auth.mfa.webauthn.verify-options" } }); + return apiSuccess({ + options: result.options, + challengeId: result.challengeId, + }); +}); diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts new file mode 100644 index 000000000..875105c6b --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -0,0 +1,127 @@ +/** + * POST /api/auth/mfa/webauthn/verify + * + * Complete a mid-login second-factor login challenge with a WebAuthn security + * key. The caller presents the login `mfaTicket` plus the assertion from + * /verify/options. On success the SAME session/token bundle the password path + * issues is returned (web → cookie; native → access+refresh), with the session + * stamped `mfaVerifiedAt`. + * + * Security invariants mirror /api/auth/mfa/verify (the TOTP path): + * - No session/token exists until the assertion verifies — the partial state + * lives in the ticket, never in a half-built session. + * - The ticket is claimed once, atomically, only AFTER the assertion verifies. + * - A failed assertion increments the ticket attempt counter and burns it at + * the cap. Anonymous surface is rate-limited per IP on top of the cap. + */ +import { NextRequest } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { apiError, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { ensureDbCompatibility } from "@/lib/db-compat"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { finishLogin } from "@/lib/auth/login-response"; +import { + loadActiveChallenge, + recordChallengeFailure, + claimChallenge, +} from "@/lib/auth/mfa/challenge"; +import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn"; +import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:mfa-verify", + 10, + 15 * 60 * 1000, + ); + const ip = rl.ip ?? "unknown"; + if (!rl.allowed) { + return apiError("Too many attempts. Please try again later.", 429, { + headers: rateLimitHeaders(rl), + }); + } + + await ensureDbCompatibility(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = mfaWebauthnLoginVerifySchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + const { mfaTicket, challengeId, credential } = parsed.data; + + const challenge = await loadActiveChallenge(mfaTicket); + if (!challenge) { + annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } }); + return apiError("Invalid or expired challenge", 401); + } + + const user = await prisma.user.findUnique({ + where: { id: challenge.userId }, + select: { id: true, username: true, onboardingCompletedAt: true }, + }); + if (!user) { + await recordChallengeFailure(challenge.id); + return apiError("Invalid or expired challenge", 401); + } + + let verified = false; + try { + verified = await verifyMfaAuthentication(challengeId, user.id, credential); + } catch { + // A malformed / expired WebAuthn challenge is a failed attempt, not a 500. + verified = false; + } + + if (!verified) { + const { exhausted } = await recordChallengeFailure(challenge.id); + await auditLog("auth.mfa.failed", { + userId: user.id, + ipAddress: ip, + details: { stage: "login", method: "webauthn", exhausted }, + }); + annotate({ + action: { name: "auth.mfa.failed" }, + meta: { method: "webauthn", exhausted }, + }); + return apiError("Security key verification failed", 401); + } + + // Assertion verified — claim the ticket atomically. Only the single winning + // caller proceeds to mint a session. + const claimed = await claimChallenge(challenge.id); + if (!claimed) { + annotate({ action: { name: "auth.mfa.verify.claim_lost" } }); + return apiError("Invalid or expired challenge", 401); + } + + await auditLog("auth.mfa.verified", { + userId: user.id, + ipAddress: ip, + details: { method: "webauthn" }, + }); + annotate({ + action: { name: "auth.mfa.verified" }, + meta: { method: "webauthn" }, + }); + + const ua = request.headers.get("user-agent"); + return finishLogin({ + user, + request, + ip, + userAgent: ua, + source: "mfa.webauthn.verify", + mfaVerified: true, + }); +}); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index af70c3348..f0ddea12a 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useId, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { KeyRound, Lock, Loader2 } from "lucide-react"; @@ -13,7 +13,13 @@ import { Separator } from "@/components/ui/separator"; import { describePasskeyError } from "@/lib/passkey-errors"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { + ApiError, + apiFetchEnvelope, + apiGet, + apiPost, +} from "@/lib/api/api-fetch"; +import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; @@ -27,6 +33,15 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + // v1.23 — second step of login when the password response carries + // `meta.mfaRequired`. Holds the opaque ticket + the factors the account can + // complete the challenge with. + const [mfaChallenge, setMfaChallenge] = useState<{ + ticket: string; + methods: MfaMethod[]; + } | null>(null); + // Guards the conditional-UI passkey autofill so it starts at most once. + const autofillStarted = useRef(false); // v1.4.27 MB3 — explicit error-region id so the email + password // inputs reference the banner via `aria-describedby`. Screen readers // pair the error with the field instead of announcing it as a @@ -112,7 +127,28 @@ export default function LoginPage() { setLoading(true); try { - await apiPost("/api/auth/login", { email, password }); + // Read the envelope `meta` directly: an MFA account gets HTTP 200 with + // `data: null` + `meta.mfaRequired`, not an error and not a session. + const { meta } = await apiFetchEnvelope< + unknown, + { + mfaRequired?: boolean; + mfaTicket?: string; + methods?: MfaMethod[]; + } + >("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (meta?.mfaRequired && meta.mfaTicket) { + setMfaChallenge({ + ticket: meta.mfaTicket, + methods: meta.methods ?? ["totp", "recovery"], + }); + return; + } await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); navigateAfterLogin(); @@ -127,6 +163,50 @@ export default function LoginPage() { } } + // v1.23 — passkey conditional-UI autofill. When the browser supports it, + // arm a discoverable-credential assertion bound to the username field so a + // returning user can pick their passkey straight from the autofill prompt. + // Best-effort: any abort (the user types a password instead) is swallowed. + useEffect(() => { + if (autofillStarted.current) return; + autofillStarted.current = true; + let cancelled = false; + + (async () => { + try { + const { browserSupportsWebAuthnAutofill, startAuthentication } = + await import("@simplewebauthn/browser"); + if (!(await browserSupportsWebAuthnAutofill())) return; + + const { options, challengeId } = await apiPost<{ + options: Parameters[0]["optionsJSON"]; + challengeId: string; + }>("/api/auth/passkey/login-options"); + + const credential = await startAuthentication({ + optionsJSON: options, + useBrowserAutofill: true, + }); + if (cancelled) return; + + await apiPost("/api/auth/passkey/login-verify", { + challengeId, + credential, + }); + await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); + navigateAfterLogin(); + } catch { + // Autofill aborts (no passkey, user dismisses, password path taken) + // are normal — stay silent and let the explicit buttons drive login. + } + })(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the @@ -143,131 +223,152 @@ export default function LoginPage() {
-
- {/* Phase A5 / B-mobile: bumped from default size (h-9, 36px) + {mfaChallenge ? ( +
+ { + await queryClient.invalidateQueries({ + queryKey: queryKeys.auth(), + }); + navigateAfterLogin(); + }} + onCancel={() => { + setMfaChallenge(null); + setError(null); + }} + /> +
+ ) : ( +
+ {/* Phase A5 / B-mobile: bumped from default size (h-9, 36px) to size="lg" so the login CTAs meet WCAG 2.5.5 (44px minimum) on mobile. Login is the most-tapped flow on a fresh install. */} - - -
- - - {t("common.or")} - - -
- - {mode === "passkey" ? ( - ) : ( -
-
- - setEmail(e.target.value)} - required - autoComplete="username" - inputMode="email" - enterKeyHint="next" - autoCapitalize="none" - spellCheck={false} - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - placeholder={t("auth.emailOrUsernamePlaceholder")} - /> -
-
- - setPassword(e.target.value)} - required - autoComplete="current-password" - enterKeyHint="go" - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - placeholder="********" - /> -
+ +
+ + + {t("common.or")} + + +
+ + {mode === "passkey" ? ( - -
- )} + ) : ( +
+
+ + setEmail(e.target.value)} + required + // `webauthn` token arms the conditional-UI passkey + // autofill prompt on this field (see the mount effect). + autoComplete="username webauthn" + inputMode="email" + enterKeyHint="next" + autoCapitalize="none" + spellCheck={false} + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} + placeholder={t("auth.emailOrUsernamePlaceholder")} + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + enterKeyHint="go" + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} + placeholder="********" + /> +
+ + +
+ )} - {error && ( - - )} - - {registrationEnabled === true && ( -

- {t("auth.noAccount")}{" "} - - {t("auth.register")} - -

- )} -
+ {error} +
+ )} + + {registrationEnabled === true && ( +

+ {t("auth.noAccount")}{" "} + + {t("auth.register")} + +

+ )} + + )} {/* v1.4.26 — discoverable privacy link below the login card. App Store reviewers and first-time visitors need a one-click diff --git a/src/components/auth/__tests__/mfa-login-step.test.tsx b/src/components/auth/__tests__/mfa-login-step.test.tsx new file mode 100644 index 000000000..27aa5c923 --- /dev/null +++ b/src/components/auth/__tests__/mfa-login-step.test.tsx @@ -0,0 +1,54 @@ +/** + * MfaLoginStep rendering — the second step of login shown when the password + * response carried `meta.mfaRequired`. Pins which controls render for each + * factor mix (TOTP+recovery, TOTP+webauthn, webauthn-only). + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +import { MfaLoginStep } from "../mfa-login-step"; + +function render(methods: ("totp" | "recovery" | "webauthn")[]) { + return renderToStaticMarkup( + {}} + onCancel={() => {}} + />, + ); +} + +describe("MfaLoginStep", () => { + it("renders the code entry + recovery toggle for a TOTP account", () => { + const html = render(["totp", "recovery"]); + expect(html).toContain("mfa-login-step"); + expect(html).toContain("auth.mfa.codeLabel"); + expect(html).toContain("auth.mfa.verify"); + expect(html).toContain("auth.mfa.useRecoveryCode"); + // No security-key button when the account has no registered key. + expect(html).not.toContain("auth.mfa.useSecurityKey"); + }); + + it("renders the security-key button alongside TOTP when both are available", () => { + const html = render(["totp", "recovery", "webauthn"]); + expect(html).toContain("auth.mfa.codeLabel"); + expect(html).toContain("auth.mfa.useSecurityKey"); + }); + + it("leads with the security key for a WebAuthn-only account", () => { + const html = render(["webauthn"]); + expect(html).toContain("auth.mfa.useSecurityKey"); + // No code form and no recovery toggle without TOTP. + expect(html).not.toContain("auth.mfa.codeLabel"); + expect(html).not.toContain("auth.mfa.useRecoveryCode"); + }); + + it("always offers a way back to the password step", () => { + expect(render(["totp"])).toContain("auth.mfa.backToLogin"); + }); +}); diff --git a/src/components/auth/mfa-login-step.tsx b/src/components/auth/mfa-login-step.tsx new file mode 100644 index 000000000..86bc86308 --- /dev/null +++ b/src/components/auth/mfa-login-step.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useId, useState } from "react"; +import { KeyRound, Loader2, Lock, ShieldCheck } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useTranslations } from "@/lib/i18n/context"; +import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { describePasskeyError } from "@/lib/passkey-errors"; + +export type MfaMethod = "totp" | "recovery" | "webauthn"; + +/** + * Second step of login when the password response carried `meta.mfaRequired`. + * + * Renders the TOTP code entry by default, with a switch to a recovery code and + * — when the account has a registered security key — a "use a security key" + * button that runs the scoped WebAuthn assertion. Each path posts back to the + * matching verify endpoint and, on success, calls `onSuccess` (the parent + * finishes the login exactly like a normal sign-in). + */ +export function MfaLoginStep({ + mfaTicket, + methods, + onSuccess, + onCancel, +}: { + mfaTicket: string; + methods: MfaMethod[]; + onSuccess: () => void; + onCancel: () => void; +}) { + const { t } = useTranslations(); + const codeFieldId = useId(); + const errorId = useId(); + + const hasTotp = methods.includes("totp"); + const hasWebauthn = methods.includes("webauthn"); + + // Default to a code entry when TOTP is available; otherwise lead with the + // security-key path (a WebAuthn-only account). + const [useRecovery, setUseRecovery] = useState(false); + const [code, setCode] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function submitCode(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await apiPost("/api/auth/mfa/verify", { + mfaTicket, + method: useRecovery ? "recovery" : "totp", + code: code.trim(), + }); + onSuccess(); + } catch (err) { + setError( + err instanceof ApiError && err.message + ? err.message + : t("auth.mfa.verifyFailed"), + ); + } finally { + setLoading(false); + } + } + + async function submitSecurityKey() { + setError(null); + setLoading(true); + try { + const { startAuthentication } = await import("@simplewebauthn/browser"); + const { options, challengeId } = await apiPost<{ + options: Parameters[0]["optionsJSON"]; + challengeId: string; + }>("/api/auth/mfa/webauthn/verify/options", { mfaTicket }); + const credential = await startAuthentication({ optionsJSON: options }); + await apiPost("/api/auth/mfa/webauthn/verify", { + mfaTicket, + challengeId, + credential, + }); + onSuccess(); + } catch (err) { + if (err instanceof ApiError) { + setError(err.message || t("auth.mfa.verifyFailed")); + } else { + const { key, params } = describePasskeyError(err); + setError(t(key, params)); + } + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +
+

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

+

+ {useRecovery + ? t("auth.mfa.recoveryHint") + : hasTotp + ? t("auth.mfa.totpHint") + : t("auth.mfa.securityKeyHint")} +

+
+ + {(hasTotp || useRecovery) && ( +
+
+ + + setCode( + useRecovery + ? e.target.value + : e.target.value.replace(/\D/g, "").slice(0, 6), + ) + } + inputMode={useRecovery ? "text" : "numeric"} + autoComplete="one-time-code" + autoFocus + placeholder={useRecovery ? "XXXXX-XXXXX" : "123456"} + className="font-mono tracking-widest" + aria-invalid={!!error || undefined} + aria-describedby={error ? errorId : undefined} + /> +
+ +
+ )} + + {hasWebauthn && ( + + )} + + {hasTotp && ( + + )} + + {error && ( + + )} + + +
+ ); +}