From f67f5becd74a3b0a42abb472279ac3a05ad785c5 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Thu, 26 Feb 2026 17:34:25 -0300 Subject: [PATCH] fix(auth): eliminate multi-tab org context bleed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two browser tabs open on different orgs would clobber each other's session because shell-layout called setActive() on every navigation, writing activeOrganizationId to the shared server-side session. Fix across both request surfaces: Server (MeshContext / MCP routes): - context-factory.ts: when x-org-id header is present on a browser session request, resolve org directly from DB with membership verification instead of reading session.activeOrganizationId. The MCP client already sends x-org-id on every call, so all MCP tool operations are now per-request rather than per-session. Client (Better Auth org-management routes): - org-store.ts: module-level (per-tab) store for the current org ID. Each browser tab has its own JS execution context so this is naturally isolated. - auth-client.ts: fetchOptions.onRequest injects organizationId on every Better Auth organization route call — as a query param for GET requests (listMembers, listRoles, getFullOrganization) and in the request body for POST requests (inviteMember, removeMember, createRole, updateRole, deleteRole). Better Auth accepts an explicit organizationId that overrides the session fallback. - shell-layout.tsx: replaced setActive() with getFullOrganization() using the org slug from the URL. Pure read — zero session mutation. Also populates the org store so subsequent calls are scoped to the correct tab org from the start. - organizations-home.tsx, inbox.tsx: update org store when the user explicitly switches orgs so the store stays current. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- apps/mesh/src/core/context-factory.ts | 42 ++++++++++++++++++- .../src/web/components/organizations-home.tsx | 8 +++- .../web/components/sidebar/footer/inbox.tsx | 7 ++++ apps/mesh/src/web/layouts/shell-layout.tsx | 12 +++++- apps/mesh/src/web/lib/auth-client.ts | 35 ++++++++++++++++ apps/mesh/src/web/lib/org-store.ts | 17 ++++++++ 6 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 apps/mesh/src/web/lib/org-store.ts diff --git a/apps/mesh/src/core/context-factory.ts b/apps/mesh/src/core/context-factory.ts index c0f04e548b..efa9f19495 100644 --- a/apps/mesh/src/core/context-factory.ts +++ b/apps/mesh/src/core/context-factory.ts @@ -632,9 +632,47 @@ async function authenticateRequest( let organization: OrganizationContext | undefined; let role: string | undefined; - if (session.session.activeOrganizationId) { - // Get full organization data (includes members with roles) + // Prefer the explicit x-org-id request header over the session's activeOrganizationId. + // The MCP client sends this header on every request with the org from the URL, enabling + // multiple browser tabs to operate in different orgs simultaneously without one tab's + // setActive() call clobbering another tab's session state. + const headerOrgId = req.headers.get("x-org-id"); + + if (headerOrgId) { + // Resolve org directly from DB and verify the session user is a member. + // This bypasses the shared session state entirely for per-request org resolution. + const membership = await timings.measure( + "auth_query_org_membership", + () => + db + .selectFrom("member") + .innerJoin( + "organization", + "organization.id", + "member.organizationId", + ) + .select([ + "member.role", + "organization.id as orgId", + "organization.slug as orgSlug", + "organization.name as orgName", + ]) + .where("member.userId", "=", session.user.id) + .where("member.organizationId", "=", headerOrgId) + .executeTakeFirst(), + ); + if (membership) { + organization = { + id: membership.orgId, + slug: membership.orgSlug, + name: membership.orgName, + }; + role = membership.role; + } + } else if (session.session.activeOrganizationId) { + // Fall back to the session's active org when no explicit header is present. + // (Existing behavior for requests that don't send x-org-id.) const orgData = await timings.measure( "auth_get_full_organization", () => diff --git a/apps/mesh/src/web/components/organizations-home.tsx b/apps/mesh/src/web/components/organizations-home.tsx index 5831bda25e..b4bca2eb5a 100644 --- a/apps/mesh/src/web/components/organizations-home.tsx +++ b/apps/mesh/src/web/components/organizations-home.tsx @@ -1,4 +1,5 @@ import { authClient } from "@/web/lib/auth-client"; +import { setCurrentOrgId } from "@/web/lib/org-store"; import { useNavigate } from "@tanstack/react-router"; import { EntityCard } from "@deco/ui/components/entity-card.tsx"; import { EntityGrid } from "@deco/ui/components/entity-grid.tsx"; @@ -40,11 +41,16 @@ function InvitationCard({ invitation }: { invitation: Invitation }) { toast.error(result.error.message); setIsAccepting(false); } else { - // Set the new org as active to update session + // Set the new org as active to update session and get org data. const setActiveResult = await authClient.organization.setActive({ organizationId: invitation.organizationId, }); + // Keep the per-tab org store in sync with the explicit switch. + if (setActiveResult?.data?.id) { + setCurrentOrgId(setActiveResult.data.id); + } + if (setActiveResult?.data?.slug) { toast.success("Invitation accepted!"); navigate({ to: "/$org", params: { org: setActiveResult.data.slug } }); diff --git a/apps/mesh/src/web/components/sidebar/footer/inbox.tsx b/apps/mesh/src/web/components/sidebar/footer/inbox.tsx index 1948214265..04aaadef95 100644 --- a/apps/mesh/src/web/components/sidebar/footer/inbox.tsx +++ b/apps/mesh/src/web/components/sidebar/footer/inbox.tsx @@ -1,4 +1,5 @@ import { authClient } from "@/web/lib/auth-client"; +import { setCurrentOrgId } from "@/web/lib/org-store"; import { MeshUserMenu } from "@/web/components/user-menu"; import { Button } from "@deco/ui/components/button.tsx"; import { @@ -45,6 +46,12 @@ function InvitationItem({ invitation }: { invitation: Invitation }) { const setActiveResult = await authClient.organization.setActive({ organizationId: invitation.organizationId, }); + + // Keep the per-tab org store in sync with the explicit switch. + if (setActiveResult?.data?.id) { + setCurrentOrgId(setActiveResult.data.id); + } + toast.success("Invitation accepted!"); const slug = setActiveResult?.data?.slug; window.location.href = slug ? `/${slug}` : "/"; diff --git a/apps/mesh/src/web/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx index 82008e13a0..08a8070916 100644 --- a/apps/mesh/src/web/layouts/shell-layout.tsx +++ b/apps/mesh/src/web/layouts/shell-layout.tsx @@ -11,6 +11,7 @@ import { useLocalStorage } from "@/web/hooks/use-local-storage"; import RequiredAuthLayout from "@/web/layouts/required-auth-layout"; import { authClient } from "@/web/lib/auth-client"; import { LOCALSTORAGE_KEYS } from "@/web/lib/localstorage-keys"; +import { setCurrentOrgId } from "@/web/lib/org-store"; import { ResizableHandle, ResizablePanel, @@ -187,10 +188,17 @@ function ShellLayoutContent() { return null; } - const { data } = await authClient.organization.setActive({ - organizationSlug: org, + // Pure read — does not mutate the shared session's activeOrganizationId. + // This is the key fix for multi-tab org isolation: previously setActive() + // would overwrite the session for every other open tab. + const { data } = await authClient.organization.getFullOrganization({ + query: { organizationSlug: org }, }); + // Populate the per-tab org store so the auth client injects organizationId + // on all subsequent Better Auth org-management calls from this tab. + setCurrentOrgId(data?.id ?? null); + return { org: data, // Project slug comes from URL param, actual project data is fetched in project-layout diff --git a/apps/mesh/src/web/lib/auth-client.ts b/apps/mesh/src/web/lib/auth-client.ts index 55595963e2..eeadc2793e 100644 --- a/apps/mesh/src/web/lib/auth-client.ts +++ b/apps/mesh/src/web/lib/auth-client.ts @@ -5,6 +5,7 @@ import { magicLinkClient, } from "better-auth/client/plugins"; import { ssoClient } from "@better-auth/sso/client"; +import { getCurrentOrgId } from "./org-store"; export const authClient = createAuthClient({ plugins: [ @@ -17,4 +18,38 @@ export const authClient = createAuthClient({ ssoClient(), magicLinkClient(), ], + fetchOptions: { + onRequest: (ctx) => { + const orgId = getCurrentOrgId(); + if (!orgId) return; + + const urlStr = typeof ctx.url === "string" ? ctx.url : ctx.url.toString(); + + // Only intercept organization-management routes. + // Skip /set-active — those calls carry their own explicit org ID in the + // body (the org the user is switching TO) and must not be overridden. + if (!urlStr.includes("/organization/") || urlStr.includes("/set-active")) + return; + + if (ctx.method?.toUpperCase() === "GET") { + // Inject as query param — Better Auth reads organizationId from query + // for listMembers, listRoles, getFullOrganization, etc. + const url = new URL(urlStr, window.location.origin); + url.searchParams.set("organizationId", orgId); + ctx.url = url.toString(); + } else if ( + ctx.body && + typeof ctx.body === "object" && + !Array.isArray(ctx.body) + ) { + // Inject into body — Better Auth reads organizationId from body for + // inviteMember, removeMember, createRole, updateRole, deleteRole, etc. + // Only inject if not already explicitly provided by the caller. + const body = ctx.body as Record; + if (!body.organizationId) { + ctx.body = { ...body, organizationId: orgId }; + } + } + }, + }, }); diff --git a/apps/mesh/src/web/lib/org-store.ts b/apps/mesh/src/web/lib/org-store.ts new file mode 100644 index 0000000000..ba9ea4bef2 --- /dev/null +++ b/apps/mesh/src/web/lib/org-store.ts @@ -0,0 +1,17 @@ +/** + * Per-tab org ID store. + * + * Browser tabs each have their own JS execution context, so module-level state + * is naturally isolated per tab. We use this to track the org the current tab + * is operating in, so the auth client can inject it on every outbound request + * rather than relying on the shared server-side session's activeOrganizationId. + */ +let currentOrgId: string | null = null; + +export function setCurrentOrgId(id: string | null): void { + currentOrgId = id; +} + +export function getCurrentOrgId(): string | null { + return currentOrgId; +}