@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
|
@@ -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
|
-
|
|
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>
|
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);
|
package/src/oauth-flows-store.ts
CHANGED
|
@@ -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" &&
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -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
|
-
|
|
1899
|
-
|
|
1900
|
-
//
|
|
1901
|
-
|
|
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
|
|