467 lines
12 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|