mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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 },
|
||||
|
||||
@@ -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[],
|
||||
|
||||
Reference in new issue
Block a user