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

@ -38,9 +38,10 @@ import { AnalyticsController } from "./analytics/analytics.controller";
import { SnapshotService } from "./analytics/snapshot.service";
import { CollectivesModule } from "./collectives/collectives.module.js";
import { MatrixSsoModule } from "./matrix-sso/matrix-sso.module.js";
import { OidcModule } from "./oidc/oidc.module.js";
@Module({
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule],
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule],
controllers: [
DemoController,
PublishingController,

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;
}

View file

@ -0,0 +1,72 @@
import { Body, Controller, Get, Header, Headers, HttpCode, Inject, Post, Query, Redirect, Req, UnauthorizedException, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { RbacGuard, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { requireSession } from "../http/validation.js";
import { OidcService, type TokenResponse, type UserInfo } from "./oidc.service.js";
import type { PublicJwk } from "./oidc.keys.js";
/**
* The Hub's OpenID Connect provider (issuer https://api.occupymedia.cc/oidc).
*
* GET /oidc/.well-known/openid-configuration discovery
* GET /oidc/jwks public ES256 keys
* GET /oidc/authorize validate, then 302 → Hub SPA consent page
* POST /oidc/authorize/consent (Bearer session) issue the single-use code
* POST /oidc/token code + PKCE verifier → tokens
* GET /oidc/userinfo Bearer access_token → claims
*
* Only /authorize/consent is session-guarded: every active member may sign in
* to files, so it needs no permission. See OidcService for the cookie-less
* authorize flow (a deliberate deviation from the design doc).
*/
@Controller("oidc")
export class OidcController {
constructor(@Inject(OidcService) private readonly oidc: OidcService) {}
@Get(".well-known/openid-configuration")
@Header("Cache-Control", "public, max-age=300")
discovery(): Record<string, unknown> {
return this.oidc.discovery();
}
@Get("jwks")
@Header("Cache-Control", "public, max-age=300")
jwks(): Promise<{ keys: PublicJwk[] }> {
return this.oidc.jwks();
}
@Get("authorize")
@Redirect()
@Header("Cache-Control", "no-store")
authorize(@Query() query: unknown): { url: string; statusCode: number } {
return { url: this.oidc.authorizeRedirect(query), statusCode: 302 };
}
@Post("authorize/consent")
@HttpCode(200)
@UseGuards(RbacGuard)
@Header("Cache-Control", "no-store")
consent(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<{ redirectTo: string }> {
const session = requireSession(request);
return this.oidc.consent(session.workspaceId, session.userId, body);
}
@Post("token")
@HttpCode(200)
@Header("Cache-Control", "no-store")
@Header("Pragma", "no-cache")
token(@Body() body: unknown): Promise<TokenResponse> {
return this.oidc.token(body);
}
@Get("userinfo")
@Header("Cache-Control", "no-store")
async userinfo(@Req() request: Request, @Headers("authorization") authorization: string | undefined): Promise<UserInfo> {
try {
return await this.oidc.userinfo(authorization);
} catch (err) {
if (err instanceof UnauthorizedException) request.res?.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"');
throw err;
}
}
}

View file

@ -0,0 +1,209 @@
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, verify, type JsonWebKey, type KeyObject } from "node:crypto";
import { link, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { SealedCredential } from "../vault/vault.js";
/**
* ES256 signing keys for the Hub's OIDC issuer.
*
* KEY MANAGEMENT CHOICE. The Hub's existing pattern for secret material is the
* vault envelope (SUPERPOSTER_KEK wraps a fresh DEK, AES-256-GCM, AAD-bound).
* The keyset is a single small blob, so it is sealed with that same envelope
* and written to OIDC_KEY_DIR (a volume next to the API's data) instead of a
* new Postgres table: no migration, and the file alone is useless without the
* KEK. The private key is generated on first boot, never leaves this process
* unsealed, and is never logged.
*
* First boot is race-safe: the sealed blob is written to a temp file and
* hard-linked into place, which fails with EEXIST if another process won —
* the loser re-reads the winner's keyset, so every replica signs with one key.
*
* The keyset holds a LIST of keys (newest signs, all are published in JWKS) so
* rotation can be added without a format change; phase 1 never rotates.
*/
/** The vault surface the keyring needs; `Vault` satisfies it, tests pass a fake. */
export interface KeySealer {
seal(workspaceId: string, credentialId: string, plaintext: Buffer): Promise<SealedCredential>;
open(workspaceId: string, credentialId: string, sealed: SealedCredential): Promise<Buffer>;
}
/** Where the sealed keyset lives. `createExclusive` returns false if it already exists. */
export interface OidcKeyStore {
read(): Promise<Buffer | null>;
createExclusive(blob: Buffer): Promise<boolean>;
}
/** AAD for the vault envelope — binds the blob to its purpose. */
const SEAL_SCOPE = "hub-oidc";
const SEAL_ID = "signing-keyset";
const FILE_FORMAT = "tmbh-oidc-keyset/v1";
const KEYSET_FILE = "keyset.sealed.json";
export class FileKeyStore implements OidcKeyStore {
constructor(private readonly dir: string) {}
async read(): Promise<Buffer | null> {
try {
return await readFile(join(this.dir, KEYSET_FILE));
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
throw err;
}
}
async createExclusive(blob: Buffer): Promise<boolean> {
await mkdir(this.dir, { recursive: true, mode: 0o700 });
const temp = join(this.dir, `.${KEYSET_FILE}.${randomBytes(6).toString("hex")}.tmp`);
await writeFile(temp, blob, { mode: 0o600, flag: "wx" });
try {
await link(temp, join(this.dir, KEYSET_FILE));
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EEXIST") return false;
throw err;
} finally {
await unlink(temp).catch(() => undefined);
}
}
}
interface StoredKey {
kid: string;
createdAt: number;
privateJwk: JsonWebKey;
}
interface Keyset {
keys: StoredKey[];
}
interface LoadedKey {
kid: string;
privateKey: KeyObject;
publicKey: KeyObject;
publicJwk: PublicJwk;
}
export interface PublicJwk {
kty: "EC";
crv: "P-256";
x: string;
y: string;
kid: string;
use: "sig";
alg: "ES256";
}
export class OidcKeyError extends Error {}
function b64json(value: unknown): string {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
}
/** RFC 7638 thumbprint — a stable kid derived from the public key alone. */
function thumbprint(jwk: JsonWebKey): string {
const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
return createHash("sha256").update(canonical).digest("base64url");
}
function loadKey(stored: StoredKey): LoadedKey {
const privateKey = createPrivateKey({ key: stored.privateJwk, format: "jwk" });
const publicKey = createPublicKey(privateKey);
const jwk = publicKey.export({ format: "jwk" });
if (jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.x !== "string" || typeof jwk.y !== "string") {
throw new OidcKeyError("keyset holds a key that is not P-256");
}
if (thumbprint(jwk) !== stored.kid) throw new OidcKeyError("keyset kid does not match its key");
return { kid: stored.kid, privateKey, publicKey, publicJwk: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y, kid: stored.kid, use: "sig", alg: "ES256" } };
}
function newKey(now: number): StoredKey {
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const privateJwk = privateKey.export({ format: "jwk" });
return { kid: thumbprint(privateJwk), createdAt: now, privateJwk };
}
async function sealKeyset(sealer: KeySealer, keyset: Keyset): Promise<Buffer> {
const sealed = await sealer.seal(SEAL_SCOPE, SEAL_ID, Buffer.from(JSON.stringify(keyset), "utf8"));
return Buffer.from(JSON.stringify({ format: FILE_FORMAT, keyVersion: sealed.keyVersion, ciphertext: sealed.ciphertext.toString("base64") }), "utf8");
}
async function openKeyset(sealer: KeySealer, blob: Buffer): Promise<Keyset> {
let envelope: { format?: unknown; keyVersion?: unknown; ciphertext?: unknown };
try {
envelope = JSON.parse(blob.toString("utf8")) as typeof envelope;
} catch {
throw new OidcKeyError("keyset file is not valid JSON");
}
if (envelope.format !== FILE_FORMAT || typeof envelope.keyVersion !== "number" || typeof envelope.ciphertext !== "string") {
throw new OidcKeyError("keyset file has an unknown format");
}
let plaintext: Buffer;
try {
plaintext = await sealer.open(SEAL_SCOPE, SEAL_ID, { keyVersion: envelope.keyVersion, ciphertext: Buffer.from(envelope.ciphertext, "base64") });
} catch {
// Wrong SUPERPOSTER_KEK or a tampered file. Never generate a replacement
// silently: that would invalidate every token oCIS holds.
throw new OidcKeyError("keyset could not be unsealed (SUPERPOSTER_KEK changed or file corrupted)");
}
const keyset = JSON.parse(plaintext.toString("utf8")) as Keyset;
if (!Array.isArray(keyset.keys) || keyset.keys.length === 0) throw new OidcKeyError("keyset is empty");
return keyset;
}
/** Loaded signing keys. The newest key signs; JWKS publishes all of them. */
export class OidcKeyring {
private constructor(private readonly keys: readonly LoadedKey[]) {}
/** Load the persisted keyset, generating and persisting one on first boot. */
static async open(store: OidcKeyStore, sealer: KeySealer, nowSeconds = Math.floor(Date.now() / 1000)): Promise<OidcKeyring> {
let blob = await store.read();
if (blob === null) {
const fresh = await sealKeyset(sealer, { keys: [newKey(nowSeconds)] });
// Lost the first-boot race: fall through and read the winner's keyset.
blob = (await store.createExclusive(fresh)) ? fresh : await store.read();
if (blob === null) throw new OidcKeyError("keyset vanished after creation");
}
const keyset = await openKeyset(sealer, blob);
const keys = [...keyset.keys].sort((a, b) => b.createdAt - a.createdAt).map(loadKey);
return new OidcKeyring(keys);
}
get signingKid(): string {
return this.keys[0]!.kid;
}
jwks(): { keys: PublicJwk[] } {
return { keys: this.keys.map((key) => ({ ...key.publicJwk })) };
}
/** Compact JWS, ES256 (raw r||s signature per RFC 7518 §3.4). */
sign(payload: Record<string, unknown>, typ = "JWT"): string {
const key = this.keys[0]!;
const signingInput = `${b64json({ alg: "ES256", typ, kid: key.kid })}.${b64json(payload)}`;
const signature = sign("sha256", Buffer.from(signingInput), { key: key.privateKey, dsaEncoding: "ieee-p1363" });
return `${signingInput}.${signature.toString("base64url")}`;
}
/** Signature-checked header + payload, or null. Claim checks are the caller's job. */
verify(token: string): { header: Record<string, unknown>; payload: Record<string, unknown> } | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [rawHeader, rawPayload, rawSignature] = parts as [string, string, string];
let header: Record<string, unknown>;
let payload: Record<string, unknown>;
try {
header = JSON.parse(Buffer.from(rawHeader, "base64url").toString("utf8")) as Record<string, unknown>;
payload = JSON.parse(Buffer.from(rawPayload, "base64url").toString("utf8")) as Record<string, unknown>;
} catch {
return null;
}
if (typeof header !== "object" || header === null || typeof payload !== "object" || payload === null) return null;
if (header.alg !== "ES256") return null;
const key = this.keys.find((k) => k.kid === header.kid);
if (key === undefined) return null;
const ok = verify("sha256", Buffer.from(`${rawHeader}.${rawPayload}`), { key: key.publicKey, dsaEncoding: "ieee-p1363" }, Buffer.from(rawSignature, "base64url"));
return ok ? { header, payload } : null;
}
}

View file

@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { OidcController } from "./oidc.controller.js";
import { OidcService } from "./oidc.service.js";
/**
* OIDC provider module — the Hub as identity provider for oCIS (files SSO).
* The signing keyset is loaded (or generated, first boot) in onModuleInit; a
* missing OIDC_KEY_DIR / KEK problem only makes the /oidc routes answer 503,
* it never blocks API boot.
*/
@Module({
controllers: [OidcController],
providers: [OidcService],
})
export class OidcModule {}

View file

@ -0,0 +1,369 @@
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
Inject,
Injectable,
Logger,
OnModuleInit,
Optional,
ServiceUnavailableException,
UnauthorizedException,
} from "@nestjs/common";
import { withTenant } from "../db/client.js";
import { USERNAME_PATTERN } from "../http/validation.js";
import { vault } from "../vault/vault.js";
import {
CODE_TTL_SECONDS,
CODE_VERIFIER,
SUPPORTED_SCOPES,
TOKEN_TTL_SECONDS,
WEB_CLIENT_ID,
authorizeParamsFrom,
errorRedirect,
isFirstPartyClient,
loadOidcConfig,
validateAuthorize,
AUTHORIZE_PARAM_NAMES,
type OidcConfig,
} from "./oidc.config.js";
import { FileKeyStore, OidcKeyring, type PublicJwk } from "./oidc.keys.js";
/** The Hub account an authorization code is issued for. */
export interface OidcUser {
id: string;
username: string;
email: string | null;
displayName: string | null;
}
/** Resolves the session's user; null unless they are an ACTIVE member of the workspace. */
export interface OidcUserDirectory {
activeMember(workspaceId: string, userId: string): Promise<OidcUser | null>;
}
export interface OidcDependencies {
config: () => OidcConfig;
keyring: (config: OidcConfig) => Promise<OidcKeyring>;
users: OidcUserDirectory;
/** Epoch milliseconds. */
now: () => number;
/** Usernames that already exist inside oCIS and must never be claimed by a Hub user. */
reservedUsernames: () => ReadonlySet<string>;
}
export const OIDC_DEPENDENCIES: unique symbol = Symbol("OIDC_DEPENDENCIES");
export interface TokenResponse {
access_token: string;
token_type: "Bearer";
expires_in: number;
id_token: string;
scope: string;
}
export interface UserInfo {
sub: string;
preferred_username: string;
name?: string;
email?: string;
}
/** Token-endpoint failures in the RFC 6749 §5.2 JSON shape. */
export class OAuthError extends HttpException {
constructor(error: string, description: string, status: HttpStatus = HttpStatus.BAD_REQUEST) {
super({ error, error_description: description }, status);
}
}
interface CodeRecord {
clientId: string;
redirectUri: string;
codeChallenge: string;
scopes: string[];
nonce: string | null;
user: OidcUser;
authTime: number;
expiresAt: number;
}
/** Bounded so a flood of consents cannot grow memory without limit. */
const MAX_PENDING_CODES = 10_000;
class DatabaseUserDirectory implements OidcUserDirectory {
async activeMember(workspaceId: string, userId: string): Promise<OidcUser | null> {
const result = await withTenant(workspaceId, (client) =>
client.query<{ id: string; username: string | null; email: string | null; display_name: string | null }>(
`SELECT u.id, u.username, u.email, u.display_name
FROM users u
JOIN memberships m ON m.user_id = u.id AND m.workspace_id = $1 AND m.status = 'active'
WHERE u.id = $2`,
[workspaceId, userId],
),
);
const row = result.rows[0];
if (row === undefined || row.username === null) return null;
return { id: row.id, username: row.username, email: row.email, displayName: row.display_name };
}
}
/** oCIS's bootstrap admin plus its service account (FILES_OCID_USER) plus OIDC_RESERVED_USERNAMES. */
function reservedFromEnv(): ReadonlySet<string> {
const names = ["admin", process.env.FILES_OCID_USER ?? "", ...(process.env.OIDC_RESERVED_USERNAMES ?? "").split(",")];
return new Set(names.map((n) => n.trim().toLowerCase()).filter((n) => n !== ""));
}
function sha256(value: string): Buffer {
return createHash("sha256").update(value).digest();
}
/** OIDC Core §3.1.3.6: left half of SHA-256(access_token), base64url. */
function atHash(accessToken: string): string {
return sha256(accessToken).subarray(0, 16).toString("base64url");
}
function str(record: Record<string, unknown>, name: string): string | undefined {
const value = record[name];
return typeof value === "string" ? value : undefined;
}
/**
* The Hub as an OpenID Connect provider for oCIS (files SSO, phase 1 of
* docs/design/2026-10-13-files-oidc-sso.md).
*
* DEVIATION FROM THE DESIGN DOC — session binding. The design binds
* /oidc/authorize to a Hub session cookie; the Hub has none (sessions are
* Bearer tokens in the SPA's localStorage, unreadable by api.occupymedia.cc
* during a top-level redirect). So authorization is split in two:
*
* 1. GET /oidc/authorize (public) validates client_id, the exact
* redirect_uri, scope and PKCE, then 302s the browser to the Hub SPA at
* app.occupymedia.cc/oidc/consent?<same params>.
* 2. The SPA calls POST /oidc/authorize/consent with its Bearer session
* (RbacGuard: signature + authz epoch). The API re-validates every param,
* resolves the session's user (active membership required), issues the
* single-use code and returns the redirect URL; the SPA navigates there.
*
* Auto-consent: only first-party clients (client_id=web, the oCIS web app)
* are registered; no consent screen is shown and any other client fails
* closed. Codes are random 256-bit values kept in memory as SHA-256 digests
* (single-use, 60 s TTL; an API restart simply invalidates pending codes).
* Tokens are stateless ES256 JWTs. Codes, tokens and keys are never logged.
*/
@Injectable()
export class OidcService implements OnModuleInit {
private readonly logger = new Logger("Oidc");
private readonly deps: OidcDependencies;
private readonly codes = new Map<string, CodeRecord>();
private keyringPromise: Promise<OidcKeyring> | null = null;
constructor(@Optional() @Inject(OIDC_DEPENDENCIES) deps?: Partial<OidcDependencies>) {
this.deps = {
config: deps?.config ?? (() => loadOidcConfig()),
keyring: deps?.keyring ?? ((config) => OidcKeyring.open(new FileKeyStore(config.keyDir), vault)),
users: deps?.users ?? new DatabaseUserDirectory(),
now: deps?.now ?? (() => Date.now()),
reservedUsernames: deps?.reservedUsernames ?? reservedFromEnv,
};
}
/** Generate (first boot) or load the signing keys now; a failure only disables the OIDC routes. */
async onModuleInit(): Promise<void> {
try {
await this.keyring();
} catch (err) {
this.logger.warn(`OIDC signing keys unavailable; /oidc answers 503: ${err instanceof Error ? err.message : "error"}`);
}
}
discovery(): Record<string, unknown> {
const { issuer } = this.config();
return {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
userinfo_endpoint: `${issuer}/userinfo`,
jwks_uri: `${issuer}/jwks`,
response_types_supported: ["code"],
response_modes_supported: ["query"],
grant_types_supported: ["authorization_code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["ES256"],
token_endpoint_auth_methods_supported: ["none"],
code_challenge_methods_supported: ["S256"],
scopes_supported: [...SUPPORTED_SCOPES],
claims_supported: ["iss", "sub", "aud", "exp", "iat", "auth_time", "nonce", "preferred_username", "name", "email"],
authorization_response_iss_parameter_supported: true,
};
}
async jwks(): Promise<{ keys: PublicJwk[] }> {
return (await this.keyring()).jwks();
}
/**
* GET /oidc/authorize → where to send the browser: the Hub consent page, or
* the client's redirect URI with an error. Unverifiable clients get a 400.
*/
authorizeRedirect(query: unknown): string {
const config = this.config();
const params = authorizeParamsFrom(query);
const result = validateAuthorize(params);
if (!result.ok && !result.redirect) throw new BadRequestException(`${result.error}: ${result.description}`);
if (!result.ok) return errorRedirect(result.redirectUri, result.state, result.error, result.description);
const consent = new URL(config.consentUrl);
for (const name of AUTHORIZE_PARAM_NAMES) {
const value = params[name];
if (value !== undefined) consent.searchParams.set(name, value);
}
return consent.toString();
}
/** POST /oidc/authorize/consent — the session's user approves (auto, first-party) and gets a code. */
async consent(workspaceId: string, userId: string, body: unknown): Promise<{ redirectTo: string }> {
const config = this.config();
const result = validateAuthorize(authorizeParamsFrom(body));
if (!result.ok && !result.redirect) throw new BadRequestException(`${result.error}: ${result.description}`);
if (!result.ok) return { redirectTo: errorRedirect(result.redirectUri, result.state, result.error, result.description) };
const request = result.request;
if (!isFirstPartyClient(request.clientId)) {
// No consent UI exists yet: anything but a first-party client fails closed.
return { redirectTo: errorRedirect(request.redirectUri, request.state, "consent_required", "this client requires explicit consent") };
}
await this.keyring(); // fail with 503 before minting a code that could never be redeemed
const user = await this.deps.users.activeMember(workspaceId, userId);
if (user === null) throw new ForbiddenException("files sign-in requires an active membership");
// preferred_username is the oCIS account name verbatim (design: same
// invariant as matrix-sso). Refuse rather than rewrite, and never let a
// Hub user claim an account oCIS already owns (admin, service account).
if (!USERNAME_PATTERN.test(user.username)) throw new ForbiddenException("this username cannot be used as a files account name");
if (this.deps.reservedUsernames().has(user.username)) throw new ForbiddenException("this username is reserved by the files server");
const now = this.deps.now();
this.sweep(now);
if (this.codes.size >= MAX_PENDING_CODES) throw new ServiceUnavailableException("too many pending sign-ins; retry shortly");
const code = randomBytes(32).toString("base64url");
this.codes.set(sha256(code).toString("base64url"), {
clientId: request.clientId,
redirectUri: request.redirectUri,
codeChallenge: request.codeChallenge,
scopes: request.scopes,
nonce: request.nonce,
user,
authTime: Math.floor(now / 1000),
expiresAt: now + CODE_TTL_SECONDS * 1000,
});
const redirect = new URL(request.redirectUri);
redirect.searchParams.set("code", code);
if (request.state !== null) redirect.searchParams.set("state", request.state);
redirect.searchParams.set("iss", config.issuer);
return { redirectTo: redirect.toString() };
}
/** POST /oidc/token — authorization_code + PKCE verifier → id_token + access_token. */
async token(body: unknown): Promise<TokenResponse> {
const config = this.config();
const params = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record<string, unknown>) : {};
if (str(params, "grant_type") !== "authorization_code") throw new OAuthError("unsupported_grant_type", "only authorization_code is supported");
const clientId = str(params, "client_id");
if (clientId !== WEB_CLIENT_ID) throw new OAuthError("invalid_client", "unknown client_id", HttpStatus.UNAUTHORIZED);
const code = str(params, "code");
if (code === undefined || code === "") throw new OAuthError("invalid_request", "code is required");
// Single use: the code is consumed by the first exchange attempt, even a
// failed one (a wrong verifier burns it — RFC 7636 §4.6 / OAuth 2.1).
const key = sha256(code).toString("base64url");
const record = this.codes.get(key);
this.codes.delete(key);
const now = this.deps.now();
if (record === undefined || now >= record.expiresAt) throw new OAuthError("invalid_grant", "code is invalid, expired or already used");
if (record.clientId !== clientId) throw new OAuthError("invalid_grant", "code was issued to another client");
if (str(params, "redirect_uri") !== record.redirectUri) throw new OAuthError("invalid_grant", "redirect_uri does not match the authorization request");
const verifier = str(params, "code_verifier");
if (verifier === undefined || !CODE_VERIFIER.test(verifier)) throw new OAuthError("invalid_grant", "a valid PKCE code_verifier is required");
const computed = sha256(verifier);
const expected = Buffer.from(record.codeChallenge, "base64url");
if (expected.length !== computed.length || !timingSafeEqual(computed, expected)) {
throw new OAuthError("invalid_grant", "PKCE verification failed");
}
const keyring = await this.keyring();
const iat = Math.floor(now / 1000);
const exp = iat + TOKEN_TTL_SECONDS;
const scope = record.scopes.join(" ");
const claims = this.userClaims(record.user, record.scopes);
const accessToken = keyring.sign(
{ iss: config.issuer, aud: clientId, sub: record.user.id, client_id: clientId, scope, iat, exp, jti: randomUUID(), ...claims },
"at+jwt",
);
const idToken = keyring.sign({
iss: config.issuer,
aud: clientId,
sub: record.user.id,
iat,
exp,
auth_time: record.authTime,
...(record.nonce !== null ? { nonce: record.nonce } : {}),
at_hash: atHash(accessToken),
...claims,
});
return { access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, id_token: idToken, scope };
}
/** GET /oidc/userinfo — claims for a valid access token (stateless: read from the token itself). */
async userinfo(authorization: string | undefined): Promise<UserInfo> {
const invalid = (): UnauthorizedException => new UnauthorizedException({ error: "invalid_token", error_description: "access token is missing, invalid or expired" });
if (authorization === undefined || !authorization.startsWith("Bearer ")) throw invalid();
const config = this.config();
const verified = (await this.keyring()).verify(authorization.slice("Bearer ".length).trim());
if (verified === null || verified.header.typ !== "at+jwt") throw invalid();
const { payload } = verified;
const exp = payload.exp;
if (payload.iss !== config.issuer || typeof exp !== "number" || exp <= Math.floor(this.deps.now() / 1000)) throw invalid();
const sub = str(payload, "sub");
const username = str(payload, "preferred_username");
if (sub === undefined || username === undefined) throw invalid();
const info: UserInfo = { sub, preferred_username: username };
const name = str(payload, "name");
const email = str(payload, "email");
if (name !== undefined) info.name = name;
if (email !== undefined) info.email = email;
return info;
}
/** preferred_username always (oCIS maps accounts by it); name/email by scope. */
private userClaims(user: OidcUser, scopes: readonly string[]): Record<string, string> {
const claims: Record<string, string> = { preferred_username: user.username };
if (scopes.includes("profile")) claims.name = user.displayName ?? user.username;
if (scopes.includes("email") && user.email !== null && user.email !== "") claims.email = user.email;
return claims;
}
private sweep(now: number): void {
for (const [key, record] of this.codes) if (now >= record.expiresAt) this.codes.delete(key);
}
private config(): OidcConfig {
try {
return this.deps.config();
} catch (err) {
throw new ServiceUnavailableException(`files sign-in is not configured: ${err instanceof Error ? err.message : "invalid OIDC config"}`);
}
}
private keyring(): Promise<OidcKeyring> {
if (this.keyringPromise === null) {
const config = this.config();
this.keyringPromise = this.deps.keyring(config).catch((err: unknown) => {
this.keyringPromise = null; // retry on the next request (e.g. volume mounted late)
this.logger.warn(`OIDC keyring load failed: ${err instanceof Error ? err.message : "error"}`);
throw new ServiceUnavailableException("files sign-in keys are unavailable");
});
}
return this.keyringPromise;
}
}

