import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import { hashPassword, verifyPassword } from '../auth/passwords.js'; import { hashToken } from '../auth/account-tokens.js'; import type { AuthUser } from '../auth/plugin.js'; import { pool } from '../db/pool.js'; import { config } from '../config.js'; const credentialsSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), password: z.string().min(12).max(200), }); const registerSchema = credentialsSchema.extend({ name: z.string().trim().min(2).max(120), }); const tokenPasswordSchema = z.object({ token: z.string().min(20), password: z.string().min(12).max(200), name: z.string().trim().min(2).max(120).optional() }); type UserRow = { id: string; email: string; display_name: string; role: AuthUser['role']; password_hash: string; is_active: boolean; }; const serializeUser = (user: UserRow): AuthUser => ({ id: user.id, email: user.email, name: user.display_name, role: user.role, }); export const authRoutes: FastifyPluginAsync = async (app) => { const publicAttempts = new Map(); const allowPublicAuthAttempt = (ip: string) => { const now = Date.now(); const earliestAllowed = now - config.AUTH_RATE_LIMIT_WINDOW_SECONDS * 1000; const attempts = (publicAttempts.get(ip) || []).filter((timestamp) => timestamp > earliestAllowed); if (attempts.length >= config.AUTH_RATE_LIMIT_MAX) { publicAttempts.set(ip, attempts); return false; } attempts.push(now); publicAttempts.set(ip, attempts); return true; }; const rejectIfRateLimited = (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => { if (allowPublicAuthAttempt(ip)) return false; reply.code(429).send({ error: 'Too many attempts. Please try again later.' }); return true; }; app.post('/accept-invitation', async (request, reply) => { if (rejectIfRateLimited(request.ip, reply)) return; const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body); const client = await pool.connect(); try { await client.query('begin'); const token = await client.query<{ email: string; role: AuthUser['role'] }>( `update account_access_tokens set used_at = now() where token_hash = $1 and purpose = 'invitation'::account_token_purpose and used_at is null and expires_at > now() returning email, role`, [hashToken(input.token)], ); if (!token.rows[0]) { await client.query('rollback'); return reply.code(400).send({ error: 'This invitation is invalid or expired' }); } const result = await client.query( `insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4) returning id, email, display_name, role, password_hash, is_active`, [token.rows[0].email, await hashPassword(input.password), input.name, token.rows[0].role], ); await client.query('commit'); const user = serializeUser(result.rows[0]); return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: '7d' }), user }); } catch { await client.query('rollback'); return reply.code(409).send({ error: 'This invitation email already has an account' }); } finally { client.release(); } }); app.post('/reset-password', async (request, reply) => { if (rejectIfRateLimited(request.ip, reply)) return; const input = tokenPasswordSchema.parse(request.body); const client = await pool.connect(); try { await client.query('begin'); const token = await client.query<{ email: string }>( `update account_access_tokens set used_at = now() where token_hash = $1 and purpose = 'password_reset'::account_token_purpose and used_at is null and expires_at > now() returning email`, [hashToken(input.token)], ); if (!token.rows[0]) { await client.query('rollback'); return reply.code(400).send({ error: 'This reset link is invalid or expired' }); } await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]); await client.query('commit'); return reply.code(204).send(); } catch (error) { await client.query('rollback'); throw error; } finally { client.release(); } }); app.post('/register', async (request, reply) => { if (rejectIfRateLimited(request.ip, reply)) return; const input = registerSchema.parse(request.body); const passwordHash = await hashPassword(input.password); try { const result = await pool.query( `insert into users (email, password_hash, display_name) values ($1, $2, $3) returning id, email, display_name, role, password_hash, is_active`, [input.email, passwordHash, input.name], ); const user = serializeUser(result.rows[0]); const token = await reply.jwtSign(user, { expiresIn: '7d' }); return reply.code(201).send({ token, user }); } catch (error: unknown) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { return reply.code(409).send({ error: 'An account with this email already exists' }); } throw error; } }); app.post('/login', async (request, reply) => { if (rejectIfRateLimited(request.ip, reply)) return; const input = credentialsSchema.parse(request.body); const result = await pool.query( `select id, email, display_name, role, password_hash, is_active from users where email = $1`, [input.email], ); const account = result.rows[0]; if (!account || !account.is_active || !(await verifyPassword(input.password, account.password_hash))) { return reply.code(401).send({ error: 'Invalid email or password' }); } const user = serializeUser(account); const token = await reply.jwtSign(user, { expiresIn: '7d' }); return { token, user }; }); app.get('/me', { preHandler: app.authenticate }, async (request) => ({ user: request.user })); };