@openparachute/hub 0.7.2-rc.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.2-rc.1",
3
+ "version": "0.7.3-rc.1",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -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 ====================================================================
@@ -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(url, { headers: { accept: "application/json" } }, fetchFn);
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-origin>/.well-known/oauth-protected-resource →
132
- * authorization_servers[0] = issuer (+ scopes_supported).
133
- * 2. RFC 8414: GET <issuer>/.well-known/oauth-authorization-server
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 only expose 8414).
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 fetchJson(
148
- `${origin}/.well-known/oauth-protected-resource`,
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
- // Step 2 RFC 8414 on the issuer.
162
- const as = await fetchJson(
163
- `${issuer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`,
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);