// 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 => { 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(""); }); }); }), ), );