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:
parent
6145281b3f
commit
96e5a63dd8
11 changed files with 1501 additions and 8 deletions
181
apps/api/src/oidc/oidc.config.ts
Normal file
181
apps/api/src/oidc/oidc.config.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue