feat(auth): complete login with a security-key second factor

When a password is accepted for an account with a second factor, the
login response carries a single-use ticket instead of a session. The
login page now renders a second step: a TOTP or recovery code, or a
WebAuthn security-key assertion scoped to the account's registered keys.
A WebAuthn-only account (no TOTP) is recognised at the password step and
offered the security-key path.

The security-key verify endpoints validate the open ticket, verify the
assertion against the user's second-factor credentials, and only then
mint the session through the shared login tail, so no session exists
before the factor passes. The login page also arms passkey conditional-UI
autofill on the username field.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:40:45 +02:00
1 parent 402ea90d45
commit 86ac1e1d12
9 files changed
+879 -119

No files matched your search

@@ -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(
<MfaLoginStep
mfaTicket="ticket-1"
methods={methods}
onSuccess={() => {}}
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");
});
});
+208
View File
@@ -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<string | null>(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<typeof startAuthentication>[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 (
<div className="space-y-4" data-testid="mfa-login-step">
<div className="flex flex-col items-center gap-2 text-center">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<ShieldCheck className="text-primary h-5 w-5" />
</div>
<h2 className="text-lg font-semibold">{t("auth.mfa.title")}</h2>
<p className="text-muted-foreground text-sm">
{useRecovery
? t("auth.mfa.recoveryHint")
: hasTotp
? t("auth.mfa.totpHint")
: t("auth.mfa.securityKeyHint")}
</p>
</div>
{(hasTotp || useRecovery) && (
<form onSubmit={submitCode} className="space-y-3">
<div className="space-y-2">
<Label htmlFor={codeFieldId}>
{useRecovery
? t("auth.mfa.recoveryLabel")
: t("auth.mfa.codeLabel")}
</Label>
<Input
id={codeFieldId}
value={code}
onChange={(e) =>
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}
/>
</div>
<Button
type="submit"
className="min-h-11 w-full"
size="lg"
disabled={loading || code.trim().length < 6}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="h-4 w-4" />
)}
{t("auth.mfa.verify")}
</Button>
</form>
)}
{hasWebauthn && (
<Button
type="button"
variant={hasTotp ? "outline" : "default"}
className="min-h-11 w-full"
size="lg"
onClick={submitSecurityKey}
disabled={loading}
>
<KeyRound className="h-4 w-4" />
{t("auth.mfa.useSecurityKey")}
</Button>
)}
{hasTotp && (
<button
type="button"
onClick={() => {
setUseRecovery((v) => !v);
setCode("");
setError(null);
}}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{useRecovery
? t("auth.mfa.useAuthenticator")
: t("auth.mfa.useRecoveryCode")}
</button>
)}
{error && (
<div
id={errorId}
role="alert"
aria-live="polite"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
</div>
)}
<button
type="button"
onClick={onCancel}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{t("auth.mfa.backToLogin")}
</button>
</div>
);
}