import { VaultServer, VaultCredentials } from '../types'; /** * Configuration options for VaultClient */ export interface VaultClientOptions { server: VaultServer; credentials: VaultCredentials; timeout?: number; retries?: number; kvVersion?: 1 | 2; // KV secret engine version } /** * Vault API error with additional context */ export class VaultError extends Error { constructor( message: string, public statusCode?: number, public errors?: string[] ) { super(message); this.name = 'VaultError'; } } /** * Browser-compatible HashiCorp Vault client * * This client provides a clean interface to the Vault HTTP API * with proper error handling, authentication, and type safety. * Supports both KV v1 and KV v2 secret engines. */ export class VaultClient { private baseUrl: string; private token?: string; private timeout: number; private retries: number; private kvVersion: 1 | 2; constructor(options: VaultClientOptions) { this.baseUrl = options.server.url.replace(/\/$/, ''); // Remove trailing slash this.token = options.credentials.token; this.timeout = options.timeout || 30000; // 30 seconds default this.retries = options.retries || 2; this.kvVersion = options.kvVersion || 2; // Default to KV v2 (most common) } /** * Transform a path based on KV version * KV v2 uses /data/ for reads/writes and /metadata/ for lists */ private transformPath(path: string, operation: 'data' | 'metadata' | 'none' = 'none'): string { const normalized = path.replace(/^\/+/, '').replace(/\/+$/, ''); if (this.kvVersion === 1) { return normalized; } // KV v2 path transformation // Check if path already has /data/ or /metadata/ if (normalized.includes('/data/') || normalized.includes('/metadata/')) { return normalized; } // For KV v2, transform the path const parts = normalized.split('/'); const mount = parts[0]; // e.g., "secret" const rest = parts.slice(1).join('/'); if (operation === 'data') { return `${mount}/data/${rest}`; } else if (operation === 'metadata') { return `${mount}/metadata/${rest}`; } return normalized; } /** * Make an HTTP request to the Vault API */ private async request( path: string, options: RequestInit = {} ): Promise { const url = `${this.baseUrl}/v1/${path.replace(/^\//, '')}`; const headers: HeadersInit = { 'Content-Type': 'application/json', ...options.headers, }; // Add authentication token if available if (this.token) { headers['X-Vault-Token'] = this.token; } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(url, { ...options, headers, signal: controller.signal, }); clearTimeout(timeoutId); // Handle non-OK responses if (!response.ok) { let errorData: { errors?: string[] } = {}; try { errorData = await response.json(); } catch { // Response might not be JSON } throw new VaultError( `Vault API error: ${response.statusText}`, response.status, errorData.errors ); } // Handle empty responses (e.g., 204 No Content) if (response.status === 204 || response.headers.get('content-length') === '0') { return null as T; } return await response.json(); } catch (error) { clearTimeout(timeoutId); if (error instanceof VaultError) { throw error; } if (error instanceof Error) { if (error.name === 'AbortError') { throw new VaultError('Request timeout'); } throw new VaultError(`Network error: ${error.message}`); } throw new VaultError('Unknown error occurred'); } } /** * Make a request with automatic retries */ private async requestWithRetry( path: string, options: RequestInit = {}, attempt = 0 ): Promise { try { return await this.request(path, options); } catch (error) { // Only retry on network errors, not on 4xx client errors if ( attempt < this.retries && error instanceof VaultError && (!error.statusCode || error.statusCode >= 500) ) { // Exponential backoff const delay = Math.pow(2, attempt) * 1000; await new Promise(resolve => setTimeout(resolve, delay)); return this.requestWithRetry(path, options, attempt + 1); } throw error; } } /** * List secrets at a given path * * For KV v2, this uses the /metadata/ endpoint * For KV v1, this uses the path directly */ async list(path: string): Promise { const normalizedPath = this.transformPath(path, 'metadata'); // Ensure path ends with / for LIST operations const listPath = normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`; const response = await this.requestWithRetry<{ data: { keys: string[] } }>( `${listPath}?list=true`, { method: 'LIST' } ); return response?.data?.keys || []; } /** * Read a secret from Vault * * For KV v2, this uses the /data/ endpoint * For KV v1, this uses the path directly */ async read>(path: string): Promise { const normalizedPath = this.transformPath(path, 'data'); if (this.kvVersion === 2) { // KV v2 returns { data: { data: {...}, metadata: {...} } } const response = await this.requestWithRetry<{ data: { data: T; metadata?: unknown }; }>(normalizedPath, { method: 'GET' }); return response?.data?.data || null; } else { // KV v1 returns { data: {...} } const response = await this.requestWithRetry<{ data: T }>( normalizedPath, { method: 'GET' } ); return response?.data || null; } } /** * Write a secret to Vault * * For KV v2, this uses the /data/ endpoint * For KV v1, this uses the path directly */ async write>( path: string, data: T ): Promise { const normalizedPath = this.transformPath(path, 'data'); const body = this.kvVersion === 2 ? { data } : data; await this.requestWithRetry(normalizedPath, { method: 'POST', body: JSON.stringify(body), }); } /** * Delete a secret from Vault * * For KV v2, this uses the /data/ endpoint (soft delete) * For KV v1, this uses the path directly (hard delete) */ async delete(path: string): Promise { const normalizedPath = this.transformPath(path, 'data'); await this.requestWithRetry(normalizedPath, { method: 'DELETE', }); } /** * Read secret metadata (KV v2 only) * Returns version history, created time, etc. */ async readMetadata(path: string): Promise<{ versions: Record; current_version: number; oldest_version: number; created_time: string; updated_time: string; } | null> { if (this.kvVersion !== 2) { throw new VaultError('Metadata is only available in KV v2'); } const normalizedPath = this.transformPath(path, 'metadata'); const response = await this.requestWithRetry<{ data: { versions: Record; current_version: number; oldest_version: number; created_time: string; updated_time: string; }; }>(normalizedPath, { method: 'GET' }); return response?.data || null; } /** * Get health status of Vault server */ async health(): Promise<{ initialized: boolean; sealed: boolean; standby: boolean; version: string; }> { // Health endpoint doesn't require authentication const url = `${this.baseUrl}/v1/sys/health`; const response = await fetch(url); return response.json(); } /** * Authenticate with username/password */ async loginUserpass(username: string, password: string): Promise { const response = await this.request<{ auth: { client_token: string }; }>('auth/userpass/login/' + username, { method: 'POST', body: JSON.stringify({ password }), }); this.token = response.auth.client_token; return this.token; } /** * Authenticate with LDAP */ async loginLdap(username: string, password: string): Promise { const response = await this.request<{ auth: { client_token: string }; }>('auth/ldap/login/' + username, { method: 'POST', body: JSON.stringify({ password }), }); this.token = response.auth.client_token; return this.token; } /** * Lookup current token info */ async tokenLookupSelf(): Promise<{ data: { accessor: string; creation_time: number; creation_ttl: number; display_name: string; entity_id: string; expire_time: string | null; explicit_max_ttl: number; id: string; issue_time: string; meta: Record; num_uses: number; orphan: boolean; path: string; policies: string[]; renewable: boolean; ttl: number; type: string; }; }> { return this.requestWithRetry('auth/token/lookup-self', { method: 'GET', }); } /** * Revoke current token (logout) */ async tokenRevokeSelf(): Promise { await this.requestWithRetry('auth/token/revoke-self', { method: 'POST', }); this.token = undefined; } /** * List all secret engine mount points * This also verifies the token is valid */ async listMounts(): Promise<{ [key: string]: { type: string; description: string; accessor: string; config: { default_lease_ttl: number; max_lease_ttl: number; }; options: { version?: string; } | null; }; }> { const response = await this.requestWithRetry<{ data: { auth?: { [key: string]: { type: string; description: string; accessor: string; config: Record; options: Record | null; }; }; secret?: { [key: string]: { type: string; description: string; accessor: string; config: { default_lease_ttl: number; max_lease_ttl: number; }; options: { version?: string; } | null; }; }; }; }>('sys/internal/ui/mounts', { method: 'GET' }); // Return only the secret engines (not auth methods) return response?.data?.secret || {}; } /** * Detect KV version for a mount point */ async detectKvVersion(mountPath: string): Promise<1 | 2> { try { const response = await this.requestWithRetry<{ data: { options: { version?: string }; type: string; }; }>(`sys/internal/ui/mounts/${mountPath}`, { method: 'GET' }); const version = response?.data?.options?.version; return version === '2' ? 2 : 1; } catch { // If detection fails, assume v2 (most common) return 2; } } }