247 lines · 11.6 KB · TypeScript
RawDownload
// Selfhost (browser): clearing the "Resource indicator" field on the// Register-OAuth-app form is a persisted decision, not a display quirk.//// The reported journey (#1789): an MCP server sits behind an authorization// server that rejects anonymous DCR, so automatic setup falls back to// bring-your-own-app registration. The form prefills the RFC 8707 resource// indicator with the MCP endpoint — but some authorization servers (Microsoft// Entra v2, AADSTS9010010) reject any request that carries `resource`, so the// user clears the field. The product guarantee under test://   1. blank normalizes to null and persists as absent,//   2. the authorize request the server receives carries NO resource parameter//      (and the token exchange doesn't either),//   3. scope discovery still works without a persisted resource (the//      integration's own discovery URL takes over), and//   4. reopening the app's edit form shows the field still empty — clearing//      stuck; nothing re-derived an endpoint over the intentional absence.import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest";import { Effect } from "effect";import { composePluginApi } from "@executor-js/api/server";import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";import { IntegrationSlug } 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 { visit } from "../src/surfaces/browser"; const api = composePluginApi([mcpHttpPlugin()] as const); // The scopes the test AS advertises in its RFC 8414 metadata. The client// persists no resource, so discovering these proves the connect path fell back// to the integration's own discovery URL for protected-resource metadata.const ADVERTISED_SCOPES = ["channels:history", "users:read"] as const; /** The test server's login page is plain text with Basic-auth POST — nothing a *  browser can click. Complete it out of band and hand back the callback URL. */const submitProviderLogin = async (loginUrl: string): Promise<string> => {  const credentials = Buffer.from("alice:password").toString("base64");  const response = await fetch(loginUrl, {    method: "POST",    redirect: "manual",    headers: { authorization: `Basic ${credentials}` },  });  const location = response.headers.get("location");  if (response.status !== 302 || !location) {    throw new Error(`provider login did not redirect (${response.status})`);  }  return new URL(location, loginUrl).toString();}; scenario(  "OAuth client · a cleared resource indicator persists and the authorize request omits RFC 8707",  { timeout: 240_000 },  Effect.scoped(    Effect.gen(function* () {      const target = yield* Target;      const browser = yield* Browser;      const { client: makeApiClient } = yield* Api;      const identity = yield* target.newIdentity();      const client = yield* makeApiClient(api, identity);       // An authorization server that rejects anonymous DCR (the Entra shape):      // /register answers 400, so the connect modal falls back to the manual      // Register-OAuth-app form — the surface under test.      const oauth = yield* serveOAuthTestServer({        scopes: [...ADVERTISED_SCOPES],        approveRedirectUri: () => false,      });       const slug = IntegrationSlug.make(`resource-clear-${randomBytes(4).toString("hex")}`);      // Lowercase+digits so slug === appName, and the actions menu is      // addressable as `Actions for ${appName}`.      const appName = `resourceclearapp${randomBytes(4).toString("hex")}`;       yield* client.mcp.addServer({        payload: {          transport: "remote",          name: `Resource clear ${String(slug)}`,          endpoint: oauth.mcpResourceUrl,          slug: String(slug),          authenticationTemplate: [{ kind: "oauth2" }],        },      });      yield* Effect.addFinalizer(() =>        client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore),      );      // The app is registered through the browser mid-scenario; reap it by slug      // whatever owner the form saved it under.      yield* Effect.addFinalizer(() =>        client.oauth.listClients().pipe(          Effect.flatMap((clients) =>            Effect.forEach(              clients.filter((candidate) => String(candidate.slug) === appName),              (candidate) =>                client.oauth                  .removeClient({                    params: { slug: candidate.slug },                    payload: { owner: candidate.owner },                  })                  .pipe(Effect.ignore),            ),          ),          Effect.ignore,        ),      );      // The connection is minted through the popup with a server-derived name;      // list-and-remove rather than guessing it.      yield* Effect.addFinalizer(() =>        client.connections.list({ query: { integration: slug } }).pipe(          Effect.flatMap((connections) =>            Effect.forEach(connections, (connection) =>              client.connections                .remove({                  params: {                    owner: connection.owner,                    integration: connection.integration,                    name: connection.name,                  },                })                .pipe(Effect.ignore),            ),          ),          Effect.ignore,        ),      );       yield* browser.session(identity, async ({ page, step }) => {        await step("Automatic setup fails — the AS rejects dynamic registration", async () => {          await visit(page, `/integrations/${String(slug)}`);          await page.getByRole("button", { name: "Add connection" }).first().click();          await page.getByRole("heading", { name: /Add connection/ }).waitFor();          await page.getByRole("button", { name: "Connect", exact: true }).click();          // DCR 400s → the modal drops to the register-an-app recovery view.          await page            .getByRole("button", { name: "Manually register an app" })            .waitFor({ timeout: 30_000 });        });         await step("Register an app, clearing the prefilled resource indicator", async () => {          await page.getByRole("button", { name: "Manually register an app" }).click();          await page.getByRole("heading", { name: "Register OAuth app" }).waitFor();          // Prefilled endpoints collapse into a summary row; the resource          // indicator lives inside, so expand it the way a user would.          await page.getByRole("button", { name: "Endpoints set from" }).click();          // The field arrives prefilled with the MCP endpoint — clearing it is          // a deliberate choice, not a no-op on an empty field.          const resource = page.locator("#oauth-resource");          await expect.poll(() => resource.inputValue()).toBe(oauth.mcpResourceUrl);          await page.locator("#oauth-app-name").fill(appName);          await page.locator("#oauth-client-id").fill("test-client");          await page.locator("#oauth-client-secret").fill("test-secret");          await resource.fill("");          await page.getByRole("button", { name: "Register app", exact: true }).click();          await page            .getByRole("heading", { name: "Register OAuth app" })            .waitFor({ state: "hidden", timeout: 20_000 });        });         await step("Connect with the registered app and complete authorization", async () => {          const popupPromise = page.waitForEvent("popup", { timeout: 30_000 });          await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();          const popup = await popupPromise;          // The test AS login page is plain text driven by Basic-auth POST, so          // complete it out of band and drive the popup to the callback — the          // same journey a user's click-through consent takes.          await popup.waitForURL(/\/login\?/, { timeout: 30_000 });          const callbackUrl = await submitProviderLogin(popup.url());          await popup.goto(callbackUrl);          await page.getByText("Connection added", { exact: true }).waitFor({ timeout: 30_000 });        });      });       // The wire truth, from the authorization server's own request log: the      // authorize request carried NO RFC 8707 resource parameter — while scope      // discovery still produced the advertised scopes (the integration's      // discovery URL covered for the absent client resource).      const requests = yield* oauth.requests;      const authorize = requests.find(        (request) => request.method === "GET" && request.path === "/authorize",      );      expect(authorize, "the popup reached the authorize endpoint").toBeDefined();      expect(        "resource" in (authorize?.query ?? {}),        `the authorize request carries no resource parameter (query: ${JSON.stringify(          authorize?.query,        )})`,      ).toBe(false);      for (const scope of ADVERTISED_SCOPES) {        expect(          authorize?.query["scope"] ?? "",          "scope discovery still works without a persisted resource",        ).toContain(scope);      }      const tokenExchange = requests.find(        (request) =>          request.method === "POST" &&          request.path === "/token" &&          request.body.includes("grant_type=authorization_code"),      );      expect(tokenExchange, "the code was exchanged at the token endpoint").toBeDefined();      expect(        tokenExchange?.body.includes("resource="),        "the token exchange carries no resource parameter",      ).toBe(false);       // Blank persisted as ABSENT on the stored client, not as "".      const saved = (yield* client.oauth.listClients()).find(        (candidate) => String(candidate.slug) === appName,      );      expect(saved, "the browser-registered app is in the catalog").toBeDefined();      expect(saved?.resource ?? null, "a cleared resource persists as absent").toBeNull();       // Reopening the form: the cleared field STAYS empty. A DCR-capable method      // only shows the app picker after automatic setup falls back, so take the      // same path a returning user would.      yield* browser.session(identity, async ({ page, step }) => {        await step("Reach the app picker again through the failed automatic setup", async () => {          await visit(page, `/integrations/${String(slug)}`);          await page.getByRole("button", { name: "Add connection" }).first().click();          await page.getByRole("heading", { name: /Add connection/ }).waitFor();          await page.getByRole("button", { name: "Connect", exact: true }).click();          await page            .getByRole("button", { name: `Actions for ${appName}` })            .waitFor({ timeout: 30_000 });        });         await step("The reopened app shows an empty resource indicator", async () => {          await page.getByRole("button", { name: `Actions for ${appName}` }).click();          await page.getByRole("menuitem", { name: "Edit" }).click();          await page.getByText(`Edit ${appName}`).waitFor();          // The prefill has landed once the stored client id is shown.          await expect            .poll(() => page.locator("#oauth-client-id").inputValue())            .toBe("test-client");          // The stored endpoints collapse here too; expand to reach the field.          await page.getByRole("button", { name: "Endpoints set from" }).click();          expect(            await page.locator("#oauth-resource").inputValue(),            "the cleared resource indicator stays empty on reopen",          ).toBe("");        });      });    }),  ),);