@opengeni/network 0.2.3 → 0.3.0-canary.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.
package/README.md CHANGED
@@ -41,3 +41,29 @@ The package also exports `readJsonBase64Field` for provider APIs that return
41
41
  large binary artifacts inside JSON. It validates declared and streamed limits,
42
42
  decodes canonical base64 incrementally, and avoids retaining the JSON envelope
43
43
  or encoded string. Callers remain responsible for validating the decoded media.
44
+
45
+ ## MCP OAuth discovery
46
+
47
+ `resolveMcpOAuthDiscovery` provides the shared MCP OAuth discovery state machine
48
+ used by runtime connections and catalog diagnostics. It prefers RFC 9728
49
+ Protected Resource Metadata and permits the MCP 2025-03-26 compatibility path
50
+ only after every PRM candidate is explicitly absent with HTTP 404 or 410. The
51
+ legacy path requires a Bearer/OAuth challenge, RFC 8414 metadata on the MCP
52
+ origin, an exact issuer match, same-origin resource binding, and PKCE S256.
53
+
54
+ The resolver is transport-independent. Its `fetchMetadata` callback must
55
+ validate each candidate before I/O, bound response size and duration, disable
56
+ automatic redirects, and independently validate and DNS-pin every redirect
57
+ hop. It must return `status: "absent"` only for HTTP 404 or 410 and throw for
58
+ network, redirect, destination-policy, HTTP, body, and JSON failures; otherwise
59
+ an unsafe or unreachable PRM endpoint could be mistaken for legacy absence.
60
+ `validateEndpoint` and `canonicalizeResource` provide the caller's deployment
61
+ policy and canonical identifier rules.
62
+
63
+ Successful results include the discovery mode, normalized metadata, structured
64
+ classification, and a stable SHA-256 provenance digest. Fail-closed errors carry
65
+ classifications for broken discovery, unverified legacy defaults, or a legacy
66
+ cross-origin configuration that requires an explicit reviewed profile. These
67
+ classifications are diagnostics; callers still decide whether a provider
68
+ profile is supported and must perform fresh runtime discovery before granting
69
+ credentials.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./json-base64.js";
2
+ export * from "./mcp-oauth-discovery.js";
2
3
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
3
4
  export type DnsAddress = {
4
5
  address: string;
package/dist/index.js CHANGED
@@ -259,6 +259,396 @@ function isJsonWhitespace(byte) {
259
259
  return byte === 32 || byte === 9 || byte === 10 || byte === 13;
260
260
  }
261
261
 
262
+ // src/mcp-oauth-discovery.ts
263
+ import { createHash } from "crypto";
264
+ var McpOAuthDiscoveryError = class extends Error {
265
+ constructor(stage, classification, message, cause) {
266
+ super(message);
267
+ this.stage = stage;
268
+ this.classification = classification;
269
+ this.cause = cause;
270
+ this.name = "McpOAuthDiscoveryError";
271
+ }
272
+ };
273
+ async function resolveMcpOAuthDiscovery(input) {
274
+ const prmCandidates = protectedResourceMetadataCandidates(
275
+ input.resourceUrl,
276
+ input.challenge.resourceMetadata
277
+ );
278
+ let prmDocument = null;
279
+ for (const candidate of prmCandidates) {
280
+ const fetched = await input.fetchMetadata({ kind: "protected_resource", url: candidate });
281
+ if (fetched.status === "absent") continue;
282
+ prmDocument = fetched;
283
+ break;
284
+ }
285
+ if (prmDocument) {
286
+ const prm = parseProtectedResourceMetadata(prmDocument, input);
287
+ const authorizationServer = prm.authorizationServers[0];
288
+ const as2 = await discoverAuthorizationServerMetadata(authorizationServer, "modern", input);
289
+ return discoveryResult({
290
+ mode: "rfc9728_protected_resource",
291
+ classification: "oauth_rfc9728",
292
+ challenge: input.challenge,
293
+ resource: prm.resource ?? input.canonicalizeResource(input.resourceUrl),
294
+ prm,
295
+ as: as2
296
+ });
297
+ }
298
+ if (input.challenge.resourceMetadata !== void 0) {
299
+ throw new McpOAuthDiscoveryError(
300
+ "protected_resource_metadata",
301
+ "oauth_discovery_broken",
302
+ "MCP advertised protected resource metadata, but no metadata document was found"
303
+ );
304
+ }
305
+ if (!input.challenge.scheme) {
306
+ throw new McpOAuthDiscoveryError(
307
+ "protected_resource_metadata",
308
+ "oauth_discovery_broken",
309
+ "MCP protected resource metadata was absent and the server returned no Bearer/OAuth challenge"
310
+ );
311
+ }
312
+ const resource = input.canonicalizeResource(input.resourceUrl);
313
+ const resourceOrigin = new URL(resource).origin;
314
+ const as = await discoverAuthorizationServerMetadata(resourceOrigin, "legacy", input);
315
+ if (new URL(as.issuer).origin !== resourceOrigin) {
316
+ throw new McpOAuthDiscoveryError(
317
+ "authorization_server_metadata",
318
+ "oauth_requires_profile",
319
+ "legacy MCP OAuth discovery requires the authorization server issuer to share the MCP server origin"
320
+ );
321
+ }
322
+ const syntheticPrm = {
323
+ resource,
324
+ authorizationServers: [as.issuer],
325
+ scopesSupported: [...input.challenge.scope],
326
+ raw: {
327
+ resource,
328
+ authorization_servers: [as.issuer],
329
+ scopes_supported: [...input.challenge.scope]
330
+ },
331
+ metadataUrl: ""
332
+ };
333
+ return discoveryResult({
334
+ mode: "legacy_2025_03_26_metadata",
335
+ classification: "oauth_legacy_same_origin_metadata",
336
+ challenge: input.challenge,
337
+ resource,
338
+ prm: syntheticPrm,
339
+ as
340
+ });
341
+ }
342
+ function parseMcpOAuthChallenge(header) {
343
+ if (!header) return { scheme: null, scope: [] };
344
+ const challenges = parseAuthenticateChallenges(header);
345
+ if (!challenges) return { scheme: null, scope: [] };
346
+ const oauthChallenges = challenges.filter((candidate) => {
347
+ const scheme = candidate.scheme.toLowerCase();
348
+ return scheme === "bearer" || scheme === "oauth";
349
+ }).map(parseOAuthChallenge);
350
+ return oauthChallenges.find((challenge) => challenge.resourceMetadata !== void 0) ?? oauthChallenges[0] ?? { scheme: null, scope: [] };
351
+ }
352
+ function parseOAuthChallenge(challenge) {
353
+ const scheme = challenge.scheme.toLowerCase();
354
+ const resourceMetadataPresent = challenge.parameterParts.some(
355
+ (part) => /^resource_metadata\s*=/i.test(part.trim())
356
+ );
357
+ const paramsText = challenge.parameterParts.join(",");
358
+ const params = {};
359
+ const re = /([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*("(?:[^"\\]|\\.)*"|[^,\s]+)/g;
360
+ let match;
361
+ while ((match = re.exec(paramsText)) !== null) {
362
+ const raw = match[2];
363
+ params[match[1].toLowerCase()] = raw.startsWith('"') ? raw.slice(1, -1).replace(/\\"/g, '"') : raw;
364
+ }
365
+ return {
366
+ scheme,
367
+ scope: params.scope ? params.scope.split(/\s+/).filter(Boolean) : [],
368
+ ...resourceMetadataPresent ? { resourceMetadata: params.resource_metadata ?? "" } : {},
369
+ ...params.error ? { error: params.error } : {}
370
+ };
371
+ }
372
+ var MAX_EMPTY_AUTHENTICATE_LIST_ELEMENTS = 8;
373
+ function parseAuthenticateChallenges(header) {
374
+ const segments = splitAuthenticateHeader(header);
375
+ if (!segments) return null;
376
+ const challenges = [];
377
+ let emptyElements = 0;
378
+ for (const segment of segments) {
379
+ const trimmed = segment.trim();
380
+ if (!trimmed) {
381
+ emptyElements += 1;
382
+ if (emptyElements > MAX_EMPTY_AUTHENTICATE_LIST_ELEMENTS) return null;
383
+ continue;
384
+ }
385
+ const token = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+/.exec(trimmed)?.[0];
386
+ if (!token) return null;
387
+ let cursor = token.length;
388
+ while (/\s/.test(trimmed[cursor] ?? "")) cursor += 1;
389
+ if (trimmed[cursor] === "=") {
390
+ const current = challenges.at(-1);
391
+ if (!current) return null;
392
+ current.parameterParts.push(trimmed);
393
+ continue;
394
+ }
395
+ const remainder = trimmed.slice(token.length);
396
+ if (remainder && !/^\s/.test(remainder)) return null;
397
+ challenges.push({
398
+ scheme: token,
399
+ parameterParts: remainder.trim() ? [remainder.trim()] : []
400
+ });
401
+ }
402
+ return challenges;
403
+ }
404
+ function splitAuthenticateHeader(header) {
405
+ const segments = [];
406
+ let segmentStart = 0;
407
+ let quoted = false;
408
+ let escaped = false;
409
+ for (let index = 0; index < header.length; index += 1) {
410
+ const character = header[index];
411
+ if (quoted) {
412
+ if (escaped) {
413
+ escaped = false;
414
+ } else if (character === "\\") {
415
+ escaped = true;
416
+ } else if (character === '"') {
417
+ quoted = false;
418
+ }
419
+ continue;
420
+ }
421
+ if (character === '"') {
422
+ quoted = true;
423
+ continue;
424
+ }
425
+ if (character === ",") {
426
+ segments.push(header.slice(segmentStart, index));
427
+ segmentStart = index + 1;
428
+ }
429
+ }
430
+ if (quoted) return null;
431
+ segments.push(header.slice(segmentStart));
432
+ return segments;
433
+ }
434
+ function protectedResourceMetadataCandidates(resourceUrl, advertisedUrl) {
435
+ return uniqueStrings([
436
+ ...advertisedUrl !== void 0 ? [advertisedUrl] : [],
437
+ ...oauthWellKnownCandidates(resourceUrl, "oauth-protected-resource")
438
+ ]);
439
+ }
440
+ function authorizationServerMetadataCandidates(authorizationServer) {
441
+ return uniqueStrings([
442
+ ...oauthWellKnownCandidates(authorizationServer, "oauth-authorization-server"),
443
+ ...oauthWellKnownCandidates(authorizationServer, "openid-configuration"),
444
+ authorizationServer
445
+ ]);
446
+ }
447
+ function legacyAuthorizationServerMetadataCandidates(resourceUrl) {
448
+ const origin = new URL(resourceUrl).origin;
449
+ return [`${origin}/.well-known/oauth-authorization-server`];
450
+ }
451
+ function oauthWellKnownCandidates(rawUrl, name) {
452
+ const url = new URL(rawUrl);
453
+ const path = url.pathname.replace(/^\/+|\/+$/g, "");
454
+ return uniqueStrings([
455
+ `${url.origin}/.well-known/${name}${path ? `/${path}` : ""}`,
456
+ `${url.origin}${path ? `/${path}` : ""}/.well-known/${name}`,
457
+ `${url.origin}/.well-known/${name}`
458
+ ]);
459
+ }
460
+ function parseProtectedResourceMetadata(fetched, input) {
461
+ const authorizationServers = stringArray(fetched.document.authorization_servers).map(
462
+ (value) => validateDiscoveryEndpoint(
463
+ input,
464
+ value,
465
+ "OAuth authorization server",
466
+ "protected_resource_metadata"
467
+ )
468
+ );
469
+ if (authorizationServers.length === 0) {
470
+ throw new McpOAuthDiscoveryError(
471
+ "protected_resource_metadata",
472
+ "oauth_discovery_broken",
473
+ "MCP protected resource metadata did not advertise an authorization server"
474
+ );
475
+ }
476
+ const resourceValue = stringValue(fetched.document.resource);
477
+ return {
478
+ authorizationServers,
479
+ scopesSupported: stringArray(fetched.document.scopes_supported),
480
+ raw: fetched.document,
481
+ metadataUrl: fetched.url,
482
+ ...resourceValue ? { resource: input.canonicalizeResource(resourceValue) } : {}
483
+ };
484
+ }
485
+ async function discoverAuthorizationServerMetadata(authorizationServer, profile, input) {
486
+ const safeAuthorizationServer = validateDiscoveryEndpoint(
487
+ input,
488
+ authorizationServer,
489
+ "OAuth authorization server",
490
+ "authorization_server_metadata"
491
+ ).replace(/\/+$/, "");
492
+ const candidates = profile === "legacy" ? legacyAuthorizationServerMetadataCandidates(safeAuthorizationServer) : authorizationServerMetadataCandidates(safeAuthorizationServer);
493
+ let fetched = null;
494
+ for (const candidate of candidates) {
495
+ const result = await input.fetchMetadata({ kind: "authorization_server", url: candidate });
496
+ if (result.status === "absent") continue;
497
+ fetched = result;
498
+ break;
499
+ }
500
+ if (!fetched) {
501
+ throw new McpOAuthDiscoveryError(
502
+ "authorization_server_metadata",
503
+ profile === "legacy" ? "oauth_legacy_default_endpoints_unverified" : "oauth_discovery_broken",
504
+ profile === "legacy" ? "legacy MCP OAuth metadata was absent; default authorization endpoints require explicit verification" : "could not discover OAuth authorization server metadata"
505
+ );
506
+ }
507
+ const authorizationEndpoint = requiredString(
508
+ fetched.document.authorization_endpoint,
509
+ "authorization_endpoint",
510
+ "authorization_server_metadata"
511
+ );
512
+ const tokenEndpoint = requiredString(
513
+ fetched.document.token_endpoint,
514
+ "token_endpoint",
515
+ "authorization_server_metadata"
516
+ );
517
+ const issuerValue = stringValue(fetched.document.issuer);
518
+ if (profile === "legacy" && !issuerValue) {
519
+ throw new McpOAuthDiscoveryError(
520
+ "authorization_server_metadata",
521
+ "oauth_discovery_broken",
522
+ "legacy MCP OAuth metadata did not include issuer"
523
+ );
524
+ }
525
+ const issuer = validateDiscoveryEndpoint(
526
+ input,
527
+ issuerValue ?? safeAuthorizationServer,
528
+ "OAuth issuer",
529
+ "authorization_server_metadata"
530
+ );
531
+ if (profile === "legacy" && normalizedIssuerIdentifier(issuer) !== normalizedIssuerIdentifier(safeAuthorizationServer)) {
532
+ const crossOrigin = new URL(issuer).origin !== new URL(safeAuthorizationServer).origin;
533
+ throw new McpOAuthDiscoveryError(
534
+ "authorization_server_metadata",
535
+ crossOrigin ? "oauth_requires_profile" : "oauth_discovery_broken",
536
+ "OAuth authorization server metadata issuer did not match the selected authorization server"
537
+ );
538
+ }
539
+ const registrationEndpoint = stringValue(fetched.document.registration_endpoint);
540
+ const parsed = {
541
+ issuer,
542
+ authorizationServer: safeAuthorizationServer,
543
+ authorizationEndpoint: validateDiscoveryEndpoint(
544
+ input,
545
+ authorizationEndpoint,
546
+ "OAuth authorization endpoint",
547
+ "authorization_server_metadata"
548
+ ),
549
+ tokenEndpoint: validateDiscoveryEndpoint(
550
+ input,
551
+ tokenEndpoint,
552
+ "OAuth token endpoint",
553
+ "authorization_server_metadata"
554
+ ),
555
+ clientIdMetadataDocumentSupported: fetched.document.client_id_metadata_document_supported === true,
556
+ tokenEndpointAuthMethodsSupported: stringArray(
557
+ fetched.document.token_endpoint_auth_methods_supported
558
+ ),
559
+ codeChallengeMethodsSupported: stringArray(fetched.document.code_challenge_methods_supported),
560
+ raw: fetched.document,
561
+ metadataUrl: fetched.url,
562
+ ...registrationEndpoint ? {
563
+ registrationEndpoint: validateDiscoveryEndpoint(
564
+ input,
565
+ registrationEndpoint,
566
+ "OAuth registration endpoint",
567
+ "authorization_server_metadata"
568
+ )
569
+ } : {}
570
+ };
571
+ if (!parsed.codeChallengeMethodsSupported.includes("S256")) {
572
+ throw new McpOAuthDiscoveryError(
573
+ "authorization_server_metadata",
574
+ "oauth_discovery_broken",
575
+ "authorization server does not support required PKCE S256"
576
+ );
577
+ }
578
+ return parsed;
579
+ }
580
+ function discoveryResult(input) {
581
+ const protectedResourceMetadataUrl = input.prm.metadataUrl || null;
582
+ return {
583
+ mode: input.mode,
584
+ classification: input.classification,
585
+ challenge: input.challenge,
586
+ resource: input.resource,
587
+ protectedResourceMetadata: input.prm,
588
+ authorizationServerMetadata: input.as,
589
+ provenance: {
590
+ protectedResourceMetadataUrl,
591
+ authorizationServerMetadataUrl: input.as.metadataUrl,
592
+ metadataSha256: createHash("sha256").update(
593
+ stableJson({
594
+ mode: input.mode,
595
+ resource: input.resource,
596
+ challenge: input.challenge,
597
+ protectedResourceMetadata: {
598
+ url: protectedResourceMetadataUrl,
599
+ document: input.prm.raw
600
+ },
601
+ authorizationServerMetadata: {
602
+ url: input.as.metadataUrl,
603
+ document: input.as.raw
604
+ }
605
+ })
606
+ ).digest("hex")
607
+ }
608
+ };
609
+ }
610
+ function validateDiscoveryEndpoint(input, rawUrl, label, stage) {
611
+ try {
612
+ return input.validateEndpoint(rawUrl, label);
613
+ } catch (error) {
614
+ throw new McpOAuthDiscoveryError(
615
+ stage,
616
+ "oauth_discovery_broken",
617
+ error instanceof Error ? error.message : `${label} was invalid`,
618
+ error
619
+ );
620
+ }
621
+ }
622
+ function normalizedIssuerIdentifier(value) {
623
+ return value.replace(/\/+$/, "");
624
+ }
625
+ function stableJson(value) {
626
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
627
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
628
+ const record = value;
629
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
630
+ }
631
+ function uniqueStrings(values) {
632
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
633
+ }
634
+ function stringArray(value) {
635
+ return Array.isArray(value) ? uniqueStrings(value.filter((entry) => typeof entry === "string")) : [];
636
+ }
637
+ function stringValue(value) {
638
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
639
+ }
640
+ function requiredString(value, field, stage) {
641
+ const parsed = stringValue(value);
642
+ if (!parsed) {
643
+ throw new McpOAuthDiscoveryError(
644
+ stage,
645
+ "oauth_discovery_broken",
646
+ `OAuth metadata did not include ${field}`
647
+ );
648
+ }
649
+ return parsed;
650
+ }
651
+
262
652
  // src/index.ts
263
653
  var DISPATCHER_CLOSE_TIMEOUT_MS = 100;
264
654
  var OAUTH_MAX_RESPONSE_BYTES = 1024 * 1024;
@@ -876,18 +1266,24 @@ function ipv6Constant(address) {
876
1266
  export {
877
1267
  DestinationPolicyError,
878
1268
  JsonBase64ResponseError,
1269
+ McpOAuthDiscoveryError,
879
1270
  OAUTH_MAX_RESPONSE_BYTES,
880
1271
  RequestDeadlineError,
881
1272
  ResponseBodyLimitError,
1273
+ authorizationServerMetadataCandidates,
882
1274
  isInvalidAddress,
883
1275
  isLocalTestEnvironment,
884
1276
  isNonPublicAddress,
885
1277
  isPrivateAddress,
1278
+ legacyAuthorizationServerMetadataCandidates,
1279
+ parseMcpOAuthChallenge,
886
1280
  pinnedFetch,
1281
+ protectedResourceMetadataCandidates,
887
1282
  readJsonBase64Field,
888
1283
  readResponseBodyBounded,
889
1284
  readResponseJsonBounded,
890
1285
  readResponseTextBounded,
1286
+ resolveMcpOAuthDiscovery,
891
1287
  resolvePinnedDestination,
892
1288
  undiciFetch,
893
1289
  validateHttpUrl