UNPKG

@nutrient-sdk/dws-mcp-server

Version:

MCP server for Nutrient DWS Processor API

341 lines (340 loc) 15.2 kB
import { createServer } from 'node:http'; import { randomBytes, createHash } from 'node:crypto'; import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { z } from 'zod'; import { logger } from '../logger.js'; function escapeHtml(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); } const CachedCredentialsSchema = z.object({ accessToken: z.string(), refreshToken: z.string().optional(), expiresAt: z.number().optional(), clientId: z.string().optional(), scopes: z.array(z.string()).optional(), }); const FETCH_TIMEOUT_MS = 15_000; const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; const pendingTokenRequests = new Map(); export function getDefaultCredentialsPath(env = process.env, homeDirectory = homedir()) { const configHome = env.XDG_CONFIG_HOME || join(homeDirectory, '.config'); return join(configHome, 'nutrient', 'credentials.json'); } export function generateCodeVerifier() { return randomBytes(32).toString('base64url'); } export function generateCodeChallenge(verifier) { return createHash('sha256').update(verifier).digest('base64url'); } export async function readCachedCredentials(credentialsPath) { try { const content = await readFile(credentialsPath, 'utf-8'); const result = CachedCredentialsSchema.safeParse(JSON.parse(content)); if (!result.success) { logger.warn('Cached credentials file is malformed, ignoring', { path: credentialsPath }); return null; } return result.data; } catch (err) { if (err.code !== 'ENOENT') { logger.warn('Failed to read cached credentials, ignoring', { path: credentialsPath, err }); } return null; } } async function writeCachedCredentials(credentialsPath, credentials) { const dir = dirname(credentialsPath); await mkdir(dir, { recursive: true, mode: 0o700 }); await writeFile(credentialsPath, JSON.stringify(credentials, null, 2), { mode: 0o600 }); } async function registerClient(config, redirectUri) { if (!config.registrationUrl) { throw new Error('DCR requires registrationUrl when clientId is not configured'); } const registrationPayload = { client_name: config.clientName ?? 'Nutrient DWS MCP Server', redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', }; logger.info('Registering OAuth client via DCR', { registrationUrl: config.registrationUrl }); logger.debug('DCR payload', registrationPayload); const response = await fetch(config.registrationUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(registrationPayload), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) { const errorText = await response.text(); logger.error('DCR failed', { status: response.status, body: errorText }); throw new Error(`Dynamic client registration failed (${response.status})`); } const data = (await response.json()); if (!data.client_id) { throw new Error('DCR response missing client_id'); } logger.info('OAuth client registered', { clientId: data.client_id }); return data.client_id; } const DEFAULT_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour export function isTokenExpired(credentials) { // Treat missing expiresAt as "unknown TTL" — assume 1 hour from now is generous // but still requires re-auth rather than using a potentially stale token forever if (!credentials.expiresAt) { return true; } // Consider expired 60 seconds early to avoid edge cases return Date.now() >= (credentials.expiresAt - 60_000); } /** Cached credentials are only reusable if they were minted under every scope the current config requests. */ function coversRequestedScopes(cached, config) { const granted = new Set(cached.scopes ?? []); return config.scopes.every((scope) => granted.has(scope)); } async function refreshAccessToken(config, clientId, refreshToken, cachedScopes) { try { logger.debug('Attempting token refresh', { tokenUrl: config.tokenUrl, clientId }); const response = await fetch(config.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: clientId, refresh_token: refreshToken, }), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) { logger.warn('Token refresh failed', { status: response.status, statusText: response.statusText }); return null; } const data = (await response.json()); return { accessToken: data.access_token, refreshToken: data.refresh_token ?? refreshToken, expiresAt: Date.now() + (data.expires_in ? data.expires_in * 1000 : DEFAULT_TOKEN_TTL_MS), scopes: data.scope ? data.scope.split(' ') : cachedScopes, }; } catch { return null; } } async function exchangeCodeForToken(config, clientId, code, codeVerifier, redirectUri) { const response = await fetch(config.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: clientId, code, redirect_uri: redirectUri, code_verifier: codeVerifier, }), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) { const errorText = await response.text(); logger.error('Token exchange failed', { status: response.status, body: errorText }); throw new Error(`Token exchange failed (${response.status})`); } const data = (await response.json()); return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, clientId, scopes: data.scope ? data.scope.split(' ') : config.scopes, }; } function buildAuthorizeUrl(config, clientId, redirectUri, codeChallenge, state) { const url = new URL(config.authorizeUrl); url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', clientId); url.searchParams.set('redirect_uri', redirectUri); url.searchParams.set('code_challenge', codeChallenge); url.searchParams.set('code_challenge_method', 'S256'); url.searchParams.set('state', state); if (config.scopes.length > 0) { url.searchParams.set('scope', config.scopes.join(' ')); } if (config.resource) { url.searchParams.set('resource', config.resource); } return url.toString(); } /** * Starts the callback server on a random available port and returns the server + assigned port. */ function startCallbackServer() { return new Promise((resolve, reject) => { const server = createServer(); server.listen(0, '127.0.0.1', () => { const addr = server.address(); if (!addr || typeof addr === 'string') { server.close(); reject(new Error('Failed to get callback server address')); return; } resolve({ server, port: addr.port }); }); server.on('error', reject); }); } async function performBrowserOAuthFlow(config) { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = randomBytes(16).toString('hex'); // 1. Start callback server on a random available port const { server, port } = await startCallbackServer(); const redirectUri = `http://localhost:${port}/callback`; logger.info('OAuth callback server listening', { port, redirectUri }); // 2. Register client via DCR (or use static clientId) with the actual redirect URI const clientId = config.clientId ?? await registerClient(config, redirectUri); // 3. Open browser for authorization const authorizeUrl = buildAuthorizeUrl(config, clientId, redirectUri, codeChallenge, state); logger.debug('Authorize URL', { authorizeUrl }); const { default: open } = await import('open'); logger.info('Opening browser for Nutrient authentication...'); await open(authorizeUrl); // 4. Wait for the OAuth callback (with timeout) return new Promise((resolve, reject) => { const timeout = setTimeout(() => { server.close(); reject(new Error('OAuth authentication timed out after 5 minutes')); }, CALLBACK_TIMEOUT_MS); server.on('request', async (req, res) => { try { const url = new URL(req.url ?? '/', `http://localhost`); if (url.pathname !== '/callback') { res.writeHead(404); res.end('Not found'); return; } const error = url.searchParams.get('error'); if (error) { const description = url.searchParams.get('error_description') ?? error; res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(`<html><body><h1>Authorization Failed</h1><p>${escapeHtml(description)}</p><p>You can close this tab.</p></body></html>`); clearTimeout(timeout); server.close(); reject(new Error(`OAuth authorization failed: ${description}`)); return; } const returnedState = url.searchParams.get('state'); if (returnedState !== state) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('<html><body><h1>Invalid State</h1><p>OAuth state mismatch. Please try again.</p></body></html>'); clearTimeout(timeout); server.close(); reject(new Error('OAuth state mismatch')); return; } const code = url.searchParams.get('code'); if (!code) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('<html><body><h1>Missing Code</h1><p>No authorization code received.</p></body></html>'); clearTimeout(timeout); server.close(); reject(new Error('No authorization code received')); return; } const credentials = await exchangeCodeForToken(config, clientId, code, codeVerifier, redirectUri); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('<html><body><h1>Authenticated!</h1><p>You can close this tab and return to your terminal.</p></body></html>'); clearTimeout(timeout); server.close(); resolve(credentials); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/html' }); res.end('<html><body><h1>Error</h1><p>Something went wrong during authentication.</p></body></html>'); clearTimeout(timeout); server.close(); reject(err); } }); }); } /** * Deletes the cached credentials file so the next `getToken` call * is forced to refresh or re-authenticate. */ export async function invalidateCachedToken(config) { const credentialsPath = config.credentialsPath ?? getDefaultCredentialsPath(); try { await unlink(credentialsPath); logger.info('Invalidated cached token', { credentialsPath }); } catch (err) { if (err.code !== 'ENOENT') { logger.warn('Failed to delete cached credentials', { credentialsPath, err }); } } } /** * Returns a valid Nutrient DWS API access token. * * Checks cached credentials first, attempts token refresh if expired, * and falls back to a browser-based OAuth flow if no valid token is available. */ export async function getToken(config) { const credentialsPath = config.credentialsPath ?? getDefaultCredentialsPath(); const pendingRequest = pendingTokenRequests.get(credentialsPath); if (pendingRequest) { logger.debug('Awaiting in-flight token acquisition', { credentialsPath }); return pendingRequest; } const tokenRequest = getTokenUncached(config, credentialsPath).finally(() => { if (pendingTokenRequests.get(credentialsPath) === tokenRequest) { pendingTokenRequests.delete(credentialsPath); } }); pendingTokenRequests.set(credentialsPath, tokenRequest); return tokenRequest; } async function getTokenUncached(config, credentialsPath) { logger.debug('Starting token acquisition', { credentialsPath }); // 1. Check cached token let cached = await readCachedCredentials(credentialsPath); if (cached && !coversRequestedScopes(cached, config)) { const missing = config.scopes.filter((scope) => !(cached?.scopes ?? []).includes(scope)); logger.info('Cached credentials do not cover configured scopes, re-authorizing', { missing }); cached = null; } if (cached) { // 2. Valid token — return it if (!isTokenExpired(cached)) { logger.debug('Using cached token (not expired)'); return cached.accessToken; } logger.debug('Cached token expired', { expiresAt: cached.expiresAt ? new Date(cached.expiresAt).toISOString() : 'unknown' }); // 3. Expired but has refresh token — try refresh const effectiveClientId = config.clientId ?? cached.clientId; if (cached.refreshToken && effectiveClientId) { logger.info('Attempting token refresh'); const refreshed = await refreshAccessToken(config, effectiveClientId, cached.refreshToken, cached.scopes); if (refreshed) { logger.info('Token refreshed successfully'); refreshed.clientId = effectiveClientId; await writeCachedCredentials(credentialsPath, refreshed); return refreshed.accessToken; } logger.warn('Token refresh failed, falling back to browser flow'); } } else { logger.info('No cached credentials found'); } // 4. No valid token — browser OAuth flow (includes DCR if needed) logger.info('Starting browser OAuth flow', { authorizeUrl: config.authorizeUrl }); const credentials = await performBrowserOAuthFlow(config); logger.info('Browser OAuth flow completed successfully'); await writeCachedCredentials(credentialsPath, credentials); return credentials.accessToken; }