History

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).

Rhys Sullivan committed 5 months ago

Commit 1f9bfe01 parent 58e23e814 files · +233 −214Browse files

Showing the changes to apps/cloud/src (6 of 14 files). Show all

Unified · Split

6 files
  1. apps/cloud/src/auth/api.ts +0 −11
  2. apps/cloud/src/auth/handlers.ts +0 −14
  3. apps/cloud/src/routes/__root.tsx +19 −15
  4. apps/cloud/src/web/auth.tsx +0 −1
  5. apps/cloud/src/web/components/foreign-org-slug.tsx +0 −61
  6. apps/cloud/src/web/components/org-menu-slot.tsx +12 −15
apps/cloud/src/auth/api.ts+0−11
@@ -34,10 +34,6 @@ const AuthOrganizationsResponse = Schema.Struct({ · 33 unmodified lines
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 = { · 99 unmodified lines
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") · 28 unmodified lines
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+0−14
@@ -301,20 +301,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 300 unmodified lines
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+19−15
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
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"; · 10 unmodified lines
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 }) { · 179 unmodified lines
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 }) { · 32 unmodified lines
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 }) { · 14 unmodified lines
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+0−1
@@ -33,7 +33,6 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( · 32 unmodified lines
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+0−61
@@ -1,61 +0,0 @@
1−import { useEffect, useRef, useState, type ReactNode } from "react";
2−import { useAtomValue, useAtomSet } from "@effect/atom-react";
3−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";
6−import { OrgSlugNotFound } from "@executor-js/react/multiplayer/org-slug-gate";
7−
8−import { organizationsAtom, switchOrganization } from "../auth";
9−
10−// ---------------------------------------------------------------------------
11−// Foreign-slug resolution for the cloud org-slug gate: the URL names a slug
12−// that isn't the active org's. Resolve it against the caller's memberships:
13−// - a member of that org (their bookmark, a teammate's shared link) → switch
14−// the session into it and reload, so every atom re-scopes
15−// - anything else → a WRONG ADDRESS → not-found. A slug must never silently
16−// resolve to a different workspace than the URL names, and single-segment
17−// typos (/this-page-does-not-exist) match the slugged index route, so this
18−// IS the app's 404 for them.
19−// Membership comes from the already-cached organizations atom; the matched
20−// case alone performs a side effect, isolated in `SwitchIntoOrg`.
21−// ---------------------------------------------------------------------------
22−
23−const Centered = (props: { children?: ReactNode }) => (
24− <div className="flex min-h-full flex-1 items-center justify-center text-sm text-muted-foreground">
25− {props.children}
26− </div>
27−);
28−
29−// Switch the session into `organizationId`, then full-reload so the whole app
30−// re-scopes. One-shot: the ref guards React's double-invoke and the render
31−// between resolution and the reload landing. A failed switch means the slug
32−// named an org we can't actually enter — fall back to not-found.
33−function SwitchIntoOrg(props: { readonly organizationId: string }) {
34− const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" });
35− const [failed, setFailed] = useState(false);
36− const started = useRef(false);
37−
38− useEffect(() => {
39− if (started.current) return;
40− started.current = true;
41− void doSwitchOrganization({
42− payload: { organizationId: props.organizationId },
43− reactivityKeys: authWriteKeys,
44− }).then((exit) => (Exit.isSuccess(exit) ? window.location.reload() : setFailed(true)));
45− }, [props.organizationId, doSwitchOrganization]);
46−
47− return failed ? <OrgSlugNotFound /> : <Centered>Switching organization…</Centered>;
48−}
49−
50−export function ForeignOrgSlug(props: { readonly slug: string }) {
51− const organizations = useAtomValue(organizationsAtom);
52−
53− return AsyncResult.match(organizations, {
54− onInitial: () => <Centered />,
55− onFailure: () => <OrgSlugNotFound />,
56− onSuccess: ({ value }) => {
57− const match = value.organizations.find((org: { slug: string }) => org.slug === props.slug);
58− return match ? <SwitchIntoOrg organizationId={match.id} /> : <OrgSlugNotFound />;
59− },
60− });
61−}
apps/cloud/src/web/components/org-menu-slot.tsx+12−15
@@ -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 { · 14 unmodified lines
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() { · 22 unmodified lines
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 · 10 unmodified lines
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>