integratedhub/services/authMiddleware.ts

169 lines
4.6 KiB
TypeScript

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<State>) {
const session = ctx.state.data;
const userId = session?.user_id?.trim() || null;
const sessionRoles = parseRoles(session?.role);
const userTypeFromDb = normalizeRole(ctx.state.user?.user_type ?? null);
const userTypeFromSession = normalizeRole(session?.user_type ?? null);
const roleSet = new Set<Role>([
...sessionRoles,
...(userTypeFromDb ? [userTypeFromDb] : []),
...(userTypeFromSession ? [userTypeFromSession] : []),
]);
return {
isAuthenticated: !!userId,
userId,
userType: userTypeFromDb ?? userTypeFromSession,
roles: Array.from(roleSet),
};
}
function unauthorizedResponse(
ctx: Context<State>,
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<State>,
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<State>) => {
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();
});
}