@enspirit/emb
Version:
A replacement for our Makefile-for-monorepos
227 lines (226 loc) • 8.49 kB
JavaScript
/* eslint-disable n/no-unsupported-features/node-builtins -- fetch is stable in Node 20+ */
import { randomBytes } from 'node:crypto';
import { createServer } from 'node:http';
import { URL, URLSearchParams } from 'node:url';
import { VaultError } from './VaultProvider.js';
/**
* Perform an interactive OIDC login with Vault.
*
* This function:
* 1. Starts a local HTTP server to receive the callback
* 2. Requests an OIDC auth URL from Vault
* 3. Opens the user's browser to the auth URL
* 4. Waits for the callback with the Vault token
* 5. Returns the token and TTL
*
* @param options - OIDC login options
* @returns The Vault client token and TTL
* @throws VaultError if the login fails
*/
export async function performOidcLogin(options) {
const { vaultAddress, role, namespace, timeout = 120_000 } = options;
const port = options.port ?? 8250;
const callbackUrl = `http://localhost:${port}/oidc/callback`;
// Generate a random state and nonce for security
const state = randomBytes(16).toString('hex');
const nonce = randomBytes(16).toString('hex');
return new Promise((resolve, reject) => {
let timeoutId;
let server;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
if (server) {
server.close();
server = undefined;
}
};
// Set up timeout
timeoutId = setTimeout(() => {
cleanup();
reject(new VaultError('OIDC login timed out. Please try again.', 'VAULT_AUTH_ERROR'));
}, timeout);
// Create the callback server
server = createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://localhost:${port}`);
if (url.pathname === '/oidc/callback') {
// Check for errors in callback
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
if (error) {
sendHtmlResponse(res, 'Login Failed', `<p>Error: ${error}</p><p>${errorDescription || ''}</p>`);
cleanup();
reject(new VaultError(`OIDC login failed: ${error} - ${errorDescription || 'Unknown error'}`, 'VAULT_AUTH_ERROR'));
return;
}
// Extract the code from the callback
const code = url.searchParams.get('code');
const returnedState = url.searchParams.get('state');
if (!code) {
sendHtmlResponse(res, 'Login Failed', '<p>No authorization code received.</p>');
cleanup();
reject(new VaultError('OIDC login failed: No authorization code received', 'VAULT_AUTH_ERROR'));
return;
}
// Note: We don't validate state client-side because Vault generates
// its own state (prefixed with 'st_') regardless of what we send.
// Vault validates the state internally when we call the callback endpoint.
try {
// Exchange the code for a Vault token
// Pass the returned state/nonce from Keycloak (which are Vault's values)
const returnedNonce = url.searchParams.get('nonce') || nonce;
const result = await exchangeCodeForToken({
vaultAddress,
role,
namespace,
code,
state: returnedState || state,
nonce: returnedNonce,
});
sendHtmlResponse(res, 'Login Successful', '<p>You have been authenticated. You may close this window.</p>');
cleanup();
resolve(result);
}
catch (error_) {
const errorMessage = error_ instanceof Error ? error_.message : 'Unknown error';
sendHtmlResponse(res, 'Login Failed', `<p>Failed to complete authentication: ${errorMessage}</p>`);
cleanup();
reject(error_);
}
}
else {
res.writeHead(404);
res.end('Not Found');
}
});
server.on('error', (err) => {
cleanup();
reject(new VaultError(`Failed to start callback server: ${err.message}`, 'VAULT_AUTH_ERROR'));
});
server.listen(port, 'localhost', async () => {
try {
// Get the OIDC auth URL from Vault
const authUrl = await getOidcAuthUrl({
vaultAddress,
role,
namespace,
redirectUri: callbackUrl,
state,
nonce,
});
// Open the browser
const open = (await import('open')).default;
await open(authUrl);
console.log('Opening browser for authentication...');
console.log(`If the browser doesn't open, navigate to:\n${authUrl}`);
}
catch (error) {
cleanup();
reject(error);
}
});
});
}
/**
* Get the OIDC auth URL from Vault.
*/
async function getOidcAuthUrl(options) {
const { vaultAddress, role, namespace, redirectUri, state, nonce } = options;
const url = new URL('/v1/auth/oidc/oidc/auth_url', vaultAddress);
const headers = {
'Content-Type': 'application/json',
};
if (namespace) {
headers['X-Vault-Namespace'] = namespace;
}
// Build the request body
// Vault API uses snake_case for these parameters
/* eslint-disable camelcase */
const body = {
redirect_uri: redirectUri,
state,
nonce,
};
if (role) {
body.role = role;
}
/* eslint-enable camelcase */
const response = await fetch(url.toString(), {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
const data = (await response.json().catch(() => ({})));
const errorMessage = data.errors?.join(', ') || `HTTP ${response.status}`;
throw new VaultError(`Failed to get OIDC auth URL: ${errorMessage}`, 'VAULT_AUTH_ERROR', response.status);
}
const data = (await response.json());
const authUrl = data.data?.auth_url;
if (!authUrl) {
throw new VaultError('Vault did not return an OIDC auth URL', 'VAULT_AUTH_ERROR');
}
return authUrl;
}
/**
* Exchange the authorization code for a Vault token.
*/
async function exchangeCodeForToken(options) {
const { vaultAddress, role, namespace, code, state, nonce } = options;
const url = new URL('/v1/auth/oidc/oidc/callback', vaultAddress);
const params = new URLSearchParams({
code,
state,
nonce,
});
if (role) {
params.set('role', role);
}
const headers = {
'Content-Type': 'application/json',
};
if (namespace) {
headers['X-Vault-Namespace'] = namespace;
}
const response = await fetch(`${url.toString()}?${params.toString()}`, {
method: 'GET',
headers,
});
if (!response.ok) {
const data = (await response.json().catch(() => ({})));
const errorMessage = data.errors?.join(', ') || `HTTP ${response.status}`;
throw new VaultError(`Failed to exchange code for token: ${errorMessage}`, 'VAULT_AUTH_ERROR', response.status);
}
const data = (await response.json());
const token = data.auth?.client_token;
if (!token) {
throw new VaultError('Vault did not return a client token', 'VAULT_AUTH_ERROR');
}
return {
token,
ttlSeconds: data.auth?.lease_duration || 3600,
};
}
/**
* Send an HTML response to the browser.
*/
function sendHtmlResponse(res, title, body) {
const html = `<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
<style>
body { font-family: sans-serif; padding: 40px; text-align: center; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>${title}</h1>
${body}
</body>
</html>`;
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
}