fix(auth): guard the webauthn login challenge kind and require user verification

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 22:22:46 +02:00
1 parent 51b8db166d
commit 2c1151dd40
3 files changed
+24 -4

No files matched your search

@@ -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
@@ -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 },
+9 -4
View File
@@ -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[],