From 4da05aa2ee26596f18463ca22cbfc8bfc8f87345 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Thu, 25 Jun 2026 22:29:57 -0700 Subject: [PATCH] Fix #1120: stop the OAuth App picker leaking apps across integrations The "OAuth App" picker listed apps registered for other integrations (e.g. Datadog clients showing up under Atlassian). A discovery-only OAuth method declares no endpoints, so the picker hit its "no endpoints, show every app" branch and never consulted which integration an app was registered for. Record the integration each OAuth app was registered for (DCR and manual), threaded sdk -> api -> react via origin.integration, and match the picker by integration first, falling back to endpoint-domain matching only when endpoints are actually declared. Adds a selfhost e2e (oauth-picker-cross-integration) that registers a Datadog app, then opens the Atlassian picker and asserts the Atlassian app shows while the Datadog one does not. --- .../oauth-picker-cross-integration.test.ts | 166 ++++++++++++++++++ packages/core/api/src/handlers/oauth.ts | 1 + packages/core/api/src/oauth/api.ts | 9 +- packages/core/sdk/src/core-tools.ts | 5 +- packages/core/sdk/src/index.ts | 2 + packages/core/sdk/src/oauth-client.ts | 17 +- packages/core/sdk/src/oauth-service.ts | 20 +-- packages/core/sdk/src/shared.ts | 2 + packages/react/src/api/atoms.tsx | 3 +- .../src/components/add-account-modal.tsx | 12 +- .../src/components/oauth-client-form.tsx | 14 +- .../plugins/use-effective-oauth-client.tsx | 89 +++++++--- 12 files changed, 292 insertions(+), 48 deletions(-) create mode 100644 e2e/selfhost/oauth-picker-cross-integration.test.ts diff --git a/e2e/selfhost/oauth-picker-cross-integration.test.ts b/e2e/selfhost/oauth-picker-cross-integration.test.ts new file mode 100644 index 000000000..28cdf5784 --- /dev/null +++ b/e2e/selfhost/oauth-picker-cross-integration.test.ts @@ -0,0 +1,166 @@ +// Selfhost (browser): REPRO for issue #1120 ("Can't configure oauth mcp which +// used to work"). The user added an Atlassian remote-MCP source and the OAuth +// app picker listed a pile of unrelated *Datadog* apps. +// +// Why it happens: a remote MCP OAuth method carries ONLY a `discoveryUrl` +// (endpoints are discovered live, see describeMcpAuthMethods in the mcp plugin), +// never a static token/authorization URL. When transparent DCR falls back to the +// bring-your-own app picker, the picker filters candidate apps by endpoint root +// domain (`selectClientsForEndpoints`). With no declared endpoints the filter +// short-circuits to "every app is usable", so EVERY registered OAuth client the +// owner has, including DCR-minted apps from other integrations, leaks into this +// integration's picker. The `origin.integration` slug each client carries is +// never consulted. +// +// The fix: the picker matches apps to the integration by `origin.integration` +// (recorded on every app, DCR or manual), falling back to endpoint-domain +// matching only when endpoints are declared. So an app registered FOR Atlassian +// shows, and a Datadog app (different integration, no shared endpoint) does not. +// +// This scenario registers two apps for the same owner — one Datadog app with NO +// integration association, and one app registered FOR the Atlassian MCP source — +// then drives that source into the BYO picker (its discovery endpoint advertises +// no OAuth metadata, so DCR falls back) and asserts the Atlassian app appears +// while the Datadog app does not. +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 { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; +import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +scenario( + "OAuth picker · a different integration's app does not leak into an MCP source's picker", + { timeout: 180_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); + + // Selfhost shares one tenant across identities, so make every resource + // unique per run — including the Datadog host, which is what the picker + // row renders and what we assert against. + const id = randomBytes(4).toString("hex"); + const datadogSlug = OAuthClientSlug.make(`datadog-${id}`); + const datadogHost = `api-${id}.datadoghq.com`; + const atlassianSlug = OAuthClientSlug.make(`atlassian-app-${id}`); + const atlassianHost = `auth-${id}.atlassian.com`; + const mcpSlug = IntegrationSlug.make(`atlassian-mcp-${id}`); + + // An Atlassian-shaped remote MCP source: it speaks MCP but advertises NO + // OAuth metadata, so the connect-time DCR probe finds no registration + // endpoint and the modal falls back to the bring-your-own app picker. + const server = yield* serveMcpServer(() => + makeGreetingMcpServer({ name: `atlassian-mcp-${id}` }), + ); + + yield* Effect.ensuring( + Effect.gen(function* () { + // A Datadog OAuth app the owner registered for a DIFFERENT integration. + // Endpoints are on datadoghq.com — nothing to do with Atlassian, and no + // integration association — so it must never surface in this picker. + yield* client.oauth.createClient({ + payload: { + owner: "user", + slug: datadogSlug, + authorizationUrl: `https://${datadogHost}/oauth2/authorize`, + tokenUrl: `https://${datadogHost}/oauth2/token`, + grant: "authorization_code", + clientId: `datadog-client-${id}`, + clientSecret: "datadog-secret", + }, + }); + + // An app the owner registered FOR the Atlassian MCP source. It carries + // `origin.integration`, so the picker surfaces it even though the source + // declares no static endpoints to match on. + yield* client.oauth.createClient({ + payload: { + owner: "user", + slug: atlassianSlug, + authorizationUrl: `https://${atlassianHost}/authorize`, + tokenUrl: `https://${atlassianHost}/token`, + grant: "authorization_code", + clientId: `atlassian-client-${id}`, + clientSecret: "atlassian-secret", + originIntegration: mcpSlug, + }, + }); + + // Persistence guard: the integration association round-trips through the + // API so the picker can rely on it (isolates server-side persistence + // from the browser read path). + const listed = yield* client.oauth.listClients(); + const atlassianRow = listed.find((c) => String(c.slug) === String(atlassianSlug)); + expect( + atlassianRow?.origin.integration && String(atlassianRow.origin.integration), + "the manually-registered app records its integration association", + ).toBe(String(mcpSlug)); + + // The Atlassian MCP source as the add flow would leave it: OAuth only. + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: `Atlassian MCP ${id}`, + endpoint: server.endpoint, + slug: mcpSlug, + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Atlassian MCP source's connect modal", async () => { + await page.goto(`/integrations/${mcpSlug}`, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "Add connection" }).first().click(); + // OAuth is the only method; transparent DCR shows the Connect CTA. + await page.getByRole("button", { name: "Connect", exact: true }).waitFor(); + }); + + await step("DCR falls back to the bring-your-own app picker", async () => { + await page.getByRole("button", { name: "Connect", exact: true }).click(); + // The probe finds no OAuth metadata and DCR falls back to the app + // step. Either register affordance ("Register a new app" when an app + // matched, "Register app" in the empty state) means we're past DCR. + await page + .getByRole("button", { name: /^Register (app|a new app)$/ }) + .first() + .waitFor({ timeout: 30_000 }); + }); + + await step("The picker lists the Atlassian app, not the Datadog one", async () => { + const dialog = page.getByRole("dialog"); + expect( + await dialog.getByText(atlassianHost).count(), + `the app registered for this integration (${atlassianHost}) is offered`, + ).toBeGreaterThan(0); + expect( + await dialog.getByText(datadogHost).count(), + `a Datadog OAuth app (${datadogHost}) must not leak into Atlassian's picker`, + ).toBe(0); + }); + }); + }), + // Never leak the source or the apps into the shared selfhost tenant. + Effect.gen(function* () { + yield* client.mcp.removeServer({ params: { slug: mcpSlug } }).pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: datadogSlug }, payload: { owner: "user" } }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: atlassianSlug }, payload: { owner: "user" } }) + .pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index 924aff682..dab67175b 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -103,6 +103,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler clientId: payload.clientId, clientSecret: payload.clientSecret, resource: payload.resource ?? null, + origin: { kind: "manual", integration: payload.originIntegration ?? null }, }); return { client }; }), diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index ed9e74356..aa73d58a7 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -65,6 +65,10 @@ const CreateClientPayload = Schema.Struct({ clientId: Schema.String, clientSecret: Schema.String, resource: Schema.optional(Schema.NullOr(Schema.String)), + /** Integration this app is being registered for, recorded on the client so the + * connect picker can surface it for that integration (mirrors the DCR path). + * A manual app can still be reused across integrations. */ + originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)), }); const CreateClientResponse = Schema.Struct({ @@ -111,7 +115,10 @@ const OAuthClientSummaryResponse = Schema.Struct({ resource: Schema.optional(Schema.NullOr(Schema.String)), clientId: Schema.String, origin: Schema.Union([ - Schema.Struct({ kind: Schema.Literal("manual") }), + Schema.Struct({ + kind: Schema.Literal("manual"), + integration: Schema.optional(Schema.NullOr(IntegrationSlug)), + }), Schema.Struct({ kind: Schema.Literal("dynamic_client_registration"), integration: Schema.optional(Schema.NullOr(IntegrationSlug)), diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 042906557..bd9118d05 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -221,7 +221,10 @@ const OAuthClientOutput = Schema.Struct({ resource: Schema.optional(Schema.NullOr(Schema.String)), clientId: Schema.String, origin: Schema.Union([ - Schema.Struct({ kind: Schema.Literal("manual") }), + Schema.Struct({ + kind: Schema.Literal("manual"), + integration: Schema.optional(Schema.NullOr(Schema.String)), + }), Schema.Struct({ kind: Schema.Literal("dynamic_client_registration"), integration: Schema.optional(Schema.NullOr(Schema.String)), diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index bbf46064c..a8a86f8d4 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -228,7 +228,9 @@ export { type OAuthGrant, type OAuthAuthentication, type OAuthClient, + type OAuthClientOrigin, type OAuthClientSummary, + oauthClientOriginIntegration, type CreateOAuthClientInput, type RegisterDynamicClientInput, type ConnectResult, diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 3b56d9f05..d48ed0ee7 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -56,12 +56,27 @@ export interface OAuthClient { } export type OAuthClientOrigin = - | { readonly kind: "manual" } + | { + readonly kind: "manual"; + /** Integration this app was registered FOR, when known. A user can reuse + * one app across integrations, so this is a hint (the integration whose + * connect flow created it), not an exclusive binding. Used by the connect + * picker to surface this app for its integration even when the integration + * declares no static endpoints to match on (e.g. an MCP source). */ + readonly integration?: IntegrationSlug | null; + } | { readonly kind: "dynamic_client_registration"; readonly integration?: IntegrationSlug | null; }; +/** The integration an OAuth app was registered for, from either origin kind + * (null when unknown). Centralizes the "which integration owns this app" read so + * the picker and the duplicate-DCR guard agree. */ +export const oauthClientOriginIntegration = ( + origin: OAuthClientOrigin, +): IntegrationSlug | null => origin.integration ?? null; + export type CreateOAuthClientInput = OAuthClient & { readonly origin?: OAuthClientOrigin; }; diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 008719b75..382e72a61 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -229,14 +229,10 @@ const parseOAuthClientOrigin = (row: { readonly origin_kind?: unknown; readonly origin_integration?: unknown; }): OAuthClientOrigin => { + const integration = + row.origin_integration == null ? null : IntegrationSlug.make(String(row.origin_integration)); if (row.origin_kind === "dynamic_client_registration") { - return { - kind: "dynamic_client_registration", - integration: - row.origin_integration == null - ? null - : IntegrationSlug.make(String(row.origin_integration)), - }; + return { kind: "dynamic_client_registration", integration }; } const slug = row.slug == null ? "" : String(row.slug); const resource = row.resource == null ? "" : String(row.resource); @@ -246,9 +242,9 @@ const parseOAuthClientOrigin = (row: { /(^|[-_])mcp($|[-_])/.test(slug) && /(^|\/)mcp($|[/?#])/.test(resource) ) { - return { kind: "dynamic_client_registration", integration: null }; + return { kind: "dynamic_client_registration", integration }; } - return { kind: "manual" }; + return { kind: "manual", integration }; }; interface LoadedOAuthClient { @@ -404,11 +400,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { resource: input.resource ?? null, origin_kind: input.origin?.kind ?? "manual", origin_integration: - input.origin?.kind === "dynamic_client_registration" - ? input.origin.integration == null - ? null - : String(input.origin.integration) - : null, + input.origin?.integration == null ? null : String(input.origin.integration), created_at: now, }), ); diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index fe5546a90..a3729c7b8 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -106,7 +106,9 @@ export { type OAuthGrant, type OAuthAuthentication, type OAuthClient, + type OAuthClientOrigin, type OAuthClientSummary, + oauthClientOriginIntegration, type CreateOAuthClientInput, type RegisterDynamicClientInput, type ConnectResult, diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 9cc364fda..607e338db 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -460,6 +460,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( readonly grant: OAuthGrant; readonly clientId: string; readonly resource?: string | null; + readonly originIntegration?: IntegrationSlug | null; }; }, ) => @@ -472,7 +473,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( tokenUrl: arg.payload.tokenUrl, resource: arg.payload.resource ?? null, clientId: arg.payload.clientId, - origin: { kind: "manual" }, + origin: { kind: "manual", integration: arg.payload.originIntegration ?? null }, }; const index = rows.findIndex( (client) => client.owner === summary.owner && client.slug === summary.slug, diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 918cb06fb..ceb12316d 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -827,7 +827,9 @@ function AddAccountModalView(props: AddAccountModalProps) { loading: oauthLoading, endpointMatched: oauthEndpointMatched, displayRegisterCTA: oauthDisplayRegisterCTA, + allClients: oauthAllClients, } = useOAuthClientsForIntegration({ + integration, tokenUrl: method?.oauth?.tokenUrl, authorizationUrl: method?.oauth?.authorizationUrl, }); @@ -1201,9 +1203,8 @@ function AddAccountModalView(props: AddAccountModalProps) {
- String(app.slug), - )} + originIntegration={integration} + existingSlugs={oauthAllClients.map((app: OAuthClientOption) => String(app.slug))} fixedSlug={editingClient.slug} fixedOwner={editingClient.owner} prefill={{ @@ -1230,9 +1231,8 @@ function AddAccountModalView(props: AddAccountModalProps) {
- String(app.slug), - )} + originIntegration={integration} + existingSlugs={oauthAllClients.map((app: OAuthClientOption) => String(app.slug))} prefill={{ authorizationUrl: oauthHandoffPrefill?.authorizationUrl ?? method.oauth?.authorizationUrl, diff --git a/packages/react/src/components/oauth-client-form.tsx b/packages/react/src/components/oauth-client-form.tsx index 9df67d955..bc17c86c9 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -1,7 +1,12 @@ import { useMemo, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; -import { OAuthClientSlug, type OAuthGrant, type Owner } from "@executor-js/sdk/shared"; +import { + type IntegrationSlug, + OAuthClientSlug, + type OAuthGrant, + type Owner, +} from "@executor-js/sdk/shared"; import { toast } from "sonner"; import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms"; @@ -53,6 +58,10 @@ export interface OAuthClientFormPrefill { export function OAuthClientForm(props: { /** Human label for the integration this app backs (used in toasts + default name). */ readonly integrationName: string; + /** Slug of the integration this app is registered for. Recorded on the client + * (`origin.integration`) so the connect picker surfaces it for this integration + * even when the integration declares no static endpoints (e.g. an MCP source). */ + readonly originIntegration?: IntegrationSlug; /** Existing client slugs, so the generated slug stays unique across apps. */ readonly existingSlugs: readonly string[]; /** Endpoints/scopes declared by the integration's OAuth method. */ @@ -71,6 +80,7 @@ export function OAuthClientForm(props: { }) { const { integrationName, + originIntegration, existingSlugs, prefill, fixedSlug, @@ -195,6 +205,7 @@ export function OAuthClientForm(props: { tokenEndpointAuthMethodsSupported: authMethods, clientName: name.trim(), redirectUri: oauthCallbackUrl(), + originIntegration: originIntegration ?? null, }, reactivityKeys: oauthClientWriteKeys, }); @@ -223,6 +234,7 @@ export function OAuthClientForm(props: { clientId: clientId.trim(), clientSecret: clientSecret.trim(), resource, + originIntegration: originIntegration ?? null, }, reactivityKeys: oauthClientWriteKeys, }); diff --git a/packages/react/src/plugins/use-effective-oauth-client.tsx b/packages/react/src/plugins/use-effective-oauth-client.tsx index 963e9caf2..df7c763f1 100644 --- a/packages/react/src/plugins/use-effective-oauth-client.tsx +++ b/packages/react/src/plugins/use-effective-oauth-client.tsx @@ -1,6 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared"; +import { + type IntegrationSlug, + OAuthClientSlug, + type OAuthClientOrigin, + type Owner, +} from "@executor-js/sdk/shared"; import { getDomain } from "tldts"; import { oauthClientsOptimisticAtom } from "../api/atoms"; @@ -10,11 +15,16 @@ import { oauthClientsOptimisticAtom } from "../api/atoms"; // // An owner can register MANY apps; each is a distinct owner-scoped `oauth_client` // row with its own slug. The connect flow lists the apps usable for an -// integration (owner-visible clients whose OAuth endpoints share a registrable -// root domain with the integration's declared endpoints when known) and lets the -// user pick one or register a new one. User-owned apps are listed before -// workspace ones. When NOTHING matches a declared endpoint, the picker shows an -// empty state + a "register an app" CTA rather than unrelated providers' apps. +// integration and lets the user pick one or register a new one. An app is usable +// for an integration when EITHER: +// - it was registered for that integration (`origin.integration`), OR +// - its OAuth endpoints share a registrable root domain with the integration's +// declared endpoints (so one app can be reused across sibling integrations). +// User-owned apps are listed before workspace ones. When the integration declares +// no endpoints AND owns no app (e.g. an MCP source whose endpoints are discovered +// at connect time), the picker shows an empty state + a "register an app" CTA +// rather than every unrelated provider's app — that broad leak was the bug behind +// "Datadog apps showing under Atlassian". // --------------------------------------------------------------------------- export interface OAuthClientOption { @@ -24,6 +34,10 @@ export interface OAuthClientOption { readonly authorizationUrl: string; readonly tokenUrl: string; readonly clientId: string; + /** Provenance — `origin.integration` is the integration this app was registered + * for (DCR or manual), which the picker uses to surface the app for that + * integration even when no static endpoints are declared to match on. */ + readonly origin?: OAuthClientOrigin; } const hostOf = (url: string): string | undefined => { @@ -39,12 +53,18 @@ const hostOf = (url: string): string | undefined => { * `accounts.google.com` → `google.com`. Falls back to the full host for * localhost / IP literals (where `tldts.getDomain` returns null) so local-dev * MCP servers still match by exact host. Returns undefined for unparseable URLs. */ -const getRootDomain = (url: string): string | undefined => { +export const getRootDomain = (url: string): string | undefined => { const root = getDomain(url); if (root) return root.toLowerCase(); return hostOf(url); }; +/** The integration an app was registered for, from either origin kind (or + * undefined). Used to surface an app for its integration even when no static + * endpoints are declared to match on. */ +const appIntegration = (app: OAuthClientOption): string | undefined => + app.origin?.integration == null ? undefined : String(app.origin.integration); + export interface UseOAuthClientsResult { /** Apps usable for this integration, user-owned first. When an endpoint was * declared but nothing matched, this is EMPTY (the unmatched apps move to @@ -73,6 +93,10 @@ export interface UseOAuthClientsResult { * an endpoint was declared and nothing matched. Equals `!endpointMatched` * once loaded. */ readonly displayRegisterCTA: boolean; + /** Every owner-visible app, UNFILTERED. Callers that need the whole catalog + * (slug-collision avoidance when minting, duplicate-DCR reuse detection) read + * this rather than the integration-filtered `clients`. */ + readonly allClients: readonly OAuthClientOption[]; } /** Sort apps user-owned first (so the user's own apps surface before shared @@ -83,44 +107,59 @@ const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClient ); /** - * Pure matcher (no React/atoms) — split owner-visible apps into the ones that - * match the integration's declared OAuth endpoints and the ones that don't. + * Pure matcher (no React/atoms) — split owner-visible apps into the ones usable + * for an integration and the ones that aren't. * - * Matching is by REGISTRABLE ROOT DOMAIN ("tld+1"). When the integration - * declares a token endpoint, an app must match by token endpoint root; the token - * endpoint is what the SDK will call during code exchange/refresh and avoids - * authorize-root coincidences. When only an authorization endpoint is declared, - * the authorize root is used as the fallback compatibility signal. Unrelated - * providers (different root) never match. + * An app is usable when EITHER signal holds: + * - INTEGRATION: it was registered for this integration (`origin.integration` + * equals `integration`). This is the only signal an MCP source has, since its + * OAuth endpoints are discovered at connect time and never declared. + * - ENDPOINT: the integration declares an endpoint and the app's endpoints share + * a REGISTRABLE ROOT DOMAIN ("tld+1"). The token endpoint is preferred (it is + * what the SDK calls during code exchange/refresh, avoiding authorize-root + * coincidences); the authorize root is the fallback when only it is declared. + * This lets one app be reused across sibling integrations of one provider. * - * When the integration declares no endpoints, every app is "matched" (no filter). + * When NEITHER an integration nor any endpoint is known (no context at all), the + * filter cannot discriminate and every app is "matched" (legacy fall-through). + * Crucially, when an integration IS known but declares no endpoints, apps are + * matched by integration only — unrelated providers' apps no longer leak in. */ export function selectClientsForEndpoints( all: readonly OAuthClientOption[], - endpoints: { readonly tokenUrl?: string; readonly authorizationUrl?: string }, + endpoints: { + readonly integration?: string; + readonly tokenUrl?: string; + readonly authorizationUrl?: string; + }, ): { readonly matched: readonly OAuthClientOption[]; readonly unmatched: readonly OAuthClientOption[]; readonly endpointMatched: boolean; } { + const wantedIntegration = endpoints.integration; const wantedTokenRoot = endpoints.tokenUrl ? getRootDomain(endpoints.tokenUrl) : undefined; const wantedAuthorizationRoot = endpoints.authorizationUrl ? getRootDomain(endpoints.authorizationUrl) : undefined; - // No declared endpoints → no filter; every app is usable. - if (!wantedTokenRoot && !wantedAuthorizationRoot) { + // No discriminating signal at all → no filter; every app is usable. + if (!wantedIntegration && !wantedTokenRoot && !wantedAuthorizationRoot) { return { matched: sortUserFirst(all), unmatched: [], endpointMatched: true }; } const matched: OAuthClientOption[] = []; const unmatched: OAuthClientOption[] = []; for (const app of all) { + const fitsIntegration = + wantedIntegration !== undefined && appIntegration(app) === wantedIntegration; const appTokenRoot = getRootDomain(app.tokenUrl); const appAuthorizationRoot = getRootDomain(app.authorizationUrl); - const fits = wantedTokenRoot + const fitsEndpoint = wantedTokenRoot ? appTokenRoot === wantedTokenRoot - : appAuthorizationRoot === wantedAuthorizationRoot || - appTokenRoot === wantedAuthorizationRoot; - if (fits) matched.push(app); + : wantedAuthorizationRoot + ? appAuthorizationRoot === wantedAuthorizationRoot || + appTokenRoot === wantedAuthorizationRoot + : false; + if (fitsIntegration || fitsEndpoint) matched.push(app); else unmatched.push(app); } return { @@ -131,6 +170,7 @@ export function selectClientsForEndpoints( } export function useOAuthClientsForIntegration(opts: { + readonly integration?: IntegrationSlug; readonly tokenUrl?: string; readonly authorizationUrl?: string; }): UseOAuthClientsResult { @@ -146,11 +186,13 @@ export function useOAuthClientsForIntegration(opts: { loading: true, endpointMatched: true, displayRegisterCTA: false, + allClients: [], }; } const all = clientsResult.value as readonly OAuthClientOption[]; const { matched, unmatched, endpointMatched } = selectClientsForEndpoints(all, { + integration: opts.integration ? String(opts.integration) : undefined, tokenUrl: opts.tokenUrl, authorizationUrl: opts.authorizationUrl, }); @@ -165,6 +207,7 @@ export function useOAuthClientsForIntegration(opts: { loading: false, endpointMatched, displayRegisterCTA: !endpointMatched, + allClients: all, }; }