@openparachute/hub 0.7.3-rc.1 → 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.3-rc.1",
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> {
@@ -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>
@@ -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