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' } } /** * Vault authentication response structure */ interface VaultAuthResponse { request_id: string lease_id: string renewable: boolean lease_duration: number data: unknown wrap_info: unknown warnings: unknown auth: { client_token: string accessor: string policies: string[] token_policies: string[] metadata: Record lease_duration: number renewable: boolean entity_id: string token_type: string orphan: boolean mfa_requirement: unknown num_uses: number } mount_type: string } /** * 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: Record = { 'Content-Type': 'application/json', ...(options.headers as Record), } // 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, version?: number): Promise { const normalizedPath = this.transformPath(path, 'data') const pathWithVersion = version ? `${normalizedPath}?version=${version}` : normalizedPath if (this.kvVersion === 2) { // KV v2 returns { data: { data: {...}, metadata: {...} } } const response = await this.requestWithRetry<{ data: { data: T; metadata?: unknown } }>(pathWithVersion, { method: 'GET' }) return response?.data?.data || null } else { // KV v1 returns { data: {...} } const response = await this.requestWithRetry<{ data: T }>(pathWithVersion, { 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< string, { created_time: string deletion_time: string destroyed: boolean } > 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< string, { created_time: string deletion_time: string destroyed: boolean } > 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/userpass/login/' + username, { method: 'POST', body: JSON.stringify({ password }), }) if (!response.auth || !response.auth.client_token) { throw new VaultError('Authentication failed: no token received from server') } this.token = response.auth.client_token console.log('✓ Token extracted from userpass response:', this.token.substring(0, 10) + '...') console.log('✓ User policies:', response.auth.policies) console.log('✓ Token metadata:', response.auth.metadata) return this.token } /** * Authenticate with LDAP */ async loginLdap(username: string, password: string): Promise { const response = await this.request('auth/ldap/login/' + username, { method: 'POST', body: JSON.stringify({ password }), }) if (!response.auth || !response.auth.client_token) { throw new VaultError('Authentication failed: no token received from server') } this.token = response.auth.client_token console.log('✓ Token extracted from LDAP response:', this.token.substring(0, 10) + '...') console.log('✓ User policies:', response.auth.policies) console.log('✓ Token metadata:', response.auth.metadata) 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 } } }