@stigmer/server 3.18.0 → 3.19.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 (116) hide show
  1. package/README.md +1 -1
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +42 -6
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/boot/config.d.ts +6 -2
  6. package/dist/boot/config.d.ts.map +1 -1
  7. package/dist/boot/config.js.map +1 -1
  8. package/dist/boot/oauth-redirect-uri.d.ts +54 -0
  9. package/dist/boot/oauth-redirect-uri.d.ts.map +1 -0
  10. package/dist/boot/oauth-redirect-uri.js +37 -0
  11. package/dist/boot/oauth-redirect-uri.js.map +1 -0
  12. package/dist/domain/agentexecution/lifecycle.js +1 -1
  13. package/dist/domain/agentexecution/lifecycle.js.map +1 -1
  14. package/dist/domain/mcpserver/complete-endpoint-auth.d.ts +16 -0
  15. package/dist/domain/mcpserver/complete-endpoint-auth.d.ts.map +1 -0
  16. package/dist/domain/mcpserver/complete-endpoint-auth.js +260 -0
  17. package/dist/domain/mcpserver/complete-endpoint-auth.js.map +1 -0
  18. package/dist/domain/mcpserver/complete-oauth-connect.d.ts.map +1 -1
  19. package/dist/domain/mcpserver/complete-oauth-connect.js +1 -1
  20. package/dist/domain/mcpserver/complete-oauth-connect.js.map +1 -1
  21. package/dist/domain/mcpserver/connect.d.ts +25 -0
  22. package/dist/domain/mcpserver/connect.d.ts.map +1 -1
  23. package/dist/domain/mcpserver/connect.js +8 -23
  24. package/dist/domain/mcpserver/connect.js.map +1 -1
  25. package/dist/domain/mcpserver/controller.d.ts.map +1 -1
  26. package/dist/domain/mcpserver/controller.js +12 -0
  27. package/dist/domain/mcpserver/controller.js.map +1 -1
  28. package/dist/domain/mcpserver/initiate-oauth-connect.d.ts.map +1 -1
  29. package/dist/domain/mcpserver/initiate-oauth-connect.js +38 -14
  30. package/dist/domain/mcpserver/initiate-oauth-connect.js.map +1 -1
  31. package/dist/domain/mcpserver/oauth/dcr.d.ts +10 -1
  32. package/dist/domain/mcpserver/oauth/dcr.d.ts.map +1 -1
  33. package/dist/domain/mcpserver/oauth/dcr.js +1 -9
  34. package/dist/domain/mcpserver/oauth/dcr.js.map +1 -1
  35. package/dist/domain/mcpserver/oauth/discovery.d.ts +43 -20
  36. package/dist/domain/mcpserver/oauth/discovery.d.ts.map +1 -1
  37. package/dist/domain/mcpserver/oauth/discovery.js +69 -54
  38. package/dist/domain/mcpserver/oauth/discovery.js.map +1 -1
  39. package/dist/domain/mcpserver/oauth/preflight.d.ts +27 -1
  40. package/dist/domain/mcpserver/oauth/preflight.d.ts.map +1 -1
  41. package/dist/domain/mcpserver/oauth/preflight.js +1 -26
  42. package/dist/domain/mcpserver/oauth/preflight.js.map +1 -1
  43. package/dist/domain/mcpserver/oauth/refresh.d.ts +2 -1
  44. package/dist/domain/mcpserver/oauth/refresh.d.ts.map +1 -1
  45. package/dist/domain/mcpserver/oauth/refresh.js +1 -1
  46. package/dist/domain/mcpserver/oauth/refresh.js.map +1 -1
  47. package/dist/domain/mcpserver/oauth/token.d.ts +9 -2
  48. package/dist/domain/mcpserver/oauth/token.d.ts.map +1 -1
  49. package/dist/domain/mcpserver/oauth/token.js +2 -8
  50. package/dist/domain/mcpserver/oauth/token.js.map +1 -1
  51. package/dist/domain/workflowexecution/lifecycle.js +1 -1
  52. package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
  53. package/dist/extensions/drivers.d.ts +17 -0
  54. package/dist/extensions/drivers.d.ts.map +1 -1
  55. package/dist/extensions/outbound-egress.d.ts +49 -0
  56. package/dist/extensions/outbound-egress.d.ts.map +1 -0
  57. package/dist/extensions/outbound-egress.js +51 -0
  58. package/dist/extensions/outbound-egress.js.map +1 -0
  59. package/dist/extensions/registry.d.ts +6 -0
  60. package/dist/extensions/registry.d.ts.map +1 -1
  61. package/dist/extensions/registry.js +10 -0
  62. package/dist/extensions/registry.js.map +1 -1
  63. package/dist/index.d.ts +2 -0
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +1 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/pipeline/apiresource-labels.d.ts +14 -0
  68. package/dist/pipeline/apiresource-labels.d.ts.map +1 -1
  69. package/dist/pipeline/apiresource-labels.js +14 -0
  70. package/dist/pipeline/apiresource-labels.js.map +1 -1
  71. package/dist/sandbox/kubernetes.d.ts.map +1 -1
  72. package/dist/sandbox/kubernetes.js +8 -1
  73. package/dist/sandbox/kubernetes.js.map +1 -1
  74. package/dist/sandbox/provisioner.d.ts +20 -5
  75. package/dist/sandbox/provisioner.d.ts.map +1 -1
  76. package/dist/sandbox/provisioner.js.map +1 -1
  77. package/dist/sandbox/steps.d.ts +22 -8
  78. package/dist/sandbox/steps.d.ts.map +1 -1
  79. package/dist/sandbox/steps.js +17 -12
  80. package/dist/sandbox/steps.js.map +1 -1
  81. package/package.json +6 -5
  82. package/src/boot/__tests__/oauth-redirect-uri.test.ts +38 -0
  83. package/src/boot/compose.ts +47 -8
  84. package/src/boot/config.ts +6 -2
  85. package/src/boot/oauth-redirect-uri.ts +53 -0
  86. package/src/domain/agentexecution/lifecycle.ts +1 -1
  87. package/src/domain/mcpserver/__tests__/complete-endpoint-auth.test.ts +270 -0
  88. package/src/domain/mcpserver/__tests__/connect-authorization.test.ts +3 -0
  89. package/src/domain/mcpserver/__tests__/connect.test.ts +31 -0
  90. package/src/domain/mcpserver/__tests__/oauth-handshake.test.ts +1 -1
  91. package/src/domain/mcpserver/complete-endpoint-auth.ts +277 -0
  92. package/src/domain/mcpserver/complete-oauth-connect.ts +1 -0
  93. package/src/domain/mcpserver/connect.ts +21 -15
  94. package/src/domain/mcpserver/controller.ts +16 -0
  95. package/src/domain/mcpserver/initiate-oauth-connect.ts +37 -13
  96. package/src/domain/mcpserver/oauth/__tests__/discovery.test.ts +120 -91
  97. package/src/domain/mcpserver/oauth/__tests__/token-refresh.test.ts +3 -1
  98. package/src/domain/mcpserver/oauth/dcr.ts +2 -1
  99. package/src/domain/mcpserver/oauth/discovery.ts +120 -89
  100. package/src/domain/mcpserver/oauth/preflight.ts +2 -1
  101. package/src/domain/mcpserver/oauth/refresh.ts +2 -1
  102. package/src/domain/mcpserver/oauth/token.ts +4 -3
  103. package/src/domain/workflowexecution/lifecycle.ts +1 -1
  104. package/src/extensions/__tests__/outbound-egress-points.test.ts +58 -0
  105. package/src/extensions/drivers.ts +17 -0
  106. package/src/extensions/outbound-egress.ts +55 -0
  107. package/src/extensions/registry.ts +19 -0
  108. package/src/index.ts +10 -0
  109. package/src/pipeline/apiresource-labels.ts +16 -0
  110. package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
  111. package/src/sandbox/__tests__/kubernetes.test.ts +10 -2
  112. package/src/sandbox/__tests__/local-process.integration.test.ts +3 -0
  113. package/src/sandbox/__tests__/steps.test.ts +72 -19
  114. package/src/sandbox/kubernetes.ts +8 -1
  115. package/src/sandbox/provisioner.ts +20 -5
  116. package/src/sandbox/steps.ts +29 -12
