Repository Public
1/** Helpers shared by the auth route files. */2import type { FastifyReply, FastifyRequest } from 'fastify';3import { LoginStep } from '@cr/shared';4import { HttpError, type Ctx } from '../../http.js';5import type { Queryable } from '../../db.js';6import { setSessionCookie } from '../../security/guard.js';7import { ABSOLUTE_SEC, RESTRICTED_SEC, createSession, revokeSession, upgradeSession } from '../../security/sessions.js';8import { throttleCheck, type ThrottleKeys } from '../../security/throttle.js';9import type { StoredCredential } from '../../security/webauthn.js';1011export interface UserRow {12 id: string;13 username: string;14 password_hash: string;15 totp_secret_enc: string | null;16 totp_last_counter: number | null;17}18export const USER_COLS = 'id, username, password_hash, totp_secret_enc, totp_last_counter';19export const userById = (db: Queryable, id: string): Promise<UserRow | null> =>20 db.one<UserRow>(`SELECT ${USER_COLS} FROM users WHERE id = $1`, [id]);2122export async function credentialsOf(db: Queryable, userId: string): Promise<StoredCredential[]> {23 const rows = await db.q<{ credential_id: string; public_key: Buffer; counter: number; transports: string[] }>(24 'SELECT credential_id, public_key, counter, transports FROM devices WHERE user_id = $1 ORDER BY created_at',25 [userId],26 );27 return rows.map((r) => ({ credentialId: r.credential_id, publicKey: r.public_key, counter: r.counter, transports: r.transports }));28}29export async function factorsOf(db: Queryable, userId: string): Promise<{ totp: boolean; passkeys: number }> {30 const r = await db.one<{ totp: boolean; passkeys: number }>(31 `SELECT (totp_secret_enc IS NOT NULL) AS totp,32 (SELECT count(*)::int FROM devices d WHERE d.user_id = u.id) AS passkeys33 FROM users u WHERE u.id = $1`,34 [userId],35 );36 return r ?? { totp: false, passkeys: 0 };37}3839/** Throws 429 (with `retryAfterSec`) when the caller is currently locked out. */40export async function enforceThrottle(ctx: Ctx, reply: FastifyReply, keys: ThrottleKeys): Promise<void> {41 const wait = await throttleCheck(ctx.db, keys);42 if (wait > 0) {43 void reply.header('Retry-After', String(wait));44 throw new HttpError(429, 'locked', 'too many failed attempts, try again later', { retryAfterSec: wait });45 }46}4748/** Creates a session and sets the cookie. Any session already presented by the caller is revoked. */49export async function issueSession(50 ctx: Ctx,51 req: FastifyRequest,52 reply: FastifyReply,53 userId: string,54 secondFactor: boolean,55): Promise<void> {56 if (req.auth) {57 await revokeSession(ctx.db, req.auth.sessionId);58 ctx.live.kickAuthSessions([req.auth.sessionId]);59 }60 const s = await createSession(ctx.db, {61 userId,62 secondFactor,63 userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null,64 ip: req.ip,65 });66 setSessionCookie(ctx, reply, s.token, secondFactor ? ABSOLUTE_SEC : RESTRICTED_SEC);67}6869/** Marks the second factor complete: the token and CSRF token are rotated (new cookie). */70export async function upgradeCurrentSession(ctx: Ctx, req: FastifyRequest, reply: FastifyReply): Promise<void> {71 if (!req.auth) throw new HttpError(401, 'unauthenticated', 'authentication required');72 const s = await upgradeSession(ctx.db, req.auth.sessionId);73 setSessionCookie(ctx, reply, s.token, ABSOLUTE_SEC);74}7576export const step = (next: LoginStep['next'], ok = true): LoginStep => ({ ok, next });77export const invalidCredentials = (): HttpError => new HttpError(401, 'invalid_credentials', 'invalid credentials');7879/**80 * Enrolling a NEW second factor is allowed from a fully authenticated session, or from a81 * restricted one only while the account has no second factor at all (first-run enrolment).82 * Without this check a stolen password alone could attach an attacker-controlled factor.83 */84export async function assertMayEnrol(ctx: Ctx, req: FastifyRequest): Promise<void> {85 if (!req.auth) throw new HttpError(401, 'unauthenticated', 'authentication required');86 if (req.auth.secondFactor) return;87 const f = await factorsOf(ctx.db, req.auth.userId);88 if (f.totp || f.passkeys > 0) throw new HttpError(403, 'second_factor_required', 'second factor required');89}90