@openparachute/hub 0.7.2 → 0.7.3-rc.2

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",
3
+ "version": "0.7.3-rc.2",
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": {
@@ -1353,6 +1353,119 @@ describe("GET /oauth/agent-grant/callback", () => {
1353
1353
  });
1354
1354
  });
1355
1355
 
1356
+ // === returnTo: send the operator back to where they started ================
1357
+ //
1358
+ // The OAuth-consent round-trip used to dead-end on a "close this tab" page. A
1359
+ // same-origin (hub-relative) `returnTo` on the approve body is stashed on the
1360
+ // flow + 302'd to on success (with `?mcp_connected=1`). The open-redirect guard
1361
+ // (`isSafeHubReturnTo`, reused) is the load-bearing property: an off-origin /
1362
+ // scheme-relative value is dropped at stash time and never lands in Location.
1363
+ describe("approve(mcp) + callback — returnTo round-trip", () => {
1364
+ async function startFlowWithBody(body: Record<string, unknown>): Promise<string> {
1365
+ const bearer = await moduleBearer();
1366
+ const cookie = await operatorCookie();
1367
+ const created = await json(
1368
+ await dispatch(
1369
+ bearerReq("PUT", "/admin/grants", bearer, {
1370
+ agent: "a",
1371
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
1372
+ }),
1373
+ ),
1374
+ );
1375
+ const id = created.id as string;
1376
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie, body));
1377
+ return id;
1378
+ }
1379
+
1380
+ test("approve stashes a valid same-origin returnTo on the flow", async () => {
1381
+ currentOAuth = fakeOAuth();
1382
+ await startFlowWithBody({ returnTo: "/admin/grants" });
1383
+ const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
1384
+ expect(flow?.returnTo).toBe("/admin/grants");
1385
+ });
1386
+
1387
+ test("approve drops an off-origin returnTo (absolute URL) — open-redirect guard", async () => {
1388
+ currentOAuth = fakeOAuth();
1389
+ await startFlowWithBody({ returnTo: "https://evil.example/steal" });
1390
+ const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
1391
+ expect(flow?.returnTo).toBeUndefined();
1392
+ });
1393
+
1394
+ test("approve drops a scheme-relative returnTo (//host) — open-redirect guard", async () => {
1395
+ currentOAuth = fakeOAuth();
1396
+ await startFlowWithBody({ returnTo: "//evil.example/steal" });
1397
+ const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
1398
+ expect(flow?.returnTo).toBeUndefined();
1399
+ });
1400
+
1401
+ test("approve drops a `..` path-traversal returnTo — open-redirect guard", async () => {
1402
+ currentOAuth = fakeOAuth();
1403
+ await startFlowWithBody({ returnTo: "/admin/../../etc/passwd" });
1404
+ const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
1405
+ expect(flow?.returnTo).toBeUndefined();
1406
+ });
1407
+
1408
+ test("approve drops a PERCENT-ENCODED `..` returnTo (%2e%2e) — open-redirect guard", async () => {
1409
+ // `new URL()` decodes %2e%2e before emitting the redirect path, so the guard
1410
+ // must reject the encoded form too, not just the literal `..`.
1411
+ currentOAuth = fakeOAuth();
1412
+ await startFlowWithBody({ returnTo: "/%2e%2e/etc/passwd" });
1413
+ const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
1414
+ expect(flow?.returnTo).toBeUndefined();
1415
+ });
1416
+
1417
+ test("callback 302-redirects to a valid returnTo (with mcp_connected=1) on success", async () => {
1418
+ currentOAuth = fakeOAuth();
1419
+ const id = await startFlowWithBody({ returnTo: "/admin/grants?agent=a" });
1420
+ const res = await callback("?code=ok&state=fixed-state");
1421
+ expect(res.status).toBe(302);
1422
+ const location = res.headers.get("location") ?? "";
1423
+ expect(location).toContain("/admin/grants");
1424
+ expect(location).toContain("agent=a");
1425
+ expect(location).toContain("mcp_connected=1");
1426
+ // Same-origin only — never a host/scheme in Location.
1427
+ expect(location.startsWith("/")).toBe(true);
1428
+ expect(location.startsWith("//")).toBe(false);
1429
+ // The grant still flipped to approved (the success side-effects all ran).
1430
+ expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("approved");
1431
+ });
1432
+
1433
+ test("callback overwrites a pre-existing mcp_connected param rather than duplicating it", async () => {
1434
+ currentOAuth = fakeOAuth();
1435
+ await startFlowWithBody({ returnTo: "/admin/grants?mcp_connected=0" });
1436
+ const res = await callback("?code=ok&state=fixed-state");
1437
+ expect(res.status).toBe(302);
1438
+ const location = res.headers.get("location") ?? "";
1439
+ expect(location).toContain("mcp_connected=1");
1440
+ expect(location).not.toContain("mcp_connected=0");
1441
+ // exactly one occurrence (searchParams.set semantics, not append)
1442
+ expect(location.match(/mcp_connected=/g)?.length).toBe(1);
1443
+ });
1444
+
1445
+ test("callback falls back to the close-tab HTML when no returnTo was stashed", async () => {
1446
+ currentOAuth = fakeOAuth();
1447
+ const id = await startFlowWithBody({}); // no returnTo
1448
+ const res = await callback("?code=ok&state=fixed-state");
1449
+ expect(res.status).toBe(200);
1450
+ expect(res.headers.get("content-type")).toContain("text/html");
1451
+ expect(await res.text()).toContain("Connected");
1452
+ expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("approved");
1453
+ });
1454
+
1455
+ test("callback falls back to the close-tab HTML for an unsafe returnTo (never 302s off-origin)", async () => {
1456
+ currentOAuth = fakeOAuth();
1457
+ // The unsafe value was already dropped at stash time, so the callback has no
1458
+ // returnTo to honor — it renders the back-compat page rather than redirecting.
1459
+ const id = await startFlowWithBody({ returnTo: "https://evil.example/steal" });
1460
+ const res = await callback("?code=ok&state=fixed-state");
1461
+ expect(res.status).toBe(200);
1462
+ expect(res.headers.get("content-type")).toContain("text/html");
1463
+ expect(res.headers.get("location")).toBeNull();
1464
+ expect(await res.text()).toContain("Connected");
1465
+ expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("approved");
1466
+ });
1467
+ });
1468
+
1356
1469
  describe("material(mcp) — auto-refresh", () => {
1357
1470
  // Drive a grant to approved-via-OAuth, with expiry in the (near) past/future.
1358
1471
  async function approvedViaOAuth(expiresAt: string): Promise<string> {
@@ -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 ====================================================================
@@ -80,6 +80,7 @@ import {
80
80
  } from "./jwt-sign.ts";
81
81
  import { type OAuthClient, deriveVaultScopeFromMcpUrl, realOAuthClient } from "./oauth-client.ts";
82
82
  import { type PendingFlow, deleteFlow, getFlowByState, putFlow } from "./oauth-flows-store.ts";
83
+ import { isSafeHubReturnTo } from "./oauth-handlers.ts";
83
84
  import { findSession, parseSessionCookie } from "./sessions.ts";
84
85
  import { isFirstAdmin } from "./users.ts";
85
86
  import { validateVaultName } from "./vault-name.ts";
@@ -630,10 +631,15 @@ async function approveGrant(req: Request, id: string, deps: AgentGrantsDeps): Pr
630
631
  const grant = getGrant(deps.storePath, id);
631
632
  if (!grant) return jsonError(404, "not_found", `no grant ${id}`);
632
633
 
633
- let body: { token?: unknown } = {};
634
+ // `returnTo` is consumed ONLY by the mcp/OAuth path (approveMcpGrant), which
635
+ // is the one approve flow that hands the browser off to a remote consent
636
+ // screen and needs somewhere to land on return. vault/service approvals
637
+ // complete synchronously and return JSON — there's no redirect, so they
638
+ // ignore `returnTo` by design.
639
+ let body: { token?: unknown; returnTo?: unknown } = {};
634
640
  try {
635
641
  const raw = await req.text();
636
- if (raw.trim().length > 0) body = JSON.parse(raw) as { token?: unknown };
642
+ if (raw.trim().length > 0) body = JSON.parse(raw) as { token?: unknown; returnTo?: unknown };
637
643
  } catch {
638
644
  return jsonError(400, "invalid_request", "body must be JSON when present");
639
645
  }
@@ -748,7 +754,7 @@ async function approveGrant(req: Request, id: string, deps: AgentGrantsDeps): Pr
748
754
  */
749
755
  async function approveMcpGrant(
750
756
  grant: GrantRecord,
751
- body: { token?: unknown },
757
+ body: { token?: unknown; returnTo?: unknown },
752
758
  deps: AgentGrantsDeps,
753
759
  approvedAt: string,
754
760
  ): Promise<Response> {
@@ -850,6 +856,16 @@ async function approveMcpGrant(
850
856
  ? discovery.scopesSupported.join(" ")
851
857
  : undefined;
852
858
 
859
+ // OPTIONAL `returnTo` — the same-origin (hub-relative) page the operator
860
+ // started from (the agent ops surface / admin grants page). Stash it on the
861
+ // flow ONLY when it passes the shared open-redirect guard; absent/invalid →
862
+ // omit it (the callback then renders the back-compat close-tab page). This is
863
+ // the open-redirect-load-bearing check — reuse, don't reinvent.
864
+ const returnTo =
865
+ typeof body.returnTo === "string" && isSafeHubReturnTo(body.returnTo)
866
+ ? body.returnTo
867
+ : undefined;
868
+
853
869
  const flow: PendingFlow = {
854
870
  state,
855
871
  grantId: grant.id,
@@ -861,6 +877,7 @@ async function approveMcpGrant(
861
877
  mcpUrl,
862
878
  ...(scope ? { scope } : {}),
863
879
  redirectUri,
880
+ ...(returnTo ? { returnTo } : {}),
864
881
  createdAt: (deps.now?.() ?? new Date()).toISOString(),
865
882
  };
866
883
  putFlow(deps.flowsStorePath, flow, (deps.now?.() ?? new Date()).getTime());
@@ -1040,6 +1057,17 @@ export async function handleOAuthGrantCallback(
1040
1057
  // NEVER log a token.
1041
1058
  console.log(`agent grant approved: id=${grant.id} agent=${grant.agent} kind=mcp mode=oauth`);
1042
1059
 
1060
+ // If the operator started from a hub page (agent ops surface / admin grants),
1061
+ // send them back there instead of a dead-end "close this tab" screen. Re-run
1062
+ // the open-redirect guard DEFENSIVELY at redirect time (the value was already
1063
+ // gated at stash time; this is belt-and-suspenders against a tampered store).
1064
+ // Append `?mcp_connected=1` (preserving any existing query) so the SPA can
1065
+ // react (toast / refetch the grant). Absent/invalid returnTo → the
1066
+ // back-compat close-tab page.
1067
+ if (flow.returnTo && isSafeHubReturnTo(flow.returnTo)) {
1068
+ return redirectToReturn(flow.returnTo);
1069
+ }
1070
+
1043
1071
  return htmlPage(
1044
1072
  200,
1045
1073
  "Connected",
@@ -1047,6 +1075,26 @@ export async function handleOAuthGrantCallback(
1047
1075
  );
1048
1076
  }
1049
1077
 
1078
+ /**
1079
+ * 302 back to a same-origin (hub-relative) `returnTo`, appending
1080
+ * `mcp_connected=1` so the SPA can show a success toast / refetch. The caller
1081
+ * MUST have already passed `returnTo` through `isSafeHubReturnTo` — this builds
1082
+ * the URL against a fixed dummy origin purely to merge the query param
1083
+ * correctly, then emits only the path+query (never the origin), so a
1084
+ * scheme-relative value can't leak through into the Location header.
1085
+ */
1086
+ function redirectToReturn(returnTo: string): Response {
1087
+ // Parse against a fixed base so query-string merging is correct even when
1088
+ // returnTo already carries `?...`. Only `pathname + search + hash` is emitted.
1089
+ const u = new URL(returnTo, "http://hub.invalid");
1090
+ u.searchParams.set("mcp_connected", "1");
1091
+ const location = `${u.pathname}${u.search}${u.hash}`;
1092
+ return new Response(null, {
1093
+ status: 302,
1094
+ headers: { location, "cache-control": "no-store" },
1095
+ });
1096
+ }
1097
+
1050
1098
  /** Minimal server-rendered HTML — NEVER carries a token. */
1051
1099
  function htmlPage(status: number, heading: string, message: string): Response {
1052
1100
  const html = `<!doctype html>
@@ -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);
@@ -52,6 +52,14 @@ export interface PendingFlow {
52
52
  readonly scope?: string;
53
53
  /** The hub's callback URL registered for this flow. */
54
54
  readonly redirectUri: string;
55
+ /**
56
+ * OPTIONAL same-origin (hub-relative) page the operator should be sent back
57
+ * to after a SUCCESSFUL consent — the agent ops surface / admin grants page
58
+ * they started from. Validated with `isSafeHubReturnTo` at stash time and
59
+ * (defensively) again at redirect time. Absent for flows started without one
60
+ * (and for all pre-existing on-disk flows — additive + back-compat).
61
+ */
62
+ readonly returnTo?: string;
55
63
  readonly createdAt: string;
56
64
  }
57
65
 
@@ -62,6 +70,10 @@ interface FlowsFile {
62
70
  function isPendingFlow(v: unknown): v is PendingFlow {
63
71
  if (!v || typeof v !== "object") return false;
64
72
  const f = v as Record<string, unknown>;
73
+ // `returnTo` is OPTIONAL + additive — absent on every pre-existing on-disk
74
+ // flow. Accept undefined; if present it must be a string (validated for
75
+ // open-redirect safety at the call sites, not here).
76
+ if (f.returnTo !== undefined && typeof f.returnTo !== "string") return false;
65
77
  return (
66
78
  typeof f.state === "string" &&
67
79
  typeof f.grantId === "string" &&
@@ -1883,6 +1883,39 @@ export async function handleApproveClientPost(
1883
1883
  return redirectResponse(returnTo);
1884
1884
  }
1885
1885
 
1886
+ /**
1887
+ * The load-bearing open-redirect guard, shared by every hub `return_to` gate.
1888
+ * A safe hub-relative target is a SINGLE-slash root-relative path: same-origin
1889
+ * to the hub, no scheme, no `//host` scheme-relative escape. Empty string is
1890
+ * rejected.
1891
+ *
1892
+ * This is the same-origin core that `isSafeAuthorizeReturnTo` (OAuth-resume)
1893
+ * builds on, and that broader same-origin gates (the agent-grant OAuth
1894
+ * callback's `returnTo`) reuse directly — they need to return the operator to
1895
+ * a `/admin/...` page, not specifically `/oauth/authorize`. Single source of
1896
+ * truth for "is this an open redirect?" — do NOT reimplement the
1897
+ * `startsWith("/") && !startsWith("//")` check elsewhere.
1898
+ */
1899
+ export function isSafeHubReturnTo(value: string): boolean {
1900
+ if (!value) return false;
1901
+ // Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
1902
+ // single-slash root-relative paths are allowed.
1903
+ if (!value.startsWith("/") || value.startsWith("//")) return false;
1904
+ // Reject `..` traversal sequences — literal AND percent-encoded (`%2e%2e`),
1905
+ // since redirectToReturn's `new URL()` parse decodes them before emitting the
1906
+ // path. They stay same-origin but a target that walks the path tree is never a
1907
+ // legitimate return-to and lets a crafted value reach an unexpected hub path.
1908
+ // (`/oauth/authorize?…` callers never carry `..`, so this is free for them.)
1909
+ if (value.includes("..")) return false;
1910
+ let decoded: string;
1911
+ try {
1912
+ decoded = decodeURIComponent(value);
1913
+ } catch {
1914
+ return false; // malformed percent-encoding → reject rather than guess
1915
+ }
1916
+ return !decoded.includes("..");
1917
+ }
1918
+
1886
1919
  /**
1887
1920
  * Validate a form-submitted `return_to` value. Must be a hub-relative URL
1888
1921
  * (no scheme, no double-slash) targeting `/oauth/authorize` with a query
@@ -1895,13 +1928,10 @@ export async function handleApproveClientPost(
1895
1928
  * valid OAuth-resume target?" for the whole hub.
1896
1929
  */
1897
1930
  export function isSafeAuthorizeReturnTo(value: string): boolean {
1898
- if (!value) return false;
1899
- // Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
1900
- // single-slash root-relative paths are allowed.
1901
- if (!value.startsWith("/") || value.startsWith("//")) return false;
1902
- // Must target the authorize endpoint with a query string. The OAuth flow
1903
- // re-enters via GET /oauth/authorize?<original-params>; anything off-path
1904
- // is a misuse.
1931
+ // Same-origin open-redirect guard first (shared single source of truth)…
1932
+ if (!isSafeHubReturnTo(value)) return false;
1933
+ // …then the authorize-specific path constraint: the OAuth flow re-enters
1934
+ // via GET /oauth/authorize?<original-params>; anything off-path is a misuse.
1905
1935
  return value.startsWith("/oauth/authorize?");
1906
1936
  }
1907
1937