Files
platform/src/servers/api/auth/passkey-router.ts
T

194 lines
5.7 KiB
TypeScript

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