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