diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts new file mode 100644 index 0000000..e9377b1 --- /dev/null +++ b/backend/src/lib/auth.ts @@ -0,0 +1,62 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { SessionContext } from "./serializers.js"; +import { forbidden, permissionDenied } from "./envelope.js"; + +export function parseCookies(header: string | undefined) { + const cookies: Record = {}; + if (!header) return cookies; + + for (const part of header.split(";")) { + const [rawKey, ...rawValue] = part.trim().split("="); + if (!rawKey) continue; + cookies[rawKey] = decodeURIComponent(rawValue.join("=")); + } + + return cookies; +} + +export function sessionCookie(request: FastifyRequest) { + return parseCookies(request.headers.cookie).session_id; +} + +export async function requireSession(app: FastifyInstance, request: FastifyRequest) { + const sessionId = sessionCookie(request); + if (!sessionId) throw permissionDenied(); + + const session = await app.prisma.session.findUnique({ + where: { id: sessionId }, + include: { + user: { + include: { + permissions: true, + accounts: true, + }, + }, + }, + }); + + if (!session || session.expiresAt <= new Date()) { + if (session) { + await app.prisma.session.delete({ where: { id: session.id } }).catch(() => undefined); + } + throw permissionDenied(); + } + + return session as SessionContext; +} + +export async function requireRole( + app: FastifyInstance, + request: FastifyRequest, + roles: string[], +) { + const session = await requireSession(app, request); + if (!roles.includes(session.user.role)) { + throw forbidden(`Role ${session.user.role} is not authorized for this operation.`); + } + return session; +} + +export function accountIdFor(session: SessionContext) { + return session.user.accounts[0]?.accountId ?? "acct-main"; +}