publishing: unfenced vault accessor for the amplify path

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

View file

@ -0,0 +1,181 @@
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
/**
* Hub-as-OIDC-issuer configuration (files SSO, phase 1).
*
* The issuer, the single first-party client and its redirect URI are fixed by
* the approved design (docs/design/2026-10-13-files-oidc-sso.md): a mismatch
* anywhere breaks oCIS token validation, so production values are constants
* and only the issuer / consent page may be overridden for local development.
*
* ENV CONTRACT (read per call, like loadMatrixSsoConfig — nothing at import):
*
* OIDC_ISSUER default https://api.occupymedia.cc/oidc (dev override only).
* OIDC_CONSENT_URL Hub SPA consent route, default `${APP_URL}/oidc/consent`,
* APP_URL defaulting to https://app.occupymedia.cc.
* OIDC_KEY_DIR directory holding the sealed signing keyset. REQUIRED in
* production (mount a volume). Dev default is under the
* OS temp dir — never inside the repo, so a dev key can
* not be committed by accident.
*/
export interface OidcConfig {
issuer: string;
consentUrl: URL;
keyDir: string;
}
export class OidcConfigError extends Error {}
export const DEFAULT_ISSUER = "https://api.occupymedia.cc/oidc";
const DEFAULT_APP_URL = "https://app.occupymedia.cc";
/** The oCIS web app — the only registered client; public (no secret), PKCE-only. */
export const WEB_CLIENT_ID = "web";
export const WEB_REDIRECT_URI = "https://files.occupymedia.cc/oidc-callback.html";
/** Clients that skip the consent screen. Everything else fails closed. */
const FIRST_PARTY_CLIENTS: ReadonlySet<string> = new Set([WEB_CLIENT_ID]);
const REDIRECT_URIS: Readonly<Record<string, readonly string[]>> = { [WEB_CLIENT_ID]: [WEB_REDIRECT_URI] };
export const SUPPORTED_SCOPES: readonly string[] = ["openid", "profile", "email"];
/** Authorization codes are single-use and live at most this long (design: ≤60 s). */
export const CODE_TTL_SECONDS = 60;
/** id_token / access_token lifetime; oCIS re-runs the (silent) authorize flow after it. */
export const TOKEN_TTL_SECONDS = 3600;
function trimmed(env: Readonly<Record<string, string | undefined>>, name: string): string {
return env[name]?.trim() ?? "";
}
function httpsOrLocal(name: string, raw: string): URL {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new OidcConfigError(`${name} must be an absolute URL`);
}
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
throw new OidcConfigError(`${name} must be https (http only for localhost)`);
}
return url;
}
/** Throws OidcConfigError on malformed or (in production) missing values. */
export function loadOidcConfig(env: Readonly<Record<string, string | undefined>> = process.env): OidcConfig {
const rawIssuer = trimmed(env, "OIDC_ISSUER") || DEFAULT_ISSUER;
const issuerUrl = httpsOrLocal("OIDC_ISSUER", rawIssuer);
if (issuerUrl.search !== "" || issuerUrl.hash !== "") throw new OidcConfigError("OIDC_ISSUER must not carry a query or fragment");
const issuer = rawIssuer.replace(/\/+$/, "");
const rawConsent = trimmed(env, "OIDC_CONSENT_URL") || `${(trimmed(env, "APP_URL") || DEFAULT_APP_URL).replace(/\/+$/, "")}/oidc/consent`;
const consentUrl = httpsOrLocal("OIDC_CONSENT_URL", rawConsent);
const rawKeyDir = trimmed(env, "OIDC_KEY_DIR");
if (rawKeyDir === "" && env.NODE_ENV === "production") {
throw new OidcConfigError("OIDC_KEY_DIR is required in production (a persistent volume for the signing keyset)");
}
const keyDir = rawKeyDir === "" ? join(tmpdir(), "tmbh-hub-oidc") : rawKeyDir;
if (!isAbsolute(keyDir)) throw new OidcConfigError("OIDC_KEY_DIR must be an absolute path");
return { issuer, consentUrl, keyDir };
}
/** Raw authorize parameters, as received on GET /oidc/authorize or relayed by the consent page. */
export interface AuthorizeParams {
response_type?: string;
client_id?: string;
redirect_uri?: string;
scope?: string;
state?: string;
nonce?: string;
code_challenge?: string;
code_challenge_method?: string;
prompt?: string;
}
export const AUTHORIZE_PARAM_NAMES: readonly (keyof AuthorizeParams)[] = [
"response_type", "client_id", "redirect_uri", "scope", "state", "nonce", "code_challenge", "code_challenge_method", "prompt",
];
/** A request that passed every check: safe to issue a code for. */
export interface ValidAuthorizeRequest {
clientId: string;
redirectUri: string;
scopes: string[];
state: string | null;
nonce: string | null;
codeChallenge: string;
}
/**
* Validation outcome. `redirect: false` errors are shown to the user and never
* sent to the redirect URI (RFC 6749 §4.1.2.1: an unverified client or
* redirect_uri must not receive a redirect). `redirect: true` errors go back to
* the client's (already verified) redirect URI as `?error=…&state=…`.
*/
export type AuthorizeValidation =
| { ok: true; request: ValidAuthorizeRequest }
| { ok: false; redirect: false; error: string; description: string }
| { ok: false; redirect: true; redirectUri: string; state: string | null; error: string; description: string };
/** RFC 7636 §4.2: BASE64URL(SHA256(verifier)) is always 43 chars. */
const S256_CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
/** RFC 7636 §4.1 verifier grammar. */
export const CODE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
const MAX_STATE_LENGTH = 512;
export function isFirstPartyClient(clientId: string): boolean {
return FIRST_PARTY_CLIENTS.has(clientId);
}
export function validateAuthorize(params: AuthorizeParams): AuthorizeValidation {
const clientId = params.client_id ?? "";
const allowed = REDIRECT_URIS[clientId];
if (allowed === undefined) return { ok: false, redirect: false, error: "invalid_client", description: "unknown client_id" };
const redirectUri = params.redirect_uri ?? "";
// Exact string match — no prefix, normalisation or wildcard semantics.
if (!allowed.includes(redirectUri)) return { ok: false, redirect: false, error: "invalid_request", description: "redirect_uri is not registered for this client" };
const state = params.state === undefined || params.state === "" ? null : params.state;
const fail = (error: string, description: string): AuthorizeValidation => ({ ok: false, redirect: true, redirectUri, state, error, description });
if (state !== null && state.length > MAX_STATE_LENGTH) return fail("invalid_request", "state is too long");
if (params.response_type !== "code") return fail("unsupported_response_type", "only response_type=code is supported");
const scopes = (params.scope ?? "").split(" ").filter((s) => s !== "");
if (!scopes.includes("openid")) return fail("invalid_scope", "scope must include openid");
const unknown = scopes.find((s) => !SUPPORTED_SCOPES.includes(s));
if (unknown !== undefined) return fail("invalid_scope", `unsupported scope: ${unknown}`);
if (params.code_challenge === undefined || params.code_challenge === "") return fail("invalid_request", "PKCE code_challenge is required");
if (params.code_challenge_method !== "S256") return fail("invalid_request", "code_challenge_method must be S256");
if (!S256_CHALLENGE.test(params.code_challenge)) return fail("invalid_request", "code_challenge is not a S256 challenge");
const nonce = params.nonce === undefined || params.nonce === "" ? null : params.nonce;
if (nonce !== null && nonce.length > MAX_STATE_LENGTH) return fail("invalid_request", "nonce is too long");
return {
ok: true,
request: { clientId, redirectUri, scopes: [...new Set(scopes)], state, nonce, codeChallenge: params.code_challenge },
};
}
/** `redirectUri?error=…&error_description=…&state=…` for a redirectable failure. */
export function errorRedirect(redirectUri: string, state: string | null, error: string, description: string): string {
const url = new URL(redirectUri);
url.searchParams.set("error", error);
url.searchParams.set("error_description", description);
if (state !== null) url.searchParams.set("state", state);
return url.toString();
}
/** Picks only the known authorize params out of an untyped query or body. */
export function authorizeParamsFrom(source: unknown): AuthorizeParams {
const record = typeof source === "object" && source !== null && !Array.isArray(source) ? (source as Record<string, unknown>) : {};
const params: AuthorizeParams = {};
for (const name of AUTHORIZE_PARAM_NAMES) {
const value = record[name];
if (typeof value === "string") params[name] = value;
}
return params;
}

View file

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

View file

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

View file

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

View file

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

View file

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