311 lines · 11.6 KB · TypeScript
RawDownload
// Cloud-only: OAuth callbacks must preserve the URL-selected organization.//// A browser session cookie can be pinned to org B while a tab is operating in// org A via the URL org selector. OAuth redirects leave the console route and// land on /api/oauth/callback, so the provider state must carry the org selector// without adding provider-facing query params to redirect_uri.import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest";import { Effect } from "effect";import type { Page } from "playwright";import { composePluginApi } from "@executor-js/api/server";import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario";import { Api, Browser, Target } from "../src/services";import type { Identity } from "../src/target";import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; const cookiePair = (response: Response, name: string): string | undefined => {  for (const header of response.headers.getSetCookie?.() ?? []) {    if (header.startsWith(`${name}=`)) return header.split(";")[0];  }  return undefined;}; const cookieValue = (pair: string): string => {  const [, value] = pair.split(/=(.*)/s);  if (!value) throw new Error("cookie pair has no value");  return value;}; const cookieOf = (identity: Identity): string => identity.headers?.cookie ?? ""; const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const setWorkosSessionCookie = async (page: Page, baseUrl: string, cookie: string) => {  await page.context().addCookies([    {      name: "wos-session",      value: cookieValue(cookie),      url: baseUrl,    },  ]);}; const expectOrgShell = async (  page: Page,  org: { readonly name: string; readonly slug: string },) => {  await page.waitForURL(    (url) => url.pathname === `/${org.slug}` || url.pathname === `/${org.slug}/`,    {      timeout: 30_000,    },  );  await page.getByRole("button", { name: new RegExp(escapeRegExp(org.name)) }).waitFor();  await page.getByRole("heading", { name: "Integrations" }).waitFor();}; const installSameWindowOAuthPopup = async (page: Page) => {  await page.addInitScript(() => {    window.open = () =>      ({        get closed() {          return false;        },        close() {},        focus() {},        location: {          get href() {            return window.location.href;          },          set href(value: string) {            window.location.assign(String(value));          },        },      }) as Window;  });}; const submitProviderLoginFromPage = async (page: Page): Promise<string> =>  fetch(page.url(), {    method: "POST",    redirect: "manual",    headers: {      authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`,    },  }).then((response) => {    const location = response.headers.get("location");    if (response.status !== 302 || !location) {      throw new Error(`provider did not return callback location (${response.status})`);    }    return new URL(location, page.url()).toString();  }); const activeOrg = (baseUrl: string, cookie: string) =>  Effect.promise(async () => {    const response = await fetch(new URL("/api/auth/me", baseUrl), {      headers: { cookie },    });    if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);    const body = (await response.json()) as {      organization: { id: string; name: string; slug: string } | null;    };    if (!body.organization) throw new Error("identity has no active organization");    return body.organization;  }); const createOrganization = (baseUrl: string, cookie: string, name: string) =>  Effect.promise(async () => {    const response = await fetch(new URL("/api/auth/create-organization", baseUrl), {      method: "POST",      headers: {        "content-type": "application/json",        cookie,        ...originHeaders(baseUrl),      },      body: JSON.stringify({ name }),    });    if (!response.ok) {      throw new Error(`/api/auth/create-organization failed (${response.status})`);    }    const session = cookiePair(response, "wos-session");    if (!session) throw new Error("create organization did not refresh the session");    const org = (await response.json()) as { id: string; name: string; slug: string };    return { org, session };  }); const oauthIntegrationSpec = (oauth: {  readonly authorizationEndpoint: string;  readonly tokenEndpoint: string;}) =>  ({    spec: {      kind: "blob" as const,      value: JSON.stringify({        openapi: "3.0.3",        info: { title: "OAuth org scope", version: "1.0.0" },        paths: {          "/me": {            get: {              operationId: "getMe",              responses: { "200": { description: "the caller" } },            },          },        },      }),    },    baseUrl: "http://127.0.0.1:59999",    authenticationTemplate: [      {        slug: "oauth",        kind: "oauth2" as const,        authorizationUrl: oauth.authorizationEndpoint,        tokenUrl: oauth.tokenEndpoint,        scopes: ["read"],      },    ],  }) as const; scenario(  "OAuth callback · state-scoped org survives a callback while the session cookie points elsewhere",  {},  Effect.gen(function* () {    const target = yield* Target;    const { client: makeApiClient } = yield* Api;    const browser = yield* Browser;    const oauth = yield* serveOAuthTestServer();     const identity = yield* target.newIdentity();    const sessionA = cookieOf(identity);    const orgA = yield* activeOrg(target.baseUrl, sessionA);     const { org: orgB, session: sessionB } = yield* createOrganization(      target.baseUrl,      sessionA,      `OAuth Callback Org B ${randomBytes(3).toString("hex")}`,    );    expect(orgB.slug, "the test has two distinct org URLs").not.toBe(orgA.slug);     const client = yield* makeApiClient(api, identity);     const integration = IntegrationSlug.make(unique("oauthscope"));    yield* client.openapi.addSpec({      payload: { ...oauthIntegrationSpec(oauth), slug: integration },    });     const clientSlug = OAuthClientSlug.make(unique("oauthc"));    yield* client.oauth.createClient({      payload: {        owner: "org",        slug: clientSlug,        authorizationUrl: oauth.authorizationEndpoint,        tokenUrl: oauth.tokenEndpoint,        grant: "authorization_code",        clientId: "test-client",        clientSecret: "test-secret",      },    });     let callback = new URL("http://invalid.example");    let providerAuthorizeUrl = new URL("http://invalid.example");    yield* browser.session(identity, async ({ page, step }) => {      await installSameWindowOAuthPopup(page);       await step("Land in the original organization", async () => {        await visit(page, `/${orgA.slug}`);        await expectOrgShell(page, orgA);      });       await step("The browser session is switched to another organization", async () => {        await setWorkosSessionCookie(page, target.baseUrl, sessionB);        await visit(page, `/${orgB.slug}`);        await expectOrgShell(page, orgB);      });       await step("Start OAuth from the original organization's add-connection flow", async () => {        // Land WITHOUT ?addAccount=1 and let the org scope settle first. The        // session cookie still points at org B, so this navigation bounces:        // first paint under the cookie's org B, OrgSlugGate canonicalizes,        // then the URL-scoped /account/me settles auth back on org A. A modal        // auto-opened by the deep link would fire its /api/oauth/clients fetch        // DURING that transient org-B scope, get org B's empty app list, never        // refetch, and leave "Connect with OAuth" disabled forever (the flake        // this step used to have). Waiting for org A's shell before opening        // the modal makes every modal fetch run under the settled org A scope.        await visit(page, `/${orgA.slug}/integrations/${String(integration)}`);        await page.getByRole("button", { name: new RegExp(escapeRegExp(orgA.name)) }).waitFor({          timeout: 30_000,        });        await page.waitForURL((url) => url.pathname.startsWith(`/${orgA.slug}/`), {          timeout: 30_000,        });        await page.getByRole("button", { name: "Add connection" }).click();        await page.getByRole("heading", { name: /Add connection/ }).waitFor({          timeout: 30_000,        });        // The footer button enables once the registered OAuth app has loaded        // and is selected; wait for that state instead of racing the click        // against the apps fetch.        await page          .getByRole("button", { name: "Connect with OAuth", disabled: false })          .waitFor({ timeout: 30_000 });        // Armed BEFORE the click so the navigation can never outrun the wait.        const authorizeRequest = page.waitForRequest(          (request) => {            const url = new URL(request.url());            return url.origin === new URL(oauth.issuerUrl).origin && url.pathname === "/authorize";          },          { timeout: 30_000 },        );        // If the click throws first (e.g. the button never enables), the armed        // wait would otherwise time out later as an UNHANDLED rejection and        // pollute the run with phantom errors. Mark it handled here; the        // `await authorizeRequest` below still surfaces its failure on the        // success path.        authorizeRequest.catch(() => {});        await page.getByRole("button", { name: "Connect with OAuth" }).click();        providerAuthorizeUrl = new URL((await authorizeRequest).url());        await page.waitForURL(          (url) => url.origin === new URL(oauth.issuerUrl).origin && url.pathname === "/login",          { timeout: 30_000 },        );        const redirectUri = new URL(providerAuthorizeUrl.searchParams.get("redirect_uri") ?? "");        expect(redirectUri.pathname, "the provider redirects to the OAuth callback").toBe(          "/api/oauth/callback",        );        expect(redirectUri.search, "the provider-facing redirect_uri has no org query").toBe("");        expect(          providerAuthorizeUrl.searchParams.get("state"),          "OAuth state is present",        ).toBeTruthy();        await page.getByText("OAuth test login").waitFor();      });       await step("The provider returns to the OAuth callback", async () => {        const callbackUrl = await submitProviderLoginFromPage(page);        callback = new URL(callbackUrl);        const response = await visit(page, callbackUrl);        expect(response?.status(), "the callback renders its popup result page").toBe(200);      });       const body = (await page.locator("body").textContent())?.trim() ?? "";      expect(        body,        "the callback completes in the org where oauth.start stored the session",      ).toContain("Connected");      expect(body, "the callback did not fall through to the cookie-pinned org").not.toContain(        "OAuth session expired or not found",      );    });     expect(      new URL(providerAuthorizeUrl.searchParams.get("redirect_uri") ?? "").search,      "the provider-facing redirect_uri remains static",    ).toBe("");    expect(      callback.searchParams.has("executor_org"),      "the provider callback has no org query",    ).toBe(false);  }).pipe(Effect.scoped),);