import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { consumeMagicLink, destroySession, getSession, issueMagicLink, loginWithPassword, } from '@bmm/auth'; import { audit } from '../lib/audit.js'; import { config } from '../config.js'; const SESSION_COOKIE = 'bmm_session'; export async function authRoutes(app: FastifyInstance): Promise { app.post('/v1/auth/magic-link', async (req, reply) => { const Body = z.object({ email: z.string().email() }); const parsed = Body.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_email' }); try { const { token, expiresAt } = await issueMagicLink(parsed.data.email); const callbackUrl = `${config.NEXT_PUBLIC_APP_URL}/login/callback?token=${token}`; // Dev transport: print to stdout. Production: send via Resend / SES. app.log.info({ to: parsed.data.email, expiresAt }, `[magic-link] -> ${callbackUrl}`); console.log(`\n[magic-link] ${parsed.data.email} ->\n ${callbackUrl}\n`); return reply.send({ ok: true }); } catch (e) { app.log.error(e); return reply.code(400).send({ error: 'magic_link_failed' }); } }); app.post('/v1/auth/verify', async (req, reply) => { const Body = z.object({ token: z.string().min(10) }); const parsed = Body.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_token' }); try { const session = await consumeMagicLink(parsed.data.token, { ipAddress: req.ip, userAgent: req.headers['user-agent'], }); reply.setCookie(SESSION_COOKIE, session.sessionToken, { httpOnly: true, sameSite: 'lax', path: '/', secure: config.NODE_ENV === 'production', maxAge: 30 * 24 * 60 * 60, }); await audit({ orgId: session.orgId, userId: session.userId, action: 'auth.login', resourceType: 'session', metadata: { email: session.email }, ipAddress: req.ip, }); return reply.send({ ok: true, user: { id: session.userId, email: session.email, orgId: session.orgId }, }); } catch (e) { app.log.warn({ err: e }, 'magic link verify failed'); return reply.code(400).send({ error: 'invalid_or_expired_token' }); } }); app.get('/v1/auth/me', async (req, reply) => { const token = req.cookies[SESSION_COOKIE]; const session = await getSession(token); if (!session) return reply.code(401).send({ error: 'unauthorized' }); return reply.send({ user: session }); }); app.post('/v1/auth/admin/login', async (req, reply) => { const Body = z.object({ email: z.string().email(), password: z.string().min(8), }); const parsed = Body.safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ error: 'invalid_input' }); } try { const session = await loginWithPassword(parsed.data.email, parsed.data.password, { ipAddress: req.ip, userAgent: req.headers['user-agent'], }); if (!session.isAdmin) { await destroySession(session.sessionToken); return reply.code(403).send({ error: 'not_admin' }); } reply.setCookie(SESSION_COOKIE, session.sessionToken, { httpOnly: true, sameSite: 'lax', path: '/', secure: config.NODE_ENV === 'production', maxAge: 30 * 24 * 60 * 60, }); await audit({ orgId: session.orgId, userId: session.userId, action: 'admin.login', resourceType: 'session', metadata: { email: session.email }, ipAddress: req.ip, }); return reply.send({ ok: true, user: { id: session.userId, email: session.email, orgId: session.orgId, isAdmin: true }, }); } catch (err) { app.log.warn({ err }, 'admin login failed'); // Constant-time-ish to discourage username enumeration await new Promise((r) => setTimeout(r, 300)); return reply.code(401).send({ error: 'invalid_credentials' }); } }); app.post('/v1/auth/logout', async (req, reply) => { const token = req.cookies[SESSION_COOKIE]; const session = token ? await getSession(token) : null; if (token) await destroySession(token); reply.clearCookie(SESSION_COOKIE, { path: '/' }); if (session) { await audit({ orgId: session.orgId, userId: session.userId, action: 'auth.logout', resourceType: 'session', ipAddress: req.ip, }); } return reply.send({ ok: true }); }); }