import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; /** * Hub-as-OIDC-issuer configuration (files SSO, phase 1). * * The issuer, the single first-party client and its redirect URI are fixed by * the approved design (docs/design/2026-10-13-files-oidc-sso.md): a mismatch * anywhere breaks oCIS token validation, so production values are constants * and only the issuer / consent page may be overridden for local development. * * ENV CONTRACT (read per call, like loadMatrixSsoConfig — nothing at import): * * OIDC_ISSUER default https://api.occupymedia.cc/oidc (dev override only). * OIDC_CONSENT_URL Hub SPA consent route, default `${APP_URL}/oidc/consent`, * APP_URL defaulting to https://app.occupymedia.cc. * OIDC_KEY_DIR directory holding the sealed signing keyset. REQUIRED in * production (mount a volume). Dev default is under the * OS temp dir — never inside the repo, so a dev key can * not be committed by accident. */ export interface OidcConfig { issuer: string; consentUrl: URL; keyDir: string; } export class OidcConfigError extends Error {} export const DEFAULT_ISSUER = "https://api.occupymedia.cc/oidc"; const DEFAULT_APP_URL = "https://app.occupymedia.cc"; /** The oCIS web app — the only registered client; public (no secret), PKCE-only. */ export const WEB_CLIENT_ID = "web"; export const WEB_REDIRECT_URI = "https://files.occupymedia.cc/oidc-callback.html"; /** Clients that skip the consent screen. Everything else fails closed. */ const FIRST_PARTY_CLIENTS: ReadonlySet = new Set([WEB_CLIENT_ID]); const REDIRECT_URIS: Readonly> = { [WEB_CLIENT_ID]: [WEB_REDIRECT_URI] }; export const SUPPORTED_SCOPES: readonly string[] = ["openid", "profile", "email"]; /** Authorization codes are single-use and live at most this long (design: ≤60 s). */ export const CODE_TTL_SECONDS = 60; /** id_token / access_token lifetime; oCIS re-runs the (silent) authorize flow after it. */ export const TOKEN_TTL_SECONDS = 3600; function trimmed(env: Readonly>, name: string): string { return env[name]?.trim() ?? ""; } function httpsOrLocal(name: string, raw: string): URL { let url: URL; try { url = new URL(raw); } catch { throw new OidcConfigError(`${name} must be an absolute URL`); } const local = url.hostname === "localhost" || url.hostname === "127.0.0.1"; if (url.protocol !== "https:" && !(local && url.protocol === "http:")) { throw new OidcConfigError(`${name} must be https (http only for localhost)`); } return url; } /** Throws OidcConfigError on malformed or (in production) missing values. */ export function loadOidcConfig(env: Readonly> = process.env): OidcConfig { const rawIssuer = trimmed(env, "OIDC_ISSUER") || DEFAULT_ISSUER; const issuerUrl = httpsOrLocal("OIDC_ISSUER", rawIssuer); if (issuerUrl.search !== "" || issuerUrl.hash !== "") throw new OidcConfigError("OIDC_ISSUER must not carry a query or fragment"); const issuer = rawIssuer.replace(/\/+$/, ""); const rawConsent = trimmed(env, "OIDC_CONSENT_URL") || `${(trimmed(env, "APP_URL") || DEFAULT_APP_URL).replace(/\/+$/, "")}/oidc/consent`; const consentUrl = httpsOrLocal("OIDC_CONSENT_URL", rawConsent); const rawKeyDir = trimmed(env, "OIDC_KEY_DIR"); if (rawKeyDir === "" && env.NODE_ENV === "production") { throw new OidcConfigError("OIDC_KEY_DIR is required in production (a persistent volume for the signing keyset)"); } const keyDir = rawKeyDir === "" ? join(tmpdir(), "tmbh-hub-oidc") : rawKeyDir; if (!isAbsolute(keyDir)) throw new OidcConfigError("OIDC_KEY_DIR must be an absolute path"); return { issuer, consentUrl, keyDir }; } /** Raw authorize parameters, as received on GET /oidc/authorize or relayed by the consent page. */ export interface AuthorizeParams { response_type?: string; client_id?: string; redirect_uri?: string; scope?: string; state?: string; nonce?: string; code_challenge?: string; code_challenge_method?: string; prompt?: string; } export const AUTHORIZE_PARAM_NAMES: readonly (keyof AuthorizeParams)[] = [ "response_type", "client_id", "redirect_uri", "scope", "state", "nonce", "code_challenge", "code_challenge_method", "prompt", ]; /** A request that passed every check: safe to issue a code for. */ export interface ValidAuthorizeRequest { clientId: string; redirectUri: string; scopes: string[]; state: string | null; nonce: string | null; codeChallenge: string; } /** * Validation outcome. `redirect: false` errors are shown to the user and never * sent to the redirect URI (RFC 6749 §4.1.2.1: an unverified client or * redirect_uri must not receive a redirect). `redirect: true` errors go back to * the client's (already verified) redirect URI as `?error=…&state=…`. */ export type AuthorizeValidation = | { ok: true; request: ValidAuthorizeRequest } | { ok: false; redirect: false; error: string; description: string } | { ok: false; redirect: true; redirectUri: string; state: string | null; error: string; description: string }; /** RFC 7636 §4.2: BASE64URL(SHA256(verifier)) is always 43 chars. */ const S256_CHALLENGE = /^[A-Za-z0-9_-]{43}$/; /** RFC 7636 §4.1 verifier grammar. */ export const CODE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/; const MAX_STATE_LENGTH = 512; export function isFirstPartyClient(clientId: string): boolean { return FIRST_PARTY_CLIENTS.has(clientId); } export function validateAuthorize(params: AuthorizeParams): AuthorizeValidation { const clientId = params.client_id ?? ""; const allowed = REDIRECT_URIS[clientId]; if (allowed === undefined) return { ok: false, redirect: false, error: "invalid_client", description: "unknown client_id" }; const redirectUri = params.redirect_uri ?? ""; // Exact string match — no prefix, normalisation or wildcard semantics. if (!allowed.includes(redirectUri)) return { ok: false, redirect: false, error: "invalid_request", description: "redirect_uri is not registered for this client" }; const state = params.state === undefined || params.state === "" ? null : params.state; const fail = (error: string, description: string): AuthorizeValidation => ({ ok: false, redirect: true, redirectUri, state, error, description }); if (state !== null && state.length > MAX_STATE_LENGTH) return fail("invalid_request", "state is too long"); if (params.response_type !== "code") return fail("unsupported_response_type", "only response_type=code is supported"); const scopes = (params.scope ?? "").split(" ").filter((s) => s !== ""); if (!scopes.includes("openid")) return fail("invalid_scope", "scope must include openid"); const unknown = scopes.find((s) => !SUPPORTED_SCOPES.includes(s)); if (unknown !== undefined) return fail("invalid_scope", `unsupported scope: ${unknown}`); if (params.code_challenge === undefined || params.code_challenge === "") return fail("invalid_request", "PKCE code_challenge is required"); if (params.code_challenge_method !== "S256") return fail("invalid_request", "code_challenge_method must be S256"); if (!S256_CHALLENGE.test(params.code_challenge)) return fail("invalid_request", "code_challenge is not a S256 challenge"); const nonce = params.nonce === undefined || params.nonce === "" ? null : params.nonce; if (nonce !== null && nonce.length > MAX_STATE_LENGTH) return fail("invalid_request", "nonce is too long"); return { ok: true, request: { clientId, redirectUri, scopes: [...new Set(scopes)], state, nonce, codeChallenge: params.code_challenge }, }; } /** `redirectUri?error=…&error_description=…&state=…` for a redirectable failure. */ export function errorRedirect(redirectUri: string, state: string | null, error: string, description: string, issuer?: string): string { const url = new URL(redirectUri); url.searchParams.set("error", error); url.searchParams.set("error_description", description); if (state !== null) url.searchParams.set("state", state); // RFC 9207 §2: iss is required on error responses too when the server // advertises authorization_response_iss_parameter_supported. if (issuer !== undefined) url.searchParams.set("iss", issuer); return url.toString(); } /** Picks only the known authorize params out of an untyped query or body. */ export function authorizeParamsFrom(source: unknown): AuthorizeParams { const record = typeof source === "object" && source !== null && !Array.isArray(source) ? (source as Record) : {}; const params: AuthorizeParams = {}; for (const name of AUTHORIZE_PARAM_NAMES) { const value = record[name]; if (typeof value === "string") params[name] = value; } return params; }