History

Record OAuth org switch flow

Rhys Sullivan committed 4 months ago

Commit c575d2f1 parent 95b9e2a1 file · +100 −49Browse files

Unified · Split

e2e/cloud/oauth-callback-org-scope.test.ts+100−49
@@ -8,12 +8,10 @@ import { randomBytes } from "node:crypto"; · 7 unmodified lines
88
99 import { expect } from "@effect/vitest";
1010 import { Effect } from "effect";
11+import type { Page } from "playwright";
1112 import { composePluginApi } from "@executor-js/api/server";
1213 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
1314 import {
14− AuthTemplateSlug,
15− ConnectionName,
16− EXECUTOR_ORG_SELECTOR_HEADER,
1715 IntegrationSlug,
1816 OAUTH_CALLBACK_ORG_QUERY_PARAM,
1917 OAuthClientSlug,
@@ -45,6 +43,68 @@ const cookieOf = (identity: Identity): string => identity.headers?.cookie ?? ""; · 25 unmodified lines
4543
4644 const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
4745
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+
48108 const activeOrg = (baseUrl: string, cookie: string) =>
49109 Effect.promise(async () => {
50110 const response = await fetch(new URL("/api/auth/me", baseUrl), {
@@ -130,16 +190,7 @@ scenario( · 79 unmodified lines
130190 );
131191 expect(orgB.slug, "the test has two distinct org URLs").not.toBe(orgA.slug);
132192
133− const scopedToOrgAWithCookieB: Identity = {
134− ...identity,
135− headers: {
136− cookie: sessionB,
137− [EXECUTOR_ORG_SELECTOR_HEADER]: orgA.slug,
138− },
139− cookies: [{ name: "wos-session", value: cookieValue(sessionB) }],
140− };
141−
142− const client = yield* makeApiClient(api, scopedToOrgAWithCookieB);
193+ const client = yield* makeApiClient(api, identity);
143194
144195 const integration = IntegrationSlug.make(unique("oauthscope"));
145196 yield* client.openapi.addSpec({
@@ -159,42 +210,42 @@ scenario( · 13 unmodified lines
159210 },
160211 });
161212
162− const started = yield* client.oauth.start({
163− payload: {
164− client: clientSlug,
165− clientOwner: "org",
166− owner: "org",
167− name: ConnectionName.make("main"),
168− integration,
169− template: AuthTemplateSlug.make("oauth"),
170− },
171− });
172− expect(started.status, "oauth.start begins at the provider").toBe("redirect");
173− const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : "";
174−
175− const authorize = yield* Effect.promise(() => fetch(authorizationUrl, { redirect: "manual" }));
176− expect(authorize.status, "the provider asks the user to log in").toBe(302);
177− const consent = yield* Effect.promise(() =>
178− fetch(authorize.headers.get("location") ?? "", {
179− method: "POST",
180− redirect: "manual",
181− headers: {
182− authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`,
183− },
184− }),
185− );
186− expect(consent.status, "provider consent redirects back to Executor").toBe(302);
187− const callback = new URL(consent.headers.get("location") ?? "");
188− const callbackPath = `${callback.pathname}${callback.search}`;
189−
190− yield* browser.session(scopedToOrgAWithCookieB, async ({ page, step }) => {
191− await step(
192− "Provider returns to the OAuth callback while the cookie is pinned to org B",
193− async () => {
194− const response = await page.goto(callbackPath, { waitUntil: "networkidle" });
195− expect(response?.status(), "the callback renders its popup result page").toBe(200);
196− },
197− );
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+ });
198249
199250 const body = (await page.locator("body").textContent())?.trim() ?? "";
200251 expect(