Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/admin-sso/logout.server.ts
T

77 lines
2.9 KiB
TypeScript

/**
* Phase 09 Task #82 — Admin-SSO logout helpers.
*
* Stores the raw OIDC `id_token` in a separate HMAC-signed HttpOnly
* cookie (scoped to /auth/admin) so that /auth/admin/logout can supply
* `id_token_hint` for RP-initiated logout without bloating the main
* session cookie or persisting tokens in the DB.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
import { loadRequiredWebConfig } from "@toolctrl/config";
export const ADMIN_SSO_ID_TOKEN_COOKIE = "admin_sso_id_token";
export const ADMIN_SSO_ID_TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // mirror app-session lifetime
const COOKIE_PATH = "/auth/admin";
function getSigningSecret(override?: string): string {
if (override) return override;
const { cryptoSecret, sessionSecret } = loadRequiredWebConfig();
const secret = cryptoSecret || sessionSecret;
if (!secret) {
throw new Error("CRYPTO_SECRET or SESSION_SECRET is required for admin-sso id-token cookie.");
}
return secret;
}
function sign(payload: string, secret: string): string {
return createHmac("sha256", secret).update(payload).digest("base64url");
}
export function serializeIdTokenCookie(idToken: string, opts: { secret?: string } = {}): string {
const secret = getSigningSecret(opts.secret);
const sig = sign(idToken, secret);
const value = `${Buffer.from(idToken, "utf8").toString("base64url")}.${sig}`;
const maxAge = Math.ceil(ADMIN_SSO_ID_TOKEN_TTL_MS / 1000);
const attrs = [`${ADMIN_SSO_ID_TOKEN_COOKIE}=${value}`, `Path=${COOKIE_PATH}`, "HttpOnly", "SameSite=Lax", `Max-Age=${maxAge}`];
if (process.env.NODE_ENV !== "test") attrs.push("Secure");
return attrs.join("; ");
}
export function clearIdTokenCookie(): string {
const attrs = [`${ADMIN_SSO_ID_TOKEN_COOKIE}=`, `Path=${COOKIE_PATH}`, "HttpOnly", "SameSite=Lax", "Max-Age=0"];
if (process.env.NODE_ENV !== "test") attrs.push("Secure");
return attrs.join("; ");
}
function readCookie(request: Request, name: string): string | null {
const header = request.headers.get("cookie");
if (!header) return null;
for (const part of header.split(/;\s*/)) {
const eq = part.indexOf("=");
if (eq === -1) continue;
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
}
return null;
}
export function readIdTokenFromRequest(request: Request, opts: { secret?: string } = {}): string | null {
const value = readCookie(request, ADMIN_SSO_ID_TOKEN_COOKIE);
if (!value) return null;
const dot = value.lastIndexOf(".");
if (dot <= 0) return null;
const encoded = value.slice(0, dot);
const sig = value.slice(dot + 1);
let secret: string;
try {
secret = getSigningSecret(opts.secret);
} catch {
return null;
}
const expectedSig = sign(Buffer.from(encoded, "base64url").toString("utf8"), secret);
const a = Buffer.from(sig);
const b = Buffer.from(expectedSig);
if (a.length !== b.length) return null;
if (!timingSafeEqual(a, b)) return null;
return Buffer.from(encoded, "base64url").toString("utf8");
}