import { Context } from "fresh"; import { define } from "../utils.ts"; import type { State } from "../utils.ts"; import { createAuthRedirect } from "../routes/auth/callback.ts"; import { authRouteRules } from "./authRouteConfig.ts"; import type { Role } from "./authTypes.ts"; const authDebug = Deno.env.get("AUTH_DEBUG") === "true"; function normalizeRole(value?: string | null): Role | null { if (!value) return null; const trimmed = value.toString().trim().toLowerCase(); if (!trimmed) return null; if (trimmed === "admin") return "admin"; if (trimmed === "landlord") return "landlord"; if (trimmed === "tenant") return "tenant"; return null; } function parseRoles(raw?: string | null): Role[] { if (!raw) return []; return raw .split(",") .map((entry) => normalizeRole(entry)) .filter((role): role is Role => !!role); } function resolveRequirement(pathname: string, method: string) { for (const rule of authRouteRules) { if (!rule.pattern.test(pathname)) continue; if (rule.requirement.methods) { const upperMethod = method.toUpperCase(); if (!rule.requirement.methods.includes(upperMethod)) { continue; } } return rule.requirement; } return null; } function isApiRequest(pathname: string, req: Request): boolean { if (pathname.startsWith("/api/")) return true; const accept = req.headers.get("accept") || ""; return accept.includes("application/json"); } function buildAuthState(ctx: Context) { const session = ctx.state.data; const userId = session?.user_id?.trim() || null; const sessionRoles = parseRoles(session?.role); const userType = normalizeRole(ctx.state.user?.user_type ?? null); const roleSet = new Set([ ...sessionRoles, ...(userType ? [userType] : []), ]); return { isAuthenticated: !!userId, userId, userType, roles: Array.from(roleSet), }; } function unauthorizedResponse( ctx: Context, pathname: string, ): Response { const isApi = isApiRequest(pathname, ctx.req); if (isApi) { return new Response( JSON.stringify({ error: "Authentication required", redirectTo: "/auth/login", }), { status: 401, headers: { "Content-Type": "application/json" }, }, ); } const search = new URL(ctx.req.url).search; const returnPath = `${pathname}${search}`; return createAuthRedirect(returnPath); } function forbiddenResponse( ctx: Context, pathname: string, requiredRoles: Role[], userRoles: Role[], requirement: { unauthorizedRedirect?: string } = {}, ): Response { const isApi = isApiRequest(pathname, ctx.req); if (isApi) { return new Response( JSON.stringify({ error: "Forbidden", required_roles: requiredRoles, user_roles: userRoles, }), { status: 403, headers: { "Content-Type": "application/json" }, }, ); } const target = requirement.unauthorizedRedirect || "/unauthorized"; const url = new URL(target, ctx.req.url); url.searchParams.set("required", requiredRoles.join(",")); if (userRoles.length > 0) { url.searchParams.set("current", userRoles.join(",")); } return Response.redirect(url.toString(), 302); } export function createAuthMiddleware() { return define.middleware(async (ctx: Context) => { const url = new URL(ctx.req.url); const requirement = resolveRequirement(url.pathname, ctx.req.method); if (!requirement || requirement.level === "public") { return await ctx.next(); } const authState = buildAuthState(ctx); ctx.state.auth = authState; if (authDebug) { console.log("DEBUG authMiddleware: auth check", { path: url.pathname, method: ctx.req.method, isAuthenticated: authState.isAuthenticated, userId: authState.userId, roles: authState.roles, }); } if (requirement.level === "optional") { return await ctx.next(); } if (!authState.isAuthenticated) { return unauthorizedResponse(ctx, url.pathname); } if (requirement.roles && requirement.roles.length > 0) { const hasRequiredRole = requirement.roles.some((role) => authState.roles.includes(role) ); if (!hasRequiredRole) { return forbiddenResponse( ctx, url.pathname, requirement.roles, authState.roles, requirement, ); } } return await ctx.next(); }); }