@agentmailbox/mcp-auth 1.0.5 → 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 +499 -49
  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": {
@@ -19,7 +30,7 @@
19
30
  * ],
20
31
  * "env": {
21
32
  * "MCP_OAUTH_CLIENT_ID": "your-client-id",
22
- * "MCP_OAUTH_CLIENT_SECRET": "your-client-secret"
33
+ * "MCP_OAUTH_CLIENT_SECRET": "your-client-secret" // pragma: allowlist secret
23
34
  * }
24
35
  * }
25
36
  * }
@@ -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,38 +162,324 @@ 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
 
52
322
  /**
53
- * Build a minimal environment for the child process.
54
- * Only passes through essential variables to avoid leaking secrets.
323
+ * Build a minimal allow-list environment for the child process.
324
+ * Only includes necessary system variables, avoiding exposure of ambient secrets.
55
325
  */
56
326
  function buildChildEnv(env) {
57
327
  const allowList = [
58
- // Essential for process execution
328
+ // Essential system paths
59
329
  'PATH', 'HOME', 'USERPROFILE',
60
330
  // Temp directories
61
331
  'TMP', 'TEMP', 'TMPDIR',
62
- // Windows-specific
63
- 'SystemRoot', 'ComSpec', 'WINDIR', 'PATHEXT',
64
- // Proxy settings (important for corporate environments)
332
+ // Windows system variables
333
+ 'SystemRoot', 'ComSpec', 'WINDIR', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA',
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',
69
- // MCP-specific
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',
344
+ // mcp-remote configuration directory
70
345
  'MCP_REMOTE_CONFIG_DIR',
71
346
  ];
72
-
73
347
  return Object.fromEntries(
74
348
  allowList.flatMap((key) => (env[key] ? [[key, env[key]]] : []))
75
349
  );
76
350
  }
77
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
+ */
78
483
  async function getAccessToken() {
79
484
  const params = new URLSearchParams({
80
485
  grant_type: 'client_credentials',
@@ -83,38 +488,73 @@ async function getAccessToken() {
83
488
  scope: scope || 'mcp-api/email.full',
84
489
  });
85
490
 
86
- // Use AbortController for timeout
87
- const controller = new AbortController();
88
- const timeoutId = setTimeout(() => controller.abort(), TOKEN_TIMEOUT_MS);
491
+ let lastError;
89
492
 
90
- let response;
91
- try {
92
- response = await fetch(tokenEndpoint, {
93
- method: 'POST',
94
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
95
- body: params.toString(),
96
- signal: controller.signal,
97
- });
98
- } catch (err) {
99
- clearTimeout(timeoutId);
100
- if (err.name === 'AbortError') {
101
- 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);
102
498
  }
103
- throw err;
104
- } finally {
105
- clearTimeout(timeoutId);
106
- }
107
499
 
108
- if (!response.ok) {
109
- const error = await response.text();
110
- throw new Error(`Token request failed: ${response.status} ${error}`);
111
- }
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());
509
+
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();
112
533
 
113
- const data = await response.json();
114
- if (!data || typeof data.access_token !== 'string' || data.access_token.trim() === '') {
115
- throw new Error('Missing or invalid access_token in token response');
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
+ }
116
554
  }
117
- return data.access_token;
555
+
556
+ // All retries exhausted
557
+ throw lastError || new Error('Failed to obtain access token after retries');
118
558
  }
119
559
 
120
560
  async function main() {
@@ -124,25 +564,33 @@ async function main() {
124
564
  // Build minimal environment with only necessary variables
125
565
  const childEnv = buildChildEnv(process.env);
126
566
 
127
- // Launch mcp-remote with Authorization header via --header flag
128
- // 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
129
575
  const child = spawn('npx', [
130
576
  '-y',
131
- 'mcp-remote',
577
+ `mcp-remote@${MCP_REMOTE_VERSION}`,
132
578
  mcpUrl,
133
579
  '--header',
134
- `Authorization:Bearer ${token}`,
580
+ 'Authorization:${MCP_HEADER_Authorization}',
135
581
  ], {
136
582
  stdio: 'inherit',
137
583
  env: childEnv,
138
584
  });
139
585
 
140
586
  child.on('error', (err) => {
141
- 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));
142
589
  process.exit(1);
143
590
  });
144
591
 
145
592
  child.on('exit', (code, signal) => {
593
+ debugLog('mcp-remote exited', { code, signal });
146
594
  if (code !== null) {
147
595
  process.exit(code);
148
596
  } else if (signal !== null) {
@@ -155,6 +603,7 @@ async function main() {
155
603
 
156
604
  // Forward signals to child process for graceful shutdown
157
605
  const forwardSignal = (sig) => {
606
+ debugLog('Forwarding signal to child', { signal: sig });
158
607
  if (child.pid) {
159
608
  child.kill(sig);
160
609
  }
@@ -162,7 +611,8 @@ async function main() {
162
611
  process.on('SIGINT', () => forwardSignal('SIGINT'));
163
612
  process.on('SIGTERM', () => forwardSignal('SIGTERM'));
164
613
  } catch (err) {
165
- console.error('Error:', err.message);
614
+ const rawMessage = err instanceof Error ? err.message : err;
615
+ console.error('Error:', sanitizeErrorMessage(rawMessage));
166
616
  process.exit(1);
167
617
  }
168
618
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentmailbox/mcp-auth",
3
- "version": "1.0.5",
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": {