browser-vault-gui/src/services/vaultClient.ts
2025-10-21 14:18:17 +02:00

467 lines
12 KiB
TypeScript

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<string, unknown>
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<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}/v1/${path.replace(/^\//, '')}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
// 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<T>(path: string, options: RequestInit = {}, attempt = 0): Promise<T> {
try {
return await this.request<T>(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<T>(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<string[]> {
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<T = Record<string, unknown>>(path: string, version?: number): Promise<T | null> {
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<T = Record<string, unknown>>(path: string, data: T): Promise<void> {
const normalizedPath = this.transformPath(path, 'data')
const body = this.kvVersion === 2 ? { data } : data
await this.requestWithRetry<void>(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<void> {
const normalizedPath = this.transformPath(path, 'data')
await this.requestWithRetry<void>(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<string> {
const response = await this.request<VaultAuthResponse>('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<string> {
const response = await this.request<VaultAuthResponse>('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<string, string>
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<void> {
await this.requestWithRetry<void>('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<string, unknown>
options: Record<string, unknown> | 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
}
}
}