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
8 8
9import { expect } from "@effect/vitest";9import { expect } from "@effect/vitest";
10import { Effect } from "effect";10import { Effect } from "effect";
11import type { Page } from "playwright";
11import { composePluginApi } from "@executor-js/api/server";12import { composePluginApi } from "@executor-js/api/server";
12import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";13import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13import {14import {
14 AuthTemplateSlug,
15 ConnectionName,
16 EXECUTOR_ORG_SELECTOR_HEADER,
17 IntegrationSlug,15 IntegrationSlug,
18 OAUTH_CALLBACK_ORG_QUERY_PARAM,16 OAUTH_CALLBACK_ORG_QUERY_PARAM,
19 OAuthClientSlug,17 OAuthClientSlug,
@@ -45,6 +43,68 @@ const cookieOf = (identity: Identity): string => identity.headers?.cookie ?? ""; · 25 unmodified lines
45 43
46const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });44const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
47 45
46const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
48const 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
58const 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
72const 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
93const 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
48const activeOrg = (baseUrl: string, cookie: string) =>108const activeOrg = (baseUrl: string, cookie: string) =>
49 Effect.promise(async () => {109 Effect.promise(async () => {
50 const response = await fetch(new URL("/api/auth/me", baseUrl), {110 const response = await fetch(new URL("/api/auth/me", baseUrl), {
@@ -130,16 +190,7 @@ scenario( · 79 unmodified lines
130 );190 );
131 expect(orgB.slug, "the test has two distinct org URLs").not.toBe(orgA.slug);191 expect(orgB.slug, "the test has two distinct org URLs").not.toBe(orgA.slug);
132 192
133 const scopedToOrgAWithCookieB: Identity = {193 const client = yield* makeApiClient(api, 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);
143 194
144 const integration = IntegrationSlug.make(unique("oauthscope"));195 const integration = IntegrationSlug.make(unique("oauthscope"));
145 yield* client.openapi.addSpec({196 yield* client.openapi.addSpec({
@@ -159,42 +210,42 @@ scenario( · 13 unmodified lines
159 },210 },
160 });211 });
161 212
162 const started = yield* client.oauth.start({213 let callback = new URL("http://invalid.example");
163 payload: {214 yield* browser.session(identity, async ({ page, step }) => {
164 client: clientSlug,215 await installSameWindowOAuthPopup(page);
165 clientOwner: "org",216
166 owner: "org",217 await step("Land in the original organization", async () => {
167 name: ConnectionName.make("main"),218 await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" });
168 integration,219 await expectOrgShell(page, orgA);
169 template: AuthTemplateSlug.make("oauth"),220 });
170 },221
171 });222 await step("The browser session is switched to another organization", async () => {
172 expect(started.status, "oauth.start begins at the provider").toBe("redirect");223 await setWorkosSessionCookie(page, target.baseUrl, sessionB);
173 const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : "";224 await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
174 225 await expectOrgShell(page, orgB);
175 const authorize = yield* Effect.promise(() => fetch(authorizationUrl, { redirect: "manual" }));226 });
176 expect(authorize.status, "the provider asks the user to log in").toBe(302);227
177 const consent = yield* Effect.promise(() =>228 await step("Start OAuth from the original organization's add-connection flow", async () => {
178 fetch(authorize.headers.get("location") ?? "", {229 await page.goto(`/${orgA.slug}/integrations/${String(integration)}?addAccount=1`, {
179 method: "POST",230 waitUntil: "networkidle",
180 redirect: "manual",231 });
181 headers: {232 await page.getByRole("heading", { name: /Add connection/ }).waitFor({
182 authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`,233 timeout: 30_000,
183 },234 });
184 }),235 await page.getByRole("button", { name: "Connect with OAuth" }).click();
185 );236 await page.waitForURL(
186 expect(consent.status, "provider consent redirects back to Executor").toBe(302);237 (url) => url.origin === new URL(oauth.issuerUrl).origin && url.pathname === "/login",
187 const callback = new URL(consent.headers.get("location") ?? "");238 { timeout: 30_000 },
188 const callbackPath = `${callback.pathname}${callback.search}`;239 );
189 240 await page.getByText("OAuth test login").waitFor();
190 yield* browser.session(scopedToOrgAWithCookieB, async ({ page, step }) => {241 });
191 await step(242
192 "Provider returns to the OAuth callback while the cookie is pinned to org B",243 await step("The provider returns to the OAuth callback", async () => {
193 async () => {244 const callbackUrl = await submitProviderLoginFromPage(page);
194 const response = await page.goto(callbackPath, { waitUntil: "networkidle" });245 callback = new URL(callbackUrl);
195 expect(response?.status(), "the callback renders its popup result page").toBe(200);246 const response = await page.goto(callbackUrl, { waitUntil: "networkidle" });
196 },247 expect(response?.status(), "the callback renders its popup result page").toBe(200);
197 );248 });
198 249
199 const body = (await page.locator("body").textContent())?.trim() ?? "";250 const body = (await page.locator("body").textContent())?.trim() ?? "";
200 expect(251 expect(