import { ADMIN_SCOPE, bearerToken } from './auth'; /** * RFC 7662 token-introspection verify-bridge * (SECURE_MODULE_PUBLISH.md §5[D-A], MODULE_PACKAGE_SIGNING.md Phase 1). * * The registry POSTs a presented publish token to the idp's introspection * endpoint, authenticating with its own confidential OIDC client credentials * (provisioned on install — ce-7aa). It reads back `{active, sub, groups, exp}` * and maps a verified identity to a publish scope the request path already * understands (ADMIN_SCOPE / per-package — see {@link claimToScope}). * * Two properties this module MUST preserve: * 1. **Fail closed.** Any non-2xx response, network error, or malformed body * denies the token (returns null). The idp being unreachable never * degrades into "allow". * 2. **No secrets in logs.** The token and client secret are never logged; * only HTTP status / error class is. */ /** Validated subset of an RFC 7662 introspection response. */ export interface IntrospectionResult { active: boolean; sub?: string; groups?: string[]; exp?: number; } export interface IntrospectionConfig { /** RFC 7662 introspection endpoint (OIDC_INTROSPECTION_ENDPOINT). */ endpoint: string; /** Confidential OIDC client id (OIDC_CLIENT_ID). */ clientId: string; /** Confidential OIDC client secret (OIDC_CLIENT_SECRET). */ clientSecret: string; /** Group whose members map to ADMIN_SCOPE (REGISTRY_ADMIN_GROUP). */ adminGroup: string; /** * Group whose members may publish modules they own (REGISTRY_PUBLISHER_GROUP, * ce-1ch). Configurable, not a literal. Members claim/publish per the * module-owner table; admins additionally publish/reassign anything. */ publisherGroup: string; } /** * A verified idp identity (ce-1ch). `identify()` resolves an Authorization * header to this via introspection, or null when the token is inactive/expired/ * unverifiable or carries no `sub` (ownership requires a stable subject). */ export interface VerifiedIdentity { /** * Verified subject — the owner key in the module-owner table. Optional: an * admin identity may publish without one (admins need no ownership anchor), * but the publisher/owner path requires it to attribute a claim. */ sub?: string; /** Member of the admin group → may publish/reassign any module. */ isAdmin: boolean; /** Member of the publisher group → may claim + publish owned modules. */ isPublisher: boolean; /** * The group that grants this identity's publish rights (admin group when * admin, else publisher group when publisher, else undefined). Recorded as * the owner entry's `sourceGroup` at claim time. */ group?: string; } /** Default admin group name when REGISTRY_ADMIN_GROUP is unset. */ const DEFAULT_ADMIN_GROUP = 'celilo-admins'; /** Default publisher group name when REGISTRY_PUBLISHER_GROUP is unset. */ const DEFAULT_PUBLISHER_GROUP = 'celilo-authors'; /** * Read introspection config from the environment (delivered by the module's * OIDC drop-in EnvironmentFile — ce-7aa). Returns null when introspection is * not configured, in which case the server keeps the opaque-token-only path. */ export function introspectionConfigFromEnv(): IntrospectionConfig | null { const endpoint = process.env.OIDC_INTROSPECTION_ENDPOINT?.trim(); const clientId = process.env.OIDC_CLIENT_ID?.trim(); const clientSecret = process.env.OIDC_CLIENT_SECRET?.trim(); if (!endpoint || !clientId || !clientSecret) return null; return { endpoint, clientId, clientSecret, adminGroup: process.env.REGISTRY_ADMIN_GROUP?.trim() || DEFAULT_ADMIN_GROUP, publisherGroup: process.env.REGISTRY_PUBLISHER_GROUP?.trim() || DEFAULT_PUBLISHER_GROUP, }; } /** * Map a verified identity's claims to a publish scope the request path * consumes. Today: membership in the admin group → ADMIN_SCOPE (`*`). * * ponytail: per-module ownership (a verified non-admin identity may publish * only modules it owns) is the module-owner table — ce-1ch — which extends * this seam. Until then a verified non-admin identity is denied (null). */ export function claimToScope(claims: IntrospectionResult, adminGroup: string): string | null { if (claims.groups?.includes(adminGroup)) return ADMIN_SCOPE; return null; } /** Hand-validate an untrusted introspection JSON body (no zod dep in this package). */ function parseIntrospection(json: unknown): IntrospectionResult | null { if (typeof json !== 'object' || json === null) return null; const o = json as Record; if (typeof o.active !== 'boolean') return null; const groups = Array.isArray(o.groups) ? o.groups.filter((g): g is string => typeof g === 'string') : undefined; return { active: o.active, sub: typeof o.sub === 'string' ? o.sub : undefined, groups, exp: typeof o.exp === 'number' ? o.exp : undefined, }; } function errClass(error: unknown): string { return error instanceof Error ? error.name : 'unknown'; } export class IntrospectionVerifier { constructor(private readonly config: IntrospectionConfig) {} /** Construct from env, or undefined when introspection isn't configured. */ static fromEnv(): IntrospectionVerifier | undefined { const config = introspectionConfigFromEnv(); return config ? new IntrospectionVerifier(config) : undefined; } /** * Verify an Authorization header value via introspection and return the * publish scope of the identity (ADMIN_SCOPE / per-package), or null when * the token is inactive, expired, unverifiable, or maps to no scope. * Fails CLOSED on every error. */ async scopeOf(header: string): Promise { const token = bearerToken(header); if (!token) return null; const claims = await this.introspect(token); if (!claims || !claims.active) return null; // RFC 7662 `active:false` already covers expiry, but double-check `exp` // defensively (seconds since epoch) so a misbehaving idp can't slip an // expired-but-active token past us. if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) return null; return claimToScope(claims, this.config.adminGroup); } /** * Verify an Authorization header and resolve the caller's identity for the * hybrid group + owner-table authorization (ce-1ch). Returns null on the same * fail-closed conditions as {@link scopeOf}. `sub` may be absent (an admin can * publish without one); the owner path in the server requires it and denies * a publisher identity that lacks it. */ async identify(header: string): Promise { const token = bearerToken(header); if (!token) return null; const claims = await this.introspect(token); if (!claims || !claims.active) return null; if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) return null; const isAdmin = claims.groups?.includes(this.config.adminGroup) ?? false; const isPublisher = claims.groups?.includes(this.config.publisherGroup) ?? false; const group = isAdmin ? this.config.adminGroup : isPublisher ? this.config.publisherGroup : undefined; return { sub: claims.sub, isAdmin, isPublisher, group }; } private async introspect(token: string): Promise { try { const basic = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString( 'base64', ); const resp = await fetch(this.config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${basic}`, Accept: 'application/json', }, body: new URLSearchParams({ token, token_type_hint: 'access_token' }).toString(), }); if (!resp.ok) { console.error(`[registry] introspection HTTP ${resp.status} — denying (fail closed)`); return null; } return parseIntrospection(await resp.json()); } catch (error) { console.error( `[registry] introspection request failed (${errClass(error)}) — denying (fail closed)`, ); return null; } } }