import { OxyRequesterClient } from './requesterClient'; import type { RequesterProof } from './requesterContext'; import { type OxySessionRevocationEvent, serviceIntrospectionSchema, resourceIntrospectionSchema, type ResourceIntrospection, type ServiceIntrospection, serviceTokenOptionsSchema, type ServiceTokenOptions, subjectProductAccessQuerySchema, subjectProductGrantSnapshotSchema, type SubjectProductAccessQuery, type SubjectProductGrantSnapshot, } from '@oxy.so/contracts'; /** * `OxyServer` — the Oxy client for a backend. * * ```ts * import { OxyServer } from '@oxy.so/core/server'; * * const oxy = new OxyServer({ * baseURL: 'https://api.oxy.so', * }); * * app.use('/api', oxy.middleware.auth()); * io.use(oxy.middleware.socket()); * app.use('/internal', oxy.middleware.service()); * * const people = await oxy.users.getMany(ids); // service lane when available * const meta = await oxy.assets.metadataByIds(fileIds); // service-only * ``` * * Everything `OxyServices` has, plus the service-token lane (a key pair from * `serviceAuth` / `configureServiceAuth`, or — with neither — the process's * attested workload identity, ADR 0026), the Express / Socket.IO middleware, * account events, and the service-only methods of `assets`, `notifications`, * `linkedAccounts`, `agency` and `reputation`. * * Node only. */ import { jwtDecode } from 'jwt-decode'; import { hasBoundedServiceTokenLifetime } from './serviceTokenLifetime'; import { assertRequestedServiceScopes } from './serviceTokenScopes'; /** Unverified cache metadata only: an old lifetime forces a new signed mint. */ function reusableServiceToken(token: string): boolean { try { const claims = jwtDecode<{ iat?: number; exp?: number }>(token); return ( hasBoundedServiceTokenLifetime(claims) && typeof claims.exp === 'number' && claims.exp > Math.floor(Date.now() / 1000) ); } catch { return false; } } import { loadNodeCrypto } from '@oxy.so/protocol'; import { OxyServices, type OxyConfig } from '../OxyServices'; import { HttpService } from '../HttpService'; import { createAuthenticatedFetch } from '../authenticatedFetch'; import { awaitRequestSignal } from '../utils/requestSignal'; import { createCancelledError } from '../utils/errorUtils'; import type { HttpMethod, ServiceLane } from '../client/context'; import type { RequestOptions, ResponsePayload } from '../HttpService'; import { logger } from '../logger'; import { canAttestWorkloadIdentity, requestWorkloadServiceToken, requestLegacyWorkloadServiceToken, type WorkloadCredentialProvider, } from './workloadIdentity'; import { createOxyMiddleware, OXY_ACCOUNT_DELETED_EVENT_URI, OXY_JWT_ISSUER, OxyAccountEventError, parseJsonSegment, resolveServiceTokenPublicKey, type OxyAccountEvent, type OxyAccountEventFeedPage, type OxyMiddleware, type ServiceActingAsVerification, type ServiceTokenJwksCache, type ServiceTokenPublicJwk, type VerifyAccountEventOptions, } from './middleware'; import { APP_PERMISSIONS_CACHE_TTL_MS, OxyAppPermissionsResolver, type AppPermissionsOptions, type AppPermissionsRequest, type OxyAppPermissions, } from './appPermissions'; import { ServerAccountsApi, ServerAppsApi, ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi, } from './namespaces'; import { ServerSafetyApi } from './safety'; import { SESSION_VALIDATION_CACHE_TTL_MS, ServerSessionApi } from './session'; import { OxyServiceEventFeed } from './serviceEvents'; import { evictOxyIdentityCache } from '../utils/identityCacheSweep'; export interface OxyServerConfig extends OxyConfig { /** Server-only temporary infrastructure credentials. Defaults to the ECS task role. */ workloadCredentials?: WorkloadCredentialProvider; /** Explicit external credential. Own services omit this and use workload identity. */ serviceAuth?: { apiKey: string; apiSecret: string }; /** * How this backend identifies itself on requests that carry no user session. * * - `'never'` (default): such requests go out anonymous. * - `'when-anonymous'`: they carry this process's service token instead — * from `serviceAuth`, or, with none, from workload attestation (ADR 0026). * A user session, when there is one, still wins; `skipAuth` requests stay * unauthenticated; a process that can produce no service token (a local * checkout) keeps sending them anonymous. * * Why a server wants it: oxy-api charges anonymous traffic to the SOURCE * ADDRESS (`rl:general`, and a +500 ms `slowDown` per request past 100 in 15 * minutes), and a fleet leaves through one NAT address, so every anonymous * read a backend makes shares — and past the threshold pays for — one budget. * A first-party service token is exempt and charged to its own application. * Measured from Mention's task (2026-09-25): `GET /users/:id` 543–575 ms * anonymous past the threshold, `POST /users/by-ids` 19–24 ms with the * service token. */ serviceIdentity?: 'never' | 'when-anonymous'; /** * This backend's Oxy application id — the application whose own permissions * (application roles) `appPermissionsFor` and * `middleware.requireAppPermission` read. Optional when `serviceAuth` is set * (or workload identity is available): the service token names the * application, and that is used instead. */ applicationId?: string; /** * How long one application-permissions answer is reused, per session * (default 30 000 ms). This is the longest a revoked grant keeps working on * this backend for a read that does not pass `fresh: true`. `0` disables the * cache. */ appPermissionsCacheTtlMs?: number; /** * How long one successful user-bearer validation (`session.validate` with an * `accessToken`, which `middleware.auth()` runs on every request) is reused * on this process, per session and token (default 30 000 ms). The cache is * used only while this server's revocation feed from Oxy is live (it needs a * service token: `serviceAuth` or workload identity), so a revoked session * stops validating here as soon as Oxy announces it; without the feed every * request asks Oxy. `0` disables the cache; `auth({ freshSession: true })` * skips it on one route. */ sessionValidationCacheTtlMs?: number; } /** * How long a failed service-token mint sends session-less requests anonymous * before the next attempt, under `serviceIdentity: 'when-anonymous'`. */ export const ANONYMOUS_SERVICE_TOKEN_RETRY_MS = 30_000; /** `POST /auth/service-token`. */ export interface ActingAsVerificationOptions { /** Only explicit read-only callers may reuse authority; effects always pass false. */ cache?: boolean; /** Taken from verified service claims by middleware, never from a user header. */ credentialId?: string; ownerAccountId?: string; environment?: string; /** Original user-session bearer; never an arbitrary user id. */ requesterToken?: string; requesterContext?: string; presentedServiceToken?: string; taskId?: string; } interface ActingAsGeneration { epoch: bigint; issued: number; completed: number; } export interface ServiceTokenResponse { token: string; expiresIn: number; appName: string; } /** * Sentinel error raised when a service token is asked for with a known apiKey * but a non-matching secret: credential drift in the caller, or a cross-tenant * cache lookup attempt. Surface as a 401-equivalent. */ export class ServiceCredentialMismatchError extends Error { constructor() { super( 'Service credential mismatch: provided secret does not match the secret stored for this apiKey', ); this.name = 'ServiceCredentialMismatchError'; } } /** * One cache entry per SHA-256(apiKey) → issued token + the secret that produced * it, kept as a Buffer for a constant-time compare on every hit, so an attacker * who learned a peer's apiKey cannot extract its cached token by guessing. */ interface ServiceTokenCacheEntry { token: string; /** Expiry as ms since epoch. */ expiresAt: number; secretBuf: Buffer; /** In-flight mint (deduplicates concurrent callers). */ pending: Promise | null; /** The raw apiKey, so `invalidateServiceToken(apiKey)` stays synchronous. Never logged. */ apiKey: string; } const WORKLOAD_CACHE_KEY = 'workload-identity'; /** * The resource server a service token is minted for (Oxy's closed set, * `packages/api/src/config/serviceTokenAudiences.ts`). A token is only ever * accepted by its own audience: Oxy's API refuses a `kaana.ai` token and * https://kaana.ai/v1 refuses an `oxy-api` one (Kaana ADR 0002 decision 6). */ export type ServiceTokenAudience = | 'oxy-api' | 'kaana.ai' | 'oxy-api:kaana-control' | `oxy-service:${string}`; type ServiceClientRequester = { accountId: string } & ( | { token: string; taskId?: never; requesterContext?: never } | { taskId: string; token?: never; requesterContext?: never } | { requesterContext: string; token?: never; taskId?: never } ); export type ServiceClientOptions = { baseURL: string } & ( | { audience: ServiceTokenAudience; /** Verified requester authority for product data. */ requester?: ServiceClientRequester; attribution?: never; } | { audience: 'kaana.ai'; /** Inference attribution only; grants no user-data access or payer authority. */ attribution: Readonly<{ accountId: string }>; requester?: never; } ); export interface ServiceHttpClient { fetch: typeof globalThis.fetch; /** For product SDK protocols such as WebSocket; validates the destination before minting. */ getAccessToken(target: string | URL, options?: { signal?: AbortSignal }): Promise; dispose(): void; } /** What `serviceToken()` mints, and what Oxy assumes when no audience is sent. */ export const DEFAULT_SERVICE_TOKEN_AUDIENCE: ServiceTokenAudience = 'oxy-api'; /** Audience, credential and normalized scope set are independent cache dimensions. */ function audienceCacheKey(audience: ServiceTokenAudience, key: string, scopes?: string[]): string { return JSON.stringify([audience, key, scopes ?? null]); } /** A token is reused until 60s before expiry (clock drift). */ const TOKEN_REUSE_MARGIN_MS = 60_000; /** Bound on remembered `appId:userId` delegation answers. */ const ACTING_AS_CACHE_MAX = 1000; /** Explicit read reuse only; effects always perform a fresh lookup. */ const ACTING_AS_GRANT_TTL_MS = 60_000; /** Explicit authoritative read denials last ten seconds; failures are never cached. */ const ACTING_AS_DENIAL_TTL_MS = 10_000; export class OxyServer extends OxyServices { private readonly workloadCredentials?: WorkloadCredentialProvider; private readonly serviceTokens = new Map(); private serviceApiKey: string | null = null; private serviceApiSecret: string | null = null; private readonly actingAs = new Map< string, { result: ServiceActingAsVerification | null; expiresAt: number } >(); private readonly actingAsPending = new Map>(); private readonly actingAsGenerations = new Map(); /** Public keys only; never private material. */ private readonly jwksCache: ServiceTokenJwksCache = { keys: new Map(), expiresAt: 0, lastAttemptAt: 0, }; /** * Before this instant a request without a user session does not try to mint * a service token and goes out anonymous. Set after a failed mint. */ private anonymousServiceTokenRetryAt = 0; private readonly configuredApplicationId: string | null; private readonly sessionValidationTtlMs: number; private readonly appPermissions: OxyAppPermissionsResolver; /** Opened the first time the validation cache would be used; see `./serviceEvents.ts`. */ private serviceEventFeed: OxyServiceEventFeed | null = null; private disposed = false; /** * Express and Socket.IO middleware. * * - `auth(options)` — Express: resolve the caller (user session or service * token) onto `req.userId` / `req.user` / `req.serviceApp`. * - `socket(options)` — Socket.IO: the same for a handshake. * - `service(options)` — Express: service tokens only. * - `requireScope(scope)` — Express, after `auth`/`service`: the service * token (and its delegation, if any) must hold `scope`. */ readonly middleware: OxyMiddleware; constructor(config: OxyServerConfig) { super(config); this.workloadCredentials = config.workloadCredentials; if (config.serviceAuth) { this.configureServiceAuth(config.serviceAuth.apiKey, config.serviceAuth.apiSecret); } const server = this; const serviceLane: ServiceLane = { get available() { return server.canMintServiceToken(); }, request: (...args) => this.serviceRequest(...args), }; this.context.service = serviceLane; if (config.serviceIdentity === 'when-anonymous') { this.http.setAnonymousAuthProvider(() => this.serviceTokenForAnonymousRequest()); } const ttl = config.appPermissionsCacheTtlMs ?? APP_PERMISSIONS_CACHE_TTL_MS; if (!Number.isFinite(ttl) || ttl < 0) { throw new Error('appPermissionsCacheTtlMs must be a non-negative number of milliseconds'); } const sessionTtl = config.sessionValidationCacheTtlMs ?? SESSION_VALIDATION_CACHE_TTL_MS; if (!Number.isFinite(sessionTtl) || sessionTtl < 0) { throw new Error('sessionValidationCacheTtlMs must be a non-negative number of milliseconds'); } this.sessionValidationTtlMs = sessionTtl; this.configuredApplicationId = config.applicationId?.trim() || null; this.appPermissions = new OxyAppPermissionsResolver({ ttlMs: ttl, defaultApplicationId: () => this.defaultApplicationId(), fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken), }); this.middleware = createOxyMiddleware({ get baseURL() { return server.baseURL; }, jwksCache: this.jwksCache, validateSession: (sessionId, options) => this.session.validate(sessionId, options), verifyActingAs: (appId, userId, options) => this.verifyActingAs(appId, userId, options), introspectServiceToken: (token) => this.introspectServiceToken(token), appPermissionsFor: (req, options) => this.appPermissionsFor(req, options), }); } /** A product SDK transport authenticated only by this process's workload. */ createServiceClient(options: ServiceClientOptions): ServiceHttpClient { const origin = new URL(options.baseURL); if (!['http:', 'https:'].includes(origin.protocol) || origin.username || origin.password) throw new Error('A service client requires an HTTP API origin'); const requester = options.requester ? { ...options.requester } : undefined; if ( requester && (!requester.accountId.trim() || !(requester.token ?? requester.taskId ?? requester.requesterContext)?.trim() || [requester.token, requester.taskId, requester.requesterContext].filter( (proof) => proof !== undefined, ).length !== 1) ) throw new Error('A requester requires an account id and session proof'); const attribution = options.attribution?.accountId; if ( options.attribution !== undefined && (options.audience !== 'kaana.ai' || requester !== undefined || typeof attribution !== 'string' || !attribution || attribution.length > 64 || /\s/.test(attribution)) ) throw new Error( 'Inference attribution requires kaana.ai, one account and no requester proof', ); const client = new HttpService({ baseURL: options.baseURL, enableCache: false }); let disposed = false; const audience = options.audience; const mint = () => this.workloadServiceToken(audience, 2); client.setAuthRefreshHandler(async () => { this.serviceTokens.delete(audienceCacheKey(audience, `${WORKLOAD_CACHE_KEY}:2`)); return mint(); }); const fetch = createAuthenticatedFetch(client, options.baseURL, { disposed: () => disposed, prepare: async () => { const token = await mint(); if (!disposed) client.setTokens(token); }, }); return { getAccessToken: async (target, requestOptions) => { if (disposed) throw new Error('The authenticated transport has been disposed'); const recipient = new URL(String(target), origin); if (recipient.protocol === 'ws:') recipient.protocol = 'http:'; else if (recipient.protocol === 'wss:') recipient.protocol = 'https:'; if ( !['http:', 'https:'].includes(recipient.protocol) || recipient.origin !== origin.origin || recipient.username || recipient.password ) throw new Error('Service credentials must target the configured API origin'); const signal = requestOptions?.signal; if (signal?.aborted) throw createCancelledError(); const token = await awaitRequestSignal(mint(), signal); if (signal?.aborted) throw createCancelledError(); if (disposed) throw new Error('The authenticated transport has been disposed'); return token; }, fetch: (input, init) => { const headers = new Headers( init?.headers ?? (input instanceof Request ? input.headers : undefined), ); headers.delete('X-Oxy-User-Id'); headers.delete('X-Oxy-Requester-Token'); headers.delete('X-Oxy-Task-Id'); headers.delete('X-Oxy-Requester-Context'); headers.delete('X-Oxy-Capability-Ticket'); headers.delete('X-Oxy-Mcp-Media-Token'); headers.delete('X-Oxy-Mcp-Media-Tool'); headers.delete('X-Oxy-Presented-Requester-Context'); headers.delete('X-Oxy-Presented-Service-Token'); if (attribution) headers.set('X-Oxy-User-Id', attribution); if (requester) { headers.set('X-Oxy-User-Id', requester.accountId); if (requester.token) headers.set('X-Oxy-Requester-Token', requester.token); if (requester.taskId) headers.set('X-Oxy-Task-Id', requester.taskId); if (requester.requesterContext) headers.set('X-Oxy-Requester-Context', requester.requesterContext); } return fetch(input, { ...init, headers }); }, dispose: () => { disposed = true; client.setAuthRefreshHandler(null); client.dispose(); }, }; } /** Live service authority; human sessions and keys have a separate resource contract. */ async introspectServiceToken(token: string): Promise { return serviceIntrospectionSchema.parse(await this.introspectResource(token)); } /** Live resource authority for a product receiving services, keys or human sessions. */ async introspectResourceToken(token: string): Promise { return resourceIntrospectionSchema.parse(await this.introspectResource(token)); } /** Live authority read by the receiving product; never cached. */ private async introspectResource(token: string): Promise { const bearer = await this.platformServiceToken(); const result = await this.request( 'POST', '/auth/resources/introspect', { token }, { cache: false, retry: false, timeout: 5000, headers: { Authorization: `Bearer ${bearer}` }, }, ); return result; } /** An isolated Oxy client carrying the original session, task or operation proof. */ withRequester(proof: RequesterProof): OxyRequesterClient { const service = this.context.service; if (!service) throw new Error('Requester context requires an authenticated service'); return new OxyRequesterClient(this.baseURL, this.cloudURL, service, proof); } // ── Server-side namespaces ─────────────────────────────────────────────── private _serverAccounts?: ServerAccountsApi; private _serverAssets?: ServerAssetsApi; private _serverNotifications?: ServerNotificationsApi; private _serverLinkedAccounts?: ServerLinkedAccountsApi; private _serverAgency?: ServerAgencyApi; private _serverApps?: ServerAppsApi; private _serverReputation?: ServerReputationApi; private _serverSafety?: ServerSafetyApi; private _serverSession?: ServerSessionApi; /** The account graph, plus the service-only `accessFor` membership verdict. */ override get accounts(): ServerAccountsApi { if (!this._serverAccounts) this._serverAccounts = new ServerAccountsApi(this.context); return this._serverAccounts; } /** Files, plus the service-only metadata and linked-URL lookups. */ override get assets(): ServerAssetsApi { if (!this._serverAssets) this._serverAssets = new ServerAssetsApi(this.context); return this._serverAssets; } /** The inbox and push tokens, plus `create`. */ override get notifications(): ServerNotificationsApi { if (!this._serverNotifications) this._serverNotifications = new ServerNotificationsApi(this.context); return this._serverNotifications; } /** Linked external accounts, plus `forUser`. */ override get linkedAccounts(): ServerLinkedAccountsApi { if (!this._serverLinkedAccounts) this._serverLinkedAccounts = new ServerLinkedAccountsApi(this.context); return this._serverLinkedAccounts; } /** Delegated capabilities, with the service-only authority calls. */ override get agency(): ServerAgencyApi { if (!this._serverAgency) this._serverAgency = new ServerAgencyApi(this.context); return this._serverAgency; } /** Application management, plus the receiver-bound Alia machine-key lookup. */ override get apps(): ServerAppsApi { if (!this._serverApps) this._serverApps = new ServerAppsApi(this.context); return this._serverApps; } /** Reputation reads, plus `award`. */ override get reputation(): ServerReputationApi { if (!this._serverReputation) this._serverReputation = new ServerReputationApi(this.context); return this._serverReputation; } /** Policy reads, plus the consumer-service decision lanes (ADR 0041). */ override get safety(): ServerSafetyApi { if (!this._serverSafety) this._serverSafety = new ServerSafetyApi(this.context); return this._serverSafety; } /** Session state, plus validation through a reviewed supervised action. */ override get session(): ServerSessionApi { if (!this._serverSession) this._serverSession = new ServerSessionApi(this.context, this.sessionValidationTtlMs, () => this.revocationFeedEpoch(), ); return this._serverSession; } /** Close the revocation feed along with the HTTP client. */ override dispose(): void { this.disposed = true; this.serviceEventFeed?.stop(); this.serviceEventFeed = null; super.dispose(); } // ── Service token ──────────────────────────────────────────────────────── /** * Set this service's application credential. Each `(apiKey, apiSecret)` pair * is cached independently, so a multi-tenant host switching credentials * cannot leak one tenant's token to another. */ configureServiceAuth(apiKey: string, apiSecret: string): void { this.serviceApiKey = apiKey; this.serviceApiSecret = apiSecret; } /** * A service token (short-lived, cached and refreshed per credential pair; * concurrent callers share one mint). * * With no credential configured or passed, the token comes from the process's * attested workload identity (ADR 0026); with neither, this throws. * * If the cache already holds a token for `apiKey` but `apiSecret` does not * constant-time match the secret that produced it, this throws * {@link ServiceCredentialMismatchError} rather than return the cached token. */ async serviceToken( apiKey?: string, apiSecret?: string, options: ServiceTokenOptions = {}, ): Promise { return this.serviceTokenFor(DEFAULT_SERVICE_TOKEN_AUDIENCE, apiKey, apiSecret, options); } /** * A service token for `audience` — e.g. `kaana.ai` for https://kaana.ai/v1. * Minted, cached and refreshed exactly as {@link serviceToken} (which is this * with `oxy-api`), from the same credential or attested workload identity; * each audience has its own cache entry, so one never answers for another. */ async serviceTokenFor( audience: ServiceTokenAudience, apiKey?: string, apiSecret?: string, options: ServiceTokenOptions = {}, ): Promise { return this.serviceTokenForProtocol(1, audience, apiKey, apiSecret, options); } private platformServiceToken(options: ServiceTokenOptions = {}): Promise { // Explicit external credential configuration keeps the public credential lane. if (this.serviceApiKey || this.serviceApiSecret) return this.serviceToken(undefined, undefined, options); return this.serviceTokenForProtocol( 2, DEFAULT_SERVICE_TOKEN_AUDIENCE, undefined, undefined, options, ); } /** Temporary reader-first rollout: manual tokens stay v1 until receivers migrate. */ private async serviceTokenForProtocol( protocolVersion: 1 | 2, audience: ServiceTokenAudience = DEFAULT_SERVICE_TOKEN_AUDIENCE, apiKey?: string, apiSecret?: string, options: ServiceTokenOptions = {}, ): Promise { const { scopes } = serviceTokenOptionsSchema.parse(options); const key = apiKey || this.serviceApiKey; const secret = apiSecret || this.serviceApiSecret; if (!key && !secret && (this.workloadCredentials || canAttestWorkloadIdentity())) { if (scopes !== undefined) throw new Error('First-party workloads do not request scopes'); return this.workloadServiceToken(audience, protocolVersion); } if (!key || !secret) { throw new Error( key || secret ? 'External service authentication requires both apiKey and apiSecret.' : 'No workload identity is available. Configure temporary workload credentials for first-party services, or explicit serviceAuth for an external application.', ); } // The apiKey is the credential's PUBLIC id (`oxy_dk_…`, the OAuth // client_id), so it keys the in-memory cache as is; the secret never does. const cacheKey = audienceCacheKey(audience, key, scopes); const now = Date.now(); const providedSecretBuf = Buffer.from(secret, 'utf8'); let entry = this.serviceTokens.get(cacheKey); if (entry) { // Verify the secret on every hit, fresh token or not. Constant-time, and // run on equal-length inputs even when the lengths differ. const nodeCrypto = await loadNodeCrypto(); const stored = entry.secretBuf; const lengthMatch = stored.length === providedSecretBuf.length; const compareBuf = lengthMatch ? providedSecretBuf : Buffer.alloc(stored.length); const equal = nodeCrypto.timingSafeEqual(stored, compareBuf); if (!lengthMatch || !equal) { logger.warn('[oxy.auth] Service token cache hit with mismatched secret', { component: 'auth', method: 'serviceToken', }); throw new ServiceCredentialMismatchError(); } if ( entry.token && reusableServiceToken(entry.token) && entry.expiresAt > now + TOKEN_REUSE_MARGIN_MS ) { return entry.token; } if (entry.pending) { return entry.pending; } } else { // Seed an empty entry so concurrent callers serialize on one promise. entry = { token: '', expiresAt: 0, secretBuf: providedSecretBuf, pending: null, apiKey: key }; this.serviceTokens.set(cacheKey, entry); } const pending = this.mintServiceToken(key, secret, cacheKey, entry, audience, scopes); entry.pending = pending; try { return await pending; } catch (error) { // Never keep an entry that never held a token: a wrong first secret must // not make the right one fail locally as a mismatch later. const failed = this.serviceTokens.get(cacheKey); if (failed?.pending === pending && !failed.token) { this.serviceTokens.delete(cacheKey); } throw error; } finally { const settled = this.serviceTokens.get(cacheKey); if (settled?.pending === pending) settled.pending = null; } } /** * Forget cached service token(s) so the next `serviceToken()` mints anew — * the way back from a 401 after a credential was revoked or rotated. * * `apiKey` clears that credential's entry; with none, the configured * credential's; with neither, every entry. Synchronous. */ invalidateServiceToken(apiKey?: string): void { const targetKey = apiKey ?? this.serviceApiKey; if (!targetKey) { this.serviceTokens.clear(); return; } // Every audience and scope set minted from that credential goes with it. for (const [cacheKey, entry] of this.serviceTokens) { if (entry.apiKey === targetKey) this.serviceTokens.delete(cacheKey); } } /** * One request with this service's token. `actAs` sends `X-Oxy-User-Id`: the * request is on behalf of that user (the API checks the delegation grant). * Never cached unless `cache` says so. Rejects with `OxyApiError`. */ /** Validated request's user session only; never changes the shared client's token. */ async productGrantSnapshotForUser( query: SubjectProductAccessQuery, accessToken: string, ): Promise { const parsed = subjectProductAccessQuerySchema.parse(query); if (!accessToken || /[\r\n]/.test(accessToken)) throw new Error('A user session is required'); const answer = await this.request( 'GET', `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}/grants`, undefined, { cache: false, retry: false, headers: { Authorization: `Bearer ${accessToken}` } }, ); const snapshot = subjectProductGrantSnapshotSchema.parse(answer); if ( snapshot.access.subjectAccountId !== parsed.subjectAccountId || snapshot.access.productId !== parsed.productId ) throw new Error('Product grant response attribution differs'); return snapshot; } /** Offline metering read: the API requires this app's live consent for this exact subject. */ async productGrantSnapshotForService( query: SubjectProductAccessQuery, ): Promise { const parsed = subjectProductAccessQuerySchema.parse(query); const answer = await this.serviceRequest( 'GET', `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}/service-grants`, undefined, { cache: false, retry: false }, ); const snapshot = subjectProductGrantSnapshotSchema.parse(answer); if ( snapshot.access.subjectAccountId !== parsed.subjectAccountId || snapshot.access.productId !== parsed.productId ) throw new Error('Product grant response attribution differs'); return snapshot; } async serviceRequest( method: HttpMethod, url: string, data?: unknown, options: RequestOptions & { actAs?: string; requesterToken?: string; taskId?: string; capabilityTicket?: string; requesterContext?: string; serviceTokenScopes?: string[]; } = {}, ): Promise> { const target = new URL( /^https?:\/\//i.test(url) ? url : `${this.baseURL.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`, ); if ( target.origin !== new URL(this.baseURL).origin || !['http:', 'https:'].includes(target.protocol) || target.username || target.password ) { throw new Error('Service requests must target the configured API origin'); } const { actAs, requesterToken, taskId, capabilityTicket, requesterContext, headers, serviceTokenScopes, ...rest } = options; const token = await this.platformServiceToken({ scopes: serviceTokenScopes }); const merged: Record = { ...headers, Authorization: `Bearer ${token}` }; if (actAs) merged['X-Oxy-User-Id'] = actAs; if ( [requesterToken, taskId, capabilityTicket, requesterContext].filter( (proof) => proof !== undefined, ).length > 1 ) throw new Error('Provide one requester proof'); if (requesterToken) merged['X-Oxy-Requester-Token'] = requesterToken; if (taskId) merged['X-Oxy-Task-Id'] = taskId; if (capabilityTicket) merged['X-Oxy-Capability-Ticket'] = capabilityTicket; if (requesterContext) merged['X-Oxy-Requester-Context'] = requesterContext; return this.request(method, target.href, data, { cache: false, ...rest, ...([requesterToken, taskId, capabilityTicket, requesterContext].some( (proof) => proof !== undefined, ) ? { cache: false, deduplicate: false, retry: false } : {}), headers: merged, }); } // ── Application roles ──────────────────────────────────────────────────── /** * Which of this application's own declared permissions the PERSON behind * `req` holds (application roles, `docs/auth/application-roles.md`). `req` * must have passed `middleware.auth()`: the answer is read from Oxy with the * request's own verified bearer, for the human operator of its session. * * Cached per session for `appPermissionsCacheTtlMs` (30 s); pass * `{ fresh: true }` before a write. Throws `OxyAppPermissionError` — no * session (`APP_PERMISSION_SESSION_REQUIRED`, also for any service-token * request), Oxy unreachable (`APP_PERMISSION_UNAVAILABLE`), no or unknown * application id (`APP_PERMISSION_NOT_CONFIGURED`) — and never answers * "nothing held" for a failure. */ appPermissionsFor( req: AppPermissionsRequest, options: AppPermissionsOptions = {}, ): Promise { return this.appPermissions.resolve(req, options); } /** * Drop cached application-permission answers — all of them, or one * person's (as the session user or the resolved operator). For a backend * that learns of a revocation out of band and will not wait out the TTL. */ invalidateAppPermissions(filter: { userId?: string } = {}): void { this.appPermissions.invalidate(filter); } /** * Drop cached session validations — all of them, one user's or one * session's. Oxy's revocation feed does this by itself; a host may also call * it, and `createOxyUserInvalidationHandler` does for the user named by each * `oxy:user:invalidate` event. */ invalidateSessionValidations(filter: { userId?: string; sessionId?: string } = {}): void { this.session.invalidateValidations(filter); } private async defaultApplicationId(): Promise { if (this.configuredApplicationId) return this.configuredApplicationId; if (!this.canMintServiceToken()) return null; const appId = jwtDecode<{ appId?: unknown }>(await this.platformServiceToken()).appId; return typeof appId === 'string' && appId.length > 0 ? appId : null; } private fetchRolesMe(applicationId: string, accessToken: string): Promise { if (/[\r\n]/.test(accessToken)) { return Promise.reject(new Error('A user session is required')); } return this.request( 'GET', `/applications/${encodeURIComponent(applicationId)}/roles/me`, undefined, { cache: false, retry: false, headers: { Authorization: `Bearer ${accessToken}` } }, ); } // ── Delegation and account events ──────────────────────────────────────── /** * Resolve authority live by default. Explicit read-only reuse lasts at most * 60s positive / 10s negative, anchored to request start; errors never cache. * Effects do not share an older in-flight read. Durable epoch plus local * completion ordering prevents ABA and late positive replies overwriting a * newer refusal even when app/credential revocation leaves epoch unchanged. */ async verifyActingAs( appId: string, userId: string, options: ActingAsVerificationOptions = {}, ): Promise { if ( options.requesterToken || options.taskId || options.requesterContext || options.presentedServiceToken ) options = { ...options, cache: false }; const pair = JSON.stringify([appId, userId]); const cacheKey = JSON.stringify([ appId, userId, options.credentialId, options.ownerAccountId, options.environment, ]); if (options.cache === true) { const cached = this.actingAs.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { this.actingAs.delete(cacheKey); this.actingAs.set(cacheKey, cached); return cached.result; } const pending = this.actingAsPending.get(cacheKey); if (pending) return pending; } let generation = this.actingAsGenerations.get(pair); if (!generation) { generation = { epoch: 0n, issued: 0, completed: 0 }; this.actingAsGenerations.set(pair, generation); if (this.actingAsGenerations.size > ACTING_AS_CACHE_MAX) { const oldest = this.actingAsGenerations.keys().next().value; if (oldest !== undefined) this.actingAsGenerations.delete(oldest); } } const serial = ++generation.issued; const startedAt = Date.now(); const lookup = this.lookupActingAs( appId, userId, options, pair, cacheKey, generation, serial, startedAt, ); if (options.cache !== true) return lookup; this.actingAsPending.set(cacheKey, lookup); try { return await lookup; } finally { if (this.actingAsPending.get(cacheKey) === lookup) this.actingAsPending.delete(cacheKey); } } /** * Account events addressed to this application (Oxy account deletions every * relying application must honour, OxyHQ/Mention#1169). */ readonly accountEvents = { /** * Verify an account event token — a webhook body, or a feed entry's `token` * — and return the event. Throws {@link OxyAccountEventError} unless it is * an EdDSA `secevent+jwt` from Oxy's key set, issued by `oxy-auth`, * addressed to this application, carrying exactly one known event. Be * idempotent on `eventId`: Oxy delivers at least once. */ verify: (token: string, options: VerifyAccountEventOptions = {}): Promise => this.verifyAccountEvent(token, options), /** * One page of account events for this application, oldest first — the pull * feed behind the webhook. Verify each entry's `token` before acting on it. */ list: async ( options: { after?: string; limit?: number } = {}, ): Promise => { const query: Record = {}; if (options.after) query.after = options.after; if (options.limit !== undefined) query.limit = String(options.limit); return this.serviceRequest('GET', '/account-events', query); }, }; // ── Internals ──────────────────────────────────────────────────────────── /** * The bearer for a request with no user session, under `serviceIdentity: * 'when-anonymous'`: this process's service token, or `null` to send the * request anonymous. * * `null`, never a throw, whenever there is no token to offer: * - no key pair and no attestable workload (a local checkout); * - the mint failed. The request still goes out, anonymous, and so does every * request for the next {@link ANONYMOUS_SERVICE_TOKEN_RETRY_MS}, so an Oxy * refusing attestation is asked once per window rather than once per read. * * The token uses the platform protocol: cached and single-flight, so * concurrent reads share one mint. */ private async serviceTokenForAnonymousRequest(): Promise { if (Date.now() < this.anonymousServiceTokenRetryAt) return null; if (!this.canMintServiceToken()) return null; try { return await this.platformServiceToken(); } catch (error) { this.anonymousServiceTokenRetryAt = Date.now() + ANONYMOUS_SERVICE_TOKEN_RETRY_MS; logger.warn( '[oxy.auth] No service token for a request without a session; sending it anonymous', { component: 'auth', method: 'serviceTokenForAnonymousRequest', retryInMs: ANONYMOUS_SERVICE_TOKEN_RETRY_MS, error: error instanceof Error ? error.message : String(error), }, ); return null; } } /** * The revocation feed's live period, or `null` while it is not live. Opens * the feed on first use — only a process that can produce a service token * has one; any other never caches a session validation. */ private revocationFeedEpoch(): number | null { if (!this.serviceEventFeed) { if (this.disposed || !this.canMintServiceToken()) return null; this.serviceEventFeed = new OxyServiceEventFeed({ baseURL: this.baseURL, serviceToken: () => this.platformServiceToken(), onRevocation: (event) => this.applySessionRevocation(event), onDown: () => this.session.invalidateValidations(), }); this.serviceEventFeed.start(); } return this.serviceEventFeed.liveEpoch(); } private applySessionRevocation(event: OxySessionRevocationEvent): void { this.session.applyRevocation(event); // Every session of the account ended (deleted, archived, signed out // everywhere): its cached identity goes too, as on `oxy:user:invalidate`. if (!event.sessionId) evictOxyIdentityCache(this.http, event.userId); } private canMintServiceToken(): boolean { return ( Boolean(this.serviceApiKey && this.serviceApiSecret) || Boolean(this.workloadCredentials) || canAttestWorkloadIdentity() ); } private async mintServiceToken( key: string, secret: string, cacheKey: string, expectedEntry: ServiceTokenCacheEntry, audience: ServiceTokenAudience, scopes?: string[], ): Promise { const response = await this.request( 'POST', '/auth/service-token', { apiKey: key, apiSecret: secret, ...(audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }), ...(scopes ? { scopes } : {}), }, { cache: false, retry: false, skipAuth: true }, ); assertRequestedServiceScopes(response.token, scopes); const expiresAt = Date.now() + response.expiresIn * 1000; const entry = this.serviceTokens.get(cacheKey); // Invalidation also fences in-flight mints: an old response cannot repopulate // a cleared cache or replace the newly minted token for the same scope set. if (entry === expectedEntry) { entry.token = response.token; entry.expiresAt = expiresAt; } return response.token; } /** A token obtained by attestation, cached like a credential's (one identity per process, one entry per audience). */ private async workloadServiceToken( audience: ServiceTokenAudience, protocolVersion: 1 | 2, ): Promise { const cacheKey = audienceCacheKey(audience, `${WORKLOAD_CACHE_KEY}:${protocolVersion}`); const entry = this.serviceTokens.get(cacheKey); if ( entry?.token && reusableServiceToken(entry.token) && entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS ) return entry.token; if (entry?.pending) return entry.pending; const seeded = entry ?? { token: '', expiresAt: 0, secretBuf: Buffer.alloc(0), pending: null, apiKey: WORKLOAD_CACHE_KEY, }; this.serviceTokens.set(cacheKey, seeded); const pending = (async () => { const mint = protocolVersion === 2 ? requestWorkloadServiceToken : requestLegacyWorkloadServiceToken; const granted = await mint({ baseUrl: this.baseURL, ...(audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }), ...(this.workloadCredentials ? { credentials: this.workloadCredentials } : {}), }); const current = this.serviceTokens.get(cacheKey); if (current === seeded) { current.token = granted.token; current.expiresAt = Date.now() + granted.expiresIn * 1000; } return granted.token; })(); seeded.pending = pending; try { return await pending; } catch (error) { const failed = this.serviceTokens.get(cacheKey); if (failed?.pending === pending && !failed.token) this.serviceTokens.delete(cacheKey); throw error; } finally { const settled = this.serviceTokens.get(cacheKey); if (settled?.pending === pending) settled.pending = null; } } private async lookupActingAs( appId: string, userId: string, options: ActingAsVerificationOptions, pair: string, cacheKey: string, generation: ActingAsGeneration, serial: number, startedAt: number, ): Promise { try { const serviceToken = await this.platformServiceToken(); const result = await this.request( 'GET', '/internal/service-acting-as/verify', { appId, userId, ...(options.taskId ? { taskId: options.taskId } : {}), ...(options.credentialId ? { credentialId: options.credentialId, ownerAccountId: options.ownerAccountId, environment: options.environment, } : {}), }, { cache: false, retry: false, timeout: 5000, headers: { Authorization: `Bearer ${serviceToken}`, ...(options.requesterToken ? { 'X-Oxy-Requester-Token': options.requesterToken } : {}), ...(options.requesterContext ? { 'X-Oxy-Presented-Requester-Context': options.requesterContext } : {}), ...(options.presentedServiceToken ? { 'X-Oxy-Presented-Service-Token': options.presentedServiceToken } : {}), }, }, ); // A deadline bounds how long an already old response can authorize. It // is not by itself a measurement of end-to-end revocation latency. if (this.actingAsGenerations.get(pair) !== generation || serial < generation.completed) return null; generation.completed = serial; this.clearActingAsPair(appId, userId); if (Date.now() - startedAt >= 5000) return null; const rawEpoch = result?.epoch; if (typeof rawEpoch !== 'string' || !/^(?:0|[1-9][0-9]{0,18})$/.test(rawEpoch)) return null; const epoch = BigInt(rawEpoch); if (epoch > 9223372036854775807n) return null; if (result.authority === 'first_party') { if ( (!options.requesterToken && !options.taskId && !options.requesterContext) || (options.presentedServiceToken && (typeof result.requesterContext !== 'string' || !result.requesterContext || result.requesterContext.length > 16384)) || result.authorized !== true || result.scopes !== undefined || epoch < generation.epoch ) return null; generation.epoch = epoch; return { authorized: true, authority: 'first_party', epoch: rawEpoch, ...(typeof result.requesterContext === 'string' ? { requesterContext: result.requesterContext } : {}), }; } if ( (result.authority !== undefined && result.authority !== 'external') || typeof result.authorized !== 'boolean' || !Array.isArray(result.scopes) || !result.scopes.every( (scope) => typeof scope === 'string' && scope.length > 0 && scope.trim() === scope, ) || new Set(result.scopes).size !== result.scopes.length || (!result.authorized && result.scopes.length !== 0) ) return null; const older = epoch < generation.epoch; if (epoch > generation.epoch) generation.epoch = epoch; if (result.authorized && older) return null; const verified: ServiceActingAsVerification | null = result.authorized === true ? { authorized: true, scopes: result.scopes, epoch: rawEpoch } : null; if (options.cache === true) this.rememberActingAs( cacheKey, verified, startedAt + (verified ? ACTING_AS_GRANT_TTL_MS : ACTING_AS_DENIAL_TTL_MS), ); return verified; } catch { if (this.actingAsGenerations.get(pair) === generation && serial >= generation.completed) { generation.completed = serial; this.clearActingAsPair(appId, userId); } logger.warn('[oxy.auth] verifyActingAs lookup failed — not cached', { component: 'auth', method: 'verifyActingAs', appId, userId, }); return null; } } private clearActingAsPair(appId: string, userId: string): void { for (const key of this.actingAs.keys()) { const [cachedApp, cachedUser] = JSON.parse(key) as [string, string]; if (cachedApp === appId && cachedUser === userId) this.actingAs.delete(key); } } private rememberActingAs( cacheKey: string, result: ServiceActingAsVerification | null, expiresAt: number, ): void { this.actingAs.delete(cacheKey); this.actingAs.set(cacheKey, { result, expiresAt }); if (this.actingAs.size > ACTING_AS_CACHE_MAX) { const oldest = this.actingAs.keys().next().value; if (oldest !== undefined) this.actingAs.delete(oldest); } } private async verifyAccountEvent( token: string, options: VerifyAccountEventOptions, ): Promise { if (typeof token !== 'string' || token.length === 0 || token.length > 16 * 1024) { throw new OxyAccountEventError('Account event token is missing or oversized'); } const parts = token.split('.'); if (parts.length !== 3 || parts.some((part) => part.length === 0)) { throw new OxyAccountEventError('Account event token is not a compact JWS'); } const [headerB64, payloadB64, signatureB64] = parts as [string, string, string]; let header: Record; let payload: Record; try { header = parseJsonSegment(headerB64); payload = parseJsonSegment(payloadB64); } catch { throw new OxyAccountEventError('Account event token is malformed'); } if ( Object.keys(header).length !== 3 || header.alg !== 'EdDSA' || header.typ !== 'secevent+jwt' || typeof header.kid !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(header.kid) ) { throw new OxyAccountEventError('Account event token header is not supported'); } const nodeCrypto = await loadNodeCrypto(); let jwk: ServiceTokenPublicJwk; try { jwk = await resolveServiceTokenPublicKey( header.kid, options.jwksUrl ?? new URL('/.well-known/jwks.json', this.baseURL).toString(), this.jwksCache, ); } catch (error) { throw new OxyAccountEventError( error instanceof Error ? error.message.replace('Service token', 'Account event') : 'Signing key is unavailable', ); } const signature = Buffer.from(signatureB64, 'base64url'); if (signature.toString('base64url') !== signatureB64 || signature.length !== 64) { throw new OxyAccountEventError('Account event token signature is malformed'); } let verified = false; try { const publicKey = nodeCrypto.createPublicKey({ key: jwk as unknown as import('node:crypto').JsonWebKey, format: 'jwk', }); verified = nodeCrypto.verify( null, Buffer.from(`${headerB64}.${payloadB64}`), publicKey, signature, ); } catch { verified = false; } if (!verified) throw new OxyAccountEventError('Account event token signature is invalid'); if (payload.iss !== OXY_JWT_ISSUER) { throw new OxyAccountEventError('Account event token issuer is not Oxy'); } const audience = options.audience ?? (await this.configuredServiceAppId()); if (typeof payload.aud !== 'string' || payload.aud !== audience) { throw new OxyAccountEventError('Account event token is addressed to another application'); } if (typeof payload.jti !== 'string' || payload.jti.length === 0 || payload.jti.length > 128) { throw new OxyAccountEventError('Account event token has no event id'); } if (typeof payload.iat !== 'number' || !Number.isFinite(payload.iat)) { throw new OxyAccountEventError('Account event token has no issue time'); } const events = payload.events; if (typeof events !== 'object' || events === null || Array.isArray(events)) { throw new OxyAccountEventError('Account event token carries no events'); } const entries = Object.entries(events as Record); const deleted = (events as Record)[OXY_ACCOUNT_DELETED_EVENT_URI]; if ( entries.length !== 1 || typeof deleted !== 'object' || deleted === null || Array.isArray(deleted) ) { throw new OxyAccountEventError('Account event token carries an unknown event'); } const body = deleted as Record; if (typeof body.userId !== 'string' || body.userId.length === 0 || body.userId.length > 128) { throw new OxyAccountEventError('Account event names no account'); } if (typeof body.occurredAt !== 'string' || Number.isNaN(Date.parse(body.occurredAt))) { throw new OxyAccountEventError('Account event has no occurrence time'); } if ( body.username !== undefined && body.username !== null && typeof body.username !== 'string' ) { throw new OxyAccountEventError('Account event username is malformed'); } return { eventId: payload.jti, type: 'account.deleted', userId: body.userId, username: typeof body.username === 'string' ? body.username : null, occurredAt: body.occurredAt, retained: body.retained === true, applicationId: payload.aud, issuedAt: payload.iat, }; } /** The `appId` claim of this service's own token. */ private async configuredServiceAppId(): Promise { const serviceToken = await this.platformServiceToken(); const appId = jwtDecode<{ appId?: unknown }>(serviceToken).appId; if (typeof appId !== 'string' || appId.length === 0) { throw new OxyAccountEventError( 'No audience given and the service credential names no application', ); } return appId; } }