import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { generateAuthenticationOptions, generateRegistrationOptions, RegistrationResponseJSON, VerifiedAuthenticationResponse, verifyAuthenticationResponse, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { isoUint8Array } from "@simplewebauthn/server/helpers"; import { getUserByEmail } from "../user/user.service"; import { createSession } from "../auth/auth.service"; import { generateOTP } from "../utils/id"; import { sendMail } from "../utils/mail"; const rpID = process.env.SERVER_DOMAIN; const rpName = "Quicker Permits"; const origin = `http://${rpID}:3000`; export async function webAuthnRoutes(fastify: FastifyInstance) { // Registration request fastify.post<{ Body: { email: string } }>( "/register/request", { schema: { body: { type: "object", properties: { email: { type: "string" }, }, }, }, }, async (req, res: FastifyReply) => { const { email } = req.body; if (!email) { return res.code(400).send({ error: "Email is required" }); } const userInDB = await getUserByEmail(email); if (!userInDB) return res.code(400).send({ error: "not allowed" }); const otp = generateOTP(); const userId = userInDB.id; const options = await generateRegistrationOptions({ rpName, rpID, userID: isoUint8Array.fromUTF8String(userId), userName: email, attestationType: "none", excludeCredentials: userInDB.passKeys.map((cred) => ({ id: cred.credentialID, type: "public-key", transports: cred.transports, })), }); userInDB.challenge = { value: options.challenge, expiry: new Date(Date.now() + 60 * 5 * 1000), }; userInDB.otp = { value: otp, expiry: new Date(Date.now() + 60 * 5 * 1000), }; await userInDB.save(); const sent = await sendMail( email, "Code for Quicker Permits Authentication", `Your code is ${otp}` ); if (!sent) return res.code(500).send({ error: "server error" }); return res.send(options); } ); // Registration verification fastify.post<{ Body: { email: string; code: string; attestationResponse: RegistrationResponseJSON; }; }>( "/register/verify", { schema: { body: { type: "object", properties: { email: { type: "string" }, code: { type: "string" }, attestationResponse: { type: "object" }, }, }, }, }, async (req, res: FastifyReply) => { const { email, code, attestationResponse } = req.body; const userInDB = await getUserByEmail(email); if (!userInDB) return res.code(400).send({ error: "not allowed" }); const challenge = userInDB.challenge; const requiredOTP = userInDB.otp; if (!challenge || !requiredOTP) return res.code(400).send({ error: "not allowed" }); if (new Date() > challenge.expiry || new Date() > requiredOTP.expiry) return res.code(400).send({ error: "challenge expired" }); if (code != requiredOTP.value) return res.code(400).send({ error: "invalid code" }); try { const verification = await verifyRegistrationResponse({ response: attestationResponse, expectedChallenge: challenge.value as string, expectedRPID: rpID, expectedOrigin: origin, }); if (!verification.verified) { return res.code(400).send({ error: "registration failed" }); } userInDB.passKeys.push({ credentialID: verification.registrationInfo.credential.id, credentialPublicKey: verification.registrationInfo.credential.publicKey, counter: verification.registrationInfo.credential.counter, transports: attestationResponse.response.transports, }); await userInDB.save(); return res.code(200).send({ success: "registration complete" }); } catch (error) { return res.code(400).send({ error: error.message }); } } ); // Authentication request fastify.post<{ Body: { email: string } }>( "/login/request", { schema: { body: { type: "string", properties: { email: { type: "string" }, }, }, }, }, async (req, res) => { const { email } = req.body; const userInDB = await getUserByEmail(email); if (!userInDB || userInDB.passKeys.length === 0) { return res.code(400).send({ error: "user not registered" }); } const options: PublicKeyCredentialRequestOptionsJSON = await generateAuthenticationOptions({ rpID, allowCredentials: userInDB.passKeys.map((cred) => ({ id: cred.credentialID, type: "public-key", transports: cred.transports, })), userVerification: "preferred", }); userInDB.challenge.value = options.challenge; userInDB.challenge.expiry = new Date(Date.now() + 60 * 5 * 1000); await userInDB.save(); return res.send(options); } ); // Authentication Verification fastify.post<{ Body: { email: string; assertionResponse: any } }>( "/login/verify", { schema: { body: { type: "object", properties: { email: { type: "string" }, assertionResponse: { type: "object" }, }, }, }, }, async (req, res) => { const { email, assertionResponse } = req.body; const userInDB = await getUserByEmail(email); if (!userInDB) return res.code(400).send({ error: "not allowed" }); const challenge = userInDB.challenge; if (!challenge) return res.code(400).send({ error: "not allowed" }); if (new Date() > challenge.expiry) return res.code(400).send({ error: "challenge expired" }); try { const credential = userInDB.passKeys.find( (cred) => cred.credentialID === assertionResponse.id ); if (!credential) return res.code(400).send({ error: "credential not found" }); const verification: VerifiedAuthenticationResponse = await verifyAuthenticationResponse({ response: assertionResponse, expectedChallenge: userInDB.challenge.value as string, expectedRPID: rpID, expectedOrigin: origin, credential: credential, }); if (!verification.verified) return res.code(400).send({ error: "Authentication failed" }); credential.counter = verification.authenticationInfo.newCounter; const newSession = await createSession( userInDB.id, req.ip, req.headers["user-agent"] ); res.send({ session_token: newSession.sid }); } catch (error) { res.code(400).send({ error: (error as Error).message }); } } ); }