import { createHash, randomBytes } from 'node:crypto' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { and, eq, gt, isNull } from 'drizzle-orm' import { config } from '../config.js' import { db } from '../db/client.js' import { refreshTokens, users } from '../db/schema.js' import { errors } from './errors.js' export const REFRESH_COOKIE = 'ff_refresh' export function hashToken(value: string) { return createHash('sha256').update(value).digest('hex') } export function generateRefreshToken() { return randomBytes(48).toString('base64url') } export function refreshCookieOptions() { return { httpOnly: true, secure: config.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/api/v1/auth', maxAge: config.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60, } } export function signAccessToken(app: FastifyInstance, user: { id: string; email: string; displayName: string }) { return app.jwt.sign({ sub: user.id, email: user.email, displayName: user.displayName, tokenType: 'access' as const, }, { expiresIn: config.ACCESS_TOKEN_TTL }) } export async function createRefreshSession(input: { userId: string userAgent?: string ipAddress?: string }) { const token = generateRefreshToken() const expiresAt = new Date(Date.now() + config.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000) const [session] = await db.insert(refreshTokens).values({ userId: input.userId, tokenHash: hashToken(token), expiresAt, userAgent: input.userAgent, ipAddress: input.ipAddress, }).returning() if (!session) throw new Error('Failed to create refresh session') return { token, session } } export async function rotateRefreshSession(token: string, meta: { userAgent?: string; ipAddress?: string }) { const [current] = await db.select().from(refreshTokens).where(and( eq(refreshTokens.tokenHash, hashToken(token)), isNull(refreshTokens.revokedAt), gt(refreshTokens.expiresAt, new Date()), )).limit(1) if (!current) throw errors.unauthorized('Refresh session is invalid or expired') const [user] = await db.select({ id: users.id, email: users.email, displayName: users.displayName, emailVerifiedAt: users.emailVerifiedAt, disabledAt: users.disabledAt, }).from(users).where(eq(users.id, current.userId)).limit(1) if (!user || user.disabledAt) throw errors.unauthorized('Account is unavailable') if (config.EMAIL_VERIFICATION_REQUIRED && !user.emailVerifiedAt) throw errors.emailVerificationRequired() const next = await createRefreshSession({ userId: user.id, ...meta }) await db.update(refreshTokens).set({ revokedAt: new Date(), replacedById: next.session.id, }).where(eq(refreshTokens.id, current.id)) return { user, token: next.token } } export async function revokeRefreshSession(token?: string) { if (!token) return await db.update(refreshTokens).set({ revokedAt: new Date() }).where(and( eq(refreshTokens.tokenHash, hashToken(token)), isNull(refreshTokens.revokedAt), )) } export async function requireAuth(request: FastifyRequest, _reply: FastifyReply) { try { await request.jwtVerify() } catch { throw errors.unauthorized() } if (request.user.tokenType !== 'access') throw errors.unauthorized() }