History

Forward the org selector header from the shared cloud session helpers

Rhys Sullivan committed 3 months ago

Commit 6fa5e8c1 parent 7f3cf091 file · +60 −30Browse files

Unified · Split

e2e/cloud/support/session.ts+60−30
@@ -3,12 +3,17 @@ · 2 unmodified lines
33 //
44 // Every one of these is a thin wrapper over a REAL product endpoint — there is
55 // no back door here. They were copy-pasted verbatim into four cloud scenarios
6−// before living here, and the copies drifted in the way that matters: only one
7−// of them carried `forBrowser`, which is the fix for the fact that `joinOrg`
8−// refreshes `headers.cookie` (the sealed session is re-issued on the new org)
9−// but leaves `cookies` on the PRE-join value — and the browser context is
10−// seeded from `cookies`. A scenario using a stale copy browses as the person's
11−// old org while its API calls use the new one.
6+// before living here, and the copies drifted in the two ways that matter, both
7+// now folded in:
8+//
9+// - only one carried `forBrowser`, the fix for the fact that `joinOrg`
10+// refreshes `headers.cookie` (the sealed session is re-issued on the new
11+// org) but leaves `cookies` on the PRE-join value — and the browser context
12+// is seeded from `cookies`. A scenario on a stale copy browses as the
13+// person's old org while its API calls use the new one.
14+// - only one forwarded the ORG SELECTOR header. Org-scoped endpoints fail
15+// closed without it (`AccountNoOrganization`), so a cookie-only copy cannot
16+// even build the fixture: `/api/account/members/invite` 403s.
1217 //
1318 // `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module
1419 // is never collected as a suite.
@@ -16,6 +21,23 @@ import { Effect } from "effect"; · 1 unmodified line
1621
1722 import type { Identity, Target as TargetShape } from "../../src/target";
1823
24+/**
25+ * The per-request org selector. `/api/auth/switch-organization` was removed
26+ * (#1000): the URL prefix or this header is the scope authority now, and the
27+ * session's own org is only a default.
28+ *
29+ * This is the RIGHT way to read one account's data in a specific org: a session
30+ * cookie captured before the account joined another org is stale the moment the
31+ * later `accept-invitation` re-seals it, so holding onto the old cookie reads as
32+ * an unauthenticated caller. Take the CURRENT identity and point it at the org
33+ * you mean.
34+ *
35+ * Mirrors `ORG_SELECTOR_HEADER` in `apps/cloud/src/auth/organization.ts`
36+ * (`EXECUTOR_ORG_SELECTOR_HEADER` in the SDK's shared exports); duplicated as a
37+ * literal so the e2e suite stays a black-box client of the deployed product.
38+ */
39+const ORG_SELECTOR_HEADER = "x-executor-organization";
40+
1941 /** The session cookie header this identity authenticates API calls with. */
2042 export const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
2143
@@ -36,7 +58,12 @@ export const forBrowser = (identity: Identity): Identity => { · 14 unmodified lines
3658 };
3759
3860 /** POST as `identity`, failing loudly on a non-2xx (a silent skip here would
39− * leave the scenario asserting against a half-built fixture). */
61+ * leave the scenario asserting against a half-built fixture).
62+ *
63+ * Sends the identity's OWN headers, not just its cookie: org-scoped endpoints
64+ * (`/api/account/members/invite` among them) fail closed with
65+ * `AccountNoOrganization` when the org selector header is absent, because the
66+ * session's own org is only a default and is never fallen back to. */
4067 export const postJson = (
4168 target: TargetShape,
4269 path: string,
@@ -47,9 +74,9 @@ export const postJson = ( · 4 unmodified lines
4774 const response = await fetch(new URL(path, target.baseUrl), {
4875 method: "POST",
4976 headers: {
77+ ...(identity.headers ?? {}),
5078 "content-type": "application/json",
5179 origin: new URL(target.baseUrl).origin,
52− cookie: cookieOf(identity),
5380 },
5481 body: JSON.stringify(body),
5582 });
@@ -59,13 +86,27 @@ export const postJson = ( · 3 unmodified lines
5986 return response;
6087 });
6188
62−/** The identity re-bound to the refreshed session cookie a response set. */
63−export const withRefreshedSession = (identity: Identity, response: Response): Identity => {
89+/** The identity re-bound to the refreshed session cookie a response set, and
90+ * scoped to `orgSelector` — carrying the cookie alone would leave every
91+ * org-scoped read the identity goes on to make failing closed. */
92+export const withRefreshedSession = (
93+ identity: Identity,
94+ response: Response,
95+ orgSelector: string,
96+): Identity => {
6497 const refreshed = (response.headers.getSetCookie?.() ?? [])
6598 .find((header) => header.startsWith("wos-session="))
6699 ?.split(";")[0];
67100 if (!refreshed) throw new Error("response did not refresh the session cookie");
68− return { ...identity, headers: { cookie: refreshed } };
101+ return { ...identity, headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector } };
102+};
103+
104+/** The org selector this identity's requests carry — the same header the web
105+ * client derives from the console URL's slug. */
106+export const orgSelectorOf = (identity: Identity): string => {
107+ const selector = identity.headers?.[ORG_SELECTOR_HEADER];
108+ if (!selector) throw new Error(`identity ${identity.label} carries no org selector header`);
109+ return selector;
69110 };
70111
71112 /**
@@ -73,10 +114,11 @@ export const withRefreshedSession = (identity: Identity, response: Response): Id · 1 unmodified line
73114 * invite carries no roleSlug, so the joiner lands as a plain MEMBER (the actor
74115 * an access-denial assertion needs); pass `roleSlug` for anything else.
75116 *
76− * The returned identity is bound to the newly-joined org, because
77− * accept-invitation re-seals the session onto it. Calling this twice for one
78− * person is how a scenario builds a multi-org member: the WorkOS membership in
79− * the earlier org persists, and the session simply points at the later one.
117+ * The returned identity is bound to the newly-joined org — the re-sealed
118+ * session from accept-invitation, scoped by the selector to the org they were
119+ * invited INTO (`admin`'s). Calling this twice for one person is how a scenario
120+ * builds a multi-org member: the WorkOS membership in the earlier org persists,
121+ * and both the session and the selector point at the later one.
80122 */
81123 export const joinOrg = (
82124 target: TargetShape,
@@ -93,7 +135,7 @@ export const joinOrg = ( · 10 unmodified lines
93135 const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
94136 invitationId: invitation.id,
95137 });
96− return withRefreshedSession(member, acceptResponse);
138+ return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin));
97139 });
98140
99141 /** Create another org for this account; returns the identity bound to it. */
@@ -104,22 +146,10 @@ export const createOrg = ( · 4 unmodified lines
104146 ): Effect.Effect<Identity> =>
105147 Effect.gen(function* () {
106148 const response = yield* postJson(target, "/api/auth/create-organization", identity, { name });
107− return withRefreshedSession(identity, response);
149+ const created = (yield* Effect.promise(() => response.clone().json())) as { id: string };
150+ return withRefreshedSession(identity, response, created.id);
108151 });
109152
110−/**
111− * The per-request org selector. `/api/auth/switch-organization` was removed
112− * (#1000): the URL prefix or this header is the scope authority now, and the
113− * session's own org is only a default.
114− *
115− * This is the RIGHT way to read one account's data in a specific org: a session
116− * cookie captured before the account joined another org is stale the moment the
117− * later `accept-invitation` re-seals it, so holding onto the old cookie reads as
118− * an unauthenticated caller. Take the CURRENT identity and point it at the org
119− * you mean.
120− */
121−const ORG_SELECTOR_HEADER = "x-executor-organization";
122−
123153 /** This identity, scoped to `organizationId` for the requests it makes. */
124154 export const inOrg = (identity: Identity, organizationId: string): Identity => ({
125155 ...identity,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t