97 lines
3.6 KiB
TypeScript
97 lines
3.6 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 the dedicated admin host) 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, resolveHostOnlyCookiePolicy } from "@hivegrid/config";
|
|
|
|
export const ADMIN_SSO_ID_TOKEN_COOKIE = "__Host-hivegrid_admin_sso_id_token";
|
|
export const LOCAL_ADMIN_SSO_ID_TOKEN_COOKIE = "hivegrid_admin_sso_id_token";
|
|
export const ADMIN_SSO_ID_TOKEN_TTL_MS = 15 * 60 * 1000; // bounded to the absolute AdminUser session lifetime
|
|
|
|
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");
|
|
}
|
|
|
|
function getCookiePolicy() {
|
|
const webConfig = loadRequiredWebConfig();
|
|
// `__Host-` requires Path=/. The dedicated admin hostname is the narrowest
|
|
// safe scope that preserves the browser-enforced no-Domain guarantee.
|
|
return resolveHostOnlyCookiePolicy({
|
|
nodeEnv: webConfig.nodeEnv,
|
|
routing: webConfig.routing,
|
|
secureName: ADMIN_SSO_ID_TOKEN_COOKIE,
|
|
localName: LOCAL_ADMIN_SSO_ID_TOKEN_COOKIE,
|
|
maxAge: Math.ceil(ADMIN_SSO_ID_TOKEN_TTL_MS / 1000),
|
|
});
|
|
}
|
|
|
|
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 policy = getCookiePolicy();
|
|
const attrs = [`${policy.name}=${value}`, `Path=${policy.path}`, "HttpOnly", "SameSite=Lax", `Max-Age=${policy.maxAge}`];
|
|
if (policy.secure) attrs.push("Secure");
|
|
return attrs.join("; ");
|
|
}
|
|
|
|
export function clearIdTokenCookie(): string {
|
|
const policy = getCookiePolicy();
|
|
const attrs = [`${policy.name}=`, `Path=${policy.path}`, "HttpOnly", "SameSite=Lax", "Max-Age=0"];
|
|
if (policy.secure) 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 {
|
|
let cookieName: string;
|
|
try {
|
|
cookieName = getCookiePolicy().name;
|
|
} catch {
|
|
return null;
|
|
}
|
|
const value = readCookie(request, cookieName);
|
|
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");
|
|
}
|