Skip to content

Commit 78aa871

Browse files
authored
Fix OAuth callback org scope (#1134)
* Fix OAuth callback org scope * Show URLs in e2e PR media * Record OAuth org switch flow
1 parent 913c8c2 commit 78aa871

14 files changed

Lines changed: 586 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Fix OAuth callbacks in cloud so they preserve the URL-selected organization when the session cookie points at another org.

apps/cloud/src/auth/organization.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// ---------------------------------------------------------------------------
1313

1414
import { Effect } from "effect";
15+
import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared";
1516

1617
import { UserStoreService } from "./context";
1718
import { WorkOSClient } from "./workos";
@@ -96,7 +97,7 @@ export const authorizeOrganization = (userId: string, organizationId: string) =>
9697
// silently re-scopes the other. Scoping per-request from the URL makes each
9798
// tab independent.
9899

99-
export const ORG_SELECTOR_HEADER = "x-executor-organization";
100+
export const ORG_SELECTOR_HEADER = EXECUTOR_ORG_SELECTOR_HEADER;
100101

101102
/** The URL-pinned org selector for a request, or `null` to fall back to the session. */
102103
export const orgSelectorFromRequest = (request: Request): string | null =>

apps/cloud/src/start.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { createMiddleware, createStart } from "@tanstack/react-start";
2+
import { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "@executor-js/sdk/shared";
23

34
import { cloudApiHandler } from "./app";
45
import { isAppOwnedPath } from "./app-paths";
56
import { authGateMiddleware } from "./auth/ssr-gate";
67
import { parseCookie } from "./auth/cookies";
8+
import { ORG_SELECTOR_HEADER } from "./auth/organization";
79
import { loginPath } from "./auth/return-to";
810
import { prepareMcpOrgScope } from "./mcp/mount";
911
import {
@@ -39,6 +41,14 @@ const getApp = () => (app ??= cloudApiHandler());
3941
const SESSION_COOKIE = "wos-session";
4042
const OAUTH_CALLBACK_PATH = "/api/oauth/callback";
4143

44+
const oauthCallbackOrgScopedRequest = (request: Request): Request => {
45+
const orgSelector = new URL(request.url).searchParams.get(OAUTH_CALLBACK_ORG_QUERY_PARAM);
46+
if (!orgSelector) return request;
47+
const headers = new Headers(request.headers);
48+
headers.set(ORG_SELECTOR_HEADER, orgSelector);
49+
return new Request(request, { headers });
50+
};
51+
4252
const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).server(
4353
({ pathname, request, next }) => {
4454
if (
@@ -66,7 +76,11 @@ const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).serv
6676
// else, including `/api/*`).
6777
const appRequestMiddleware = createMiddleware({ type: "request" }).server(
6878
({ pathname, request, next }) => {
69-
if (isAppOwnedPath(pathname)) return getApp().handler(prepareMcpOrgScope(request));
79+
if (isAppOwnedPath(pathname)) {
80+
const scopedRequest =
81+
pathname === OAUTH_CALLBACK_PATH ? oauthCallbackOrgScopedRequest(request) : request;
82+
return getApp().handler(prepareMcpOrgScope(scopedRequest));
83+
}
7084
return next();
7185
},
7286
);
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Cloud-only: OAuth callbacks must preserve the URL-selected organization.
2+
//
3+
// A browser session cookie can be pinned to org B while a tab is operating in
4+
// org A via the URL org selector. OAuth redirects leave the console route and
5+
// land on /api/oauth/callback, so the callback URL itself must carry the same
6+
// org selector that was present when oauth.start created the session.
7+
import { randomBytes } from "node:crypto";
8+
9+
import { expect } from "@effect/vitest";
10+
import { Effect } from "effect";
11+
import type { Page } from "playwright";
12+
import { composePluginApi } from "@executor-js/api/server";
13+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
14+
import {
15+
IntegrationSlug,
16+
OAUTH_CALLBACK_ORG_QUERY_PARAM,
17+
OAuthClientSlug,
18+
} from "@executor-js/sdk/shared";
19+
import { serveOAuthTestServer } from "@executor-js/sdk/testing";
20+
21+
import { scenario } from "../src/scenario";
22+
import { Api, Browser, Target } from "../src/services";
23+
import type { Identity } from "../src/target";
24+
25+
const api = composePluginApi([openApiHttpPlugin()] as const);
26+
27+
const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
28+
29+
const cookiePair = (response: Response, name: string): string | undefined => {
30+
for (const header of response.headers.getSetCookie?.() ?? []) {
31+
if (header.startsWith(`${name}=`)) return header.split(";")[0];
32+
}
33+
return undefined;
34+
};
35+
36+
const cookieValue = (pair: string): string => {
37+
const [, value] = pair.split(/=(.*)/s);
38+
if (!value) throw new Error("cookie pair has no value");
39+
return value;
40+
};
41+
42+
const cookieOf = (identity: Identity): string => identity.headers?.cookie ?? "";
43+
44+
const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
45+
46+
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47+
48+
const setWorkosSessionCookie = async (page: Page, baseUrl: string, cookie: string) => {
49+
await page.context().addCookies([
50+
{
51+
name: "wos-session",
52+
value: cookieValue(cookie),
53+
url: baseUrl,
54+
},
55+
]);
56+
};
57+
58+
const expectOrgShell = async (
59+
page: Page,
60+
org: { readonly name: string; readonly slug: string },
61+
) => {
62+
await page.waitForURL(
63+
(url) => url.pathname === `/${org.slug}` || url.pathname === `/${org.slug}/`,
64+
{
65+
timeout: 30_000,
66+
},
67+
);
68+
await page.getByRole("button", { name: new RegExp(escapeRegExp(org.name)) }).waitFor();
69+
await page.getByRole("heading", { name: "Integrations" }).waitFor();
70+
};
71+
72+
const installSameWindowOAuthPopup = async (page: Page) => {
73+
await page.addInitScript(() => {
74+
window.open = () =>
75+
({
76+
get closed() {
77+
return false;
78+
},
79+
close() {},
80+
focus() {},
81+
location: {
82+
get href() {
83+
return window.location.href;
84+
},
85+
set href(value: string) {
86+
window.location.assign(String(value));
87+
},
88+
},
89+
}) as Window;
90+
});
91+
};
92+
93+
const submitProviderLoginFromPage = async (page: Page): Promise<string> =>
94+
fetch(page.url(), {
95+
method: "POST",
96+
redirect: "manual",
97+
headers: {
98+
authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`,
99+
},
100+
}).then((response) => {
101+
const location = response.headers.get("location");
102+
if (response.status !== 302 || !location) {
103+
throw new Error(`provider did not return callback location (${response.status})`);
104+
}
105+
return new URL(location, page.url()).toString();
106+
});
107+
108+
const activeOrg = (baseUrl: string, cookie: string) =>
109+
Effect.promise(async () => {
110+
const response = await fetch(new URL("/api/auth/me", baseUrl), {
111+
headers: { cookie },
112+
});
113+
if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);
114+
const body = (await response.json()) as {
115+
organization: { id: string; name: string; slug: string } | null;
116+
};
117+
if (!body.organization) throw new Error("identity has no active organization");
118+
return body.organization;
119+
});
120+
121+
const createOrganization = (baseUrl: string, cookie: string, name: string) =>
122+
Effect.promise(async () => {
123+
const response = await fetch(new URL("/api/auth/create-organization", baseUrl), {
124+
method: "POST",
125+
headers: {
126+
"content-type": "application/json",
127+
cookie,
128+
...originHeaders(baseUrl),
129+
},
130+
body: JSON.stringify({ name }),
131+
});
132+
if (!response.ok) {
133+
throw new Error(`/api/auth/create-organization failed (${response.status})`);
134+
}
135+
const session = cookiePair(response, "wos-session");
136+
if (!session) throw new Error("create organization did not refresh the session");
137+
const org = (await response.json()) as { id: string; name: string; slug: string };
138+
return { org, session };
139+
});
140+
141+
const oauthIntegrationSpec = (oauth: {
142+
readonly authorizationEndpoint: string;
143+
readonly tokenEndpoint: string;
144+
}) =>
145+
({
146+
spec: {
147+
kind: "blob" as const,
148+
value: JSON.stringify({
149+
openapi: "3.0.3",
150+
info: { title: "OAuth org scope", version: "1.0.0" },
151+
paths: {
152+
"/me": {
153+
get: {
154+
operationId: "getMe",
155+
responses: { "200": { description: "the caller" } },
156+
},
157+
},
158+
},
159+
}),
160+
},
161+
baseUrl: "http://127.0.0.1:59999",
162+
authenticationTemplate: [
163+
{
164+
slug: "oauth",
165+
kind: "oauth2" as const,
166+
authorizationUrl: oauth.authorizationEndpoint,
167+
tokenUrl: oauth.tokenEndpoint,
168+
scopes: ["read"],
169+
},
170+
],
171+
}) as const;
172+
173+
scenario(
174+
"OAuth callback · URL-scoped org survives a callback while the session cookie points elsewhere",
175+
{},
176+
Effect.gen(function* () {
177+
const target = yield* Target;
178+
const { client: makeApiClient } = yield* Api;
179+
const browser = yield* Browser;
180+
const oauth = yield* serveOAuthTestServer();
181+
182+
const identity = yield* target.newIdentity();
183+
const sessionA = cookieOf(identity);
184+
const orgA = yield* activeOrg(target.baseUrl, sessionA);
185+
186+
const { org: orgB, session: sessionB } = yield* createOrganization(
187+
target.baseUrl,
188+
sessionA,
189+
`OAuth Callback Org B ${randomBytes(3).toString("hex")}`,
190+
);
191+
expect(orgB.slug, "the test has two distinct org URLs").not.toBe(orgA.slug);
192+
193+
const client = yield* makeApiClient(api, identity);
194+
195+
const integration = IntegrationSlug.make(unique("oauthscope"));
196+
yield* client.openapi.addSpec({
197+
payload: { ...oauthIntegrationSpec(oauth), slug: integration },
198+
});
199+
200+
const clientSlug = OAuthClientSlug.make(unique("oauthc"));
201+
yield* client.oauth.createClient({
202+
payload: {
203+
owner: "org",
204+
slug: clientSlug,
205+
authorizationUrl: oauth.authorizationEndpoint,
206+
tokenUrl: oauth.tokenEndpoint,
207+
grant: "authorization_code",
208+
clientId: "test-client",
209+
clientSecret: "test-secret",
210+
},
211+
});
212+
213+
let callback = new URL("http://invalid.example");
214+
yield* browser.session(identity, async ({ page, step }) => {
215+
await installSameWindowOAuthPopup(page);
216+
217+
await step("Land in the original organization", async () => {
218+
await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" });
219+
await expectOrgShell(page, orgA);
220+
});
221+
222+
await step("The browser session is switched to another organization", async () => {
223+
await setWorkosSessionCookie(page, target.baseUrl, sessionB);
224+
await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
225+
await expectOrgShell(page, orgB);
226+
});
227+
228+
await step("Start OAuth from the original organization's add-connection flow", async () => {
229+
await page.goto(`/${orgA.slug}/integrations/${String(integration)}?addAccount=1`, {
230+
waitUntil: "networkidle",
231+
});
232+
await page.getByRole("heading", { name: /Add connection/ }).waitFor({
233+
timeout: 30_000,
234+
});
235+
await page.getByRole("button", { name: "Connect with OAuth" }).click();
236+
await page.waitForURL(
237+
(url) => url.origin === new URL(oauth.issuerUrl).origin && url.pathname === "/login",
238+
{ timeout: 30_000 },
239+
);
240+
await page.getByText("OAuth test login").waitFor();
241+
});
242+
243+
await step("The provider returns to the OAuth callback", async () => {
244+
const callbackUrl = await submitProviderLoginFromPage(page);
245+
callback = new URL(callbackUrl);
246+
const response = await page.goto(callbackUrl, { waitUntil: "networkidle" });
247+
expect(response?.status(), "the callback renders its popup result page").toBe(200);
248+
});
249+
250+
const body = (await page.locator("body").textContent())?.trim() ?? "";
251+
expect(
252+
body,
253+
"the callback completes in the org where oauth.start stored the session",
254+
).toContain("Connected");
255+
expect(body, "the callback did not fall through to the cookie-pinned org").not.toContain(
256+
"OAuth session expired or not found",
257+
);
258+
});
259+
260+
expect(
261+
callback.searchParams.get(OAUTH_CALLBACK_ORG_QUERY_PARAM),
262+
"the provider callback URL carries the original org selector",
263+
).toBe(orgA.slug);
264+
}).pipe(Effect.scoped),
265+
);

0 commit comments

Comments
 (0)