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
34 activeOrganizationId: Schema.NullOr(Schema.String),34 activeOrganizationId: Schema.NullOr(Schema.String),
35});35});
36 36
37const SwitchOrganizationBody = Schema.Struct({
38 organizationId: Schema.String,
39});
40
41const CreateOrganizationBody = Schema.Struct({37const CreateOrganizationBody = Schema.Struct({
42 name: Schema.String,38 name: Schema.String,
43});39});
@@ -143,7 +139,6 @@ export const AUTH_PATHS = { · 99 unmodified lines
143 login: "/api/auth/login",139 login: "/api/auth/login",
144 logout: "/api/auth/logout",140 logout: "/api/auth/logout",
145 callback: "/api/auth/callback",141 callback: "/api/auth/callback",
146 switchOrganization: "/api/auth/switch-organization",
147} as const;142} as const;
148 143
149const AuthErrors = [UserStoreError, WorkOSError] as const;144const AuthErrors = [UserStoreError, WorkOSError] as const;
@@ -178,12 +173,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 28 unmodified lines
178 error: WorkOSError,173 error: WorkOSError,
179 }),174 }),
180 )175 )
181 .add(
182 HttpApiEndpoint.post("switchOrganization", "/auth/switch-organization", {
183 payload: SwitchOrganizationBody,
184 error: WorkOSError,
185 }),
186 )
187 .add(176 .add(
188 HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {177 HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {
189 payload: CreateOrganizationBody,178 payload: CreateOrganizationBody,
apps/cloud/src/auth/handlers.ts+0−14
@@ -301,20 +301,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 300 unmodified lines
301 };301 };
302 }),302 }),
303 )303 )
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 )
318 .handle("createOrganization", ({ payload }) =>304 .handle("createOrganization", ({ payload }) =>
319 Effect.gen(function* () {305 Effect.gen(function* () {
320 const workos = yield* WorkOSClient;306 const workos = yield* WorkOSClient;
apps/cloud/src/routes/__root.tsx+19−15
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 createRootRoute,7 createRootRoute,
8 useLocation,8 useLocation,
9 useNavigate,9 useNavigate,
10 useParams,
10} from "@tanstack/react-router";11} from "@tanstack/react-router";
11import { AutumnProvider } from "autumn-js/react";12import { AutumnProvider } from "autumn-js/react";
12import posthog from "posthog-js";13import posthog from "posthog-js";
@@ -23,7 +24,6 @@ import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; · 10 unmodified lines
23import { AuthProvider, useAuth } from "../web/auth";24import { AuthProvider, useAuth } from "../web/auth";
24import { loginPath } from "../auth/return-to";25import { loginPath } from "../auth/return-to";
25import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths";26import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths";
26import { ForeignOrgSlug } from "../web/components/foreign-org-slug";
27import { SupportOptions } from "../web/components/support-options";27import { SupportOptions } from "../web/components/support-options";
28import { Shell } from "../web/shell";28import { Shell } from "../web/shell";
29import appCss from "@executor-js/react/globals.css?url";29import appCss from "@executor-js/react/globals.css?url";
@@ -209,13 +209,20 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 179 unmodified lines
209 const navigate = useNavigate();209 const navigate = useNavigate();
210 const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname);210 const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname);
211 const isPublicRoute = PUBLIC_PATHS.has(location.pathname);211 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;
212 216
213 // The SSR gate already bounced fresh org-less document requests to217 // The SSR gate already bounced fresh org-less document requests to
214 // /create-org; this catches the MID-SESSION transitions (org deleted,218 // /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.
216 const needsOrgRedirect =222 const needsOrgRedirect =
217 auth.status === "authenticated" &&223 auth.status === "authenticated" &&
218 auth.organization == null &&224 auth.organization == null &&
225 !urlOrgSlug &&
219 !isOnboardingRoute &&226 !isOnboardingRoute &&
220 !isPublicRoute;227 !isPublicRoute;
221 228
@@ -254,7 +261,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 32 unmodified lines
254 }261 }
255 262
256 if (auth.organization == null) {263 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 />;
258 }269 }
259 270
260 // Seed the server connection from the SSR origin so origin-derived UI (the271 // 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
275 organizationId={auth.organization.id}286 organizationId={auth.organization.id}
276 organizationSlug={activeSlug}287 organizationSlug={activeSlug}
277 >288 >
278 <OrgSlugGate289 {/* The org header scopes every request to the URL's org, so
279 activeSlug={activeSlug}290 reaching here means the caller is a member of `activeSlug`
280 // Framed by the real shell: a foreign slug resolves (or291 (a foreign slug already 404'd above). The gate only keeps
281 // 404s) inside the app chrome, exactly like the route-level292 the URL canonical — bare → /<slug>. */}
282 // not-found — never a bare full-page state.293 <OrgSlugGate activeSlug={activeSlug}>
283 foreignSlug={(slug) => (
284 <>
285 <Shell content={<ForeignOrgSlug slug={slug} />} />
286 <Toaster />
287 </>
288 )}
289 >
290 <Shell />294 <Shell />
291 <Toaster />295 <Toaster />
292 </OrgSlugGate>296 </OrgSlugGate>
apps/cloud/src/web/auth.tsx+0−1
@@ -33,7 +33,6 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( · 32 unmodified lines
33 }),33 }),
34);34);
35 35
36export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
37export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");36export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");
38 37
39export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {38export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {
apps/cloud/src/web/components/foreign-org-slug.tsx+0−61
@@ -1,61 +0,0 @@
1import { useEffect, useRef, useState, type ReactNode } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";
5import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
6import { OrgSlugNotFound } from "@executor-js/react/multiplayer/org-slug-gate";
7
8import { 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
23const 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.
33function 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
50export 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 @@
1import { useState } from "react";1import { useState } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";
5import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
6import { trackEvent } from "@executor-js/react/api/analytics";4import { trackEvent } from "@executor-js/react/api/analytics";
7import { Button } from "@executor-js/react/components/button";5import { Button } from "@executor-js/react/components/button";
8import {6import {
@@ -23,7 +21,7 @@ import { · 14 unmodified lines
23 DropdownMenuSubTrigger,21 DropdownMenuSubTrigger,
24} from "@executor-js/react/components/dropdown-menu";22} from "@executor-js/react/components/dropdown-menu";
25import { useAuth } from "../auth";23import { useAuth } from "../auth";
26import { organizationsAtom, switchOrganization } from "../auth";24import { organizationsAtom } from "../auth";
27import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form";25import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form";
28 26
29// ---------------------------------------------------------------------------27// ---------------------------------------------------------------------------
@@ -52,18 +50,15 @@ function CheckIcon() { · 22 unmodified lines
52 50
53function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) {51function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) {
54 const organizations = useAtomValue(organizationsAtom);52 const organizations = useAtomValue(organizationsAtom);
55 const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" });
56 53
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 }) => {
58 if (organization.id === props.activeOrganizationId) return;59 if (organization.id === props.activeOrganizationId) return;
59 const exit = await doSwitchOrganization({60 trackEvent("org_switched", { success: true });
60 payload: { organizationId: organization.id },61 window.location.href = `/${organization.slug}`;
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}`;
67 };62 };
68 63
69 return AsyncResult.match(organizations, {64 return AsyncResult.match(organizations, {
@@ -80,7 +75,9 @@ function OrganizationSwitcherItems(props: { activeOrganizationId: string | null · 10 unmodified lines
80 <DropdownMenuItem75 <DropdownMenuItem
81 key={organization.id}76 key={organization.id}
82 disabled={isActive}77 disabled={isActive}
83 onClick={() => handleSwitch(organization)}78 onClick={() => {
79 handleSwitch(organization);
80 }}
84 className="text-xs"81 className="text-xs"
85 >82 >
86 <span className="min-w-0 flex-1 truncate">{organization.name}</span>83 <span className="min-w-0 flex-1 truncate">{organization.name}</span>