@agentmailbox/mcp-auth 1.0.10 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +78 -4
  2. package/index.js +492 -41
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -57,13 +57,87 @@ Replace:
57
57
  | Variable | Required | Description |
58
58
  |----------|----------|-------------|
59
59
  | `MCP_OAUTH_CLIENT_ID` | Yes | Your OAuth2 client ID |
60
- | `MCP_OAUTH_CLIENT_SECRET` | Yes | Your OAuth2 client secret |
60
+ | `MCP_OAUTH_CLIENT_SECRET` | Conditional | Your OAuth2 client secret (or use file-based secret) |
61
+ | `MCP_OAUTH_CLIENT_SECRET_FILE` | Conditional | Path to file containing client secret (more secure) |
62
+ | `MCP_DISABLE_CERT_PINNING` | No | Set to `"true"` to disable certificate pinning (for corporate proxies) |
63
+ | `MCP_DEBUG` | No | Set to `"true"` to enable debug logging (never logs secrets) |
64
+
65
+ **Note:** Either `MCP_OAUTH_CLIENT_SECRET` or `MCP_OAUTH_CLIENT_SECRET_FILE` must be provided.
66
+
67
+ ### Using File-Based Secrets (Recommended for Production)
68
+
69
+ For enhanced security, store your client secret in a file instead of an environment variable:
70
+
71
+ ```bash
72
+ # Create a secret file with restricted permissions (avoids shell history)
73
+ read -rs SECRET && printf '%s' "$SECRET" > ~/.agentmail-secret && unset SECRET
74
+ chmod 600 ~/.agentmail-secret
75
+ ```
76
+
77
+ Then configure:
78
+ ```json
79
+ {
80
+ "env": {
81
+ "MCP_OAUTH_CLIENT_ID": "your-client-id",
82
+ "MCP_OAUTH_CLIENT_SECRET_FILE": "/Users/you/.agentmail-secret"
83
+ }
84
+ }
85
+ ```
86
+
87
+ ## Security Features
88
+
89
+ This package implements several security measures:
90
+
91
+ | Feature | Description |
92
+ |---------|-------------|
93
+ | **HTTPS Enforcement** | All endpoints must use HTTPS (localhost allowed for development) |
94
+ | **Certificate Pinning** | SPKI pinning for AgentMailbox domains (Amazon Root CAs) |
95
+ | **Environment Isolation** | Child processes receive minimal environment variables |
96
+ | **File-Based Secrets** | Option to read secrets from files instead of environment variables |
97
+ | **Response Size Limits** | Prevents memory exhaustion from malicious responses (100KB max) |
98
+ | **Token Validation** | Validates JWT token format before use |
99
+ | **Error Sanitization** | Removes sensitive data from error messages |
100
+ | **Pinned Dependencies** | Uses pinned version of mcp-remote |
101
+ | **Exponential Backoff** | Automatic retries with jitter to prevent thundering herd |
102
+ | **Security Headers** | Advisory checks for HSTS and X-Content-Type-Options (logs warnings in debug mode) |
103
+
104
+ ### Certificate Pinning
105
+
106
+ Certificate pinning is enabled by default for AgentMailbox domains (`auth.agentmailbox.to`, `mcp.agentmailbox.to`). This helps mitigate many MITM scenarios by validating that certificates are signed by expected root CAs.
107
+
108
+ If you're behind a corporate proxy that performs TLS inspection, you may need to disable pinning:
109
+
110
+ ```json
111
+ {
112
+ "env": {
113
+ "MCP_DISABLE_CERT_PINNING": "true"
114
+ }
115
+ }
116
+ ```
117
+
118
+ **Warning:** Only disable certificate pinning if you understand the security implications.
119
+
120
+ ## Troubleshooting
121
+
122
+ Enable debug logging to diagnose connection issues:
123
+
124
+ ```json
125
+ {
126
+ "env": {
127
+ "MCP_DEBUG": "true"
128
+ }
129
+ }
130
+ ```
131
+
132
+ Debug logs are written to stderr and include timestamps. **Sensitive data (tokens, secrets) is never logged.**
61
133
 
