190 lines
6.7 KiB
TypeScript
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() };
|
|
}
|