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
3//3//
4// Every one of these is a thin wrapper over a REAL product endpoint — there is4// Every one of these is a thin wrapper over a REAL product endpoint — there is
5// no back door here. They were copy-pasted verbatim into four cloud scenarios5// 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 one6// before living here, and the copies drifted in the two ways that matter, both
7// of them carried `forBrowser`, which is the fix for the fact that `joinOrg`7// now folded in:
8// refreshes `headers.cookie` (the sealed session is re-issued on the new org)8//
9// but leaves `cookies` on the PRE-join value — and the browser context is9// - only one carried `forBrowser`, the fix for the fact that `joinOrg`
10// seeded from `cookies`. A scenario using a stale copy browses as the person's10// refreshes `headers.cookie` (the sealed session is re-issued on the new
11// old org while its API calls use the new one.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.
12//17//
13// `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module18// `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module
14// is never collected as a suite.19// is never collected as a suite.
@@ -16,6 +21,23 @@ import { Effect } from "effect"; · 1 unmodified line
16 21
17import type { Identity, Target as TargetShape } from "../../src/target";22import type { Identity, Target as TargetShape } from "../../src/target";
18 23
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 */
39const ORG_SELECTOR_HEADER = "x-executor-organization";
40
19/** The session cookie header this identity authenticates API calls with. */41/** The session cookie header this identity authenticates API calls with. */
20export const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";42export const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
21 43
@@ -36,7 +58,12 @@ export const forBrowser = (identity: Identity): Identity => { · 14 unmodified lines
36};58};
37 59
38/** POST as `identity`, failing loudly on a non-2xx (a silent skip here would60/** 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. */
40export const postJson = (67export const postJson = (
41 target: TargetShape,68 target: TargetShape,
42 path: string,69 path: string,
@@ -47,9 +74,9 @@ export const postJson = ( · 4 unmodified lines
47 const response = await fetch(new URL(path, target.baseUrl), {74 const response = await fetch(new URL(path, target.baseUrl), {
48 method: "POST",75 method: "POST",
49 headers: {76 headers: {
77 ...(identity.headers ?? {}),
50 "content-type": "application/json",78 "content-type": "application/json",
51 origin: new URL(target.baseUrl).origin,79 origin: new URL(target.baseUrl).origin,
52 cookie: cookieOf(identity),
53 },80 },
54 body: JSON.stringify(body),81 body: JSON.stringify(body),
55 });82 });
@@ -59,13 +86,27 @@ export const postJson = ( · 3 unmodified lines
59 return response;86 return response;
60 });87 });
61 88
62/** The identity re-bound to the refreshed session cookie a response set. */89/** The identity re-bound to the refreshed session cookie a response set, and
63export const withRefreshedSession = (identity: Identity, response: Response): Identity => {90 * scoped to `orgSelector` — carrying the cookie alone would leave every
91 * org-scoped read the identity goes on to make failing closed. */
92export const withRefreshedSession = (
93 identity: Identity,
94 response: Response,
95 orgSelector: string,
96): Identity => {
64 const refreshed = (response.headers.getSetCookie?.() ?? [])97 const refreshed = (response.headers.getSetCookie?.() ?? [])
65 .find((header) => header.startsWith("wos-session="))98 .find((header) => header.startsWith("wos-session="))
66 ?.split(";")[0];99 ?.split(";")[0];
67 if (!refreshed) throw new Error("response did not refresh the session cookie");100 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. */
106export 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;
69};110};
70 111
71/**112/**
@@ -73,10 +114,11 @@ export const withRefreshedSession = (identity: Identity, response: Response): Id · 1 unmodified line
73 * invite carries no roleSlug, so the joiner lands as a plain MEMBER (the actor114 * invite carries no roleSlug, so the joiner lands as a plain MEMBER (the actor
74 * an access-denial assertion needs); pass `roleSlug` for anything else.115 * an access-denial assertion needs); pass `roleSlug` for anything else.
75 *116 *
76 * The returned identity is bound to the newly-joined org, because117 * The returned identity is bound to the newly-joined org — the re-sealed
77 * accept-invitation re-seals the session onto it. Calling this twice for one118 * session from accept-invitation, scoped by the selector to the org they were
78 * person is how a scenario builds a multi-org member: the WorkOS membership in119 * invited INTO (`admin`'s). Calling this twice for one person is how a scenario
79 * the earlier org persists, and the session simply points at the later one.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.
80 */122 */
81export const joinOrg = (123export const joinOrg = (
82 target: TargetShape,124 target: TargetShape,
@@ -93,7 +135,7 @@ export const joinOrg = ( · 10 unmodified lines
93 const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {135 const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
94 invitationId: invitation.id,136 invitationId: invitation.id,
95 });137 });
96 return withRefreshedSession(member, acceptResponse);138 return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin));
97 });139 });
98 140
99/** Create another org for this account; returns the identity bound to it. */141/** Create another org for this account; returns the identity bound to it. */
@@ -104,22 +146,10 @@ export const createOrg = ( · 4 unmodified lines
104): Effect.Effect<Identity> =>146): Effect.Effect<Identity> =>
105 Effect.gen(function* () {147 Effect.gen(function* () {
106 const response = yield* postJson(target, "/api/auth/create-organization", identity, { name });148 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);
108 });151 });
109 152
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 */
121const ORG_SELECTOR_HEADER = "x-executor-organization";
122
123/** This identity, scoped to `organizationId` for the requests it makes. */153/** This identity, scoped to `organizationId` for the requests it makes. */
124export const inOrg = (identity: Identity, organizationId: string): Identity => ({154export const inOrg = (identity: Identity, organizationId: string): Identity => ({
125 ...identity,155 ...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