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

190 lines
6.7 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 /auth/admin. 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 } from "@toolctrl/config";
import { generators } from "openid-client";
export const ADMIN_SSO_CHALLENGE_COOKIE = "admin_sso_challenge";
export const ADMIN_SSO_CHALLENGE_TTL_MS = 10 * 60 * 1000;
const COOKIE_PATH = "/auth/admin";
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 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 maxAgeSeconds = Math.ceil(ADMIN_SSO_CHALLENGE_TTL_MS / 1000);
const attributes = [
`${ADMIN_SSO_CHALLENGE_COOKIE}=${value}`,
`Path=${COOKIE_PATH}`,
"HttpOnly",
"SameSite=Lax",
`Max-Age=${maxAgeSeconds}`,
];
if (process.env.NODE_ENV !== "test") {
attributes.push("Secure");
}
return attributes.join("; ");
}
/**
* Returns a `Set-Cookie` header that clears the challenge cookie.
*/
export function clearAdminSsoChallengeCookie(): string {
const attributes = [
`${ADMIN_SSO_CHALLENGE_COOKIE}=`,
`Path=${COOKIE_PATH}`,
"HttpOnly",
"SameSite=Lax",
"Max-Age=0",
];
if (process.env.NODE_ENV !== "test") {
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 {
const value = readCookieValue(request, ADMIN_SSO_CHALLENGE_COOKIE);
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: string; p: AdminSsoChallengePayload };
try {
decoded = JSON.parse(base64UrlDecode(body).toString("utf8"));
} catch {
return { ok: false, reason: "malformed" };
}
if (decoded.v !== PAYLOAD_VERSION || !decoded.p) {
return { ok: false, reason: "malformed" };
}
const now = options.now ? options.now() : Date.now();
if (typeof decoded.p.createdAt !== "number" || now - decoded.p.createdAt > ADMIN_SSO_CHALLENGE_TTL_MS) {
return { ok: false, reason: "expired" };
}
return { ok: true, payload: decoded.p, clearCookie: clearAdminSsoChallengeCookie() };
}