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[],