@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.
- package/README.md +1 -1
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +42 -6
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/config.d.ts +6 -2
- package/dist/boot/config.d.ts.map +1 -1
- package/dist/boot/config.js.map +1 -1
- package/dist/boot/oauth-redirect-uri.d.ts +54 -0
- package/dist/boot/oauth-redirect-uri.d.ts.map +1 -0
- package/dist/boot/oauth-redirect-uri.js +37 -0
- package/dist/boot/oauth-redirect-uri.js.map +1 -0
- package/dist/domain/agentexecution/lifecycle.js +1 -1
- package/dist/domain/agentexecution/lifecycle.js.map +1 -1
- package/dist/domain/mcpserver/complete-endpoint-auth.d.ts +16 -0
- package/dist/domain/mcpserver/complete-endpoint-auth.d.ts.map +1 -0
- package/dist/domain/mcpserver/complete-endpoint-auth.js +260 -0
- package/dist/domain/mcpserver/complete-endpoint-auth.js.map +1 -0
- package/dist/domain/mcpserver/complete-oauth-connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/complete-oauth-connect.js +1 -1
- package/dist/domain/mcpserver/complete-oauth-connect.js.map +1 -1
- package/dist/domain/mcpserver/connect.d.ts +25 -0
- package/dist/domain/mcpserver/connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/connect.js +8 -23
- package/dist/domain/mcpserver/connect.js.map +1 -1
- package/dist/domain/mcpserver/controller.d.ts.map +1 -1
- package/dist/domain/mcpserver/controller.js +12 -0
- package/dist/domain/mcpserver/controller.js.map +1 -1
- package/dist/domain/mcpserver/initiate-oauth-connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/initiate-oauth-connect.js +38 -14
- package/dist/domain/mcpserver/initiate-oauth-connect.js.map +1 -1
- package/dist/domain/mcpserver/oauth/dcr.d.ts +10 -1
- package/dist/domain/mcpserver/oauth/dcr.d.ts.map +1 -1
- package/dist/domain/mcpserver/oauth/dcr.js +1 -9
- package/dist/domain/mcpserver/oauth/dcr.js.map +1 -1
- package/dist/domain/mcpserver/oauth/discovery.d.ts +43 -20
- package/dist/domain/mcpserver/oauth/discovery.d.ts.map +1 -1
- package/dist/domain/mcpserver/oauth/discovery.js +69 -54
- package/dist/domain/mcpserver/oauth/discovery.js.map +1 -1
- package/dist/domain/mcpserver/oauth/preflight.d.ts +27 -1
- package/dist/domain/mcpserver/oauth/preflight.d.ts.map +1 -1
- package/dist/domain/mcpserver/oauth/preflight.js +1 -26
- package/dist/domain/mcpserver/oauth/preflight.js.map +1 -1
- package/dist/domain/mcpserver/oauth/refresh.d.ts +2 -1
- package/dist/domain/mcpserver/oauth/refresh.d.ts.map +1 -1
- package/dist/domain/mcpserver/oauth/refresh.js +1 -1
- package/dist/domain/mcpserver/oauth/refresh.js.map +1 -1
- package/dist/domain/mcpserver/oauth/token.d.ts +9 -2
- package/dist/domain/mcpserver/oauth/token.d.ts.map +1 -1
- package/dist/domain/mcpserver/oauth/token.js +2 -8
- package/dist/domain/mcpserver/oauth/token.js.map +1 -1
- package/dist/domain/workflowexecution/lifecycle.js +1 -1
- package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
- package/dist/extensions/drivers.d.ts +17 -0
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/outbound-egress.d.ts +49 -0
- package/dist/extensions/outbound-egress.d.ts.map +1 -0
- package/dist/extensions/outbound-egress.js +51 -0
- package/dist/extensions/outbound-egress.js.map +1 -0
- package/dist/extensions/registry.d.ts +6 -0
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +10 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/apiresource-labels.d.ts +14 -0
- package/dist/pipeline/apiresource-labels.d.ts.map +1 -1
- package/dist/pipeline/apiresource-labels.js +14 -0
- package/dist/pipeline/apiresource-labels.js.map +1 -1
- package/dist/sandbox/kubernetes.d.ts.map +1 -1
- package/dist/sandbox/kubernetes.js +8 -1
- package/dist/sandbox/kubernetes.js.map +1 -1
- package/dist/sandbox/provisioner.d.ts +20 -5
- package/dist/sandbox/provisioner.d.ts.map +1 -1
- package/dist/sandbox/provisioner.js.map +1 -1
- package/dist/sandbox/steps.d.ts +22 -8
- package/dist/sandbox/steps.d.ts.map +1 -1
- package/dist/sandbox/steps.js +17 -12
- package/dist/sandbox/steps.js.map +1 -1
- package/package.json +6 -5
- package/src/boot/__tests__/oauth-redirect-uri.test.ts +38 -0
- package/src/boot/compose.ts +47 -8
- package/src/boot/config.ts +6 -2
- package/src/boot/oauth-redirect-uri.ts +53 -0
- package/src/domain/agentexecution/lifecycle.ts +1 -1
- package/src/domain/mcpserver/__tests__/complete-endpoint-auth.test.ts +270 -0
- package/src/domain/mcpserver/__tests__/connect-authorization.test.ts +3 -0
- package/src/domain/mcpserver/__tests__/connect.test.ts +31 -0
- package/src/domain/mcpserver/__tests__/oauth-handshake.test.ts +1 -1
- package/src/domain/mcpserver/complete-endpoint-auth.ts +277 -0
- package/src/domain/mcpserver/complete-oauth-connect.ts +1 -0
- package/src/domain/mcpserver/connect.ts +21 -15
- package/src/domain/mcpserver/controller.ts +16 -0
- package/src/domain/mcpserver/initiate-oauth-connect.ts +37 -13
- package/src/domain/mcpserver/oauth/__tests__/discovery.test.ts +120 -91
- package/src/domain/mcpserver/oauth/__tests__/token-refresh.test.ts +3 -1
- package/src/domain/mcpserver/oauth/dcr.ts +2 -1
- package/src/domain/mcpserver/oauth/discovery.ts +120 -89
- package/src/domain/mcpserver/oauth/preflight.ts +2 -1
- package/src/domain/mcpserver/oauth/refresh.ts +2 -1
- package/src/domain/mcpserver/oauth/token.ts +4 -3
- package/src/domain/workflowexecution/lifecycle.ts +1 -1
- package/src/extensions/__tests__/outbound-egress-points.test.ts +58 -0
- package/src/extensions/drivers.ts +17 -0
- package/src/extensions/outbound-egress.ts +55 -0
- package/src/extensions/registry.ts +19 -0
- package/src/index.ts +10 -0
- package/src/pipeline/apiresource-labels.ts +16 -0
- package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
- package/src/sandbox/__tests__/kubernetes.test.ts +10 -2
- package/src/sandbox/__tests__/local-process.integration.test.ts +3 -0
- package/src/sandbox/__tests__/steps.test.ts +72 -19
- package/src/sandbox/kubernetes.ts +8 -1
- package/src/sandbox/provisioner.ts +20 -5
- package/src/sandbox/steps.ts +29 -12
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pins
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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("
|
|
59
|
-
it("
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"https://auth.example.com/
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
133
|
-
|
|
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:
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
35
|
-
serverUrl
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
} catch
|
|
74
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|