mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(auth): count a passkey sign-in as a satisfied second factor
A passwordless passkey login already clears user verification and is a phishing-resistant possession factor, but it never stamped the session, so a passkey user could not satisfy an MFA-enforcement policy or pass a step-up re-check. Stamp Session.mfaVerifiedAt on a primary passkey login so it counts as a completed second factor — equivalent to a password+TOTP sign-in. The password+TOTP path is unchanged.
This commit is contained in:
1 parent
c8bef61344
commit
94c3cdea96
2 files changed
+41
-1
No files matched your search
@@ -22,6 +22,14 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa-enrollment", () => ({
|
||||
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/login-alert", () => ({
|
||||
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
// v1.4.43 W13 M-4 — passkey/login-verify now uses the auth-surface
|
||||
@@ -53,6 +61,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const FAKE_USER = {
|
||||
@@ -133,4 +142,14 @@ describe("POST /api/auth/passkey/login-verify — native token issuance", () =>
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.apiToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// v1.23 (M-review M1) — a primary passkey login is a satisfied second factor.
|
||||
it("stamps mfaVerifiedAt on the session so the passkey satisfies step-up", async () => {
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
// createSession(userId, onboardingPending, ip, ua, mfaVerifiedAt) — the
|
||||
// 5th argument must be a Date for a primary passkey sign-in.
|
||||
const args = vi.mocked(createSession).mock.calls[0];
|
||||
expect(args[4]).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
@@ -69,8 +70,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// `hl_onboarding` cookie itself; pass the user's onboarding state
|
||||
// through so the proxy short-circuits the redirect before
|
||||
// hydration.
|
||||
//
|
||||
// v1.23 (M-review M1) — stamp `mfaVerifiedAt` on a primary passkey login. A
|
||||
// discoverable passkey is a phishing-resistant possession factor that already
|
||||
// cleared user verification (`userVerification: 'required'` on the ceremony),
|
||||
// so a passwordless passkey sign-in is itself a satisfied second factor: it
|
||||
// meets an MFA-enforcement policy AND passes `requireFreshMfa` step-up,
|
||||
// exactly like a completed password+TOTP login. (The password+TOTP path is
|
||||
// unchanged — it stamps the session at `/api/auth/mfa/verify`.)
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(user.id, user.onboardingCompletedAt == null, ip, ua);
|
||||
await createSession(
|
||||
user.id,
|
||||
user.onboardingCompletedAt == null,
|
||||
ip,
|
||||
ua,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
// v1.23 — sync the admin-enforced-MFA forced-enrollment hint cookie.
|
||||
await syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
});
|
||||
|
||||
await auditLog("auth.login.passkey", {
|
||||
userId: user.id,
|
||||
|
||||
Reference in new issue
Block a user