publishing: unfenced vault accessor for the amplify path

createPublishingCredentialAccessor is execution-fenced (Temporal contexts
only) — the API-process amplify route threw EXECUTION_CREDENTIAL_FENCE.
Use the established PgConnectionStore+vault accessor instead (same trust
level as the archive amplify route's bot-token path).
This commit is contained in:
glass beadian 2026-10-13 18:32:38 +00:00
parent 6145281b3f
commit 96e5a63dd8
11 changed files with 1501 additions and 8 deletions

View file

@ -0,0 +1,181 @@
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): 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);
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;
}