View file

@ -0,0 +1,353 @@
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BadRequestException, ForbiddenException, HttpException, UnauthorizedException } from "@nestjs/common";
import { Vault, type SealedCredential } from "../vault/vault.js";
import { CODE_TTL_SECONDS, DEFAULT_ISSUER, OidcConfigError, WEB_REDIRECT_URI, loadOidcConfig, validateAuthorize, type OidcConfig } from "./oidc.config.js";
import { FileKeyStore, OidcKeyError, OidcKeyring, type KeySealer, type PublicJwk } from "./oidc.keys.js";
import { OidcService, type OidcUser, type OidcUserDirectory, type TokenResponse } from "./oidc.service.js";
const WORKSPACE = "11111111-1111-4111-8111-111111111111";
const ADA: OidcUser = { id: "22222222-2222-4222-8222-222222222222", username: "ada.l", email: "ada@example.org", displayName: "Ada Lovelace" };
function pkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("base64url");
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
}
function authorizeParams(challenge: string, extra: Record<string, string> = {}): Record<string, string> {
return {
response_type: "code",
client_id: "web",
redirect_uri: WEB_REDIRECT_URI,
scope: "openid profile email",
state: "st-123",
nonce: "n-456",
code_challenge: challenge,
code_challenge_method: "S256",
...extra,
};
}
function decode(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record<string, unknown>;
}
/** Independent ES256 check against the published JWKS (what oCIS does). */
function verifiedBy(jwks: { keys: PublicJwk[] }, token: string): Record<string, unknown> {
const [header, payload, signature] = token.split(".") as [string, string, string];
const jwk = jwks.keys.find((k) => k.kid === decode(header).kid);
if (jwk === undefined) throw new Error("kid not in JWKS");
const key = createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, format: "jwk" });
const ok = verify("sha256", Buffer.from(`${header}.${payload}`), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(signature, "base64url"));
if (!ok) throw new Error("signature did not verify");
return decode(payload);
}
class FakeUsers implements OidcUserDirectory {
readonly members = new Map<string, OidcUser>([[ADA.id, ADA]]);
async activeMember(workspaceId: string, userId: string): Promise<OidcUser | null> {
return workspaceId === WORKSPACE ? (this.members.get(userId) ?? null) : null;
}
}
/** Distinct KEK → the sealed keyset must not open. */
class OtherKekSealer implements KeySealer {
async seal(): Promise<SealedCredential> {
throw new Error("unused");
}
async open(): Promise<Buffer> {
throw new Error("unsupported state or unable to authenticate data");
}
}
let keyDir: string;
beforeEach(async () => {
keyDir = await mkdtemp(join(tmpdir(), "hub-oidc-"));
});
afterEach(async () => {
await rm(keyDir, { recursive: true, force: true });
});
function harness(): { service: OidcService; users: FakeUsers; clock: { now: number }; config: OidcConfig } {
const users = new FakeUsers();
const clock = { now: 1_800_000_000_000 };
const config: OidcConfig = { issuer: DEFAULT_ISSUER, consentUrl: new URL("https://app.occupymedia.cc/oidc/consent"), keyDir };
const vault = new Vault();
const service = new OidcService({
config: () => config,
keyring: (c) => OidcKeyring.open(new FileKeyStore(c.keyDir), vault),
users,
now: () => clock.now,
reservedUsernames: () => new Set(["admin", "svc-files"]),
});
return { service, users, clock, config };
}
async function issueCode(service: OidcService, challenge: string, extra: Record<string, string> = {}): Promise<string> {
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, extra));
const url = new URL(redirectTo);
const code = url.searchParams.get("code");
if (code === null) throw new Error(`no code in redirect: ${url.searchParams.get("error") ?? ""}`);
return code;
}
function exchange(service: OidcService, code: string, verifier: string | undefined, extra: Record<string, string> = {}): Promise<TokenResponse> {
const body: Record<string, string> = { grant_type: "authorization_code", client_id: "web", redirect_uri: WEB_REDIRECT_URI, code, ...extra };
if (verifier !== undefined) body.code_verifier = verifier;
return service.token(body);
}
async function oauthError(promise: Promise<unknown>): Promise<{ status: number; error: string }> {
const err = await promise.then(() => null, (e: unknown) => e);
if (!(err instanceof HttpException)) throw new Error("expected an HttpException");
const body = err.getResponse() as { error?: string };
return { status: err.getStatus(), error: body.error ?? "" };
}
describe("loadOidcConfig", () => {
it("defaults to the production issuer and consent page", () => {
const config = loadOidcConfig({ OIDC_KEY_DIR: "/var/lib/hub/oidc" });
expect(config.issuer).toBe("https://api.occupymedia.cc/oidc");
expect(config.consentUrl.toString()).toBe("https://app.occupymedia.cc/oidc/consent");
});
it("requires a key volume in production and refuses non-https issuers", () => {
expect(() => loadOidcConfig({ NODE_ENV: "production" })).toThrow(OidcConfigError);
expect(() => loadOidcConfig({ OIDC_KEY_DIR: "/k", OIDC_ISSUER: "http://api.example.org/oidc" })).toThrow(OidcConfigError);
expect(loadOidcConfig({ OIDC_KEY_DIR: "/k", OIDC_ISSUER: "http://localhost:3000/oidc/" }).issuer).toBe("http://localhost:3000/oidc");
});
});
describe("validateAuthorize", () => {
const { challenge } = pkce();
it("never redirects to an unknown client or an unregistered redirect_uri", () => {
expect(validateAuthorize(authorizeParams(challenge, { client_id: "evil" }))).toMatchObject({ ok: false, redirect: false, error: "invalid_client" });
for (const redirect_uri of [`${WEB_REDIRECT_URI}?x=1`, "https://files.occupymedia.cc/oidc-callback.html/", "https://evil.example/oidc-callback.html"]) {
expect(validateAuthorize(authorizeParams(challenge, { redirect_uri }))).toMatchObject({ ok: false, redirect: false });
}
});
it("requires PKCE S256 (missing challenge or plain method is rejected)", () => {
const missing = authorizeParams(challenge);
delete missing.code_challenge;
expect(validateAuthorize(missing)).toMatchObject({ ok: false, redirect: true, error: "invalid_request", state: "st-123" });
expect(validateAuthorize(authorizeParams(challenge, { code_challenge_method: "plain" }))).toMatchObject({ ok: false, redirect: true });
expect(validateAuthorize(authorizeParams("too-short", {}))).toMatchObject({ ok: false, redirect: true });
});
it("requires openid and refuses unknown scopes", () => {
expect(validateAuthorize(authorizeParams(challenge, { scope: "profile email" }))).toMatchObject({ ok: false, error: "invalid_scope" });
expect(validateAuthorize(authorizeParams(challenge, { scope: "openid offline_access" }))).toMatchObject({ ok: false, error: "invalid_scope" });
expect(validateAuthorize(authorizeParams(challenge))).toMatchObject({ ok: true, request: { scopes: ["openid", "profile", "email"] } });
});
});
describe("OidcKeyring persistence", () => {
it("generates on first boot, then reloads the same key across restarts", async () => {
const vault = new Vault();
const first = await OidcKeyring.open(new FileKeyStore(keyDir), vault);
const token = first.sign({ hello: "world" });
const restarted = await OidcKeyring.open(new FileKeyStore(keyDir), new Vault());
expect(restarted.signingKid).toBe(first.signingKid);
expect(restarted.jwks()).toEqual(first.jwks());
expect(restarted.verify(token)?.payload).toEqual({ hello: "world" });
});
it("stores the keyset sealed (no private JWK on disk) with owner-only permissions", async () => {
await OidcKeyring.open(new FileKeyStore(keyDir), new Vault());
const file = join(keyDir, "keyset.sealed.json");
const raw = await readFile(file, "utf8");
expect(raw).not.toContain("privateJwk");
expect(raw).not.toContain("\"d\"");
expect((await stat(file)).mode & 0o777).toBe(0o600);
});
it("converges on one key when two processes boot at once", async () => {
const [a, b] = await Promise.all([
OidcKeyring.open(new FileKeyStore(keyDir), new Vault()),
OidcKeyring.open(new FileKeyStore(keyDir), new Vault()),
]);
expect(a.signingKid).toBe(b.signingKid);
});
it("refuses to silently regenerate when the keyset cannot be unsealed", async () => {
await OidcKeyring.open(new FileKeyStore(keyDir), new Vault());
await expect(OidcKeyring.open(new FileKeyStore(keyDir), new OtherKekSealer())).rejects.toBeInstanceOf(OidcKeyError);
});
it("rejects tokens with a tampered payload or a foreign key", async () => {
const ring = await OidcKeyring.open(new FileKeyStore(keyDir), new Vault());
const [header, , signature] = ring.sign({ sub: "a" }).split(".");
expect(ring.verify(`${header}.${Buffer.from(JSON.stringify({ sub: "b" })).toString("base64url")}.${signature}`)).toBeNull();
const otherDir = await mkdtemp(join(tmpdir(), "hub-oidc-other-"));
try {
const other = await OidcKeyring.open(new FileKeyStore(otherDir), new Vault());
expect(ring.verify(other.sign({ sub: "a" }))).toBeNull();
} finally {
await rm(otherDir, { recursive: true, force: true });
}
});
});
describe("OidcService", () => {
it("publishes discovery for the exact issuer", () => {
const doc = harness().service.discovery();
expect(doc.issuer).toBe("https://api.occupymedia.cc/oidc");
expect(doc.authorization_endpoint).toBe("https://api.occupymedia.cc/oidc/authorize");
expect(doc.token_endpoint).toBe("https://api.occupymedia.cc/oidc/token");
expect(doc.userinfo_endpoint).toBe("https://api.occupymedia.cc/oidc/userinfo");
expect(doc.jwks_uri).toBe("https://api.occupymedia.cc/oidc/jwks");
expect(doc.code_challenge_methods_supported).toEqual(["S256"]);
expect(doc.id_token_signing_alg_values_supported).toEqual(["ES256"]);
});
it("GET /authorize forwards valid requests to the Hub consent page and bad clients to a 400", () => {
const { service } = harness();
const { challenge } = pkce();
const url = new URL(service.authorizeRedirect({ ...authorizeParams(challenge), junk: "x" }));
expect(`${url.origin}${url.pathname}`).toBe("https://app.occupymedia.cc/oidc/consent");
expect(url.searchParams.get("code_challenge")).toBe(challenge);
expect(url.searchParams.get("state")).toBe("st-123");
expect(url.searchParams.has("junk")).toBe(false);
const noPkce = new URL(service.authorizeRedirect(authorizeParams(challenge, { code_challenge_method: "plain" })));
expect(`${noPkce.origin}${noPkce.pathname}`).toBe(WEB_REDIRECT_URI);
expect(noPkce.searchParams.get("error")).toBe("invalid_request");
expect(() => service.authorizeRedirect(authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }))).toThrow(BadRequestException);
});
it("auto-consents for client_id=web: the session gets a code straight back, no consent screen", async () => {
const { service } = harness();
const { challenge } = pkce();
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge));
const url = new URL(redirectTo);
expect(`${url.origin}${url.pathname}`).toBe(WEB_REDIRECT_URI);
expect(url.searchParams.get("code")).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(url.searchParams.get("state")).toBe("st-123");
expect(url.searchParams.get("iss")).toBe(DEFAULT_ISSUER);
});
it("only issues codes to active members with a usable, unreserved username", async () => {
const { service, users } = harness();
const { challenge } = pkce();
await expect(service.consent(WORKSPACE, "33333333-3333-4333-8333-333333333333", authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
await expect(service.consent("44444444-4444-4444-8444-444444444444", ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
users.members.set(ADA.id, { ...ADA, username: "admin" });
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
users.members.set(ADA.id, { ...ADA, username: "Ada" });
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
});
it("re-validates relayed params: a forged redirect_uri from the consent page is refused", async () => {
const { service } = harness();
const { challenge } = pkce();
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }))).rejects.toBeInstanceOf(BadRequestException);
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { code_challenge_method: "plain" }));
expect(new URL(redirectTo).searchParams.has("code")).toBe(false);
});
it("maps Hub identity into ES256 id_token / access_token claims", async () => {
const { service, clock } = harness();
const { verifier, challenge } = pkce();
const tokens = await exchange(service, await issueCode(service, challenge), verifier);
expect(tokens.token_type).toBe("Bearer");
expect(tokens.scope).toBe("openid profile email");
const jwks = await service.jwks();
expect(decode(tokens.id_token.split(".")[0]!)).toMatchObject({ alg: "ES256", typ: "JWT", kid: jwks.keys[0]!.kid });
const iat = Math.floor(clock.now / 1000);
const idClaims = verifiedBy(jwks, tokens.id_token);
expect(idClaims).toMatchObject({
iss: "https://api.occupymedia.cc/oidc",
aud: "web",
sub: ADA.id,
preferred_username: "ada.l",
email: "ada@example.org",
name: "Ada Lovelace",
nonce: "n-456",
iat,
exp: iat + tokens.expires_in,
});
expect(idClaims.at_hash).toBe(createHash("sha256").update(tokens.access_token).digest().subarray(0, 16).toString("base64url"));
expect(decode(tokens.access_token.split(".")[0]!).typ).toBe("at+jwt");
expect(verifiedBy(jwks, tokens.access_token)).toMatchObject({ iss: DEFAULT_ISSUER, sub: ADA.id, client_id: "web", preferred_username: "ada.l" });
expect(await service.userinfo(`Bearer ${tokens.access_token}`)).toEqual({
sub: ADA.id,
preferred_username: "ada.l",
name: "Ada Lovelace",
email: "ada@example.org",
});
});
it("omits name/email when their scopes were not requested", async () => {
const { service } = harness();
const { verifier, challenge } = pkce();
const tokens = await exchange(service, await issueCode(service, challenge, { scope: "openid" }), verifier);
const claims = verifiedBy(await service.jwks(), tokens.id_token);
expect(claims.preferred_username).toBe("ada.l");
expect(claims).not.toHaveProperty("email");
expect(claims).not.toHaveProperty("name");
});
it("enforces PKCE: a missing or wrong verifier is rejected (and burns the code)", async () => {
const { service } = harness();
const { verifier, challenge } = pkce();
const missing = await issueCode(service, challenge);
expect(await oauthError(exchange(service, missing, undefined))).toEqual({ status: 400, error: "invalid_grant" });
const wrong = await issueCode(service, challenge);
expect(await oauthError(exchange(service, wrong, pkce().verifier))).toEqual({ status: 400, error: "invalid_grant" });
expect(await oauthError(exchange(service, wrong, verifier))).toEqual({ status: 400, error: "invalid_grant" });
});
it("codes are single-use", async () => {
const { service } = harness();
const { verifier, challenge } = pkce();
const code = await issueCode(service, challenge);
await exchange(service, code, verifier);
expect(await oauthError(exchange(service, code, verifier))).toEqual({ status: 400, error: "invalid_grant" });
});
it(`codes expire after ${CODE_TTL_SECONDS}s`, async () => {
const { service, clock } = harness();
const { verifier, challenge } = pkce();
const code = await issueCode(service, challenge);
clock.now += CODE_TTL_SECONDS * 1000;
expect(await oauthError(exchange(service, code, verifier))).toEqual({ status: 400, error: "invalid_grant" });
});
it("binds the code to its client and redirect_uri", async () => {
const { service } = harness();
const { verifier, challenge } = pkce();
expect(await oauthError(exchange(service, await issueCode(service, challenge), verifier, { client_id: "other" }))).toEqual({ status: 401, error: "invalid_client" });
expect(await oauthError(exchange(service, await issueCode(service, challenge), verifier, { redirect_uri: "https://files.occupymedia.cc/" }))).toEqual({ status: 400, error: "invalid_grant" });
expect(await oauthError(service.token({ grant_type: "password", client_id: "web" }))).toEqual({ status: 400, error: "unsupported_grant_type" });
});
it("userinfo refuses id_tokens, expired tokens and garbage", async () => {
const { service, clock } = harness();
const { verifier, challenge } = pkce();
const tokens = await exchange(service, await issueCode(service, challenge), verifier);
await expect(service.userinfo(undefined)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(service.userinfo("Bearer not.a.jwt")).rejects.toBeInstanceOf(UnauthorizedException);
await expect(service.userinfo(`Bearer ${tokens.id_token}`)).rejects.toBeInstanceOf(UnauthorizedException);
clock.now += (tokens.expires_in + 1) * 1000;
await expect(service.userinfo(`Bearer ${tokens.access_token}`)).rejects.toBeInstanceOf(UnauthorizedException);
});
it("keeps the same signing key across a service restart, so issued tokens stay valid", async () => {
const first = harness();
const { verifier, challenge } = pkce();
const tokens = await exchange(first.service, await issueCode(first.service, challenge), verifier);
const restarted = harness();
expect(await restarted.service.userinfo(`Bearer ${tokens.access_token}`)).toMatchObject({ sub: ADA.id });
});
});

