diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index 1c03e80..74e19e3 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -613,7 +613,7 @@ export async function addPasskey(): Promise { await apiJson(`${API_BASE}/add-credential-verify`, { method: 'POST', - body: JSON.stringify({ credentialResponse }) + body: JSON.stringify({ credentialResponse, challenge: options.challenge }) }) const masterKey = getActiveMasterKey() diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index f9cf483..65bd7f4 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -22,6 +22,7 @@ const rpID = process.env.RP_ID || 'localhost' const origin = process.env.ORIGIN || 'http://localhost:5173' const registrationChallenges = new Map() +/** WebAuthn registration challenges for add-credential flow: challenge -> userId */ const addCredentialChallenges = new Map() const activeChallenges = new Set() @@ -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: {