Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ const AuthOrganizationsResponse = Schema.Struct({
activeOrganizationId: Schema.NullOr(Schema.String),
});

const SwitchOrganizationBody = Schema.Struct({
organizationId: Schema.String,
});

const CreateOrganizationBody = Schema.Struct({
name: Schema.String,
});
Expand Down Expand Up @@ -143,7 +139,6 @@ export const AUTH_PATHS = {
login: "/api/auth/login",
logout: "/api/auth/logout",
callback: "/api/auth/callback",
switchOrganization: "/api/auth/switch-organization",
} as const;

const AuthErrors = [UserStoreError, WorkOSError] as const;
Expand Down Expand Up @@ -178,12 +173,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
error: WorkOSError,
}),
)
.add(
HttpApiEndpoint.post("switchOrganization", "/auth/switch-organization", {
payload: SwitchOrganizationBody,
error: WorkOSError,
}),
)
.add(
HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {
payload: CreateOrganizationBody,
Expand Down
14 changes: 0 additions & 14 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,20 +301,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
};
}),
)
.handle("switchOrganization", ({ payload }) =>
Effect.gen(function* () {
const workos = yield* WorkOSClient;
const session = yield* SessionContext;

const refreshed = yield* workos.refreshSession(
session.sealedSession,
payload.organizationId,
);
if (refreshed) {
(yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS);
}
}),
)
.handle("createOrganization", ({ payload }) =>
Effect.gen(function* () {
const workos = yield* WorkOSClient;
Expand Down
34 changes: 19 additions & 15 deletions apps/cloud/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createRootRoute,
useLocation,
useNavigate,
useParams,
} from "@tanstack/react-router";
import { AutumnProvider } from "autumn-js/react";
import posthog from "posthog-js";
Expand All @@ -23,7 +24,6 @@ import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint";
import { AuthProvider, useAuth } from "../web/auth";
import { loginPath } from "../auth/return-to";
import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths";
import { ForeignOrgSlug } from "../web/components/foreign-org-slug";
import { SupportOptions } from "../web/components/support-options";
import { Shell } from "../web/shell";
import appCss from "@executor-js/react/globals.css?url";
Expand Down Expand Up @@ -209,13 +209,20 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) {
const navigate = useNavigate();
const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname);
const isPublicRoute = PUBLIC_PATHS.has(location.pathname);
// The org the URL names (the `{-$orgSlug}` segment), if any. `/account/me`
// is scoped to it, so `auth.organization` IS this org when the caller is a
// member — and `null` when the URL names an org they can't access.
const urlOrgSlug = (useParams({ strict: false }) as { orgSlug?: string }).orgSlug;

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

Expand Down Expand Up @@ -254,7 +261,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) {
}

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

// Seed the server connection from the SSR origin so origin-derived UI (the
Expand All @@ -275,18 +286,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) {
organizationId={auth.organization.id}
organizationSlug={activeSlug}
>
<OrgSlugGate
activeSlug={activeSlug}
// Framed by the real shell: a foreign slug resolves (or
// 404s) inside the app chrome, exactly like the route-level
// not-found — never a bare full-page state.
foreignSlug={(slug) => (
<>
<Shell content={<ForeignOrgSlug slug={slug} />} />
<Toaster />
</>
)}
>
{/* The org header scopes every request to the URL's org, so
reaching here means the caller is a member of `activeSlug`
(a foreign slug already 404'd above). The gate only keeps
the URL canonical — bare → /<slug>. */}
<OrgSlugGate activeSlug={activeSlug}>
<Shell />
<Toaster />
</OrgSlugGate>
Expand Down
1 change: 0 additions & 1 deletion apps/cloud/src/web/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export const organizationsAtom = Atom.refreshOnWindowFocus(
}),
);

export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");

