mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {}): 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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<string, unknown>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
+214
-113
@@ -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<string | null>(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<typeof startAuthentication>[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 (
|
||||
<div className="flex w-full max-w-sm flex-col gap-4">
|
||||
{/* 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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
|
||||
{mfaChallenge ? (
|
||||
<div className="mt-8">
|
||||
<MfaLoginStep
|
||||
mfaTicket={mfaChallenge.ticket}
|
||||
methods={mfaChallenge.methods}
|
||||
onSuccess={async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.auth(),
|
||||
});
|
||||
navigateAfterLogin();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setMfaChallenge(null);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 space-y-4">
|
||||
{/* 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. */}
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.loginWithPasskey")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("common.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
{mode === "passkey" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
onClick={() => setMode("password")}
|
||||
disabled={loading}
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
{t("auth.loginWithPassword")}
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.loginWithPasskey")}
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => 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")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
enterKeyHint="go"
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
placeholder="********"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("common.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
{mode === "passkey" ? (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
onClick={() => setMode("password")}
|
||||
>
|
||||
{loading && mode === "password" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.login")}
|
||||
<Lock className="h-4 w-4" />
|
||||
{t("auth.loginWithPassword")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
) : (
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => 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")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
enterKeyHint="go"
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
placeholder="********"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && mode === "password" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.login")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
id={errorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationEnabled === true && (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
{t("auth.noAccount")}{" "}
|
||||
<Link
|
||||
href="/auth/register"
|
||||
// axe-core `link-in-text-block` requires a visible
|
||||
// distinction beyond colour alone (WCAG 1.4.1). Forcing
|
||||
// the underline always-on instead of `hover:underline`
|
||||
// satisfies the rule without changing the visual after
|
||||
// hover.
|
||||
className="text-primary underline"
|
||||
{error && (
|
||||
<div
|
||||
id={errorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{t("auth.register")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationEnabled === true && (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
{t("auth.noAccount")}{" "}
|
||||
<Link
|
||||
href="/auth/register"
|
||||
// axe-core `link-in-text-block` requires a visible
|
||||
// distinction beyond colour alone (WCAG 1.4.1). Forcing
|
||||
// the underline always-on instead of `hover:underline`
|
||||
// satisfies the rule without changing the visual after
|
||||
// hover.
|
||||
className="text-primary underline"
|
||||
>
|
||||
{t("auth.register")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* v1.4.26 — discoverable privacy link below the login card.
|
||||
App Store reviewers and first-time visitors need a one-click
|
||||
|
||||
@@ -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