169 lines
4.6 KiB
TypeScript
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();
|
|
});
|
|
}
|