diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index da327b7..d86f5b7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/oidc/oidc.config.ts b/apps/api/src/oidc/oidc.config.ts new file mode 100644 index 0000000..460f663 --- /dev/null +++ b/apps/api/src/oidc/oidc.config.ts @@ -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 = new Set([WEB_CLIENT_ID]); +const REDIRECT_URIS: Readonly> = { [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>, 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> = 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) : {}; + const params: AuthorizeParams = {}; + for (const name of AUTHORIZE_PARAM_NAMES) { + const value = record[name]; + if (typeof value === "string") params[name] = value; + } + return params; +} diff --git a/apps/api/src/oidc/oidc.controller.ts b/apps/api/src/oidc/oidc.controller.ts new file mode 100644 index 0000000..0ad22ad --- /dev/null +++ b/apps/api/src/oidc/oidc.controller.ts @@ -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 { + 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 { + return this.oidc.token(body); + } + + @Get("userinfo") + @Header("Cache-Control", "no-store") + async userinfo(@Req() request: Request, @Headers("authorization") authorization: string | undefined): Promise { + try { + return await this.oidc.userinfo(authorization); + } catch (err) { + if (err instanceof UnauthorizedException) request.res?.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"'); + throw err; + } + } +} diff --git a/apps/api/src/oidc/oidc.keys.ts b/apps/api/src/oidc/oidc.keys.ts new file mode 100644 index 0000000..780542b --- /dev/null +++ b/apps/api/src/oidc/oidc.keys.ts @@ -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; + open(workspaceId: string, credentialId: string, sealed: SealedCredential): Promise; +} + +/** Where the sealed keyset lives. `createExclusive` returns false if it already exists. */ +export interface OidcKeyStore { + read(): Promise; + createExclusive(blob: Buffer): Promise; +} + +/** 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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; payload: Record } | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + const [rawHeader, rawPayload, rawSignature] = parts as [string, string, string]; + let header: Record; + let payload: Record; + try { + header = JSON.parse(Buffer.from(rawHeader, "base64url").toString("utf8")) as Record; + payload = JSON.parse(Buffer.from(rawPayload, "base64url").toString("utf8")) as Record; + } 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; + } +} diff --git a/apps/api/src/oidc/oidc.module.ts b/apps/api/src/oidc/oidc.module.ts new file mode 100644 index 0000000..ebb465a --- /dev/null +++ b/apps/api/src/oidc/oidc.module.ts @@ -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 {} diff --git a/apps/api/src/oidc/oidc.service.ts b/apps/api/src/oidc/oidc.service.ts new file mode 100644 index 0000000..8412f59 --- /dev/null +++ b/apps/api/src/oidc/oidc.service.ts @@ -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; +} + +export interface OidcDependencies { + config: () => OidcConfig; + keyring: (config: OidcConfig) => Promise; + users: OidcUserDirectory; + /** Epoch milliseconds. */ + now: () => number; + /** Usernames that already exist inside oCIS and must never be claimed by a Hub user. */ + reservedUsernames: () => ReadonlySet; +} + +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 { + 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 { + 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, 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?. + * 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(); + private keyringPromise: Promise | null = null; + + constructor(@Optional() @Inject(OIDC_DEPENDENCIES) deps?: Partial) { + 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 { + try { + await this.keyring(); + } catch (err) { + this.logger.warn(`OIDC signing keys unavailable; /oidc answers 503: ${err instanceof Error ? err.message : "error"}`); + } + } + + discovery(): Record { + 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 { + const config = this.config(); + const params = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record) : {}; + 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 { + 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 { + const claims: Record = { 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 { + 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; + } +} diff --git a/apps/api/src/oidc/oidc.test.ts b/apps/api/src/oidc/oidc.test.ts new file mode 100644 index 0000000..6aa7a2c --- /dev/null +++ b/apps/api/src/oidc/oidc.test.ts @@ -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 = {}): Record { + 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 { + return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record; +} + +/** Independent ES256 check against the published JWKS (what oCIS does). */ +function verifiedBy(jwks: { keys: PublicJwk[] }, token: string): Record { + 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([[ADA.id, ADA]]); + async activeMember(workspaceId: string, userId: string): Promise { + return workspaceId === WORKSPACE ? (this.members.get(userId) ?? null) : null; + } +} + +/** Distinct KEK → the sealed keyset must not open. */ +class OtherKekSealer implements KeySealer { + async seal(): Promise { + throw new Error("unused"); + } + async open(): Promise { + 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 = {}): Promise { + 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 = {}): Promise { + const body: Record = { 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): 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 }); + }); +}); diff --git a/apps/api/src/publishing/publishing.controller.ts b/apps/api/src/publishing/publishing.controller.ts index 4918221..daaddb0 100644 --- a/apps/api/src/publishing/publishing.controller.ts +++ b/apps/api/src/publishing/publishing.controller.ts @@ -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 { + const session = requireSession(request); + return this.publishing.listPublications(session.workspaceId, parseId(draftId)); + } + @Post() @RequirePermission("draft.create") async create( diff --git a/apps/api/src/publishing/publishing.service.test.ts b/apps/api/src/publishing/publishing.service.test.ts index 62bb553..196d116 100644 --- a/apps/api/src/publishing/publishing.service.test.ts +++ b/apps/api/src/publishing/publishing.service.test.ts @@ -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> = [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>> => { + expect(values).toEqual([draftId]); + let rows: Array>; + 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); diff --git a/apps/api/src/publishing/publishing.service.ts b/apps/api/src/publishing/publishing.service.ts index 5d6bc5f..c3f6e41 100644 --- a/apps/api/src/publishing/publishing.service.ts +++ b/apps/api/src/publishing/publishing.service.ts @@ -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; + 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; + 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[0], init?: Parameters[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, diff --git a/apps/web/src/app/oidc/consent/page.tsx b/apps/web/src/app/oidc/consent/page.tsx new file mode 100644 index 0000000..178043d --- /dev/null +++ b/apps/web/src/app/oidc/consent/page.tsx @@ -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 { + const query = new URLSearchParams(search); + const params: Record = {}; + 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({ 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 ( +
+ +
+ ); + } + + return ( +
+ +

Files sign-in

+ {phase.kind === "signin" ? ( + <> +

Sign in to the Hub first, then open Drive again.

+ + Sign in + + + ) : ( + <> +

{phase.message}

+ + Back to Drive + + + )} +
+
+ ); +}