From 2c1151dd40ab1c3c5b223afa97bae1b1a30bccd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 22:22:46 +0200 Subject: [PATCH] fix(auth): guard the webauthn login challenge kind and require user verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the TOTP login path: both the security-key verify and verify-options routes now reject any challenge whose kind is not "login" with the same generic 401, so a future step-up or non-login challenge can never mint a login session through these endpoints. Set the registration and authentication ceremonies to userVerification:"required" to match the server's UV enforcement — a key that passes the browser but skips UV would otherwise fail server-side. --- .../api/auth/mfa/webauthn/verify/options/route.ts | 7 +++++++ src/app/api/auth/mfa/webauthn/verify/route.ts | 8 ++++++++ src/lib/auth/mfa/webauthn.ts | 13 +++++++++---- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/app/api/auth/mfa/webauthn/verify/options/route.ts b/src/app/api/auth/mfa/webauthn/verify/options/route.ts index 4f7a569d8..aeff3e827 100644 --- a/src/app/api/auth/mfa/webauthn/verify/options/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/options/route.ts @@ -54,6 +54,13 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Invalid or expired challenge", 401); } + // Only a login-issued challenge may begin a login assertion here. Reject any + // other challenge kind with the same generic 401. + if (challenge.kind !== "login") { + 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 diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts index c7ba603e5..3ad7c0b57 100644 --- a/src/app/api/auth/mfa/webauthn/verify/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -69,6 +69,14 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Invalid or expired challenge", 401); } + // Only a login-issued challenge may be redeemed for a session here. A future + // step-up or non-login challenge kind must never mint a login session through + // this endpoint, so reject anything but "login" with the same generic 401. + if (challenge.kind !== "login") { + 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 }, diff --git a/src/lib/auth/mfa/webauthn.ts b/src/lib/auth/mfa/webauthn.ts index aa5dc36ef..0358321af 100644 --- a/src/lib/auth/mfa/webauthn.ts +++ b/src/lib/auth/mfa/webauthn.ts @@ -122,9 +122,12 @@ export async function createMfaRegistrationOptions( // credential need not be discoverable — and a non-resident key does not // burn a discoverable-credential slot, allowing many keys per account. residentKey: "discouraged", - // A roaming security key supplies the possession factor; UV is a bonus, - // not a requirement, because the password already covered a factor. - userVerification: "preferred", + // The server verify enforces user verification (SimpleWebAuthn's default + // `requireUserVerification: true`), so the browser ceremony must ask for + // it too — a key that passes the browser but skips UV would otherwise + // fail server-side. Make the intent explicit rather than relying on the + // ceremony defaulting to a stricter posture. + userVerification: "required", authenticatorAttachment: "cross-platform", }, }); @@ -200,7 +203,9 @@ export async function createMfaAuthenticationOptions(userId: string) { const options = await generateAuthenticationOptions({ rpID: getRpId(), - userVerification: "preferred", + // Match server-side enforcement: the verify step requires user + // verification (SimpleWebAuthn default), so the browser must request it too. + userVerification: "required", allowCredentials: credentials.map((c) => ({ id: c.credentialId, transports: c.transports as Transport[],