import { Router } from 'express' import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server' import { prisma } from '../db.js' import { requireReauth, requireUser } from '../middleware/auth.js' import { clearSessionCookie, extendReauth, readSessionFromRequest, setSessionCookie, setSessionTokenCookie } from '../session.js' const router = Router() const rpName = 'Kapteins Daagbok' const rpID = process.env.RP_ID || 'localhost' const origin = process.env.ORIGIN || 'http://localhost:5173' const registrationChallenges = new Map() const activeChallenges = new Set() router.post('/register-options', async (req, res) => { try { const { username } = req.body if (!username) { return res.status(400).json({ error: 'Username is required' }) } const existingUser = await prisma.user.findUnique({ where: { username } }) if (existingUser) { return res.status(400).json({ error: 'User already exists' }) } const userID = Buffer.from(username, 'utf8').toString('base64url') const options = await generateRegistrationOptions({ rpName, rpID, userID, userName: username, userDisplayName: username, attestationType: 'none', authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, supportedAlgorithmIDs: [-7, -257] }) registrationChallenges.set(username, options.challenge) return res.json(options) } catch (error: any) { console.error('Error generating registration options:', error) return res.status(500).json({ error: error.message || 'Internal server error' }) } }) router.post('/register-verify', async (req, res) => { try { const { username, credentialResponse, encryptedMasterKeyPrf, encryptedMasterKeyPrfIv, encryptedMasterKeyPrfTag, encryptedMasterKeyRec, encryptedMasterKeyRecIv, encryptedMasterKeyRecTag } = req.body if (!username || !credentialResponse) { return res.status(400).json({ error: 'Username and credentialResponse are required' }) } const expectedChallenge = registrationChallenges.get(username) if (!expectedChallenge) { return res.status(400).json({ error: 'Challenge not found or expired' }) } const verification = await verifyRegistrationResponse({ response: credentialResponse, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID }) if (!verification.verified || !verification.registrationInfo) { return res.status(400).json({ error: 'WebAuthn verification failed' }) } const { credentialID, credentialPublicKey, counter } = verification.registrationInfo const user = await prisma.user.create({ data: { username, encryptedMasterKeyPrf, encryptedMasterKeyPrfIv, encryptedMasterKeyPrfTag, encryptedMasterKeyRec, encryptedMasterKeyRecIv, encryptedMasterKeyRecTag, credentials: { create: { credentialId: Buffer.from(credentialID).toString('base64url'), publicKey: Buffer.from(credentialPublicKey), counter: BigInt(counter), transports: credentialResponse.response.transports || [] } } } }) registrationChallenges.delete(username) setSessionCookie(res, user.id, true) return res.json({ verified: true, userId: user.id }) } catch (error: any) { console.error('Error verifying registration response:', error) return res.status(500).json({ error: error.message || 'Internal server error' }) } }) router.post('/login-options', async (req, res) => { try { const { username } = req.body let allowCredentials: any[] = [] if (username) { const user = await prisma.user.findUnique({ where: { username }, include: { credentials: true } }) if (user) { allowCredentials = user.credentials.map((cred) => ({ id: Buffer.from(cred.credentialId, 'base64url'), type: 'public-key', transports: cred.transports as any[] })) } } const options = await generateAuthenticationOptions({ rpID, allowCredentials, userVerification: 'preferred' }) activeChallenges.add(options.challenge) return res.json(options) } catch (error: any) { console.error('Error generating authentication options:', error) return res.status(500).json({ error: error.message || 'Internal server error' }) } }) router.post('/login-verify', async (req, res) => { try { const { credentialResponse, challenge } = req.body if (!credentialResponse || !challenge) { return res.status(400).json({ error: 'credentialResponse and challenge are required' }) } if (!activeChallenges.has(challenge)) { return res.status(400).json({ error: 'Challenge not found or expired' }) } activeChallenges.delete(challenge) const dbCred = await prisma.credential.findUnique({ where: { credentialId: credentialResponse.id }, include: { user: true } }) if (!dbCred) { return res.status(400).json({ error: 'Credential not recognized' }) } const user = dbCred.user const verification = await verifyAuthenticationResponse({ response: credentialResponse, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: { credentialID: Buffer.from(dbCred.credentialId, 'base64url'), credentialPublicKey: dbCred.publicKey, counter: Number(dbCred.counter) } }) if (!verification.verified || !verification.authenticationInfo) { return res.status(400).json({ error: 'Authentication failed' }) } await prisma.credential.update({ where: { id: dbCred.id }, data: { counter: BigInt(verification.authenticationInfo.newCounter) } }) setSessionCookie(res, user.id, true) return res.json({ verified: true, userId: user.id, username: user.username, encryptedMasterKeyPrf: user.encryptedMasterKeyPrf, encryptedMasterKeyPrfIv: user.encryptedMasterKeyPrfIv, encryptedMasterKeyPrfTag: user.encryptedMasterKeyPrfTag, encryptedMasterKeyRec: user.encryptedMasterKeyRec, encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv, encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag }) } catch (error: any) { console.error('Error verifying authentication response:', error) return res.status(500).json({ error: error.message || 'Internal server error' }) } }) router.get('/session', (req, res) => { const session = readSessionFromRequest(req) if (!session) { return res.status(401).json({ authenticated: false }) } return res.json({ authenticated: true, userId: session.userId }) }) router.post('/logout', (req, res) => { clearSessionCookie(res) return res.json({ success: true }) }) router.post('/reauth-options', requireUser, async (req: any, res) => { try { const user = await prisma.user.findUnique({ where: { id: req.userId }, include: { credentials: true } }) if (!user || user.credentials.length === 0) { return res.status(400).json({ error: 'No passkey credentials found' }) } const allowCredentials = user.credentials.map((cred) => ({ id: Buffer.from(cred.credentialId, 'base64url'), type: 'public-key' as const, transports: cred.transports as any[] })) const options = await generateAuthenticationOptions({ rpID, allowCredentials, userVerification: 'required' }) activeChallenges.add(options.challenge) return res.json(options) } catch (error: any) { console.error('Error generating reauth options:', error) return res.status(500).json({ error: error.message || 'Internal server error' }) } }) router.post('/reauth-verify', requireUser, async (req: any, res) => { try { const { credentialResponse, challenge } = req.body if (!credentialResponse || !challenge) { return res.status(400).json({ error: 'credentialResponse and challenge are required' }) } if (!activeChallenges.has(challenge)) { return res.status(400).json({ error: 'Challenge not found or expired' }) } activeChallenges.delete(challenge) const dbCred = await prisma.credential.findUnique({ where: { credentialId: credentialResponse.id }, include: { user: true } }) if (!dbCred || dbCred.userId !== req.userId) { return res.status(403).json({ error: 'Credential does not belong to this account' }) } const verification = await verifyAuthenticationResponse({ response: credentialResponse, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: { credentialID: Buffer.from(dbCred.credentialId, 'base64url'), credentialPublicKey: dbCred.publicKey, counter: Number(dbCred.counter) } }) if (!verification.verified || !verification.authenticationInfo) { return res.status(400).json({ error: 'Reauthentication failed' }) } await prisma.credential.update({ where: { id: dbCred.id }, data: { counter: BigInt(verification.authenticationInfo.newCounter) } }) const currentToken = req.cookies?.daagbok_session const extended = typeof currentToken === 'string' ? extendReauth(currentToken) : null if (extended) { setSessionTokenCookie(res, extended) } else { setSessionCookie(res, req.userId, true) } return res.json({ verified: true }) } catch (error: any) { console.error('Error verifying reauth:', error) return res.status(500).json({ error: error.message || 'Internal server error' }) } }) router.delete('/delete-account', requireReauth, async (req: any, res) => { try { const user = await prisma.user.findUnique({ where: { id: req.userId } }) if (!user) { return res.status(404).json({ error: 'User not found' }) } await prisma.user.delete({ where: { id: req.userId } }) clearSessionCookie(res) return res.json({ success: true }) } catch (error: any) { console.error('Error deleting account:', error) return res.status(500).json({ error: error.message || 'Internal server error' }) } }) router.post('/enroll-prf', requireReauth, async (req: any, res) => { try { const { encryptedMasterKeyPrf, encryptedMasterKeyPrfIv, encryptedMasterKeyPrfTag } = req.body if (!encryptedMasterKeyPrf || !encryptedMasterKeyPrfIv || !encryptedMasterKeyPrfTag) { return res.status(400).json({ error: 'Missing required PRF key fields' }) } if ( typeof encryptedMasterKeyPrf !== 'string' || typeof encryptedMasterKeyPrfIv !== 'string' || typeof encryptedMasterKeyPrfTag !== 'string' ) { return res.status(400).json({ error: 'Invalid PRF key fields format' }) } await prisma.user.update({ where: { id: req.userId }, data: { encryptedMasterKeyPrf, encryptedMasterKeyPrfIv, encryptedMasterKeyPrfTag } }) return res.json({ success: true }) } catch (error: any) { console.error('Error enrolling PRF key:', error) return res.status(500).json({ error: error.message || 'Internal server error' }) } }) export default router