browser-vault-gui/src/services/vaultClient.ts
2025-10-20 18:45:52 +02:00

446 lines
13 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';
}
}
/**
* 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: 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<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): Promise<T | null> {
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<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<{
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<string> {
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<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;
}
}
}