From the logic audit: - consent rejects sid-less bearers and requires a LIVE hub session (sessionAlive) before minting codes — a logged-out bearer can no longer mint downstream tokens inside its TTL - refresh rotation race closed: consume returns whether THIS call won; the loser revokes the family (reuse detection now holds under concurrency) - error redirects carry iss (RFC 9207) on error responses too - logout awaits the revocation and no longer returns ok for missing or forged bearers
184 lines
8.7 KiB
TypeScript
184 lines
8.7 KiB
TypeScript
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<string> = new Set([WEB_CLIENT_ID]);
|
|
const REDIRECT_URIS: Readonly<Record<string, readonly string[]>> = { [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<Record<string, string | undefined>>, 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<Record<string, string | undefined>> = 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<string, unknown>) : {};
|
|
const params: AuthorizeParams = {};
|
|
for (const name of AUTHORIZE_PARAM_NAMES) {
|
|
const value = record[name];
|
|
if (typeof value === "string") params[name] = value;
|
|
}
|
|
return params;
|
|
}
|