mcp-compose 0.3.0 → 0.4.0

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 (40) hide show
  1. package/README.md +80 -4
  2. package/dist/bin/mcp-compose.js +78 -20
  3. package/dist/bin/mcp-compose.js.map +1 -1
  4. package/dist/bin/mcp-gateway.js +92 -17
  5. package/dist/bin/mcp-gateway.js.map +1 -1
  6. package/dist/package.json +21 -3
  7. package/dist/src/command.d.ts +5 -1
  8. package/dist/src/command.d.ts.map +1 -1
  9. package/dist/src/command.js +24 -22
  10. package/dist/src/command.js.map +1 -1
  11. package/dist/src/config.d.ts +4 -1
  12. package/dist/src/config.d.ts.map +1 -1
  13. package/dist/src/config.js +20 -4
  14. package/dist/src/config.js.map +1 -1
  15. package/dist/src/gateway.d.ts +1 -0
  16. package/dist/src/gateway.d.ts.map +1 -1
  17. package/dist/src/gateway.js +2070 -222
  18. package/dist/src/gateway.js.map +1 -1
  19. package/dist/src/oauth.d.ts +27 -3
  20. package/dist/src/oauth.d.ts.map +1 -1
  21. package/dist/src/oauth.js +557 -78
  22. package/dist/src/oauth.js.map +1 -1
  23. package/dist/src/pm2-args.d.ts +2 -0
  24. package/dist/src/pm2-args.d.ts.map +1 -0
  25. package/dist/src/pm2-args.js +19 -0
  26. package/dist/src/pm2-args.js.map +1 -0
  27. package/dist/src/pm2.d.ts +3 -1
  28. package/dist/src/pm2.d.ts.map +1 -1
  29. package/dist/src/pm2.js +85 -75
  30. package/dist/src/pm2.js.map +1 -1
  31. package/dist/src/process-tree.d.ts +4 -0
  32. package/dist/src/process-tree.d.ts.map +1 -0
  33. package/dist/src/process-tree.js +81 -0
  34. package/dist/src/process-tree.js.map +1 -0
  35. package/dist/src/sync.d.ts.map +1 -1
  36. package/dist/src/sync.js +70 -4
  37. package/dist/src/sync.js.map +1 -1
  38. package/dist/src/types.d.ts +9 -0
  39. package/dist/src/types.d.ts.map +1 -1
  40. package/package.json +21 -3
package/dist/src/oauth.js CHANGED
@@ -3,11 +3,13 @@ import { createHash, randomBytes } from 'node:crypto';
3
3
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
- import { exec } from 'node:child_process';
6
+ import { spawn } from 'node:child_process';
7
7
  // --- Constants ---
8
8
  const CONFIG_DIR = join(homedir(), '.mcp-auth', 'mcp-compose');
9
9
  const DEFAULT_AUTH_TIMEOUT = 120_000;
10
- const MCP_PROTOCOL_VERSION = '2025-03-26';
10
+ const MCP_PROTOCOL_VERSION = '2026-07-28';
11
+ /** OAuth metadata and token endpoints are remote input. Never buffer them without a limit. */
12
+ const MAX_OAUTH_RESPONSE_BODY_BYTES = 1024 * 1024;
11
13
  const CLIENT_NAME = 'mcp-compose';
12
14
  // --- Helpers ---
