Skip to content

Commit fd7f779

Browse files
authored
Remember the last-visited org for bare URL entries (#1481)
1 parent ddbbe86 commit fd7f779

5 files changed

Lines changed: 211 additions & 5 deletions

File tree

apps/cloud/src/auth/handlers.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
isOverFreeOrganizationLimit,
2828
shouldApplyFreeOrganizationLimit,
2929
} from "../extensions/billing/plans";
30+
import { LAST_ORG_COOKIE } from "./last-org-cookie";
3031
import {
3132
ORG_SELECTOR_HEADER,
3233
authorizeOrganization,
@@ -218,11 +219,25 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
218219
: null;
219220

220221
// Prefer the org in the URL that sent the user to login. If the URL
221-
// is bare, or not an org route, fall back to WorkOS's org and then to
222-
// the first active membership for org-less sessions. Pending
223-
// memberships are skipped because refreshing into one 400s and would
224-
// bypass invite consent.
225-
let targetOrganizationId = requestedOrg?.id ?? result.organizationId ?? null;
222+
// is bare, or not an org route, prefer the org this browser last
223+
// worked in (the last-org cookie — it outlives the session precisely
224+
// so a fresh login lands where the user left off), then WorkOS's
225+
// org, then the first active membership for org-less sessions.
226+
// Pending memberships are skipped because refreshing into one 400s
227+
// and would bypass invite consent. The cookie is membership-checked
228+
// like any selector, so a stale one just falls through.
229+
let targetOrganizationId = requestedOrg?.id ?? null;
230+
if (!targetOrganizationId && !requestedOrgSelector) {
231+
const lastOrgSlug = request.cookies[LAST_ORG_COOKIE];
232+
const lastOrg =
233+
lastOrgSlug && isValidOrgSlug(lastOrgSlug)
234+
? yield* authorizeOrganizationSelector(result.user.id, lastOrgSlug).pipe(
235+
Effect.orElseSucceed(() => null),
236+
)
237+
: null;
238+
targetOrganizationId = lastOrg?.id ?? null;
239+
}
240+
targetOrganizationId ??= result.organizationId ?? null;
226241
if (!targetOrganizationId && !requestedOrgSelector) {
227242
const memberships = yield* workos.listUserMemberships(result.user.id);
228243
const existingActive = memberships.data.find((m) => m.status === "active");
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// ---------------------------------------------------------------------------
2+
// Last-visited-org cookie — the browser's "which org was I working in" memory.
3+
//
4+
// Org switching is stateless by design (the URL slug scopes every request; see
5+
// organization.ts), which leaves nothing that remembers the last org across
6+
// entries: the sealed session still carries the org pinned at LOGIN time, so a
7+
// bare URL (`executor.sh/`) would always canonicalize onto that first org. This
8+
// cookie fills the gap: the client records the slug of the org it's verifiably
9+
// viewing, and the two bare-entry deciders honor it —
10+
//
11+
// - the SSR auth gate redirects bare document paths onto it (ssr-gate.ts)
12+
// - the login callback prefers it when picking the org for a fresh session
13+
// with a bare returnTo (handlers.ts)
14+
//
15+
// It is a PREFERENCE, never an authority: both readers re-check live membership
16+
// through the same authorize path as any org selector, so a stale or forged
17+
// value at worst falls back to today's behavior. Not HttpOnly — the client is
18+
// the writer. Deliberately NOT cleared on logout: surviving the session is what
19+
// lets the next login land on the last-used org.
20+
// ---------------------------------------------------------------------------
21+
22+
export const LAST_ORG_COOKIE = "executor-last-org";
23+
24+
/** Outlives the 7d session on purpose — it spans logins. */
25+
const LAST_ORG_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
26+
27+
/** Browser-side write. Slugs are `[a-z0-9-]` by grammar, so no encoding. */
28+
export const writeLastOrgCookie = (slug: string): void => {
29+
document.cookie = `${LAST_ORG_COOKIE}=${slug}; Path=/; Max-Age=${LAST_ORG_MAX_AGE_SECONDS}; SameSite=Lax${
30+
window.location.protocol === "https:" ? "; Secure" : ""
31+
}`;
32+
};

apps/cloud/src/auth/ssr-gate.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import { createMiddleware } from "@tanstack/react-start";
2424
import { Effect, Exit, Layer, ManagedRuntime } from "effect";
2525

26+
import { isValidOrgSlug } from "@executor-js/api";
2627
import {
2728
AUTH_HINT_COOKIE,
2829
AUTH_HINT_MAX_AGE_SECONDS,
@@ -35,7 +36,9 @@ import { isAppOwnedPath } from "../app-paths";
3536
import { makeDbLayer } from "../db/db";
3637
import { makeUserStoreLayer, UserStoreService } from "./context";
3738
import { parseCookie } from "./cookies";
39+
import { LAST_ORG_COOKIE } from "./last-org-cookie";
3840
import { sealedSessionDisplayName } from "./middleware";
41+
import { authorizeOrganizationSelector } from "./organization";
3942
import { browserOriginFromRequest } from "./request-origin";
4043
import { loginPath, safeReturnTo } from "./return-to";
4144
import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths";
@@ -158,6 +161,23 @@ const organizationDisplay = async (
158161
: { name: "", slug: "" };
159162
};
160163

164+
// Live membership check for the last-org cookie's slug. Same authorize path
165+
// as any org selector — the cookie is a preference, so a slug the user can't
166+
// access (stale after removal/deletion, or forged) resolves to null and the
167+
// bare path falls through to today's canonicalize-onto-session-org behavior.
168+
// Per-request store layers for the same reason as organizationDisplay.
169+
const authorizeLastOrgSlug = async (
170+
userId: string,
171+
slug: string,
172+
): Promise<{ readonly id: string } | null> => {
173+
const exit = await getRuntime().runPromiseExit(
174+
authorizeOrganizationSelector(userId, slug).pipe(
175+
Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer())),
176+
),
177+
);
178+
return Exit.isSuccess(exit) ? exit.value : null;
179+
};
180+
161181
const hintSetCookie = (hint: AuthHint) =>
162182
`${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`;
163183

@@ -226,6 +246,39 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server(
226246
return redirect("/create-org", { refreshedSession: session.refreshedSession });
227247
}
228248

249+
// A BARE console path (no org slug in the URL) canonicalizes onto the org
250+
// this browser last worked in (the last-org cookie), not the org pinned in
251+
// the session at login time — a multi-org user re-entering at `/` lands
252+
// where they left off. Slugged URLs never enter here (the URL names its
253+
// own scope), so this costs nothing on the steady state; the not-found
254+
// contract is untouched because an unknown-but-valid slug in the URL reads
255+
// as slugged, not bare. When the cookie matches the session's own org (the
256+
// overwhelmingly common single-org case) the client-side OrgSlugGate
257+
// already canonicalizes onto it, so skip the live membership check and the
258+
// redirect entirely.
259+
const lastOrgSlug = parseCookie(cookieHeader, LAST_ORG_COOKIE);
260+
const firstSegment = pathname.split("/")[1] ?? "";
261+
if (
262+
session.organizationId &&
263+
lastOrgSlug &&
264+
isValidOrgSlug(lastOrgSlug) &&
265+
!isValidOrgSlug(firstSegment) &&
266+
!ONBOARDING_PATHS.has(pathname)
267+
) {
268+
const sessionOrgSlug = (await organizationDisplay(session.organizationId)).slug;
269+
if (lastOrgSlug !== sessionOrgSlug) {
270+
// The cookie is a preference, not an authority: only redirect onto an
271+
// org the caller actively belongs to (stale/forged values fall through
272+
// to today's session-org canonicalization).
273+
const lastOrg = await authorizeLastOrgSlug(session.userId, lastOrgSlug);
274+
if (lastOrg) {
275+
return redirect(`/${lastOrgSlug}${pathname === "/" ? "" : pathname}${url.search}`, {
276+
refreshedSession: session.refreshedSession,
277+
});
278+
}
279+
}
280+
}
281+
229282
// Serve the document WITH the verified identity: the hint rides to the
230283
// SSR render through middleware context (the root loader reads it), so
231284
// the server paints the real authenticated shell — no loading state, no

apps/cloud/src/web/auth.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "@executor-js/react/multiplayer/auth-context";
1010
import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint";
1111

12+
import { writeLastOrgCookie } from "../auth/last-org-cookie";
1213
import { CloudApiClient } from "./client";
1314

1415
// ---------------------------------------------------------------------------
@@ -57,6 +58,16 @@ export const AuthProvider = ({
5758

5859
const onIdentify = React.useCallback<IdentifyFn>(
5960
(state) => {
61+
// Record the org this browser is verifiably viewing: onIdentify fires
62+
// only on resolved `/account/me` answers (never hint optimism), and that
63+
// call is URL-scoped, so `state.organization` IS the org of the current
64+
// tab. The cookie makes the next bare entry (`/`) and the next login
65+
// land here instead of the session's login-time org — see
66+
// ../auth/last-org-cookie.ts. Deliberately not cleared on signout: the
67+
// preference is the point of surviving the session.
68+
if (state.status === "authenticated" && state.organization) {
69+
writeLastOrgCookie(state.organization.slug);
70+
}
6071
if (!posthog) return;
6172
if (state.status === "authenticated") {
6273
posthog.identify(state.user.id, {

e2e/cloud/org-last-visited.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Cloud-only (browser): a bare entry (`/`) lands in the org this browser LAST
2+
// worked in, not the org pinned into the session cookie at login time.
3+
//
4+
// Org switching is stateless (the URL slug scopes every request; nothing
5+
// rewrites the session cookie), so without extra memory a bare entry would
6+
// always canonicalize onto the session's login-time org — for a multi-org user
7+
// that reads as "executor.sh forgot which org I was on". The last-org cookie
8+
// (`executor-last-org`) is that memory: the client records the slug of the org
9+
// it is verifiably viewing, and the SSR gate redirects bare document requests
10+
// onto it after re-checking live membership.
11+
import { expect } from "@effect/vitest";
12+
import { Effect } from "effect";
13+
14+
import { scenario } from "../src/scenario";
15+
import { Browser, Target } from "../src/services";
16+
17+
const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
18+
19+
scenario(
20+
"Org URLs · a bare entry lands in the last-visited org, not the session's login org",
21+
{},
22+
Effect.gen(function* () {
23+
const target = yield* Target;
24+
const browser = yield* Browser;
25+
26+
// Identity starts in org A. Create org B through the real endpoint, which
27+
// returns the refreshed cookie — the SESSION is now pinned to org B, so
28+
// without the last-org cookie every bare entry would land in B.
29+
const identity = yield* target.newIdentity();
30+
const cookie = identity.headers?.cookie ?? "";
31+
32+
const createB = yield* Effect.promise(() =>
33+
fetch(new URL("/api/auth/create-organization", target.baseUrl), {
34+
method: "POST",
35+
headers: {
36+
"content-type": "application/json",
37+
cookie,
38+
...CLOUD_ORIGIN_HEADERS(target.baseUrl),
39+
},
40+
body: JSON.stringify({ name: "Last Visited Org B" }),
41+
}),
42+
);
43+
expect(createB.ok, "org B was created").toBe(true);
44+
const orgB = (yield* Effect.promise(() => createB.json())) as { slug: string };
45+
const setCookie = createB.headers.get("set-cookie") ?? "";
46+
const sessionB = /wos-session=([^;]+)/.exec(setCookie)?.[1];
47+
expect(sessionB, "creating org B pinned the session into it").toBeTruthy();
48+
49+
const orgs = (yield* Effect.promise(() =>
50+
fetch(new URL("/api/auth/organizations", target.baseUrl), {
51+
headers: { cookie: `wos-session=${sessionB}` },
52+
}).then((r) => r.json()),
53+
)) as { organizations: ReadonlyArray<{ name: string; slug: string }> };
54+
const slugA = orgs.organizations.find((o) => o.name.startsWith("Org user-"))?.slug;
55+
expect(slugA, "org A has a slug").toBeTruthy();
56+
expect(slugA, "the two orgs have distinct slugs").not.toBe(orgB.slug);
57+
58+
// Drive the browser as the session pinned to B.
59+
const inB = {
60+
...identity,
61+
headers: { cookie: `wos-session=${sessionB}` },
62+
cookies: [{ name: "wos-session", value: sessionB! }],
63+
};
64+
65+
yield* browser.session(inB, async ({ page, step }) => {
66+
await step("Work in org A by its slug URL (the session still pins org B)", async () => {
67+
await page.goto(`/${slugA}`, { waitUntil: "networkidle" });
68+
await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });
69+
// The client records the viewed org once /account/me confirms it.
70+
await page.waitForFunction(
71+
(slug) => document.cookie.includes(`executor-last-org=${slug}`),
72+
slugA,
73+
{ timeout: 30_000 },
74+
);
75+
});
76+
77+
await step("A bare entry (`/`) returns to org A, not the session's org B", async () => {
78+
await page.goto("/", { waitUntil: "networkidle" });
79+
await page.waitForURL(
80+
(url) => url.pathname === `/${slugA}` || url.pathname === `/${slugA}/`,
81+
{
82+
timeout: 30_000,
83+
},
84+
);
85+
await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });
86+
});
87+
88+
await step("A bare deep link keeps its path while landing in org A", async () => {
89+
await page.goto("/policies", { waitUntil: "networkidle" });
90+
await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 });
91+
await page.getByText("Policies").first().waitFor({ timeout: 30_000 });
92+
});
93+
});
94+
}),
95+
);

0 commit comments

Comments
 (0)