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