import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { generateAuthenticationOptions, generateRegistrationOptions, RegistrationResponseJSON, VerifiedAuthenticationResponse, verifyAuthenticationResponse, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { isoUint8Array } from "@simplewebauthn/server/helpers"; import { getUserByEmail, getUserByToken } from "../user/user.service"; import { createSession } from "../auth/auth.service"; const rpID = process.env.SERVER_DOMAIN; const rpName = "Quicker Permits"; const origin = `https://${rpID}`; export async function webAuthnRoutes(fastify: FastifyInstance) { // Registration request fastify.post<{ Body: { token: string } }>( "/register/request", { schema: { body: { type: "object", properties: { token: { type: "string" }, }, }, }, }, async (req, res: FastifyReply) => { const { token } = req.body; if (!token) { return res.code(400).send({ error: "bad request" }); } const userInDB = await getUserByToken(token); if (!userInDB) return res.code(400).send({ error: "bad request" }); if (new Date() > userInDB.token.expiry) return res.code(400).send({ error: "link expired" }); const userId = userInDB.id; const options = await generateRegistrationOptions({ rpName, rpID, userID: isoUint8Array.fromUTF8String(userId), userName: userInDB.email, attestationType: "none", excludeCredentials: userInDB.passKeys.map((cred) => ({ // @ts-ignore id: cred.credentialID, type: "public-key", // @ts-ignore transports: cred.transports, })), }); userInDB.challenge = { value: options.challenge, expiry: new Date(Date.now() + 60 * 10 * 1000), }; await userInDB.save(); return res.send(options); } ); // Registration verification fastify.post<{ Body: { token: string; attestationResponse: RegistrationResponseJSON; }; }>( "/register/verify", { schema: { body: { type: "object", properties: { token: { type: "string" }, attestationResponse: { type: "object", additionalProperties: true }, }, }, }, }, async (req, res: FastifyReply) => { const { token, attestationResponse } = req.body; const userInDB = await getUserByToken(token); if (!userInDB) return res.code(400).send({ error: "bad request" }); const challenge = userInDB.challenge; const tokenInDb = userInDB.token; if (!challenge) return res.code(400).send({ error: "not allowed" }); if (new Date() > challenge.expiry || new Date() > tokenInDb.expiry) return res.code(400).send({ error: "challenge expired" }); 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.status = "active"; 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: "object", 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) => ({ // @ts-ignore id: cred.credentialID, type: "public-key", // @ts-ignore 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( // @ts-ignore (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: { // @ts-ignore id: credential.credentialID, // @ts-ignore publicKey: credential.credentialPublicKey.buffer, // @ts-ignore counter: credential.counter, // @ts-ignore transports: credential.transports, }, }); if (!verification.verified) return res.code(400).send({ error: "Authentication failed" }); // @ts-ignore credential.counter = verification.authenticationInfo.newCounter; const newSession = await createSession( userInDB.id, req.ip, req.headers["user-agent"] ); userInDB.lastLogin = new Date(); await userInDB.save(); res.send({ session_token: newSession.sid }); } catch (error) { res.code(400).send({ error: (error as Error).message }); } } ); }