import type { Handler } from 'hono'; import type { User } from 'types'; import { createRouter } from '../../create-router'; import { userMiddleware, passkeyRateLimiter } from '../../_middlewares'; import { sign } from '../../jwt'; import * as errors from '../../custom-errors'; import { isLockdown, noteBlocked } from './panic'; import { getUserByEmail, getPasskeysByUserIdAndOrigin, getPasskeyByCredentialId, createPasskey, updatePasskey, storeChallenge, consumeChallenge, } from 'officerdb'; import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server'; import type { RegistrationResponseJSON, AuthenticationResponseJSON } from '@simplewebauthn/server'; export const passkeyRouter = createRouter(); const CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes const RP_NAME = 'Officer'; function getRpId(origin: string): string { try { const url = new URL(origin); return url.hostname; } catch { return 'officer.dev'; } } // Generate registration options (challenge) for creating a new passkey const passkeyRouterPostChallenge: Handler = async (ctx) => { const { email } = ctx.req.param(); const origin = ctx.get('origin') as string; const rpId = getRpId(origin); const dbUser = await getUserByEmail(email!); if (!dbUser) throw errors.NOT_FOUND('User not found'); // Get existing passkeys to exclude them const existingPasskeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin); const options = await generateRegistrationOptions({ rpName: RP_NAME, rpID: rpId, userName: email!, userDisplayName: email!, attestationType: 'none', excludeCredentials: existingPasskeys.map((p) => ({ id: p.credentialId!, })), authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', }, }); await storeChallenge(dbUser.id, origin, options.challenge, CHALLENGE_TTL_MS); return ctx.json(options); }; passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostChallenge); // Verify and store new passkey registration const passkeyRouterPost: Handler = async (ctx) => { const origin = ctx.get('origin') as string; const rpId = getRpId(origin); const user = ctx.get('user') as User; const response = ctx.get('body') as RegistrationResponseJSON; const storedChallenge = await consumeChallenge(user.id, origin); if (!storedChallenge) throw errors.BAD_CREDENTIALS(); const verification = await verifyRegistrationResponse({ response, expectedChallenge: storedChallenge, expectedOrigin: origin, expectedRPID: rpId, }); if (!verification.verified || !verification.registrationInfo) { throw errors.BAD_CREDENTIALS(); } const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; await createPasskey({ userId: user.id, origin, credentialId: credential.id, publicKey: Buffer.from(credential.publicKey).toString('base64'), counter: credential.counter, }); return ctx.json({ ok: true, credentialDeviceType, credentialBackedUp }); }; passkeyRouter.post('/credentials', userMiddleware, passkeyRouterPost); // Generate authentication options for signing in with passkey const passkeyRouterGet: Handler = async (ctx) => { const { email } = ctx.req.param(); const origin = ctx.get('origin') as string; const rpId = getRpId(origin); const dbUser = await getUserByEmail(email!); if (!dbUser) throw errors.NOT_FOUND('User not found'); const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin); const options = await generateAuthenticationOptions({ rpID: rpId, allowCredentials: passkeys.map((p) => ({ id: p.credentialId!, })), userVerification: 'preferred', }); await storeChallenge(dbUser.id, origin, options.challenge, CHALLENGE_TTL_MS); return ctx.json(options); }; passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet); // Verify passkey authentication and issue token const passkeyRouterPostVerify: Handler = async (ctx) => { if (isLockdown()) { noteBlocked('passkey login'); throw errors.UNAUTHORIZED(); } // duress lockdown blocks passkey logins too const { email } = ctx.req.param(); const origin = ctx.get('origin') as string; const rpId = getRpId(origin); const response = ctx.get('body') as AuthenticationResponseJSON; const dbUser = await getUserByEmail(email!); if (!dbUser) throw errors.UNAUTHORIZED(); const storedChallenge = await consumeChallenge(dbUser.id, origin); if (!storedChallenge) throw errors.BAD_CREDENTIALS(); // Find the passkey being used const dbPasskey = await getPasskeyByCredentialId(dbUser.id, response.id); if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS(); const verification = await verifyAuthenticationResponse({ response, expectedChallenge: storedChallenge, expectedOrigin: origin, expectedRPID: rpId, credential: { id: dbPasskey.credentialId!, publicKey: Buffer.from(dbPasskey.publicKey, 'base64'), counter: dbPasskey.counter, }, }); if (!verification.verified) throw errors.BAD_CREDENTIALS(); // Update counter to prevent replay attacks await updatePasskey(dbPasskey.id, { counter: verification.authenticationInfo.newCounter }); const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin); const { id, name, username, role } = dbUser; const token = await sign({ id, email, name, username, role, passkeys: passkeys.length, }); return ctx.json({ token, user: { id, email, name, username, role, passkeys: passkeys.length, }, }); }; passkeyRouter.post('/verify/:email', passkeyRateLimiter, passkeyRouterPostVerify);