mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user