227 lines
7.1 KiB
TypeScript
Executable File
227 lines
7.1 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);
|
|
|
|
if (url.hostname === "cyberhybridhub.com") {
|
|
return Response.redirect("https://portal.cyberhybridhub.com", 302);
|
|
}
|
|
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
|
|
|
|
// 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;
|
|
});
|
|
}
|