fix(auth): Add-credential-Challenges pro Versuch und single-use

Speichert Challenges nach challenge statt userId für parallele Flows und invalidiert sie vor der Verifikation, damit fehlgeschlagene Versuche keine Leaks hinterlassen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 09:25:02 +02:00
parent 75eba362d6
commit 6ad75ff947
2 changed files with 16 additions and 10 deletions
+1 -1
View File
@@ -613,7 +613,7 @@ export async function addPasskey(): Promise<void> {
await apiJson(`${API_BASE}/add-credential-verify`, {
method: 'POST',
body: JSON.stringify({ credentialResponse })
body: JSON.stringify({ credentialResponse, challenge: options.challenge })
})
const masterKey = getActiveMasterKey()
+15 -9
View File
@@ -22,6 +22,7 @@ const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
const registrationChallenges = new Map<string, string>()
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
const addCredentialChallenges = new Map<string, string>()
const activeChallenges = new Set<string>()
@@ -462,7 +463,7 @@ router.post('/add-credential-options', requireReauth, async (req: any, res) => {
excludeCredentials
})
addCredentialChallenges.set(req.userId, options.challenge)
addCredentialChallenges.set(options.challenge, req.userId)
return res.json(options)
} catch (error: any) {
@@ -473,16 +474,23 @@ router.post('/add-credential-options', requireReauth, async (req: any, res) => {
router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
try {
const { credentialResponse } = req.body
if (!credentialResponse) {
return res.status(400).json({ error: 'credentialResponse is required' })
const { credentialResponse, challenge } = req.body
if (!credentialResponse || !challenge) {
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
}
const expectedChallenge = addCredentialChallenges.get(req.userId)
if (!expectedChallenge) {
const challengeUserId = addCredentialChallenges.get(challenge)
if (!challengeUserId) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
if (challengeUserId !== req.userId) {
return res.status(403).json({ error: 'Challenge does not belong to this account' })
}
// Single-use: invalidate before verification so failed attempts cannot be retried
addCredentialChallenges.delete(challenge)
const user = await prisma.user.findUnique({
where: { id: req.userId }
})
@@ -493,7 +501,7 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
const verification = await verifyRegistrationResponse({
response: credentialResponse,
expectedChallenge,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpID
})
@@ -522,8 +530,6 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
}
})
addCredentialChallenges.delete(req.userId)
return res.json({
verified: true,
credential: {