UNPKG

@enspirit/emb

Version:

A replacement for our Makefile-for-monorepos

267 lines (266 loc) 10.2 kB
/* eslint-disable n/no-unsupported-features/node-builtins -- fetch is stable in Node 20+ */ import { AbstractSecretProvider } from '../SecretProvider.js'; import { cacheToken, clearCachedToken, getCachedToken, } from './VaultTokenCache.js'; /** * Error class for Vault-specific errors. */ export class VaultError extends Error { code; statusCode; constructor(message, code, statusCode) { super(message); this.code = code; this.statusCode = statusCode; this.name = 'VaultError'; } } /** * HashiCorp Vault secret provider. * Supports KV v2 secrets engine. */ export class VaultProvider extends AbstractSecretProvider { token = null; async connect() { const { auth, address, namespace } = this.config; // Try to use cached token first (for methods that benefit from caching) if (auth.method === 'oidc') { const cached = await getCachedToken(address, namespace); if (cached) { this.token = cached.token; try { await this.verifyToken(); // Cached token is still valid return; } catch { // Cached token is invalid, clear it and proceed with fresh auth await clearCachedToken(address, namespace); this.token = null; } } } let authResult; switch (auth.method) { case 'approle': { authResult = await this.loginAppRole(auth.roleId, auth.secretId); break; } case 'jwt': { authResult = await this.loginJwt(auth.role, auth.jwt); break; } case 'kubernetes': { authResult = await this.loginKubernetes(auth.role); break; } case 'oidc': { authResult = await this.loginOidc(auth.role, auth.port); break; } case 'token': { // For explicit tokens, we don't know the TTL - use a default authResult = { token: auth.token, ttlSeconds: 3600 }; break; } default: { throw new VaultError(`Unsupported auth method: ${auth.method}`, 'VAULT_AUTH_ERROR'); } } this.token = authResult.token; // Verify the token works by looking it up await this.verifyToken(); // Cache the token for methods that benefit from caching if (auth.method === 'oidc' && authResult.ttlSeconds > 0) { await cacheToken(address, authResult.token, authResult.ttlSeconds, namespace); } } async disconnect() { this.token = null; this.clearCache(); } async fetchSecret(ref) { if (!this.token) { throw new VaultError('Not connected to Vault', 'VAULT_NOT_CONNECTED'); } // For KV v2, the path needs 'data' inserted after the mount point // e.g., "secret/myapp" becomes "secret/data/myapp" const path = this.normalizeKvPath(ref.path); const url = new URL(`/v1/${path}`, this.config.address); if (ref.version) { url.searchParams.set('version', ref.version); } const response = await fetch(url.toString(), { method: 'GET', headers: this.buildHeaders(), }); if (!response.ok) { const error = await this.parseErrorResponse(response); const namespace = this.config.namespace ? ` (namespace: ${this.config.namespace})` : ''; throw new VaultError(`Failed to read secret at '${ref.path}'${namespace}: ${error.message}`, 'VAULT_READ_ERROR', response.status); } const data = await response.json(); // KV v2 wraps the data in a 'data' field return (data.data?.data || data.data || {}); } /** * Normalize a path for the appropriate secrets engine. * - KV v2: Insert '/data/' after the mount point * - 1Password Connect: Use path as-is (contains /vaults/ and /items/) * - Other engines: Use path as-is */ normalizeKvPath(path) { // If path already contains '/data/', assume it's correctly formatted for KV v2 if (path.includes('/data/')) { return path; } // 1Password Connect paths contain /vaults/ and /items/ - don't modify if (path.includes('/vaults/') || path.includes('/items/')) { return path; } // For KV v2, insert /data/ after the mount point // Split by first '/' to get mount and rest of path const firstSlash = path.indexOf('/'); if (firstSlash === -1) { // Just a mount, no sub-path return `${path}/data`; } const mount = path.slice(0, Math.max(0, firstSlash)); const subPath = path.slice(Math.max(0, firstSlash + 1)); return `${mount}/data/${subPath}`; } buildHeaders() { const headers = { 'X-Vault-Token': this.token, 'Content-Type': 'application/json', }; if (this.config.namespace) { headers['X-Vault-Namespace'] = this.config.namespace; } return headers; } async loginAppRole(roleId, secretId) { const url = new URL('/v1/auth/approle/login', this.config.address); const response = await fetch(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', ...(this.config.namespace && { 'X-Vault-Namespace': this.config.namespace, }), }, // Vault API uses snake_case for these properties // eslint-disable-next-line camelcase body: JSON.stringify({ role_id: roleId, secret_id: secretId }), }); if (!response.ok) { const error = await this.parseErrorResponse(response); throw new VaultError(`AppRole login failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status); } const data = (await response.json()); return { token: data.auth?.client_token || '', ttlSeconds: data.auth?.lease_duration || 3600, }; } async loginKubernetes(role) { // Read the service account token from the mounted file const fs = await import('node:fs/promises'); const tokenPath = '/var/run/secrets/kubernetes.io/serviceaccount/token'; let jwt; try { jwt = await fs.readFile(tokenPath, 'utf8'); } catch { throw new VaultError(`Could not read Kubernetes service account token from ${tokenPath}`, 'VAULT_AUTH_ERROR'); } const url = new URL('/v1/auth/kubernetes/login', this.config.address); const response = await fetch(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', ...(this.config.namespace && { 'X-Vault-Namespace': this.config.namespace, }), }, body: JSON.stringify({ role, jwt }), }); if (!response.ok) { const error = await this.parseErrorResponse(response); throw new VaultError(`Kubernetes login failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status); } const data = (await response.json()); return { token: data.auth?.client_token || '', ttlSeconds: data.auth?.lease_duration || 3600, }; } /** * Authenticate using JWT (non-interactive). * Suitable for CI/CD pipelines where a JWT is provided externally. */ async loginJwt(role, jwt) { const url = new URL('/v1/auth/jwt/login', this.config.address); const response = await fetch(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', ...(this.config.namespace && { 'X-Vault-Namespace': this.config.namespace, }), }, body: JSON.stringify({ role, jwt }), }); if (!response.ok) { const error = await this.parseErrorResponse(response); throw new VaultError(`JWT login failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status); } const data = (await response.json()); return { token: data.auth?.client_token || '', ttlSeconds: data.auth?.lease_duration || 3600, }; } /** * Authenticate using OIDC (interactive browser flow). * Opens a browser for the user to authenticate with Keycloak/OIDC provider. */ async loginOidc(role, port) { const { performOidcLogin } = await import('./VaultOidcHelper.js'); const result = await performOidcLogin({ vaultAddress: this.config.address, role, port: port ?? 8250, namespace: this.config.namespace, }); return { token: result.token, ttlSeconds: result.ttlSeconds, }; } async verifyToken() { const url = new URL('/v1/auth/token/lookup-self', this.config.address); const response = await fetch(url.toString(), { method: 'GET', headers: this.buildHeaders(), }); if (!response.ok) { const error = await this.parseErrorResponse(response); throw new VaultError(`Token verification failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status); } } async parseErrorResponse(response) { try { const data = (await response.json()); return { message: data.errors?.join(', ') || `HTTP ${response.status}`, }; } catch { return { message: `HTTP ${response.status}: ${response.statusText}` }; } } }