@nutrient-sdk/dws-mcp-server
Version:
MCP server for Nutrient DWS Processor API
341 lines (340 loc) • 15.2 kB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
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;
}