diff --git a/.changeset/oauth-resource-explicit-absence.md b/.changeset/oauth-resource-explicit-absence.md new file mode 100644 index 000000000..8d556a88c --- /dev/null +++ b/.changeset/oauth-resource-explicit-absence.md @@ -0,0 +1,17 @@ +--- +"executor": patch +"@executor-js/react": patch +--- + +**An OAuth app can now be registered without an RFC 8707 resource, and that absence holds on every request** + +Microsoft Entra v2 rejects any authorization request that carries both a v2 `scope` (such as `https://api.fabric.microsoft.com/.default`) and the RFC 8707 `resource` parameter, failing with `AADSTS9010010` before the consent screen. Executor made that unavoidable for MCP servers behind Entra: registering an app for an MCP integration always derived the MCP endpoint as the resource, the form had no field to change it, and so every request carried the parameter Entra rejects. + +The register/edit OAuth app form now shows the resource indicator. It is still prefilled for MCP servers — nothing changes for providers that accept the parameter — but it can be cleared, and a cleared value persists as "no resource". A resource-less app then omits `resource` on all four grants alike: the authorization request, the code exchange, token refresh, and client-credentials. Symmetry matters here — sending `resource` on authorize but not on the token request (or the reverse) would bind the two tokens to different audiences. + +Two adjacent gaps closed with it: + +- MCP scope discovery no longer depends on the app's resource. It now falls back to the integration's own discovery URL (the MCP endpoint), so clearing the resource does not break connecting. +- Token refresh for a first-party OAuth app dropped the app's configured resource, refreshing to a different audience than the original grant. It now sends the same resource the authorization request sent. + +Apps that keep their resource — the default for every discovered MCP server — behave exactly as before: the parameter is sent on every grant, as the MCP authorization spec expects. diff --git a/e2e/selfhost/oauth-resource-indicator-clear.test.ts b/e2e/selfhost/oauth-resource-indicator-clear.test.ts new file mode 100644 index 000000000..e49443677 --- /dev/null +++ b/e2e/selfhost/oauth-resource-indicator-clear.test.ts @@ -0,0 +1,247 @@ +// 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(""); + }); + }); + }), + ), +); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 9c6ecfd0c..d6a2b1c4b 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -2309,6 +2309,10 @@ export const createExecutor = { ), ); }); + +// --------------------------------------------------------------------------- +// RFC 8707 resource omission for a resource-less client (#1789) +// +// A client persisted with NO resource sends no `resource` parameter on ANY +// request — authorize, code exchange, refresh, client-credentials. Microsoft +// Entra v2 rejects requests that carry `resource` next to a v2 `scope` +// (AADSTS9010010), and the way out is a client whose resource is absent; that +// absence must hold on every grant, or the token audience diverges between +// authorize and token. The mirror-image assertions — a client WITH a resource +// sends it on authorize + exchange + refresh — live in the tests above. +// --------------------------------------------------------------------------- +describe("resource-less client sends no resource parameter (#1789)", () => { + it.effect("authorize, code exchange, and refresh all omit `resource`", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + // `resource: null` — explicitly none, not merely unset. + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + resource: null, + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + expect(new URL(started.authorizationUrl).searchParams.has("resource")).toBe(false); + + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + // Force expiry so the next execute refreshes. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { expires_at: Date.now() - 60_000 }, + }), + ); + const refreshed = (yield* executor.execute( + ToolAddress.make("tools.acme.org.main.whoami"), + {}, + )) as { token: string }; + expect(refreshed.token).toMatch(/^at_/); + + // What the authorization server actually SAW: the authorize request, + // the code exchange, and the refresh each carried no `resource`. + const requests = yield* server.requests; + const authorize = requests.find((r) => r.path === "/authorize" && r.method === "GET"); + expect(authorize).toBeDefined(); + expect(authorize?.query.resource ?? null).toBeNull(); + const exchange = requests.find( + (r) => r.path === "/token" && r.body.includes("grant_type=authorization_code"), + ); + expect(exchange).toBeDefined(); + expect(exchange?.body ?? "").not.toContain("resource="); + const refresh = requests.find( + (r) => r.path === "/token" && r.body.includes("grant_type=refresh_token"), + ); + expect(refresh).toBeDefined(); + expect(refresh?.body ?? "").not.toContain("resource="); + }), + ), + ); + + it.effect("client_credentials omits `resource` for a resource-less client", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "client_credentials", + clientId: "test-client", + clientSecret: "test-secret", + resource: null, + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("cc"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("connected"); + + const requests = yield* server.requests; + const grant = requests.find( + (r) => r.path === "/token" && r.body.includes("grant_type=client_credentials"), + ); + expect(grant).toBeDefined(); + expect(grant?.body ?? "").not.toContain("resource="); + }), + ), + ); + + it.effect("client_credentials sends `resource` when the client has one", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "client_credentials", + clientId: "test-client", + clientSecret: "test-secret", + resource: server.mcpResourceUrl, + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("cc"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("connected"); + + const requests = yield* server.requests; + const grant = requests.find( + (r) => r.path === "/token" && r.body.includes("grant_type=client_credentials"), + ); + expect(grant?.body).toContain(`resource=${encodeURIComponent(server.mcpResourceUrl)}`); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 65db26ddb..6e7aa4166 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -38,7 +38,7 @@ const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const; const makeScopePluginWithId = ( id: TId, config: { readonly scopes: readonly string[] | null }, - options: { readonly discoversScopes?: boolean } = {}, + options: { readonly discoversScopes?: boolean; readonly discoveryUrl?: string } = {}, ) => definePlugin(() => ({ id, @@ -62,7 +62,7 @@ const makeScopePluginWithId = ( kind: "oauth", template: String(TEMPLATE), ...(options.discoversScopes - ? { oauth: { discoveryUrl: `https://${id}.example/mcp` } } + ? { oauth: { discoveryUrl: options.discoveryUrl ?? `https://${id}.example/mcp` } } : {}), }, ]; @@ -495,19 +495,28 @@ describe("oauth.start integration-driven scopes", () => { ); it.effect( - "(h) for MCP, a client with no resource fails start (discovery cannot run without one)", + "(h) for MCP, a client with no resource still discovers scopes from the integration's discovery URL", () => Effect.scoped( Effect.gen(function* () { + // #1789 — a user may CLEAR the client's RFC 8707 resource (Entra v2 + // rejects the parameter). Scope discovery must not die with it: the + // integration's own discovery URL (the MCP endpoint) is probed + // instead, and the authorize request carries no `resource`. const server = yield* serveMetadataServer({ prm: { scopesSupported: ["read"] } }); const plugins = [ memoryCredentialsPlugin(), - makeMcpScopePlugin({ scopes: null }), + makeScopePluginWithId( + "mcp", + { scopes: null }, + { discoversScopes: true, discoveryUrl: server.mcpResourceUrl }, + ), ] as const; const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.mcp.seed(); - // No `resource` on the client — discovery has nothing to probe. + // No `resource` on the client — the wire parameter is absent by + // choice, while discovery still has the integration's URL. yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, @@ -518,17 +527,20 @@ describe("oauth.start integration-driven scopes", () => { clientSecret: "test-secret", }); - const exit = yield* Effect.exit( - executor.oauth.start({ - owner: "org", - client: CLIENT, - clientOwner: "org", - name: ConnectionName.make("main"), - integration: INTEG, - template: TEMPLATE, - }), - ); - expect(Exit.isFailure(exit)).toBe(true); + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["read"]); + // The cleared resource stays cleared on the wire. + expect(new URL(started.authorizationUrl).searchParams.has("resource")).toBe(false); }), ), ); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 62260893d..054bde564 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -167,10 +167,15 @@ const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAu * integration declares the scopes to request (`scopes`, possibly empty — an * empty set requests no scopes), or it declares none and the request scopes * are discovered from the server's metadata at connect (`discover`, used by - * MCP). The two are mutually exclusive by construction. */ + * MCP). The two are mutually exclusive by construction. + * + * `discover` carries the integration's own discovery URL (the MCP endpoint) + * so scope discovery does not depend on the CLIENT having a persisted RFC + * 8707 resource: a user may clear the client's resource (Entra v2 rejects + * the parameter, #1789) without losing scope discovery. */ export type OAuthScopePolicy = | { readonly kind: "scopes"; readonly scopes: readonly string[] } - | { readonly kind: "discover" }; + | { readonly kind: "discover"; readonly discoveryUrl: string }; /** Everything the OAuth service needs from the executor: fuma access for the * owned `oauth_client` / `oauth_session` tables, the default credential @@ -205,10 +210,12 @@ export interface OAuthServiceDeps { * DECLARES (e.g. an OpenAPI bundle's authentication-template scope union), * NOT the scopes frozen on a specific `oauth_client` row. These are * requested verbatim at connect (`start`); an empty set requests none. - * - `{ kind: "discover" }`: the integration declares no scopes, so `start` - * discovers the request scopes from the server's RFC 9728 / RFC 8414 - * metadata. Used by server-targeting integrations (MCP) whose scopes live - * on the server rather than in a template. + * - `{ kind: "discover", discoveryUrl }`: the integration declares no + * scopes, so `start` discovers the request scopes from the server's RFC + * 9728 / RFC 8414 metadata. Used by server-targeting integrations (MCP) + * whose scopes live on the server rather than in a template. + * `discoveryUrl` is the integration's protected-resource URL (the MCP + * endpoint), used when the client persists no resource. */ readonly resolveOAuthScopePolicy: ( integration: IntegrationSlug, @@ -1463,7 +1470,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const requestedScopes = scopePolicy.kind === "discover" ? yield* (() => { - const discovered = discoverScopesForResource(client.resource).pipe( + // Scope discovery reads protected-resource metadata. The client's + // persisted resource is the historical source and stays primary, + // but it is a WIRE parameter the user may clear (Entra v2 rejects + // `resource`, #1789) — the integration's own discovery URL then + // keeps scope discovery working for a resource-less client. + const discovered = discoverScopesForResource( + client.resource ?? scopePolicy.discoveryUrl, + ).pipe( Effect.mapError( (cause) => new OAuthStartError({ diff --git a/packages/react/src/components/oauth-client-form.tsx b/packages/react/src/components/oauth-client-form.tsx index 9d73c881d..c9b65d2a2 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -224,6 +224,13 @@ export function OAuthClientForm(props: { mode: "promiseExit", }); + // Blank means "send no RFC 8707 resource": persist null so every OAuth + // request (authorize, exchange, refresh, client-credentials) omits the + // parameter. Clearing must stick — no endpoint is re-derived over an + // intentional absence (Entra v2 rejects `resource`, #1789). + const normalizedResource = + resource == null || resource.trim().length === 0 ? null : resource.trim(); + const canSubmit = canSubmitOAuthClientForm({ submitting, name, @@ -250,7 +257,7 @@ export function OAuthClientForm(props: { authorizationUrl, tokenUrl, issuer: discoveredIssuer, - resource, + resource: normalizedResource, }); const showAppSetup = appSetup !== undefined && grant === "authorization_code" && !showAutoRegister; @@ -303,7 +310,7 @@ export function OAuthClientForm(props: { registrationEndpoint: registrationEndpoint.trim(), authorizationUrl: authorizationUrl.trim(), tokenUrl: tokenUrl.trim(), - resource, + resource: normalizedResource, // DCR sends the integration's declared scopes, or the discovered set when // none are declared, to the AS at registration (the app stores none). scopes: [...registrationScopes(declaredScopes, discoveredScopes)], @@ -337,7 +344,7 @@ export function OAuthClientForm(props: { grant, clientId: clientId.trim(), clientSecret: clientSecret.trim(), - resource, + resource: normalizedResource, // Editing preserves the app's already-recorded origin (via // `intentIntegration`, passed verbatim by the caller); a fresh // registration from an integration's dialog stamps recorded intent. @@ -613,6 +620,26 @@ export function OAuthClientForm(props: { /> + {/* RFC 8707 resource indicator. Prefilled for MCP servers; the field + exists so a user can CLEAR it — some authorization servers + (Microsoft Entra v2) reject requests that carry `resource`, and a + cleared value persists as "no resource" on every OAuth request. */} +
+ + ) => setResource(e.target.value)} + className="font-mono" + /> +
+ {endpointsKnown ? (