Unified · Split
| @@ -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"; |
| 45 | import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google"; | ||
| 45 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; | 46 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; |
| 46 | import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk"; | 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 | export const CLOUD_MOUNT_PREFIX = "/api" as const; | 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 | // Executor-owned provider apps, enabled per provider by setting BOTH env vars | 103 | // Executor-owned provider apps, enabled per provider by setting BOTH env vars |
| 92 | // (id + secret). Each provider-side registration must list | 104 | // (id + secret). Each provider-side registration must list |
| 93 | // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug | 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 | 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 | : []), |
| @@ -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'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 | <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's API detection tool, contact us by email, or otherwise communicate with us, | 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 | } as const; | 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 | scenario( | 94 | scenario( |
| 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 | |||
| 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 | // 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 number | 112 | // Shrink the per-org hourly execution cap (prod default 1000) to a number |
| 108 | // the rate-limit-backstop scenario can actually exhaust with real | 113 | // the rate-limit-backstop scenario can actually exhaust with real |
| 109 | // executions — but see execution-limits.ts: it must stay above every other | 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 | /** Host-operated app declared in executor config — every org connects | 124 | /** Host-operated app declared in executor config — every org connects |
| 125 | * through it; nothing to paste. `integrations` ranks it as the default | 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 | 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 | }); |
| @@ -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 stored | 97 | /** 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 still | 126 | * 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. */ | ||
| 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 | export type CreateOAuthClientInput = OAuthClient & { | 147 | export type CreateOAuthClientInput = OAuthClient & { |
| 128 | /** Stored-row origins only — `first_party` is config-declared, never created | 148 | /** 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). */ |
| @@ -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* () { |
| @@ -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.fuma | 1038 | 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_code | 1438 | // The PKCE verifier is minted by `start` for every authorization_code |
| 1404 | // session. A null/missing one means a corrupt session row — exchanging | 1439 | // session. A null/missing one means a corrupt session row — exchanging |
| @@ -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, |
| @@ -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. */ | ||
| 322 | export const googleCatalogOAuthScopesForPreset = (presetId: string): readonly string[] => | ||
| 323 | compactGoogleOAuthScopes([ | ||
| 324 | ...GOOGLE_IDENTITY_SCOPES, | ||
| 325 | ...googleOAuthConsentScopesForPreset(presetId), | ||
| 326 | ]); | ||
| 327 | |||
| 320 | const googleCatalogAuthTemplate = (presetId: string) => [ | 328 | const 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 | ||
| @@ -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 is | 1520 | // 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, |
| @@ -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", { |
| @@ -53,6 +53,19 @@ export const isDcrClient = (app: OAuthClientOption): boolean => · 52 unmodified lines | |||
| 53 | export const isFirstPartyClient = (app: OAuthClientOption): boolean => | 53 | export 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. */ | ||
| 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 | const hostOf = (url: string): string | undefined => { | 69 | const 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 this | 175 | /** 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, whose | 181 | /** When set, an integration that targets a SPECIFIC server (MCP, whose |
| 166 | * endpoints are discovered at connect) must match by endpoint — absent | 182 | * endpoints are discovered at connect) must match by endpoint — absent |
| 167 | * endpoints mean NO match (show the register CTA), never "every app | 183 | * 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 paints | 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 | 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) { |