@openparachute/hub 0.7.2 → 0.7.3-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/oauth-client.test.ts +75 -0
- package/src/oauth-client.ts +102 -12
package/package.json
CHANGED
|
@@ -126,6 +126,81 @@ describe("discover", () => {
|
|
|
126
126
|
const { fn } = fakeFetch({});
|
|
127
127
|
await expect(discover("not-a-url", fn)).rejects.toBeInstanceOf(OAuthClientError);
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
test("RFC 9728 path-inserted PRM + auth server on a SEPARATE host (the Read.ai shape)", async () => {
|
|
131
|
+
// The PRM lives ONLY at the path-inserted location (host root 404s) and
|
|
132
|
+
// points at an auth server on a DIFFERENT host. The pre-fix discover()
|
|
133
|
+
// probed only the host-root PRM, fell back to mcp-origin-as-issuer, and
|
|
134
|
+
// 404'd on the (nonexistent) authorization-server doc — this is the
|
|
135
|
+
// regression that blocked connecting Read.ai.
|
|
136
|
+
const { fn, calls } = fakeFetch({
|
|
137
|
+
"https://api.example.ai/.well-known/oauth-protected-resource/mcp": () =>
|
|
138
|
+
json({
|
|
139
|
+
resource: "https://api.example.ai/mcp",
|
|
140
|
+
authorization_servers: ["https://authn.example.ai/"],
|
|
141
|
+
scopes_supported: ["mcp:execute", "meeting:read"],
|
|
142
|
+
}),
|
|
143
|
+
"https://authn.example.ai/.well-known/oauth-authorization-server": () =>
|
|
144
|
+
json({
|
|
145
|
+
issuer: "https://authn.example.ai/",
|
|
146
|
+
authorization_endpoint: "https://authn.example.ai/oauth2/auth",
|
|
147
|
+
token_endpoint: "https://authn.example.ai/oauth2/token",
|
|
148
|
+
registration_endpoint: "https://api.example.ai/oauth/register",
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
const d = await discover("https://api.example.ai/mcp/", fn);
|
|
152
|
+
expect(d.issuer).toBe("https://authn.example.ai/");
|
|
153
|
+
expect(d.authorizationEndpoint).toBe("https://authn.example.ai/oauth2/auth");
|
|
154
|
+
expect(d.tokenEndpoint).toBe("https://authn.example.ai/oauth2/token");
|
|
155
|
+
expect(d.registrationEndpoint).toBe("https://api.example.ai/oauth/register");
|
|
156
|
+
// 9728 scopes win
|
|
157
|
+
expect(d.scopesSupported).toEqual(["mcp:execute", "meeting:read"]);
|
|
158
|
+
// proves the path-inserted PRM probe was used (host-root PRM isn't defined)
|
|
159
|
+
expect(
|
|
160
|
+
calls.some((c) => c.url === "https://api.example.ai/.well-known/oauth-protected-resource/mcp"),
|
|
161
|
+
).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("AS discovery falls back to openid-configuration when oauth-authorization-server is absent", async () => {
|
|
165
|
+
const { fn } = fakeFetch({
|
|
166
|
+
"https://api.example.ai/.well-known/oauth-protected-resource/mcp": () =>
|
|
167
|
+
json({ authorization_servers: ["https://authn.example.ai/"] }),
|
|
168
|
+
// No RFC 8414 doc — only OIDC discovery.
|
|
169
|
+
"https://authn.example.ai/.well-known/openid-configuration": () =>
|
|
170
|
+
json({
|
|
171
|
+
issuer: "https://authn.example.ai/",
|
|
172
|
+
authorization_endpoint: "https://authn.example.ai/oauth2/auth",
|
|
173
|
+
token_endpoint: "https://authn.example.ai/oauth2/token",
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
const d = await discover("https://api.example.ai/mcp", fn);
|
|
177
|
+
expect(d.tokenEndpoint).toBe("https://authn.example.ai/oauth2/token");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("malformed authorization_servers issuer throws OAuthClientError (not a raw TypeError)", async () => {
|
|
181
|
+
const { fn } = fakeFetch({
|
|
182
|
+
"https://api.example.ai/.well-known/oauth-protected-resource/mcp": () =>
|
|
183
|
+
json({ authorization_servers: ["not-a-url"] }),
|
|
184
|
+
});
|
|
185
|
+
await expect(discover("https://api.example.ai/mcp", fn)).rejects.toBeInstanceOf(OAuthClientError);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("AS discovery uses the OIDC-append form for a path-ful issuer", async () => {
|
|
189
|
+
const { fn } = fakeFetch({
|
|
190
|
+
"https://api.example.ai/.well-known/oauth-protected-resource/mcp": () =>
|
|
191
|
+
json({ authorization_servers: ["https://idp.example.ai/tenant1"] }),
|
|
192
|
+
// ONLY the OIDC-append URL exists (issuer-path + /.well-known/openid-configuration),
|
|
193
|
+
// not the RFC 8414 insert form — proves the append candidate is generated.
|
|
194
|
+
"https://idp.example.ai/tenant1/.well-known/openid-configuration": () =>
|
|
195
|
+
json({
|
|
196
|
+
issuer: "https://idp.example.ai/tenant1",
|
|
197
|
+
authorization_endpoint: "https://idp.example.ai/tenant1/auth",
|
|
198
|
+
token_endpoint: "https://idp.example.ai/tenant1/token",
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
const d = await discover("https://api.example.ai/mcp", fn);
|
|
202
|
+
expect(d.tokenEndpoint).toBe("https://idp.example.ai/tenant1/token");
|
|
203
|
+
});
|
|
129
204
|
});
|
|
130
205
|
|
|
131
206
|
// === DCR ====================================================================
|
package/src/oauth-client.ts
CHANGED
|
@@ -28,6 +28,15 @@ export type FetchFn = typeof fetch;
|
|
|
28
28
|
/** Default outbound timeout for every OAuth-client request. */
|
|
29
29
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Shorter timeout for best-effort DISCOVERY probes. Discovery tries several
|
|
33
|
+
* well-known candidates in priority order (path-inserted, host-root, OIDC), so a
|
|
34
|
+
* full 15s per probe could stack into a long wall-clock wait on a slow/firewalled
|
|
35
|
+
* remote. These metadata GETs are quick when they exist; a tight ceiling bounds
|
|
36
|
+
* the worst case while still tolerating a sluggish responder.
|
|
37
|
+
*/
|
|
38
|
+
const DISCOVERY_PROBE_TIMEOUT_MS = 6_000;
|
|
39
|
+
|
|
31
40
|
/**
|
|
32
41
|
* `fetch` with an AbortController timeout. The hub has no shared fetch wrapper,
|
|
33
42
|
* so this lives here for all outbound OAuth-client calls — a slow/hung remote
|
|
@@ -93,10 +102,15 @@ async function fetchJson(
|
|
|
93
102
|
url: string,
|
|
94
103
|
fetchFn: FetchFn,
|
|
95
104
|
what: string,
|
|
105
|
+
timeoutMs?: number,
|
|
96
106
|
): Promise<Record<string, unknown>> {
|
|
97
107
|
let res: Response;
|
|
98
108
|
try {
|
|
99
|
-
res = await fetchWithTimeout(
|
|
109
|
+
res = await fetchWithTimeout(
|
|
110
|
+
url,
|
|
111
|
+
{ headers: { accept: "application/json" }, ...(timeoutMs ? { timeout: timeoutMs } : {}) },
|
|
112
|
+
fetchFn,
|
|
113
|
+
);
|
|
100
114
|
} catch (err) {
|
|
101
115
|
throw new OAuthClientError(`${what}: request to ${url} failed`, err);
|
|
102
116
|
}
|
|
@@ -125,44 +139,120 @@ function asStringArray(v: unknown): string[] | undefined {
|
|
|
125
139
|
return out.length > 0 ? out : undefined;
|
|
126
140
|
}
|
|
127
141
|
|
|
142
|
+
/**
|
|
143
|
+
* RFC 9728 §3.1 / RFC 8414 §3.1 well-known URL candidates for a resource or
|
|
144
|
+
* issuer URL. The spec INSERTS the well-known segment after the host while
|
|
145
|
+
* PRESERVING the URL's path: `https://h/p` → `https://h/.well-known/<seg>/p`.
|
|
146
|
+
* We try the path-inserted form FIRST (correct when the resource/issuer carries
|
|
147
|
+
* a path — e.g. an MCP served at `/mcp`, or a multi-tenant issuer at `/tenant`),
|
|
148
|
+
* then the host-root form (`https://h/.well-known/<seg>`) as a fallback for
|
|
149
|
+
* path-less deployments + older servers. De-duplicated, in priority order.
|
|
150
|
+
*/
|
|
151
|
+
function wellKnownCandidates(url: string, segment: string): string[] {
|
|
152
|
+
const u = new URL(url);
|
|
153
|
+
// The query string is intentionally excluded (u.pathname omits it) — RFC 9728
|
|
154
|
+
// §3.1 builds the well-known URL from the resource PATH only.
|
|
155
|
+
const path = u.pathname.replace(/\/+$/, ""); // drop trailing slash(es)
|
|
156
|
+
const root = `${u.origin}/.well-known/${segment}`;
|
|
157
|
+
const out = path ? [`${u.origin}/.well-known/${segment}${path}`, root] : [root];
|
|
158
|
+
return [...new Set(out)];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Fetch the first candidate URL (in priority order) that returns a valid JSON
|
|
163
|
+
* object. Lets discovery probe the path-inserted well-known location first, then
|
|
164
|
+
* fall back to the host root. If EVERY candidate fails, throws an
|
|
165
|
+
* OAuthClientError naming all tried locations (so a failure points at every
|
|
166
|
+
* probe, not just the last).
|
|
167
|
+
*/
|
|
168
|
+
async function fetchJsonFirst(
|
|
169
|
+
urls: string[],
|
|
170
|
+
fetchFn: FetchFn,
|
|
171
|
+
what: string,
|
|
172
|
+
timeoutMs?: number,
|
|
173
|
+
): Promise<Record<string, unknown>> {
|
|
174
|
+
const errors: string[] = [];
|
|
175
|
+
for (const url of urls) {
|
|
176
|
+
try {
|
|
177
|
+
return await fetchJson(url, fetchFn, what, timeoutMs);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
throw new OAuthClientError(
|
|
183
|
+
`${what}: no metadata found — tried ${urls.length} location(s): ${errors.join(" | ")}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
128
187
|
/**
|
|
129
188
|
* Discover the issuer + endpoints for a remote MCP URL.
|
|
130
189
|
*
|
|
131
|
-
* 1. RFC 9728: GET <mcp
|
|
132
|
-
* authorization_servers[0] = issuer (+ scopes_supported).
|
|
133
|
-
*
|
|
190
|
+
* 1. RFC 9728: GET <mcp>/.well-known/oauth-protected-resource[/<path>] →
|
|
191
|
+
* authorization_servers[0] = issuer (+ scopes_supported). The path-inserted
|
|
192
|
+
* location is tried first (the modern MCP shape: an MCP at `/mcp` advertises
|
|
193
|
+
* at `/.well-known/oauth-protected-resource/mcp`, and its auth server often
|
|
194
|
+
* lives on a SEPARATE host), then the host root.
|
|
195
|
+
* 2. RFC 8414 / OIDC: GET <issuer>/.well-known/oauth-authorization-server then
|
|
196
|
+
* /.well-known/openid-configuration (each path-inserted then host-root) →
|
|
134
197
|
* authorization_endpoint, token_endpoint, registration_endpoint,
|
|
135
198
|
* revocation_endpoint (+ scopes_supported fallback).
|
|
136
199
|
*
|
|
137
200
|
* If the resource has no 9728 doc, falls back to treating the MCP origin itself
|
|
138
|
-
* as the issuer and reading its 8414 doc directly (8414-only resources).
|
|
201
|
+
* as the issuer and reading its 8414/OIDC doc directly (8414-only resources).
|
|
139
202
|
*/
|
|
140
203
|
export async function discover(mcpUrl: string, fetchFn: FetchFn = fetch): Promise<DiscoveryResult> {
|
|
141
204
|
const origin = originOf(mcpUrl);
|
|
142
205
|
|
|
143
|
-
// Step 1 — RFC 9728 (best-effort; some resources
|
|
206
|
+
// Step 1 — RFC 9728 protected-resource metadata (best-effort; some resources
|
|
207
|
+
// only expose 8414). Probe the PATH-INSERTED location first (an MCP served at
|
|
208
|
+
// `/mcp` advertises at `/.well-known/oauth-protected-resource/mcp`), then the
|
|
209
|
+
// host root — this is what lets a resource whose auth server lives on a
|
|
210
|
+
// SEPARATE host be discovered at all.
|
|
144
211
|
let issuer: string | undefined;
|
|
145
212
|
let prScopes: string[] | undefined;
|
|
146
213
|
try {
|
|
147
|
-
const pr = await
|
|
148
|
-
|
|
214
|
+
const pr = await fetchJsonFirst(
|
|
215
|
+
wellKnownCandidates(mcpUrl, "oauth-protected-resource"),
|
|
149
216
|
fetchFn,
|
|
150
217
|
"protected-resource discovery",
|
|
218
|
+
DISCOVERY_PROBE_TIMEOUT_MS,
|
|
151
219
|
);
|
|
152
220
|
const servers = asStringArray(pr.authorization_servers);
|
|
153
221
|
issuer = servers?.[0];
|
|
154
222
|
prScopes = asStringArray(pr.scopes_supported);
|
|
155
223
|
} catch {
|
|
156
|
-
// No 9728 doc — fall back to the MCP origin as issuer.
|
|
224
|
+
// No 9728 doc anywhere — fall back to the MCP origin as issuer.
|
|
157
225
|
issuer = undefined;
|
|
158
226
|
}
|
|
159
227
|
if (!issuer) issuer = origin;
|
|
160
228
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
229
|
+
// The issuer comes from the (attacker-influenceable) PRM doc — validate it
|
|
230
|
+
// parses as a URL so a malformed value surfaces as an OAuthClientError rather
|
|
231
|
+
// than a raw TypeError out of the candidate construction below.
|
|
232
|
+
let issuerUrl: URL;
|
|
233
|
+
try {
|
|
234
|
+
issuerUrl = new URL(issuer);
|
|
235
|
+
} catch {
|
|
236
|
+
throw new OAuthClientError(`authorization_servers[0] is not a valid URL: ${issuer}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Step 2 — RFC 8414 / OIDC metadata on the issuer. Path-inserted before host
|
|
240
|
+
// root, and `oauth-authorization-server` before `openid-configuration`, so an
|
|
241
|
+
// issuer that sits at a path or only serves OIDC discovery still resolves. Plus
|
|
242
|
+
// the OIDC Discovery 1.0 §4.1 APPEND form (`<issuer-with-path>/.well-known/
|
|
243
|
+
// openid-configuration`) for a path-ful issuer — distinct from the 8414 INSERT.
|
|
244
|
+
const asCandidates = [
|
|
245
|
+
...wellKnownCandidates(issuer, "oauth-authorization-server"),
|
|
246
|
+
...wellKnownCandidates(issuer, "openid-configuration"),
|
|
247
|
+
];
|
|
248
|
+
if (issuerUrl.pathname.replace(/\/+$/, "")) {
|
|
249
|
+
asCandidates.push(`${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`);
|
|
250
|
+
}
|
|
251
|
+
const as = await fetchJsonFirst(
|
|
252
|
+
[...new Set(asCandidates)],
|
|
164
253
|
fetchFn,
|
|
165
254
|
"authorization-server discovery",
|
|
255
|
+
DISCOVERY_PROBE_TIMEOUT_MS,
|
|
166
256
|
);
|
|
167
257
|
|
|
168
258
|
const authorizationEndpoint = asString(as.authorization_endpoint);
|