13
15
  function serverHash(url) {
@@ -16,27 +18,242 @@ function serverHash(url) {
16
18
  function generateCodeVerifier() {
17
19
  return randomBytes(32).toString('base64url');
18
20
  }
21
+ function generateState() {
22
+ return randomBytes(32).toString('base64url');
23
+ }
19
24
  function generateCodeChallenge(verifier) {
20
25
  return createHash('sha256').update(verifier).digest('base64url');
21
26
  }
27
+ function normalizeResourceUrl(serverUrl) {
28
+ const url = new URL(serverUrl);
29
+ url.search = '';
30
+ url.hash = '';
31
+ return url.toString();
32
+ }
33
+ function validateAuthorizationServerIssuer(value) {
34
+ if (typeof value !== 'string')
35
+ throw new Error('OAuth protected-resource metadata has an invalid authorization server');
36
+ let url;
37
+ try {
38
+ url = new URL(value);
39
+ }
40
+ catch {
41
+ throw new Error('OAuth protected-resource metadata has an invalid authorization server');
42
+ }
43
+ if (!['http:', 'https:'].includes(url.protocol)
44
+ || url.username || url.password || url.hash || url.search) {
45
+ throw new Error('OAuth protected-resource metadata has an invalid authorization server');
46
+ }
47
+ if (url.protocol === 'http:' && !isLiteralLoopbackHost(url.hostname)) {
48
+ throw new Error('OAuth authorization server must use HTTPS outside loopback');
49
+ }
50
+ return value;
51
+ }
52
+ function isLiteralLoopbackHost(hostname) {
53
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
54
+ return host === 'localhost' || host === '::1' || /^127(?:\.\d{1,3}){3}$/.test(host);
55
+ }
56
+ function validateOAuthEndpoint(value, label) {
57
+ let url;
58
+ try {
59
+ url = new URL(value);
60
+ }
61
+ catch {
62
+ throw new Error(`OAuth ${label} endpoint is invalid`);
63
+ }
64
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash
65
+ || (url.protocol === 'http:' && !isLiteralLoopbackHost(url.hostname))) {
66
+ throw new Error(`OAuth ${label} endpoint must use HTTPS outside loopback`);
67
+ }
68
+ return url.toString();
69
+ }
22
70
  async function ensureConfigDir() {
23
71
  await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
24
72
  }
25
- function openBrowser(url) {
26
- const cmd = process.platform === 'darwin' ? 'open'
27
- : process.platform === 'win32' ? 'start ""'
28
- : 'xdg-open';
29
- exec(`${cmd} "${url}"`, (err) => {
30
- if (err) {
31
- process.stderr.write(`Please open this URL in your browser:\n${url}\n`);
32
- }
73
+ const defaultBrowserLauncher = (command, args, options) => spawn(command, args, options);
74
+ /** Opens an authorization URL as a literal process argument, never through a shell. */
75
+ export function openBrowser(url, launchBrowser = defaultBrowserLauncher) {
76
+ let command;
77
+ let args;
78
+ if (process.platform === 'darwin') {
79
+ command = 'open';
80
+ args = [url];
81
+ }
82
+ else if (process.platform === 'win32') {
83
+ // `start` is a cmd.exe built-in. rundll32 accepts the URL as a direct
84
+ // argument, so URL syntax cannot become shell syntax on Windows.
85
+ command = 'rundll32.exe';
86
+ args = ['url.dll,FileProtocolHandler', url];
87
+ }
88
+ else {
89
+ command = 'xdg-open';
90
+ args = [url];
91
+ }
92
+ const child = launchBrowser(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
93
+ child.once('error', () => {
94
+ process.stderr.write(`Please open this URL in your browser:\n${url}\n`);
33
95
  });
96
+ child.unref();
34
97
  }
35
- function parseWwwAuthenticateScope(header) {
98
+ function normalizeScope(scope) {
99
+ if (!scope)
100
+ return undefined;
101
+ const tokens = scope.trim().split(/\s+/);
102
+ return tokens.length > 0 && tokens.every((token) => /^[\x21\x23-\x5B\x5D-\x7E]+$/.test(token))
103
+ ? tokens.join(' ')
104
+ : undefined;
105
+ }
106
+ function scopeTokens(scope) {
107
+ return new Set(normalizeScope(scope)?.split(' ') ?? []);
108
+ }
109
+ function isScopeSubset(scope, allowedScope) {
110
+ const requested = scopeTokens(scope);
111
+ const allowed = scopeTokens(allowedScope);
112
+ return requested.size === 0 || (allowed.size > 0 && [...requested].every((token) => allowed.has(token)));
113
+ }
114
+ function unionScopes(...scopes) {
115
+ const tokens = new Set();
116
+ for (const scope of scopes) {
117
+ for (const token of scopeTokens(scope))
118
+ tokens.add(token);
119
+ }
120
+ return tokens.size > 0 ? [...tokens].join(' ') : undefined;
121
+ }
122
+ function metadataScopes(scope) {
123
+ return Array.isArray(scope) ? normalizeScope(scope.filter((value) => typeof value === 'string').join(' ')) : undefined;
124
+ }
125
+ /** Parse Bearer auth-params without mistaking quoted commas or escaped quotes for challenge boundaries. */
126
+ function parseBearerChallenge(header) {
36
127
  if (!header)
37
128
  return undefined;
38
- const match = /scope="([^"]+)"/.exec(header);
39
- return match?.[1];
129
+ let position = 0;
130
+ let firstBearer;
131
+ const readToken = () => {
132
+ const start = position;
133
+ while (position < header.length && /[!#$%&'*+.^_`|~0-9A-Za-z-]/.test(header[position] ?? ''))
134
+ position += 1;
135
+ return position > start ? header.slice(start, position) : undefined;
136
+ };
137
+ const skipWhitespace = () => {
138
+ while (position < header.length && /[ \t]/.test(header[position] ?? ''))
139
+ position += 1;
140
+ };
141
+ const readValue = () => {
142
+ if (header[position] !== '"')
143
+ return readToken();
144
+ position += 1;
145
+ let value = '';
146
+ while (position < header.length) {
147
+ const char = header[position++] ?? '';
148
+ if (char === '"')
149
+ return value;
150
+ if (char === '\\' && position < header.length)
151
+ value += header[position++] ?? '';
152
+ else
153
+ value += char;
154
+ }
155
+ return undefined;
156
+ };
157
+ while (position < header.length) {
158
+ while (header[position] === ',' || /[ \t]/.test(header[position] ?? ''))
159
+ position += 1;
160
+ const scheme = readToken();
161
+ if (!scheme)
162
+ break;
163
+ skipWhitespace();
164
+ const params = new Map();
165
+ while (position < header.length) {
166
+ const saved = position;
167
+ const name = readToken();
168
+ skipWhitespace();
169
+ if (!name || header[position] !== '=') {
170
+ position = saved;
171
+ break;
172
+ }
173
+ position += 1;
174
+ skipWhitespace();
175
+ const value = readValue();
176
+ if (value === undefined)
177
+ break;
178
+ params.set(name.toLowerCase(), value);
179
+ skipWhitespace();
180
+ if (header[position] !== ',')
181
+ break;
182
+ position += 1;
183
+ const afterComma = position;
184
+ skipWhitespace();
185
+ readToken();
186
+ skipWhitespace();
187
+ const isNextParam = header[position] === '=';
188
+ position = afterComma;
189
+ if (!isNextParam)
190
+ break;
191
+ skipWhitespace();
192
+ }
193
+ if (scheme.toLowerCase() === 'bearer') {
194
+ firstBearer ??= params;
195
+ if (params.has('scope') || params.has('resource_metadata'))
196
+ return params;
197
+ }
198
+ }
199
+ return firstBearer;
200
+ }
201
+ function parseWwwAuthenticateScope(header) {
202
+ return normalizeScope(parseBearerChallenge(header)?.get('scope'));
203
+ }
204
+ function challengeMetadataUrl(header, serverUrl) {
205
+ const raw = parseBearerChallenge(header)?.get('resource_metadata');
206
+ if (!raw)
207
+ return undefined;
208
+ try {
209
+ const expected = new URL(serverUrl);
210
+ const metadata = new URL(raw);
211
+ if (metadata.origin !== expected.origin
212
+ || !['http:', 'https:'].includes(metadata.protocol)
213
+ || metadata.username || metadata.password || metadata.hash)
214
+ return undefined;
215
+ return metadata.toString();
216
+ }
217
+ catch {
218
+ return undefined;
219
+ }
220
+ }
221
+ function abortError(signal) {
222
+ return signal.reason instanceof Error ? signal.reason : new Error('OAuth operation aborted');
223
+ }
224
+ function throwIfAborted(signal) {
225
+ if (signal.aborted)
226
+ throw abortError(signal);
227
+ }
228
+ /**
229
+ * Use one deadline for the whole operation, rather than giving each fallback
230
+ * endpoint a fresh timeout. This prevents a discovery chain from exceeding
231
+ * the lifetime of its gateway request.
232
+ */
233
+ function createBoundedOperation(timeout, callerSignal) {
234
+ const controller = new AbortController();
235
+ const abortFromCaller = () => { controller.abort(callerSignal?.reason); };
236
+ if (callerSignal?.aborted)
237
+ abortFromCaller();
238
+ else
239
+ callerSignal?.addEventListener('abort', abortFromCaller, { once: true });
240
+ const timer = setTimeout(() => { controller.abort(new Error('OAuth operation timed out')); }, timeout);
241
+ timer.unref();
242
+ return {
243
+ signal: controller.signal,
244
+ dispose: () => {
245
+ clearTimeout(timer);
246
+ callerSignal?.removeEventListener('abort', abortFromCaller);
247
+ },
248
+ };
249
+ }
250
+ async function awaitWithSignal(promise, signal) {
251
+ throwIfAborted(signal);
252
+ return await new Promise((resolve, reject) => {
253
+ const onAbort = () => { reject(abortError(signal)); };
254
+ signal.addEventListener('abort', onAbort, { once: true });
255
+ promise.then(resolve, reject).finally(() => { signal.removeEventListener('abort', onAbort); });
256
+ });
40
257
  }
41
258
  function listenOnRandomPort(server) {
42
259
  return new Promise((resolve) => {
@@ -60,24 +277,132 @@ async function saveStoredAuth(hash, auth) {
60
277
  await writeFile(join(CONFIG_DIR, `${hash}.json`), JSON.stringify(auth, null, 2), { mode: 0o600 });
61
278
  }
62
279
  // --- Discovery ---
63
- async function fetchJson(url, headers) {
280
+ class OAuthResponseTooLargeError extends Error {
281
+ constructor() {
282
+ super(`OAuth response body exceeds ${String(MAX_OAUTH_RESPONSE_BODY_BYTES)} bytes`);
283
+ }
284
+ }
285
+ async function readBoundedResponseText(res) {
286
+ const body = res.body;
287
+ if (!body)
288
+ return '';
289
+ const contentLength = res.headers.get('content-length');
290
+ if (contentLength && /^\d+$/.test(contentLength) && Number(contentLength) > MAX_OAUTH_RESPONSE_BODY_BYTES) {
291
+ await body.cancel();
292
+ throw new OAuthResponseTooLargeError();
293
+ }
294
+ const reader = body.getReader();
295
+ const chunks = [];
296
+ let byteLength = 0;
64
297
  try {
65
- const res = headers ? await fetch(url, { headers }) : await fetch(url);
298
+ for (;;) {
299
+ const result = await reader.read();
300
+ if (result.done)
301
+ break;
302
+ const value = result.value;
303
+ byteLength += value.byteLength;
304
+ if (byteLength > MAX_OAUTH_RESPONSE_BODY_BYTES) {
305
+ await reader.cancel();
306
+ throw new OAuthResponseTooLargeError();
307
+ }
308
+ chunks.push(value);
309
+ }
310
+ }
311
+ finally {
312
+ reader.releaseLock();
313
+ }
314
+ return Buffer.concat(chunks).toString('utf8');
315
+ }
316
+ async function readBoundedJson(res) {
317
+ return JSON.parse(await readBoundedResponseText(res));
318
+ }
319
+ /** Consume an ignored error response up to the same limit, then release its socket. */
320
+ async function discardBoundedResponseBody(res) {
321
+ const body = res.body;
322
+ if (!body)
323
+ return;
324
+ const contentLength = res.headers.get('content-length');
325
+ if (contentLength && /^\d+$/.test(contentLength) && Number(contentLength) > MAX_OAUTH_RESPONSE_BODY_BYTES) {
326
+ await body.cancel();
327
+ return;
328
+ }
329
+ const reader = body.getReader();
330
+ let byteLength = 0;
331
+ try {
332
+ for (;;) {
333
+ const result = await reader.read();
334
+ if (result.done)
335
+ return;
336
+ byteLength += result.value.byteLength;
337
+ if (byteLength > MAX_OAUTH_RESPONSE_BODY_BYTES) {
338
+ await reader.cancel();
339
+ return;
340
+ }
341
+ }
342
+ }
343
+ finally {
344
+ reader.releaseLock();
345
+ }
346
+ }
347
+ async function fetchJson(url, signal, headers) {
348
+ try {
349
+ throwIfAborted(signal);
350
+ const res = headers
351
+ ? await fetch(url, { headers, signal, redirect: 'error' })
352
+ : await fetch(url, { signal, redirect: 'error' });
66
353
  if (res.ok)
67
- return await res.json();
354
+ return await readBoundedJson(res);
355
+ await discardBoundedResponseBody(res);
68
356
  }
69
- catch {
357
+ catch (error) {
358
+ throwIfAborted(signal);
359
+ if (error instanceof OAuthResponseTooLargeError)
360
+ throw error;
70
361
  // ignore network errors, continue
71
362
  }
72
363
  return undefined;
73
364
  }
74
- async function discoverProtectedResource(serverUrl, headers) {
75
- const url = new URL(serverUrl);
76
- const fetchHeaders = { 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION, ...headers };
77
- // Path-aware URL first, then root fallback (RFC 9728)
78
- return await fetchJson(new URL(`/.well-known/oauth-protected-resource${url.pathname}`, url.origin).toString(), fetchHeaders) ?? await fetchJson(new URL('/.well-known/oauth-protected-resource', url.origin).toString(), fetchHeaders);
365
+ async function discoverProtectedResource(serverUrl, signal, headers, challengedMetadataUrl) {
366
+ const resource = normalizeResourceUrl(serverUrl);
367
+ const fetchHeaders = Object.fromEntries(Object.entries(headers ?? {})
368
+ .filter(([name]) => name.toLowerCase() !== 'mcp-protocol-version'));
369
+ // Headers are case-insensitive on the wire. Set this after user headers so
370
+ // discovery always carries the protocol version this client implements.
371
+ fetchHeaders['MCP-Protocol-Version'] = MCP_PROTOCOL_VERSION;
372
+ if (challengedMetadataUrl) {
373
+ const metadata = await fetchJson(challengedMetadataUrl, signal, fetchHeaders);
374
+ if (metadata)
375
+ return validateProtectedResourceMetadata(metadata, resource, challengedMetadataUrl, 'resource_metadata');
376
+ }
377
+ // Path-aware URL first, then root fallback (RFC 9728).
378
+ for (const metadataUrl of protectedResourceMetadataUrls(resource)) {
379
+ const metadata = await fetchJson(metadataUrl, signal, fetchHeaders);
380
+ if (metadata)
381
+ return validateProtectedResourceMetadata(metadata, resource, metadataUrl, 'discovery');
382
+ }
383
+ return undefined;
384
+ }
385
+ function protectedResourceMetadataUrl(resource) {
386
+ const url = new URL(resource);
387
+ return new URL(`/.well-known/oauth-protected-resource${url.pathname}`, url.origin).toString();
388
+ }
389
+ function protectedResourceMetadataUrls(resource) {
390
+ const url = new URL(resource);
391
+ return [...new Set([
392
+ protectedResourceMetadataUrl(resource),
393
+ new URL('/.well-known/oauth-protected-resource', url.origin).toString(),
394
+ ])];
79
395
  }
80
- async function discoverAuthServer(authServerUrl) {
396
+ /** RFC 9728 §3.3 requires exact string identity, after JSON decoding, not URL normalization. */
397
+ function validateProtectedResourceMetadata(metadata, resource, metadataUrl, source) {
398
+ if (metadata.resource !== resource
399
+ || (source === 'discovery' && !protectedResourceMetadataUrls(resource).includes(metadataUrl))) {
400
+ const label = source === 'resource_metadata' ? 'resource_metadata' : 'protected-resource metadata';
401
+ throw new Error(`OAuth ${label} does not bind to its protected resource`);
402
+ }
403
+ return metadata;
404
+ }
405
+ async function discoverAuthServer(authServerUrl, signal) {
81
406
  const url = new URL(authServerUrl);
82
407
  const hasPath = url.pathname !== '/' && url.pathname !== '';
83
408
  // RFC 8414 + OIDC Discovery URLs in priority order
@@ -90,16 +415,20 @@ async function discoverAuthServer(authServerUrl) {
90
415
  new URL('/.well-known/openid-configuration', url.origin),
91
416
  ];
92
417
  for (const discoveryUrl of urls) {
93
- const meta = await fetchJson(discoveryUrl.toString());
94
- if (meta)
418
+ const meta = await fetchJson(discoveryUrl.toString(), signal);
419
+ if (meta) {
420
+ if (typeof meta.issuer !== 'string' || meta.issuer !== authServerUrl) {
421
+ throw new Error('OAuth authorization-server metadata issuer is not bound to its server');
422
+ }
95
423
  return meta;
424
+ }
96
425
  }
97
426
  return undefined;
98
427
  }
99
428
  // --- Registration (RFC 7591) ---
100
- async function registerClient(authServerUrl, metadata, redirectUri) {
101
- const endpoint = metadata?.registration_endpoint
102
- ?? new URL('/register', authServerUrl).toString();
429
+ async function registerClient(authServerUrl, metadata, redirectUri, signal) {
430
+ const endpoint = validateOAuthEndpoint(metadata?.registration_endpoint
431
+ ?? new URL('/register', authServerUrl).toString(), 'registration');
103
432
  const res = await fetch(endpoint, {
104
433
  method: 'POST',
105
434
  headers: { 'Content-Type': 'application/json' },
@@ -109,12 +438,16 @@ async function registerClient(authServerUrl, metadata, redirectUri) {
109
438
  grant_types: ['authorization_code', 'refresh_token'],
110
439
  response_types: ['code'],
111
440
  token_endpoint_auth_method: 'none',
441
+ application_type: 'native',
112
442
  }),
443
+ signal,
444
+ redirect: 'error',
113
445
  });
114
446
  if (!res.ok) {
115
- throw new Error(`Client registration failed: ${String(res.status)} ${await res.text()}`);
447
+ await readBoundedResponseText(res);
448
+ throw new Error(`Client registration failed with HTTP ${String(res.status)}`);
116
449
  }
117
- const info = await res.json();
450
+ const info = await readBoundedJson(res);
118
451
  info.redirect_uris = [redirectUri];
119
452
  return info;
120
453
  }
@@ -129,56 +462,92 @@ function buildClientAuthHeaders(clientInfo) {
129
462
  }
130
463
  return headers;
131
464
  }
132
- async function exchangeCodeForTokens(authServerUrl, metadata, clientInfo, code, codeVerifier, redirectUri) {
133
- const tokenUrl = metadata?.token_endpoint ?? new URL('/token', authServerUrl).toString();
465
+ function requireBearerToken(tokens) {
466
+ if (tokens.token_type.toLowerCase() !== 'bearer') {
467
+ throw new Error(`Unsupported OAuth token type: ${tokens.token_type}`);
468
+ }
469
+ return tokens;
470
+ }
471
+ async function exchangeCodeForTokens(authServerUrl, metadata, clientInfo, code, codeVerifier, redirectUri, resource, signal) {
472
+ const tokenUrl = validateOAuthEndpoint(metadata?.token_endpoint ?? new URL('/token', authServerUrl).toString(), 'token');
134
473
  const params = new URLSearchParams({
135
474
  grant_type: 'authorization_code',
136
475
  code,
137
476
  code_verifier: codeVerifier,
138
477
  redirect_uri: redirectUri,
139
478
  client_id: clientInfo.client_id,
479
+ resource,
140
480
  });
141
481
  const res = await fetch(tokenUrl, {
142
482
  method: 'POST',
143
483
  headers: buildClientAuthHeaders(clientInfo),
144
484
  body: params.toString(),
485
+ signal,
486
+ redirect: 'error',
145
487
  });
146
488
  if (!res.ok) {
147
- throw new Error(`Token exchange failed: ${String(res.status)} ${await res.text()}`);
489
+ await readBoundedResponseText(res);
490
+ throw new Error(`Token exchange failed with HTTP ${String(res.status)}`);
148
491
  }
149
- return await res.json();
492
+ return requireBearerToken(await readBoundedJson(res));
150
493
  }
151
- async function refreshAccessToken(authServerUrl, metadata, clientInfo, refreshToken) {
152
- const tokenUrl = metadata?.token_endpoint ?? new URL('/token', authServerUrl).toString();
494
+ async function refreshAccessToken(authServerUrl, metadata, clientInfo, refreshToken, resource, scope, originalScope, signal) {
495
+ const tokenUrl = validateOAuthEndpoint(metadata?.token_endpoint ?? new URL('/token', authServerUrl).toString(), 'token');
153
496
  const params = new URLSearchParams({
154
497
  grant_type: 'refresh_token',
155
498
  refresh_token: refreshToken,
156
499
  client_id: clientInfo.client_id,
500
+ resource,
157
501
  });
502
+ if (scope)
503
+ params.set('scope', scope);
158
504
  try {
159
505
  const res = await fetch(tokenUrl, {
160
506
  method: 'POST',
161
507
  headers: buildClientAuthHeaders(clientInfo),
162
508
  body: params.toString(),
509
+ signal,
510
+ redirect: 'error',
163
511
  });
164
- if (!res.ok)
512
+ if (!res.ok) {
513
+ await discardBoundedResponseBody(res);
514
+ return undefined;
515
+ }
516
+ const tokens = requireBearerToken(await readBoundedJson(res));
517
+ // RFC 6749 permits scope omission on refresh. Keep the known grant, and
518
+ // reject a response that claims a broader grant than the original token.
519
+ if (tokens.scope && originalScope && !isScopeSubset(tokens.scope, originalScope))
165
520
  return undefined;
166
- const tokens = await res.json();
521
+ const effectiveScope = scope ?? originalScope;
522
+ if (!tokens.scope && effectiveScope)
523
+ tokens.scope = effectiveScope;
167
524
  // Preserve original refresh token if server doesn't return a new one
168
525
  tokens.refresh_token ??= refreshToken;
169
526
  return tokens;
170
527
  }
171
528
  catch {
529
+ throwIfAborted(signal);
172
530
  return undefined;
173
531
  }
174
532
  }
175
533
  // --- Callback Server ---
176
- function waitForOAuthCallback(server, port, timeout) {
534
+ function waitForOAuthCallback(server, port, expectedState, expectedIssuer, issuerRequired, signal) {
177
535
  return new Promise((resolve, reject) => {
178
- const timer = setTimeout(() => {
536
+ let settled = false;
537
+ const finish = (callback) => {
538
+ if (settled)
539
+ return;
540
+ settled = true;
541
+ signal.removeEventListener('abort', onAbort);
179
542
  server.close();
180
- reject(new Error('OAuth authorization timed out'));
181
- }, timeout);
543
+ callback();
544
+ };
545
+ const onAbort = () => { finish(() => { reject(abortError(signal)); }); };
546
+ if (signal.aborted) {
547
+ onAbort();
548
+ return;
549
+ }
550
+ signal.addEventListener('abort', onAbort, { once: true });
182
551
  server.on('request', (req, res) => {
183
552
  const reqUrl = new URL(req.url ?? '/', `http://127.0.0.1:${String(port)}`);
184
553
  if (reqUrl.pathname !== '/oauth/callback') {
@@ -188,21 +557,39 @@ function waitForOAuthCallback(server, port, timeout) {
188
557
  }
189
558
  const code = reqUrl.searchParams.get('code');
190
559
  const error = reqUrl.searchParams.get('error');
560
+ const state = reqUrl.searchParams.get('state');
561
+ const issuer = reqUrl.searchParams.get('iss');
562
+ if (state !== expectedState) {
563
+ finish(() => {
564
+ res.writeHead(400, { 'Content-Type': 'text/html' });
565
+ res.end('<html><body><h1>Authorization failed</h1><p>Invalid state</p></body></html>');
566
+ reject(new Error('OAuth state validation failed'));
567
+ });
568
+ return;
569
+ }
570
+ if ((issuer === null && issuerRequired) || (issuer !== null && issuer !== expectedIssuer)) {
571
+ finish(() => {
572
+ res.writeHead(400, { 'Content-Type': 'text/html' });
573
+ res.end('<html><body><h1>Authorization failed</h1><p>Invalid issuer</p></body></html>');
574
+ reject(new Error('OAuth issuer validation failed'));
575
+ });
576
+ return;
577
+ }
191
578
  if (code) {
192
- clearTimeout(timer);
193
- res.writeHead(200, { 'Content-Type': 'text/html' });
194
- res.end('<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>');
195
- server.close();
196
- resolve(code);
579
+ finish(() => {
580
+ res.writeHead(200, { 'Content-Type': 'text/html' });
581
+ res.end('<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>');
582
+ resolve(code);
583
+ });
197
584
  }
198
585
  else {
199
- clearTimeout(timer);
200
586
  const msg = (error ?? reqUrl.searchParams.get('error_description') ?? 'Unknown error')
201
587
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
202
- res.writeHead(400, { 'Content-Type': 'text/html' });
203
- res.end(`<html><body><h1>Authorization failed</h1><p>${msg}</p></body></html>`);
204
- server.close();
205
- reject(new Error(`OAuth error: ${msg}`));
588
+ finish(() => {
589
+ res.writeHead(400, { 'Content-Type': 'text/html' });
590
+ res.end(`<html><body><h1>Authorization failed</h1><p>${msg}</p></body></html>`);
591
+ reject(new Error(`OAuth error: ${msg}`));
592
+ });
206
593
  }
207
594
  });
208
595
  });
@@ -216,31 +603,55 @@ export class OAuthClient {
216
603
  stored;
217
604
  authServerUrl;
218
605
  authServerMetadata;
606
+ resource;
607
+ protectedResourceScope;
219
608
  /** Deduplicates concurrent handleUnauthorized() calls into a single auth flow. */
220
609
  pendingAuth = null;
221
610
  constructor(options) {
222
611
  this.serverUrl = options.serverUrl;
223
612
  this.headers = options.headers ?? {};
224
613
  this.authTimeout = options.authTimeout ?? DEFAULT_AUTH_TIMEOUT;
614
+ if (!Number.isFinite(this.authTimeout) || this.authTimeout <= 0) {
615
+ throw new Error('OAuth authTimeout must be a positive finite number');
616
+ }
225
617
  this.hash = serverHash(options.serverUrl);
226
618
  }
619
+ async runOperation(options, operation) {
620
+ const bounded = createBoundedOperation(this.authTimeout, options?.signal);
621
+ try {
622
+ return await operation(bounded.signal);
623
+ }
624
+ finally {
625
+ bounded.dispose();
626
+ }
627
+ }
227
628
  /**
228
- * Get a valid access token, loading from disk cache if available.
629
+ * Get a cached access token only after binding it to the authorization
630
+ * server currently discovered for this protected resource.
229
631
  */
230
- async getAccessToken() {
231
- this.stored ??= await loadStoredAuth(this.hash);
232
- return this.stored?.tokens?.access_token;
632
+ async getAccessToken(options) {
633
+ return await this.runOperation(options, async (signal) => {
634
+ this.stored ??= await loadStoredAuth(this.hash);
635
+ const storedTokens = this.stored?.tokens;
636
+ if (!storedTokens?.access_token || typeof storedTokens.token_type !== 'string'
637
+ || storedTokens.token_type.toLowerCase() !== 'bearer')
638
+ return undefined;
639
+ await this.discover(signal);
640
+ const issuer = this.authServerMetadata?.issuer;
641
+ return issuer !== undefined && this.stored?.issuer === issuer ? storedTokens.access_token : undefined;
642
+ });
233
643
  }
234
644
  /**
235
645
  * Handle a 401 response from the remote server.
236
646
  * Deduplicates concurrent calls — only one auth flow runs at a time;
237
647
  * subsequent callers wait for the same result.
238
648
  */
239
- async handleUnauthorized(wwwAuthenticate) {
240
- if (this.pendingAuth) {
241
- return this.pendingAuth;
649
+ async handleUnauthorized(wwwAuthenticate, options) {
650
+ const pendingAuth = this.pendingAuth;
651
+ if (pendingAuth) {
652
+ return await this.runOperation(options, async (signal) => await awaitWithSignal(pendingAuth, signal));
242
653
  }
243
- this.pendingAuth = this.executeAuthFlow(wwwAuthenticate);
654
+ this.pendingAuth = this.runOperation(options, async (signal) => await this.executeAuthFlow(wwwAuthenticate, signal));
244
655
  try {
245
656
  return await this.pendingAuth;
246
657
  }
@@ -248,14 +659,26 @@ export class OAuthClient {
248
659
  this.pendingAuth = null;
249
660
  }
250
661
  }
251
- async executeAuthFlow(wwwAuthenticate) {
252
- const scope = parseWwwAuthenticateScope(wwwAuthenticate);
253
- this.stored ??= await loadStoredAuth(this.hash) ?? {};
662
+ async executeAuthFlow(wwwAuthenticate, signal) {
663
+ const challengedScope = parseWwwAuthenticateScope(wwwAuthenticate);
664
+ const challengedMetadataUrl = challengeMetadataUrl(wwwAuthenticate, this.serverUrl);
665
+ this.stored ??= await loadStoredAuth(this.hash);
666
+ this.stored ??= {};
254
667
  // Discover auth server
255
- await this.discover();
668
+ await this.discover(signal, challengedMetadataUrl);
669
+ const issuer = this.getAuthServerIssuer();
670
+ const scope = this.effectiveScope(challengedScope, issuer);
256
671
  // Try refresh first
257
- if (this.stored.tokens?.refresh_token && this.stored.clientInfo && this.authServerUrl) {
258
- const refreshed = await refreshAccessToken(this.authServerUrl, this.authServerMetadata, this.stored.clientInfo, this.stored.tokens.refresh_token);
672
+ if (this.stored.tokens?.refresh_token
673
+ && this.stored.clientInfo
674
+ && this.authServerUrl
675
+ && this.stored.issuer === issuer
676
+ && this.resource
677
+ && (!challengedScope || isScopeSubset(challengedScope, this.stored.tokens.scope))) {
678
+ const refreshScope = challengedScope && isScopeSubset(challengedScope, this.stored.tokens.scope)
679
+ ? challengedScope
680
+ : undefined;
681
+ const refreshed = await refreshAccessToken(this.authServerUrl, this.authServerMetadata, this.stored.clientInfo, this.stored.tokens.refresh_token, this.resource, refreshScope, this.stored.tokens.scope, signal);
259
682
  if (refreshed) {
260
683
  this.stored.tokens = refreshed;
261
684
  this.stored.tokenSavedAt = new Date().toISOString();
@@ -267,36 +690,56 @@ export class OAuthClient {
267
690
  if (!this.authServerUrl) {
268
691
  throw new Error('Failed to discover authorization server');
269
692
  }
693
+ if (!this.authServerMetadata?.code_challenge_methods_supported?.includes('S256')) {
694
+ throw new Error('OAuth authorization server does not advertise PKCE S256 support');
695
+ }
270
696
  // Start callback server on a random port
271
697
  const callbackHttpServer = createHttpServer();
272
698
  const callbackPort = await listenOnRandomPort(callbackHttpServer);
273
699
  const redirectUri = `http://127.0.0.1:${String(callbackPort)}/oauth/callback`;
274
700
  try {
275
- // Register client (or re-register if redirect_uri changed)
276
- if (!this.stored.clientInfo?.redirect_uris?.includes(redirectUri)) {
277
- this.stored.clientInfo = await registerClient(this.authServerUrl, this.authServerMetadata, redirectUri);
701
+ // Do not reuse a registration that was issued by a different authorization server.
702
+ if (this.stored.issuer !== issuer
703
+ || !this.stored.clientInfo?.redirect_uris?.includes(redirectUri)) {
704
+ this.stored.clientInfo = await registerClient(this.authServerUrl, this.authServerMetadata, redirectUri, signal);
278
705
  }
279
706
  // PKCE
280
707
  const codeVerifier = generateCodeVerifier();
281
708
  const codeChallenge = generateCodeChallenge(codeVerifier);
709
+ const state = generateState();
282
710
  // Build authorization URL
283
- const authEndpoint = this.authServerMetadata?.authorization_endpoint
284
- ?? new URL('/authorize', this.authServerUrl).toString();
711
+ const authEndpoint = validateOAuthEndpoint(this.authServerMetadata.authorization_endpoint
712
+ ?? new URL('/authorize', this.authServerUrl).toString(), 'authorization');
285
713
  const authUrl = new URL(authEndpoint);
286
714
  authUrl.searchParams.set('response_type', 'code');
287
715
  authUrl.searchParams.set('client_id', this.stored.clientInfo.client_id);
288
716
  authUrl.searchParams.set('redirect_uri', redirectUri);
289
717
  authUrl.searchParams.set('code_challenge', codeChallenge);
290
718
  authUrl.searchParams.set('code_challenge_method', 'S256');
719
+ authUrl.searchParams.set('state', state);
720
+ authUrl.searchParams.set('resource', this.resource ?? normalizeResourceUrl(this.serverUrl));
291
721
  if (scope)
292
722
  authUrl.searchParams.set('scope', scope);
293
723
  // Open browser and wait for callback
294
724
  process.stderr.write(`Opening browser for authorization...\n`);
725
+ const callback = waitForOAuthCallback(callbackHttpServer, callbackPort, state, issuer, this.authServerMetadata.authorization_response_iss_parameter_supported === true, signal);
295
726
  openBrowser(authUrl.toString());
296
- const code = await waitForOAuthCallback(callbackHttpServer, callbackPort, this.authTimeout);
727
+ const code = await callback;
297
728
  // Exchange code for tokens
298
- const tokens = await exchangeCodeForTokens(this.authServerUrl, this.authServerMetadata, this.stored.clientInfo, code, codeVerifier, redirectUri);
729
+ const tokens = await exchangeCodeForTokens(this.authServerUrl, this.authServerMetadata, this.stored.clientInfo, code, codeVerifier, redirectUri, this.resource ?? normalizeResourceUrl(this.serverUrl), signal);
730
+ // The token response may omit scope when it matches the authorization
731
+ // request. Persist that effective grant rather than losing it.
732
+ if (!tokens.scope && scope)
733
+ tokens.scope = scope;
734
+ if (tokens.scope && scope && !isScopeSubset(tokens.scope, scope)) {
735
+ throw new Error('OAuth token response scope exceeds the authorization request');
736
+ }
299
737
  this.stored.tokens = tokens;
738
+ this.stored.issuer = issuer;
739
+ if (scope)
740
+ this.stored.requestedScope = scope;
741
+ else
742
+ delete this.stored.requestedScope;
300
743
  this.stored.tokenSavedAt = new Date().toISOString();
301
744
  await saveStoredAuth(this.hash, this.stored);
302
745
  return tokens.access_token;
@@ -305,13 +748,49 @@ export class OAuthClient {
305
748
  callbackHttpServer.close();
306
749
  }
307
750
  }
308
- async discover() {
309
- if (this.authServerUrl)
751
+ async discover(signal, challengedMetadataUrl) {
752
+ // A 401 challenge can name a current protected-resource metadata document
753
+ // after an earlier cached-token lookup populated discovery state. Re-read
754
+ // and validate it so its authorization server and scopes govern this flow.
755
+ if (this.authServerUrl && !challengedMetadataUrl)
310
756
  return;
311
- const resourceMeta = await discoverProtectedResource(this.serverUrl, this.headers);
757
+ const resourceMeta = await discoverProtectedResource(this.serverUrl, signal, this.headers, challengedMetadataUrl);
312
758
  const firstServer = resourceMeta?.authorization_servers?.[0];
313
- this.authServerUrl = firstServer ?? new URL('/', this.serverUrl).toString();
314
- this.authServerMetadata = await discoverAuthServer(this.authServerUrl);
759
+ const authServerUrl = firstServer === undefined
760
+ ? validateAuthorizationServerIssuer(new URL(this.serverUrl).origin)
761
+ : validateAuthorizationServerIssuer(firstServer);
762
+ const resource = resourceMeta?.resource ?? normalizeResourceUrl(this.serverUrl);
763
+ const authServerMetadata = await discoverAuthServer(authServerUrl, signal);
764
+ throwIfAborted(signal);
765
+ this.authServerUrl = authServerUrl;
766
+ this.resource = resource;
767
+ this.protectedResourceScope = metadataScopes(resourceMeta?.scopes_supported);
768
+ this.authServerMetadata = authServerMetadata;
769
+ }
770
+ /**
771
+ * A Bearer challenge is authoritative. For a step-up request retain the
772
+ * existing grant/request and add the challenged scopes. On the first flow,
773
+ * protected-resource metadata supplies the only discovery fallback.
774
+ */
775
+ effectiveScope(challengedScope, issuer) {
776
+ const sameIssuer = this.stored?.issuer === issuer;
777
+ if (challengedScope) {
778
+ return sameIssuer
779
+ ? unionScopes(this.stored?.tokens?.scope, this.stored?.requestedScope, challengedScope)
780
+ : challengedScope;
781
+ }
782
+ return sameIssuer
783
+ ? unionScopes(this.stored?.tokens?.scope, this.stored?.requestedScope)
784
+ : this.protectedResourceScope;
785
+ }
786
+ getAuthServerIssuer() {
787
+ if (!this.authServerUrl) {
788
+ throw new Error('Failed to discover authorization server');
789
+ }
790
+ if (!this.authServerMetadata?.issuer) {
791
+ throw new Error('OAuth authorization-server metadata has no issuer');
792
+ }
793
+ return this.authServerMetadata.issuer;
315
794
  }
316
795
  }
317
796
  //# sourceMappingURL=oauth.js.map