diff --git a/README.md b/README.md index d8e3c63..c596058 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ const client = new Client(); await client.authenticateOAuthPkce(); ``` -For custom transports, call `Client.createGRPCTransport(oidcConfig, { oidcTokenHolder })` with an `OAuthTokenHolder` (exported from this package). The usual path is `new Client(oidcConfig)`, which wires the holder and transport automatically. +For custom transports, call `Client.createGRPCTransport(oidcConfig, { oidcTokenHolder: client.oauthSession.oauthHolder })` after constructing a `Client` in OIDC mode, or pass a holder from your own OIDC flow. The usual path is `new Client(oidcConfig)`, which wires the session and transport automatically. ## Getting Started diff --git a/examples/example_interactive_oidc.js b/examples/example_interactive_oidc.js index c9efab1..d84db95 100644 --- a/examples/example_interactive_oidc.js +++ b/examples/example_interactive_oidc.js @@ -10,7 +10,7 @@ * DIRECTORY_CLIENT_TLS_SKIP_VERIFY. */ -import { Client, Config, OAuthPkceError, TokenCache, models } from "agntcy-dir"; +import { Client, Config, OAuthPkceError, models } from "agntcy-dir"; const DEFAULT_OIDC_ISSUER = "https://dev.idp.ads.outshift.io"; const DEFAULT_SERVER_ADDRESS = "dev.gateway.ads.outshift.io:443"; @@ -49,12 +49,12 @@ function parseArgs(argv) { return out; } -function hasUsableOidcTokenWithoutPkce() { +function hasUsableOidcTokenWithoutPkce(config) { const authToken = (process.env.DIRECTORY_CLIENT_AUTH_TOKEN ?? "").trim(); if (authToken) { return true; } - return new TokenCache().getValidToken() !== undefined; + return new Client(config).hasCachedOAuthToken(); } function parseOidcCallbackPort() { @@ -103,7 +103,7 @@ async function buildClient() { const config = buildConfig(); const client = new Client(config); - if (hasUsableOidcTokenWithoutPkce()) { + if (hasUsableOidcTokenWithoutPkce(config)) { console.log("Using cached OIDC token."); return client; } diff --git a/src/client/auth/index.ts b/src/client/auth/index.ts new file mode 100644 index 0000000..d4c786a --- /dev/null +++ b/src/client/auth/index.ts @@ -0,0 +1,22 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +export { + OAuthPkceError, + OAuthTokenHolder, + fetchOpenidConfiguration, + runLoopbackPkceLogin, + type OidcPkceConfig, + type OpenIdConfiguration, +} from './oauthPkce.js'; +export { + CachedToken, + TokenCache, + TOKEN_CACHE_FILE, + DEFAULT_TOKEN_CACHE_DIR, + type CachedTokenJson, +} from './tokenCache.js'; +export { + OAuthSessionManager, + cachedTokenFromResponse, +} from './session.js'; diff --git a/src/client/oauthPkce.ts b/src/client/auth/oauthPkce.ts similarity index 100% rename from src/client/oauthPkce.ts rename to src/client/auth/oauthPkce.ts diff --git a/src/client/auth/session.ts b/src/client/auth/session.ts new file mode 100644 index 0000000..0dd6834 --- /dev/null +++ b/src/client/auth/session.ts @@ -0,0 +1,109 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import { Config } from '../config.js'; +import { + fetchOpenidConfiguration, + OAuthTokenHolder, + runLoopbackPkceLogin, +} from './oauthPkce.js'; +import { CachedToken, TokenCache } from './tokenCache.js'; + +export function cachedTokenFromResponse( + config: Config, + payload: Record, +): CachedToken { + const expiresIn = payload.expires_in; + let expiresAt: Date | undefined; + if (typeof expiresIn === 'number' && Number.isFinite(expiresIn)) { + expiresAt = new Date(Date.now() + expiresIn * 1000); + } else if (typeof expiresIn === 'string' && expiresIn !== '') { + const n = Number(expiresIn); + if (Number.isFinite(n)) { + expiresAt = new Date(Date.now() + n * 1000); + } + } + const refreshToken = payload.refresh_token; + const tokenType = payload.token_type; + return new CachedToken( + String(payload.access_token), + typeof tokenType === 'string' ? tokenType : '', + 'oidc', + config.oidcIssuer, + typeof refreshToken === 'string' ? refreshToken : '', + expiresAt, + '', + '', + '', + new Date(), + ); +} + +/** Coordinates OIDC token state with interactive PKCE flow and cache. */ +export class OAuthSessionManager { + readonly config: Config; + private readonly tokenCache: TokenCache; + private _oauthHolder: OAuthTokenHolder | null = null; + + constructor(config: Config, tokenCache?: TokenCache) { + this.config = config; + this.tokenCache = tokenCache ?? new TokenCache(); + + if (this.config.authMode === 'oidc') { + this._oauthHolder = new OAuthTokenHolder(); + if (this.config.authToken) { + this._oauthHolder.setTokens(this.config.authToken); + } else { + const cachedToken = this.tokenCache.getValidToken(); + if (cachedToken !== undefined) { + this._oauthHolder.setTokens(cachedToken.accessToken); + } + } + } + } + + get oauthHolder(): OAuthTokenHolder | null { + return this._oauthHolder; + } + + hasAccessToken(): boolean { + if (this._oauthHolder === null) { + return false; + } + try { + this._oauthHolder.getAccessToken(); + return true; + } catch { + return false; + } + } + + async authenticate(): Promise { + if (this.config.authMode !== 'oidc') { + throw new Error("authenticateOAuthPkce() requires authMode='oidc'"); + } + if (this.config.oidcIssuer === '') { + throw new Error('oidc_issuer is required for authenticateOAuthPkce()'); + } + if (this.config.oidcClientId === '') { + throw new Error('oidc_client_id is required for authenticateOAuthPkce()'); + } + if (this._oauthHolder === null) { + throw new Error('OAuth token holder not initialized'); + } + const verify = !this.config.tlsSkipVerify; + const timeoutMs = Math.min(30_000, this.config.oidcAuthTimeout * 1000); + const meta = await fetchOpenidConfiguration(this.config.oidcIssuer, { + verify, + timeoutMs, + }); + const payload = await runLoopbackPkceLogin(this.config, meta, { + verify, + timeoutMs: this.config.oidcAuthTimeout * 1000, + }); + this._oauthHolder.updateFromTokenResponse(payload); + this.tokenCache.save(cachedTokenFromResponse(this.config, payload)); + console.log('Authenticated with OAuth PKCE'); + console.log('Access token acquired.'); + } +} diff --git a/src/client/tokenCache.ts b/src/client/auth/tokenCache.ts similarity index 100% rename from src/client/tokenCache.ts rename to src/client/auth/tokenCache.ts diff --git a/src/client/client.ts b/src/client/client.ts index 12c3dcb..d4e3c81 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -1,372 +1,36 @@ // Copyright AGNTCY Contributors (https://github.com/agntcy) // SPDX-License-Identifier: Apache-2.0 -import { readFileSync, mkdtempSync, rmSync, existsSync, openSync, closeSync } from 'node:fs'; -import type * as http2 from 'node:http2'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { env } from 'node:process'; -import { spawnSync, SpawnSyncReturns } from 'node:child_process'; - import { Client as GrpcClient, createClient, - Interceptor, - Transport, + type Transport, } from '@connectrpc/connect'; -import { createGrpcTransport } from '@connectrpc/connect-node'; -import { createClient as createClientSpiffe, X509SVID } from 'spiffe'; -import { fromJsonString } from '@bufbuild/protobuf'; -import * as models from '../models'; +import { createGrpcTransport as createConnectGrpcTransport } from '@connectrpc/connect-node'; +import * as models from '../models/index.js'; +import { OAuthSessionManager } from './auth/session.js'; +import { Config } from './config.js'; import { - fetchOpenidConfiguration, - OAuthTokenHolder, - runLoopbackPkceLogin, -} from './oauthPkce.js'; -import { CachedToken, TokenCache } from './tokenCache.js'; - -export { CachedToken, TokenCache, TOKEN_CACHE_FILE } from './tokenCache.js'; -export { OAuthPkceError, OAuthTokenHolder } from './oauthPkce.js'; - -export type AuthMode = '' | 'x509' | 'jwt' | 'tls' | 'oidc'; - -function parseBoolEnv(value: string | undefined, defaultVal: boolean): boolean { - if (value === undefined || value === '') { - return defaultVal; - } - return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()); -} - -function parseIntEnv(value: string | undefined, defaultVal: number): number { - if (value === undefined || value === '') { - return defaultVal; - } - return Number.parseInt(value, 10); -} - -function parseFloatEnv(value: string | undefined, defaultVal: number): number { - if (value === undefined || value === '') { - return defaultVal; - } - return Number.parseFloat(value); -} - -function parseCommaScopes(value: string | undefined, defaultList: string[]): string[] { - if (value === undefined || value === '') { - return [...defaultList]; - } - return value - .split(',') - .map((s) => s.trim()) - .filter(Boolean); -} - -/** - * Configuration class for the AGNTCY Directory client. - * - * This class manages configuration settings for connecting to the Directory service - * and provides default values and environment-based configuration loading. - */ -export class Config { - static DEFAULT_SERVER_ADDRESS = '127.0.0.1:8888'; - static DEFAULT_DIRCTL_PATH = 'dirctl'; - static DEFAULT_SPIFFE_ENDPOINT_SOCKET = ''; - static DEFAULT_AUTH_MODE = ''; - static DEFAULT_AUTH_TOKEN = ''; - static DEFAULT_JWT_AUDIENCE = ''; - static DEFAULT_TLS_CA_FILE = ''; - static DEFAULT_TLS_CERT_FILE = ''; - static DEFAULT_TLS_KEY_FILE = ''; - static DEFAULT_TLS_SERVER_NAME = ''; - static DEFAULT_TLS_SKIP_VERIFY = false; - static DEFAULT_OIDC_ISSUER = ''; - static DEFAULT_OIDC_CLIENT_ID = ''; - static DEFAULT_OIDC_CLIENT_SECRET = ''; - static DEFAULT_OIDC_REDIRECT_URI = 'http://localhost:8484/callback'; - static DEFAULT_OIDC_CALLBACK_PORT = 8484; - static DEFAULT_OIDC_AUTH_TIMEOUT = 300; - static DEFAULT_OIDC_SCOPES = ['openid', 'profile', 'email']; - - serverAddress: string; - dirctlPath: string; - spiffeEndpointSocket: string; - authMode: AuthMode; - authToken: string; - /** Backward-compatible alias for `authToken`. */ - oidcAccessToken: string; - jwtAudience: string; - tlsCaFile: string; - tlsCertFile: string; - tlsKeyFile: string; - tlsServerName: string; - tlsSkipVerify: boolean; - oidcIssuer: string; - oidcClientId: string; - oidcClientSecret: string; - oidcRedirectUri: string; - oidcCallbackPort: number; - oidcAuthTimeout: number; - oidcScopes: string[]; - dockerConfig: DockerConfig | undefined; - - /** - * Creates a new Config instance. - * - * @param serverAddress - The server address to connect to. Defaults to '127.0.0.1:8888' - * @param dirctlPath - Path to the dirctl executable. Defaults to 'dirctl' - * @param spiffeEndpointSocket - Path to the spire server socket. Defaults to empty string. - * @param authMode - Authentication mode: '' for insecure, 'x509', 'jwt', 'tls', or 'oidc'. Defaults to '' - * @param jwtAudience - JWT audience for JWT authentication. Required when authMode is 'jwt' - */ - constructor( - serverAddress = Config.DEFAULT_SERVER_ADDRESS, - dirctlPath = Config.DEFAULT_DIRCTL_PATH, - spiffeEndpointSocket = Config.DEFAULT_SPIFFE_ENDPOINT_SOCKET, - authMode: AuthMode = Config.DEFAULT_AUTH_MODE as AuthMode, - jwtAudience = Config.DEFAULT_JWT_AUDIENCE, - tlsCaFile = Config.DEFAULT_TLS_CA_FILE, - tlsCertFile = Config.DEFAULT_TLS_CERT_FILE, - tlsKeyFile = Config.DEFAULT_TLS_KEY_FILE, - authToken = Config.DEFAULT_AUTH_TOKEN, - tlsServerName = Config.DEFAULT_TLS_SERVER_NAME, - tlsSkipVerify = Config.DEFAULT_TLS_SKIP_VERIFY, - oidcIssuer = Config.DEFAULT_OIDC_ISSUER, - oidcClientId = Config.DEFAULT_OIDC_CLIENT_ID, - oidcClientSecret = Config.DEFAULT_OIDC_CLIENT_SECRET, - oidcRedirectUri = Config.DEFAULT_OIDC_REDIRECT_URI, - oidcCallbackPort = Config.DEFAULT_OIDC_CALLBACK_PORT, - oidcAuthTimeout = Config.DEFAULT_OIDC_AUTH_TIMEOUT, - oidcScopes: string[] | undefined = undefined, - oidcAccessToken: string | undefined = undefined, - dockerConfig: DockerConfig | undefined = undefined, - ) { - const resolvedAuthToken = authToken || oidcAccessToken || ''; - - // add protocol prefix if not set - // use unsafe http unless spire/auth is used - if ( - !serverAddress.startsWith('http://') && - !serverAddress.startsWith('https://') - ) { - // use https protocol when X.509, JWT, TLS, or OIDC auth is used - if ( - authMode === 'x509' || - authMode === 'jwt' || - authMode === 'tls' || - authMode === 'oidc' - ) { - serverAddress = `https://${serverAddress}`; - } else { - serverAddress = `http://${serverAddress}`; - } - } - - this.serverAddress = serverAddress; - this.dirctlPath = dirctlPath; - this.spiffeEndpointSocket = spiffeEndpointSocket; - this.authMode = authMode; - this.authToken = resolvedAuthToken; - this.oidcAccessToken = resolvedAuthToken; - this.jwtAudience = jwtAudience; - this.tlsCaFile = tlsCaFile; - this.tlsCertFile = tlsCertFile; - this.tlsKeyFile = tlsKeyFile; - this.tlsServerName = tlsServerName; - this.tlsSkipVerify = tlsSkipVerify; - this.oidcIssuer = oidcIssuer; - this.oidcClientId = oidcClientId; - this.oidcClientSecret = oidcClientSecret; - this.oidcRedirectUri = oidcRedirectUri; - this.oidcCallbackPort = oidcCallbackPort; - this.oidcAuthTimeout = oidcAuthTimeout; - this.oidcScopes = - oidcScopes !== undefined ? [...oidcScopes] : [...Config.DEFAULT_OIDC_SCOPES]; - this.dockerConfig = dockerConfig; - } - - /** - * Load configuration from environment variables. - * - * @param prefix - Environment variable prefix. Defaults to 'DIRECTORY_CLIENT_' - * @returns A new Config instance with values loaded from environment variables - * - * @example - * ```typescript - * // Load with default prefix - * const config = Config.loadFromEnv(); - * - * // Load with custom prefix - * const config = Config.loadFromEnv("MY_APP_"); - * ``` - */ - static loadFromEnv(prefix = 'DIRECTORY_CLIENT_') { - // Load dirctl path from env without env prefix - const dirctlPath = env['DIRCTL_PATH'] || Config.DEFAULT_DIRCTL_PATH; - - // Load other config values with env prefix - const serverAddress = - env[`${prefix}SERVER_ADDRESS`] || Config.DEFAULT_SERVER_ADDRESS; - const spiffeEndpointSocketPath = - env[`${prefix}SPIFFE_SOCKET_PATH`] || Config.DEFAULT_SPIFFE_ENDPOINT_SOCKET; - const authMode = (env[`${prefix}AUTH_MODE`] || Config.DEFAULT_AUTH_MODE) as AuthMode; - const authToken = env[`${prefix}AUTH_TOKEN`] || Config.DEFAULT_AUTH_TOKEN; - const jwtAudience = env[`${prefix}JWT_AUDIENCE`] || Config.DEFAULT_JWT_AUDIENCE; - const tlsCaFile = env[`${prefix}TLS_CA_FILE`] || Config.DEFAULT_TLS_CA_FILE; - const tlsCertFile = env[`${prefix}TLS_CERT_FILE`] || Config.DEFAULT_TLS_CERT_FILE; - const tlsKeyFile = env[`${prefix}TLS_KEY_FILE`] || Config.DEFAULT_TLS_KEY_FILE; - const tlsServerName = - env[`${prefix}TLS_SERVER_NAME`] || Config.DEFAULT_TLS_SERVER_NAME; - const tlsSkipVerify = parseBoolEnv( - env[`${prefix}TLS_SKIP_VERIFY`], - Config.DEFAULT_TLS_SKIP_VERIFY, - ); - const oidcIssuer = env[`${prefix}OIDC_ISSUER`] || Config.DEFAULT_OIDC_ISSUER; - const oidcClientId = - env[`${prefix}OIDC_CLIENT_ID`] || Config.DEFAULT_OIDC_CLIENT_ID; - const oidcClientSecret = - env[`${prefix}OIDC_CLIENT_SECRET`] || Config.DEFAULT_OIDC_CLIENT_SECRET; - const oidcRedirectUri = - env[`${prefix}OIDC_REDIRECT_URI`] || Config.DEFAULT_OIDC_REDIRECT_URI; - const oidcCallbackPort = parseIntEnv( - env[`${prefix}OIDC_CALLBACK_PORT`], - Config.DEFAULT_OIDC_CALLBACK_PORT, - ); - const oidcAuthTimeout = parseFloatEnv( - env[`${prefix}OIDC_AUTH_TIMEOUT`], - Config.DEFAULT_OIDC_AUTH_TIMEOUT, - ); - const oidcScopes = parseCommaScopes( - env[`${prefix}OIDC_SCOPES`], - Config.DEFAULT_OIDC_SCOPES, - ); - - var dockerConfig = undefined; - const dirctlImage = env["DIRCTL_IMAGE"] - const dirctlImageTag = env["DIRCTL_IMAGE_TAG"] - if (dirctlImage || dirctlImageTag) { - dockerConfig = new DockerConfig( - dirctlImage || DockerConfig.DEFAULT_DIRCTL_IMAGE, - dirctlImageTag || DockerConfig.DEFAULT_DIRCTL_IMAGE_TAG, - new Map, - [], - "0:0", - ) - } - - return new Config( - serverAddress, - dirctlPath, - spiffeEndpointSocketPath, - authMode, - jwtAudience, - tlsCaFile, - tlsCertFile, - tlsKeyFile, - authToken, - tlsServerName, - tlsSkipVerify, - oidcIssuer, - oidcClientId, - oidcClientSecret, - oidcRedirectUri, - oidcCallbackPort, - oidcAuthTimeout, - oidcScopes, - undefined, - dockerConfig, - ); - } - - getCommandAndArgs(args: string[]): [string, string[]] { - if (this.dockerConfig) { - const dockerArgs = this.dockerConfig.getDockerArgs(); - return ["docker", dockerArgs.concat(args)]; - } else { - return [`${this.dirctlPath}`, args] - } - } -} - -class DockerConfig { - static DEFAULT_DIRCTL_IMAGE = 'ghcr.io/agntcy/dir-ctl'; - static DEFAULT_DIRCTL_IMAGE_TAG = 'latest'; - - dirctlImage: string; - dirctlImageTag: string; - envs: Map; - mounts: string[]; - user: string; - - constructor( - dirctlImage: string, - dirctlImageTag: string, - envs: Map, - mounts: string[], - user: string, - ) { - this.dirctlImage = dirctlImage; - this.dirctlImageTag = dirctlImageTag; - this.envs = envs; - this.mounts = mounts; - this.user = user; - } - - getDockerArgs(): string[] { - this.pruneMounts(); - const commands = ["container", "run", "--name=dir-ctl", "--rm", "--network", "host"] - if (this.user) { - commands.push("--user") - commands.push(this.user) - } - this.envs.forEach((value: string, key: string) => { - commands.push("--env") - commands.push(`${key}=${value}`) - }) - this.mounts.forEach((value: string) => { - commands.push("--mount") - commands.push(value) - }) - commands.push(`${this.dirctlImage}:${this.dirctlImageTag}`) - return commands - } - - pruneMounts(): void { - this.mounts = this.mounts.filter((mount: string) => { - if (mount.startsWith("type=bind")) { - const [type, src, dst] = mount.split(","); - const [_, _src] = src.split("="); - return existsSync(_src); - } - - return false; - }) - } -} - + EventService, + NamingService, + PublicationService, + RoutingService, + SearchService, + SignService, + StoreService, + SyncService, +} from './services/index.js'; +import { createGrpcTransport, createOidcTransport } from './transport/channels.js'; +import type { OAuthTokenHolder } from './auth/oauthPkce.js'; /** * High-level client for interacting with AGNTCY Directory services. - * - * This client provides a unified interface for operations across the Directory API. - * It handles gRPC communication and provides convenient methods for common operations - * including storage, routing, search, signing, and synchronization. - * - * @example - * ```typescript - * // Create client with default configuration - * const client = new Client(); - * - * // Create client with custom configuration - * const config = new Config('localhost:8888', '/usr/local/bin/dirctl'); - * const client = new Client(config); - * - * // Use client for operations - * const records = await client.push([record]); - * ``` */ export class Client { config: Config; + readonly oauthSession: OAuthSessionManager; + storeClient: GrpcClient; routingClient: GrpcClient; publicationClient: GrpcClient; @@ -376,1383 +40,205 @@ export class Client { eventClient: GrpcClient; namingClient: GrpcClient; - private oauthHolder: OAuthTokenHolder | null = null; + storeService: StoreService; + routingService: RoutingService; + publicationService: PublicationService; + searchService: SearchService; + signService: SignService; + syncService: SyncService; + eventService: EventService; + namingService: NamingService; - /** - * Initialize the client with the given configuration. - * - * @param config - Optional client configuration. If null, loads from environment - * variables using Config.loadFromEnv() - * @param grpcTransport - Optional transport to use for gRPC communication. - * Can be created with Client.createGRPCTransport(config) - * - * @throws {Error} If unable to establish connection to the server or configuration is invalid - * - * @example - * ```typescript - * // Load config from environment - * const client = new Client(); - * - * // Use custom config - * const config = new Config('localhost:9999'); - * const grpcTransport = await Client.createGRPCTransport(config); - * const client = new Client(config, grpcTransport); - * ``` - */ constructor(); constructor(config?: Config); constructor(config?: Config, grpcTransport?: Transport); constructor(config?: Config, grpcTransport?: Transport) { - // Load config from environment if not provided - if (!config) { - config = Config.loadFromEnv(); - } - this.config = config; - - if (config.authMode === 'oidc') { - this.oauthHolder = new OAuthTokenHolder(); - if (config.authToken) { - this.oauthHolder.setTokens(config.authToken); - } else { - const cachedToken = new TokenCache().getValidToken(); - if (cachedToken !== undefined) { - this.oauthHolder.setTokens(cachedToken.accessToken); - } - } - } - - // if no transport provided, use insecure transport (or OIDC transport) - if (!grpcTransport) { - if (config.authMode === 'oidc') { - if (this.oauthHolder === null) { + const resolvedConfig = config ?? Config.loadFromEnv(); + this.config = resolvedConfig; + this.oauthSession = new OAuthSessionManager(resolvedConfig); + + let transport = grpcTransport; + if (transport === undefined) { + if (resolvedConfig.authMode === 'oidc') { + const holder = this.oauthSession.oauthHolder; + if (holder === null) { throw new Error('OAuth token holder not initialized'); } - grpcTransport = Client.createOidcTransport(config, this.oauthHolder); + transport = createOidcTransport(resolvedConfig, holder); } else { - grpcTransport = createGrpcTransport({ - baseUrl: config.serverAddress, + transport = createConnectGrpcTransport({ + baseUrl: resolvedConfig.serverAddress, }); } } - // Set clients for all services - this.storeClient = createClient(models.store_v1.StoreService, grpcTransport); - this.routingClient = createClient( - models.routing_v1.RoutingService, - grpcTransport, - ); - this.publicationClient = createClient(models.routing_v1.PublicationService, grpcTransport); - this.searchClient = createClient(models.search_v1.SearchService, grpcTransport); - this.signClient = createClient(models.sign_v1.SignService, grpcTransport); - this.syncClient = createClient(models.store_v1.SyncService, grpcTransport); - this.eventClient = createClient(models.events_v1.EventService, grpcTransport); - this.namingClient = createClient(models.naming_v1.NamingService, grpcTransport); - } - - private static convertToPEM(bytes: Uint8Array, label: string): string { - // Convert Uint8Array to base64 string - let binary = ''; - const len = bytes.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); - } - const base64String = btoa(binary); - - // Split base64 string into 64-character lines - const lines = base64String.match(/.{1,64}/g) || []; - - // Build PEM formatted string with headers and footers - const pem = [ - `-----BEGIN ${label}-----`, - ...lines, - `-----END ${label}-----` - ].join('\n'); + this.storeClient = createClient(models.store_v1.StoreService, transport); + this.routingClient = createClient(models.routing_v1.RoutingService, transport); + this.publicationClient = createClient(models.routing_v1.PublicationService, transport); + this.searchClient = createClient(models.search_v1.SearchService, transport); + this.signClient = createClient(models.sign_v1.SignService, transport); + this.syncClient = createClient(models.store_v1.SyncService, transport); + this.eventClient = createClient(models.events_v1.EventService, transport); + this.namingClient = createClient(models.naming_v1.NamingService, transport); - return pem; - } - - private static secureNodeOptions( - config: Config, - base: http2.SecureClientSessionOptions, - ): http2.SecureClientSessionOptions { - const out: http2.SecureClientSessionOptions = { ...base }; - const sn = config.tlsServerName.trim(); - if (sn !== '') { - out.servername = sn; - } - return out; - } - - private static createOidcTransport(config: Config, holder: OAuthTokenHolder): Transport { - let ca: string | undefined; - if (config.tlsCaFile !== '') { - try { - ca = readFileSync(config.tlsCaFile).toString(); - } catch (e) { - throw new Error(`Failed to read TLS CA file: ${(e as Error).message}`); - } - } - const nodeBase: http2.SecureClientSessionOptions = {}; - if (ca !== undefined) { - nodeBase.ca = ca; - } - const bearerInterceptor: Interceptor = (next) => async (req) => { - req.header.set('authorization', `Bearer ${holder.getAccessToken()}`); - return await next(req); - }; - return createGrpcTransport({ - baseUrl: config.serverAddress, - interceptors: [bearerInterceptor], - nodeOptions: Client.secureNodeOptions(config, nodeBase), - }); + this.storeService = new StoreService(this.storeClient); + this.routingService = new RoutingService(this.routingClient); + this.publicationService = new PublicationService(this.publicationClient); + this.searchService = new SearchService(this.searchClient); + this.signService = new SignService(resolvedConfig, this.signClient); + this.syncService = new SyncService(this.syncClient); + this.eventService = new EventService(this.eventClient); + this.namingService = new NamingService(this.namingClient); } static async createGRPCTransport( config: Config, - oidcOptions?: { oidcTokenHolder: OAuthTokenHolder }, + options?: { oidcTokenHolder?: OAuthTokenHolder }, ): Promise { - // Handle different authentication modes - switch (config.authMode) { - case '': - return createGrpcTransport({ - baseUrl: config.serverAddress, - }); - - case 'jwt': - return await this.createJWTTransport(config); - - case 'x509': - return await this.createX509Transport(config); - - case 'tls': - return await this.createTLSTransport(config); - - case 'oidc': { - const holder = oidcOptions?.oidcTokenHolder; - if (holder === undefined) { - throw new Error( - 'createGRPCTransport: authMode oidc requires options.oidcTokenHolder', - ); - } - return Client.createOidcTransport(config, holder); - } - - default: - throw new Error(`Unsupported auth mode: ${config.authMode}`); - } - } - - private static async createX509Transport(config: Config): Promise { - if (config.spiffeEndpointSocket === '') { - throw new Error('SPIFFE socket path is required for X.509 authentication'); - } - - // Create secure transport with SPIFFE X.509 - const client = createClientSpiffe(config.spiffeEndpointSocket); - - let svid: X509SVID = { - spiffeId: '', - hint: '', - x509Svid: new Uint8Array(), - x509SvidKey: new Uint8Array(), - bundle: new Uint8Array(), - }; - - const svidStream = client.fetchX509SVID({}); - for await (const message of svidStream.responses) { - message.svids.forEach((_svid) => { - svid = _svid; - }) - - if (message.svids.length > 0) { - break - } - } - - // Create transport settings for gRPC client - const transport = createGrpcTransport({ - baseUrl: config.serverAddress, - nodeOptions: Client.secureNodeOptions(config, { - ca: this.convertToPEM(svid.bundle, "TRUSTED CERTIFICATE"), - cert: this.convertToPEM(svid.x509Svid, "CERTIFICATE"), - key: this.convertToPEM(svid.x509SvidKey, "PRIVATE KEY"), - }), - }); - - return transport; - } - - private static async createJWTTransport(config: Config): Promise { - if (config.spiffeEndpointSocket === '') { - throw new Error('SPIFFE socket path is required for JWT authentication'); - } - - if (config.jwtAudience === '') { - throw new Error('JWT audience is required for JWT authentication'); - } - - // Create SPIFFE client - const client = createClientSpiffe(config.spiffeEndpointSocket); - - // Fetch X.509 bundle for verifying server's TLS certificate - // In JWT mode, the server presents its X.509-SVID via TLS for transport security - let bundle: Uint8Array | null = null; - const bundleStream = client.fetchX509Bundles({}); - for await (const message of bundleStream.responses) { - // Get the first bundle from the bundles map - // bundles is a map where bytes is ASN.1 DER encoded - for (const [_, bundleData] of Object.entries(message.bundles)) { - // Convert to a new Uint8Array to ensure type compatibility - bundle = new Uint8Array(bundleData); - break; - } - if (bundle !== null) { - break; - } - } - - if (bundle === null || bundle.length === 0) { - throw new Error('Failed to fetch X.509 bundle from SPIRE: no bundles returned'); - } - - // Create JWT interceptor that fetches and injects JWT tokens - const jwtInterceptor: Interceptor = (next) => async (req) => { - // Fetch JWT-SVID from SPIRE - // Note: spiffeId is empty string to use the workload's default identity - const jwtCall = client.fetchJWTSVID({ - spiffeId: '', - audience: [config.jwtAudience] - }); - - const response = await jwtCall.response; - - if (!response.svids || response.svids.length === 0) { - throw new Error('Failed to fetch JWT-SVID from SPIRE: no SVIDs returned'); - } - - const jwtToken = response.svids[0].svid; - - // Add JWT token to request headers - req.header.set('authorization', `Bearer ${jwtToken}`); - - return await next(req); - }; - - // Create transport with JWT interceptor and TLS using SPIFFE bundle - // For JWT mode: Server presents X.509-SVID via TLS, clients authenticate with JWT-SVID - const transport = createGrpcTransport({ - baseUrl: config.serverAddress, - interceptors: [jwtInterceptor], - nodeOptions: Client.secureNodeOptions(config, { - ca: this.convertToPEM(bundle, "CERTIFICATE"), - }), - }); - - return transport; - } - - private static async createTLSTransport(config: Config): Promise { - if (config.tlsCaFile === '') { - throw new Error('TLS CA file is required for TLS authentication'); - } - if (config.tlsCertFile === '') { - throw new Error('TLS certificate file is required for TLS authentication'); - } - if (config.tlsKeyFile === '') { - throw new Error('TLS key file is required for TLS authentication'); - } - - let root_ca: string; - let cert_chain: string; - let private_key: string; - - try { - root_ca = readFileSync(config.tlsCaFile).toString(); - cert_chain = readFileSync(config.tlsCertFile).toString(); - private_key = readFileSync(config.tlsKeyFile).toString(); - } catch (e) { - console.error('Error reading file:', (e as Error).message); - throw e; - } - - const transport = createGrpcTransport({ - baseUrl: config.serverAddress, - nodeOptions: Client.secureNodeOptions(config, { - ca: root_ca, - cert: cert_chain, - key: private_key, - }), + return createGrpcTransport(config, { + oidcTokenHolder: options?.oidcTokenHolder, }); - - return transport; } - private cachedTokenFromResponse(payload: Record): CachedToken { - const expiresIn = payload.expires_in; - let expiresAt: Date | undefined; - if (typeof expiresIn === 'number' && Number.isFinite(expiresIn)) { - expiresAt = new Date(Date.now() + expiresIn * 1000); - } else if (typeof expiresIn === 'string' && expiresIn !== '') { - const n = Number(expiresIn); - if (Number.isFinite(n)) { - expiresAt = new Date(Date.now() + n * 1000); - } - } - const refreshToken = payload.refresh_token; - const tokenType = payload.token_type; - return new CachedToken( - String(payload.access_token), - typeof tokenType === 'string' ? tokenType : '', - 'oidc', - this.config.oidcIssuer, - typeof refreshToken === 'string' ? refreshToken : '', - expiresAt, - '', - '', - '', - new Date(), - ); + hasCachedOAuthToken(): boolean { + return this.oauthSession.hasAccessToken(); } - /** - * Run browser-based OAuth 2.0 Authorization Code + PKCE login (loopback callback). - * - * Requires `authMode: 'oidc'`, `oidcIssuer`, and `oidcClientId`. - */ - async authenticateOAuthPkce(): Promise { - if (this.config.authMode !== 'oidc') { - throw new Error("authenticateOAuthPkce() requires authMode='oidc'"); - } - if (this.config.oidcIssuer === '') { - throw new Error('oidc_issuer is required for authenticateOAuthPkce()'); - } - if (this.config.oidcClientId === '') { - throw new Error('oidc_client_id is required for authenticateOAuthPkce()'); - } - if (this.oauthHolder === null) { + getAccessToken(): string { + const holder = this.oauthSession.oauthHolder; + if (holder === null) { throw new Error('OAuth token holder not initialized'); } - const verify = !this.config.tlsSkipVerify; - const timeoutMs = Math.min(30_000, this.config.oidcAuthTimeout * 1000); - const meta = await fetchOpenidConfiguration(this.config.oidcIssuer, { - verify, - timeoutMs, - }); - const payload = await runLoopbackPkceLogin(this.config, meta, { - verify, - timeoutMs: this.config.oidcAuthTimeout * 1000, - }); - this.oauthHolder.updateFromTokenResponse(payload); - new TokenCache().save(this.cachedTokenFromResponse(payload)); - console.log('Authenticated with OAuth PKCE'); - console.log('Access token acquired.'); + return holder.getAccessToken(); } - /** - * Request generator helper function for streaming requests. - */ - private async *requestGenerator(reqs: T[]): AsyncIterable { - for (const req of reqs) { - yield req; - } + async authenticateOAuthPkce(): Promise { + await this.oauthSession.authenticate(); } - /** - * Push records to the Store API. - * - * Uploads one or more records to the content store, making them available - * for retrieval and reference. Each record is assigned a unique content - * identifier (CID) based on its content hash. - * - * @param records - Array of Record objects to push to the store - * @returns Promise that resolves to an array of RecordRef objects containing the CIDs of the pushed records - * - * @throws {Error} If the gRPC call fails or the push operation fails - * - * @example - * ```typescript - * const records = [createRecord("example")]; - * const refs = await client.push(records); - * console.log(`Pushed with CID: ${refs[0].cid}`); - * ``` - */ async push( records: models.core_v1.Record[], ): Promise { - const responses: models.core_v1.RecordRef[] = []; - - for await (const response of this.storeClient.push( - this.requestGenerator(records), - )) { - responses.push(response); - } - - return responses; + return this.storeService.push(records); } - /** - * Push records with referrer metadata to the Store API. - * - * Uploads records along with optional artifacts and referrer information. - * This is useful for pushing complex objects that include additional - * metadata or associated artifacts. - * - * @param requests - Array of PushReferrerRequest objects containing records and optional artifacts - * @returns Promise that resolves to an array of PushReferrerResponse objects containing the details of pushed artifacts - * - * @throws {Error} If the gRPC call fails or the push operation fails - * - * @example - * ```typescript - * const requests = [new models.store_v1.PushReferrerRequest({record: record})]; - * const responses = await client.push_referrer(requests); - * ``` - */ async push_referrer( requests: models.store_v1.PushReferrerRequest[], ): Promise { - const responses: models.store_v1.PushReferrerResponse[] = []; - - for await (const response of this.storeClient.pushReferrer( - this.requestGenerator(requests), - )) { - responses.push(response); - } - - return responses; + return this.storeService.push_referrer(requests); } - /** - * Pull records from the Store API by their references. - * - * Retrieves one or more records from the content store using their - * content identifiers (CIDs). - * - * @param refs - Array of RecordRef objects containing the CIDs to retrieve - * @returns Promise that resolves to an array of Record objects retrieved from the store - * - * @throws {Error} If the gRPC call fails or the pull operation fails - * - * @example - * ```typescript - * const refs = [new models.core_v1.RecordRef({cid: "QmExample123"})]; - * const records = await client.pull(refs); - * for (const record of records) { - * console.log(`Retrieved record: ${record}`); - * } - * ``` - */ - async pull( - refs: models.core_v1.RecordRef[], - ): Promise { - const records: models.core_v1.Record[] = []; - - for await (const response of this.storeClient.pull( - this.requestGenerator(refs), - )) { - records.push(response); - } - - return records; + async pull(refs: models.core_v1.RecordRef[]): Promise { + return this.storeService.pull(refs); } - /** - * Pull records with referrer metadata from the Store API. - * - * Retrieves records along with their associated artifacts and referrer - * information. This provides access to complex objects that include - * additional metadata or associated artifacts. - * - * @param requests - Array of PullReferrerRequest objects containing records and optional artifacts for pull operations - * @returns Promise that resolves to an array of PullReferrerResponse objects containing the retrieved records - * - * @throws {Error} If the gRPC call fails or the pull operation fails - * - * @example - * ```typescript - * const requests = [new models.store_v1.PullReferrerRequest({ref: ref})]; - * const responses = await client.pull_referrer(requests); - * for (const response of responses) { - * console.log(`Retrieved: ${response}`); - * } - * ``` - */ async pull_referrer( requests: models.store_v1.PullReferrerRequest[], ): Promise { - const responses: models.store_v1.PullReferrerResponse[] = []; - - for await (const response of this.storeClient.pullReferrer( - this.requestGenerator(requests), - )) { - responses.push(response); - } - - return responses; + return this.storeService.pull_referrer(requests); } - /** - * Search objects from the Store API matching the specified queries. - * - * Performs a search across the storage using the provided search queries - * and returns a list of matching CIDs. This is efficient for lookups - * where only the CIDs are needed. - * - * @param request - SearchCIDsRequest containing queries, filters, and search options - * @returns Promise that resolves to an array of SearchCIDsResponse objects matching the queries - * - * @throws {Error} If the gRPC call fails or the search operation fails - * - * @example - * ```typescript - * const request = create(models.search_v1.SearchCIDsRequestSchema, {queries: [query], limit: 10}); - * const responses = await client.searchCIDs(request); - * for (const response of responses) { - * console.log(`Found CID: ${response.recordCid}`); - * } - * ``` - */ async searchCIDs( request: models.search_v1.SearchCIDsRequest, ): Promise { - const responses: models.search_v1.SearchCIDsResponse[] = []; - - for await (const response of this.searchClient.searchCIDs(request)) { - responses.push(response); - } - - return responses; + return this.searchService.searchCIDs(request); } - /** - * Search for full records from the Store API matching the specified queries. - * - * Performs a search across the storage using the provided search queries - * and returns a list of full records with all metadata. - * - * @param request - SearchRecordsRequest containing queries, filters, and search options - * @returns Promise that resolves to an array of SearchRecordsResponse objects matching the queries - * - * @throws {Error} If the gRPC call fails or the search operation fails - * - * @example - * ```typescript - * const request = create(models.search_v1.SearchRecordsRequestSchema, {queries: [query], limit: 10}); - * const responses = await client.searchRecords(request); - * for (const response of responses) { - * console.log(`Found: ${response.record?.name}`); - * } - * ``` - */ async searchRecords( request: models.search_v1.SearchRecordsRequest, ): Promise { - const responses: models.search_v1.SearchRecordsResponse[] = []; - - for await (const response of this.searchClient.searchRecords(request)) { - responses.push(response); - } - - return responses; + return this.searchService.searchRecords(request); } - /** - * Look up metadata for records in the Store API. - * - * Retrieves metadata information for one or more records without - * downloading the full record content. This is useful for checking - * if records exist and getting basic information about them. - * - * @param refs - Array of RecordRef objects containing the CIDs to look up - * @returns Promise that resolves to an array of RecordMeta objects containing metadata for the records - * - * @throws {Error} If the gRPC call fails or the lookup operation fails - * - * @example - * ```typescript - * const refs = [new models.core_v1.RecordRef({cid: "QmExample123"})]; - * const metadatas = await client.lookup(refs); - * for (const meta of metadatas) { - * console.log(`Record size: ${meta.size}`); - * } - * ``` - */ - async lookup( - refs: models.core_v1.RecordRef[], - ): Promise { - const recordMetas: models.core_v1.RecordMeta[] = []; - - for await (const response of this.storeClient.lookup( - this.requestGenerator(refs), - )) { - recordMetas.push(response); - } - - return recordMetas; + async lookup(refs: models.core_v1.RecordRef[]): Promise { + return this.storeService.lookup(refs); } - /** - * List objects from the Routing API matching the specified criteria. - * - * Returns a list of objects that match the filtering and - * query criteria specified in the request. - * - * @param request - ListRequest specifying filtering criteria, pagination, etc. - * @returns Promise that resolves to an array of ListResponse objects matching the criteria - * - * @throws {Error} If the gRPC call fails or the list operation fails - * - * @example - * ```typescript - * const request = new models.routing_v1.ListRequest({limit: 10}); - * const responses = await client.list(request); - * for (const response of responses) { - * console.log(`Found object: ${response.cid}`); - * } - * ``` - */ async list( request: models.routing_v1.ListRequest, ): Promise { - const results: models.routing_v1.ListResponse[] = []; - - for await (const response of this.routingClient.list(request)) { - results.push(response); - } - - return results; + return this.routingService.list(request); } - /** - * Publish objects to the Routing API matching the specified criteria. - * - * Makes the specified objects available for discovery and retrieval by other - * clients in the network. The objects must already exist in the store before - * they can be published. - * - * @param request - PublishRequest containing the query for the objects to publish - * @returns Promise that resolves when the publish operation is complete - * - * @throws {Error} If the gRPC call fails or the object cannot be published - * - * @example - * ```typescript - * const ref = new models.routing_v1.RecordRef({cid: "QmExample123"}); - * const request = new models.routing_v1.PublishRequest({recordRefs: [ref]}); - * await client.publish(request); - * ``` - */ async publish(request: models.routing_v1.PublishRequest): Promise { - await this.routingClient.publish(request); + return this.routingService.publish(request); } - /** - * Unpublish objects from the Routing API matching the specified criteria. - * - * Removes the specified objects from the public network, making them no - * longer discoverable by other clients. The objects remain in the local - * store but are not available for network discovery. - * - * @param request - UnpublishRequest containing the query for the objects to unpublish - * @returns Promise that resolves when the unpublish operation is complete - * - * @throws {Error} If the gRPC call fails or the objects cannot be unpublished - * - * @example - * ```typescript - * const ref = new models.routing_v1.RecordRef({cid: "QmExample123"}); - * const request = new models.routing_v1.UnpublishRequest({recordRefs: [ref]}); - * await client.unpublish(request); - * ``` - */ async unpublish(request: models.routing_v1.UnpublishRequest): Promise { - await this.routingClient.unpublish(request); + return this.routingService.unpublish(request); } - /** - * Delete records from the Store API. - * - * Permanently removes one or more records from the content store using - * their content identifiers (CIDs). This operation cannot be undone. - * - * @param refs - Array of RecordRef objects containing the CIDs to delete - * @returns Promise that resolves when the deletion is complete - * - * @throws {Error} If the gRPC call fails or the delete operation fails - * - * @example - * ```typescript - * const refs = [new models.core_v1.RecordRef({cid: "QmExample123"})]; - * await client.delete(refs); - * ``` - */ async delete(refs: models.core_v1.RecordRef[]): Promise { - await this.storeClient.delete(this.requestGenerator(refs)); + return this.storeService.delete(refs); } - /** - * Sign a record with a cryptographic signature. - * - * Creates a cryptographic signature for a record using either a private - * key or OIDC-based signing. The signing process uses the external dirctl - * command-line tool to perform the actual cryptographic operations. - * - * @param req - SignRequest containing the record reference and signing provider - * configuration. The provider can specify either key-based signing - * (with a private key) or OIDC-based signing - * @param oidc_client_id - OIDC client identifier for OIDC-based signing. Defaults to "sigstore" - * @returns SignResponse containing the signature - * - * @throws {Error} If the signing operation fails or unsupported provider is supplied - * - * @example - * ```typescript - * const req = new models.sign_v1.SignRequest({ - * recordRef: new models.core_v1.RecordRef({cid: "QmExample123"}), - * provider: new models.sign_v1.SignProvider({key: keyConfig}) - * }); - * const response = client.sign(req); - * console.log(`Signature: ${response.signature}`); - * ``` - */ sign(req: models.sign_v1.SignRequest): void { - - var output; - - switch (req.provider?.request.case) { - case 'oidc': - output = this.__sign_with_oidc( - req.recordRef?.cid || '', - req.provider.request.value, - ); - break; - - case 'key': - output = this.__sign_with_key( - req.recordRef?.cid || '', - req.provider.request.value, - ); - break; - - default: - throw new Error('unsupported provider was supplied'); - } - - if (output.status !== 0) { - throw output.error || output.stderr; - } + this.signService.sign(req); } - /** - * Verify a cryptographic signature on a record. - * - * Validates the cryptographic signature of a previously signed record - * to ensure its authenticity and integrity. This operation verifies - * that the record has not been tampered with since signing. - * - * The verification process uses the external dirctl command-line tool - * to perform the actual cryptographic operations. - * - * When fromServer is true, uses the server's cached verification result. - * - * @param request - VerifyRequest containing the record reference and verification parameters. - * The provider can specify either key-based verification (with a public key) - * or OIDC-based verification - * @returns VerifyResponse containing the verification result and details - * - * @throws {Error} If the verification operation fails or unsupported provider is supplied - * - * @example - * ```typescript - * const request = new models.sign_v1.VerifyRequest({ - * recordRef: new models.core_v1.RecordRef({cid: "QmExample123"}) - * }); - * const response = client.verify(request); - * console.log(`Signature valid: ${response.success}`); - * ``` - */ async verify( request: models.sign_v1.VerifyRequest, ): Promise { - if (request.fromServer) { - return await this._verifyViaServer(request); - } - - // Create a temp file for output - const tempDir = mkdtempSync(join(tmpdir(), 'dirctl-verify-')); - const outputPath = join(tempDir, 'output.json'); - closeSync(openSync(outputPath, 'w')); // create empty file - let _outputPath = outputPath; - - if (this.config.dockerConfig) { - _outputPath = outputPath.split('/').reverse()[0]; - this.config.dockerConfig.mounts.push(`type=bind,src=${outputPath},dst=/${_outputPath}`); - } - - try { - switch (request.provider?.request.case) { - case 'oidc': - this.__verify_with_oidc( - request.recordRef?.cid || '', - request.provider.request.value, - _outputPath, - ); - break; - - case 'key': - this.__verify_with_key( - request.recordRef?.cid || '', - request.provider.request.value, - _outputPath, - ); - break; - - case 'any': - this.__verify_with_any( - request.recordRef?.cid || '', - request.provider.request.value, - _outputPath, - ); - break; - - default: - // Default: verify any valid signature - this.__verify_with_any( - request.recordRef?.cid || '', - undefined, - _outputPath, - ); - break; - } - - // Read and parse the output file - const jsonContent = readFileSync(outputPath, 'utf8'); - return fromJsonString(models.sign_v1.VerifyResponseSchema, jsonContent); - } catch (e) { - throw new Error(`Failed to parse verification response: ${e}`); - } finally { - // Clean up the temp directory - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - } + return this.signService.verify(request); } - private async _verifyViaServer( - request: models.sign_v1.VerifyRequest, - ): Promise { - if (!request.recordRef?.cid) { - throw new Error('VerifyRequest.recordRef with cid is required'); - } - const response = await this.signClient.verify(request); - return response; - } - - /** - * Create a new synchronization configuration. - * - * Creates a new sync configuration that defines how data should be - * synchronized between different Directory servers. This allows for - * automated data replication and consistency across multiple locations. - * - * @param request - CreateSyncRequest containing the sync configuration details - * including source, target, and synchronization parameters - * @returns Promise that resolves to a CreateSyncResponse containing the created sync details - * including the sync ID and configuration - * - * @throws {Error} If the gRPC call fails or the sync creation fails - * - * @example - * ```typescript - * const request = new models.store_v1.CreateSyncRequest(); - * const response = await client.create_sync(request); - * console.log(`Created sync with ID: ${response.syncId}`); - * ``` - */ async create_sync( request: models.store_v1.CreateSyncRequest, ): Promise { - return await this.syncClient.createSync(request); + return this.syncService.create_sync(request); } - /** - * List existing synchronization configurations. - * - * Retrieves a list of all sync configurations that have been created, - * with optional filtering and pagination support. This allows you to - * monitor and manage multiple synchronization processes. - * - * @param request - ListSyncsRequest containing filtering criteria, pagination options, - * and other query parameters - * @returns Promise that resolves to an array of ListSyncsItem objects with - * their details including ID, name, status, and configuration parameters - * - * @throws {Error} If the gRPC call fails or the list operation fails - * - * @example - * ```typescript - * const request = new models.store_v1.ListSyncsRequest({limit: 10}); - * const syncs = await client.list_syncs(request); - * for (const sync of syncs) { - * console.log(`Sync: ${sync}`); - * } - * ``` - */ async list_syncs( request: models.store_v1.ListSyncsRequest, ): Promise { - const results: models.store_v1.ListSyncsItem[] = []; - - for await (const response of this.syncClient.listSyncs(request)) { - results.push(response); - } - - return results; + return this.syncService.list_syncs(request); } - /** - * Retrieve detailed information about a specific synchronization configuration. - * - * Gets comprehensive details about a specific sync configuration including - * its current status, configuration parameters, performance metrics, - * and any recent errors or warnings. - * - * @param request - GetSyncRequest containing the sync ID or identifier to retrieve - * @returns Promise that resolves to a GetSyncResponse with detailed information about the sync configuration - * including status, metrics, configuration, and logs - * - * @throws {Error} If the gRPC call fails or the get operation fails - * - * @example - * ```typescript - * const request = new models.store_v1.GetSyncRequest({syncId: "sync-123"}); - * const response = await client.get_sync(request); - * console.log(`Sync status: ${response.status}`); - * console.log(`Last update: ${response.lastUpdateTime}`); - * ``` - */ async get_sync( request: models.store_v1.GetSyncRequest, ): Promise { - return await this.syncClient.getSync(request); + return this.syncService.get_sync(request); } - /** - * Delete a synchronization configuration. - * - * Permanently removes a sync configuration and stops any ongoing - * synchronization processes. This operation cannot be undone and - * will halt all data synchronization for the specified configuration. - * - * @param request - DeleteSyncRequest containing the sync ID or identifier to delete - * @returns Promise that resolves to a DeleteSyncResponse when the deletion is complete - * - * @throws {Error} If the gRPC call fails or the delete operation fails - * - * @example - * ```typescript - * const request = new models.store_v1.DeleteSyncRequest({syncId: "sync-123"}); - * await client.delete_sync(request); - * console.log("Sync deleted"); - * ``` - */ async delete_sync( request: models.store_v1.DeleteSyncRequest, ): Promise { - return await this.syncClient.deleteSync(request); + return this.syncService.delete_sync(request); } - /** - * Get events from the Event API matching the specified criteria. - * - * Retrieves a list of events that match the filtering and query criteria - * specified in the request. - * - * @param request - ListenRequest specifying filtering criteria, pagination, etc. - * @returns Promise that resolves to an array of ListenResponse objects matching the criteria - * - * @throws {Error} If the gRPC call fails or the get events operation fails - */ listen( - request: models.events_v1.ListenRequest + request: models.events_v1.ListenRequest, ): AsyncIterable { - return this.eventClient.listen(request); + return this.eventService.listen(request); } - /** - * CreatePublication creates a new publication request that will be processed by the PublicationWorker. - * The publication request can specify either a query, a list of specific CIDs, - * or all records to be announced to the DHT. - * - * @param request - PublishRequest containing record references and queries options. - * - * @returns CreatePublicationResponse returns the result of creating a publication request. - * This includes the publication ID and any relevant metadata. - * - * @throws {Error} If the gRPC call fails or the list operation fails - */ async create_publication( request: models.routing_v1.PublishRequest, ): Promise { - return await this.publicationClient.createPublication(request); + return this.publicationService.create_publication(request); } - /** - * ListPublications returns a stream of all publication requests in the system. - * This allows monitoring of pending, processing, and completed publication requests. - * - * @param request - ListPublicationsRequest contains optional filters for listing publication requests. - * - * @returns Promise that resolves to an array of ListPublicationsItem represents - * a single publication request in the list response. - * Contains publication details including ID, status, and creation timestamp. - * - * @throws {Error} If the gRPC call fails or the list operation fails - */ async list_publication( request: models.routing_v1.ListPublicationsRequest, ): Promise { - const results: models.routing_v1.ListPublicationsItem[] = []; - - for await (const response of this.publicationClient.listPublications(request)) { - results.push(response); - } - - return results; + return this.publicationService.list_publication(request); } - /** - * GetPublication retrieves details of a specific publication request by its identifier. - * This includes the current status and any associated metadata. - * - * @param request - GetPublicationRequest specifies which publication to retrieve by its identifier. - * - * @returns GetPublicationResponse contains the full details of a specific publication request. - * Includes status, progress information, and any error details if applicable. - * - * @throws {Error} If the gRPC call fails or the get operation fails - */ async get_publication( request: models.routing_v1.GetPublicationRequest, ): Promise { - return await this.publicationClient.getPublication(request); + return this.publicationService.get_publication(request); } - /** - * Resolve a record name to CIDs. - * - * Resolves a record reference (name with optional version) to content identifiers (CIDs). - * When no version is specified, returns all versions sorted by creation time (newest first). - * - * @param request - ResolveRequest containing the name and optional version - * @returns Promise that resolves to a ResolveResponse containing the resolved record references - * - * @throws {Error} If the gRPC call fails or the resolve operation fails - * - * @example - * ```typescript - * import { create } from "@bufbuild/protobuf"; - * - * // Resolve latest version - * const request = create(models.naming_v1.ResolveRequestSchema, { name: "cisco.com/agent" }); - * const response = await client.resolve(request); - * console.log(`Latest CID: ${response.records[0].cid}`); - * - * // Resolve specific version - * const request = create(models.naming_v1.ResolveRequestSchema, { name: "cisco.com/agent", version: "v1.0.0" }); - * const response = await client.resolve(request); - * ``` - */ async resolve( request: models.naming_v1.ResolveRequest, ): Promise { - return await this.namingClient.resolve(request); + return this.namingService.resolve(request); } - /** - * Get verification info for a record. - * - * Retrieves the name verification status for a record. Can look up by CID directly - * or by name (with optional version) which will be resolved first. - * - * @param request - GetVerificationInfoRequest containing cid, name, and/or version - * @returns Promise that resolves to a GetVerificationInfoResponse containing verification status - * - * @throws {Error} If the gRPC call fails or the operation fails - * - * @example - * ```typescript - * import { create } from "@bufbuild/protobuf"; - * - * // Check by CID - * const request = create(models.naming_v1.GetVerificationInfoRequestSchema, { cid: "bafyreib..." }); - * const response = await client.getVerificationInfo(request); - * - * // Check by name (latest version) - * const request = create(models.naming_v1.GetVerificationInfoRequestSchema, { name: "cisco.com/agent" }); - * const response = await client.getVerificationInfo(request); - * - * // Check by name with specific version - * const request = create(models.naming_v1.GetVerificationInfoRequestSchema, { name: "cisco.com/agent", version: "v1.0.0" }); - * const response = await client.getVerificationInfo(request); - * ``` - */ async getVerificationInfo( request: models.naming_v1.GetVerificationInfoRequest, ): Promise { - return await this.namingClient.getVerificationInfo(request); - } - - /** - * Sign a record using a private key. - * - * This private method handles key-based signing by passing the key reference - * directly to the dirctl command. The key can be a file path, URL, or KMS URI. - * - * @param cid - Content identifier of the record to sign - * @param req - SignWithKey request containing the private key reference - * @returns SignResponse containing the signature - * - * @throws {Error} If any error occurs during signing - * - * @private - */ - private __sign_with_key(cid: string, req: models.sign_v1.SignWithKey): SpawnSyncReturns { - // Prepare environment for command - // Always set COSIGN_PASSWORD (even if empty) to avoid terminal prompts - const shell_env = { ...env }; - shell_env['COSIGN_PASSWORD'] = req.password ? String(req.password) : ''; - if (this.config.dockerConfig) { - this.config.dockerConfig.envs.set("COSIGN_PASSWORD", shell_env["COSIGN_PASSWORD"]); - } - - // Pass key reference directly to dirctl which handles - // file paths, URLs, KMS URIs, etc. - const args = ["sign", cid, "--key", req.privateKey]; - const [command, commandArgs] = this.config.getCommandAndArgs(args); - - // Execute command - const output = spawnSync( - command, commandArgs, - { env: shell_env, encoding: 'utf8', stdio: 'pipe' }, - ); - - return output; - } - - /** - * Sign a record using OIDC-based authentication. - * - * This private method handles OIDC-based signing by building the appropriate - * dirctl command with OIDC parameters and executing it. - * - * @param cid - Content identifier of the record to sign - * @param req - SignWithOIDC request containing the OIDC configuration - * @returns SignResponse containing the signature - * - * @throws {Error} If any error occurs during signing - * - * @private - */ - private __sign_with_oidc( - cid: string, - req: models.sign_v1.SignWithOIDC, - ): SpawnSyncReturns { - // Prepare command - let args = ["sign", cid]; - if (req.idToken !== '') { - args.push(...["--oidc-token", req.idToken]); - } - if ( - req.options?.oidcProviderUrl !== undefined && - req.options.oidcProviderUrl !== '' - ) { - args.push(...["--oidc-provider-url", req.options.oidcProviderUrl]); - } - if (req.options?.oidcClientId !== undefined && req.options.oidcClientId !== '') { - args.push(...["--oidc-client-id", req.options.oidcClientId]); - } - if (req.options?.oidcClientSecret !== undefined && req.options.oidcClientSecret !== '') { - args.push(...["--oidc-client-secret", req.options.oidcClientSecret]); - } - if (req.options?.skipTlog !== undefined && req.options.skipTlog) { - args.push("--skip-tlog"); - } - if (req.options?.fulcioUrl !== undefined && req.options.fulcioUrl !== '') { - args.push(...["--fulcio-url", req.options.fulcioUrl]); - } - if (req.options?.rekorUrl !== undefined && req.options.rekorUrl !== '') { - args.push(...["--rekor-url", req.options.rekorUrl]); - } - if ( - req.options?.timestampUrl !== undefined && - req.options.timestampUrl !== '' - ) { - args.push(...["--timestamp-url", req.options.timestampUrl]); - } - - const [command, commandArgs] = this.config.getCommandAndArgs(args); - - // Execute command - let output = spawnSync(command, commandArgs, { - env: { ...env }, - encoding: 'utf8', - stdio: 'pipe', - }); - - return output; - } - - /** - * Verify a record using a public key. - * - * This private method handles key-based verification by passing the public key - * reference to the dirctl command. The key can be a file path, URL, or KMS URI. - * - * @param cid - Content identifier of the record to verify - * @param req - VerifyWithKey request containing the public key reference - * @param outputPath - Path to the output file for the verification result - * - * @throws {Error} If any error occurs during verification - * - * @private - */ - private __verify_with_key(cid: string, req: models.sign_v1.VerifyWithKey, outputPath: string): void { - // Pass key reference directly to dirctl which handles - // file paths, URLs, KMS URIs, etc. - const args = ["verify", cid, "--key", req.publicKey, "--output-file", outputPath]; - const [command, commandArgs] = this.config.getCommandAndArgs(args); - - // Execute command - let output = spawnSync( - command, commandArgs, - { env: { ...env }, encoding: 'utf8', stdio: 'pipe' }, - ); - - if (output.status !== 0) { - throw new Error(output.stderr || output.stdout || 'Verification failed'); - } - } - - /** - * Verify a record with any valid signature. - * - * This private method handles verification that accepts any valid signature, - * with optional OIDC verification options for additional constraints. - * - * @param cid - Content identifier of the record to verify - * @param req - VerifyWithAny request containing optional OIDC options, or undefined for default verification - * @param outputPath - Path to the output file for the verification result - * - * @throws {Error} If any error occurs during verification - * - * @private - */ - private __verify_with_any( - cid: string, - req: models.sign_v1.VerifyWithAny | undefined, - outputPath: string, - ): void { - // Prepare command - let args = ["verify", cid, "--output-file", outputPath]; - - // Add OIDC options if provided - if (req?.oidcOptions !== undefined) { - if (req.oidcOptions.tufMirrorUrl !== undefined && req.oidcOptions.tufMirrorUrl !== '') { - args.push(...["--tuf-mirror-url", req.oidcOptions.tufMirrorUrl]); - } - if (req.oidcOptions.trustedRootPath !== undefined && req.oidcOptions.trustedRootPath !== '') { - args.push(...["--trusted-root-path", req.oidcOptions.trustedRootPath]); - } - if (req.oidcOptions.ignoreTlog === true) { - args.push("--ignore-tlog"); - } - if (req.oidcOptions.ignoreTsa === true) { - args.push("--ignore-tsa"); - } - if (req.oidcOptions.ignoreSct === true) { - args.push("--ignore-sct"); - } - } - - const [command, commandArgs] = this.config.getCommandAndArgs(args); - - // Execute command - let output = spawnSync(command, commandArgs, { - env: { ...env }, - encoding: 'utf8', - stdio: 'pipe', - }); - - if (output.status !== 0) { - throw new Error(output.stderr || output.stdout || 'Verification failed'); - } - } - - /** - * Verify a record using OIDC-based verification. - * - * This private method handles OIDC-based verification by building the appropriate - * dirctl command with OIDC parameters and executing it. - * - * @param cid - Content identifier of the record to verify - * @param req - VerifyWithOIDC request containing the OIDC configuration, or undefined for default verification - * @param outputPath - Path to the output file for the verification result - * - * @throws {Error} If any error occurs during verification - * - * @private - */ - private __verify_with_oidc( - cid: string, - req: models.sign_v1.VerifyWithOIDC | undefined, - outputPath: string, - ): void { - // Prepare command - let args = ["verify", cid, "--output-file", outputPath]; - - // Add OIDC-specific parameters if provided - if (req !== undefined) { - if (req.issuer !== undefined && req.issuer !== '') { - args.push(...["--oidc-issuer", req.issuer]); - } - if (req.subject !== undefined && req.subject !== '') { - args.push(...["--oidc-subject", req.subject]); - } - - // Add verification options if present - if (req.options !== undefined) { - if (req.options.tufMirrorUrl !== undefined && req.options.tufMirrorUrl !== '') { - args.push(...["--tuf-mirror-url", req.options.tufMirrorUrl]); - } - if (req.options.trustedRootPath !== undefined && req.options.trustedRootPath !== '') { - args.push(...["--trusted-root-path", req.options.trustedRootPath]); - } - if (req.options.ignoreTlog === true) { - args.push("--ignore-tlog"); - } - if (req.options.ignoreTsa === true) { - args.push("--ignore-tsa"); - } - if (req.options.ignoreSct === true) { - args.push("--ignore-sct"); - } - } - } - - const [command, commandArgs] = this.config.getCommandAndArgs(args); - - // Execute command - let output = spawnSync(command, commandArgs, { - env: { ...env }, - encoding: 'utf8', - stdio: 'pipe', - }); - - if (output.status !== 0) { - throw new Error(output.stderr || output.stdout || 'Verification failed'); - } + return this.namingService.getVerificationInfo(request); } } diff --git a/src/client/config.ts b/src/client/config.ts new file mode 100644 index 0000000..daa03e2 --- /dev/null +++ b/src/client/config.ts @@ -0,0 +1,290 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import { existsSync } from 'node:fs'; +import { env } from 'node:process'; + +export type AuthMode = '' | 'x509' | 'jwt' | 'tls' | 'oidc'; + +function parseBoolEnv(value: string | undefined, defaultVal: boolean): boolean { + if (value === undefined || value === '') { + return defaultVal; + } + return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()); +} + +function parseIntEnv(value: string | undefined, defaultVal: number): number { + if (value === undefined || value === '') { + return defaultVal; + } + return Number.parseInt(value, 10); +} + +function parseFloatEnv(value: string | undefined, defaultVal: number): number { + if (value === undefined || value === '') { + return defaultVal; + } + return Number.parseFloat(value); +} + +function parseCommaScopes(value: string | undefined, defaultList: string[]): string[] { + if (value === undefined || value === '') { + return [...defaultList]; + } + return value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +export class DockerConfig { + static DEFAULT_DIRCTL_IMAGE = 'ghcr.io/agntcy/dir-ctl'; + static DEFAULT_DIRCTL_IMAGE_TAG = 'latest'; + + dirctlImage: string; + dirctlImageTag: string; + envs: Map; + mounts: string[]; + user: string; + + constructor( + dirctlImage: string, + dirctlImageTag: string, + envs: Map, + mounts: string[], + user: string, + ) { + this.dirctlImage = dirctlImage; + this.dirctlImageTag = dirctlImageTag; + this.envs = envs; + this.mounts = mounts; + this.user = user; + } + + getDockerArgs(): string[] { + this.pruneMounts(); + const commands = ['container', 'run', '--name=dir-ctl', '--rm', '--network', 'host']; + if (this.user) { + commands.push('--user'); + commands.push(this.user); + } + this.envs.forEach((value: string, key: string) => { + commands.push('--env'); + commands.push(`${key}=${value}`); + }); + this.mounts.forEach((value: string) => { + commands.push('--mount'); + commands.push(value); + }); + commands.push(`${this.dirctlImage}:${this.dirctlImageTag}`); + return commands; + } + + pruneMounts(): void { + this.mounts = this.mounts.filter((mount: string) => { + if (mount.startsWith('type=bind')) { + const parts = mount.split(','); + const srcPart = parts.find((p) => p.startsWith('src=')); + if (srcPart === undefined) { + return false; + } + const _src = srcPart.split('=')[1]; + return existsSync(_src); + } + return false; + }); + } +} + +/** + * Configuration class for the AGNTCY Directory client. + */ +export class Config { + static DEFAULT_SERVER_ADDRESS = '127.0.0.1:8888'; + static DEFAULT_DIRCTL_PATH = 'dirctl'; + static DEFAULT_SPIFFE_ENDPOINT_SOCKET = ''; + static DEFAULT_AUTH_MODE = ''; + static DEFAULT_AUTH_TOKEN = ''; + static DEFAULT_JWT_AUDIENCE = ''; + static DEFAULT_TLS_CA_FILE = ''; + static DEFAULT_TLS_CERT_FILE = ''; + static DEFAULT_TLS_KEY_FILE = ''; + static DEFAULT_TLS_SERVER_NAME = ''; + static DEFAULT_TLS_SKIP_VERIFY = false; + static DEFAULT_OIDC_ISSUER = ''; + static DEFAULT_OIDC_CLIENT_ID = ''; + static DEFAULT_OIDC_CLIENT_SECRET = ''; + static DEFAULT_OIDC_REDIRECT_URI = 'http://localhost:8484/callback'; + static DEFAULT_OIDC_CALLBACK_PORT = 8484; + static DEFAULT_OIDC_AUTH_TIMEOUT = 300; + static DEFAULT_OIDC_SCOPES = ['openid', 'profile', 'email']; + + serverAddress: string; + dirctlPath: string; + spiffeEndpointSocket: string; + authMode: AuthMode; + authToken: string; + /** Backward-compatible alias for `authToken`. */ + oidcAccessToken: string; + jwtAudience: string; + tlsCaFile: string; + tlsCertFile: string; + tlsKeyFile: string; + tlsServerName: string; + tlsSkipVerify: boolean; + oidcIssuer: string; + oidcClientId: string; + oidcClientSecret: string; + oidcRedirectUri: string; + oidcCallbackPort: number; + oidcAuthTimeout: number; + oidcScopes: string[]; + dockerConfig: DockerConfig | undefined; + + constructor( + serverAddress = Config.DEFAULT_SERVER_ADDRESS, + dirctlPath = Config.DEFAULT_DIRCTL_PATH, + spiffeEndpointSocket = Config.DEFAULT_SPIFFE_ENDPOINT_SOCKET, + authMode: AuthMode = Config.DEFAULT_AUTH_MODE as AuthMode, + jwtAudience = Config.DEFAULT_JWT_AUDIENCE, + tlsCaFile = Config.DEFAULT_TLS_CA_FILE, + tlsCertFile = Config.DEFAULT_TLS_CERT_FILE, + tlsKeyFile = Config.DEFAULT_TLS_KEY_FILE, + authToken = Config.DEFAULT_AUTH_TOKEN, + tlsServerName = Config.DEFAULT_TLS_SERVER_NAME, + tlsSkipVerify = Config.DEFAULT_TLS_SKIP_VERIFY, + oidcIssuer = Config.DEFAULT_OIDC_ISSUER, + oidcClientId = Config.DEFAULT_OIDC_CLIENT_ID, + oidcClientSecret = Config.DEFAULT_OIDC_CLIENT_SECRET, + oidcRedirectUri = Config.DEFAULT_OIDC_REDIRECT_URI, + oidcCallbackPort = Config.DEFAULT_OIDC_CALLBACK_PORT, + oidcAuthTimeout = Config.DEFAULT_OIDC_AUTH_TIMEOUT, + oidcScopes: string[] | undefined = undefined, + oidcAccessToken: string | undefined = undefined, + dockerConfig: DockerConfig | undefined = undefined, + ) { + const resolvedAuthToken = authToken || oidcAccessToken || ''; + + if ( + !serverAddress.startsWith('http://') && + !serverAddress.startsWith('https://') + ) { + if ( + authMode === 'x509' || + authMode === 'jwt' || + authMode === 'tls' || + authMode === 'oidc' + ) { + serverAddress = `https://${serverAddress}`; + } else { + serverAddress = `http://${serverAddress}`; + } + } + + this.serverAddress = serverAddress; + this.dirctlPath = dirctlPath; + this.spiffeEndpointSocket = spiffeEndpointSocket; + this.authMode = authMode; + this.authToken = resolvedAuthToken; + this.oidcAccessToken = resolvedAuthToken; + this.jwtAudience = jwtAudience; + this.tlsCaFile = tlsCaFile; + this.tlsCertFile = tlsCertFile; + this.tlsKeyFile = tlsKeyFile; + this.tlsServerName = tlsServerName; + this.tlsSkipVerify = tlsSkipVerify; + this.oidcIssuer = oidcIssuer; + this.oidcClientId = oidcClientId; + this.oidcClientSecret = oidcClientSecret; + this.oidcRedirectUri = oidcRedirectUri; + this.oidcCallbackPort = oidcCallbackPort; + this.oidcAuthTimeout = oidcAuthTimeout; + this.oidcScopes = + oidcScopes !== undefined ? [...oidcScopes] : [...Config.DEFAULT_OIDC_SCOPES]; + this.dockerConfig = dockerConfig; + } + + static loadFromEnv(prefix = 'DIRECTORY_CLIENT_') { + const dirctlPath = env['DIRCTL_PATH'] || Config.DEFAULT_DIRCTL_PATH; + + const serverAddress = + env[`${prefix}SERVER_ADDRESS`] || Config.DEFAULT_SERVER_ADDRESS; + const spiffeEndpointSocketPath = + env[`${prefix}SPIFFE_SOCKET_PATH`] || Config.DEFAULT_SPIFFE_ENDPOINT_SOCKET; + const authMode = (env[`${prefix}AUTH_MODE`] || Config.DEFAULT_AUTH_MODE) as AuthMode; + const authToken = env[`${prefix}AUTH_TOKEN`] || Config.DEFAULT_AUTH_TOKEN; + const jwtAudience = env[`${prefix}JWT_AUDIENCE`] || Config.DEFAULT_JWT_AUDIENCE; + const tlsCaFile = env[`${prefix}TLS_CA_FILE`] || Config.DEFAULT_TLS_CA_FILE; + const tlsCertFile = env[`${prefix}TLS_CERT_FILE`] || Config.DEFAULT_TLS_CERT_FILE; + const tlsKeyFile = env[`${prefix}TLS_KEY_FILE`] || Config.DEFAULT_TLS_KEY_FILE; + const tlsServerName = + env[`${prefix}TLS_SERVER_NAME`] || Config.DEFAULT_TLS_SERVER_NAME; + const tlsSkipVerify = parseBoolEnv( + env[`${prefix}TLS_SKIP_VERIFY`], + Config.DEFAULT_TLS_SKIP_VERIFY, + ); + const oidcIssuer = env[`${prefix}OIDC_ISSUER`] || Config.DEFAULT_OIDC_ISSUER; + const oidcClientId = + env[`${prefix}OIDC_CLIENT_ID`] || Config.DEFAULT_OIDC_CLIENT_ID; + const oidcClientSecret = + env[`${prefix}OIDC_CLIENT_SECRET`] || Config.DEFAULT_OIDC_CLIENT_SECRET; + const oidcRedirectUri = + env[`${prefix}OIDC_REDIRECT_URI`] || Config.DEFAULT_OIDC_REDIRECT_URI; + const oidcCallbackPort = parseIntEnv( + env[`${prefix}OIDC_CALLBACK_PORT`], + Config.DEFAULT_OIDC_CALLBACK_PORT, + ); + const oidcAuthTimeout = parseFloatEnv( + env[`${prefix}OIDC_AUTH_TIMEOUT`], + Config.DEFAULT_OIDC_AUTH_TIMEOUT, + ); + const oidcScopes = parseCommaScopes( + env[`${prefix}OIDC_SCOPES`], + Config.DEFAULT_OIDC_SCOPES, + ); + + let dockerConfig: DockerConfig | undefined = undefined; + const dirctlImage = env['DIRCTL_IMAGE']; + const dirctlImageTag = env['DIRCTL_IMAGE_TAG']; + if (dirctlImage || dirctlImageTag) { + dockerConfig = new DockerConfig( + dirctlImage || DockerConfig.DEFAULT_DIRCTL_IMAGE, + dirctlImageTag || DockerConfig.DEFAULT_DIRCTL_IMAGE_TAG, + new Map(), + [], + '0:0', + ); + } + + return new Config( + serverAddress, + dirctlPath, + spiffeEndpointSocketPath, + authMode, + jwtAudience, + tlsCaFile, + tlsCertFile, + tlsKeyFile, + authToken, + tlsServerName, + tlsSkipVerify, + oidcIssuer, + oidcClientId, + oidcClientSecret, + oidcRedirectUri, + oidcCallbackPort, + oidcAuthTimeout, + oidcScopes, + undefined, + dockerConfig, + ); + } + + getCommandAndArgs(args: string[]): [string, string[]] { + if (this.dockerConfig) { + const dockerArgs = this.dockerConfig.getDockerArgs(); + return ['docker', dockerArgs.concat(args)]; + } + return [`${this.dirctlPath}`, args]; + } +} diff --git a/src/client/dirctl/index.ts b/src/client/dirctl/index.ts new file mode 100644 index 0000000..a8e01fd --- /dev/null +++ b/src/client/dirctl/index.ts @@ -0,0 +1,6 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +export { buildDirctlBaseCommand, getDirctlCommandAndArgs, runDirctl } from './runner.js'; +export { signRecord, signWithKey, signWithOidc } from './signing.js'; +export { verifyRecord, verifyWithAny, verifyWithKey, verifyWithOidc } from './verification.js'; diff --git a/src/client/dirctl/runner.ts b/src/client/dirctl/runner.ts new file mode 100644 index 0000000..83c4304 --- /dev/null +++ b/src/client/dirctl/runner.ts @@ -0,0 +1,67 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; +import { env } from 'node:process'; + +import { Config, DockerConfig } from '../config.js'; + +function copyDockerConfig(dockerConfig: DockerConfig): DockerConfig { + return new DockerConfig( + dockerConfig.dirctlImage, + dockerConfig.dirctlImageTag, + new Map(dockerConfig.envs), + [...dockerConfig.mounts], + dockerConfig.user, + ); +} + +export function buildDirctlBaseCommand( + config: Config, + shellEnv?: Record, + extraMounts?: string[], +): string[] { + const extra = extraMounts ?? []; + if (config.dirctlPath) { + return [config.dirctlPath]; + } + if (config.dockerConfig === undefined) { + throw new Error('Either dirctlPath or dockerConfig must be configured'); + } + + const dockerConfig = copyDockerConfig(config.dockerConfig); + if (shellEnv) { + for (const [key, value] of Object.entries(shellEnv)) { + dockerConfig.envs.set(key, value); + } + } + dockerConfig.mounts.push(...extra); + return dockerConfig.getDockerArgs(); +} + +export function runDirctl( + config: Config, + args: string[], + options?: { + env?: Record; + extraMounts?: string[]; + }, +): SpawnSyncReturns { + const base = buildDirctlBaseCommand(config, options?.env, options?.extraMounts); + const command = base[0]; + const commandArgs = [...base.slice(1), ...args]; + const shellEnv = { ...env, ...options?.env }; + + return spawnSync(command, commandArgs, { + env: shellEnv, + encoding: 'utf8', + stdio: 'pipe', + }); +} + +export function getDirctlCommandAndArgs( + config: Config, + args: string[], +): [string, string[]] { + return config.getCommandAndArgs(args); +} diff --git a/src/client/dirctl/signing.ts b/src/client/dirctl/signing.ts new file mode 100644 index 0000000..6337a0c --- /dev/null +++ b/src/client/dirctl/signing.ts @@ -0,0 +1,99 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; +import { env } from 'node:process'; + +import type { Config } from '../config.js'; +import * as models from '../../models/index.js'; + +export function signWithKey( + config: Config, + cid: string, + req: models.sign_v1.SignWithKey, +): SpawnSyncReturns { + // NOTE: dirctl appends :80 or :443 to server address if has http or https:// prefix + const dirctlServerAddress = config.serverAddress.replace(/^https?:\/\//, ''); + + const shell_env = { ...env }; + shell_env['COSIGN_PASSWORD'] = req.password ? String(req.password) : ''; + if (config.dockerConfig) { + config.dockerConfig.envs.set('COSIGN_PASSWORD', shell_env['COSIGN_PASSWORD']); + config.dockerConfig.envs.set('DIRECTORY_CLIENT_SERVER_ADDRESS', dirctlServerAddress); + } + + const args = ['sign', cid, '--key', req.privateKey]; + const [command, commandArgs] = config.getCommandAndArgs(args); + + return spawnSync(command, commandArgs, { + env: { ...env, 'DIRECTORY_CLIENT_SERVER_ADDRESS': dirctlServerAddress }, + encoding: 'utf8', + stdio: 'pipe', + }); +} + +export function signWithOidc( + config: Config, + cid: string, + req: models.sign_v1.SignWithOIDC, +): SpawnSyncReturns { + const args = ['sign', cid]; + if (req.idToken !== '') { + args.push(...['--oidc-token', req.idToken]); + } + if ( + req.options?.oidcProviderUrl !== undefined && + req.options.oidcProviderUrl !== '' + ) { + args.push(...['--oidc-provider-url', req.options.oidcProviderUrl]); + } + if (req.options?.oidcClientId !== undefined && req.options.oidcClientId !== '') { + args.push(...['--oidc-client-id', req.options.oidcClientId]); + } + if (req.options?.oidcClientSecret !== undefined && req.options.oidcClientSecret !== '') { + args.push(...['--oidc-client-secret', req.options.oidcClientSecret]); + } + if (req.options?.skipTlog !== undefined && req.options.skipTlog) { + args.push('--skip-tlog'); + } + if (req.options?.fulcioUrl !== undefined && req.options.fulcioUrl !== '') { + args.push(...['--fulcio-url', req.options.fulcioUrl]); + } + if (req.options?.rekorUrl !== undefined && req.options.rekorUrl !== '') { + args.push(...['--rekor-url', req.options.rekorUrl]); + } + if (req.options?.timestampUrl !== undefined && req.options.timestampUrl !== '') { + args.push(...['--timestamp-url', req.options.timestampUrl]); + } + + const [command, commandArgs] = config.getCommandAndArgs(args); + + // NOTE: dirctl appends :80 or :443 to server address if has http or https:// prefix + const dirctlServerAddress = config.serverAddress.replace(/^https?:\/\//, ''); + + return spawnSync(command, commandArgs, { + env: { ...env, 'DIRECTORY_CLIENT_SERVER_ADDRESS': dirctlServerAddress }, + encoding: 'utf8', + stdio: 'pipe', + }); +} + +export function signRecord(config: Config, req: models.sign_v1.SignRequest): void { + const cid = req.recordRef?.cid || ''; + let output: SpawnSyncReturns; + + switch (req.provider?.request.case) { + case 'oidc': + output = signWithOidc(config, cid, req.provider.request.value); + break; + case 'key': + output = signWithKey(config, cid, req.provider.request.value); + break; + default: + throw new Error('unsupported provider was supplied'); + } + + if (output.status !== 0) { + throw output.error || output.stderr; + } +} diff --git a/src/client/dirctl/verification.ts b/src/client/dirctl/verification.ts new file mode 100644 index 0000000..ede5fa6 --- /dev/null +++ b/src/client/dirctl/verification.ts @@ -0,0 +1,155 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import { closeSync, mkdtempSync, openSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { env } from 'node:process'; + +import { fromJsonString } from '@bufbuild/protobuf'; + +import type { Config } from '../config.js'; +import * as models from '../../models/index.js'; + +function runVerifyCommand( + config: Config, + args: string[], +): void { + const [command, commandArgs] = config.getCommandAndArgs(args); + const output = spawnSync(command, commandArgs, { + env: { ...env, 'DIRECTORY_CLIENT_SERVER_ADDRESS': config.serverAddress }, + encoding: 'utf8', + stdio: 'pipe', + }); + + if (output.status !== 0) { + throw new Error(output.stderr || output.stdout || 'Verification failed'); + } +} + +export function verifyWithKey( + config: Config, + cid: string, + req: models.sign_v1.VerifyWithKey, + outputPath: string, +): void { + const args = ['verify', cid, '--key', req.publicKey, '--output-file', outputPath]; + runVerifyCommand(config, args); +} + +export function verifyWithAny( + config: Config, + cid: string, + req: models.sign_v1.VerifyWithAny | undefined, + outputPath: string, +): void { + const args = ['verify', cid, '--output-file', outputPath]; + + if (req?.oidcOptions !== undefined) { + if (req.oidcOptions.tufMirrorUrl !== undefined && req.oidcOptions.tufMirrorUrl !== '') { + args.push(...['--tuf-mirror-url', req.oidcOptions.tufMirrorUrl]); + } + if (req.oidcOptions.trustedRootPath !== undefined && req.oidcOptions.trustedRootPath !== '') { + args.push(...['--trusted-root-path', req.oidcOptions.trustedRootPath]); + } + if (req.oidcOptions.ignoreTlog === true) { + args.push('--ignore-tlog'); + } + if (req.oidcOptions.ignoreTsa === true) { + args.push('--ignore-tsa'); + } + if (req.oidcOptions.ignoreSct === true) { + args.push('--ignore-sct'); + } + } + + runVerifyCommand(config, args); +} + +export function verifyWithOidc( + config: Config, + cid: string, + req: models.sign_v1.VerifyWithOIDC | undefined, + outputPath: string, +): void { + const args = ['verify', cid, '--output-file', outputPath]; + + if (req !== undefined) { + if (req.issuer !== undefined && req.issuer !== '') { + args.push(...['--oidc-issuer', req.issuer]); + } + if (req.subject !== undefined && req.subject !== '') { + args.push(...['--oidc-subject', req.subject]); + } + + if (req.options !== undefined) { + if (req.options.tufMirrorUrl !== undefined && req.options.tufMirrorUrl !== '') { + args.push(...['--tuf-mirror-url', req.options.tufMirrorUrl]); + } + if (req.options.trustedRootPath !== undefined && req.options.trustedRootPath !== '') { + args.push(...['--trusted-root-path', req.options.trustedRootPath]); + } + if (req.options.ignoreTlog === true) { + args.push('--ignore-tlog'); + } + if (req.options.ignoreTsa === true) { + args.push('--ignore-tsa'); + } + if (req.options.ignoreSct === true) { + args.push('--ignore-sct'); + } + } + } + + runVerifyCommand(config, args); +} + +export function verifyRecord( + config: Config, + request: models.sign_v1.VerifyRequest, +): models.sign_v1.VerifyResponse { + const tempDir = mkdtempSync(join(tmpdir(), 'dirctl-verify-')); + const outputPath = join(tempDir, 'output.json'); + closeSync(openSync(outputPath, 'w')); + let resolvedOutputPath = outputPath; + + if (config.dockerConfig) { + resolvedOutputPath = outputPath.split('/').reverse()[0]; + config.dockerConfig.mounts.push(`type=bind,src=${outputPath},dst=/${resolvedOutputPath}`); + } + + try { + const cid = request.recordRef?.cid || ''; + + switch (request.provider?.request.case) { + case 'oidc': + verifyWithOidc(config, cid, request.provider.request.value, resolvedOutputPath); + break; + case 'key': + verifyWithKey(config, cid, request.provider.request.value, resolvedOutputPath); + break; + case 'any': + verifyWithAny(config, cid, request.provider.request.value, resolvedOutputPath); + break; + default: + throw new Error('unsupported provider was supplied'); + } + + if (!existsSync(outputPath)) { + throw new Error('verification output file was not created'); + } + + const jsonContent = readFileSync(outputPath, 'utf8'); + + return fromJsonString(models.sign_v1.VerifyResponseSchema, jsonContent); + } catch (e) { + throw new Error(`Failed to parse verification response: ${e}`); + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } +} diff --git a/src/client/index.ts b/src/client/index.ts index e85cba1..6c0be41 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,6 @@ // Copyright AGNTCY Contributors (https://github.com/agntcy) // SPDX-License-Identifier: Apache-2.0 -export * from './client'; +export { Client } from './client.js'; +export { Config, type AuthMode } from './config.js'; +export { OAuthPkceError } from './auth/oauthPkce.js'; diff --git a/src/client/services/base.ts b/src/client/services/base.ts new file mode 100644 index 0000000..9f73b1b --- /dev/null +++ b/src/client/services/base.ts @@ -0,0 +1,25 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +export async function* requestGenerator(reqs: T[]): AsyncIterable { + for (const req of reqs) { + yield req; + } +} + +export async function collectStream(stream: AsyncIterable): Promise { + const results: T[] = []; + for await (const item of stream) { + results.push(item); + } + return results; +} + +export async function invoke(opName: string, call: () => Promise): Promise { + try { + return await call(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`${opName} failed: ${msg}`); + } +} diff --git a/src/client/services/events.ts b/src/client/services/events.ts new file mode 100644 index 0000000..8c87c4b --- /dev/null +++ b/src/client/services/events.ts @@ -0,0 +1,18 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import type { Client } from '@connectrpc/connect'; + +import * as models from '../../models/index.js'; + +export class EventService { + constructor( + private readonly eventClient: Client, + ) {} + + listen( + request: models.events_v1.ListenRequest, + ): AsyncIterable { + return this.eventClient.listen(request); + } +} diff --git a/src/client/services/index.ts b/src/client/services/index.ts new file mode 100644 index 0000000..92f3ad4 --- /dev/null +++ b/src/client/services/index.ts @@ -0,0 +1,12 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +export { collectStream, invoke, requestGenerator } from './base.js'; +export { StoreService } from './store.js'; +export { RoutingService } from './routing.js'; +export { SearchService } from './search.js'; +export { PublicationService } from './publication.js'; +export { SyncService } from './sync.js'; +export { EventService } from './events.js'; +export { NamingService } from './naming.js'; +export { SignService } from './signing.js'; diff --git a/src/client/services/naming.ts b/src/client/services/naming.ts new file mode 100644 index 0000000..227f9a5 --- /dev/null +++ b/src/client/services/naming.ts @@ -0,0 +1,24 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import type { Client } from '@connectrpc/connect'; + +import * as models from '../../models/index.js'; + +export class NamingService { + constructor( + private readonly namingClient: Client, + ) {} + + async resolve( + request: models.naming_v1.ResolveRequest, + ): Promise { + return await this.namingClient.resolve(request); + } + + async getVerificationInfo( + request: models.naming_v1.GetVerificationInfoRequest, + ): Promise { + return await this.namingClient.getVerificationInfo(request); + } +} diff --git a/src/client/services/publication.ts b/src/client/services/publication.ts new file mode 100644 index 0000000..7f97e8e --- /dev/null +++ b/src/client/services/publication.ts @@ -0,0 +1,33 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import type { Client } from '@connectrpc/connect'; + +import * as models from '../../models/index.js'; +import { collectStream } from './base.js'; + +export class PublicationService { + constructor( + private readonly publicationClient: Client< + typeof models.routing_v1.PublicationService + >, + ) {} + + async create_publication( + request: models.routing_v1.PublishRequest, + ): Promise { + return await this.publicationClient.createPublication(request); + } + + async list_publication( + request: models.routing_v1.ListPublicationsRequest, + ): Promise { + return collectStream(this.publicationClient.listPublications(request)); + } + + async get_publication( + request: models.routing_v1.GetPublicationRequest, + ): Promise { + return await this.publicationClient.getPublication(request); + } +} diff --git a/src/client/services/routing.ts b/src/client/services/routing.ts new file mode 100644 index 0000000..6ad536d --- /dev/null +++ b/src/client/services/routing.ts @@ -0,0 +1,27 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import type { Client } from '@connectrpc/connect'; + +import * as models from '../../models/index.js'; +import { collectStream } from './base.js'; + +export class RoutingService { + constructor( + private readonly routingClient: Client, + ) {} + + async publish(request: models.routing_v1.PublishRequest): Promise { + await this.routingClient.publish(request); + } + + async list( + request: models.routing_v1.ListRequest, + ): Promise { + return collectStream(this.routingClient.list(request)); + } + + async unpublish(request: models.routing_v1.UnpublishRequest): Promise { + await this.routingClient.unpublish(request); + } +} diff --git a/src/client/services/search.ts b/src/client/services/search.ts new file mode 100644 index 0000000..5cce8f1 --- /dev/null +++ b/src/client/services/search.ts @@ -0,0 +1,25 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import type { Client } from '@connectrpc/connect'; + +import * as models from '../../models/index.js'; +import { collectStream } from './base.js'; + +export class SearchService { + constructor( + private readonly searchClient: Client, + ) {} + + async searchCIDs( + request: models.search_v1.SearchCIDsRequest, + ): Promise { + return collectStream(this.searchClient.searchCIDs(request)); + } + + async searchRecords( + request: models.search_v1.SearchRecordsRequest, + ): Promise { + return collectStream(this.searchClient.searchRecords(request)); + } +} diff --git a/src/client/services/signing.ts b/src/client/services/signing.ts new file mode 100644 index 0000000..d43306e --- /dev/null +++ b/src/client/services/signing.ts @@ -0,0 +1,32 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import type { Client } from '@connectrpc/connect'; + +import type { Config } from '../config.js'; +import { signRecord } from '../dirctl/signing.js'; +import { verifyRecord } from '../dirctl/verification.js'; +import * as models from '../../models/index.js'; + +export class SignService { + constructor( + private readonly config: Config, + private readonly signClient: Client, + ) {} + + sign(req: models.sign_v1.SignRequest): void { + signRecord(this.config, req); + } + + async verify( + request: models.sign_v1.VerifyRequest, + ): Promise { + if (request.fromServer) { + if (!request.recordRef?.cid) { + throw new Error('VerifyRequest.recordRef with cid is required'); + } + return await this.signClient.verify(request); + } + return verifyRecord(this.config, request); + } +} diff --git a/src/client/services/store.ts b/src/client/services/store.ts new file mode 100644 index 0000000..acd5dc0 --- /dev/null +++ b/src/client/services/store.ts @@ -0,0 +1,39 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import type { Client } from '@connectrpc/connect'; + +import * as models from '../../models/index.js'; +import { collectStream, requestGenerator } from './base.js'; + +export class StoreService { + constructor(private readonly storeClient: Client) {} + + async push(records: models.core_v1.Record[]): Promise { + return collectStream(this.storeClient.push(requestGenerator(records))); + } + + async push_referrer( + requests: models.store_v1.PushReferrerRequest[], + ): Promise { + return collectStream(this.storeClient.pushReferrer(requestGenerator(requests))); + } + + async pull(refs: models.core_v1.RecordRef[]): Promise { + return collectStream(this.storeClient.pull(requestGenerator(refs))); + } + + async pull_referrer( + requests: models.store_v1.PullReferrerRequest[], + ): Promise { + return collectStream(this.storeClient.pullReferrer(requestGenerator(requests))); + } + + async lookup(refs: models.core_v1.RecordRef[]): Promise { + return collectStream(this.storeClient.lookup(requestGenerator(refs))); + } + + async delete(refs: models.core_v1.RecordRef[]): Promise { + await this.storeClient.delete(requestGenerator(refs)); + } +} diff --git a/src/client/services/sync.ts b/src/client/services/sync.ts new file mode 100644 index 0000000..df270b5 --- /dev/null +++ b/src/client/services/sync.ts @@ -0,0 +1,35 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import type { Client } from '@connectrpc/connect'; + +import * as models from '../../models/index.js'; +import { collectStream } from './base.js'; + +export class SyncService { + constructor(private readonly syncClient: Client) {} + + async create_sync( + request: models.store_v1.CreateSyncRequest, + ): Promise { + return await this.syncClient.createSync(request); + } + + async list_syncs( + request: models.store_v1.ListSyncsRequest, + ): Promise { + return collectStream(this.syncClient.listSyncs(request)); + } + + async get_sync( + request: models.store_v1.GetSyncRequest, + ): Promise { + return await this.syncClient.getSync(request); + } + + async delete_sync( + request: models.store_v1.DeleteSyncRequest, + ): Promise { + return await this.syncClient.deleteSync(request); + } +} diff --git a/src/client/transport/channels.ts b/src/client/transport/channels.ts new file mode 100644 index 0000000..b59734b --- /dev/null +++ b/src/client/transport/channels.ts @@ -0,0 +1,192 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from 'node:fs'; +import type * as http2 from 'node:http2'; + +import type { Transport } from '@connectrpc/connect'; +import { createGrpcTransport as createConnectGrpcTransport } from '@connectrpc/connect-node'; +import { createClient as createClientSpiffe, type X509SVID } from 'spiffe'; + +import type { Config } from '../config.js'; +import type { OAuthTokenHolder } from '../auth/oauthPkce.js'; +import { createBearerAuthInterceptor, createJwtAuthInterceptor } from './interceptors.js'; + +function convertToPEM(bytes: Uint8Array, label: string): string { + let binary = ''; + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + const base64String = btoa(binary); + const lines = base64String.match(/.{1,64}/g) || []; + return [`-----BEGIN ${label}-----`, ...lines, `-----END ${label}-----`].join('\n'); +} + +function secureNodeOptions( + config: Config, + base: http2.SecureClientSessionOptions, +): http2.SecureClientSessionOptions { + const out: http2.SecureClientSessionOptions = { ...base }; + const sn = config.tlsServerName.trim(); + if (sn !== '') { + out.servername = sn; + } + return out; +} + +export function createOidcTransport(config: Config, holder: OAuthTokenHolder): Transport { + let ca: string | undefined; + if (config.tlsCaFile !== '') { + try { + ca = readFileSync(config.tlsCaFile).toString(); + } catch (e) { + throw new Error(`Failed to read TLS CA file: ${(e as Error).message}`); + } + } + const nodeBase: http2.SecureClientSessionOptions = {}; + if (ca !== undefined) { + nodeBase.ca = ca; + } + return createConnectGrpcTransport({ + baseUrl: config.serverAddress, + interceptors: [createBearerAuthInterceptor(holder)], + nodeOptions: secureNodeOptions(config, nodeBase), + }); +} + +async function createX509Transport(config: Config): Promise { + if (config.spiffeEndpointSocket === '') { + throw new Error('SPIFFE socket path is required for X.509 authentication'); + } + + const client = createClientSpiffe(config.spiffeEndpointSocket); + + let svid: X509SVID = { + spiffeId: '', + hint: '', + x509Svid: new Uint8Array(), + x509SvidKey: new Uint8Array(), + bundle: new Uint8Array(), + }; + + const svidStream = client.fetchX509SVID({}); + for await (const message of svidStream.responses) { + message.svids.forEach((_svid) => { + svid = _svid; + }); + + if (message.svids.length > 0) { + break; + } + } + + return createConnectGrpcTransport({ + baseUrl: config.serverAddress, + nodeOptions: secureNodeOptions(config, { + ca: convertToPEM(svid.bundle, 'TRUSTED CERTIFICATE'), + cert: convertToPEM(svid.x509Svid, 'CERTIFICATE'), + key: convertToPEM(svid.x509SvidKey, 'PRIVATE KEY'), + }), + }); +} + +async function createJWTTransport(config: Config): Promise { + if (config.spiffeEndpointSocket === '') { + throw new Error('SPIFFE socket path is required for JWT authentication'); + } + + const client = createClientSpiffe(config.spiffeEndpointSocket); + + let bundle: Uint8Array | null = null; + const bundleStream = client.fetchX509Bundles({}); + for await (const message of bundleStream.responses) { + for (const [, bundleData] of Object.entries(message.bundles)) { + bundle = new Uint8Array(bundleData); + break; + } + if (bundle !== null) { + break; + } + } + + if (bundle === null || bundle.length === 0) { + throw new Error('Failed to fetch X.509 bundle from SPIRE: no bundles returned'); + } + + return createConnectGrpcTransport({ + baseUrl: config.serverAddress, + interceptors: [createJwtAuthInterceptor(config)], + nodeOptions: secureNodeOptions(config, { + ca: convertToPEM(bundle, 'CERTIFICATE'), + }), + }); +} + +async function createTLSTransport(config: Config): Promise { + if (config.tlsCaFile === '') { + throw new Error('TLS CA file is required for TLS authentication'); + } + if (config.tlsCertFile === '') { + throw new Error('TLS certificate file is required for TLS authentication'); + } + if (config.tlsKeyFile === '') { + throw new Error('TLS key file is required for TLS authentication'); + } + + let root_ca: string; + let cert_chain: string; + let private_key: string; + + try { + root_ca = readFileSync(config.tlsCaFile).toString(); + cert_chain = readFileSync(config.tlsCertFile).toString(); + private_key = readFileSync(config.tlsKeyFile).toString(); + } catch (e) { + console.error('Error reading file:', (e as Error).message); + throw e; + } + + return createConnectGrpcTransport({ + baseUrl: config.serverAddress, + nodeOptions: secureNodeOptions(config, { + ca: root_ca, + cert: cert_chain, + key: private_key, + }), + }); +} + +export async function createGrpcTransport( + config: Config, + options?: { oidcTokenHolder?: OAuthTokenHolder }, +): Promise { + switch (config.authMode) { + case '': + return createConnectGrpcTransport({ + baseUrl: config.serverAddress, + }); + + case 'jwt': + return await createJWTTransport(config); + + case 'x509': + return await createX509Transport(config); + + case 'tls': + return await createTLSTransport(config); + + case 'oidc': { + const holder = options?.oidcTokenHolder; + if (holder === undefined) { + throw new Error( + 'createGrpcTransport: authMode oidc requires options.oidcTokenHolder', + ); + } + return createOidcTransport(config, holder); + } + + default: + throw new Error(`Unsupported auth mode: ${config.authMode}`); + } +} diff --git a/src/client/transport/index.ts b/src/client/transport/index.ts new file mode 100644 index 0000000..52f7245 --- /dev/null +++ b/src/client/transport/index.ts @@ -0,0 +1,5 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +export { createGrpcTransport, createOidcTransport } from './channels.js'; +export { createBearerAuthInterceptor, createJwtAuthInterceptor } from './interceptors.js'; diff --git a/src/client/transport/interceptors.ts b/src/client/transport/interceptors.ts new file mode 100644 index 0000000..b2fa983 --- /dev/null +++ b/src/client/transport/interceptors.ts @@ -0,0 +1,44 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +import type { Interceptor } from '@connectrpc/connect'; +import { createClient as createClientSpiffe } from 'spiffe'; + +import type { Config } from '../config.js'; +import type { OAuthTokenHolder } from '../auth/oauthPkce.js'; + +export function createBearerAuthInterceptor(holder: OAuthTokenHolder): Interceptor { + return (next) => async (req) => { + req.header.set('authorization', `Bearer ${holder.getAccessToken()}`); + return await next(req); + }; +} + +export function createJwtAuthInterceptor(config: Config): Interceptor { + if (config.spiffeEndpointSocket === '') { + throw new Error('SPIFFE socket path is required for JWT authentication'); + } + if (config.jwtAudience === '') { + throw new Error('JWT audience is required for JWT authentication'); + } + + const client = createClientSpiffe(config.spiffeEndpointSocket); + + return (next) => async (req) => { + const jwtCall = client.fetchJWTSVID({ + spiffeId: '', + audience: [config.jwtAudience], + }); + + const response = await jwtCall.response; + + if (!response.svids || response.svids.length === 0) { + throw new Error('Failed to fetch JWT-SVID from SPIRE: no SVIDs returned'); + } + + const jwtToken = response.svids[0].svid; + req.header.set('authorization', `Bearer ${jwtToken}`); + + return await next(req); + }; +} diff --git a/src/index.ts b/src/index.ts index 41226f9..cd7fc06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ // Copyright AGNTCY Contributors (https://github.com/agntcy) // SPDX-License-Identifier: Apache-2.0 -export * from './client'; +export { Client, Config, OAuthPkceError, type AuthMode } from './client/index.js'; export * as models from './models'; diff --git a/test/client.test.ts b/test/client.test.ts index 55d5f12..2a057d2 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -407,6 +407,13 @@ describe('Client', () => { const response = await client.verify( create(models.sign_v1.VerifyRequestSchema, { recordRef: ref, + provider: { + request: { + case: 'any', + value: { + }, + }, + }, }), ); diff --git a/test/oidc-auth.test.ts b/test/oidc-auth.test.ts index ecc2a36..9df2e17 100644 --- a/test/oidc-auth.test.ts +++ b/test/oidc-auth.test.ts @@ -8,8 +8,9 @@ import { env } from 'node:process'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { Client, Config, TokenCache, TOKEN_CACHE_FILE } from '../src/client/client.js'; -import * as oauthPkce from '../src/client/oauthPkce.js'; +import { Client, Config } from '../src/index.js'; +import * as oauthPkce from '../src/client/auth/oauthPkce.js'; +import { TokenCache, TOKEN_CACHE_FILE } from '../src/client/auth/tokenCache.js'; describe('OIDC auth config', () => { const originalEnv = { ...env }; @@ -77,7 +78,7 @@ describe('OIDC auth client', () => { ); const client = new Client(config); - expect(client['oauthHolder']?.getAccessToken()).toBe('preissued-token'); + expect(client.oauthSession.oauthHolder?.getAccessToken()).toBe('preissued-token'); expect(fetchSpy).not.toHaveBeenCalled(); expect(loginSpy).not.toHaveBeenCalled(); cacheSpy.mockRestore(); @@ -96,7 +97,7 @@ describe('OIDC auth client', () => { ); const client = new Client(config); - expect(() => client['oauthHolder']?.getAccessToken()).toThrow( + expect(() => client.oauthSession.oauthHolder?.getAccessToken()).toThrow( /DIRECTORY_CLIENT_AUTH_TOKEN/, ); expect(fetchSpy).not.toHaveBeenCalled(); @@ -137,7 +138,7 @@ describe('OIDC auth client', () => { ); const client = new Client(config); - expect(client['oauthHolder']?.getAccessToken()).toBe('cached-token'); + expect(client.oauthSession.oauthHolder?.getAccessToken()).toBe('cached-token'); }); test('authenticateOAuthPkce updates access token', async () => { @@ -172,7 +173,7 @@ describe('OIDC auth client', () => { const client = new Client(config); await client.authenticateOAuthPkce(); - expect(client['oauthHolder']?.getAccessToken()).toBe('fresh-token'); + expect(client.oauthSession.oauthHolder?.getAccessToken()).toBe('fresh-token'); expect(oauthPkce.fetchOpenidConfiguration).toHaveBeenCalledOnce(); expect(oauthPkce.runLoopbackPkceLogin).toHaveBeenCalledOnce(); });