History

Preserve URL org scope in cloud auth flows

Rhys Sullivan committed 4 months ago

Commit 417a2551 parent c575d2f14 files · +391 −68Browse files

Unified · Split

14 files
  1. apps/cloud/src/api.request-scope.node.test.ts +2 −2
  2. apps/cloud/src/api/layers.ts +15 −13
  3. apps/cloud/src/api/router.ts +2 −2
  4. apps/cloud/src/auth/handlers.ts +69 −31
  5. apps/cloud/src/auth/middleware-live.ts +0 −7
  6. apps/cloud/src/extensions/routes.ts +7 −4
  7. apps/cloud/src/mcp/session-durable-object.ts +1 −0
  8. apps/cloud/src/org/api.ts +2 −3
  9. apps/cloud/src/org/auth-middleware.ts +67 −0
  10. apps/cloud/src/org/handlers.ts +8 −4
  11. apps/cloud/src/web/client.tsx +6 −1
  12. apps/host-cloudflare/src/mcp/session-durable-object.ts +1 −0
  13. e2e/cloud/mcp-browser-approval-org-scope.test.ts +206 −0
  14. packages/hosts/mcp/src/browser-approval.ts +5 −1
apps/cloud/src/api.request-scope.node.test.ts+2−2
@@ -188,8 +188,8 @@ describe("makeApiLive (prod handler factory) request scoping", () => { · 187 unmodified lines
188188 // has built the per-request layer. We don't care about the response —
189189 // only that the layer was built once per request. `/integrations` is a
190190 // v2 protected route (the old `/scope` group was removed).
191− await handler(new Request("http://test.local/integrations"));
192− await handler(new Request("http://test.local/integrations"));
191+ await handler(new Request("http://test.local/integrations"), Context.empty());
192+ await handler(new Request("http://test.local/integrations"), Context.empty());
193193
194194 expect(counts.acquires).toBe(2);
195195 expect(counts.releases).toBe(2);
apps/cloud/src/api/layers.ts+15−13
@@ -4,7 +4,7 @@ import { Layer } from "effect"; · 3 unmodified lines
44
55 import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api/server";
66
7−import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live";
7+import { SessionAuthLive } from "../auth/middleware-live";
88 import { UserStoreService } from "../auth/context";
99 import {
1010 CloudAuthPublicHandlers,
@@ -14,6 +14,7 @@ import { · 3 unmodified lines
1414 import { DbService } from "../db/db";
1515 import { WorkerTelemetryLive } from "../observability/telemetry";
1616 import { OrgHttpApi } from "../org/api";
17+import { orgAuthMiddleware } from "../org/auth-middleware";
1718 import { OrgHandlers } from "../org/handlers";
1819 import { ErrorCaptureLive } from "../observability";
1920
@@ -61,23 +62,24 @@ export const makeNonProtectedApiLive = (rsLive: Layer.Layer<DbService | UserStor · 41 unmodified lines
6162 Layer.provideMerge(AutumnService.Default),
6263 );
6364
64−// Cloud-only WorkOS domain-verification routes. Auth is enforced by `OrgAuth`
65−// middleware declared on `OrgHttpApi`. The domain handlers read the boot
66−// `WorkOSClient` plus the `AuthContext` from `OrgAuthLive`; the
67−// `getDomainVerificationLink` handler also gates on billing, so
68−// `AutumnService.Default` is provided HERE (not on the neutral boot core).
69−// Unlike the member endpoints that used to live here, they need no per-request
70−// DB scoping (and `OrgAuthLive` stays session-scoped — see its note).
71−export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe(
72− Layer.provide(OrgHandlers),
73− Layer.provideMerge(OrgAuthLive),
74− Layer.provideMerge(AutumnService.Default),
75−);
65+// Cloud-only WorkOS domain-verification routes. Auth is enforced by a router
66+// middleware that resolves the URL org selector header before falling back to
67+// the session org, so slug lookup uses the same per-request UserStoreService as
68+// the account and protected APIs. The `getDomainVerificationLink` handler also
69+// gates on billing, so `AutumnService.Default` is provided here, not on the
70+// neutral boot core.
71+export const makeOrgApiLive = (rsLive: Layer.Layer<DbService | UserStoreService>) =>
72+ HttpApiBuilder.layer(OrgHttpApi).pipe(
73+ Layer.provide(OrgHandlers),
74+ Layer.provide(orgAuthMiddleware(rsLive)),
75+ Layer.provideMerge(AutumnService.Default),
76+ );
7677
7778 // Default export uses the production per-request layer. Existing callers that
7879 // import `NonProtectedApiLive` continue to work; the `make*` factory exists for
7980 // tests that need to swap in a fake.
8081 export const NonProtectedApiLive = makeNonProtectedApiLive(RequestScopedServicesLive);
82+export const OrgApiLive = makeOrgApiLive(RequestScopedServicesLive);
8183
8284 // ---------------------------------------------------------------------------
8385 // Protected API
apps/cloud/src/api/router.ts+2−2
@@ -12,8 +12,8 @@ import { CloudDocsLive } from "../extensions/docs"; · 11 unmodified lines
1212 import { ApiErrorLoggingLive } from "../observability/error-logging";
1313 import {
1414 BootSharedServices,
15− OrgApiLive,
1615 RequestScopedServicesLive,
16+ makeOrgApiLive,
1717 makeNonProtectedApiLive,
1818 } from "./layers";
1919 import { makeProtectedApiLive } from "./protected";
@@ -35,7 +35,7 @@ export const makeApiLive = (requestScopedLive: Layer.Layer<DbService | UserStore · 15 unmodified lines
3535 );
3636 return Layer.mergeAll(
3737 makeNonProtectedApiLive(requestScopedLive),
38− OrgApiLive,
38+ makeOrgApiLive(requestScopedLive),
3939 makeAccountApiLive(requestScopedLive),
4040 CloudDocsLive,
4141 makeProtectedApiLive(requestScopedLive),
apps/cloud/src/auth/handlers.ts+69−31
@@ -1,6 +1,7 @@
11 import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
2−import { HttpServerResponse } from "effect/unstable/http";
2+import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
33 import { Duration, Effect, Predicate } from "effect";
4+import { isValidOrgSlug } from "@executor-js/api";
45
56 import {
67 AUTH_PATHS,
@@ -25,7 +26,12 @@ import { · 18 unmodified lines
2526 isOverFreeOrganizationLimit,
2627 shouldApplyFreeOrganizationLimit,
2728 } from "../extensions/billing/plans";
28−import { authorizeOrganization, resolveOrganization } from "./organization";
29+import {
30+ ORG_SELECTOR_HEADER,
31+ authorizeOrganization,
32+ authorizeOrganizationSelector,
33+ resolveOrganization,
34+} from "./organization";
2935 import type {
3036 McpSessionApprovalResult,
3137 McpSessionResumeApprovalResult,
@@ -82,14 +88,37 @@ const timingSafeEqual = (a: string, b: string): boolean => { · 50 unmodified lines
8288 return diff === 0;
8389 };
8490
85−const requireSessionOrganizationId = Effect.gen(function* () {
91+const requestHeaders = Effect.map(HttpServerRequest.HttpServerRequest.asEffect(), (req) => ({
92+ ...req.headers,
93+}));
94+
95+const firstPathSegment = (path: string): string | null => {
96+ const pathname = path.split(/[?#]/, 1)[0] ?? "";
97+ const segment = pathname.split("/")[1];
98+ return segment && isValidOrgSlug(segment) ? segment : null;
99+};
100+
101+const requestedOrgSelectorFromReturnTo = (returnTo: string): string | null =>
102+ firstPathSegment(returnTo);
103+
104+const requireSelectedOrganization = Effect.gen(function* () {
86105 const session = yield* SessionContext;
87− if (!session.organizationId) {
106+ const headers = yield* requestHeaders;
107+ const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
108+ if (!selector) {
88109 return yield* new NoOrganization();
89110 }
111+
112+ const org = yield* authorizeOrganizationSelector(session.accountId, selector).pipe(
113+ Effect.catch(() => Effect.fail(new NoOrganization())),
114+ );
115+ if (!org) {
116+ return yield* new NoOrganization();
117+ }
118+
90119 return {
91120 ...session,
92− organizationId: session.organizationId,
121+ organizationId: org.id,
93122 };
94123 });
95124
@@ -194,38 +223,47 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 98 unmodified lines
194223
195224 let sealedSession = result.sealedSession;
196225
197− // If the auth response didn't surface an org but the user is already
198− // an *active* member of one, rehydrate the session with it. Pending
199− // memberships (which represent unaccepted invitations on WorkOS's
200− // side) are skipped — refreshing into one 400s, and silently
201− // attaching an unaccepted org would also bypass invite consent.
202− // If they have no active memberships, leave the session org-less —
203− // AuthGate's onboarding flow surfaces pending invites and the
204− // create-org form. We never auto-create organizations on login.
205− if (!result.organizationId && sealedSession) {
226+ // Resume where the SSR gate interrupted them. The state passed the
227+ // CSRF check above whenever it's present, but it's still a
228+ // round-tripped value, so the returnTo inside it is re-validated like
229+ // any other untrusted path.
230+ const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/";
231+ const requestedOrgSelector = requestedOrgSelectorFromReturnTo(returnTo);
232+ const requestedOrg = requestedOrgSelector
233+ ? yield* authorizeOrganizationSelector(result.user.id, requestedOrgSelector).pipe(
234+ Effect.orElseSucceed(() => null),
235+ )
236+ : null;
237+
238+ // Prefer the org in the URL that sent the user to login. If the URL
239+ // is bare, or not an org route, fall back to WorkOS's org and then to
240+ // the first active membership for org-less sessions. Pending
241+ // memberships are skipped because refreshing into one 400s and would
242+ // bypass invite consent.
243+ let targetOrganizationId = requestedOrg?.id ?? result.organizationId ?? null;
244+ if (!targetOrganizationId && !requestedOrgSelector) {
206245 const memberships = yield* workos.listUserMemberships(result.user.id);
207246 const existingActive = memberships.data.find((m) => m.status === "active");
208− if (existingActive) {
209− // Best-effort refresh — if WorkOS rejects (e.g. the membership
210− // was just revoked), fall through to an org-less session rather
211− // than 500ing the entire callback.
212− const refreshed = yield* workos
213− .refreshSession(sealedSession, existingActive.organizationId)
214− .pipe(Effect.orElseSucceed(() => null));
215− if (refreshed) sealedSession = refreshed;
216− }
247+ targetOrganizationId = existingActive?.organizationId ?? null;
248+ }
249+
250+ if (
251+ targetOrganizationId &&
252+ targetOrganizationId !== result.organizationId &&
253+ sealedSession
254+ ) {
255+ // Best-effort refresh: if WorkOS rejects, fall through with the
256+ // original session instead of 500ing the entire callback.
257+ const refreshed = yield* workos
258+ .refreshSession(sealedSession, targetOrganizationId)
259+ .pipe(Effect.orElseSucceed(() => null));
260+ if (refreshed) sealedSession = refreshed;
217261 }
218262
219263 if (!sealedSession) {
220264 return HttpServerResponse.text("Failed to create session", { status: 500 });
221265 }
222266
223− // Resume where the SSR gate interrupted them. The state passed the
224− // CSRF check above whenever it's present, but it's still a
225− // round-tripped value — so the returnTo inside it is re-validated
226− // like any other untrusted path.
227− const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/";
228−
229267 return deleteResponseCookie(
230268 setResponseCookie(
231269 HttpServerResponse.redirect(returnTo, { status: 302 }),
@@ -489,7 +527,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 257 unmodified lines
489527 )
490528 .handle("getMcpPaused", ({ params }) =>
491529 Effect.gen(function* () {
492− const owner = yield* requireSessionOrganizationId;
530+ const owner = yield* requireSelectedOrganization;
493531 const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId);
494532 const result = yield* Effect.promise(
495533 () =>
@@ -511,7 +549,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines
511549 )
512550 .handle("resumeMcpExecution", ({ params, payload }) =>
513551 Effect.gen(function* () {
514− const owner = yield* requireSessionOrganizationId;
552+ const owner = yield* requireSelectedOrganization;
515553 const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId);
516554 const result = yield* Effect.promise(
517555 () =>
apps/cloud/src/auth/middleware-live.ts+0−7
@@ -85,13 +85,6 @@ export const OrgAuthLive = Layer.effect( · 84 unmodified lines
8585 return yield* Effect.fail(new NoOrganization());
8686 }
8787
88− // NOTE: the domains plane stays scoped to the SESSION org, not the URL
89− // org. Unlike the data + account planes (which resolve the selector
90− // header), this `HttpApiMiddleware` security handler may carry no
91− // residual requirement, so it can't reach the per-request
92− // `UserStoreService` a slug→id resolution needs. The domains surface
93− // (org-settings → domain verification) is niche; URL-scoping it would
94− // mean converting this to an HttpRouter middleware — deferred.
9588 const session = sessionFromSealed(result, Redacted.value(credential));
9689 const auth = {
9790 accountId: session.accountId,
apps/cloud/src/extensions/routes.ts+7−4
@@ -33,8 +33,9 @@ import { · 32 unmodified lines
3333 NonProtectedApi,
3434 } from "../auth/handlers";
3535 import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api";
36−import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live";
36+import { SessionAuthLive } from "../auth/middleware-live";
3737 import { OrgApi, OrgHttpApi } from "../org/api";
38+import { orgAuthMiddleware } from "../org/auth-middleware";
3839 import { OrgHandlers } from "../org/handlers";
3940 import { AutumnService } from "../extensions/billing/service";
4041 import { DbService } from "../db/db";
@@ -79,11 +80,13 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer<DbService | UserSto · 38 unmodified lines
7980 Layer.provide(apiPrefixedRouter),
8081 );
8182
82− // Cloud-only WorkOS domain-verification routes; `OrgAuth` enforces an
83− // authenticated org session. No per-request DB scoping needed.
83+ // Cloud-only WorkOS domain-verification routes; the auth middleware resolves
84+ // the URL org selector header before falling back to the session org, so slug
85+ // lookup needs the same request-scoped UserStoreService as other org-scoped
86+ // APIs.
8487 const OrgRoutes = HttpApiBuilder.layer(OrgHttpApi).pipe(
8588 Layer.provide(OrgHandlers),
86− Layer.provideMerge(OrgAuthLive),
89+ Layer.provide(orgAuthMiddleware(rsLive)),
8790 Layer.provideMerge(AutumnService.Default),
8891 Layer.provide(apiPrefixedRouter),
8992 );
apps/cloud/src/mcp/session-durable-object.ts+1−0
@@ -217,6 +217,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> { · 216 unmodified lines
217217 origin: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
218218 executionId,
219219 sessionId: self.sessionId,
220+ organizationSlug: sessionMeta.organizationSlug,
220221 }),
221222 }
222223 : { mode: sessionElicitationMode },
apps/cloud/src/org/api.ts+2−3
@@ -1,7 +1,6 @@
11 import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
22 import { Schema } from "effect";
33 import { WorkOSError } from "../auth/errors";
4−import { OrgAuth } from "../auth/middleware";
54
65 // ---------------------------------------------------------------------------
76 // Cloud-local org API — the WorkOS domain-verification surface only. Members /
@@ -59,5 +58,5 @@ export class OrgApi extends HttpApiGroup.make("org") · 51 unmodified lines
5958 }),
6059 ) {}
6160
62−/** Org API with org-level auth — requires authenticated session with an org. */
63−export const OrgHttpApi = HttpApi.make("org").add(OrgApi).middleware(OrgAuth);
61+/** Org API with org-level auth supplied by the router middleware in auth-middleware.ts. */
62+export const OrgHttpApi = HttpApi.make("org").add(OrgApi);
apps/cloud/src/org/auth-middleware.ts+67−0
@@ -0,0 +1,67 @@
1+import { Effect, Layer } from "effect";
2+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3+
4+import { AuthContext, requestScopedMiddleware } from "@executor-js/api/server";
5+
6+import { UserStoreService } from "../auth/context";
7+import { sessionFromSealed } from "../auth/middleware";
8+import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
9+import { WorkOSClient } from "../auth/workos";
10+import { DbService } from "../db/db";
11+
12+const unauthorized = () =>
13+ HttpServerResponse.jsonUnsafe(
14+ {
15+ error: "Invalid or expired session",
16+ code: "invalid_session",
17+ },
18+ { status: 401 },
19+ );
20+
21+const noOrganization = () =>
22+ HttpServerResponse.jsonUnsafe(
23+ {
24+ error: "No organization in session",
25+ code: "no_organization",
26+ },
27+ { status: 403 },
28+ );
29+
30+const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext }>()(
31+ Effect.gen(function* () {
32+ const captured = yield* Effect.context<WorkOSClient>();
33+ const workos = yield* WorkOSClient;
34+ return (httpEffect) =>
35+ Effect.gen(function* () {
36+ const request = yield* HttpServerRequest.HttpServerRequest;
37+ const cookieValue = request.cookies["wos-session"] ?? "";
38+ const result = yield* workos
39+ .authenticateSealedSession(cookieValue)
40+ .pipe(Effect.orElseSucceed(() => null));
41+ if (!result) return unauthorized();
42+
43+ const selector = request.headers[ORG_SELECTOR_HEADER] ?? result.organizationId;
44+ if (!selector) return noOrganization();
45+
46+ const org = yield* authorizeOrganizationSelector(result.userId, selector).pipe(
47+ Effect.orElseSucceed(() => null),
48+ );
49+ if (!org) return noOrganization();
50+
51+ const session = sessionFromSealed(result, cookieValue);
52+ const auth = AuthContext.of({
53+ accountId: session.accountId,
54+ organizationId: org.id,
55+ email: session.email,
56+ name: session.name,
57+ avatarUrl: session.avatarUrl,
58+ roles: [],
59+ });
60+
61+ return yield* Effect.provideService(httpEffect, AuthContext, auth);
62+ }).pipe(Effect.provideContext(captured));
63+ }),
64+);
65+
66+export const orgAuthMiddleware = (rsLive: Layer.Layer<DbService | UserStoreService>) =>
67+ OrgAuthMiddleware.combine(requestScopedMiddleware(rsLive)).layer;
apps/cloud/src/org/handlers.ts+8−4
@@ -5,6 +5,7 @@ import { AuthContext } from "@executor-js/api/server"; · 4 unmodified lines
55 import { env } from "cloudflare:workers";
66 import { WorkOSClient } from "../auth/workos";
77 import { AutumnService } from "../extensions/billing/service";
8+import { resolveOrganization } from "../auth/organization";
89 import { Forbidden, OrgHttpApi } from "./api";
910
1011 // ---------------------------------------------------------------------------
@@ -89,13 +90,16 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 78 unmodified lines
8990 return yield* new Forbidden();
9091 }
9192
93+ const org = yield* resolveOrganization(auth.organizationId).pipe(
94+ Effect.catchCause(() => Effect.succeed(null)),
95+ );
96+ const returnPath = org?.slug ? `/${org.slug}/org` : "/org";
9297 const workos = yield* WorkOSClient;
93− // WorkOS requires an ABSOLUTE returnUrl — fall back to the pinned origin
94− // (matching every other VITE_PUBLIC_SITE_URL site in this host), not a
95− // bare relative path that WorkOS would reject.
98+ // WorkOS requires an absolute returnUrl. Keep the URL org slug in that
99+ // return path so the browser comes back to the same selected org.
96100 const { link } = yield* workos.generateDomainVerificationPortalLink(
97101 auth.organizationId,
98− `${env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"}/org`,
102+ `${env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"}${returnPath}`,
99103 );
100104 return { link };
101105 }),
apps/cloud/src/web/client.tsx+6−1
@@ -1,7 +1,8 @@
11 import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
2−import { FetchHttpClient } from "effect/unstable/http";
2+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
33 import { addGroup } from "@executor-js/api";
44 import { getBaseUrl } from "@executor-js/react/api/base-url";
5+import { EXECUTOR_ORG_HEADER, getActiveOrgSlug } from "@executor-js/react/api/server-connection";
56 import { CloudAuthApi } from "../auth/api";
67 import { OrgApi } from "../org/api";
78
@@ -14,6 +15,10 @@ const CloudApiClient = AtomHttpApi.Service<"CloudApiClient">()("CloudApiClient", · 6 unmodified lines
1415 api: CloudApi,
1516 httpClient: FetchHttpClient.layer,
1617 baseUrl: getBaseUrl(),
18+ transformClient: HttpClient.mapRequest((request) => {
19+ const orgSlug = getActiveOrgSlug();
20+ return orgSlug ? HttpClientRequest.setHeader(request, EXECUTOR_ORG_HEADER, orgSlug) : request;
21+ }),
1722 });
1823
1924 export { CloudApiClient };
apps/host-cloudflare/src/mcp/session-durable-object.ts+1−0
@@ -108,6 +108,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 107 unmodified lines
108108 origin: origin ?? "https://unconfigured-origin.invalid",
109109 executionId,
110110 sessionId: self.sessionId,
111+ organizationSlug: sessionMeta.organizationSlug,
111112 });
112113 },
113114 }
e2e/cloud/mcp-browser-approval-org-scope.test.ts+206−0
@@ -0,0 +1,206 @@
1+// Cloud-only: MCP browser approval URLs must preserve the session's org scope.
2+//
3+// The MCP session is created in org A, then the browser cookie is moved to org B
4+// before the human opens the approval link. The returned approval URL itself
5+// must carry org A's slug so the rendered approval page scopes both GET and
6+// resume POST requests to org A, not the cookie-pinned org B.
7+import { randomBytes } from "node:crypto";
8+
9+import { expect } from "@effect/vitest";
10+import { Effect } from "effect";
11+import type { Page, Request } from "playwright";
12+import { composePluginApi } from "@executor-js/api/server";
13+
14+import { scenario } from "../src/scenario";
15+import { Api, Browser, Mcp, Target } from "../src/services";
16+import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp";
17+
18+const coreApi = composePluginApi([] as const);
19+
20+const GATE_TOOL = "executor.coreTools.policies.list";
21+
22+const GATED_CODE = `
23+const result = await tools.executor.coreTools.policies.list({});
24+return JSON.stringify(result);
25+`;
26+
27+const cookiePair = (response: Response, name: string): string | undefined => {
28+ for (const header of response.headers.getSetCookie?.() ?? []) {
29+ if (header.startsWith(`${name}=`)) return header.split(";")[0];
30+ }
31+ return undefined;
32+};
33+
34+const cookieValue = (pair: string): string => {
35+ const [, value] = pair.split(/=(.*)/s);
36+ if (!value) throw new Error("cookie pair has no value");
37+ return value;
38+};
39+
40+const cookieOf = (identity: { readonly headers?: Record<string, string> }): string =>
41+ identity.headers?.cookie ?? "";
42+
43+const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
44+
45+const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46+
47+const setWorkosSessionCookie = async (page: Page, baseUrl: string, cookie: string) => {
48+ await page.context().addCookies([
49+ {
50+ name: "wos-session",
51+ value: cookieValue(cookie),
52+ url: baseUrl,
53+ },
54+ ]);
55+};
56+
57+const expectOrgShell = async (
58+ page: Page,
59+ org: { readonly name: string; readonly slug: string },
60+) => {
61+ await page.waitForURL(
62+ (url) => url.pathname === `/${org.slug}` || url.pathname === `/${org.slug}/`,
63+ {
64+ timeout: 30_000,
65+ },
66+ );
67+ await page.getByRole("button", { name: new RegExp(escapeRegExp(org.name)) }).waitFor();
68+ await page.getByRole("heading", { name: "Integrations" }).waitFor();
69+};
70+
71+const activeOrg = (baseUrl: string, cookie: string) =>
72+ Effect.promise(async () => {
73+ const response = await fetch(new URL("/api/auth/me", baseUrl), {
74+ headers: { cookie },
75+ });
76+ if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);
77+ const body = (await response.json()) as {
78+ organization: { id: string; name: string; slug: string } | null;
79+ };
80+ if (!body.organization) throw new Error("identity has no active organization");
81+ return body.organization;
82+ });
83+
84+const createOrganization = (baseUrl: string, cookie: string, name: string) =>
85+ Effect.promise(async () => {
86+ const response = await fetch(new URL("/api/auth/create-organization", baseUrl), {
87+ method: "POST",
88+ headers: {
89+ "content-type": "application/json",
90+ cookie,
91+ ...originHeaders(baseUrl),
92+ },
93+ body: JSON.stringify({ name }),
94+ });
95+ if (!response.ok) {
96+ throw new Error(`/api/auth/create-organization failed (${response.status})`);
97+ }
98+ const session = cookiePair(response, "wos-session");
99+ if (!session) throw new Error("create organization did not refresh the session");
100+ const org = (await response.json()) as { id: string; name: string; slug: string };
101+ return { org, session };
102+ });
103+
104+const approvalApiRequest =
105+ (approval: McpBrowserApproval, method: "GET" | "POST") =>
106+ (request: Request): boolean => {
107+ const url = request.url();
108+ const executionPath = `/executions/${encodeURIComponent(approval.executionId)}`;
109+ return (
110+ request.method() === method &&
111+ url.includes("/api/mcp-sessions/") &&
112+ url.includes(executionPath) &&
113+ (method === "POST" ? url.endsWith(`${executionPath}/resume`) : !url.endsWith("/resume"))
114+ );
115+ };
116+
117+scenario(
118+ "MCP approval · URL-scoped org survives approval while the session cookie points elsewhere",
119+ { timeout: 180_000 },
120+ Effect.gen(function* () {
121+ const target = yield* Target;
122+ const api = yield* Api;
123+ const browser = yield* Browser;
124+ const mcp = yield* Mcp;
125+
126+ const identity = yield* target.newIdentity();
127+ const sessionA = cookieOf(identity);
128+ const orgA = yield* activeOrg(target.baseUrl, sessionA);
129+ const client = yield* api.client(coreApi, identity);
130+
131+ const policy = yield* client.policies.create({
132+ payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" },
133+ });
134+
135+ yield* Effect.gen(function* () {
136+ const mcpSession = mcp.session(identity, { elicitationMode: "browser" });
137+ yield* mcpSession.listTools();
138+
139+ const paused = yield* mcpSession.call("execute", { code: GATED_CODE });
140+ const approval = parseBrowserApproval(paused);
141+ const approvalUrl = new URL(approval.approvalUrl);
142+ expect(approvalUrl.pathname, "the real MCP approval URL is pinned to the session org").toBe(
143+ `/${orgA.slug}/resume/${approval.executionId}`,
144+ );
145+
146+ const { org: orgB, session: sessionB } = yield* createOrganization(
147+ target.baseUrl,
148+ sessionA,
149+ `MCP Approval Org B ${randomBytes(3).toString("hex")}`,
150+ );
151+ expect(orgB.slug, "the browser can be pinned to a different org").not.toBe(orgA.slug);
152+
153+ const [resumed] = yield* Effect.all(
154+ [
155+ mcpSession.awaitResume(approval.executionId),
156+ browser.session(identity, async ({ page, step }) => {
157+ await step("Land in the original organization", async () => {
158+ await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" });
159+ await expectOrgShell(page, orgA);
160+ });
161+
162+ await step("The browser session is switched to another organization", async () => {
163+ await setWorkosSessionCookie(page, target.baseUrl, sessionB);
164+ await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
165+ await expectOrgShell(page, orgB);
166+ });
167+
168+ await step("Open the original organization's approval URL and approve", async () => {
169+ const loadRequest = page.waitForRequest(approvalApiRequest(approval, "GET"), {
170+ timeout: 30_000,
171+ });
172+ await page.goto(approval.approvalUrl, { waitUntil: "networkidle" });
173+ expect(
174+ (await loadRequest).headers()["x-executor-organization"],
175+ "loading the approval page scopes the paused execution lookup to org A",
176+ ).toBe(orgA.slug);
177+
178+ await page.getByRole("button", { name: "Approve" }).waitFor();
179+ const resumeRequest = page.waitForRequest(approvalApiRequest(approval, "POST"), {
180+ timeout: 30_000,
181+ });
182+ await page.getByRole("button", { name: "Approve" }).click();
183+ expect(
184+ (await resumeRequest).headers()["x-executor-organization"],
185+ "approving the paused execution scopes the resume POST to org A",
186+ ).toBe(orgA.slug);
187+ await page.getByText("Approve sent").waitFor();
188+ });
189+ }),
190+ ],
191+ { concurrency: "unbounded" },
192+ );
193+
194+ expect(resumed.ok, "the approved execution completed without error").toBe(true);
195+ expect(resumed.text, "the gated tool ran in org A and returned its policy").toContain(
196+ policy.id,
197+ );
198+ }).pipe(
199+ Effect.ensuring(
200+ client.policies
201+ .remove({ params: { policyId: policy.id }, payload: { owner: "org" } })
202+ .pipe(Effect.ignore),
203+ ),
204+ );
205+ }),
206+);
packages/hosts/mcp/src/browser-approval.ts+5−1
@@ -53,6 +53,8 @@ export const readElicitationMode = (request: Request): McpElicitationMode => { · 52 unmodified lines
5353
5454 /**
5555 * Build the console approval URL for a paused execution:
56+ * `<origin>/<organizationSlug>/resume/<executionId>?mcp_session_id=<sessionId>`
57+ * when the host knows the org slug, otherwise
5658 * `<origin>/resume/<executionId>?mcp_session_id=<sessionId>`. The
5759 * `mcp_session_id` query routes the console's resume page back to the host's
5860 * approval endpoint for that session.
@@ -61,8 +63,10 @@ export const buildResumeApprovalUrl = (input: { · 2 unmodified lines
6163 readonly origin: string | URL;
6264 readonly executionId: string;
6365 readonly sessionId?: string | null;
66+ readonly organizationSlug?: string | null;
6467 }): string => {
65− const url = new URL(`/resume/${encodeURIComponent(input.executionId)}`, input.origin);
68+ const orgPath = input.organizationSlug ? `/${encodeURIComponent(input.organizationSlug)}` : "";
69+ const url = new URL(`${orgPath}/resume/${encodeURIComponent(input.executionId)}`, input.origin);
6670 if (input.sessionId) url.searchParams.set("mcp_session_id", input.sessionId);
6771 return url.toString();
6872 };

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t