446 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|