// src-ssr/routes/auth.js import express from 'express'; import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server'; import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already import prisma from '../database.js'; import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store import { getSetting } from '../utils/settings.js'; const router = express.Router(); // Helper function to get user authenticators async function getUserAuthenticators(userId) { return prisma.authenticator.findMany({ where: { userId }, select: { credentialID: true, credentialPublicKey: true, counter: true, transports: true, }, }); } // Helper function to get a user by username async function getUserByUsername(username) { return prisma.user.findUnique({ where: { username } }); } // Helper function to get a user by ID async function getUserById(id) { return prisma.user.findUnique({ where: { id } }); } // Helper function to get an authenticator by credential ID async function getAuthenticatorByCredentialID(credentialID) { return prisma.authenticator.findUnique({ where: { credentialID } }); } // Generate Registration Options router.post('/generate-registration-options', async(req, res) => { // Destructure username, email, and fullName from the request body const { username, email, fullName, registrationToken } = req.body; if (!username) { return res.status(400).json({ error: 'Username is required' }); } //Check if the registrationToken matches the setting const registrationTokenSetting = await getSetting('REGISTRATION_TOKEN'); if (registrationTokenSetting !== registrationToken) { return res.status(403).json({ error: 'Invalid registration token' }); } try { let user = await getUserByUsername(username); // If user doesn't exist, create one with the provided details if (!user) { const userData = { username }; if (email) userData.email = email; // Add email if provided if (fullName) userData.fullName = fullName; // Add fullName if provided user = await prisma.user.create({ data: userData, }); } const userAuthenticators = await getUserAuthenticators(user.id); if(userAuthenticators.length > 0) { //The user is trying to register a new authenticator, so we need to check if the user registering is the same as the one in the session if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id) { return res.status(403).json({ error: 'Invalid registration attempt.' }); } } const options = await generateRegistrationOptions({ rpName, rpID, userName: user.username, // Don't prompt users for additional authenticators if they've already registered some excludeCredentials: userAuthenticators.map(auth => ({ id: auth.credentialID, // Use isoBase64URL helper type: 'public-key', // Optional: Specify transports if you know them transports: auth.transports ? auth.transports.split(',') : undefined, })), authenticatorSelection: { // Defaults residentKey: 'required', userVerification: 'preferred', }, // Strong advice: Always require attestation for registration attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production }); // Store the challenge challengeStore.set(user.id, options.challenge); req.session.userId = user.id; // Temporarily store userId in session for verification step res.json(options); } catch (error) { console.error('Registration options error:', error); // Handle potential Prisma unique constraint errors (e.g., email already exists) if (error.code === 'P2002' && error.meta?.target?.includes('email')) { return res.status(409).json({ error: 'Email address is already in use.' }); } res.status(500).json({ error: 'Failed to generate registration options' }); } }); // Verify Registration router.post('/verify-registration', async(req, res) => { const { registrationResponse } = req.body; const userId = req.session.userId; // Retrieve userId stored during options generation if (!userId) { return res.status(400).json({ error: 'User session not found. Please start registration again.' }); } const expectedChallenge = challengeStore.get(userId); if (!expectedChallenge) { return res.status(400).json({ error: 'Challenge not found or expired' }); } try { const user = await getUserById(userId); if (!user) { return res.status(404).json({ error: 'User not found' }); } const verification = await verifyRegistrationResponse({ response: registrationResponse, expectedChallenge: expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, // Adjust based on your requirements }); const { verified, registrationInfo } = verification; if (verified && registrationInfo) { const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo; const credentialID = credential.id; const credentialPublicKey = credential.publicKey; const counter = credential.counter; const transports = credential.transports || []; // Use empty array if transports are not provided // Check if authenticator with this ID already exists const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID)); if (existingAuthenticator) { return res.status(409).json({ error: 'Authenticator already registered' }); } // Save the authenticator await prisma.authenticator.create({ data: { credentialID, // Store as Base64URL string credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes counter: BigInt(counter), // Store as BigInt credentialDeviceType, credentialBackedUp, transports: transports.join(','), // Store transports as comma-separated string userId: user.id, }, }); // Clear the challenge and temporary userId challengeStore.delete(userId); delete req.session.userId; // Log the user in by setting the final session userId req.session.loggedInUserId = user.id; res.json({ verified: true }); } else { // This else block was previously misplaced before the if block res.status(400).json({ error: 'Registration verification failed' }); } } catch (error) { console.error('Registration verification error:', error); // Handle potential Prisma unique constraint errors (e.g., email already exists) if (error.code === 'P2002' && error.meta?.target?.includes('email')) { return res.status(409).json({ error: 'Email address is already in use.' }); } challengeStore.delete(userId); // Clean up challenge on error delete req.session.userId; res.status(500).json({ error: 'Failed to verify registration', details: error.message }); } }); // Generate Authentication Options router.post('/generate-authentication-options', async(req, res) => { const { username } = req.body; try { let user; if (username) { user = await getUserByUsername(username); } else if (req.session.loggedInUserId) { // If already logged in, allow re-authentication (e.g., for step-up) user = await getUserById(req.session.loggedInUserId); } if (!user) { return res.status(404).json({ error: 'User not found' }); } const userAuthenticators = await getUserAuthenticators(user.id); const options = await generateAuthenticationOptions({ rpID, // Require users to use a previously-registered authenticator allowCredentials: userAuthenticators.map(auth => ({ id: auth.credentialID, type: 'public-key', transports: auth.transports ? auth.transports.split(',') : undefined, })), userVerification: 'preferred', }); // Store the challenge associated with the user ID for verification challengeStore.set(user.id, options.challenge); req.session.challengeUserId = user.id; // Store user ID associated with this challenge res.json(options); } catch (error) { console.error('Authentication options error:', error); res.status(500).json({ error: 'Failed to generate authentication options' }); } }); // Verify Authentication router.post('/verify-authentication', async(req, res) => { const { authenticationResponse } = req.body; const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge if (!challengeUserId) { return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' }); } const expectedChallenge = challengeStore.get(challengeUserId); if (!expectedChallenge) { return res.status(400).json({ error: 'Challenge not found or expired' }); } try { const user = await getUserById(challengeUserId); if (!user) { return res.status(404).json({ error: 'User associated with challenge not found' }); } const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id); if (!authenticator) { return res.status(404).json({ error: 'Authenticator not found' }); } // Ensure the authenticator belongs to the user attempting to log in if (authenticator.userId !== user.id) { return res.status(403).json({ error: 'Authenticator does not belong to this user' }); } const verification = await verifyAuthenticationResponse({ response: authenticationResponse, expectedChallenge: expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, credential: { id: authenticator.credentialID, publicKey: authenticator.credentialPublicKey, counter: authenticator.counter.toString(), // Convert BigInt to string for comparison transports: authenticator.transports ? authenticator.transports.split(',') : undefined, }, requireUserVerification: false, // Enforce user verification }); const { verified, authenticationInfo } = verification; if (verified) { // Update the authenticator counter await prisma.authenticator.update({ where: { credentialID: authenticator.credentialID }, data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter }); // Clear the challenge and associated user ID challengeStore.delete(challengeUserId); delete req.session.challengeUserId; // Log the user in req.session.loggedInUserId = user.id; res.json({ verified: true, user: { id: user.id, username: user.username } }); } else { res.status(400).json({ error: 'Authentication verification failed' }); } } catch (error) { console.error('Authentication verification error:', error); challengeStore.delete(challengeUserId); // Clean up challenge on error delete req.session.challengeUserId; res.status(500).json({ error: 'Failed to verify authentication', details: error.message }); } }); // GET Passkeys for Logged-in User router.get('/passkeys', async(req, res) => { if (!req.session.loggedInUserId) { return res.status(401).json({ error: 'Not authenticated' }); } try { const userId = req.session.loggedInUserId; const authenticators = await prisma.authenticator.findMany({ where: { userId }, select: { credentialID: true, // Already Base64URL string // Add other fields if needed, e.g., createdAt if you add it to the schema // createdAt: true, }, }); // No need to convert credentialID here as it's stored as Base64URL string res.json(authenticators); } catch (error) { console.error('Error fetching passkeys:', error); res.status(500).json({ error: 'Failed to fetch passkeys' }); } }); // DELETE Passkey router.delete('/passkeys/:credentialID', async(req, res) => { if (!req.session.loggedInUserId) { return res.status(401).json({ error: 'Not authenticated' }); } const { credentialID } = req.params; // This is already a Base64URL string from the client if (!credentialID) { return res.status(400).json({ error: 'Credential ID is required' }); } try { const userId = req.session.loggedInUserId; // Find the authenticator first to ensure it belongs to the logged-in user const authenticator = await prisma.authenticator.findUnique({ where: { credentialID: credentialID }, // Use the Base64URL string directly }); if (!authenticator) { return res.status(404).json({ error: 'Passkey not found' }); } // Security check: Ensure the passkey belongs to the user trying to delete it if (authenticator.userId !== userId) { return res.status(403).json({ error: 'Permission denied' }); } // Delete the authenticator await prisma.authenticator.delete({ where: { credentialID: credentialID }, }); res.json({ message: 'Passkey deleted successfully' }); } catch (error) { console.error('Error deleting passkey:', error); // Handle potential Prisma errors, e.g., record not found if deleted between check and delete if (error.code === 'P2025') { // Prisma code for record not found on delete/update return res.status(404).json({ error: 'Passkey not found' }); } res.status(500).json({ error: 'Failed to delete passkey' }); } }); // Check Authentication Status router.get('/status', async(req, res) => { if (req.session.loggedInUserId) { const user = await getUserById(req.session.loggedInUserId); if (!user) { req.session.destroy(err => {}); return res.status(401).json({ status: 'unauthenticated' }); } // Include email and fullName in the response return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email, fullName: user.fullName } }); } res.json({ status: 'unauthenticated' }); }); // Logout router.post('/logout', (req, res) => { req.session.destroy(err => { if (err) { console.error('Logout error:', err); return res.status(500).json({ error: 'Failed to logout' }); } res.json({ message: 'Logged out successfully' }); }); }); export default router;