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
42 PluginsProvider,42 PluginsProvider,
43 collectTables,43 collectTables,
44} from "@executor-js/api/server";44} from "@executor-js/api/server";
45import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
45import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";46import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
46import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk";47import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk";
47 48
@@ -88,6 +89,17 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 40 unmodified lines
88 */89 */
89export const CLOUD_MOUNT_PREFIX = "/api" as const;90export const CLOUD_MOUNT_PREFIX = "/api" as const;
90 91
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.
96const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [
97 ...new Set([
98 ...googleCatalogOAuthScopesForPreset("google-calendar"),
99 ...googleCatalogOAuthScopesForPreset("google-sheets"),
100 ]),
101];
102
91// Executor-owned provider apps, enabled per provider by setting BOTH env vars103// Executor-owned provider apps, enabled per provider by setting BOTH env vars
92// (id + secret). Each provider-side registration must list104// (id + secret). Each provider-side registration must list
93// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug105// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
@@ -119,6 +131,7 @@ const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] = · 25 unmodified lines
119 tokenUrl: "https://oauth2.googleapis.com/token",131 tokenUrl: "https://oauth2.googleapis.com/token",
120 clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID,132 clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID,
121 clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET,133 clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET,
134 allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES,
122 },135 },
123 ]136 ]
124 : []),137 : []),
apps/marketing/src/pages/privacy.astro+20−0
@@ -53,6 +53,26 @@ import LegalLayout from "../components/LegalLayout.astro"; · 52 unmodified lines
53 a call, run code, or execute a workflow.53 a call, run code, or execute a workflow.
54 </p>54 </p>
55 55
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
56 <h3>Website inputs and support communications</h3>76 <h3>Website inputs and support communications</h3>
57 <p>77 <p>
58 If you submit a URL to our website&apos;s API detection tool, contact us by email, or otherwise communicate with us,78 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
62 ],62 ],
63} as const;63} as const;
64 64
65const 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
65scenario(94scenario(
66 "First-party OAuth · the host-declared GitHub app is listed and drives the authorize redirect",95 "First-party OAuth · the host-declared GitHub app is listed and drives the authorize redirect",
67 {},96 {},
68 Effect.scoped(97 Effect.scoped(
69 Effect.gen(function* () {98 Effect.gen(function* () {
70 const target = yield* Target;99 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;
71 const { client: makeApiClient } = yield* Api;104 const { client: makeApiClient } = yield* Api;
72 const identity = yield* target.newIdentity();105 const identity = yield* target.newIdentity();
73 const client = yield* makeApiClient(api, identity);106 const client = yield* makeApiClient(api, identity);
@@ -133,3 +166,87 @@ scenario( · 59 unmodified lines
133 }),166 }),
134 ),167 ),
135);168);
169
170scenario(
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
104 // complete authorize → token dance instead of stopping at github.com.104 // complete authorize → token dance instead of stopping at github.com.
105 FIRST_PARTY_GITHUB_AUTHORIZE_URL: process.env.FIRST_PARTY_GITHUB_AUTHORIZE_URL,105 FIRST_PARTY_GITHUB_AUTHORIZE_URL: process.env.FIRST_PARTY_GITHUB_AUTHORIZE_URL,
106 FIRST_PARTY_GITHUB_TOKEN_URL: process.env.FIRST_PARTY_GITHUB_TOKEN_URL,106 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",
107 // Shrink the per-org hourly execution cap (prod default 1000) to a number112 // Shrink the per-org hourly execution cap (prod default 1000) to a number
108 // the rate-limit-backstop scenario can actually exhaust with real113 // the rate-limit-backstop scenario can actually exhaust with real
109 // executions — but see execution-limits.ts: it must stay above every other114 // 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
123 }),123 }),
124 /** Host-operated app declared in executor config — every org connects124 /** Host-operated app declared in executor config — every org connects
125 * through it; nothing to paste. `integrations` ranks it as the default125 * 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. */
127 Schema.Struct({128 Schema.Struct({
128 kind: Schema.Literal("first_party"),129 kind: Schema.Literal("first_party"),
129 integrations: Schema.optional(Schema.Array(IntegrationSlug)),130 integrations: Schema.optional(Schema.Array(IntegrationSlug)),
131 allowedScopes: Schema.optional(Schema.Array(Schema.String)),
130 }),132 }),
131 ]),133 ]),
132});134});
packages/core/sdk/src/oauth-client.ts+20−0
@@ -88,6 +88,10 @@ export type OAuthClientOrigin = · 87 unmodified lines
88 * one Google app deliberately backs gmail, calendar, drive, …. */88 * one Google app deliberately backs gmail, calendar, drive, …. */
89 readonly kind: "first_party";89 readonly kind: "first_party";
90 readonly integrations?: readonly IntegrationSlug[];90 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[];
91 };95 };
92 96
93/** Slug namespace separating config-declared first-party apps from stored97/** Slug namespace separating config-declared first-party apps from stored
@@ -122,8 +126,24 @@ export interface FirstPartyOAuthClientConfig { · 28 unmodified lines
122 * exact-match default for those integrations. Endpoint-host matching still126 * exact-match default for those integrations. Endpoint-host matching still
123 * applies when omitted. */127 * applies when omitted. */
124 readonly integrations?: readonly IntegrationSlug[];128 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[];
125}133}
126 134
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. */
138export 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
127export type CreateOAuthClientInput = OAuthClient & {147export type CreateOAuthClientInput = OAuthClient & {
128 /** Stored-row origins only — `first_party` is config-declared, never created148 /** Stored-row origins only — `first_party` is config-declared, never created
129 * through this surface (the service also rejects the slug namespace). */149 * 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
176 const server = yield* serveOAuthTestServer({ scopes: ["read"] });176 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
177 const { executor } = yield* makeTestWorkspaceHarness({177 const { executor } = yield* makeTestWorkspaceHarness({
178 plugins,178 plugins,
179 firstPartyOAuthClients: [firstPartyClientFor(server)],179 firstPartyOAuthClients: [
180 { ...firstPartyClientFor(server), allowedScopes: ["openid", "read"] },
181 ],
180 });182 });
181 yield* executor.acme.seed();183 yield* executor.acme.seed();
182 184
@@ -193,13 +195,45 @@ describe("first-party oauth clients", () => { · 10 unmodified lines
193 const clients = yield* executor.oauth.listClients();195 const clients = yield* executor.oauth.listClients();
194 expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]);196 expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]);
195 const firstParty = clients[0]!;197 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 });
197 expect(firstParty.clientId).toBe("test-client");203 expect(firstParty.clientId).toBe("test-client");
198 expect("clientSecret" in firstParty).toBe(false);204 expect("clientSecret" in firstParty).toBe(false);
199 }),205 }),
200 ),206 ),
201 );207 );
202 208
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
203 it.effect("createClient and removeClient reject the reserved first-party namespace", () =>237 it.effect("createClient and removeClient reject the reserved first-party namespace", () =>
204 Effect.scoped(238 Effect.scoped(
205 Effect.gen(function* () {239 Effect.gen(function* () {
packages/core/sdk/src/oauth-service.ts+36−1
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
36 OAuthRegisterDynamicError,36 OAuthRegisterDynamicError,
37 OAuthSessionNotFoundError,37 OAuthSessionNotFoundError,
38 OAuthStartError,38 OAuthStartError,
39 firstPartyOAuthClientAllowsScopes,
39 firstPartyOAuthClientSlug,40 firstPartyOAuthClientSlug,
40 isFirstPartyOAuthClientSlug,41 isFirstPartyOAuthClientSlug,
41 type ConnectResult,42 type ConnectResult,
@@ -1027,7 +1028,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 985 unmodified lines
1027 tokenUrl: config.tokenUrl,1028 tokenUrl: config.tokenUrl,
1028 resource: null,1029 resource: null,
1029 clientId: config.clientId,1030 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 },
1031 }),1036 }),
1032 );1037 );
1033 return deps.fuma1038 return deps.fuma
@@ -1218,6 +1223,22 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 184 unmodified lines
1218 )1223 )
1219 : dedupeScopes(scopePolicy.scopes);1224 : dedupeScopes(scopePolicy.scopes);
1220 1225
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
1221 // client_credentials: exchange immediately and mint the connection.1242 // client_credentials: exchange immediately and mint the connection.
1222 if (client.grant === "client_credentials") {1243 if (client.grant === "client_credentials") {
1223 const token = yield* exchangeClientCredentials({1244 const token = yield* exchangeClientCredentials({
@@ -1399,6 +1420,20 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 175 unmodified lines
1399 restartRequired: true,1420 restartRequired: true,
1400 });1421 });
1401 }1422 }
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 }
1402 1437
1403 // The PKCE verifier is minted by `start` for every authorization_code1438 // The PKCE verifier is minted by `start` for every authorization_code
1404 // session. A null/missing one means a corrupt session row — exchanging1439 // 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
17 googleStandardUserOAuthPresets,17 googleStandardUserOAuthPresets,
18 googleOAuthConsentScopes,18 googleOAuthConsentScopes,
19 googleOAuthConsentScopesForPreset,19 googleOAuthConsentScopesForPreset,
20 googleCatalogOAuthScopesForPreset,
20 googleServiceSlug,21 googleServiceSlug,
21 googleAudienceWarningsForUrls,22 googleAudienceWarningsForUrls,
22 googlePresetForDiscoveryUrl,23 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
317 "google-cloud-resource-manager": { operation: "cloudresourcemanager.projects.list" },317 "google-cloud-resource-manager": { operation: "cloudresourcemanager.projects.list" },
318};318};
319 319
320/** Complete Google OAuth scope set requested by a catalog preset, including
321 * identity scopes used to label and distinguish connected accounts. */
322export const googleCatalogOAuthScopesForPreset = (presetId: string): readonly string[] =>
323 compactGoogleOAuthScopes([
324 ...GOOGLE_IDENTITY_SCOPES,
325 ...googleOAuthConsentScopesForPreset(presetId),
326 ]);
327
320const googleCatalogAuthTemplate = (presetId: string) => [328const googleCatalogAuthTemplate = (presetId: string) => [
321 {329 {
322 slug: GOOGLE_OAUTH_SECURITY_SCHEME,330 slug: GOOGLE_OAUTH_SECURITY_SCHEME,
323 kind: "oauth2" as const,331 kind: "oauth2" as const,
324 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,332 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
325 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,333 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
326 scopes: compactGoogleOAuthScopes([334 scopes: googleCatalogOAuthScopesForPreset(presetId),
327 ...GOOGLE_IDENTITY_SCOPES,
328 ...googleOAuthConsentScopesForPreset(presetId),
329 ]),
330 },335 },
331];336];
332 337
packages/react/src/components/add-account-modal.tsx+1−0
@@ -1516,6 +1516,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1515 unmodified lines
1516 // unrelated provider's app.1516 // unrelated provider's app.
1517 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,1517 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,
1518 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,1518 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,
1519 scopes: method?.oauth?.scopes,
1519 // Recorded intent: a manual app registered from THIS integration's dialog is1520 // Recorded intent: a manual app registered from THIS integration's dialog is
1520 // a tier-1 match regardless of host.1521 // a tier-1 match regardless of host.
1521 integration,1522 integration,
packages/react/src/plugins/use-effective-oauth-client.test.ts+23−0
@@ -293,6 +293,29 @@ describe("selectClientsForEndpoints", () => { · 292 unmodified lines
293 ]);293 ]);
294 });294 });
295 295
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
296 it("intent-matches a first-party app to its declared integrations even without endpoints", () => {319 it("intent-matches a first-party app to its declared integrations even without endpoints", () => {
297 const integration = IntegrationSlug.make("github_rest");320 const integration = IntegrationSlug.make("github_rest");
298 const firstParty = app("first-party:github", {321 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
53export const isFirstPartyClient = (app: OAuthClientOption): boolean =>53export const isFirstPartyClient = (app: OAuthClientOption): boolean =>
54 app.origin.kind === "first_party";54 app.origin.kind === "first_party";
55 55
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. */
59const 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
56const hostOf = (url: string): string | undefined => {69const hostOf = (url: string): string | undefined => {
57 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host"70 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host"
58 try {71 try {
@@ -162,6 +175,9 @@ export function selectClientsForEndpoints( · 103 unmodified lines
162 /** The integration whose picker this is. A manual app stamped with this175 /** The integration whose picker this is. A manual app stamped with this
163 * integration (recorded intent) is a tier-1 match regardless of host. */176 * integration (recorded intent) is a tier-1 match regardless of host. */
164 readonly integration?: IntegrationSlug;177 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[];
165 /** When set, an integration that targets a SPECIFIC server (MCP, whose181 /** When set, an integration that targets a SPECIFIC server (MCP, whose
166 * endpoints are discovered at connect) must match by endpoint — absent182 * endpoints are discovered at connect) must match by endpoint — absent
167 * endpoints mean NO match (show the register CTA), never "every app183 * endpoints mean NO match (show the register CTA), never "every app
@@ -175,7 +191,9 @@ export function selectClientsForEndpoints( · 7 unmodified lines
175 readonly endpointMatched: boolean;191 readonly endpointMatched: boolean;
176} {192} {
177 // DCR clients are plumbing, never picker options.193 // 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 );
179 197
180 const intent = endpoints.integration;198 const intent = endpoints.integration;
181 const matchesIntent = (app: OAuthClientOption): boolean => {199 const matchesIntent = (app: OAuthClientOption): boolean => {
@@ -262,6 +280,7 @@ export function useOAuthClientsForIntegration(opts: { · 80 unmodified lines
262 readonly tokenUrl?: string;280 readonly tokenUrl?: string;
263 readonly authorizationUrl?: string;281 readonly authorizationUrl?: string;
264 readonly integration?: IntegrationSlug;282 readonly integration?: IntegrationSlug;
283 readonly scopes?: readonly string[];
265 readonly requireEndpointMatch?: boolean;284 readonly requireEndpointMatch?: boolean;
266}): UseOAuthClientsResult {285}): UseOAuthClientsResult {
267 // Read the optimistic list so a just-registered/edited/removed app paints286 // Read the optimistic list so a just-registered/edited/removed app paints
@@ -283,9 +302,17 @@ export function useOAuthClientsForIntegration(opts: { · 15 unmodified lines
283 tokenUrl: opts.tokenUrl,302 tokenUrl: opts.tokenUrl,
284 authorizationUrl: opts.authorizationUrl,303 authorizationUrl: opts.authorizationUrl,
285 integration: opts.integration,304 integration: opts.integration,
305 scopes: opts.scopes,
286 requireEndpointMatch: opts.requireEndpointMatch,306 requireEndpointMatch: opts.requireEndpointMatch,
287 }),307 }),
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 ],
289 );316 );
290 317
291 if (!loaded) {318 if (!loaded) {