Skip to main content
Glama
vaultService.ts11.1 kB
/** * HashiCorp Vault Service * * Provides secure credential retrieval from HashiCorp Vault. * Features: * - Token-based authentication * - Automatic token renewal * - Secret caching with TTL * - Secret watching for rotation */ import { logger } from "../utils/logger.js"; import { VAULT_SECRET_PATHS, type VaultSecretName, type VaultCredentials, } from "../config/vaultPaths.js"; export interface VaultConfig { readonly addr: string; readonly token: string; readonly namespace?: string; readonly cacheTTL?: number; // milliseconds } export interface VaultSecretResponse { readonly data: { readonly data: Record<string, string>; readonly metadata: { readonly created_time: string; readonly version: number; }; }; } export interface VaultTokenLookupResponse { readonly data: { readonly creation_time: number; readonly creation_ttl: number; readonly display_name: string; readonly expire_time: string | null; readonly explicit_max_ttl: number; readonly id: string; readonly issue_time: string; readonly meta: Record<string, string> | null; readonly num_uses: number; readonly orphan: boolean; readonly path: string; readonly policies: readonly string[]; readonly renewable: boolean; readonly ttl: number; readonly type: string; }; } interface CachedSecret { readonly data: Record<string, string>; readonly expiresAt: number; // timestamp readonly version: number; } export class VaultService { private readonly config: VaultConfig; private readonly cache: Map<string, CachedSecret>; private readonly watchers: Map<string, NodeJS.Timeout>; public constructor(config: VaultConfig) { this.config = { ...config, cacheTTL: config.cacheTTL ?? 300000, // Default 5 minutes }; this.cache = new Map(); this.watchers = new Map(); logger.info("VaultService initialized", { addr: this.config.addr, namespace: this.config.namespace, cacheTTL: this.config.cacheTTL, }); } /** * Retrieve secret from Vault * Uses cache if available and not expired */ public async getSecret(name: VaultSecretName): Promise<Record<string, string>> { const path = VAULT_SECRET_PATHS[name]; const cached = this.cache.get(path); // Return cached value if still valid if (cached && Date.now() < cached.expiresAt) { logger.debug("Vault secret cache hit", { name, path }); return cached.data; } // Fetch from Vault logger.debug("Vault secret cache miss, fetching", { name, path }); const response = await this.fetchSecret(path); // Cache the result const expiresAt = Date.now() + (this.config.cacheTTL ?? 300000); this.cache.set(path, { data: response.data.data, expiresAt, version: response.data.metadata.version, }); return response.data.data; } /** * Fetch secret directly from Vault API */ private async fetchSecret(path: string): Promise<VaultSecretResponse> { const url = `${this.config.addr}/v1/${path}`; const headers: Record<string, string> = { "X-Vault-Token": this.config.token, }; if (this.config.namespace) { headers["X-Vault-Namespace"] = this.config.namespace; } logger.debug("Fetching secret from Vault", { url, path }); const response = await fetch(url, { method: "GET", headers, }); if (!response.ok) { const errorText = await response.text(); logger.error("Vault API error", { status: response.status, statusText: response.statusText, error: errorText, path, }); throw new Error( `Vault API error: ${response.status} ${response.statusText}: ${errorText}` ); } const data = (await response.json()) as VaultSecretResponse; logger.debug("Secret fetched successfully", { path, version: data.data.metadata.version, }); return data; } /** * Watch a secret for changes and invalidate cache * Polls Vault at specified interval (default 60s) */ public watchSecret( name: VaultSecretName, onChange: (newData: Record<string, string>) => void, intervalMs = 60000 ): void { const path = VAULT_SECRET_PATHS[name]; // Clear existing watcher if any this.stopWatching(name); logger.info("Starting secret watcher", { name, path, intervalMs }); let lastVersion = 0; const checkForChanges = async (): Promise<void> => { try { const response = await this.fetchSecret(path); const currentVersion = response.data.metadata.version; if (currentVersion > lastVersion) { logger.info("Secret version changed", { name, path, oldVersion: lastVersion, newVersion: currentVersion, }); lastVersion = currentVersion; // Invalidate cache this.cache.delete(path); // Notify callback onChange(response.data.data); } } catch (error) { logger.error("Error checking secret version", { name, path, error: error instanceof Error ? error.message : String(error), }); } }; // Initial check to get current version void checkForChanges(); // Set up interval const intervalId = setInterval(() => { void checkForChanges(); }, intervalMs); this.watchers.set(name, intervalId); } /** * Stop watching a secret */ public stopWatching(name: VaultSecretName): void { const intervalId = this.watchers.get(name); if (intervalId) { clearInterval(intervalId); this.watchers.delete(name); logger.info("Stopped secret watcher", { name }); } } /** * Stop all secret watchers */ public stopAllWatchers(): void { for (const [name, intervalId] of this.watchers.entries()) { clearInterval(intervalId); logger.info("Stopped secret watcher", { name }); } this.watchers.clear(); } /** * Validate token and check expiration */ public async validateToken(): Promise<VaultTokenLookupResponse> { const url = `${this.config.addr}/v1/auth/token/lookup-self`; const headers: Record<string, string> = { "X-Vault-Token": this.config.token, }; if (this.config.namespace) { headers["X-Vault-Namespace"] = this.config.namespace; } const response = await fetch(url, { method: "GET", headers, }); if (!response.ok) { const errorText = await response.text(); logger.error("Token validation failed", { status: response.status, error: errorText, }); throw new Error(`Token validation failed: ${response.status} ${errorText}`); } const data = (await response.json()) as VaultTokenLookupResponse; logger.info("Token validated", { policies: data.data.policies, ttl: data.data.ttl, renewable: data.data.renewable, expire_time: data.data.expire_time, }); return data; } /** * Renew token (if renewable) */ public async renewToken(incrementSeconds?: number): Promise<void> { const url = `${this.config.addr}/v1/auth/token/renew-self`; const headers: Record<string, string> = { "X-Vault-Token": this.config.token, "Content-Type": "application/json", }; if (this.config.namespace) { headers["X-Vault-Namespace"] = this.config.namespace; } const body = incrementSeconds ? JSON.stringify({ increment: `${incrementSeconds}s` }) : "{}"; const response = await fetch(url, { method: "POST", headers, body, }); if (!response.ok) { const errorText = await response.text(); logger.error("Token renewal failed", { status: response.status, error: errorText, }); throw new Error(`Token renewal failed: ${response.status} ${errorText}`); } const data = await response.json(); logger.info("Token renewed successfully", { ttl: data.auth?.lease_duration, renewable: data.auth?.renewable, }); } /** * Clear all cached secrets */ public clearCache(): void { this.cache.clear(); logger.info("Vault cache cleared"); } /** * Get cache statistics */ public getCacheStats(): { readonly size: number; readonly entries: readonly { readonly path: string; readonly version: number; readonly expiresAt: number; readonly expiresInMs: number; }[]; } { const now = Date.now(); const entries = Array.from(this.cache.entries()).map(([path, cached]) => ({ path, version: cached.version, expiresAt: cached.expiresAt, expiresInMs: cached.expiresAt - now, })); return { size: this.cache.size, entries, }; } /** * Convenience method to get typed credentials */ public async getCredentials<T extends VaultCredentials>( name: VaultSecretName ): Promise<T> { const data = await this.getSecret(name); return data as unknown as T; } /** * Health check - validates token and Vault connectivity */ public async healthCheck(): Promise<{ readonly healthy: boolean; readonly vault: { readonly addr: string; readonly reachable: boolean; }; readonly token: { readonly valid: boolean; readonly ttl: number; readonly renewable: boolean; }; }> { try { // Check Vault reachability const healthUrl = `${this.config.addr}/v1/sys/health`; const healthResponse = await fetch(healthUrl); const vaultReachable = healthResponse.ok || healthResponse.status === 429; // 429 = standby // Validate token const tokenInfo = await this.validateToken(); return { healthy: vaultReachable && tokenInfo.data.ttl > 0, vault: { addr: this.config.addr, reachable: vaultReachable, }, token: { valid: true, ttl: tokenInfo.data.ttl, renewable: tokenInfo.data.renewable, }, }; } catch (error) { logger.error("Vault health check failed", { error: error instanceof Error ? error.message : String(error), }); return { healthy: false, vault: { addr: this.config.addr, reachable: false, }, token: { valid: false, ttl: 0, renewable: false, }, }; } } } /** * Create VaultService from environment variables */ export function createVaultServiceFromEnv(): VaultService | null { const addr = process.env.VAULT_ADDR; const token = process.env.VAULT_TOKEN; if (!addr || !token) { logger.warn("Vault not configured (missing VAULT_ADDR or VAULT_TOKEN)"); return null; } const config: VaultConfig = { addr, token, namespace: process.env.VAULT_NAMESPACE, cacheTTL: process.env.VAULT_CACHE_TTL ? parseInt(process.env.VAULT_CACHE_TTL, 10) : undefined, }; return new VaultService(config); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/acampkin95/MCPCentralManager'

If you have feedback or need assistance with the MCP directory API, please join our Discord server