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
188 // has built the per-request layer. We don't care about the response —188 // has built the per-request layer. We don't care about the response —
189 // only that the layer was built once per request. `/integrations` is a189 // only that the layer was built once per request. `/integrations` is a
190 // v2 protected route (the old `/scope` group was removed).190 // v2 protected route (the old `/scope` group was removed).
191 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"));192 await handler(new Request("http://test.local/integrations"), Context.empty());
193 193
194 expect(counts.acquires).toBe(2);194 expect(counts.acquires).toBe(2);
195 expect(counts.releases).toBe(2);195 expect(counts.releases).toBe(2);
apps/cloud/src/api/layers.ts+15−13
@@ -4,7 +4,7 @@ import { Layer } from "effect"; · 3 unmodified lines
4 4
5import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api/server";5import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api/server";
6 6
7import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live";7import { SessionAuthLive } from "../auth/middleware-live";
8import { UserStoreService } from "../auth/context";8import { UserStoreService } from "../auth/context";
9import {9import {
10 CloudAuthPublicHandlers,10 CloudAuthPublicHandlers,
@@ -14,6 +14,7 @@ import { · 3 unmodified lines
14import { DbService } from "../db/db";14import { DbService } from "../db/db";
15import { WorkerTelemetryLive } from "../observability/telemetry";15import { WorkerTelemetryLive } from "../observability/telemetry";
16import { OrgHttpApi } from "../org/api";16import { OrgHttpApi } from "../org/api";
17import { orgAuthMiddleware } from "../org/auth-middleware";
17import { OrgHandlers } from "../org/handlers";18import { OrgHandlers } from "../org/handlers";
18import { ErrorCaptureLive } from "../observability";19import { ErrorCaptureLive } from "../observability";
19 20
@@ -61,23 +62,24 @@ export const makeNonProtectedApiLive = (rsLive: Layer.Layer<DbService | UserStor · 41 unmodified lines
61 Layer.provideMerge(AutumnService.Default),62 Layer.provideMerge(AutumnService.Default),
62 );63 );
63 64
64// Cloud-only WorkOS domain-verification routes. Auth is enforced by `OrgAuth`65// Cloud-only WorkOS domain-verification routes. Auth is enforced by a router
65// middleware declared on `OrgHttpApi`. The domain handlers read the boot66// middleware that resolves the URL org selector header before falling back to
66// `WorkOSClient` plus the `AuthContext` from `OrgAuthLive`; the67// the session org, so slug lookup uses the same per-request UserStoreService as
67// `getDomainVerificationLink` handler also gates on billing, so68// the account and protected APIs. The `getDomainVerificationLink` handler also
68// `AutumnService.Default` is provided HERE (not on the neutral boot core).69// gates on billing, so `AutumnService.Default` is provided here, not on the
69// Unlike the member endpoints that used to live here, they need no per-request70// neutral boot core.
70// DB scoping (and `OrgAuthLive` stays session-scoped — see its note).71export const makeOrgApiLive = (rsLive: Layer.Layer<DbService | UserStoreService>) =>
71export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe(72 HttpApiBuilder.layer(OrgHttpApi).pipe(
72 Layer.provide(OrgHandlers),73 Layer.provide(OrgHandlers),
73 Layer.provideMerge(OrgAuthLive),74 Layer.provide(orgAuthMiddleware(rsLive)),
74 Layer.provideMerge(AutumnService.Default),75 Layer.provideMerge(AutumnService.Default),
75);76 );
76 77
77// Default export uses the production per-request layer. Existing callers that78// Default export uses the production per-request layer. Existing callers that
78// import `NonProtectedApiLive` continue to work; the `make*` factory exists for79// import `NonProtectedApiLive` continue to work; the `make*` factory exists for
79// tests that need to swap in a fake.80// tests that need to swap in a fake.
80export const NonProtectedApiLive = makeNonProtectedApiLive(RequestScopedServicesLive);81export const NonProtectedApiLive = makeNonProtectedApiLive(RequestScopedServicesLive);
82export const OrgApiLive = makeOrgApiLive(RequestScopedServicesLive);
81 83
82// ---------------------------------------------------------------------------84// ---------------------------------------------------------------------------
83// Protected API85// Protected API
apps/cloud/src/api/router.ts+2−2
@@ -12,8 +12,8 @@ import { CloudDocsLive } from "../extensions/docs"; · 11 unmodified lines
12import { ApiErrorLoggingLive } from "../observability/error-logging";12import { ApiErrorLoggingLive } from "../observability/error-logging";
13import {13import {
14 BootSharedServices,14 BootSharedServices,
15 OrgApiLive,
16 RequestScopedServicesLive,15 RequestScopedServicesLive,
16 makeOrgApiLive,
17 makeNonProtectedApiLive,17 makeNonProtectedApiLive,
18} from "./layers";18} from "./layers";
19import { makeProtectedApiLive } from "./protected";19import { makeProtectedApiLive } from "./protected";
@@ -35,7 +35,7 @@ export const makeApiLive = (requestScopedLive: Layer.Layer<DbService | UserStore · 15 unmodified lines
35 );35 );
36 return Layer.mergeAll(36 return Layer.mergeAll(
37 makeNonProtectedApiLive(requestScopedLive),37 makeNonProtectedApiLive(requestScopedLive),
38 OrgApiLive,38 makeOrgApiLive(requestScopedLive),
39 makeAccountApiLive(requestScopedLive),39 makeAccountApiLive(requestScopedLive),
40 CloudDocsLive,40 CloudDocsLive,
41 makeProtectedApiLive(requestScopedLive),41 makeProtectedApiLive(requestScopedLive),
apps/cloud/src/auth/handlers.ts+69−31
@@ -1,6 +1,7 @@
1import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";1import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
2import { HttpServerResponse } from "effect/unstable/http";2import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3import { Duration, Effect, Predicate } from "effect";3import { Duration, Effect, Predicate } from "effect";
4import { isValidOrgSlug } from "@executor-js/api";
4 5
5import {6import {
6 AUTH_PATHS,7 AUTH_PATHS,
@@ -25,7 +26,12 @@ import { · 18 unmodified lines
25 isOverFreeOrganizationLimit,26 isOverFreeOrganizationLimit,
26 shouldApplyFreeOrganizationLimit,27 shouldApplyFreeOrganizationLimit,
27} from "../extensions/billing/plans";28} from "../extensions/billing/plans";
28import { authorizeOrganization, resolveOrganization } from "./organization";29import {
30 ORG_SELECTOR_HEADER,
31 authorizeOrganization,
32 authorizeOrganizationSelector,
33 resolveOrganization,
34} from "./organization";
29import type {35import type {
30 McpSessionApprovalResult,36 McpSessionApprovalResult,
31 McpSessionResumeApprovalResult,37 McpSessionResumeApprovalResult,
@@ -82,14 +88,37 @@ const timingSafeEqual = (a: string, b: string): boolean => { · 50 unmodified lines
82 return diff === 0;88 return diff === 0;
83};89};
84 90
85const requireSessionOrganizationId = Effect.gen(function* () {91const requestHeaders = Effect.map(HttpServerRequest.HttpServerRequest.asEffect(), (req) => ({
92 ...req.headers,
93}));
94
95const 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
101const requestedOrgSelectorFromReturnTo = (returnTo: string): string | null =>
102 firstPathSegment(returnTo);
103
104const requireSelectedOrganization = Effect.gen(function* () {
86 const session = yield* SessionContext;105 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) {
88 return yield* new NoOrganization();109 return yield* new NoOrganization();
89 }110 }
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
90 return {119 return {
91 ...session,120 ...session,
92 organizationId: session.organizationId,121 organizationId: org.id,
93 };122 };
94});123});
95 124
@@ -194,38 +223,47 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 98 unmodified lines
194 223
195 let sealedSession = result.sealedSession;224 let sealedSession = result.sealedSession;
196 225
197 // If the auth response didn't surface an org but the user is already226 // Resume where the SSR gate interrupted them. The state passed the
198 // an *active* member of one, rehydrate the session with it. Pending227 // CSRF check above whenever it's present, but it's still a
199 // memberships (which represent unaccepted invitations on WorkOS's228 // round-tripped value, so the returnTo inside it is re-validated like
200 // side) are skipped — refreshing into one 400s, and silently229 // any other untrusted path.
201 // attaching an unaccepted org would also bypass invite consent.230 const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/";
202 // If they have no active memberships, leave the session org-less —231 const requestedOrgSelector = requestedOrgSelectorFromReturnTo(returnTo);
203 // AuthGate's onboarding flow surfaces pending invites and the232 const requestedOrg = requestedOrgSelector
204 // create-org form. We never auto-create organizations on login.233 ? yield* authorizeOrganizationSelector(result.user.id, requestedOrgSelector).pipe(
205 if (!result.organizationId && sealedSession) {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) {
206 const memberships = yield* workos.listUserMemberships(result.user.id);245 const memberships = yield* workos.listUserMemberships(result.user.id);
207 const existingActive = memberships.data.find((m) => m.status === "active");246 const existingActive = memberships.data.find((m) => m.status === "active");
208 if (existingActive) {247 targetOrganizationId = existingActive?.organizationId ?? null;
209 // Best-effort refresh — if WorkOS rejects (e.g. the membership248 }
210 // was just revoked), fall through to an org-less session rather249
211 // than 500ing the entire callback.250 if (
212 const refreshed = yield* workos251 targetOrganizationId &&
213 .refreshSession(sealedSession, existingActive.organizationId)252 targetOrganizationId !== result.organizationId &&
214 .pipe(Effect.orElseSucceed(() => null));253 sealedSession
215 if (refreshed) sealedSession = refreshed;254 ) {
216 }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;
217 }261 }
218 262
219 if (!sealedSession) {263 if (!sealedSession) {
220 return HttpServerResponse.text("Failed to create session", { status: 500 });264 return HttpServerResponse.text("Failed to create session", { status: 500 });
221 }265 }
222 266
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
229 return deleteResponseCookie(267 return deleteResponseCookie(
230 setResponseCookie(268 setResponseCookie(
231 HttpServerResponse.redirect(returnTo, { status: 302 }),269 HttpServerResponse.redirect(returnTo, { status: 302 }),
@@ -489,7 +527,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 257 unmodified lines
489 )527 )
490 .handle("getMcpPaused", ({ params }) =>528 .handle("getMcpPaused", ({ params }) =>
491 Effect.gen(function* () {529 Effect.gen(function* () {
492 const owner = yield* requireSessionOrganizationId;530 const owner = yield* requireSelectedOrganization;
493 const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId);531 const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId);
494 const result = yield* Effect.promise(532 const result = yield* Effect.promise(
495 () =>533 () =>
@@ -511,7 +549,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines
511 )549 )
512 .handle("resumeMcpExecution", ({ params, payload }) =>550 .handle("resumeMcpExecution", ({ params, payload }) =>
513 Effect.gen(function* () {551 Effect.gen(function* () {
514 const owner = yield* requireSessionOrganizationId;552 const owner = yield* requireSelectedOrganization;
515 const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId);553 const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId);
516 const result = yield* Effect.promise(554 const result = yield* Effect.promise(
517 () =>555 () =>
apps/cloud/src/auth/middleware-live.ts+0−7
@@ -85,13 +85,6 @@ export const OrgAuthLive = Layer.effect( · 84 unmodified lines
85 return yield* Effect.fail(new NoOrganization());85 return yield* Effect.fail(new NoOrganization());
86 }86 }
87 87
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.
95 const session = sessionFromSealed(result, Redacted.value(credential));88 const session = sessionFromSealed(result, Redacted.value(credential));
96 const auth = {89 const auth = {
97 accountId: session.accountId,90 accountId: session.accountId,
apps/cloud/src/extensions/routes.ts+7−4
@@ -33,8 +33,9 @@ import { · 32 unmodified lines
33 NonProtectedApi,33 NonProtectedApi,
34} from "../auth/handlers";34} from "../auth/handlers";
35import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api";35import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api";
36import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live";36import { SessionAuthLive } from "../auth/middleware-live";
37import { OrgApi, OrgHttpApi } from "../org/api";37import { OrgApi, OrgHttpApi } from "../org/api";
38import { orgAuthMiddleware } from "../org/auth-middleware";
38import { OrgHandlers } from "../org/handlers";39import { OrgHandlers } from "../org/handlers";
39import { AutumnService } from "../extensions/billing/service";40import { AutumnService } from "../extensions/billing/service";
40import { DbService } from "../db/db";41import { DbService } from "../db/db";
@@ -79,11 +80,13 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer<DbService | UserSto · 38 unmodified lines
79 Layer.provide(apiPrefixedRouter),80 Layer.provide(apiPrefixedRouter),
80 );81 );
81 82
82 // Cloud-only WorkOS domain-verification routes; `OrgAuth` enforces an83 // Cloud-only WorkOS domain-verification routes; the auth middleware resolves
83 // authenticated org session. No per-request DB scoping needed.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.
84 const OrgRoutes = HttpApiBuilder.layer(OrgHttpApi).pipe(87 const OrgRoutes = HttpApiBuilder.layer(OrgHttpApi).pipe(
85 Layer.provide(OrgHandlers),88 Layer.provide(OrgHandlers),
86 Layer.provideMerge(OrgAuthLive),89 Layer.provide(orgAuthMiddleware(rsLive)),
87 Layer.provideMerge(AutumnService.Default),90 Layer.provideMerge(AutumnService.Default),
88 Layer.provide(apiPrefixedRouter),91 Layer.provide(apiPrefixedRouter),
89 );92 );
apps/cloud/src/mcp/session-durable-object.ts+1−0
@@ -217,6 +217,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> { · 216 unmodified lines
217 origin: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",217 origin: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
218 executionId,218 executionId,
219 sessionId: self.sessionId,219 sessionId: self.sessionId,
220 organizationSlug: sessionMeta.organizationSlug,
220 }),221 }),
221 }222 }
222 : { mode: sessionElicitationMode },223 : { mode: sessionElicitationMode },
apps/cloud/src/org/api.ts+2−3
@@ -1,7 +1,6 @@
1import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";1import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { WorkOSError } from "../auth/errors";3import { WorkOSError } from "../auth/errors";
4import { OrgAuth } from "../auth/middleware";
5 4
6// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
7// Cloud-local org API — the WorkOS domain-verification surface only. Members /6// 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
59 }),58 }),
60 ) {}59 ) {}
61 60
62/** Org API with org-level auth — requires authenticated session with an org. */61/** Org API with org-level auth supplied by the router middleware in auth-middleware.ts. */
63export const OrgHttpApi = HttpApi.make("org").add(OrgApi).middleware(OrgAuth);62export const OrgHttpApi = HttpApi.make("org").add(OrgApi);
apps/cloud/src/org/auth-middleware.ts+67−0
@@ -0,0 +1,67 @@
1import { Effect, Layer } from "effect";
2import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3
4import { AuthContext, requestScopedMiddleware } from "@executor-js/api/server";
5
6import { UserStoreService } from "../auth/context";
7import { sessionFromSealed } from "../auth/middleware";
8import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
9import { WorkOSClient } from "../auth/workos";
10import { DbService } from "../db/db";
11
12const unauthorized = () =>
13 HttpServerResponse.jsonUnsafe(
14 {
15 error: "Invalid or expired session",
16 code: "invalid_session",
17 },
18 { status: 401 },
19 );
20
21const noOrganization = () =>
22 HttpServerResponse.jsonUnsafe(
23 {
24 error: "No organization in session",
25 code: "no_organization",
26 },
27 { status: 403 },
28 );
29
30const 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
66export 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
5import { env } from "cloudflare:workers";5import { env } from "cloudflare:workers";
6import { WorkOSClient } from "../auth/workos";6import { WorkOSClient } from "../auth/workos";
7import { AutumnService } from "../extensions/billing/service";7import { AutumnService } from "../extensions/billing/service";
8import { resolveOrganization } from "../auth/organization";
8import { Forbidden, OrgHttpApi } from "./api";9import { Forbidden, OrgHttpApi } from "./api";
9 10
10// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
@@ -89,13 +90,16 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 78 unmodified lines
89 return yield* new Forbidden();90 return yield* new Forbidden();
90 }91 }
91 92
93 const org = yield* resolveOrganization(auth.organizationId).pipe(
94 Effect.catchCause(() => Effect.succeed(null)),
95 );
96 const returnPath = org?.slug ? `/${org.slug}/org` : "/org";
92 const workos = yield* WorkOSClient;97 const workos = yield* WorkOSClient;
93 // WorkOS requires an ABSOLUTE returnUrl — fall back to the pinned origin98 // WorkOS requires an absolute returnUrl. Keep the URL org slug in that
94 // (matching every other VITE_PUBLIC_SITE_URL site in this host), not a99 // return path so the browser comes back to the same selected org.
95 // bare relative path that WorkOS would reject.
96 const { link } = yield* workos.generateDomainVerificationPortalLink(100 const { link } = yield* workos.generateDomainVerificationPortalLink(
97 auth.organizationId,101 auth.organizationId,
98 `${env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"}/org`,102 `${env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"}${returnPath}`,
99 );103 );
100 return { link };104 return { link };
101 }),105 }),
apps/cloud/src/web/client.tsx+6−1
@@ -1,7 +1,8 @@
1import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";1import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
2import { FetchHttpClient } from "effect/unstable/http";2import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3import { addGroup } from "@executor-js/api";3import { addGroup } from "@executor-js/api";
4import { getBaseUrl } from "@executor-js/react/api/base-url";4import { getBaseUrl } from "@executor-js/react/api/base-url";
5import { EXECUTOR_ORG_HEADER, getActiveOrgSlug } from "@executor-js/react/api/server-connection";
5import { CloudAuthApi } from "../auth/api";6import { CloudAuthApi } from "../auth/api";
6import { OrgApi } from "../org/api";7import { OrgApi } from "../org/api";
7 8
@@ -14,6 +15,10 @@ const CloudApiClient = AtomHttpApi.Service<"CloudApiClient">()("CloudApiClient", · 6 unmodified lines
14 api: CloudApi,15 api: CloudApi,
15 httpClient: FetchHttpClient.layer,16 httpClient: FetchHttpClient.layer,
16 baseUrl: getBaseUrl(),17 baseUrl: getBaseUrl(),
18 transformClient: HttpClient.mapRequest((request) => {
19 const orgSlug = getActiveOrgSlug();
20 return orgSlug ? HttpClientRequest.setHeader(request, EXECUTOR_ORG_HEADER, orgSlug) : request;
21 }),
17});22});
18 23
19export { CloudApiClient };24export { 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
108 origin: origin ?? "https://unconfigured-origin.invalid",108 origin: origin ?? "https://unconfigured-origin.invalid",
109 executionId,109 executionId,
110 sessionId: self.sessionId,110 sessionId: self.sessionId,
111 organizationSlug: sessionMeta.organizationSlug,
111 });112 });
112 },113 },
113 }114 }
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.
7import { randomBytes } from "node:crypto";
8
9import { expect } from "@effect/vitest";
10import { Effect } from "effect";
11import type { Page, Request } from "playwright";
12import { composePluginApi } from "@executor-js/api/server";
13
14import { scenario } from "../src/scenario";
15import { Api, Browser, Mcp, Target } from "../src/services";
16import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp";
17
18const coreApi = composePluginApi([] as const);
19
20const GATE_TOOL = "executor.coreTools.policies.list";
21
22const GATED_CODE = `
23const result = await tools.executor.coreTools.policies.list({});
24return JSON.stringify(result);
25`;
26
27const 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
34const 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
40const cookieOf = (identity: { readonly headers?: Record<string, string> }): string =>
41 identity.headers?.cookie ?? "";
42
43const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
44
45const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
47const 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
57const 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
71const 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
84const 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
104const 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
117scenario(
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
53 53
54/**54/**
55 * Build the console approval URL for a paused execution:55 * 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
56 * `<origin>/resume/<executionId>?mcp_session_id=<sessionId>`. The58 * `<origin>/resume/<executionId>?mcp_session_id=<sessionId>`. The
57 * `mcp_session_id` query routes the console's resume page back to the host's59 * `mcp_session_id` query routes the console's resume page back to the host's
58 * approval endpoint for that session.60 * approval endpoint for that session.
@@ -61,8 +63,10 @@ export const buildResumeApprovalUrl = (input: { · 2 unmodified lines
61 readonly origin: string | URL;63 readonly origin: string | URL;
62 readonly executionId: string;64 readonly executionId: string;
63 readonly sessionId?: string | null;65 readonly sessionId?: string | null;
66 readonly organizationSlug?: string | null;
64}): string => {67}): 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);
66 if (input.sessionId) url.searchParams.set("mcp_session_id", input.sessionId);70 if (input.sessionId) url.searchParams.set("mcp_session_id", input.sessionId);
67 return url.toString();71 return url.toString();
68};72};

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