Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/admin-sso/state.server.ts
T

226 lines
8.4 KiB
TypeScript

/**
* Phase 09 Task #82 — PKCE / state / nonce challenge persistence.
*
* Stores the tuple { state, nonce, codeVerifier, createdAt } in an
* HMAC-signed HttpOnly cookie scoped to the dedicated admin host. The cookie is
* single-use: `consumeAdminSsoChallenge()` returns the payload and emits a
* `Set-Cookie` header that clears it.
*
* We sign with HMAC-SHA256 over the JSON payload using
* `CRYPTO_SECRET || SESSION_SECRET` (mirroring the magic-link convention),
* and we encode payload+signature with URL-safe base64. The payload itself
* is NOT encrypted — none of the values are user-secret per spec, and
* encryption would just hide bugs.
*
* TTL is 10 minutes which is generous for Authentik UI redirects but short
* enough that stolen cookies have a tiny replay window. The TTL is checked
* server-side (cookie Max-Age is best-effort; clients lie).
*/
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { loadRequiredWebConfig, resolveHostOnlyCookiePolicy } from "@hivegrid/config";
import { generators } from "openid-client";
export const ADMIN_SSO_CHALLENGE_COOKIE = "__Host-hivegrid_admin_sso_challenge";
export const LOCAL_ADMIN_SSO_CHALLENGE_COOKIE = "hivegrid_admin_sso_challenge";
export const ADMIN_SSO_CHALLENGE_TTL_MS = 10 * 60 * 1000;
const ADMIN_SSO_CHALLENGE_CLOCK_SKEW_MS = 60 * 1000;
const PAYLOAD_VERSION = "v1";
export type AdminSsoChallengePayload = {
readonly state: string;
readonly nonce: string;
readonly codeVerifier: string;
readonly codeChallenge: string;
/** Epoch milliseconds. */
readonly createdAt: number;
/** Optional safe `returnTo` path captured at /auth/admin/login. */
readonly returnTo?: string;
};
export type ConsumeResult =
| { readonly ok: true; readonly payload: AdminSsoChallengePayload; readonly clearCookie: string }
| { readonly ok: false; readonly reason: "missing" | "malformed" | "bad-signature" | "expired" };
export type CreateOptions = {
readonly returnTo?: string;
/** Injectable clock for tests. */
readonly now?: () => number;
/** Override secret (tests). */
readonly secret?: string;
};
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 challenge cookies.");
}
return secret;
}
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_CHALLENGE_COOKIE,
localName: LOCAL_ADMIN_SSO_CHALLENGE_COOKIE,
maxAge: Math.ceil(ADMIN_SSO_CHALLENGE_TTL_MS / 1000),
});
}
function base64UrlEncode(buf: Buffer | string): string {
const b = typeof buf === "string" ? Buffer.from(buf, "utf8") : buf;
return b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64UrlDecode(value: string): Buffer {
const pad = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
const standard = value.replace(/-/g, "+").replace(/_/g, "/") + pad;
return Buffer.from(standard, "base64");
}
function sign(payload: string, secret: string): string {
return base64UrlEncode(createHmac("sha256", secret).update(payload).digest());
}
function verifySignature(payload: string, signature: string, secret: string): boolean {
const expected = sign(payload, secret);
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Generate a fresh challenge tuple. Caller is responsible for setting
* `Set-Cookie` from `cookie` on the redirect response.
*/
export function createAdminSsoChallenge(options: CreateOptions = {}): { payload: AdminSsoChallengePayload; cookie: string } {
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const payload: AdminSsoChallengePayload = {
state: randomBytes(32).toString("hex"),
nonce: randomBytes(32).toString("hex"),
codeVerifier,
codeChallenge,
createdAt: options.now ? options.now() : Date.now(),
returnTo: options.returnTo,
};
const cookie = serializeAdminSsoChallengeCookie(payload, options.secret);
return { payload, cookie };
}
export function serializeAdminSsoChallengeCookie(payload: AdminSsoChallengePayload, secretOverride?: string): string {
const secret = getSigningSecret(secretOverride);
const body = base64UrlEncode(JSON.stringify({ v: PAYLOAD_VERSION, p: payload }));
const sig = sign(body, secret);
const value = `${body}.${sig}`;
const policy = getCookiePolicy();
const attributes = [`${policy.name}=${value}`, `Path=${policy.path}`, "HttpOnly", "SameSite=Lax", `Max-Age=${policy.maxAge}`];
if (policy.secure) {
attributes.push("Secure");
}
return attributes.join("; ");
}
/**
* Returns a `Set-Cookie` header that clears the challenge cookie.
*/
export function clearAdminSsoChallengeCookie(): string {
const policy = getCookiePolicy();
const attributes = [`${policy.name}=`, `Path=${policy.path}`, "HttpOnly", "SameSite=Lax", "Max-Age=0"];
if (policy.secure) {
attributes.push("Secure");
}
return attributes.join("; ");
}
function readCookieValue(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;
const key = part.slice(0, eq).trim();
if (key === name) return part.slice(eq + 1).trim();
}
return null;
}
/**
* Read + verify + invalidate the challenge cookie attached to `request`.
* Always returns a `clearCookie` header that the caller must include on
* the redirect response so the cookie cannot be replayed.
*/
export function consumeAdminSsoChallenge(request: Request, options: { now?: () => number; secret?: string } = {}): ConsumeResult {
let cookieName: string;
try {
cookieName = getCookiePolicy().name;
} catch {
return { ok: false, reason: "malformed" };
}
const value = readCookieValue(request, cookieName);
if (!value) return { ok: false, reason: "missing" };
const dot = value.lastIndexOf(".");
if (dot <= 0) return { ok: false, reason: "malformed" };
const body = value.slice(0, dot);
const sig = value.slice(dot + 1);
let secret: string;
try {
secret = getSigningSecret(options.secret);
} catch {
return { ok: false, reason: "malformed" };
}
if (!verifySignature(body, sig, secret)) {
return { ok: false, reason: "bad-signature" };
}
let decoded: { v?: unknown; p?: unknown };
try {
decoded = JSON.parse(base64UrlDecode(body).toString("utf8")) as { v?: unknown; p?: unknown };
} catch {
return { ok: false, reason: "malformed" };
}
if (decoded.v !== PAYLOAD_VERSION || !isAdminSsoChallengePayload(decoded.p)) {
return { ok: false, reason: "malformed" };
}
const now = options.now ? options.now() : Date.now();
if (decoded.p.createdAt > now + ADMIN_SSO_CHALLENGE_CLOCK_SKEW_MS) {
return { ok: false, reason: "malformed" };
}
if (now - decoded.p.createdAt > ADMIN_SSO_CHALLENGE_TTL_MS) {
return { ok: false, reason: "expired" };
}
return { ok: true, payload: decoded.p, clearCookie: clearAdminSsoChallengeCookie() };
}
function isAdminSsoChallengePayload(value: unknown): value is AdminSsoChallengePayload {
if (!value || typeof value !== "object") {
return false;
}
const payload = value as Partial<Record<keyof AdminSsoChallengePayload, unknown>>;
if (
typeof payload.state !== "string" ||
!/^[0-9a-f]{64}$/.test(payload.state) ||
typeof payload.nonce !== "string" ||
!/^[0-9a-f]{64}$/.test(payload.nonce) ||
typeof payload.codeVerifier !== "string" ||
!/^[A-Za-z0-9._~-]{43,128}$/.test(payload.codeVerifier) ||
typeof payload.codeChallenge !== "string" ||
!/^[A-Za-z0-9_-]{43}$/.test(payload.codeChallenge) ||
typeof payload.createdAt !== "number" ||
!Number.isFinite(payload.createdAt) ||
(payload.returnTo !== undefined && typeof payload.returnTo !== "string")
) {
return false;
}
try {
return generators.codeChallenge(payload.codeVerifier) === payload.codeChallenge;
} catch {
return false;
}
}