Files
permit-api/src/webauthn/webauthn.route.ts
2025-02-28 15:32:15 +05:30

252 lines
7.1 KiB
TypeScript

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 });
}
}
);
}