Skip to content

Commit 1f9bfe0

Browse files
authored
Web clients scope by the URL org; drop cookie-based org switching (#1000)
The console URL's org slug is now the request scope on the client too: both API clients (core + account) read the slug from window.location at request time and send it as the x-executor-organization header, so every tab's requests are scoped to the org its own URL names — independent of the single shared session cookie. With requests URL-scoped, the cookie-based switch machinery is dead weight: - delete the switchOrganization endpoint/handler/atom; the org switcher and create-org just navigate to /<slug> (the session already authenticates the user to all their orgs, so there is nothing to switch server-side). - delete ForeignOrgSlug; a slug the caller can't access now resolves to a null org via /account/me and the shell 404s, no switch-into-org dance. - simplify OrgSlugGate to canonicalize a bare URL onto the active slug and otherwise render through (a slug in the URL is already the scope). Flips the multi-tab scenario from reproducing the cookie-steal corruption to asserting per-tab independence, and reframes the foreign-slug scenario around URL scoping (no session switch; the cookie is never rewritten).
1 parent 58e23e8 commit 1f9bfe0

14 files changed

Lines changed: 233 additions & 214 deletions

File tree

apps/cloud/src/auth/api.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,6 @@ const AuthOrganizationsResponse = Schema.Struct({
3434
activeOrganizationId: Schema.NullOr(Schema.String),
3535
});
3636

37-
const SwitchOrganizationBody = Schema.Struct({
38-
organizationId: Schema.String,
39-
});
40-
4137
const CreateOrganizationBody = Schema.Struct({
4238
name: Schema.String,
4339
});
@@ -143,7 +139,6 @@ export const AUTH_PATHS = {
143139
login: "/api/auth/login",
144140
logout: "/api/auth/logout",
145141
callback: "/api/auth/callback",
146-
switchOrganization: "/api/auth/switch-organization",
147142
} as const;
148143

149144
const AuthErrors = [UserStoreError, WorkOSError] as const;
@@ -178,12 +173,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
178173
error: WorkOSError,
179174
}),
180175
)
181-
.add(
182-
HttpApiEndpoint.post("switchOrganization", "/auth/switch-organization", {
183-
payload: SwitchOrganizationBody,
184-
error: WorkOSError,
185-
}),
186-
)
187176
.add(
188177
HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {
189178
payload: CreateOrganizationBody,

apps/cloud/src/auth/handlers.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -301,20 +301,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
301301
};
302302
}),
303303
)
304-
.handle("switchOrganization", ({ payload }) =>
305-
Effect.gen(function* () {
306-
const workos = yield* WorkOSClient;
307-
const session = yield* SessionContext;
308-
309-
const refreshed = yield* workos.refreshSession(
310-
session.sealedSession,
311-
payload.organizationId,
312-
);
313-
if (refreshed) {
314-
(yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS);
315-
}
316-
}),
317-
)
318304
.handle("createOrganization", ({ payload }) =>
319305
Effect.gen(function* () {
320306
const workos = yield* WorkOSClient;

apps/cloud/src/routes/__root.tsx

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createRootRoute,
88
useLocation,
99
useNavigate,
10+
useParams,
1011
} from "@tanstack/react-router";
1112
import { AutumnProvider } from "autumn-js/react";
1213
import posthog from "posthog-js";
@@ -23,7 +24,6 @@ import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint";
2324
import { AuthProvider, useAuth } from "../web/auth";
2425
import { loginPath } from "../auth/return-to";
2526
import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths";
26-
import { ForeignOrgSlug } from "../web/components/foreign-org-slug";
2727
import { SupportOptions } from "../web/components/support-options";
2828
import { Shell } from "../web/shell";
2929
import appCss from "@executor-js/react/globals.css?url";
@@ -209,13 +209,20 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) {
209209
const navigate = useNavigate();
210210
const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname);
211211
const isPublicRoute = PUBLIC_PATHS.has(location.pathname);
212+
// The org the URL names (the `{-$orgSlug}` segment), if any. `/account/me`
213+
// is scoped to it, so `auth.organization` IS this org when the caller is a
214+
// member — and `null` when the URL names an org they can't access.
215+
const urlOrgSlug = (useParams({ strict: false }) as { orgSlug?: string }).orgSlug;
212216

213217
// The SSR gate already bounced fresh org-less document requests to
214218
// /create-org; this catches the MID-SESSION transitions (org deleted,
215-
// membership revoked → /account/me now reports no org).
219+
// membership revoked → /account/me now reports no org). Only for BARE paths:
220+
// an org-less result on a slugged URL is a wrong address (404 below), not a
221+
// reason to send the user to onboarding.
216222
const needsOrgRedirect =
217223
auth.status === "authenticated" &&
218224
auth.organization == null &&
225+
!urlOrgSlug &&
219226
!isOnboardingRoute &&
220227
!isPublicRoute;
221228

@@ -254,7 +261,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) {
254261
}
255262