62
134
  ## How It Works
63
135
 
64
- 1. Fetches an OAuth2 access token using the Client Credentials flow
65
- 2. Passes the token to `mcp-remote` via environment variable
66
- 3. Forwards all MCP communication to the AgentMailbox server
136
+ 1. Reads client credentials from environment variables or secret file
137
+ 2. Fetches an OAuth2 access token using the Client Credentials flow (with automatic retries)
138
+ 3. Validates the token response (size limits, JWT format, security headers)
139
+ 4. Passes the token to `mcp-remote` via environment variable
140
+ 5. Forwards all MCP communication to the AgentMailbox server
67
141
 
68
142
  ## Requirements
69
143
 
package/index.js CHANGED
@@ -5,6 +5,17 @@
5
5
  * OAuth2 Client Credentials wrapper for MCP servers.
6
6
  * Handles M2M (machine-to-machine) authentication for AgentMailbox MCP endpoints.
7
7
  *
8
+ * Security features:
9
+ * - HTTPS enforcement for all endpoints (localhost allowed for development)
10
+ * - Certificate pinning for AgentMailbox domains (optional, enabled by default)
11
+ * - Environment isolation for child processes
12
+ * - Support for secret file to avoid environment variable exposure
13
+ * - Response size limits and token format validation
14
+ * - Sanitized error messages to prevent information leakage
15
+ * - Exponential backoff for retries (prevents accidental DoS)
16
+ * - Security headers validation
17
+ * - Optional debug logging for troubleshooting
18
+ *
8
19
  * Usage with npx in claude_desktop_config.json:
