History

Launch built-in Google OAuth for Calendar and Sheets

Rhys Sullivan committed 2 months ago

Commit ac3bea51 parent 67ee8a013 files · +313 −10Browse files

Unified · Split

13 files
  1. apps/cloud/src/engine/execution-stack.ts +13 −0
  2. apps/marketing/src/pages/privacy.astro +20 −0
  3. e2e/scenarios/first-party-oauth.test.ts +117 −0
  4. e2e/setup/cloud.boot.ts +5 −0
  5. packages/core/api/src/oauth/api.ts +3 −1
  6. packages/core/sdk/src/oauth-client.ts +20 −0
  7. packages/core/sdk/src/oauth-first-party.test.ts +36 −2
  8. packages/core/sdk/src/oauth-service.ts +36 −1
  9. packages/plugins/openapi/src/providers/google/index.ts +1 −0
  10. packages/plugins/openapi/src/providers/google/presets.ts +9 −4
  11. packages/react/src/components/add-account-modal.tsx +1 −0
  12. packages/react/src/plugins/use-effective-oauth-client.test.ts +23 −0
  13. packages/react/src/plugins/use-effective-oauth-client.tsx +29 −2
apps/cloud/src/engine/execution-stack.ts+13−0
@@ -42,6 +42,7 @@ import { · 41 unmodified lines
4242 PluginsProvider,
4343 collectTables,
4444 } from "@executor-js/api/server";
45+import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
4546 import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
4647 import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk";
4748
@@ -88,6 +89,17 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 40 unmodified lines
8889 */
8990 export const CLOUD_MOUNT_PREFIX = "/api" as const;
9091
92+// Initial Google launch boundary. Calendar + Sheets are sensitive scopes but
93+// not restricted Workspace scopes; Gmail and account-wide Drive remain absent
94+// until their separate verification/security work is complete. The same scope
95+// source builds the catalog auth templates, preventing config drift.
96+const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [
97+ ...new Set([
98+ ...googleCatalogOAuthScopesForPreset("google-calendar"),
99+ ...googleCatalogOAuthScopesForPreset("google-sheets"),
100+ ]),
101+];
102+
91103 // Executor-owned provider apps, enabled per provider by setting BOTH env vars
92104 // (id + secret). Each provider-side registration must list
93105 // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
@@ -119,6 +131,7 @@ const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] = · 25 unmodified lines
119131 tokenUrl: "https://oauth2.googleapis.com/token",
120132 clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID,
121133 clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET,
134+ allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES,
122135 },
123136 ]
124137 : []),
apps/marketing/src/pages/privacy.astro+20−0
@@ -53,6 +53,26 @@ import LegalLayout from "../components/LegalLayout.astro"; · 52 unmodified lines
5353 a call, run code, or execute a workflow.
5454 </p>
5555
56+ <h3>Google Workspace API data</h3>
57+ <p>
58+ If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you
59+ request through that integration. Depending on the service and permissions you choose, this may include accessing
60+ or modifying Google Calendar events, Google Sheets spreadsheets, or other Google Workspace content. Executor stores
61+ OAuth credentials and connection metadata so the integration can continue working, and processes Google Workspace
62+ content when carrying out your requested tool calls and returning their results to you or the agent you directed to
63+ make the call.
64+ </p>
65+ <p>
66+ We do not use Google Workspace API data for advertising or to train generalized artificial-intelligence or
67+ machine-learning models. We disclose it only as needed to provide the user-requested integration, operate and secure
68+ the Services, comply with law, or as otherwise described in this Policy with the user&apos;s consent.
69+ </p>
70+ <p>
71+ Executor&apos;s use and transfer of information received from Google Workspace APIs adheres to the
72+ <a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>,
73+ including its Limited Use requirements.
74+ </p>
75+
5676 <h3>Website inputs and support communications</h3>
5777 <p>
5878 If you submit a URL to our website&apos;s API detection tool, contact us by email, or otherwise communicate with us,
e2e/scenarios/first-party-oauth.test.ts+117−0
@@ -62,12 +62,45 @@ const githubShapedIntegrationSpec = { · 61 unmodified lines
6262 ],
6363 } as const;
6464
65+const googleShapedIntegrationSpec = (scopes: readonly string[]) => ({
66+ spec: {
67+ kind: "blob" as const,
68+ value: JSON.stringify({
69+ openapi: "3.0.3",
70+ info: { title: "Google-shaped API", version: "1.0.0" },
71+ paths: {
72+ "/resource": {
73+ get: {
74+ operationId: "getResource",
75+ tags: ["default"],
76+ responses: { "200": { description: "a Google resource" } },
77+ },
78+ },
79+ },
80+ }),
81+ },
82+ baseUrl: "https://www.googleapis.com",
83+ authenticationTemplate: [
84+ {
85+ slug: "oauth",
86+ kind: "oauth2" as const,
87+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
88+ tokenUrl: "https://oauth2.googleapis.com/token",
89+ scopes,
90+ },
91+ ],
92+});
93+
6594 scenario(
6695 "First-party OAuth · the host-declared GitHub app is listed and drives the authorize redirect",
6796 {},
6897 Effect.scoped(
6998 Effect.gen(function* () {
7099 const target = yield* Target;
100+ // First-party registrations are a cloud-host capability. This scenario
101+ // intentionally does not apply to self-host, whose operator supplies
102+ // their own OAuth apps through its existing registration flow.
103+ if (target.name !== "cloud") return;
71104 const { client: makeApiClient } = yield* Api;
72105 const identity = yield* target.newIdentity();
73106 const client = yield* makeApiClient(api, identity);
@@ -133,3 +166,87 @@ scenario( · 59 unmodified lines
133166 }),
134167 ),
135168 );
169+
170+scenario(
171+ "First-party OAuth · Google offers Calendar and Sheets but refuses Gmail scopes",
172+ {},
173+ Effect.scoped(
174+ Effect.gen(function* () {
175+ const target = yield* Target;
176+ if (target.name !== "cloud") return;
177+ const { client: makeApiClient } = yield* Api;
178+ const identity = yield* target.newIdentity();
179+ const client = yield* makeApiClient(api, identity);
180+
181+ const clients = yield* client.oauth.listClients();
182+ const google = clients.find((candidate) => String(candidate.slug) === "first-party:google");
183+ expect(google, "the env-declared first-party Google app is listed").toBeDefined();
184+ expect(google?.origin.kind).toBe("first_party");
185+ if (google?.origin.kind !== "first_party") return;
186+ expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar");
187+ expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets");
188+ expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/");
189+ expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive");
190+
191+ const calendar = IntegrationSlug.make(unique("google_calendar"));
192+ yield* client.openapi.addSpec({
193+ payload: {
194+ ...googleShapedIntegrationSpec([
195+ "openid",
196+ "email",
197+ "profile",
198+ "https://www.googleapis.com/auth/calendar",
199+ ]),
200+ slug: calendar,
201+ },
202+ });
203+ const started = yield* client.oauth.start({
204+ payload: {
205+ client: OAuthClientSlug.make("first-party:google"),
206+ clientOwner: "org",
207+ owner: "org",
208+ name: ConnectionName.make("calendar"),
209+ integration: calendar,
210+ template: AuthTemplateSlug.make("oauth"),
211+ },
212+ });
213+ expect(started.status).toBe("redirect");
214+ const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : "";
215+ const authorize = new URL(authorizationUrl);
216+ expect(authorize.origin + authorize.pathname).toBe(
217+ "https://accounts.google.com/o/oauth2/v2/auth",
218+ );
219+ expect(authorize.searchParams.get("client_id")).toBe("e2e-first-party-google");
220+ expect(authorize.searchParams.get("access_type")).toBe("offline");
221+ expect(new Set(authorize.searchParams.get("scope")?.split(" ") ?? [])).toEqual(
222+ new Set(["openid", "email", "profile", "https://www.googleapis.com/auth/calendar"]),
223+ );
224+
225+ const gmail = IntegrationSlug.make(unique("google_gmail"));
226+ yield* client.openapi.addSpec({
227+ payload: {
228+ ...googleShapedIntegrationSpec([
229+ "openid",
230+ "email",
231+ "profile",
232+ "https://mail.google.com/",
233+ ]),
234+ slug: gmail,
235+ },
236+ });
237+ const blocked = yield* client.oauth
238+ .start({
239+ payload: {
240+ client: OAuthClientSlug.make("first-party:google"),
241+ clientOwner: "org",
242+ owner: "org",
243+ name: ConnectionName.make("gmail"),
244+ integration: gmail,
245+ template: AuthTemplateSlug.make("oauth"),
246+ },
247+ })
248+ .pipe(Effect.flip);
249+ expect(blocked).toBeDefined();
250+ }),
251+ ),
252+);
e2e/setup/cloud.boot.ts+5−0
@@ -104,6 +104,11 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> · 103 unmodified lines
104104 // complete authorize → token dance instead of stopping at github.com.
105105 FIRST_PARTY_GITHUB_AUTHORIZE_URL: process.env.FIRST_PARTY_GITHUB_AUTHORIZE_URL,
106106 FIRST_PARTY_GITHUB_TOKEN_URL: process.env.FIRST_PARTY_GITHUB_TOKEN_URL,
107+ // Fake Google registration for redirect-only first-party coverage. The
108+ // scenario never visits Google or exchanges a code; it proves cloud env,
109+ // scope policy, and authorization URL construction end to end.
110+ FIRST_PARTY_GOOGLE_CLIENT_ID: "e2e-first-party-google",
111+ FIRST_PARTY_GOOGLE_CLIENT_SECRET: "e2e-first-party-google-secret",
107112 // Shrink the per-org hourly execution cap (prod default 1000) to a number
108113 // the rate-limit-backstop scenario can actually exhaust with real
109114 // executions — but see execution-limits.ts: it must stay above every other
packages/core/api/src/oauth/api.ts+3−1
@@ -123,10 +123,12 @@ const OAuthClientSummaryResponse = Schema.Struct({ · 122 unmodified lines
123123 }),
124124 /** Host-operated app declared in executor config — every org connects
125125 * through it; nothing to paste. `integrations` ranks it as the default
126− * for those integrations in the picker. */
126+ * for those integrations; `allowedScopes` is the host-enforced scope
127+ * boundary the picker mirrors before offering it. */
127128 Schema.Struct({
128129 kind: Schema.Literal("first_party"),
129130 integrations: Schema.optional(Schema.Array(IntegrationSlug)),
131+ allowedScopes: Schema.optional(Schema.Array(Schema.String)),
130132 }),
131133 ]),
132134 });
packages/core/sdk/src/oauth-client.ts+20−0
@@ -88,6 +88,10 @@ export type OAuthClientOrigin = · 87 unmodified lines
8888 * one Google app deliberately backs gmail, calendar, drive, …. */
8989 readonly kind: "first_party";
9090 readonly integrations?: readonly IntegrationSlug[];
91+ /** OAuth scopes this deployment permits the app to request. Omitted means
92+ * the provider app is unrestricted; present means every requested scope
93+ * must be in this set. This is public policy metadata, not a secret. */
94+ readonly allowedScopes?: readonly string[];
9195 };
9296
9397 /** Slug namespace separating config-declared first-party apps from stored
@@ -122,8 +126,24 @@ export interface FirstPartyOAuthClientConfig { · 28 unmodified lines
122126 * exact-match default for those integrations. Endpoint-host matching still
123127 * applies when omitted. */
124128 readonly integrations?: readonly IntegrationSlug[];
129+ /** OAuth scopes this deployment permits the app to request. Omit to allow
130+ * every scope declared by a matching integration. When present, OAuth start
131+ * and completion fail unless every requested scope belongs to this set. */
132+ readonly allowedScopes?: readonly string[];
125133 }
126134
135+/** Whether a first-party app may request an integration's complete OAuth scope
136+ * set. An omitted policy preserves provider-wide clients; an explicit policy
137+ * is fail-closed and requires every requested scope to be listed. */
138+export const firstPartyOAuthClientAllowsScopes = (
139+ config: Pick<FirstPartyOAuthClientConfig, "allowedScopes">,
140+ requestedScopes: readonly string[],
141+): boolean => {
142+ if (config.allowedScopes === undefined) return true;
143+ const allowed = new Set(config.allowedScopes);
144+ return requestedScopes.every((scope) => allowed.has(scope));
145+};
146+
127147 export type CreateOAuthClientInput = OAuthClient & {
128148 /** Stored-row origins only — `first_party` is config-declared, never created
129149 * through this surface (the service also rejects the slug namespace). */
packages/core/sdk/src/oauth-first-party.test.ts+36−2
@@ -176,7 +176,9 @@ describe("first-party oauth clients", () => { · 175 unmodified lines
176176 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
177177 const { executor } = yield* makeTestWorkspaceHarness({
178178 plugins,
179− firstPartyOAuthClients: [firstPartyClientFor(server)],
179+ firstPartyOAuthClients: [
180+ { ...firstPartyClientFor(server), allowedScopes: ["openid", "read"] },
181+ ],
180182 });
181183 yield* executor.acme.seed();
182184
@@ -193,13 +195,45 @@ describe("first-party oauth clients", () => { · 10 unmodified lines
193195 const clients = yield* executor.oauth.listClients();
194196 expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]);
195197 const firstParty = clients[0]!;
196− expect(firstParty.origin).toEqual({ kind: "first_party", integrations: [INTEG] });
198+ expect(firstParty.origin).toEqual({
199+ kind: "first_party",
200+ integrations: [INTEG],
201+ allowedScopes: ["openid", "read"],
202+ });
197203 expect(firstParty.clientId).toBe("test-client");
198204 expect("clientSecret" in firstParty).toBe(false);
199205 }),
200206 ),
201207 );
202208
209+ it.effect("a scope-limited first-party app rejects an integration outside its policy", () =>
210+ Effect.scoped(
211+ Effect.gen(function* () {
212+ const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] });
213+ const { executor } = yield* makeTestWorkspaceHarness({
214+ plugins,
215+ firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }],
216+ });
217+ yield* executor.acme.seed(["write"]);
218+
219+ const error = yield* executor.oauth
220+ .start({
221+ owner: "org",
222+ client: FIRST_PARTY,
223+ clientOwner: "org",
224+ name: ConnectionName.make("blocked"),
225+ integration: INTEG,
226+ template: TEMPLATE,
227+ })
228+ .pipe(Effect.flip);
229+
230+ expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true);
231+ const startError = error as OAuthStartError;
232+ expect(startError.message).toContain("not enabled for integration acme");
233+ }),
234+ ),
235+ );
236+
203237 it.effect("createClient and removeClient reject the reserved first-party namespace", () =>
204238 Effect.scoped(
205239 Effect.gen(function* () {
packages/core/sdk/src/oauth-service.ts+36−1
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
3636 OAuthRegisterDynamicError,
3737 OAuthSessionNotFoundError,
3838 OAuthStartError,
39+ firstPartyOAuthClientAllowsScopes,
3940 firstPartyOAuthClientSlug,
4041 isFirstPartyOAuthClientSlug,
4142 type ConnectResult,
@@ -1027,7 +1028,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 985 unmodified lines
10271028 tokenUrl: config.tokenUrl,
10281029 resource: null,
10291030 clientId: config.clientId,
1030− origin: { kind: "first_party", integrations: config.integrations ?? [] },
1031+ origin: {
1032+ kind: "first_party",
1033+ ...(config.integrations !== undefined ? { integrations: config.integrations } : {}),
1034+ ...(config.allowedScopes !== undefined ? { allowedScopes: config.allowedScopes } : {}),
1035+ },
10311036 }),
10321037 );
10331038 return deps.fuma
@@ -1218,6 +1223,22 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 184 unmodified lines
12181223 )
12191224 : dedupeScopes(scopePolicy.scopes);
12201225
1226+ // An explicitly scope-limited first-party app is an authorization
1227+ // boundary, not picker decoration. Endpoint matching can associate one
1228+ // Google client with every Google API, so enforce the complete requested
1229+ // set here before persisting an OAuth session or redirecting the browser.
1230+ if (firstPartyFlow) {
1231+ const firstParty = firstPartyBySlug.get(String(input.client));
1232+ if (
1233+ firstParty !== undefined &&
1234+ !firstPartyOAuthClientAllowsScopes(firstParty, requestedScopes)
1235+ ) {
1236+ return yield* new OAuthStartError({
1237+ message: `The built-in OAuth app is not enabled for integration ${input.integration}.`,
1238+ });
1239+ }
1240+ }
1241+
12211242 // client_credentials: exchange immediately and mint the connection.
12221243 if (client.grant === "client_credentials") {
12231244 const token = yield* exchangeClientCredentials({
@@ -1399,6 +1420,20 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 175 unmodified lines
13991420 restartRequired: true,
14001421 });
14011422 }
1423+ if (isFirstPartyOAuthClientSlug(String(session.clientSlug))) {
1424+ const firstParty = firstPartyBySlug.get(String(session.clientSlug));
1425+ if (
1426+ firstParty !== undefined &&
1427+ firstParty.allowedScopes !== undefined &&
1428+ (session.requestedScopes === null ||
1429+ !firstPartyOAuthClientAllowsScopes(firstParty, session.requestedScopes))
1430+ ) {
1431+ return yield* new OAuthCompleteError({
1432+ message: `The built-in OAuth app is no longer enabled for integration ${session.integration}; restart the flow.`,
1433+ restartRequired: true,
1434+ });
1435+ }
1436+ }
14021437
14031438 // The PKCE verifier is minted by `start` for every authorization_code
14041439 // session. A null/missing one means a corrupt session row — exchanging
packages/plugins/openapi/src/providers/google/index.ts+1−0
@@ -17,6 +17,7 @@ export { · 16 unmodified lines
1717 googleStandardUserOAuthPresets,
1818 googleOAuthConsentScopes,
1919 googleOAuthConsentScopesForPreset,
20+ googleCatalogOAuthScopesForPreset,
2021 googleServiceSlug,
2122 googleAudienceWarningsForUrls,
2223 googlePresetForDiscoveryUrl,
packages/plugins/openapi/src/providers/google/presets.ts+9−4
@@ -317,16 +317,21 @@ const GOOGLE_HEALTH_CHECKS: Readonly<Record<string, HealthCheckSpec>> = { · 316 unmodified lines
317317 "google-cloud-resource-manager": { operation: "cloudresourcemanager.projects.list" },
318318 };
319319
320+/** Complete Google OAuth scope set requested by a catalog preset, including
321+ * identity scopes used to label and distinguish connected accounts. */
322+export const googleCatalogOAuthScopesForPreset = (presetId: string): readonly string[] =>
323+ compactGoogleOAuthScopes([
324+ ...GOOGLE_IDENTITY_SCOPES,
325+ ...googleOAuthConsentScopesForPreset(presetId),
326+ ]);
327+
320328 const googleCatalogAuthTemplate = (presetId: string) => [
321329 {
322330 slug: GOOGLE_OAUTH_SECURITY_SCHEME,
323331 kind: "oauth2" as const,
324332 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
325333 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
326− scopes: compactGoogleOAuthScopes([
327− ...GOOGLE_IDENTITY_SCOPES,
328− ...googleOAuthConsentScopesForPreset(presetId),
329− ]),
334+ scopes: googleCatalogOAuthScopesForPreset(presetId),
330335 },
331336 ];
332337
packages/react/src/components/add-account-modal.tsx+1−0
@@ -1516,6 +1516,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1515 unmodified lines
15161516 // unrelated provider's app.
15171517 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,
15181518 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,
1519+ scopes: method?.oauth?.scopes,
15191520 // Recorded intent: a manual app registered from THIS integration's dialog is
15201521 // a tier-1 match regardless of host.
15211522 integration,
packages/react/src/plugins/use-effective-oauth-client.test.ts+23−0
@@ -293,6 +293,29 @@ describe("selectClientsForEndpoints", () => { · 292 unmodified lines
293293 ]);
294294 });
295295
296+ it("hides a scope-limited first-party app from another API on the same provider", () => {
297+ const firstParty = app("first-party:google", {
298+ owner: "org",
299+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
300+ tokenUrl: "https://oauth2.googleapis.com/token",
301+ origin: {
302+ kind: "first_party",
303+ allowedScopes: ["openid", "email", "https://www.googleapis.com/auth/calendar"],
304+ },
305+ });
306+ const result = selectClientsForEndpoints([firstParty], {
307+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
308+ tokenUrl: "https://oauth2.googleapis.com/token",
309+ integration: IntegrationSlug.make("renamed_gmail"),
310+ scopes: ["openid", "email", "https://mail.google.com/"],
311+ });
312+
313+ expect(result.endpointMatched).toBe(false);
314+ expect(result.matched).toEqual([]);
315+ expect(result.nearMatches).toEqual([]);
316+ expect(result.unmatched).toEqual([]);
317+ });
318+
296319 it("intent-matches a first-party app to its declared integrations even without endpoints", () => {
297320 const integration = IntegrationSlug.make("github_rest");
298321 const firstParty = app("first-party:github", {
packages/react/src/plugins/use-effective-oauth-client.tsx+29−2
@@ -53,6 +53,19 @@ export const isDcrClient = (app: OAuthClientOption): boolean => · 52 unmodified lines
5353 export const isFirstPartyClient = (app: OAuthClientOption): boolean =>
5454 app.origin.kind === "first_party";
5555
56+/** Mirror the host's first-party scope boundary in the picker. The server is
57+ * authoritative; this prevents offering a built-in app for a flow it will
58+ * reject. An explicit policy with unknown scopes fails closed. */
59+const firstPartyClientAllowsScopes = (
60+ app: OAuthClientOption,
61+ requestedScopes: readonly string[] | undefined,
62+): boolean => {
63+ if (app.origin.kind !== "first_party" || app.origin.allowedScopes === undefined) return true;
64+ if (requestedScopes === undefined) return false;
65+ const allowed = new Set(app.origin.allowedScopes);
66+ return requestedScopes.every((scope) => allowed.has(scope));
67+};
68+
5669 const hostOf = (url: string): string | undefined => {
5770 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host"
5871 try {
@@ -162,6 +175,9 @@ export function selectClientsForEndpoints( · 103 unmodified lines
162175 /** The integration whose picker this is. A manual app stamped with this
163176 * integration (recorded intent) is a tier-1 match regardless of host. */
164177 readonly integration?: IntegrationSlug;
178+ /** Complete scope set declared by the selected OAuth auth method. Used to
179+ * hide scope-limited first-party apps that the host will reject. */
180+ readonly scopes?: readonly string[];
165181 /** When set, an integration that targets a SPECIFIC server (MCP, whose
166182 * endpoints are discovered at connect) must match by endpoint — absent
167183 * endpoints mean NO match (show the register CTA), never "every app
@@ -175,7 +191,9 @@ export function selectClientsForEndpoints( · 7 unmodified lines
175191 readonly endpointMatched: boolean;
176192 } {
177193 // DCR clients are plumbing, never picker options.
178− const manual = all.filter((app) => !isDcrClient(app));
194+ const manual = all.filter(
195+ (app) => !isDcrClient(app) && firstPartyClientAllowsScopes(app, endpoints.scopes),
196+ );
179197
180198 const intent = endpoints.integration;
181199 const matchesIntent = (app: OAuthClientOption): boolean => {
@@ -262,6 +280,7 @@ export function useOAuthClientsForIntegration(opts: { · 80 unmodified lines
262280 readonly tokenUrl?: string;
263281 readonly authorizationUrl?: string;
264282 readonly integration?: IntegrationSlug;
283+ readonly scopes?: readonly string[];
265284 readonly requireEndpointMatch?: boolean;
266285 }): UseOAuthClientsResult {
267286 // Read the optimistic list so a just-registered/edited/removed app paints
@@ -283,9 +302,17 @@ export function useOAuthClientsForIntegration(opts: { · 15 unmodified lines
283302 tokenUrl: opts.tokenUrl,
284303 authorizationUrl: opts.authorizationUrl,
285304 integration: opts.integration,
305+ scopes: opts.scopes,
286306 requireEndpointMatch: opts.requireEndpointMatch,
287307 }),
288− [all, opts.tokenUrl, opts.authorizationUrl, opts.integration, opts.requireEndpointMatch],
308+ [
309+ all,
310+ opts.tokenUrl,
311+ opts.authorizationUrl,
312+ opts.integration,
313+ opts.scopes,
314+ opts.requireEndpointMatch,
315+ ],
289316 );
290317
291318 if (!loaded) {