export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {
Expand Down
61 changes: 0 additions & 61 deletions apps/cloud/src/web/components/foreign-org-slug.tsx

This file was deleted.

27 changes: 12 additions & 15 deletions apps/cloud/src/web/components/org-menu-slot.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useState } from "react";
import { useAtomValue, useAtomSet } from "@effect/atom-react";
import { useAtomValue } from "@effect/atom-react";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import * as Exit from "effect/Exit";
import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
import { trackEvent } from "@executor-js/react/api/analytics";
import { Button } from "@executor-js/react/components/button";
import {
Expand All @@ -23,7 +21,7 @@ import {
DropdownMenuSubTrigger,
} from "@executor-js/react/components/dropdown-menu";
import { useAuth } from "../auth";
import { organizationsAtom, switchOrganization } from "../auth";
import { organizationsAtom } from "../auth";
import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -52,18 +50,15 @@ function CheckIcon() {

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

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

return AsyncResult.match(organizations, {
Expand All @@ -80,7 +75,9 @@ function OrganizationSwitcherItems(props: { activeOrganizationId: string | null
<DropdownMenuItem
key={organization.id}
disabled={isActive}
onClick={() => handleSwitch(organization)}
onClick={() => {
handleSwitch(organization);
}}
className="text-xs"
>
<span className="min-w-0 flex-1 truncate">{organization.name}</span>
Expand Down
4 changes: 2 additions & 2 deletions apps/host-cloudflare/web/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ function AuthenticatedApp() {
const auth = useAuth();
const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;

// Single-org instance: any URL slug other than the instance org's (or none)
// canonicalizes — no foreignSlug handler, there's nothing to switch to.
// Single-org instance: a bare URL canonicalizes onto the instance org's
// slug. There's only ever one org, so no other slug is reachable.
const gated = (
<>
<Shell onSignOut={signOut} navItems={defaultShellNavItems} apiKeysTo={null} />
Expand Down
4 changes: 2 additions & 2 deletions apps/host-selfhost/web/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ function AuthenticatedApp() {
const auth = useAuth();
const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;

// Single-org instance: any URL slug other than the instance org's (or none)
// canonicalizes — no foreignSlug handler, there's nothing to switch to.
// Single-org instance: a bare URL canonicalizes onto the instance org's
// slug. There's only ever one org, so no other slug is reachable.
const gated = (
<>
<Shell onSignOut={signOut} navItems={selfHostNavItems} />
Expand Down
111 changes: 111 additions & 0 deletions e2e/cloud/org-multitab-cookie.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Cloud-only (browser): two tabs, two orgs, at the same time — independent.
//
// WorkOS still pins ONE org into the sealed `wos-session` cookie, and the whole
// browser shares one cookie jar. Under the OLD cookie-based "active org" model
// that made "active organization" a browser-global: two tabs could not be in
// two orgs at once, and a switch (or the slug gate's switch-to-honor-the-URL)
// silently re-scoped the other tab out from under it.
//
// The stateless URL model removes that hazard. The slug in the path is the
// request scope: every API call carries it (the `x-executor-organization`
// header), the server re-checks live membership and resolves data for THAT
// org, and the session merely authenticates the user to all their orgs at
// once. Nothing writes the cookie on a switch. So this scenario — once the
// reproduction of the corruption — now asserts the opposite: each tab's
// requests stay scoped to its own URL org, no matter what the other tab does.
//
// Everything runs through the browser (onboarding + the menu create-org), so
// the single-use WorkOS refresh-token chain stays browser-owned and valid.
import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { scenario } from "../src/scenario";
import { Browser, Target } from "../src/services";

scenario(
"Org tabs · two tabs on different orgs stay independent (URL-scoped, no cookie steal)",
{},
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const identity = yield* target.newIdentity({ org: false });

yield* browser.session(identity, async ({ page: tab1, step }) => {
const slugOf = (page: typeof tab1) => new URL(page.url()).pathname.replace(/^\/|\/.*$/g, "");

// The org slug a page's REAL app requests carry — read straight off the
// outgoing `x-executor-organization` header, the actual request scope.
// This is what makes the two tabs independent; the shared session cookie
// is irrelevant to it.
const requestOrgSlugOf = async (page: typeof tab1): Promise<string> => {
const matching = page.waitForRequest(
(request) =>
request.url().includes("/api/") &&
request.headers()["x-executor-organization"] !== undefined,
{ timeout: 15_000 },
);
// Nudge the app to refetch so a fresh scoped request goes out.
void page.reload({ waitUntil: "commit" });
return (await matching).headers()["x-executor-organization"]!;
};

let slugA = "";
let slugB = "";

await step("Onboard org A in tab 1", async () => {
await tab1.goto("/", { waitUntil: "networkidle" });
await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A");
await tab1.getByRole("button", { name: "Create organization" }).click();
await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 });
await tab1.getByRole("button", { name: "Continue to app" }).click();
await tab1.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000 });
await tab1.getByText("Integrations").first().waitFor({ timeout: 30_000 });
slugA = slugOf(tab1);
});

await step("Create org B from tab 1's account menu — tab 1 is now in B", async () => {
await tab1.getByRole("button", { name: /Test User/ }).click();
await tab1.getByRole("menuitem", { name: "Multitab A" }).click();
await tab1
.locator('[data-slot="dropdown-menu-sub-content"]')
.getByText("Create organization", { exact: true })
.click();
await tab1.getByText("Add another organization").waitFor();
await tab1.getByPlaceholder("Northwind Labs").fill("Multitab B");
await tab1.getByRole("button", { name: "Create organization" }).click();
await tab1.waitForURL((url) => url.pathname !== `/${slugA}`, { timeout: 30_000 });
await tab1.getByText("Integrations").first().waitFor({ timeout: 30_000 });
slugB = slugOf(tab1);
});
expect(slugB, "the two orgs have distinct slugs").not.toBe(slugA);

// A second tab in the SAME context — shares tab 1's cookie jar.
const tab2 = await tab1.context().newPage();

await step("Tab 2 opens org A's URL and stays in A — no switch, no reload loop", async () => {
await tab2.goto(`/${slugA}/policies`, { waitUntil: "networkidle" });
await tab2.getByText("Policies").first().waitFor({ timeout: 30_000 });
expect(new URL(tab2.url()).pathname, "tab 2 stays on org A's URL").toBe(
`/${slugA}/policies`,
);
expect(await requestOrgSlugOf(tab2), "tab 2's API requests are scoped to org A").toBe(
slugA,
);
});

await step("Tab 1 is untouched: still org B, and its requests still scope to B", async () => {
expect(new URL(tab1.url()).pathname, "tab 1's URL still says org B").toBe(`/${slugB}`);
expect(
await tab1.getByRole("button", { name: /Multitab B/ }).isVisible(),
"tab 1's sidebar still shows org B",
).toBe(true);
// The crux: tab 2 opening org A did NOT re-scope tab 1. Tab 1's own
// requests still carry org B's slug — the URL is the scope, not a
// shared cookie a sibling tab can steal.
expect(await requestOrgSlugOf(tab1), "tab 1's API requests stay scoped to org B").toBe(
slugB,
);
});
});
}),
);
Loading
Loading