9
20
  * {
10
21
  * "mcpServers": {
@@ -27,15 +38,123 @@
27
38
  */
28
39
 
29
40
  import { spawn } from 'child_process';
41
+ import { readFileSync } from 'fs';
42
+ import { request } from 'https';
43
+ import { request as httpRequest } from 'http';
44
+ import { createHash } from 'crypto';
45
+ import { checkServerIdentity as tlsCheckServerIdentity } from 'tls';
46
+
47
+ // Security constants
48
+ const TOKEN_TIMEOUT_MS = 30000; // 30 seconds
49
+ const MAX_TOKEN_RESPONSE_SIZE = 100000; // 100KB max response
50
+ const MCP_REMOTE_VERSION = '0.1.12'; // Pinned version for security
51
+
52
+ // Retry configuration with exponential backoff
53
+ const MAX_RETRIES = 3;
54
+ const INITIAL_RETRY_DELAY_MS = 1000; // 1 second
55
+ const MAX_RETRY_DELAY_MS = 10000; // 10 seconds
56
+
57
+ // Valid localhost hostnames (including IPv6)
58
+ const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
59
+
60
+ // Known AgentMailbox domains that should have certificate pinning
61
+ const PINNED_DOMAINS = new Set([
62
+ 'auth.agentmailbox.to',
63
+ 'mcp.agentmailbox.to',
64
+ 'api.agentmailbox.to',
65
+ ]);
30
66
 
31
- // Default timeout for token requests (30 seconds)
32
- const TOKEN_TIMEOUT_MS = 30000;
67
+ // Expected security headers for token endpoint
68
+ const EXPECTED_SECURITY_HEADERS = {
69
+ 'strict-transport-security': true, // HSTS should be present
70
+ 'x-content-type-options': 'nosniff',
71
+ };
72
+
73
+ // SPKI (Subject Public Key Info) hashes for certificate pinning
74
+ // These are SHA-256 hashes of the public keys in the certificate chain
75
+ // Using Amazon Root CA and intermediate CAs that sign AgentMailbox certs
76
+ // To update: openssl s_client -connect auth.agentmailbox.to:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
77
+ // Public key hashes for certificate pinning (these are PUBLIC, not secrets)
78
+ // pragma: allowlist nextline secret
79
+ const PINNED_PUBLIC_KEYS = new Set([
80
+ // Amazon Root CA 1 - pragma: allowlist secret
81
+ '++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=', // pragma: allowlist secret
82
+ // Amazon Root CA 2
83
+ 'f0KW/FtqTjs108NpYj42SrGvOB2PpxIVM8nWxjPqJGE=', // pragma: allowlist secret
84
+ // Amazon Root CA 3
85
+ 'NqvDJlas/GRcYbcWE8S/IceG7Qz2ddXE5XX9x8JMA+k=', // pragma: allowlist secret
86
+ // Amazon Root CA 4
87
+ '9+ze1cZgR9KO1kZrVDxA4HQ6voHRCSVNz4RdTCx4U8U=', // pragma: allowlist secret
88
+ // Starfield Services Root CA (Amazon's root)
89
+ 'KwccWaCgrnaw6tsrrSO61FgLacNgG2MMLq8GE6+oP5I=', // pragma: allowlist secret
90
+ ]);
33
91
 
34
92
  const [mcpUrl, tokenEndpoint, scope] = process.argv.slice(2);
35
93
  const clientId = process.env.MCP_OAUTH_CLIENT_ID;
36
- const clientSecret = process.env.MCP_OAUTH_CLIENT_SECRET;
37
94
 
38
- if (!mcpUrl || !tokenEndpoint || !clientId || !clientSecret) {
95
+ // Configuration flags
96
+ const DISABLE_CERT_PINNING = process.env.MCP_DISABLE_CERT_PINNING === 'true';
97
+ const DEBUG_MODE = process.env.MCP_DEBUG === 'true';
98
+
99
+ /**
100
+ * Debug logger - only logs when MCP_DEBUG=true
101
+ * Never logs sensitive data like secrets or tokens
102
+ */
103
+ function debugLog(message, data = null) {
104
+ if (!DEBUG_MODE) return;
105
+
106
+ const timestamp = new Date().toISOString();
107
+ const prefix = `[mcp-auth ${timestamp}]`;
108
+
109
+ if (data) {
110
+ // Sanitize any potentially sensitive data
111
+ const safeData = JSON.stringify(data, (key, value) => {
112
+ const sensitiveKeys = ['secret', 'token', 'password', 'authorization', 'bearer'];
113
+ if (sensitiveKeys.some(k => key.toLowerCase().includes(k))) {
114
+ return '[REDACTED]';
115
+ }
116
+ return value;
117
+ });
118
+ console.error(`${prefix} ${message}:`, safeData);
119
+ } else {
120
+ console.error(`${prefix} ${message}`);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Get client secret from environment variable or file.
126
+ * File-based secrets are more secure as they're not visible in process listings.
127
+ */
128
+ function getClientSecret() {
129
+ // Prefer file-based secret if specified
130
+ const secretFile = process.env.MCP_OAUTH_CLIENT_SECRET_FILE;
131
+ if (secretFile) {
132
+ debugLog('Reading secret from file', { path: secretFile });
133
+ try {
134
+ const secret = readFileSync(secretFile, 'utf-8').trim();
135
+ if (!secret) {
136
+ console.error('Error: Secret file is empty');
137
+ process.exit(1);
138
+ }
139
+ debugLog('Secret loaded from file successfully');
140
+ return secret;
141
+ } catch (err) {
142
+ console.error(`Error: Failed to read secret file: ${err.code === 'ENOENT' ? 'File not found' : 'Read error'}`);
143
+ process.exit(1);
144
+ }
145
+ }
146
+
147
+ // Fall back to environment variable
148
+ const secret = process.env.MCP_OAUTH_CLIENT_SECRET;
149
+ if (!secret) {
150
+ console.error('Error: MCP_OAUTH_CLIENT_SECRET or MCP_OAUTH_CLIENT_SECRET_FILE must be set');
151
+ process.exit(1);
152
+ }
153
+ debugLog('Using secret from environment variable');
154
+ return secret;
155
+ }
156
+
157
+ if (!mcpUrl || !tokenEndpoint || !clientId) {
39
158
  console.error('Usage: npx @agentmailbox/mcp-auth <mcp-url> <token-endpoint> [scope]');
40
159
  console.error('');
41
160
  console.error('Arguments:');
@@ -43,9 +162,160 @@ if (!mcpUrl || !tokenEndpoint || !clientId || !clientSecret) {
43
162
  console.error(' token-endpoint The OAuth2 token endpoint (e.g., https://auth.agentmailbox.to/oauth2/token)');
44
163
  console.error(' scope Optional OAuth2 scope (default: mcp-api/email.full)');
45
164
  console.error('');
46
- console.error('Environment variables required:');
47
- console.error(' MCP_OAUTH_CLIENT_ID Your OAuth2 client ID');
48
- console.error(' MCP_OAUTH_CLIENT_SECRET Your OAuth2 client secret');
165
+ console.error('Environment variables:');
166
+ console.error(' MCP_OAUTH_CLIENT_ID Your OAuth2 client ID (required)');
167
+ console.error(' MCP_OAUTH_CLIENT_SECRET Your OAuth2 client secret');
168
+ console.error(' MCP_OAUTH_CLIENT_SECRET_FILE Path to file containing client secret (more secure)');
169
+ console.error(' MCP_DISABLE_CERT_PINNING Set to "true" to disable certificate pinning (for corporate proxies)');
170
+ console.error(' MCP_DEBUG Set to "true" to enable debug logging (never logs secrets)');
171
+ process.exit(1);
172
+ }
173
+
174
+ debugLog('Starting mcp-auth', {
175
+ mcpUrl,
176
+ tokenEndpoint,
177
+ scope: scope || 'mcp-api/email.full',
178
+ certPinning: !DISABLE_CERT_PINNING,
179
+ });
180
+
181
+ // Get secret after argument validation
182
+ const clientSecret = getClientSecret();
183
+
184
+ /**
185
+ * Validate that a URL uses HTTPS (or localhost for development).
186
+ * Prevents sending credentials over insecure connections.
187
+ */
188
+ function isSecureEndpoint(url) {
189
+ try {
190
+ const parsed = new URL(url);
191
+
192
+ // HTTPS is always allowed
193
+ if (parsed.protocol === 'https:') {
194
+ return true;
195
+ }
196
+
197
+ // HTTP only allowed for true localhost (not localhost.attacker.com)
198
+ if (parsed.protocol === 'http:') {
199
+ return LOCALHOST_HOSTS.has(parsed.hostname.toLowerCase());
200
+ }
201
+
202
+ return false;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Validate URL format to prevent injection attacks.
210
+ */
211
+ function isValidUrl(url) {
212
+ try {
213
+ const parsed = new URL(url);
214
+ // Only allow http/https protocols
215
+ return ['http:', 'https:'].includes(parsed.protocol);
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Check if a domain should have certificate pinning.
223
+ */
224
+ function shouldPinCertificate(hostname) {
225
+ if (DISABLE_CERT_PINNING) return false;
226
+ return PINNED_DOMAINS.has(hostname.toLowerCase());
227
+ }
228
+
229
+ /**
230
+ * Verify certificate against pinned public keys.
231
+ * Uses SPKI (Subject Public Key Info) pinning which survives cert renewals.
232
+ */
233
+ function verifyCertificatePin(cert, hostname) {
234
+ if (!shouldPinCertificate(hostname)) {
235
+ debugLog('Certificate pinning skipped', { hostname, reason: DISABLE_CERT_PINNING ? 'disabled' : 'not in pinned domains' });
236
+ return; // No pinning required for this domain
237
+ }
238
+
239
+ debugLog('Verifying certificate pin', { hostname });
240
+
241
+ // Get the certificate chain, tracking seen certs to prevent any cycle
242
+ const certChain = [];
243
+ const seenCerts = new Set();
244
+ let currentCert = cert;
245
+ while (currentCert) {
246
+ // Break if we've seen this certificate before (any cycle, not just back to root)
247
+ if (seenCerts.has(currentCert)) break;
248
+ seenCerts.add(currentCert);
249
+ certChain.push(currentCert);
250
+ const issuer = currentCert.issuerCertificate;
251
+ // Break if no issuer or self-signed (issuer points to itself)
252
+ if (!issuer || issuer === currentCert) break;
253
+ currentCert = issuer;
254
+ }
255
+
256
+ // Check if any certificate in the chain matches our pinned keys
257
+ for (const c of certChain) {
258
+ if (c.pubkey) {
259
+ const spkiHash = createHash('sha256').update(c.pubkey).digest('base64');
260
+ if (PINNED_PUBLIC_KEYS.has(spkiHash)) {
261
+ debugLog('Certificate pin verified successfully');
262
+ return; // Valid pin found
263
+ }
264
+ }
265
+ }
266
+
267
+ throw new Error(`Certificate pinning failed for ${hostname}. None of the certificates in the chain match pinned public keys.`);
268
+ }
269
+
270
+ /**
271
+ * Verify expected security headers are present in the response.
272
+ */
273
+ function verifySecurityHeaders(headers, hostname) {
274
+ // Only verify for AgentMailbox domains
275
+ if (!PINNED_DOMAINS.has(hostname.toLowerCase())) {
276
+ return;
277
+ }
278
+
279
+ const warnings = [];
280
+
281
+ for (const [header, expectedValue] of Object.entries(EXPECTED_SECURITY_HEADERS)) {
282
+ const actualValue = headers[header];
283
+
284
+ if (expectedValue === true) {
285
+ // Just check presence
286
+ if (!actualValue) {
287
+ warnings.push(`Missing security header: ${header}`);
288
+ }
289
+ } else if (actualValue !== expectedValue) {
290
+ warnings.push(`Unexpected ${header} value: expected "${expectedValue}", got "${actualValue || 'missing'}"`);
291
+ }
292
+ }
293
+
294
+ if (warnings.length > 0) {
295
+ debugLog('Security header warnings', { warnings });
296
+ // Don't fail, just log warnings in debug mode
297
+ } else {
298
+ debugLog('Security headers verified');
299
+ }
300
+ }
301
+
302
+ if (!isValidUrl(tokenEndpoint)) {
303
+ console.error('Error: Invalid token-endpoint URL format');
304
+ process.exit(1);
305
+ }
306
+
307
+ if (!isValidUrl(mcpUrl)) {
308
+ console.error('Error: Invalid mcp-url URL format');
309
+ process.exit(1);
310
+ }
311
+
312
+ if (!isSecureEndpoint(tokenEndpoint)) {
313
+ console.error('Error: token-endpoint must use HTTPS (localhost allowed for development)');
314
+ process.exit(1);
315
+ }
316
+
317
+ if (!isSecureEndpoint(mcpUrl)) {
318
+ console.error('Error: mcp-url must use HTTPS (localhost allowed for development)');
49
319
  process.exit(1);
50
320
  }
51
321
 
@@ -60,12 +330,17 @@ function buildChildEnv(env) {
60
330
  // Temp directories
61
331
  'TMP', 'TEMP', 'TMPDIR',
62
332
  // Windows system variables
63
- 'SystemRoot', 'ComSpec', 'WINDIR', 'PATHEXT',
333
+ 'SystemRoot', 'ComSpec', 'WINDIR', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA',
64
334
  // Proxy configuration
65
335
  'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
66
336
  'http_proxy', 'https_proxy', 'no_proxy',
67
337
  // Node.js configuration
68
338
  'NODE_EXTRA_CA_CERTS', 'NODE_OPTIONS',
339
+ // npm/npx configuration (for custom registries and cache in enterprise environments)
340
+ 'NPM_CONFIG_REGISTRY', 'npm_config_registry',
341
+ 'NPM_CONFIG_USERCONFIG', 'npm_config_userconfig',
342
+ 'NPM_CONFIG_CACHE', 'npm_config_cache',
343
+ 'NPM_CONFIG_PREFIX', 'npm_config_prefix',
69
344
  // mcp-remote configuration directory
70
345
  'MCP_REMOTE_CONFIG_DIR',
71
346
  ];
@@ -74,6 +349,137 @@ function buildChildEnv(env) {
74
349
  );
75
350
  }
76
351
 
352
+ /**
353
+ * Validate that a string looks like a JWT token.
354
+ * JWTs have three base64url-encoded parts separated by dots.
355
+ */
356
+ function isValidJwtFormat(token) {
357
+ if (typeof token !== 'string') return false;
358
+ const parts = token.split('.');
359
+ if (parts.length !== 3) return false;
360
+
361
+ // Check each part is valid base64url (alphanumeric, -, _)
362
+ const base64urlRegex = /^[A-Za-z0-9_-]+$/;
363
+ return parts.every(part => part.length > 0 && base64urlRegex.test(part));
364
+ }
365
+
366
+ /**
367
+ * Sanitize error messages to prevent information leakage.
368
+ * Defensively handles non-string inputs to avoid secondary failures.
369
+ */
370
+ function sanitizeErrorMessage(message) {
371
+ // Safely coerce input to string
372
+ const safeMessage = typeof message === 'string' ? message : String(message ?? '');
373
+ // Remove potential sensitive data patterns
374
+ return safeMessage
375
+ .substring(0, 200) // Limit length
376
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [REDACTED]')
377
+ .replace(/client_secret[^&]*/gi, 'client_secret=[REDACTED]')
378
+ .replace(/access_token[^&]*/gi, 'access_token=[REDACTED]');
379
+ }
380
+
381
+ /**
382
+ * Sleep for a given number of milliseconds.
383
+ */
384
+ function sleep(ms) {
385
+ return new Promise(resolve => setTimeout(resolve, ms));
386
+ }
387
+
388
+ /**
389
+ * Calculate exponential backoff delay with jitter.
390
+ */
391
+ function calculateBackoffDelay(attempt) {
392
+ const exponentialDelay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt);
393
+ const cappedDelay = Math.min(exponentialDelay, MAX_RETRY_DELAY_MS);
394
+ // Add jitter (0-25% of delay) to prevent thundering herd
395
+ const jitter = cappedDelay * Math.random() * 0.25;
396
+ return Math.floor(cappedDelay + jitter);
397
+ }
398
+
399
+ /**
400
+ * Make HTTPS request with certificate pinning support.
401
+ */
402
+ function makeRequest(urlString, options, body) {
403
+ return new Promise((resolve, reject) => {
404
+ const url = new URL(urlString);
405
+ const isHttps = url.protocol === 'https:';
406
+ const requestFn = isHttps ? request : httpRequest;
407
+
408
+ const reqOptions = {
409
+ hostname: url.hostname,
410
+ port: url.port || (isHttps ? 443 : 80),
411
+ path: url.pathname + url.search,
412
+ method: options.method || 'GET',
413
+ headers: options.headers || {},
414
+ timeout: options.timeout || TOKEN_TIMEOUT_MS,
415
+ };
416
+
417
+ // Add certificate pinning during TLS handshake (before credentials are sent)
418
+ if (isHttps) {
419
+ reqOptions.checkServerIdentity = (host, cert) => {
420
+ // First, perform standard hostname verification
421
+ const hostnameError = tlsCheckServerIdentity(host, cert);
422
+ if (hostnameError) return hostnameError;
423
+ // Then verify certificate pinning
424
+ try {
425
+ verifyCertificatePin(cert, host);
426
+ return undefined; // Success
427
+ } catch (e) {
428
+ return e instanceof Error ? e : new Error('Certificate pinning failed');
429
+ }
430
+ };
431
+ }
432
+
433
+ debugLog('Making request', { hostname: url.hostname, method: reqOptions.method, path: reqOptions.path });
434
+
435
+ const req = requestFn(reqOptions, (res) => {
436
+ // Verify security headers
437
+ verifySecurityHeaders(res.headers, url.hostname);
438
+
439
+ let data = '';
440
+ let receivedLength = 0;
441
+
442
+ res.on('data', (chunk) => {
443
+ receivedLength += chunk.length;
444
+ if (receivedLength > MAX_TOKEN_RESPONSE_SIZE) {
445
+ req.destroy();
446
+ reject(new Error('Token response exceeded maximum allowed size'));
447
+ return;
448
+ }
449
+ data += chunk;
450
+ });
451
+
452
+ res.on('end', () => {
453
+ debugLog('Request completed', { status: res.statusCode, dataLength: data.length });
454
+ resolve({
455
+ status: res.statusCode,
456
+ headers: res.headers,
457
+ data,
458
+ });
459
+ });
460
+ });
461
+
462
+ req.on('error', (err) => {
463
+ debugLog('Request error', { error: err.message });
464
+ reject(new Error('Failed to connect to token endpoint'));
465
+ });
466
+
467
+ req.on('timeout', () => {
468
+ req.destroy();
469
+ debugLog('Request timeout');
470
+ reject(new Error(`Token request timed out after ${TOKEN_TIMEOUT_MS}ms`));
471
+ });
472
+
473
+ if (body) {
474
+ req.write(body);
475
+ }
476
+ req.end();
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Get access token with exponential backoff retry logic.
482
+ */
77
483
  async function getAccessToken() {
78
484
  const params = new URLSearchParams({
79
485
  grant_type: 'client_credentials',
@@ -82,38 +488,73 @@ async function getAccessToken() {
82
488
  scope: scope || 'mcp-api/email.full',
83
489
  });
84
490
 
85
- // Use AbortController for timeout
86
- const controller = new AbortController();
87
- const timeoutId = setTimeout(() => controller.abort(), TOKEN_TIMEOUT_MS);
491
+ let lastError;
88
492
 
89
- let response;
90
- try {
91
- response = await fetch(tokenEndpoint, {
92
- method: 'POST',
93
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
94
- body: params.toString(),
95
- signal: controller.signal,
96
- });
97
- } catch (err) {
98
- clearTimeout(timeoutId);
99
- if (err.name === 'AbortError') {
100
- throw new Error(`Token request timed out after ${TOKEN_TIMEOUT_MS}ms`);
493
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
494
+ if (attempt > 0) {
495
+ const delay = calculateBackoffDelay(attempt - 1);
496
+ debugLog(`Retry attempt ${attempt}/${MAX_RETRIES}`, { delayMs: delay });
497
+ await sleep(delay);
101
498
  }
102
- throw err;
103
- } finally {
104
- clearTimeout(timeoutId);
105
- }
106
499
 
107
- if (!response.ok) {
108
- const error = await response.text();
109
- throw new Error(`Token request failed: ${response.status} ${error}`);
110
- }
500
+ try {
501
+ const response = await makeRequest(tokenEndpoint, {
502
+ method: 'POST',
503
+ headers: {
504
+ 'Content-Type': 'application/x-www-form-urlencoded',
505
+ 'Content-Length': Buffer.byteLength(params.toString()),
506
+ },
507
+ timeout: TOKEN_TIMEOUT_MS,
508
+ }, params.toString());
111
509
 
112
- const data = await response.json();
113
- if (!data || typeof data.access_token !== 'string' || data.access_token.trim() === '') {
114
- throw new Error('Missing or invalid access_token in token response');
510
+ // Don't retry on client errors (4xx) - these won't succeed on retry
511
+ if (response.status >= 400 && response.status < 500) {
512
+ debugLog('Client error, not retrying', { status: response.status });
513
+ throw new Error(`Token request failed with status ${response.status}`);
514
+ }
515
+
516
+ // Retry on server errors (5xx) or network issues
517
+ if (response.status !== 200) {
518
+ throw new Error(`Token request failed with status ${response.status}`);
519
+ }
520
+
521
+ let data;
522
+ try {
523
+ data = JSON.parse(response.data);
524
+ } catch {
525
+ throw new Error('Invalid JSON response from token endpoint');
526
+ }
527
+
528
+ if (!data || typeof data.access_token !== 'string' || data.access_token.trim() === '') {
529
+ throw new Error('Missing access_token in response');
530
+ }
531
+
532
+ const token = data.access_token.trim();
533
+
534
+ // Validate token format (should be JWT)
535
+ if (!isValidJwtFormat(token)) {
536
+ throw new Error('Invalid token format received');
537
+ }
538
+
539
+ debugLog('Access token obtained successfully');
540
+ return token;
541
+
542
+ } catch (err) {
543
+ lastError = err;
544
+ debugLog('Token request failed', { attempt, error: err.message });
545
+
546
+ // Don't retry on certain errors
547
+ if (err.message.includes('Certificate pinning failed') ||
548
+ err.message.includes('Invalid token format') ||
549
+ err.message.includes('Missing access_token') ||
550
+ err.message.includes('status 4')) {
551
+ throw err;
552
+ }
553
+ }
115
554
  }
116
- return data.access_token;
555
+
556
+ // All retries exhausted
557
+ throw lastError || new Error('Failed to obtain access token after retries');
117
558
  }
118
559
 
119
560
  async function main() {
@@ -123,25 +564,33 @@ async function main() {
123
564
  // Build minimal environment with only necessary variables
124
565
  const childEnv = buildChildEnv(process.env);
125
566
 
126
- // Launch mcp-remote with Authorization header via --header flag
127
- // Note: Token appears briefly in process args, but this is acceptable for MCP client usage
567
+ // Add token to child environment for mcp-remote to use
568
+ // This avoids exposing the token in process arguments
569
+ childEnv.MCP_HEADER_Authorization = `Bearer ${token}`;
570
+
571
+ debugLog('Launching mcp-remote', { version: MCP_REMOTE_VERSION, mcpUrl });
572
+
573
+ // Launch mcp-remote with pinned version
574
+ // Token is passed via environment variable, not command line args
128
575
  const child = spawn('npx', [
129
576
  '-y',
130
- 'mcp-remote',
577
+ `mcp-remote@${MCP_REMOTE_VERSION}`,
131
578
  mcpUrl,
132
579
  '--header',
133
- `Authorization:Bearer ${token}`,
580
+ 'Authorization:${MCP_HEADER_Authorization}',
134
581
  ], {
135
582
  stdio: 'inherit',
136
583
  env: childEnv,
137
584
  });
138
585
 
139
586
  child.on('error', (err) => {
140
- console.error('Failed to start mcp-remote:', err);
587
+ const rawMessage = err instanceof Error ? err.message : err;
588
+ console.error('Failed to start mcp-remote:', sanitizeErrorMessage(rawMessage));
141
589
  process.exit(1);
142
590
  });
143
591
 
144
592
  child.on('exit', (code, signal) => {
593
+ debugLog('mcp-remote exited', { code, signal });
145
594
  if (code !== null) {
146
595
  process.exit(code);
147
596
  } else if (signal !== null) {
@@ -154,6 +603,7 @@ async function main() {
154
603
 
155
604
  // Forward signals to child process for graceful shutdown
156
605
  const forwardSignal = (sig) => {
606
+ debugLog('Forwarding signal to child', { signal: sig });
157
607
  if (child.pid) {
158
608
  child.kill(sig);
159
609
  }
@@ -161,7 +611,8 @@ async function main() {
161
611
  process.on('SIGINT', () => forwardSignal('SIGINT'));
162
612
  process.on('SIGTERM', () => forwardSignal('SIGTERM'));
163
613
  } catch (err) {
164
- console.error('Error:', err.message);
614
+ const rawMessage = err instanceof Error ? err.message : err;
615
+ console.error('Error:', sanitizeErrorMessage(rawMessage));
165
616
  process.exit(1);
166
617
  }
167
618
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentmailbox/mcp-auth",
3
- "version": "1.0.10",
3
+ "version": "1.1.1",
4
4
  "description": "OAuth2 Client Credentials wrapper for MCP servers - enables M2M authentication with AgentMailbox",
5
5
  "type": "module",
6
6
  "bin": {