import type { FastifyInstance } from 'fastify'; import { eq } from 'drizzle-orm'; import { users } from '@source/database'; import { hashPassword, verifyPassword } from '../../lib/password.js'; import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/jwt.js'; import type { RefreshTokenPayload } from '../../lib/jwt.js'; import { AppError } from '../../lib/errors.js'; import { RegisterSchema, LoginSchema } from './schemas.js'; const REFRESH_COOKIE_NAME = 'refresh_token'; const REFRESH_COOKIE_OPTIONS = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/api/auth', maxAge: 7 * 24 * 60 * 60, // 7 days in seconds }; export default async function authRoutes(app: FastifyInstance) { // POST /api/auth/register app.post('/register', { schema: RegisterSchema }, async (request, reply) => { const { email, username, password } = request.body as { email: string; username: string; password: string; }; // Check if email already exists const existingEmail = await app.db.query.users.findFirst({ where: eq(users.email, email), }); if (existingEmail) { throw AppError.conflict('Email already in use', 'EMAIL_TAKEN'); } // Check if username already exists const existingUsername = await app.db.query.users.findFirst({ where: eq(users.username, username), }); if (existingUsername) { throw AppError.conflict('Username already in use', 'USERNAME_TAKEN'); } const passwordHash = await hashPassword(password); const [user] = await app.db .insert(users) .values({ email, username, passwordHash, }) .returning({ id: users.id, email: users.email, username: users.username, isSuperAdmin: users.isSuperAdmin, }); // Generate tokens const accessToken = signAccessToken(app, { sub: user!.id, email: user!.email, isSuperAdmin: user!.isSuperAdmin, }); const refreshToken = signRefreshToken(app, { sub: user!.id, type: 'refresh', }); reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS); return reply.code(201).send({ user: { id: user!.id, email: user!.email, username: user!.username, isSuperAdmin: user!.isSuperAdmin, }, accessToken, }); }); // POST /api/auth/login app.post('/login', { schema: LoginSchema }, async (request, reply) => { const { email, password } = request.body as { email: string; password: string }; const user = await app.db.query.users.findFirst({ where: eq(users.email, email), }); if (!user) { throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS'); } const isValid = await verifyPassword(user.passwordHash, password); if (!isValid) { throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS'); } const accessToken = signAccessToken(app, { sub: user.id, email: user.email, isSuperAdmin: user.isSuperAdmin, }); const refreshToken = signRefreshToken(app, { sub: user.id, type: 'refresh', }); reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS); return { user: { id: user.id, email: user.email, username: user.username, isSuperAdmin: user.isSuperAdmin, avatarUrl: user.avatarUrl, }, accessToken, }; }); // POST /api/auth/refresh app.post('/refresh', async (request, reply) => { const token = request.cookies[REFRESH_COOKIE_NAME]; if (!token) { throw AppError.unauthorized('No refresh token', 'NO_REFRESH_TOKEN'); } let payload: RefreshTokenPayload; try { payload = verifyRefreshToken(app, token); } catch { reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' }); throw AppError.unauthorized('Invalid refresh token', 'INVALID_REFRESH_TOKEN'); } const user = await app.db.query.users.findFirst({ where: eq(users.id, payload.sub), }); if (!user) { reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' }); throw AppError.unauthorized('User not found', 'USER_NOT_FOUND'); } // Token rotation: issue new tokens const accessToken = signAccessToken(app, { sub: user.id, email: user.email, isSuperAdmin: user.isSuperAdmin, }); const newRefreshToken = signRefreshToken(app, { sub: user.id, type: 'refresh', }); reply.setCookie(REFRESH_COOKIE_NAME, newRefreshToken, REFRESH_COOKIE_OPTIONS); return { accessToken }; }); // POST /api/auth/logout app.post('/logout', async (_request, reply) => { reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' }); return { success: true }; }); // POST /api/auth/change-password app.post('/change-password', { onRequest: [app.authenticate] }, async (request) => { const { currentPassword, newPassword } = request.body as { currentPassword: string; newPassword: string; }; if (!currentPassword || !newPassword || newPassword.length < 8) { throw AppError.badRequest('New password must be at least 8 characters'); } const user = await app.db.query.users.findFirst({ where: eq(users.id, request.user.sub), }); if (!user) { throw AppError.notFound('User not found'); } const isValid = await verifyPassword(user.passwordHash, currentPassword); if (!isValid) { throw AppError.unauthorized('Current password is incorrect', 'INVALID_PASSWORD'); } const newHash = await hashPassword(newPassword); await app.db .update(users) .set({ passwordHash: newHash, updatedAt: new Date() }) .where(eq(users.id, user.id)); return { success: true }; }); // GET /api/auth/me app.get('/me', { onRequest: [app.authenticate] }, async (request) => { const payload = request.user; const user = await app.db.query.users.findFirst({ where: eq(users.id, payload.sub), columns: { id: true, email: true, username: true, isSuperAdmin: true, avatarUrl: true, createdAt: true, }, }); if (!user) { throw AppError.notFound('User not found'); } return { user }; }); }