77 lines
2.9 KiB
TypeScript
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");
|
|
}
|