256263
if (auth.organization == null) {
257-
return <BlankScreen />;
264+
// A URL naming an org this session can't access (`/account/me` returned no
265+
// org for its slug) is a wrong address → the route 404, framed by nothing
266+
// (the user isn't "in" any org here). A bare path with no org is a new
267+
// user — the redirect effect above is taking them to onboarding.
268+
return urlOrgSlug ? <NotFoundPage /> : <BlankScreen />;
258269
}
259270

260271
// Seed the server connection from the SSR origin so origin-derived UI (the
@@ -275,18 +286,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) {
275286
organizationId={auth.organization.id}
276287
organizationSlug={activeSlug}
277288
>
278-
<OrgSlugGate
279-
activeSlug={activeSlug}
280-
// Framed by the real shell: a foreign slug resolves (or
281-
// 404s) inside the app chrome, exactly like the route-level
282-
// not-found — never a bare full-page state.
283-
foreignSlug={(slug) => (
284-
<>
285-
<Shell content={<ForeignOrgSlug slug={slug} />} />
286-
<Toaster />
287-
</>
288-
)}
289-
>
289+
{/* The org header scopes every request to the URL's org, so
290+
reaching here means the caller is a member of `activeSlug`
291+
(a foreign slug already 404'd above). The gate only keeps
292+
the URL canonical — bare → /<slug>. */}
293+
<OrgSlugGate activeSlug={activeSlug}>
290294
<Shell />
291295
<Toaster />
292296
</OrgSlugGate>

apps/cloud/src/web/auth.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ export const organizationsAtom = Atom.refreshOnWindowFocus(
3333
}),
3434
);
3535

36-
export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
3736
export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");
3837