@@ -1,12 +1,17 @@
1
1
  /**
2
- * Pins RFC 8414 discovery against Go oauth/discovery.go: the well-known
3
- * URI lives at the URL ORIGIN (not under the server's path), S256 support
4
- * is mandatory when advertised, and every error message is text initiate
5
- * embeds in its FailedPrecondition copy — asserted verbatim.
2
+ * Pins authorization-server discovery: for an MCP endpoint the RFC 9728
3
+ * walk (the protected-resource document names the login server, which may
4
+ * live on another origin and carry a path) with the endpoint's origin as
5
+ * the last resort the retired reader used; for an author's `discovery_url`
6
+ * an issuer read; S256 mandatory when advertised; and every error message
7
+ * verbatim, because initiate embeds it in its FailedPrecondition copy and
8
+ * the conformance suite pins it against the first document tried.
6
9
  */
7
10
  import { describe, expect, it } from "vitest";
8
11
 
9
- import { buildWellKnownUrl, discoverAuthorizationServer } from "../discovery.js";
12
+ import type { OutboundFetch } from "@stigmer/outbound/egress";
13
+
14
+ import { buildWellKnownUrl, discoverAtIssuer, discoverForResource } from "../discovery.js";
10
15
 
11
16
  function jsonResponse(status: number, body: unknown): Response {
12
17
  return new Response(JSON.stringify(body), {
@@ -24,127 +29,151 @@ const VALID_METADATA = {
24
29
  code_challenge_methods_supported: ["S256"],
25
30
  };
26
31
 
32
+ /** A fetch answering from a URL table; anything unlisted is a 404. */
33
+ function serving(documents: Record<string, unknown>): { fetchImpl: OutboundFetch; requested: string[]; accepts: string[] } {
34
+ const requested: string[] = [];
35
+ const accepts: string[] = [];
36
+ const fetchImpl: OutboundFetch = async (url, init) => {
37
+ requested.push(String(url));
38
+ accepts.push((init?.headers as Record<string, string>)["Accept"] ?? "");
39
+ const body = documents[String(url)];
40
+ return body === undefined ? jsonResponse(404, {}) : jsonResponse(200, body);
41
+ };
42
+ return { fetchImpl, requested, accepts };
43
+ }
44
+
27
45
  describe("buildWellKnownUrl", () => {
28
46
  it("builds at the origin, dropping the server path (Linear-style URL)", () => {
29
- expect(buildWellKnownUrl("https://mcp.linear.app/mcp")).toBe(
30
- "https://mcp.linear.app/.well-known/oauth-authorization-server",
31
- );
47
+ expect(buildWellKnownUrl("https://mcp.linear.app/mcp")).toBe("https://mcp.linear.app/.well-known/oauth-authorization-server");
32
48
  });
33
49
 
34
50
  it("preserves a non-default port in the origin", () => {
35
- expect(buildWellKnownUrl("http://127.0.0.1:8931/api/v2")).toBe(
36
- "http://127.0.0.1:8931/.well-known/oauth-authorization-server",
37
- );
51
+ expect(buildWellKnownUrl("http://127.0.0.1:8931/api/v2")).toBe("http://127.0.0.1:8931/.well-known/oauth-authorization-server");
38
52
  });
39
53
 
40
54
  const invalid: Array<[string, string, string]> = [
41
- [
42
- "a non-http(s) scheme",
43
- "ftp://example.com",
44
- 'invalid server URL for discovery: unsupported scheme "ftp": only http and https are supported',
45
- ],
55
+ ["a non-http(s) scheme", "ftp://example.com", 'invalid server URL for discovery: unsupported scheme "ftp": only http and https are supported'],
46
56
  ];
47
57
  it.each(invalid)("rejects %s", (_label, url, message) => {
48
58
  expect(() => buildWellKnownUrl(url)).toThrow(message);
49
59
  });
50
60
 
51
61
  it("rejects an unparseable URL with the Go prefix", () => {
52
- expect(() => buildWellKnownUrl("::::")).toThrow(
53
- /^invalid server URL for discovery: /,
54
- );
62
+ expect(() => buildWellKnownUrl("::::")).toThrow(/^invalid server URL for discovery: /);
55
63
  });
56
64
  });
57
65
 
58
- describe("discoverAuthorizationServer", () => {
59
- it("parses valid metadata and sends Accept: application/json to the origin well-known URI", async () => {
60
- let requestedUrl = "";
61
- let accept = "";
62
- const metadata = await discoverAuthorizationServer(
63
- "https://mcp.example.com/mcp",
64
- async (url, init) => {
65
- requestedUrl = String(url);
66
- accept = (init?.headers as Record<string, string>)["Accept"] ?? "";
67
- return jsonResponse(200, VALID_METADATA);
68
- },
69
- );
70
- expect(requestedUrl).toBe(
71
- "https://mcp.example.com/.well-known/oauth-authorization-server",
72
- );
73
- expect(accept).toBe("application/json");
74
- expect(metadata.authorizationEndpoint).toBe(
75
- "https://auth.example.com/authorize",
76
- );
66
+ describe("discoverForResource", () => {
67
+ it("reaches a login server on another origin through the protected-resource document (RFC 9728)", async () => {
68
+ const { fetchImpl, requested } = serving({
69
+ "https://mcp.example.com/.well-known/oauth-protected-resource/mcp": { resource: "https://mcp.example.com/mcp", authorization_servers: ["https://auth.example.com"] },
70
+ "https://auth.example.com/.well-known/oauth-authorization-server": VALID_METADATA,
71
+ });
72
+ const metadata = await discoverForResource("https://mcp.example.com/mcp", fetchImpl);
73
+ expect(metadata.authorizationEndpoint).toBe("https://auth.example.com/authorize");
74
+ expect(metadata.registrationEndpoint).toBe("https://auth.example.com/register");
75
+ expect(requested).toEqual([
76
+ "https://mcp.example.com/.well-known/oauth-protected-resource/mcp",
77
+ "https://auth.example.com/.well-known/oauth-authorization-server",
78
+ ]);
79
+ });
80
+
81
+ it("inserts the well-known segment before an issuer's path (RFC 8414 section 3)", async () => {
82
+ const { fetchImpl } = serving({
83
+ "https://mcp.example.com/.well-known/oauth-protected-resource": { authorization_servers: ["https://auth.example.com/tenant"] },
84
+ "https://auth.example.com/.well-known/oauth-authorization-server/tenant": VALID_METADATA,
85
+ });
86
+ const metadata = await discoverForResource("https://mcp.example.com/", fetchImpl);
77
87
  expect(metadata.tokenEndpoint).toBe("https://auth.example.com/token");
78
- expect(metadata.registrationEndpoint).toBe(
79
- "https://auth.example.com/register",
80
- );
88
+ });
89
+
90
+ it("falls back to the endpoint's origin when no protected-resource document exists, sending Accept: application/json", async () => {
91
+ const { fetchImpl, requested, accepts } = serving({
92
+ "https://mcp.example.com/.well-known/oauth-authorization-server": VALID_METADATA,
93
+ });
94
+ const metadata = await discoverForResource("https://mcp.example.com/mcp", fetchImpl);
81
95
  expect(metadata.scopesSupported).toEqual(["read", "write"]);
96
+ expect(requested).toEqual([
97
+ "https://mcp.example.com/.well-known/oauth-protected-resource/mcp",
98
+ "https://mcp.example.com/.well-known/oauth-protected-resource",
99
+ "https://mcp.example.com/.well-known/oauth-authorization-server",
100
+ ]);
101
+ expect(new Set(accepts)).toEqual(new Set(["application/json"]));
82
102
  });
83
103
 
84
- it("refuses a non-200 with the MCP Authorization hint (Go copy, verbatim)", async () => {
85
- await expect(
86
- discoverAuthorizationServer("https://mcp.example.com/mcp", async () =>
87
- jsonResponse(404, {}),
88
- ),
89
- ).rejects.toThrow(
104
+ it("refuses a non-200 naming the origin's RFC 8414 document with the MCP Authorization hint (Go copy, verbatim)", async () => {
105
+ const fetchImpl: OutboundFetch = async () => jsonResponse(404, {});
106
+ await expect(discoverForResource("https://mcp.example.com/mcp", fetchImpl)).rejects.toThrow(
90
107
  "authorization server discovery failed: https://mcp.example.com/.well-known/oauth-authorization-server returned HTTP 404 (expected 200). " +
91
108
  "This MCP server may not support the MCP Authorization specification",
92
109
  );
93
110
  });
94
111
 
112
+ it("wraps a network failure with the discovery-request prefix", async () => {
113
+ const fetchImpl: OutboundFetch = async () => {
114
+ throw new Error("connect ECONNREFUSED");
115
+ };
116
+ await expect(discoverForResource("https://mcp.example.com/mcp", fetchImpl)).rejects.toThrow(/^discovery request to .* failed: connect ECONNREFUSED$/);
117
+ });
118
+
119
+ it("screens the URL before dialling", async () => {
120
+ const fetchImpl: OutboundFetch = async () => jsonResponse(200, VALID_METADATA);
121
+ await expect(discoverForResource("ftp://example.com", fetchImpl)).rejects.toThrow(
122
+ 'invalid server URL for discovery: unsupported scheme "ftp": only http and https are supported',
123
+ );
124
+ });
125
+ });
126
+
127
+ describe("discoverAtIssuer", () => {
128
+ it("reads an author's discovery_url as an issuer, RFC 8414 at its origin first", async () => {
129
+ const { fetchImpl, requested } = serving({
130
+ "https://auth.example.com/.well-known/oauth-authorization-server": VALID_METADATA,
131
+ });
132
+ const metadata = await discoverAtIssuer("https://auth.example.com", fetchImpl);
133
+ expect(metadata.authorizationEndpoint).toBe("https://auth.example.com/authorize");
134
+ expect(requested).toEqual(["https://auth.example.com/.well-known/oauth-authorization-server"]);
135
+ });
136
+
137
+ it("falls back to OpenID's document", async () => {
138
+ const { fetchImpl, requested } = serving({
139
+ "https://auth.example.com/.well-known/openid-configuration": VALID_METADATA,
140
+ });
141
+ await discoverAtIssuer("https://auth.example.com", fetchImpl);
142
+ expect(requested).toEqual([
143
+ "https://auth.example.com/.well-known/oauth-authorization-server",
144
+ "https://auth.example.com/.well-known/openid-configuration",
145
+ ]);
146
+ });
147
+
148
+ it("refuses a non-200 naming the first document tried with its status (the conformance suite's pin)", async () => {
149
+ const fetchImpl: OutboundFetch = async () => jsonResponse(503, { error: "metadata unavailable" });
150
+ await expect(discoverAtIssuer("http://127.0.0.1:8931", fetchImpl)).rejects.toThrow(
151
+ "authorization server discovery failed: http://127.0.0.1:8931/.well-known/oauth-authorization-server returned HTTP 503 (expected 200). " +
152
+ "This MCP server may not support the MCP Authorization specification",
153
+ );
154
+ });
155
+
95
156
  const missingEndpoint: Array<[string, Record<string, unknown>, string]> = [
96
- [
97
- "authorization_endpoint",
98
- { ...VALID_METADATA, authorization_endpoint: undefined },
99
- "is missing authorization_endpoint",
100
- ],
101
- [
102
- "token_endpoint",
103
- { ...VALID_METADATA, token_endpoint: undefined },
104
- "is missing token_endpoint",
105
- ],
157
+ ["authorization_endpoint", { ...VALID_METADATA, authorization_endpoint: undefined }, "is missing authorization_endpoint"],
158
+ ["token_endpoint", { ...VALID_METADATA, token_endpoint: undefined }, "is missing token_endpoint"],
106
159
  ];
107
- it.each(missingEndpoint)(
108
- "refuses metadata without %s",
109
- async (_label, body, fragment) => {
110
- await expect(
111
- discoverAuthorizationServer("https://mcp.example.com/mcp", async () =>
112
- jsonResponse(200, body),
113
- ),
114
- ).rejects.toThrow(fragment);
115
- },
116
- );
160
+ it.each(missingEndpoint)("refuses metadata without %s, naming the document (Go copy)", async (_label, body, fragment) => {
161
+ const fetchImpl: OutboundFetch = async () => jsonResponse(200, body);
162
+ await expect(discoverAtIssuer("https://auth.example.com", fetchImpl)).rejects.toThrow(
163
+ `authorization server at https://auth.example.com/.well-known/oauth-authorization-server ${fragment}`,
164
+ );
165
+ });
117
166
 
118
167
  it("refuses a server that advertises methods without S256 (Go %v list rendering)", async () => {
119
- await expect(
120
- discoverAuthorizationServer("https://mcp.example.com/mcp", async () =>
121
- jsonResponse(200, {
122
- ...VALID_METADATA,
123
- code_challenge_methods_supported: ["plain"],
124
- }),
125
- ),
126
- ).rejects.toThrow(
168
+ const fetchImpl: OutboundFetch = async () => jsonResponse(200, { ...VALID_METADATA, code_challenge_methods_supported: ["plain"] });
169
+ await expect(discoverAtIssuer("https://auth.example.com", fetchImpl)).rejects.toThrow(
127
170
  "does not support S256 PKCE (supports: [plain]). S256 is required by the MCP Authorization specification",
128
171
  );
129
172
  });
130
173
 
131
174
  it("accepts a server that advertises NO methods (list absent = unconstrained)", async () => {
132
- const metadata = await discoverAuthorizationServer(
133
- "https://mcp.example.com/mcp",
134
- async () =>
135
- jsonResponse(200, {
136
- ...VALID_METADATA,
137
- code_challenge_methods_supported: undefined,
138
- }),
139
- );
175
+ const fetchImpl: OutboundFetch = async () => jsonResponse(200, { ...VALID_METADATA, code_challenge_methods_supported: undefined });
176
+ const metadata = await discoverAtIssuer("https://auth.example.com", fetchImpl);
140
177
  expect(metadata.codeChallengeMethodsSupported).toEqual([]);
141
178
  });
142
-
143
- it("wraps a network failure with the discovery-request prefix", async () => {
144
- await expect(
145
- discoverAuthorizationServer("https://mcp.example.com/mcp", async () => {
146
- throw new Error("connect ECONNREFUSED");
147
- }),
148
- ).rejects.toThrow(/^discovery request to .* failed: connect ECONNREFUSED$/);
149
- });
150
179
  });
@@ -287,7 +287,9 @@ describe("refreshTokenIfExpired", () => {
287
287
 
288
288
  it("expired with no refresh token: throws the re-auth error", async () => {
289
289
  await expect(
290
- refreshTokenIfExpired(grant(nowSeconds() - 10), "", "", "", silentLogger),
290
+ refreshTokenIfExpired(grant(nowSeconds() - 10), "", "", "", silentLogger, () => {
291
+ throw new Error("token endpoint must not be reached");
292
+ }),
291
293
  ).rejects.toThrow(
292
294
  "access token for resource 'mcps_1' has expired and no refresh token is available. " +
293
295
  "Please re-authenticate via OAuth Connect",
@@ -6,6 +6,7 @@
6
6
  * "never ciphertext-of-empty" rule meaningful (oss#394).
7
7
  * Proven by mcpserver-oauth.conformance.test.ts (CONFORMANCE_TARGET=local).
8
8
  */
9
+ import type { OutboundFetch } from "@stigmer/outbound/egress";
9
10
  import { truncateBody } from "./truncate-body.js";
10
11
 
11
12
  /** Dynamic Client Registration response per RFC 7591 (Go DCRResponse). */
@@ -29,7 +30,7 @@ export async function registerClient(
29
30
  registrationEndpoint: string,
30
31
  redirectUri: string,
31
32
  clientName: string,
32
- fetchImpl: typeof fetch = fetch,
33
+ fetchImpl: OutboundFetch,
33
34
  ): Promise<DcrResponse> {
34
35
  const requestBody = JSON.stringify({
35
36
  redirect_uris: [redirectUri],
@@ -1,16 +1,49 @@
1
1
  /**
2
- * RFC 8414 authorization-server discovery — ports
3
- * pkg/domain/mcpserver/oauth/discovery.go. The well-known URI lives at the
4
- * URL ORIGIN (scheme + host), not under the server's path — a Linear-style
5
- * MCP URL like https://mcp.linear.app/mcp discovers at
6
- * https://mcp.linear.app/.well-known/oauth-authorization-server.
7
- * Proven by mcpserver-oauth.conformance.test.ts (CONFORMANCE_TARGET=local).
2
+ * Authorization-server discovery for the DCR arm of Sign in: finding the
3
+ * login server that protects an MCP endpoint, and validating what it says.
4
+ *
5
+ * Two entry points, because the spec gives Sign in two kinds of URL:
6
+ *
7
+ * - `discoverForResource(mcpUrl)`: the MCP endpoint itself (`http.url`).
8
+ * Its login server is found by the RFC 9728 walk in
9
+ * `@stigmer/outbound/mcp-oauth`: the protected-resource document at the
10
+ * endpoint (path-suffixed, then bare) names `authorization_servers`;
11
+ * each issuer's metadata is read by RFC 8414 with the issuer's path
12
+ * inserted, then at its origin, then OpenID's document. When no
13
+ * protected-resource document exists the endpoint's origin is the one
14
+ * issuer to try, which is exactly what this module did before the walk
15
+ * (a Linear-style https://mcp.linear.app/mcp read
16
+ * https://mcp.linear.app/.well-known/oauth-authorization-server), so
17
+ * no server that signed in before signs in less now. Of the 41
18
+ * OAuth-protected servers the catalogue audit resolved, 21 keep their
19
+ * login server on another origin; only the walk reaches them.
20
+ * - `discoverAtIssuer(issuerUrl)`: the author's `auth.discovery_url`, an
21
+ * override naming the login server. It is read as an issuer: RFC 8414
22
+ * with its path, then its origin, then OpenID's.
23
+ *
24
+ * Both return the same `AuthServerMetadata` (the Go AuthServerMetadata's
25
+ * parsed camelCase view) and hold the login server to the same rules:
26
+ * both endpoints present, and S256 offered when methods are listed. The
27
+ * error copy is contract: initiate embeds it in its FailedPrecondition, the
28
+ * conformance suite pins it, and the failure names the FIRST document the
29
+ * walk tried (for a bare issuer, the RFC 8414 document at its origin, the
30
+ * one the retired reader named) with its status or its network error.
31
+ * Proven by __tests__/discovery.test.ts and
32
+ * mcpserver-oauth.conformance.test.ts (CONFORMANCE_TARGET=local).
8
33
  */
34
+ import type { OutboundFetch } from "@stigmer/outbound/egress";
35
+ import {
36
+ authorizationServerMetadataUrls,
37
+ readAuthorizationServerMetadata,
38
+ resolveAuthorizationServers,
39
+ type AuthorizationServerMetadata,
40
+ type MetadataAttempt,
41
+ } from "@stigmer/outbound/mcp-oauth";
9
42
 
10
43
  /**
11
- * OAuth 2.0 Authorization Server Metadata discovered via RFC 8414
12
- * (Go AuthServerMetadata). Field names keep the RFC's snake_case on the
13
- * wire; this interface is the parsed camelCase view.
44
+ * OAuth 2.0 Authorization Server Metadata (Go AuthServerMetadata). Field
45
+ * names keep the RFC's snake_case on the wire; this interface is the parsed
46
+ * camelCase view.
14
47
  */
15
48
  export interface AuthServerMetadata {
16
49
  issuer: string;
@@ -21,117 +54,115 @@ export interface AuthServerMetadata {
21
54
  codeChallengeMethodsSupported: string[];
22
55
  }
23
56
 
24
- /** Go's discoveryHTTPClient 10s timeout — a named constant per guidelines. */
57
+ /** Go's discoveryHTTPClient 10s timeout — a named constant per guidelines; per document read. */
25
58
  export const DISCOVERY_REQUEST_TIMEOUT_MS = 10_000;
26
59
 
60
+ /** The login server protecting an MCP endpoint, by the RFC 9728 walk with the origin as the last resort. */
61
+ export async function discoverForResource(mcpUrl: string, fetchImpl: OutboundFetch): Promise<AuthServerMetadata> {
62
+ const resource = parseDiscoveryUrl(mcpUrl);
63
+ const deps = { fetchImpl, timeoutMs: DISCOVERY_REQUEST_TIMEOUT_MS };
64
+ const resolved = await resolveAuthorizationServers(resource.href, undefined, deps);
65
+ const issuers = resolved.issuers.length > 0 ? resolved.issuers : [resource.origin];
66
+ return readIssuers(issuers, deps);
67
+ }
68
+
69
+ /** The login server an author named in `auth.discovery_url`, read as an issuer. */
70
+ export async function discoverAtIssuer(issuerUrl: string, fetchImpl: OutboundFetch): Promise<AuthServerMetadata> {
71
+ const issuer = parseDiscoveryUrl(issuerUrl);
72
+ return readIssuers([issuer.href], { fetchImpl, timeoutMs: DISCOVERY_REQUEST_TIMEOUT_MS });
73
+ }
74
+
27
75
  /**
28
- * Fetches the OAuth Authorization Server Metadata from the MCP server's
29
- * .well-known endpoint per RFC 8414 (Go DiscoverAuthorizationServer).
30
- * Throws with Go's message text on non-200, unparseable metadata, missing
31
- * endpoints, or an S256-less server — initiate embeds these messages in
32
- * its FailedPrecondition copy, so the text is contract.
76
+ * The document the retired reader named, kept so a failure names a URL the
77
+ * suites and the operators already know: RFC 8414 at the issuer's origin.
78
+ * Exported for the tests that pin the shape.
33
79
  */
34
- export async function discoverAuthorizationServer(
35
- serverUrl: string,
36
- fetchImpl: typeof fetch = fetch,
37
- ): Promise<AuthServerMetadata> {
38
- const wellKnownUrl = buildWellKnownUrl(serverUrl);
80
+ export function buildWellKnownUrl(serverUrl: string): string {
81
+ const parsed = parseDiscoveryUrl(serverUrl);
82
+ return `${parsed.origin}/.well-known/oauth-authorization-server`;
83
+ }
39
84
 
40
- let response: Response;
41
- try {
42
- response = await fetchImpl(wellKnownUrl, {
43
- method: "GET",
44
- headers: { Accept: "application/json" },
45
- signal: AbortSignal.timeout(DISCOVERY_REQUEST_TIMEOUT_MS),
46
- });
47
- } catch (error) {
48
- throw new Error(
49
- `discovery request to ${wellKnownUrl} failed: ${error instanceof Error ? error.message : String(error)}`,
50
- );
85
+ async function readIssuers(issuers: readonly string[], deps: { fetchImpl: OutboundFetch; timeoutMs: number }): Promise<AuthServerMetadata> {
86
+ const attempts: MetadataAttempt[] = [];
87
+ for (const issuer of issuers) {
88
+ const read = await readAuthorizationServerMetadata(issuer, deps);
89
+ attempts.push(...read.attempts);
90
+ if (read.kind === "found") {
91
+ const metadata = toAuthServerMetadata(read.metadata);
92
+ validateMetadata(metadata, read.metadata.metadataUrl);
93
+ return metadata;
94
+ }
51
95
  }
96
+ throw discoveryFailure(issuers, attempts);
97
+ }
52
98
 
53
- if (response.status !== 200) {
54
- // Drain the body so the connection can be reused; the status alone
55
- // classifies the failure.
56
- await response.text();
57
- throw new Error(
58
- `authorization server discovery failed: ${wellKnownUrl} returned HTTP ${response.status} (expected 200). ` +
59
- "This MCP server may not support the MCP Authorization specification",
60
- );
99
+ function toAuthServerMetadata(m: AuthorizationServerMetadata): AuthServerMetadata {
100
+ return {
101
+ issuer: m.issuer,
102
+ authorizationEndpoint: m.authorizationEndpoint,
103
+ tokenEndpoint: m.tokenEndpoint,
104
+ registrationEndpoint: m.registrationEndpoint,
105
+ scopesSupported: [...m.scopesSupported],
106
+ codeChallengeMethodsSupported: [...m.codeChallengeMethodsSupported],
107
+ };
108
+ }
109
+
110
+ /**
111
+ * The failure names the first document tried for the first issuer: for a
112
+ * bare issuer that is RFC 8414 at its origin, the URL the retired reader
113
+ * named, so the pinned copy reads as it always did.
114
+ */
115
+ function discoveryFailure(issuers: readonly string[], attempts: readonly MetadataAttempt[]): Error {
116
+ const firstIssuer = issuers[0] ?? "";
117
+ const named = firstDocumentFor(firstIssuer);
118
+ const attempt = attempts.find((a) => a.url === named) ?? attempts[0];
119
+ if (attempt?.error !== undefined) {
120
+ return new Error(`discovery request to ${attempt.url} failed: ${attempt.error}`);
61
121
  }
122
+ if (attempt?.missing !== undefined) {
123
+ return new Error(`authorization server at ${attempt.url} is missing ${attempt.missing}`);
124
+ }
125
+ const status = attempt?.status ?? 0;
126
+ return new Error(
127
+ `authorization server discovery failed: ${named} returned HTTP ${status} (expected 200). ` +
128
+ "This MCP server may not support the MCP Authorization specification",
129
+ );
130
+ }
62
131
 
63
- let raw: {
64
- issuer?: string;
65
- authorization_endpoint?: string;
66
- token_endpoint?: string;
67
- registration_endpoint?: string;
68
- scopes_supported?: string[];
69
- code_challenge_methods_supported?: string[];
70
- };
132
+ function firstDocumentFor(issuer: string): string {
71
133
  try {
72
- raw = (await response.json()) as typeof raw;
73
- } catch (error) {
74
- throw new Error(
75
- `failed to parse authorization server metadata: ${error instanceof Error ? error.message : String(error)}`,
76
- );
134
+ return authorizationServerMetadataUrls(new URL(issuer))[0] ?? issuer;
135
+ } catch {
136
+ return issuer;
77
137
  }
78
-
79
- const metadata: AuthServerMetadata = {
80
- issuer: raw.issuer ?? "",
81
- authorizationEndpoint: raw.authorization_endpoint ?? "",
82
- tokenEndpoint: raw.token_endpoint ?? "",
83
- registrationEndpoint: raw.registration_endpoint ?? "",
84
- scopesSupported: raw.scopes_supported ?? [],
85
- codeChallengeMethodsSupported: raw.code_challenge_methods_supported ?? [],
86
- };
87
-
88
- validateMetadata(metadata, wellKnownUrl);
89
- return metadata;
90
138
  }
91
139
 
92
- /**
93
- * Constructs the .well-known URL from the MCP server URL (Go
94
- * buildWellKnownURL). Per RFC 8414, the well-known URI is at the origin.
95
- */
96
- export function buildWellKnownUrl(serverUrl: string): string {
140
+ /** Parse and screen a discovery URL; the copy is the Go buildWellKnownURL's, embedded by initiate. */
141
+ function parseDiscoveryUrl(serverUrl: string): URL {
97
142
  let parsed: URL;
98
143
  try {
99
144
  parsed = new URL(serverUrl);
100
145
  } catch (error) {
101
- throw new Error(
102
- `invalid server URL for discovery: ${error instanceof Error ? error.message : String(error)}`,
103
- );
146
+ throw new Error(`invalid server URL for discovery: ${error instanceof Error ? error.message : String(error)}`);
104
147
  }
105
-
106
148
  const scheme = parsed.protocol.replace(/:$/, "");
107
149
  if (scheme !== "http" && scheme !== "https") {
108
- throw new Error(
109
- `invalid server URL for discovery: unsupported scheme "${scheme}": only http and https are supported`,
110
- );
150
+ throw new Error(`invalid server URL for discovery: unsupported scheme "${scheme}": only http and https are supported`);
111
151
  }
112
152
  if (parsed.host === "") {
113
153
  throw new Error("invalid server URL for discovery: server URL has no host");
114
154
  }
115
-
116
- const origin = `${scheme}://${parsed.host}`;
117
- return `${origin.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`;
155
+ return parsed;
118
156
  }
119
157
 
120
158
  function validateMetadata(m: AuthServerMetadata, sourceUrl: string): void {
121
159
  if (m.authorizationEndpoint === "") {
122
- throw new Error(
123
- `authorization server at ${sourceUrl} is missing authorization_endpoint`,
124
- );
160
+ throw new Error(`authorization server at ${sourceUrl} is missing authorization_endpoint`);
125
161
  }
126
162
  if (m.tokenEndpoint === "") {
127
- throw new Error(
128
- `authorization server at ${sourceUrl} is missing token_endpoint`,
129
- );
163
+ throw new Error(`authorization server at ${sourceUrl} is missing token_endpoint`);
130
164
  }
131
- if (
132
- m.codeChallengeMethodsSupported.length > 0 &&
133
- !m.codeChallengeMethodsSupported.includes("S256")
134
- ) {
165
+ if (m.codeChallengeMethodsSupported.length > 0 && !m.codeChallengeMethodsSupported.includes("S256")) {
135
166
  // Go renders the supported list with %v — space-separated in
136
167
  // brackets; matched exactly because initiate forwards this text.
137
168
  throw new Error(
@@ -23,6 +23,7 @@
23
23
  * the authorization server at this point, and nothing is consumed until
24
24
  * the callback leg, which the probe never reaches.
25
25
  */
26
+ import type { OutboundFetch } from "@stigmer/outbound/egress";
26
27
  import { truncateBody } from "./truncate-body.js";
27
28
 
28
29
  /**
@@ -63,7 +64,7 @@ const PREFLIGHT_BODY_READ_CAP = 4096;
63
64
  */
64
65
  export async function preflightAuthorize(
65
66
  authorizationUrl: string,
66
- fetchImpl: typeof fetch = fetch,
67
+ fetchImpl: OutboundFetch,
67
68
  ): Promise<AuthorizeRejection | undefined> {
68
69
  // Network errors propagate to the caller, who treats them as
69
70
  // fail-open diagnostics — exactly Go's (nil, err) contract.
@@ -3,6 +3,7 @@
3
3
  * Consumed by the agentexecution EC builder's OAuth injection (#17); the
4
4
  * connect-lane refresh callers arrive with #19.
5
5
  */
6
+ import type { OutboundFetch } from "@stigmer/outbound/egress";
6
7
  import type { OAuthGrant } from "../../../store/interface.js";
7
8
  import type { Logger } from "../../../boot/logger.js";
8
9
  import { refreshToken } from "./token.js";
@@ -40,7 +41,7 @@ export async function refreshTokenIfExpired(
40
41
  clientSecret: string,
41
42
  tokenAuthMethod: string,
42
43
  logger: Logger,
43
- fetchImpl: typeof fetch = fetch,
44
+ fetchImpl: OutboundFetch,
44
45
  ): Promise<RefreshResult> {
45
46
  if (grant.accessTokenExpiresAt === 0) {
46
47
  // Token does not expire.
@@ -4,6 +4,7 @@
4
4
  * (#19). Proven by mcpserver-oauth.conformance.test.ts and the Class B
5
5
  * mcpserver-connect suite.
6
6
  */
7
+ import type { OutboundFetch } from "@stigmer/outbound/egress";
7
8
  import { truncateBody } from "./truncate-body.js";
8
9
 
9
10
  /** Token-endpoint response (Go TokenResponse). */
@@ -40,7 +41,7 @@ export async function exchangeCode(
40
41
  clientId: string,
41
42
  clientSecret: string,
42
43
  tokenAuthMethod: string,
43
- fetchImpl: typeof fetch = fetch,
44
+ fetchImpl: OutboundFetch,
44
45
  ): Promise<TokenResponse> {
45
46
  const params = new URLSearchParams({
46
47
  grant_type: "authorization_code",
@@ -71,7 +72,7 @@ export async function refreshToken(
71
72
  clientId: string,
72
73
  clientSecret: string,
73
74
  tokenAuthMethod: string,
74
- fetchImpl: typeof fetch = fetch,
75
+ fetchImpl: OutboundFetch,
75
76
  ): Promise<TokenResponse> {
76
77
  const params = new URLSearchParams({
77
78
  grant_type: "refresh_token",
@@ -94,7 +95,7 @@ async function doTokenRequest(
94
95
  clientId: string,
95
96
  clientSecret: string,
96
97
  tokenAuthMethod: string,
97
- fetchImpl: typeof fetch,
98
+ fetchImpl: OutboundFetch,
98
99
  ): Promise<TokenResponse> {
99
100
  // Exactly one credential channel per request — RFC 6749 §2.3 forbids
100
101
  // presenting the secret through more than one method. Post mode rides
@@ -934,7 +934,7 @@ export async function recoverExecution(
934
934
  temporalConfig: deps.temporalConfig,
935
935
  },
936
936
  loadedExecution(ctx),
937
- ctx.callerIdentity.identityId,
937
+ ctx.callerIdentity,
938
938
  );
939
939
  },
940
940
  },