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
|
|
@ -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,
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
72
apps/api/src/oidc/oidc.controller.ts
Normal file
72
apps/api/src/oidc/oidc.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
209
apps/api/src/oidc/oidc.keys.ts
Normal file
209
apps/api/src/oidc/oidc.keys.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
15
apps/api/src/oidc/oidc.module.ts
Normal file
15
apps/api/src/oidc/oidc.module.ts
Normal 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 {}
|
||||
369
apps/api/src/oidc/oidc.service.ts
Normal file
369
apps/api/src/oidc/oidc.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
353
apps/api/src/oidc/oidc.test.ts
Normal file
353
apps/api/src/oidc/oidc.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
94
apps/web/src/app/oidc/consent/page.tsx
Normal file
94
apps/web/src/app/oidc/consent/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue