Repository Public
1/** Second factors: TOTP enrolment, passkey registration / login, device + session management. */2import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';3import { z } from 'zod';4import { Device, Id, LoginStep, TotpEnrollFinish, TotpEnrollStart } from '@cr/shared';5import { HttpError, authOf, notFound, parse, type Ctx } from '../../http.js';6import { randomToken, seal, sha256Hex, unseal } from '../../security/crypto.js';7import { cookieOptions } from '../../security/guard.js';8import { findSessionById, revokeSession, setPending } from '../../security/sessions.js';9import { throttleRecord } from '../../security/throttle.js';10import { base32Decode, generateTotpSecret, otpauthUrl, verifyTotp } from '../../security/totp.js';11import {12 AuthenticationCredential, CHALLENGE_TTL_MS, RegistrationCredential, authenticationOptions, registrationOptions,13 verifyAuthentication, verifyRegistration,14} from '../../security/webauthn.js';15import {16 assertMayEnrol, credentialsOf, enforceThrottle, factorsOf, invalidCredentials, issueSession, step, upgradeCurrentSession,17} from './common.js';1819/** Body of the verify endpoints: `{credential, label?}`; a bare credential JSON is accepted too. */20const wrapped = <S extends z.ZodType>(cred: S) =>21 z.preprocess(22 (v) => (v && typeof v === 'object' && 'id' in v && !('credential' in v) ? { credential: v } : v),23 z.object({ credential: cred, label: z.string().min(1).max(60).optional() }),24 );25const RegisterVerify = wrapped(RegistrationCredential);26const LoginVerify = wrapped(AuthenticationCredential);27const Pending = z.object({ challenge: z.string(), exp: z.number() });28const PendingTotp = z.object({ secretEnc: z.string(), exp: z.number() });2930async function pendingOf(ctx: Ctx, sessionId: string): Promise<Record<string, unknown>> {31 return (await findSessionById(ctx.db, sessionId))?.pending ?? {};32}3334export function registerFactorRoutes(app: FastifyInstance, ctx: Ctx): void {35 const { db, audit, config } = ctx;3637 // ---------- TOTP enrolment ----------38 app.post('/auth/totp/enroll/start', { config: { auth: 'restricted', doc: { summary: 'Begin TOTP enrolment', response: TotpEnrollStart, responseName: 'TotpEnrollStart' } } }, async (req) => {39 const a = authOf(req);40 await assertMayEnrol(ctx, req);41 const secret = generateTotpSecret();42 await setPending(db, a.sessionId, 'totpEnroll', {43 secretEnc: seal(config.secretKey, secret, `totp-enroll:${a.sessionId}`),44 exp: ctx.now() + 10 * 60_000,45 });46 await audit.req(req, 'auth.totp.enroll.start');47 return { secret, otpauthUrl: otpauthUrl(secret, a.username, ctx.config.agent.productName) };48 });4950 app.post('/auth/totp/enroll/finish', { config: { auth: 'restricted', doc: { summary: 'Confirm TOTP enrolment with a code', body: TotpEnrollFinish, bodyName: 'TotpCode', response: LoginStep, responseName: 'LoginStep' } } }, async (req, reply) => {51 const a = authOf(req);52 await assertMayEnrol(ctx, req);53 const body = parse(TotpEnrollFinish, req.body);54 const keys = { ip: req.ip, username: a.username };55 await enforceThrottle(ctx, reply, keys);56 const p = PendingTotp.safeParse((await pendingOf(ctx, a.sessionId)).totpEnroll);57 if (!p.success || p.data.exp < ctx.now()) throw new HttpError(409, 'no_enrolment', 'no TOTP enrolment in progress');58 const secret = unseal(config.secretKey, p.data.secretEnc, `totp-enroll:${a.sessionId}`);59 const counter = verifyTotp(base32Decode(secret), body.code, ctx.now(), null);60 if (counter === null) {61 await throttleRecord(db, keys, false);62 await audit.req(req, 'auth.totp.enroll.finish', { ok: false });63 throw new HttpError(401, 'invalid_code', 'invalid code');64 }65 await db.q('UPDATE users SET totp_secret_enc = $2, totp_last_counter = $3, updated_at = now() WHERE id = $1', [66 a.userId, seal(config.secretKey, secret, `totp:${a.userId}`), counter,67 ]);68 await setPending(db, a.sessionId, 'totpEnroll', null);69 if (!a.secondFactor) await upgradeCurrentSession(ctx, req, reply);70 await audit.req(req, 'auth.totp.enroll.finish');71 return step('done');72 });7374 app.delete('/auth/totp', { config: { doc: { summary: 'Remove TOTP (needs another second factor)' } } }, async (req) => {75 const a = authOf(req);76 if ((await factorsOf(db, a.userId)).passkeys === 0) {77 throw new HttpError(409, 'last_second_factor', 'cannot remove the last second factor');78 }79 await db.q('UPDATE users SET totp_secret_enc = NULL, totp_last_counter = NULL, updated_at = now() WHERE id = $1', [a.userId]);80 await audit.req(req, 'auth.totp.delete');81 return { ok: true };82 });8384 // ---------- passkey registration ----------85 app.post('/auth/passkey/register/options', { config: { auth: 'restricted', doc: { summary: 'WebAuthn registration options' } } }, async (req) => {86 const a = authOf(req);87 await assertMayEnrol(ctx, req);88 const options = await registrationOptions(config, { id: a.userId, username: a.username }, await credentialsOf(db, a.userId));89 await setPending(db, a.sessionId, 'webauthnRegister', { challenge: options.challenge, exp: ctx.now() + CHALLENGE_TTL_MS });90 return options;91 });9293 app.post('/auth/passkey/register/verify', { config: { auth: 'restricted', doc: { summary: 'WebAuthn registration verification', body: RegisterVerify, bodyName: 'PasskeyRegisterVerify', response: LoginStep, responseName: 'LoginStep' } } }, async (req, reply) => {94 const a = authOf(req);95 await assertMayEnrol(ctx, req);96 const body = parse(RegisterVerify, req.body);97 const keys = { ip: req.ip, username: a.username };98 await enforceThrottle(ctx, reply, keys);99 const p = Pending.safeParse((await pendingOf(ctx, a.sessionId)).webauthnRegister);100 await setPending(db, a.sessionId, 'webauthnRegister', null); // single use101 if (!p.success || p.data.exp < ctx.now()) throw new HttpError(409, 'no_challenge', 'no registration in progress');102 const cred = await verifyRegistration(config, body.credential, p.data.challenge);103 if (!cred) {104 await throttleRecord(db, keys, false);105 await audit.req(req, 'auth.passkey.register', { ok: false });106 throw new HttpError(401, 'passkey_rejected', 'passkey registration could not be verified');107 }108 const row = await db.one<{ id: string }>(109 `INSERT INTO devices (user_id, label, credential_id, public_key, counter, transports)110 VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (credential_id) DO NOTHING RETURNING id`,111 [a.userId, body.label ?? 'Passkey', cred.credentialId, cred.publicKey, cred.counter, cred.transports],112 );113 if (!row) throw new HttpError(409, 'already_registered', 'this passkey is already registered');114 if (!a.secondFactor) await upgradeCurrentSession(ctx, req, reply);115 await audit.req(req, 'auth.passkey.register', { target: row.id });116 return step('done');117 });118119 // ---------- passkey login (first factor on its own, or second step after the password) ----------120 const preCookie = config.preAuthCookieName;121 const clearPre = (reply: FastifyReply): void => void reply.clearCookie(preCookie, cookieOptions(ctx, 0));122123 app.post('/auth/passkey/login/options', { config: { auth: 'preauth', doc: { summary: 'WebAuthn authentication options' } } }, async (req, reply) => {124 await enforceThrottle(ctx, reply, { ip: req.ip });125 if (req.auth) {126 const options = await authenticationOptions(config, await credentialsOf(db, req.auth.userId));127 await setPending(db, req.auth.sessionId, 'webauthnLogin', { challenge: options.challenge, exp: ctx.now() + CHALLENGE_TTL_MS });128 return options;129 }130 const options = await authenticationOptions(config, null);131 const token = randomToken(32);132 await db.q(133 "INSERT INTO preauth_challenges (token_hash, challenge, ip, expires_at) VALUES ($1, $2, $3, now() + interval '5 minutes')",134 [sha256Hex(token), options.challenge, req.ip],135 );136 void reply.setCookie(preCookie, token, cookieOptions(ctx, CHALLENGE_TTL_MS / 1000));137 return options;138 });139140 async function takeChallenge(req: FastifyRequest, reply: FastifyReply): Promise<string | null> {141 if (req.auth) {142 const p = Pending.safeParse((await pendingOf(ctx, req.auth.sessionId)).webauthnLogin);143 await setPending(db, req.auth.sessionId, 'webauthnLogin', null);144 return p.success && p.data.exp >= ctx.now() ? p.data.challenge : null;145 }146 const token = req.cookies[preCookie];147 clearPre(reply);148 if (!token) return null;149 const row = await db.one<{ challenge: string }>(150 'DELETE FROM preauth_challenges WHERE token_hash = $1 AND expires_at > now() RETURNING challenge',151 [sha256Hex(token)],152 );153 return row?.challenge ?? null;154 }155156 app.post('/auth/passkey/login/verify', { config: { auth: 'preauth', doc: { summary: 'WebAuthn authentication verification', body: LoginVerify, bodyName: 'PasskeyLoginVerify', response: LoginStep, responseName: 'LoginStep' } } }, async (req, reply) => {157 const body = parse(LoginVerify, req.body);158 await enforceThrottle(ctx, reply, { ip: req.ip, username: req.auth?.username });159 const challenge = await takeChallenge(req, reply);160 if (!challenge) throw new HttpError(409, 'no_challenge', 'no passkey login in progress');161 const dev = await db.one<{ id: string; user_id: string; username: string; credential_id: string; public_key: Buffer; counter: number; transports: string[] }>(162 `SELECT d.id, d.user_id, u.username, d.credential_id, d.public_key, d.counter, d.transports163 FROM devices d JOIN users u ON u.id = d.user_id WHERE d.credential_id = $1`,164 [body.credential.id],165 );166 const sameUser = !req.auth || req.auth.userId === dev?.user_id;167 const newCounter =168 dev && sameUser169 ? await verifyAuthentication(config, body.credential, challenge, {170 credentialId: dev.credential_id, publicKey: dev.public_key, counter: dev.counter, transports: dev.transports,171 })172 : null;173 const keys = { ip: req.ip, username: dev?.username ?? req.auth?.username };174 if (!dev || newCounter === null) {175 await throttleRecord(db, keys, false);176 await audit.req(req, 'auth.passkey.login', { ok: false });177 throw invalidCredentials();178 }179 await db.q('UPDATE devices SET counter = $2, last_used_at = now() WHERE id = $1', [dev.id, newCounter]);180 await throttleRecord(db, keys, true);181 if (req.auth && !req.auth.secondFactor) await upgradeCurrentSession(ctx, req, reply);182 else await issueSession(ctx, req, reply, dev.user_id, true);183 await audit.record({ actor: dev.username, action: 'auth.passkey.login', ip: req.ip, target: dev.id });184 return step('done');185 });186187 // ---------- devices (passkeys + active sessions) ----------188 app.get('/auth/devices', { config: { doc: { summary: 'Passkeys and active sessions', response: z.array(Device), responseName: 'DeviceList' } } }, async (req): Promise<Device[]> => {189 const a = authOf(req);190 const passkeys = await db.q<{ id: string; label: string; created_at: Date; last_used_at: Date | null }>(191 'SELECT id, label, created_at, last_used_at FROM devices WHERE user_id = $1 ORDER BY created_at', [a.userId],192 );193 const sessions = await db.q<{ id: string; user_agent: string | null; ip: string | null; created_at: Date; last_seen_at: Date }>(194 `SELECT id, user_agent, host(ip) AS ip, created_at, last_seen_at FROM auth_sessions195 WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > now() AND second_factor196 AND last_seen_at > now() - interval '12 hours' ORDER BY last_seen_at DESC`,197 [a.userId],198 );199 return [200 ...passkeys.map((p): Device => ({201 id: p.id, kind: 'passkey', label: p.label, createdAt: p.created_at.toISOString(),202 lastUsedAt: p.last_used_at?.toISOString() ?? null, current: false, userAgent: null, ip: null,203 })),204 ...sessions.map((s): Device => ({205 id: s.id, kind: 'session', label: 'Browser session', createdAt: s.created_at.toISOString(),206 lastUsedAt: s.last_seen_at.toISOString(), current: s.id === a.sessionId, userAgent: s.user_agent, ip: s.ip,207 })),208 ];209 });210211 app.delete('/auth/devices/:id', { config: { doc: { summary: 'Remove a passkey or revoke a session' } } }, async (req) => {212 const a = authOf(req);213 const { id } = parse(z.object({ id: Id }), req.params);214 const passkey = await db.one<{ id: string }>('SELECT id FROM devices WHERE id = $1 AND user_id = $2', [id, a.userId]);215 if (passkey) {216 const f = await factorsOf(db, a.userId);217 if (!f.totp && f.passkeys <= 1) throw new HttpError(409, 'last_second_factor', 'cannot remove the last second factor');218 await db.q('DELETE FROM devices WHERE id = $1', [id]);219 await audit.req(req, 'auth.device.delete', { target: id, detail: { kind: 'passkey' } });220 return { ok: true };221 }222 const session = await db.one<{ id: string }>('SELECT id FROM auth_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL', [id, a.userId]);223 if (!session) throw notFound('device');224 await revokeSession(db, id);225 ctx.live.kickAuthSessions([id]);226 await audit.req(req, 'auth.device.delete', { target: id, detail: { kind: 'session' } });227 return { ok: true };228 });229}230