View file

@ -76,6 +76,16 @@ export class PublishingController {
return this.gates.listForDraft(session.workspaceId, parseId(draftId));
}
@Get(":draftId/publications")
@RequirePermission("draft.view")
async publicationsForDraft(
@Req() request: Request & AuthenticatedRequest,
@Param("draftId") draftId: string,
): ReturnType<PublishingService["listPublications"]> {
const session = requireSession(request);
return this.publishing.listPublications(session.workspaceId, parseId(draftId));
}
@Post()
@RequirePermission("draft.create")
async create(

View file

@ -119,6 +119,101 @@ function serviceWithDatabase(database: FakeDatabase, canUseConnection = true): P
}
describe("publishing draft state machine", () => {
it("lists tenant-scoped publication attempts and receipts, with empty and missing drafts distinguished", async () => {
const now = new Date("2026-10-06T10:00:00.123Z");
const attempt = {
id: "50000000-0000-4000-8000-000000000005",
connection_id: connectionId,
attempt_no: "9007199254740993",
dispatch_count: 1,
status: "completed",
created_at: now,
updated_at: now,
first_dispatch_started_at: now,
dispatch_started_at: now,
provider_retry_deadline: now,
received_at: now,
published_at: now,
remote_id: "remote-post",
remote_url: "https://example.com/posts/remote-post",
error_code: null,
};
let exists = true;
let attempts: Array<Record<string, unknown>> = [attempt, {
...attempt,
id: "60000000-0000-4000-8000-000000000006",
attempt_no: "1",
dispatch_count: 0,
status: "failed",
first_dispatch_started_at: null,
dispatch_started_at: null,
provider_retry_deadline: null,
received_at: null,
published_at: null,
remote_id: null,
remote_url: null,
error_code: "AUTHZ_REVOKED",
}];
const query = vi.fn(async (sql: string, values: readonly unknown[]): Promise<QueryResult<Record<string, unknown>>> => {
expect(values).toEqual([draftId]);
let rows: Array<Record<string, unknown>>;
if (sql === "SELECT id FROM drafts WHERE id = $1") rows = exists ? [{ id: draftId }] : [];
else if (sql.includes("FROM publishing_attempts a")) {
expect(sql).toContain("r.attempt_id = a.id AND r.workspace_id = a.workspace_id");
expect(sql).toContain("p.idempotency_key = a.idempotency_key AND p.workspace_id = a.workspace_id");
expect(sql).toContain("WHERE a.draft_id = $1");
expect(sql).toContain("ORDER BY a.attempt_no DESC");
rows = attempts;
} else throw new Error(`unexpected SQL in fake: ${sql}`);
return { command: "SELECT", rowCount: rows.length, oid: 0, fields: [], rows };
});
const tenants: string[] = [];
const service = new PublishingService({
tenantTransaction: async (tenant, fn) => {
tenants.push(tenant);
return fn({ query } as unknown as PoolClient);
},
});
await expect(service.listPublications(workspaceId, draftId)).resolves.toEqual({ publications: [{
id: attempt.id,
connectionId,
attemptNo: "9007199254740993",
dispatchCount: 1,
state: "completed",
createdAt: now,
updatedAt: now,
firstDispatchStartedAt: now,
dispatchStartedAt: now,
providerRetryDeadline: now,
receivedAt: now,
publishedAt: now,
remoteId: "remote-post",
remoteUrl: "https://example.com/posts/remote-post",
}, {
id: "60000000-0000-4000-8000-000000000006",
connectionId,
attemptNo: "1",
dispatchCount: 0,
state: "failed",
createdAt: now,
updatedAt: now,
error: "AUTHZ_REVOKED",
}] });
attempts = [{ ...attempt, status: "unknown", remote_url: null, published_at: null, error_code: "PUBLISH_OUTCOME_UNKNOWN" }];
const unsettled = await service.listPublications(workspaceId, draftId);
expect(unsettled.publications[0]).toMatchObject({ state: "unknown", remoteId: "remote-post", receivedAt: now, error: "PUBLISH_OUTCOME_UNKNOWN" });
expect(unsettled.publications[0]).not.toHaveProperty("remoteUrl");
expect(unsettled.publications[0]).not.toHaveProperty("publishedAt");
attempts = [];
await expect(service.listPublications(workspaceId, draftId)).resolves.toEqual({ publications: [] });
exists = false;
query.mockClear();
await expect(service.listPublications(workspaceId, draftId)).rejects.toBeInstanceOf(NotFoundException);
expect(query).toHaveBeenCalledTimes(1);
expect(tenants).toEqual([workspaceId, workspaceId, workspaceId, workspaceId]);
});
it("moves an editing draft through review and approval", async () => {
const database: FakeDatabase = { state: "editing", scheduled: false };
const service = serviceWithDatabase(database);

View file

@ -1,21 +1,19 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common";
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
import {
createPublishingCredentialAccessor,
isPublishingProvider,
providerFetch,
} from "@tactical-media-broadcast-hub/connector-runtime";
import { ConnectorRegistry, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
import { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime";
import { ConnectorRegistry, type CredentialAccessor, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
import { Client, Connection, WorkflowExecutionAlreadyStartedError } from "@temporalio/client";
import { PublishingExecutionRepository, PublishingStateError, canonicalPostHash, assertPublishingSchema, type AttemptRef } from "@tactical-media-broadcast-hub/publishing-execution";
import type { PoolClient } from "pg";
import { markDraftPollsStale, autoOpenDraftPoll } from "../chatgov/poll.service.js";
import { PgConnectionStore } from "../connections/connection-store.js";
import { scrub } from "../audit/audit.service.js";
import { ApprovalGateService, ApprovalGateBlockedError } from "./approval-gate.service.js";
import { pool, withTenant } from "../db/client.js";
import { userCanUseConnection } from "../groups/groups.service.js";
import { vault } from "../vault/vault.js";
export type DraftState = "editing" | "in_review" | "approved" | "rejected" | "scheduled" | "published";
@ -77,12 +75,45 @@ export const PUBLISHING_DEPENDENCIES = Symbol("PUBLISHING_DEPENDENCIES");
let publishingConnectors: ConnectorRegistry | undefined;
/**
* Vault-backed credential accessor for the amplify path. Unlike the fenced
* execution accessor (connector-runtime), this runs in the API process where
* the trigger is the user request itself — same trust level as the archive
* amplify route's bot-token path.
*/
function publishingCredentialAccessor(): CredentialAccessor {
const store = new PgConnectionStore();
return {
async getAccessToken(connection) {
return publishingCredentialAccessor().getSecret(connection, "access_token");
},
async getSecret(connection, key) {
const stored = await store.get(connection.workspaceId, connection.id);
if (!stored.credential) throw new Error("Credential missing");
const bytes = await vault.open(connection.workspaceId, connection.id, stored.credential);
const secrets = JSON.parse(bytes.toString()) as Record<string, unknown>;
const value = secrets[key];
if (typeof value !== "string" || value === "") throw new Error("Credential missing");
return value;
},
async put(connection, key, value) {
const stored = await store.get(connection.workspaceId, connection.id);
if (!stored.credential) throw new Error("Credential missing");
const bytes = await vault.open(connection.workspaceId, connection.id, stored.credential);
const secrets = JSON.parse(bytes.toString()) as Record<string, unknown>;
secrets[key] = value;
const sealed = await vault.seal(connection.workspaceId, connection.id, Buffer.from(JSON.stringify(secrets)));
await store.rotateCredential(connection.workspaceId, connection.id, stored.credential, sealed);
},
};
}
/** Publishing-side connectors with vault-backed credentials, once per process. */
function connectorFor(provider: string): PlatformConnector {
publishingConnectors ??= (() => {
const registry = new ConnectorRegistry();
const options = {
credentials: createPublishingCredentialAccessor(pool),
credentials: publishingCredentialAccessor(),
fetchImpl: (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
providerFetch(input, init),
env: process.env,
@ -233,6 +264,69 @@ export class PublishingService {
});
}
/** One entry per business attempt, including provider evidence before finalization. */
async listPublications(workspaceId: string, draftId: string): Promise<{
publications: Array<{
id: string;
connectionId: string;
attemptNo: string;
dispatchCount: number;
state: "ready" | "in_progress" | "completed" | "failed" | "unknown" | "abandoned";
createdAt: Date;
updatedAt: Date;
firstDispatchStartedAt?: Date;
dispatchStartedAt?: Date;
providerRetryDeadline?: Date;
receivedAt?: Date;
publishedAt?: Date;
remoteId?: string;
remoteUrl?: string;
error?: string;
}>;
}> {
return this.dependencies.tenantTransaction(workspaceId, async (client) => {
const draft = await client.query("SELECT id FROM drafts WHERE id = $1", [draftId]);
if (draft.rowCount !== 1) throw new NotFoundException(`draft ${draftId} not found`);
const result = await client.query(
`SELECT a.id, a.connection_id, a.attempt_no, a.dispatch_count, a.status,
a.created_at, a.updated_at, a.first_dispatch_started_at,
a.dispatch_started_at, a.provider_retry_deadline, a.error_code,
r.received_at, COALESCE(p.published_at, r.published_at) AS published_at,
COALESCE(p.remote_id, r.remote_id) AS remote_id,
COALESCE(p.remote_url, r.remote_url) AS remote_url
FROM publishing_attempts a
LEFT JOIN publishing_receipts r ON r.attempt_id = a.id AND r.workspace_id = a.workspace_id
LEFT JOIN publications p ON p.idempotency_key = a.idempotency_key AND p.workspace_id = a.workspace_id
WHERE a.draft_id = $1
ORDER BY a.attempt_no DESC`,
[draftId],
);
return {
publications: result.rows.map((value: unknown) => {
const row = requiredRow(value, "publishing attempt");
const timestamp = (value: unknown): Date => value instanceof Date ? value : new Date(String(value));
return {
id: String(row.id),
connectionId: String(row.connection_id),
attemptNo: String(row.attempt_no),
dispatchCount: Number(row.dispatch_count),
state: row.status as "ready" | "in_progress" | "completed" | "failed" | "unknown" | "abandoned",
createdAt: timestamp(row.created_at),
updatedAt: timestamp(row.updated_at),
...(row.first_dispatch_started_at === null || row.first_dispatch_started_at === undefined ? {} : { firstDispatchStartedAt: timestamp(row.first_dispatch_started_at) }),
...(row.dispatch_started_at === null || row.dispatch_started_at === undefined ? {} : { dispatchStartedAt: timestamp(row.dispatch_started_at) }),
...(row.provider_retry_deadline === null || row.provider_retry_deadline === undefined ? {} : { providerRetryDeadline: timestamp(row.provider_retry_deadline) }),
...(row.received_at === null || row.received_at === undefined ? {} : { receivedAt: timestamp(row.received_at) }),
...(row.published_at === null || row.published_at === undefined ? {} : { publishedAt: timestamp(row.published_at) }),
...(row.remote_id === null || row.remote_id === undefined ? {} : { remoteId: String(row.remote_id) }),
...(row.remote_url === null || row.remote_url === undefined ? {} : { remoteUrl: String(row.remote_url) }),
...(row.error_code === null || row.error_code === undefined ? {} : { error: String(row.error_code) }),
};
}),
};
});
}
async updateDraft(
workspaceId: string,
actor: Actor,

View file

@ -0,0 +1,94 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Card } from "../../../components/ui/primitives";
import { LoadingPanel } from "../../../components/brand/loading";
import { api, ApiError, errorMessage } from "../../../lib/api-client";
import { getSessionToken } from "../../../lib/session-token";
/**
* /oidc/consent — the Hub half of files (oCIS) single sign-on.
*
* The API's GET /oidc/authorize validates oCIS's request and sends the browser
* here, because only this origin holds the Hub session (a Bearer token in
* localStorage — there is no session cookie the API could read). We relay the
* authorize params to POST /oidc/authorize/consent with that session; the API
* re-validates everything, issues a single-use code and answers with the oCIS
* callback URL, which we navigate to. client_id=web is first-party, so there
* is no consent screen: the user only ever sees a brief "Signing in…".
*/
const AUTHORIZE_PARAMS = [
"response_type", "client_id", "redirect_uri", "scope", "state", "nonce", "code_challenge", "code_challenge_method", "prompt",
] as const;
type Phase = { kind: "working" } | { kind: "signin" } | { kind: "error"; message: string };
function authorizeParams(search: string): Record<string, string> {
const query = new URLSearchParams(search);
const params: Record<string, string> = {};
for (const name of AUTHORIZE_PARAMS) {
const value = query.get(name);
if (value !== null) params[name] = value;
}
return params;
}
export default function OidcConsentPage(): React.ReactElement {
const [phase, setPhase] = useState<Phase>({ kind: "working" });
// Each run issues a single-use code; never mint twice (StrictMode double effects).
const started = useRef(false);
useEffect(() => {
if (started.current) return;
started.current = true;
if (getSessionToken() === null) {
setPhase({ kind: "signin" });
return;
}
api
.post<{ redirectTo: string }>("/oidc/authorize/consent", authorizeParams(window.location.search), { redirectOn401: false })
.then(({ redirectTo }) => {
const target = new URL(redirectTo);
if (target.protocol !== "https:" && target.protocol !== "http:") throw new Error("Unexpected sign-in redirect");
// replace(): Back must not return here and re-run a spent authorization.
window.location.replace(target.toString());
})
.catch((err: unknown) => {
if (err instanceof ApiError && err.status === 401) setPhase({ kind: "signin" });
else setPhase({ kind: "error", message: errorMessage(err) });
});
}, []);
if (phase.kind === "working") {
return (
<div className="flex min-h-screen items-center justify-center bg-bg">
<LoadingPanel label="Signing in to files…" />
</div>
);
}
return (
<div className="flex min-h-full items-center justify-center bg-bg p-6">
<Card className="w-[420px] max-w-full p-6 text-center">
<h1 className="text-xl font-semibold tracking-tight">Files sign-in</h1>
{phase.kind === "signin" ? (
<>
<p className="mt-2 text-xs text-text-faint">Sign in to the Hub first, then open Drive again.</p>
<Link href="/login" className="mt-6 inline-block text-xs text-text underline">
Sign in
</Link>
</>
) : (
<>
<p className="mt-2 text-xs text-danger">{phase.message}</p>
<Link href="/files/drive" className="mt-6 inline-block text-xs text-text-faint underline hover:text-text">
Back to Drive
</Link>
</>
)}
</Card>
</div>
);
}