3938
export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {

apps/cloud/src/web/components/foreign-org-slug.tsx

Lines changed: 0 additions & 61 deletions
This file was deleted.

apps/cloud/src/web/components/org-menu-slot.tsx

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { useState } from "react";
2-
import { useAtomValue, useAtomSet } from "@effect/atom-react";
2+
import { useAtomValue } from "@effect/atom-react";
33
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4-
import * as Exit from "effect/Exit";
5-
import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
64
import { trackEvent } from "@executor-js/react/api/analytics";
75
import { Button } from "@executor-js/react/components/button";
86
import {
@@ -23,7 +21,7 @@ import {
2321
DropdownMenuSubTrigger,
2422
} from "@executor-js/react/components/dropdown-menu";
2523
import { useAuth } from "../auth";
26-
import { organizationsAtom, switchOrganization } from "../auth";
24+
import { organizationsAtom } from "../auth";
2725
import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form";
2826

2927
// ---------------------------------------------------------------------------
@@ -52,18 +50,15 @@ function CheckIcon() {
5250

5351
function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) {
5452
const organizations = useAtomValue(organizationsAtom);
55-
const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" });
5653

57-
const handleSwitch = async (organization: { id: string; slug: string }) => {
54+
// Switching orgs is now a pure URL navigation: the session authenticates the
55+
// user to ALL their orgs, and the slug in the path scopes every request (the
56+
// `x-executor-organization` header). No cookie to rewrite, no server switch
57+
// call — just land on the other org's URL root and the whole app re-scopes.
58+
const handleSwitch = (organization: { id: string; slug: string }) => {
5859
if (organization.id === props.activeOrganizationId) return;
59-
const exit = await doSwitchOrganization({
60-
payload: { organizationId: organization.id },
61-
reactivityKeys: authWriteKeys,
62-
});
63-
trackEvent("org_switched", { success: Exit.isSuccess(exit) });
64-
// Land on the new org's URL root — a plain reload would keep the OLD
65-
// org's slug in the path and the slug gate would switch right back.
66-
if (Exit.isSuccess(exit)) window.location.href = `/${organization.slug}`;
60+
trackEvent("org_switched", { success: true });
61+
window.location.href = `/${organization.slug}`;
6762
};
6863

6964
return AsyncResult.match(organizations, {
@@ -80,7 +75,9 @@ function OrganizationSwitcherItems(props: { activeOrganizationId: string | null
8075
<DropdownMenuItem
8176
key={organization.id}
8277
disabled={isActive}
83-
onClick={() => handleSwitch(organization)}
78+
onClick={() => {
79+
handleSwitch(organization);
80+
}}
8481
className="text-xs"
8582
>
8683
<span className="min-w-0 flex-1 truncate">{organization.name}</span>

apps/host-cloudflare/web/routes/__root.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ function AuthenticatedApp() {
6262
const auth = useAuth();
6363
const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;
6464

65-
// Single-org instance: any URL slug other than the instance org's (or none)
66-
// canonicalizes — no foreignSlug handler, there's nothing to switch to.
65+
// Single-org instance: a bare URL canonicalizes onto the instance org's
66+
// slug. There's only ever one org, so no other slug is reachable.
6767
const gated = (
6868
<>
6969
<Shell onSignOut={signOut} navItems={defaultShellNavItems} apiKeysTo={null} />

apps/host-selfhost/web/routes/__root.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ function AuthenticatedApp() {
7070
const auth = useAuth();
7171
const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;
7272

73-
// Single-org instance: any URL slug other than the instance org's (or none)
74-
// canonicalizes — no foreignSlug handler, there's nothing to switch to.
73+
// Single-org instance: a bare URL canonicalizes onto the instance org's
74+
// slug. There's only ever one org, so no other slug is reachable.
7575
const gated = (
7676
<>
7777
<Shell onSignOut={signOut} navItems={selfHostNavItems} />
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Cloud-only (browser): two tabs, two orgs, at the same time — independent.
2+
//
3+
// WorkOS still pins ONE org into the sealed `wos-session` cookie, and the whole
4+
// browser shares one cookie jar. Under the OLD cookie-based "active org" model
5+
// that made "active organization" a browser-global: two tabs could not be in
6+
// two orgs at once, and a switch (or the slug gate's switch-to-honor-the-URL)
7+
// silently re-scoped the other tab out from under it.
8+
//
9+
// The stateless URL model removes that hazard. The slug in the path is the
10+
// request scope: every API call carries it (the `x-executor-organization`
11+
// header), the server re-checks live membership and resolves data for THAT
12+
// org, and the session merely authenticates the user to all their orgs at
13+
// once. Nothing writes the cookie on a switch. So this scenario — once the
14+
// reproduction of the corruption — now asserts the opposite: each tab's
15+
// requests stay scoped to its own URL org, no matter what the other tab does.
16+
//
17+
// Everything runs through the browser (onboarding + the menu create-org), so
18+
// the single-use WorkOS refresh-token chain stays browser-owned and valid.
19+
import { expect } from "@effect/vitest";
20+
import { Effect } from "effect";
21+
22+
import { scenario } from "../src/scenario";
23+
import { Browser, Target } from "../src/services";
24+
25+
scenario(
26+
"Org tabs · two tabs on different orgs stay independent (URL-scoped, no cookie steal)",
27+
{},
28+
Effect.gen(function* () {
29+
const target = yield* Target;
30+
const browser = yield* Browser;
31+
const identity = yield* target.newIdentity({ org: false });
32+
33+
yield* browser.session(identity, async ({ page: tab1, step }) => {
34+
const slugOf = (page: typeof tab1) => new URL(page.url()).pathname.replace(/^\/|\/.*$/g, "");
35+
36+
// The org slug a page's REAL app requests carry — read straight off the
37+
// outgoing `x-executor-organization` header, the actual request scope.
38+
// This is what makes the two tabs independent; the shared session cookie
39+
// is irrelevant to it.
40+
const requestOrgSlugOf = async (page: typeof tab1): Promise<string> => {
41+
const matching = page.waitForRequest(
42+
(request) =>
43+
request.url().includes("/api/") &&
44+
request.headers()["x-executor-organization"] !== undefined,
45+
{ timeout: 15_000 },
46+
);
47+
// Nudge the app to refetch so a fresh scoped request goes out.
48+
void page.reload({ waitUntil: "commit" });
49+
return (await matching).headers()["x-executor-organization"]!;
50+
};
51+
52+
let slugA = "";
53+
let slugB = "";
54+
55+
await step("Onboard org A in tab 1", async () => {
56+
await tab1.goto("/", { waitUntil: "networkidle" });
57+
await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A");
58+
await tab1.getByRole("button", { name: "Create organization" }).click();
59+
await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 });
60+
await tab1.getByRole("button", { name: "Continue to app" }).click();
61+
await tab1.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000 });
62+
await tab1.getByText("Integrations").first().waitFor({ timeout: 30_000 });
63+
slugA = slugOf(tab1);
64+
});
65+
66+
await step("Create org B from tab 1's account menu — tab 1 is now in B", async () => {
67+
await tab1.getByRole("button", { name: /Test User/ }).click();
68+
await tab1.getByRole("menuitem", { name: "Multitab A" }).click();
69+
await tab1
70+
.locator('[data-slot="dropdown-menu-sub-content"]')
71+
.getByText("Create organization", { exact: true })
72+
.click();
73+
await tab1.getByText("Add another organization").waitFor();
74+
await tab1.getByPlaceholder("Northwind Labs").fill("Multitab B");
75+
await tab1.getByRole("button", { name: "Create organization" }).click();
76+
await tab1.waitForURL((url) => url.pathname !== `/${slugA}`, { timeout: 30_000 });
77+
await tab1.getByText("Integrations").first().waitFor({ timeout: 30_000 });
78+
slugB = slugOf(tab1);
79+
});
80+
expect(slugB, "the two orgs have distinct slugs").not.toBe(slugA);
81+
82+
// A second tab in the SAME context — shares tab 1's cookie jar.
83+
const tab2 = await tab1.context().newPage();
84+
85+
await step("Tab 2 opens org A's URL and stays in A — no switch, no reload loop", async () => {
86+
await tab2.goto(`/${slugA}/policies`, { waitUntil: "networkidle" });
87+
await tab2.getByText("Policies").first().waitFor({ timeout: 30_000 });
88+
expect(new URL(tab2.url()).pathname, "tab 2 stays on org A's URL").toBe(
89+
`/${slugA}/policies`,
90+
);
91+
expect(await requestOrgSlugOf(tab2), "tab 2's API requests are scoped to org A").toBe(
92+
slugA,
93+
);
94+
});
95+
96+
await step("Tab 1 is untouched: still org B, and its requests still scope to B", async () => {
97+
expect(new URL(tab1.url()).pathname, "tab 1's URL still says org B").toBe(`/${slugB}`);
98+
expect(
99+
await tab1.getByRole("button", { name: /Multitab B/ }).isVisible(),
100+
"tab 1's sidebar still shows org B",
101+
).toBe(true);
102+
// The crux: tab 2 opening org A did NOT re-scope tab 1. Tab 1's own
103+
// requests still carry org B's slug — the URL is the scope, not a
104+
// shared cookie a sibling tab can steal.
105+
expect(await requestOrgSlugOf(tab1), "tab 1's API requests stay scoped to org B").toBe(
106+
slugB,
107+
);
108+
});
109+
});
110+
}),
111+
);

0 commit comments

Comments
 (0)