integratedhub/services/stateMiddleware.ts

231 lines
7.2 KiB
TypeScript
Executable File

import { define, type State } from "../utils.ts";
import { Context } from "fresh";
import * as Iron from "iron";
import {
deleteStateKey,
getStateKeys,
getUser,
setStateKey,
User,
} from "./userService.ts";
import { getCookies } from "$std/http/cookie.ts";
import { runStateKeyCleanup } from "./dbCleanup.ts";
import {
generatePassword,
isSecureRequest,
persistWithEncryptedCookie,
SessionCookie,
} from "../routes/auth/callback.ts";
const useDb = true;
const authDebug = Deno.env.get("AUTH_DEBUG") === "true";
export function generateNonce(length = 32) {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array)).replace(/[^a-zA-Z0-9]/g, "");
}
export interface SessionCookieParams {
sessionCookie: string | null;
stateId: string | null;
}
const emptySession: SessionCookie = {
started_on: 0,
user_name: "",
user_encrypted: false,
user_id: "",
role: "",
user_type: "",
};
export function readSessionCookiesFromCookies(
headers: Headers,
): SessionCookieParams {
const cookies: Record<string, string> = getCookies(headers);
return {
sessionCookie: cookies["session"] ?? null,
stateId: cookies["xx_state_id"] ?? null,
};
}
export async function hashText(text: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(
"",
);
return hashHex;
}
export function InitiPGSessionMiddleware() {
return define.middleware(async (ctx: Context<State>) => {
const url = new URL(ctx.req.url);
console.log("Pathname:", ctx.req.method, url.pathname);
if (
url.pathname.startsWith("/api/ai-images/file/") ||
url.pathname.startsWith("/api/audio-blob/") ||
url.pathname.startsWith("/api/image-blob/") ||
url.pathname === "/health" || url.pathname.startsWith("/auth/") ||
url.pathname.startsWith("/node_modules/") ||
url.pathname.startsWith("/.") ||
url.pathname.startsWith("/favicon.ico")
) {
return await ctx.next();
}
const timestampNow = Date.now();
let sessionData: SessionCookie | null = null;
const sessionCookieParams = readSessionCookiesFromCookies(ctx.req.headers);
if (authDebug) {
console.log("DEBUG stateMiddleware: cookies", {
path: url.pathname,
hasSession: !!sessionCookieParams.sessionCookie,
hasStateId: !!sessionCookieParams.stateId,
});
}
if (url.searchParams.get("popstate") === "true") {
url.searchParams.delete("popstate");
if (sessionCookieParams.sessionCookie) {
url.pathname = "/auth/logout";
return Response.redirect(url.toString(), 302);
} else {
return Response.redirect(url.toString(), 302);
}
}
const xxStateId = sessionCookieParams.stateId;
let theStateKey: string | null = null;
if (xxStateId) {
if (useDb) {
const stateKeys = await getStateKeys(xxStateId);
let unsealErrorCount = 0;
stateKeys.sort((a, b) => b.created_on - a.created_on);
const timestamp = timestampNow - (1000 * 60 * 60);
for (const stateKey of stateKeys) {
if (stateKey.created_on < timestamp) {
deleteStateKey(stateKey.seal_key);
} else if (sessionCookieParams.sessionCookie) {
let stateData: SessionCookie | null = null;
try {
stateData = await Iron.unseal(
sessionCookieParams.sessionCookie,
stateKey.seal_key,
Iron.defaults,
) as SessionCookie;
} catch (_e) {
unsealErrorCount++;
ctx.state.authError = "invalid_session";
continue;
}
if (stateData && stateData.user_id) {
const thisHash = await hashText(JSON.stringify(stateData));
if (thisHash === stateKey.cookie_hash) {
const renewWindow = timestampNow - (5 * 60 * 1000);
const newKey = generatePassword();
if (stateKey.created_on < renewWindow) {
stateData.started_on = timestampNow;
const cookieHash = await hashText(JSON.stringify(stateData));
await setStateKey(newKey, xxStateId, cookieHash);
theStateKey = newKey;
} else {
theStateKey = stateKey.seal_key;
}
sessionData = stateData;
if (authDebug) {
console.log("DEBUG stateMiddleware: session validated", {
user_id: sessionData.user_id,
state_id: xxStateId,
});
}
break;
} else {
console.log(
"DEBUG stateMiddleware: hash mismatch, cookie invalid",
);
ctx.state.authError = "invalid_session";
}
} else {
console.log(
"DEBUG stateMiddleware: stateData missing or no user_id",
{
hasStateData: !!stateData,
user_id: stateData?.user_id,
},
);
ctx.state.authError = "invalid_session";
}
}
}
if (authDebug && unsealErrorCount > 0 && !sessionData) {
console.log(
"DEBUG stateMiddleware: failed to unseal session cookie",
{ state_id: xxStateId, attempts: unsealErrorCount },
);
}
}
}
ctx.state.photoUrl = "";
ctx.state.data = sessionData ?? emptySession;
ctx.state.host = url.href;
ctx.state.urlRoute = url.pathname;
ctx.state.stateId = sessionCookieParams.stateId ?? "";
ctx.state.sealKey = theStateKey ?? undefined; // Store the validated seal key for logout
const themeCookie = getCookies(ctx.req.headers)["cyberhybrid-theme"];
if (themeCookie === "light" || themeCookie === "dark") {
ctx.state.themePreference = themeCookie;
}
// Inject full user data into ctx.state.user for route handlers
let userData: User | null = null;
if (sessionData && sessionData.user_id) {
try {
userData = await getUser(sessionData.user_id);
} catch (error) {
console.error("Error fetching user data:", error);
// Continue without user data - session is valid but user lookup failed
}
}
ctx.state.user = userData;
const preResp = await ctx.next();
if (sessionCookieParams.sessionCookie && !sessionData) {
const expiredDate = new Date(0).toUTCString();
const isSecure = isSecureRequest(ctx.req);
const deleteCookie =
`session=; Expires=${expiredDate}; Path=/; HttpOnly=true; Secure=${isSecure}; SameSite=Lax`;
preResp.headers.append("Set-Cookie", deleteCookie);
}
runStateKeyCleanup().catch((err) => {
console.error("State key cleanup error:", err);
});
return theStateKey && sessionData
? persistWithEncryptedCookie(
sessionData as SessionCookie,
theStateKey,
preResp,
ctx.req,
xxStateId || "",
)
: preResp;
});
}