* Fix HubSpot optional scopes for workspace OAuth * chore: rerun flaky cloud E2E * Honor integration-declared HubSpot optional scopes * Test queue timeout with a controlled clock * Verify optional OAuth scopes through consent and tool execution ---------
Unified · Split
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/sdk": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Send HubSpot optional permissions in `optional_scope` for workspace OAuth clients so accounts can connect without optional product features. | ||
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | |||
| 6 | } from "@executor-js/plugin-openapi/providers/microsoft"; | 6 | } from "@executor-js/plugin-openapi/providers/microsoft"; |
| 7 | import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; | 7 | import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; |
| 8 | import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk"; | 8 | import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk"; |
| 9 | import { HUBSPOT_OPTIONAL_SCOPES } from "@executor-js/sdk/host-internal"; | ||
| 9 | 10 | ||
| 10 | import { makeGoogleOAuthListing } from "../analytics/google-oauth-listing"; | 11 | import { makeGoogleOAuthListing } from "../analytics/google-oauth-listing"; |
| 11 | import { POSTHOG_INGEST_HOST } from "../edge/passthrough"; | 12 | import { POSTHOG_INGEST_HOST } from "../edge/passthrough"; |
| @@ -152,12 +153,6 @@ const HUBSPOT_REQUIRED_SCOPES = [ · 140 unmodified lines | |||
| 152 | "timeline", | 153 | "timeline", |
| 153 | ] as const; | 154 | ] as const; |
| 154 | 155 | ||
| 155 | const HUBSPOT_OPTIONAL_SCOPES = [ | ||
| 156 | "content", | ||
| 157 | "crm.objects.custom.read", | ||
| 158 | "crm.schemas.custom.read", | ||
| 159 | ] as const; | ||
| 160 | |||
| 161 | const MICROSOFT_SCOPES = [ | 156 | const MICROSOFT_SCOPES = [ |
| 162 | "User.Read", | 157 | "User.Read", |
| 163 | "Calendars.ReadWrite", | 158 | "Calendars.ReadWrite", |
| @@ -0,0 +1,172 @@ | |||
| 1 | import { randomBytes } from "node:crypto"; | ||
| 2 | |||
| 3 | import { expect } from "@effect/vitest"; | ||
| 4 | import { connectEmulator } from "@executor-js/emulate"; | ||
| 5 | import { Effect } from "effect"; | ||
| 6 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 7 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 8 | import { | ||
| 9 | AuthTemplateSlug, | ||
| 10 | ConnectionName, | ||
| 11 | IntegrationSlug, | ||
| 12 | OAuthClientSlug, | ||
| 13 | } from "@executor-js/sdk/shared"; | ||
| 14 | |||
| 15 | import { createEmulatorInstance } from "../src/emulator-instance"; | ||
| 16 | import { scenario } from "../src/scenario"; | ||
| 17 | import { Api, Browser, Mcp, Target } from "../src/services"; | ||
| 18 | |||
| 19 | const api = composePluginApi([openApiHttpPlugin()] as const); | ||
| 20 | |||
| 21 | scenario( | ||
| 22 | "OAuth optional scopes · the integration partitions scopes and completes an authenticated connection", | ||
| 23 | { timeout: 180_000 }, | ||
| 24 | Effect.scoped( | ||
| 25 | Effect.gen(function* () { | ||
| 26 | const target = yield* Target; | ||
| 27 | const browser = yield* Browser; | ||
| 28 | const mcp = yield* Mcp; | ||
| 29 | const { client: makeClient } = yield* Api; | ||
| 30 | const identity = yield* target.newIdentity(); | ||
| 31 | const client = yield* makeClient(api, identity); | ||
| 32 | const session = mcp.session(identity); | ||
| 33 | expect((yield* session.call("execute", { code: "return true;" })).ok).toBe(true); | ||
| 34 | const base = yield* createEmulatorInstance("github", "optional-scopes"); | ||
| 35 | const emulator = yield* Effect.promise(() => | ||
| 36 | connectEmulator({ baseUrl: base, service: "github" }), | ||
| 37 | ); | ||
| 38 | yield* Effect.promise(() => emulator.seed({ users: [{ login: "optional-scope-user" }] })); | ||
| 39 | const slug = IntegrationSlug.make(`optional-${randomBytes(4).toString("hex")}`); | ||
| 40 | const app = OAuthClientSlug.make(`${slug}-app`); | ||
| 41 | yield* Effect.addFinalizer(() => | ||
| 42 | client.openapi.removeSpec({ params: { slug } }).pipe(Effect.orDie), | ||
| 43 | ); | ||
| 44 | yield* Effect.addFinalizer(() => | ||
| 45 | client.oauth | ||
| 46 | .removeClient({ params: { slug: app }, payload: { owner: "org" } }) | ||
| 47 | .pipe(Effect.orDie), | ||
| 48 | ); | ||
| 49 | // GitHub supplies the real OAuth transport and protected resource. The test | ||
| 50 | // verifies Executor's partitioning contract; it does not claim that GitHub | ||
| 51 | // implements HubSpot's optional-grant policy. | ||
| 52 | const authorizationUrl = `${base}/login/oauth/authorize`; | ||
| 53 | const tokenUrl = `${base}/login/oauth/access_token`; | ||
| 54 | yield* client.openapi.addSpec({ | ||
| 55 | payload: { | ||
| 56 | slug, | ||
| 57 | baseUrl: base, | ||
| 58 | spec: { | ||
| 59 | kind: "blob", | ||
| 60 | value: JSON.stringify({ | ||
| 61 | openapi: "3.0.3", | ||
| 62 | info: { title: "Optional scope API", version: "1" }, | ||
| 63 | paths: { | ||
| 64 | "/user": { | ||
| 65 | get: { | ||
| 66 | operationId: "getUser", | ||
| 67 | security: [{ oauth: ["read:user"] }], | ||
| 68 | responses: { "200": { description: "Authenticated user" } }, | ||
| 69 | }, | ||
| 70 | }, | ||
| 71 | }, | ||
| 72 | components: { | ||
| 73 | securitySchemes: { | ||
| 74 | oauth: { | ||
| 75 | type: "oauth2", | ||
| 76 | flows: { | ||
| 77 | authorizationCode: { | ||
| 78 | authorizationUrl, | ||
| 79 | tokenUrl, | ||
| 80 | scopes: { | ||
| 81 | "read:user": "Read user", | ||
| 82 | "user:email": "Read email when granted", | ||
| 83 | }, | ||
| 84 | }, | ||
| 85 | }, | ||
| 86 | }, | ||
| 87 | }, | ||
| 88 | }, | ||
| 89 | }), | ||
| 90 | }, | ||
| 91 | authenticationTemplate: [ | ||
| 92 | { | ||
| 93 | slug: "oauth", | ||
| 94 | kind: "oauth2", | ||
| 95 | authorizationUrl: `${authorizationUrl}?optional_scope=user%3Aemail`, | ||
| 96 | tokenUrl, | ||
| 97 | scopes: ["read:user", "user:email"], | ||
| 98 | }, | ||
| 99 | ], | ||
| 100 | }, | ||
| 101 | }); | ||
| 102 | yield* client.oauth.createClient({ | ||
| 103 | payload: { | ||
| 104 | owner: "org", | ||
| 105 | slug: app, | ||
| 106 | grant: "authorization_code", | ||
| 107 | authorizationUrl, | ||
| 108 | tokenUrl, | ||
| 109 | clientId: "optional-test-client", | ||
| 110 | clientSecret: "optional-test-secret", | ||
| 111 | originIntegration: slug, | ||
| 112 | }, | ||
| 113 | }); | ||
| 114 | const started = yield* client.oauth.start({ | ||
| 115 | payload: { | ||
| 116 | owner: "org", | ||
| 117 | client: app, | ||
| 118 | clientOwner: "org", | ||
| 119 | name: ConnectionName.make("main"), | ||
| 120 | integration: slug, | ||
| 121 | template: AuthTemplateSlug.make("oauth"), | ||
| 122 | }, | ||
| 123 | }); | ||
| 124 | if (started.status !== "redirect") | ||
| 125 | return yield* Effect.die("Expected authorization redirect"); | ||
| 126 | |||
| 127 | yield* Effect.addFinalizer(() => | ||
| 128 | client.oauth.cancel({ payload: { state: started.state } }).pipe(Effect.orDie), | ||
| 129 | ); | ||
| 130 | const url = new URL(started.authorizationUrl); | ||
| 131 | expect(url.searchParams.get("scope")).toBe("read:user"); | ||
| 132 | expect(url.searchParams.get("optional_scope")).toBe("user:email"); | ||
| 133 | yield* browser.session(identity, async ({ page, step }) => { | ||
| 134 | await step("Review and approve the OAuth consent request", async () => { | ||
| 135 | await page.goto(started.authorizationUrl); | ||
| 136 | await page.getByRole("button", { name: /optional-scope-user/ }).click(); | ||
| 137 | await page.getByText("Connected", { exact: true }).waitFor({ timeout: 30_000 }); | ||
| 138 | }); | ||
| 139 | }); | ||
| 140 | yield* Effect.addFinalizer(() => | ||
| 141 | client.connections | ||
| 142 | .remove({ | ||
| 143 | params: { owner: "org", integration: slug, name: ConnectionName.make("main") }, | ||
| 144 | }) | ||
| 145 | .pipe(Effect.orDie), | ||
| 146 | ); | ||
| 147 | const catalog = yield* client.tools.list({ query: { integration: slug } }); | ||
| 148 | const tool = catalog.find((entry) => entry.name.endsWith("getUser")); | ||
| 149 | if (!tool) | ||
| 150 | return yield* Effect.die( | ||
| 151 | `Authenticated getUser tool missing: ${catalog.map((entry) => entry.name).join(", ")}`, | ||
| 152 | ); | ||
| 153 | let result = yield* session.call("execute", { | ||
| 154 | code: `const path = ${JSON.stringify(String(tool.address))}.split(".").slice(1); let call = tools; for (const part of path) call = call[part]; return await call({});`, | ||
| 155 | }); | ||
| 156 | for (let attempts = 0; result.text.includes("executionId:") && attempts < 10; attempts += 1) | ||
| 157 | result = yield* session.approvePaused(result.text); | ||
| 158 | expect(result.ok).toBe(true); | ||
| 159 | expect(result.text).toContain("optional-scope-user"); | ||
| 160 | const ledger = yield* Effect.promise(() => emulator.ledger.list()); | ||
| 161 | const authorize = ledger.find( | ||
| 162 | (entry) => entry.method === "GET" && entry.path.endsWith("/login/oauth/authorize"), | ||
| 163 | ); | ||
| 164 | expect(new URLSearchParams(authorize?.query).get("optional_scope")).toBe("user:email"); | ||
| 165 | expect( | ||
| 166 | ledger | ||
| 167 | .filter((entry) => entry.path === "/user" && entry.response.status === 200) | ||
| 168 | .map((entry) => entry.identity.user?.login), | ||
| 169 | ).toContain("optional-scope-user"); | ||
| 170 | }), | ||
| 171 | ), | ||
| 172 | ); | ||
| @@ -208,6 +208,7 @@ import { · 207 unmodified lines | |||
| 208 | exchangeClientCredentials, | 208 | exchangeClientCredentials, |
| 209 | isPermanentTokenRejection, | 209 | isPermanentTokenRejection, |
| 210 | isUnusableSuccessTokenResponse, | 210 | isUnusableSuccessTokenResponse, |
| 211 | optionalScopesFromAuthorizationUrl, | ||
| 211 | shouldRefreshToken, | 212 | shouldRefreshToken, |
| 212 | type OAuth2TokenResponse, | 213 | type OAuth2TokenResponse, |
| 213 | type OAuthEndpointUrlPolicy, | 214 | type OAuthEndpointUrlPolicy, |
| @@ -6711,7 +6712,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6497 unmodified lines | |||
| 6711 | discoveryUrl: oauth.discoveryUrl, | 6712 | discoveryUrl: oauth.discoveryUrl, |
| 6712 | } satisfies OAuthScopePolicy; | 6713 | } satisfies OAuthScopePolicy; |
| 6713 | } | 6714 | } |
| 6714 | return { kind: "scopes", scopes: oauth?.scopes ?? [] } satisfies OAuthScopePolicy; | 6715 | return { |
| 6716 | kind: "scopes", | ||
| 6717 | scopes: oauth?.scopes ?? [], | ||
| 6718 | optionalScopes: | ||
| 6719 | oauth?.authorizationUrl === undefined | ||
| 6720 | ? [] | ||
| 6721 | : optionalScopesFromAuthorizationUrl(oauth.authorizationUrl), | ||
| 6722 | } satisfies OAuthScopePolicy; | ||
| 6715 | }), | 6723 | }), |
| 6716 | httpClientLayer: config.httpClientLayer, | 6724 | httpClientLayer: config.httpClientLayer, |
| 6717 | fetch: config.fetch, | 6725 | fetch: config.fetch, |
| @@ -37,7 +37,11 @@ export { · 36 unmodified lines | |||
| 37 | type HostedHttpClientOptions, | 37 | type HostedHttpClientOptions, |
| 38 | } from "./hosted-http-client"; | 38 | } from "./hosted-http-client"; |
| 39 | 39 | ||
| 40 | export { OAUTH2_DEFAULT_TIMEOUT_MS, assertSupportedOAuthEndpointUrl } from "./oauth-helpers"; | 40 | export { |
| 41 | HUBSPOT_OPTIONAL_SCOPES, | ||
| 42 | OAUTH2_DEFAULT_TIMEOUT_MS, | ||
| 43 | assertSupportedOAuthEndpointUrl, | ||
| 44 | } from "./oauth-helpers"; | ||
| 41 | 45 | ||
| 42 | export { | 46 | export { |
| 43 | DEFAULT_SUBJECT_LAST_SEEN_THROTTLE_MS, | 47 | DEFAULT_SUBJECT_LAST_SEEN_THROTTLE_MS, |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | |||
| 25 | idTokenIdentityLabel, | 25 | idTokenIdentityLabel, |
| 26 | isPermanentTokenRejection, | 26 | isPermanentTokenRejection, |
| 27 | isUnusableSuccessTokenResponse, | 27 | isUnusableSuccessTokenResponse, |
| 28 | optionalScopesFromAuthorizationUrl, | ||
| 28 | refreshAccessToken, | 29 | refreshAccessToken, |
| 29 | shouldRefreshToken, | 30 | shouldRefreshToken, |
| 30 | } from "./oauth-helpers"; | 31 | } from "./oauth-helpers"; |
| @@ -193,16 +194,33 @@ describe("PKCE", () => { · 162 unmodified lines | |||
| 193 | // buildAuthorizationUrl | 194 | // buildAuthorizationUrl |
| 194 | // --------------------------------------------------------------------------- | 195 | // --------------------------------------------------------------------------- |
| 195 | 196 | ||
| 196 | describe("providerAuthorizeExtras (Google offline/consent quirk)", () => { | 197 | describe("providerAuthorizeExtras (provider authorization quirks)", () => { |
| 197 | it("adds access_type=offline + prompt=consent for the Google authorize host", () => { | 198 | it("adds access_type=offline + prompt=consent for the Google authorize host", () => { |
| 198 | expect(providerAuthorizeExtras("https://accounts.google.com/o/oauth2/v2/auth")).toEqual({ | 199 | expect(providerAuthorizeExtras("https://accounts.google.com/o/oauth2/v2/auth")).toEqual({ |
| 199 | access_type: "offline", | 200 | access_type: "offline", |
| 200 | prompt: "consent", | 201 | prompt: "consent", |
| 201 | }); | 202 | }); |
| 202 | }); | 203 | }); |
| 203 | it("adds nothing for non-Google hosts or an unparseable URL (token host ≠ authorize host)", () => { | 204 | |
| 205 | it("adds optional_scope for workspace-owned HubSpot OAuth clients", () => { | ||
| 206 | expect(providerAuthorizeExtras("https://app.hubspot.com/oauth/authorize")).toEqual({ | ||
| 207 | optional_scope: "content crm.objects.custom.read crm.schemas.custom.read", | ||
| 208 | }); | ||
| 209 | }); | ||
| 210 | |||
| 211 | it("reads integration-declared optional_scope values from an authorization URL", () => { | ||
| 212 | expect( | ||
| 213 | optionalScopesFromAuthorizationUrl( | ||
| 214 | "https://app.hubspot.com/oauth/authorize?optional_scope=crm.objects.contacts.read+crm.objects.contacts.write+crm.objects.contacts.read", | ||
| 215 | ), | ||
| 216 | ).toEqual(["crm.objects.contacts.read", "crm.objects.contacts.write"]); | ||
| 217 | expect(optionalScopesFromAuthorizationUrl("not a url")).toEqual([]); | ||
| 218 | }); | ||
| 219 | |||
| 220 | it("adds nothing for unrelated hosts, token hosts, or an unparseable URL", () => { | ||
| 204 | expect(providerAuthorizeExtras("https://accounts.spotify.com/authorize")).toEqual({}); | 221 | expect(providerAuthorizeExtras("https://accounts.spotify.com/authorize")).toEqual({}); |
| 205 | expect(providerAuthorizeExtras("https://oauth2.googleapis.com/token")).toEqual({}); | 222 | expect(providerAuthorizeExtras("https://oauth2.googleapis.com/token")).toEqual({}); |
| 223 | expect(providerAuthorizeExtras("https://api.hubapi.com/oauth/v3/token")).toEqual({}); | ||
| 206 | expect(providerAuthorizeExtras("not a url")).toEqual({}); | 224 | expect(providerAuthorizeExtras("not a url")).toEqual({}); |
| 207 | }); | 225 | }); |
| 208 | }); | 226 | }); |
| @@ -113,6 +113,16 @@ export const OAUTH2_REFRESH_SKEW_MS = 60_000; · 112 unmodified lines | |||
| 113 | /** Default token-endpoint timeout. */ | 113 | /** Default token-endpoint timeout. */ |
| 114 | export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000; | 114 | export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000; |
| 115 | 115 | ||
| 116 | /** HubSpot scopes that the registered app may grant but must receive through | ||
| 117 | * HubSpot's non-standard `optional_scope` authorize parameter. Keeping these | ||
| 118 | * out of the RFC `scope` parameter lets accounts without the corresponding | ||
| 119 | * product features complete consent while still granting them when present. */ | ||
| 120 | export const HUBSPOT_OPTIONAL_SCOPES = [ | ||
| 121 | "content", | ||
| 122 | "crm.objects.custom.read", | ||
| 123 | "crm.schemas.custom.read", | ||
| 124 | ] as const; | ||
| 125 | |||
| 116 | /** RFC 8693 §2.1 token-exchange grant. */ | 126 | /** RFC 8693 §2.1 token-exchange grant. */ |
| 117 | export const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; | 127 | export const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; |
| 118 | 128 | ||
| @@ -247,7 +257,12 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string · 128 unmodified lines | |||
| 247 | * re-consent can silently keep the old scope set. Do not add | 257 | * re-consent can silently keep the old scope set. Do not add |
| 248 | * `include_granted_scopes=true` here: with historical grants on the same Google | 258 | * `include_granted_scopes=true` here: with historical grants on the same Google |
| 249 | * consent app, Google folds those unrelated scopes into the new consent flow and | 259 | * consent app, Google folds those unrelated scopes into the new consent flow and |
| 250 | * can fail inside accounts.google.com before returning to our callback. */ | 260 | * can fail inside accounts.google.com before returning to our callback. |
| 261 | * | ||
| 262 | * HubSpot: app scopes marked optional are ignored when they are omitted from | ||
| 263 | * the provider-specific `optional_scope` parameter. The OpenAPI auth template | ||
| 264 | * can only declare RFC scopes, so this host-level quirk must apply to both | ||
| 265 | * first-party and workspace-owned HubSpot OAuth clients. */ | ||
| 251 | export const providerAuthorizeExtras = ( | 266 | export const providerAuthorizeExtras = ( |
| 252 | authorizationUrl: string, | 267 | authorizationUrl: string, |
| 253 | ): Readonly<Record<string, string>> => { | 268 | ): Readonly<Record<string, string>> => { |
| @@ -257,12 +272,30 @@ export const providerAuthorizeExtras = ( · 3 unmodified lines | |||
| 257 | if (host === "accounts.google.com") { | 272 | if (host === "accounts.google.com") { |
| 258 | return { access_type: "offline", prompt: "consent" }; | 273 | return { access_type: "offline", prompt: "consent" }; |
| 259 | } | 274 | } |
| 275 | if (host === "app.hubspot.com") { | ||
| 276 | return { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") }; | ||
| 277 | } | ||
| 260 | } catch { | 278 | } catch { |
| 261 | // Unparseable authorization URL — let buildAuthorizationUrl surface the error. | 279 | // Unparseable authorization URL — let buildAuthorizationUrl surface the error. |
| 262 | } | 280 | } |
| 263 | return {}; | 281 | return {}; |
| 264 | }; | 282 | }; |
| 265 | 283 | ||
| 284 | /** Provider-specific scopes embedded in an integration's authorization | ||
| 285 | * endpoint. HubSpot models app-optional permissions with the non-standard | ||
| 286 | * `optional_scope` query parameter, so they are part of the integration's | ||
| 287 | * request contract rather than the registered OAuth app identity. */ | ||
| 288 | export const optionalScopesFromAuthorizationUrl = (authorizationUrl: string): readonly string[] => { | ||
| 289 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input -> no optional scopes | ||
| 290 | try { | ||
| 291 | const value = new URL(authorizationUrl).searchParams.get("optional_scope"); | ||
| 292 | if (value == null) return []; | ||
| 293 | return [...new Set(value.split(/\s+/).filter(Boolean))]; | ||
| 294 | } catch { | ||
| 295 | return []; | ||
| 296 | } | ||
| 297 | }; | ||
| 298 | |||
| 266 | // --------------------------------------------------------------------------- | 299 | // --------------------------------------------------------------------------- |
| 267 | // Regional token-endpoint rebind | 300 | // Regional token-endpoint rebind |
| 268 | // | 301 | // |
| @@ -37,7 +37,10 @@ const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const; · 36 unmodified lines | |||
| 37 | * scopes (the MCP/no-template-scopes case). */ | 37 | * scopes (the MCP/no-template-scopes case). */ |
| 38 | const makeScopePluginWithId = <const TId extends string>( | 38 | const makeScopePluginWithId = <const TId extends string>( |
| 39 | id: TId, | 39 | id: TId, |
| 40 | config: { readonly scopes: readonly string[] | null }, | 40 | config: { |
| 41 | readonly scopes: readonly string[] | null; | ||
| 42 | readonly authorizationUrl?: string; | ||
| 43 | }, | ||
| 41 | options: { readonly discoversScopes?: boolean; readonly discoveryUrl?: string } = {}, | 44 | options: { readonly discoversScopes?: boolean; readonly discoveryUrl?: string } = {}, |
| 42 | ) => | 45 | ) => |
| 43 | definePlugin(() => ({ | 46 | definePlugin(() => ({ |
| @@ -49,7 +52,10 @@ const makeScopePluginWithId = <const TId extends string>( · 5 unmodified lines | |||
| 49 | }), | 52 | }), |
| 50 | invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), | 53 | invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), |
| 51 | describeAuthMethods: (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { | 54 | describeAuthMethods: (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { |
| 52 | const cfg = record.config as { readonly scopes?: readonly string[] | null } | null; | 55 | const cfg = record.config as { |
| 56 | readonly scopes?: readonly string[] | null; | ||
| 57 | readonly authorizationUrl?: string; | ||
| 58 | } | null; | ||
| 53 | const scopes = cfg?.scopes; | 59 | const scopes = cfg?.scopes; |
| 54 | if (scopes == null) { | 60 | if (scopes == null) { |
| 55 | // No declared oauth scopes. Server-targeting methods (MCP) expose a | 61 | // No declared oauth scopes. Server-targeting methods (MCP) expose a |
| @@ -73,7 +79,12 @@ const makeScopePluginWithId = <const TId extends string>( · 17 unmodified lines | |||
| 73 | label: "OAuth2", | 79 | label: "OAuth2", |
| 74 | kind: "oauth", | 80 | kind: "oauth", |
| 75 | template: String(TEMPLATE), | 81 | template: String(TEMPLATE), |
| 76 | oauth: { scopes }, | 82 | oauth: { |
| 83 | scopes, | ||
| 84 | ...(cfg?.authorizationUrl === undefined | ||
| 85 | ? {} | ||
| 86 | : { authorizationUrl: cfg.authorizationUrl }), | ||
| 87 | }, | ||
| 77 | }, | 88 | }, |
| 78 | ]; | 89 | ]; |
| 79 | }, | 90 | }, |
| @@ -82,13 +93,20 @@ const makeScopePluginWithId = <const TId extends string>( · 2 unmodified lines | |||
| 82 | ctx.core.integrations.register({ | 93 | ctx.core.integrations.register({ |
| 83 | slug: INTEG, | 94 | slug: INTEG, |
| 84 | description: "Acme", | 95 | description: "Acme", |
| 85 | config: { scopes: config.scopes }, | 96 | config: { |
| 97 | scopes: config.scopes, | ||
| 98 | ...(config.authorizationUrl === undefined | ||
| 99 | ? {} | ||
| 100 | : { authorizationUrl: config.authorizationUrl }), | ||
| 101 | }, | ||
| 86 | }), | 102 | }), |
| 87 | }), | 103 | }), |
| 88 | }))(); | 104 | }))(); |
| 89 | 105 | ||
| 90 | const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) => | 106 | const makeScopePlugin = (config: { |
| 91 | makeScopePluginWithId("acme", config); | 107 | readonly scopes: readonly string[] | null; |
| 108 | readonly authorizationUrl?: string; | ||
| 109 | }) => makeScopePluginWithId("acme", config); | ||
| 92 | 110 | ||
| 93 | const makeMcpScopePlugin = (config: { readonly scopes: readonly string[] | null }) => | 111 | const makeMcpScopePlugin = (config: { readonly scopes: readonly string[] | null }) => |
| 94 | makeScopePluginWithId("mcp", config, { discoversScopes: true }); | 112 | makeScopePluginWithId("mcp", config, { discoversScopes: true }); |
| @@ -226,6 +244,57 @@ describe("oauth.start integration-driven scopes", () => { · 131 unmodified lines | |||
| 226 | ), | 244 | ), |
| 227 | ); | 245 | ); |
| 228 | 246 | ||
| 247 | it.effect("moves integration-declared optional scopes out of scope into optional_scope", () => | ||
| 248 | Effect.scoped( | ||
| 249 | Effect.gen(function* () { | ||
| 250 | const declared = [ | ||
| 251 | "oauth", | ||
| 252 | "crm.objects.contacts.read", | ||
| 253 | "crm.objects.companies.read", | ||
| 254 | ] as const; | ||
| 255 | const optional = [ | ||
| 256 | "crm.objects.contacts.read", | ||
| 257 | "crm.objects.companies.read", | ||
| 258 | "content", | ||
| 259 | ] as const; | ||
| 260 | const server = yield* serveOAuthTestServer({ scopes: [...declared] }); | ||
| 261 | const plugins = [ | ||
| 262 | memoryCredentialsPlugin(), | ||
| 263 | makeScopePlugin({ | ||
| 264 | scopes: declared, | ||
| 265 | authorizationUrl: `${server.authorizationEndpoint}?optional_scope=${optional.join("+")}`, | ||
| 266 | }), | ||
| 267 | ] as const; | ||
| 268 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 269 | yield* executor.acme.seed(); | ||
| 270 | yield* executor.oauth.createClient({ | ||
| 271 | owner: "org", | ||
| 272 | slug: CLIENT, | ||
| 273 | authorizationUrl: server.authorizationEndpoint, | ||
| 274 | tokenUrl: server.tokenEndpoint, | ||
| 275 | grant: "authorization_code", | ||
| 276 | clientId: "test-client", | ||
| 277 | clientSecret: "test-secret", | ||
| 278 | }); | ||
| 279 | |||
| 280 | const started = yield* executor.oauth.start({ | ||
| 281 | owner: "org", | ||
| 282 | client: CLIENT, | ||
| 283 | clientOwner: "org", | ||
| 284 | name: ConnectionName.make("main"), | ||
| 285 | integration: INTEG, | ||
| 286 | template: TEMPLATE, | ||
| 287 | }); | ||
| 288 | expect(started.status).toBe("redirect"); | ||
| 289 | if (started.status !== "redirect") return; | ||
| 290 | |||
| 291 | const url = new URL(started.authorizationUrl); | ||
| 292 | expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["oauth"]); | ||
| 293 | expect(url.searchParams.get("optional_scope")?.split(/\s+/)).toEqual(optional); | ||
| 294 | }), | ||
| 295 | ), | ||
| 296 | ); | ||
| 297 | |||
| 229 | it.effect("filters stale declared scopes against authorization-server metadata", () => | 298 | it.effect("filters stale declared scopes against authorization-server metadata", () => |
| 230 | Effect.scoped( | 299 | Effect.scoped( |
| 231 | Effect.gen(function* () { | 300 | Effect.gen(function* () { |
| @@ -199,7 +199,14 @@ const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAu · 198 unmodified lines | |||
| 199 | * 8707 resource: a user may clear the client's resource (Entra v2 rejects | 199 | * 8707 resource: a user may clear the client's resource (Entra v2 rejects |
| 200 | * the parameter, #1789) without losing scope discovery. */ | 200 | * the parameter, #1789) without losing scope discovery. */ |
| 201 | export type OAuthScopePolicy = | 201 | export type OAuthScopePolicy = |
| 202 | | { readonly kind: "scopes"; readonly scopes: readonly string[] } | 202 | | { |
| 203 | readonly kind: "scopes"; | ||
| 204 | readonly scopes: readonly string[]; | ||
| 205 | /** Provider-specific scopes declared on the integration's authorization | ||
| 206 | * endpoint (HubSpot `optional_scope`). These must not also be sent in | ||
| 207 | * the RFC `scope` parameter. */ | ||
| 208 | readonly optionalScopes?: readonly string[]; | ||
| 209 | } | ||
| 203 | | { readonly kind: "discover"; readonly discoveryUrl: string }; | 210 | | { readonly kind: "discover"; readonly discoveryUrl: string }; |
| 204 | 211 | ||
| 205 | /** Everything the OAuth service needs from the executor: fuma access for the | 212 | /** Everything the OAuth service needs from the executor: fuma access for the |
| @@ -2006,11 +2013,23 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1800 unmodified lines | |||
| 2006 | : scopePolicy.kind === "discover" | 2013 | : scopePolicy.kind === "discover" |
| 2007 | ? requestedScopes | 2014 | ? requestedScopes |
| 2008 | : yield* filterAuthorizationCodeScopes(client, requestedScopes); | 2015 | : yield* filterAuthorizationCodeScopes(client, requestedScopes); |
| 2016 | const providerExtras = providerAuthorizeExtras(client.authorizationUrl); | ||
| 2017 | const workspaceOptionalScopes = firstPartyFlow | ||
| 2018 | ? [] | ||
| 2019 | : dedupeScopes([ | ||
| 2020 | ...(providerExtras.optional_scope ?? "").split(/\s+/).filter(Boolean), | ||
| 2021 | ...(scopePolicy.kind === "scopes" ? (scopePolicy.optionalScopes ?? []) : []), | ||
| 2022 | ]); | ||
| 2023 | const workspaceOptionalScopeSet = new Set(workspaceOptionalScopes); | ||
| 2009 | const completeAuthorizationScopes = dedupeScopes([ | 2024 | const completeAuthorizationScopes = dedupeScopes([ |
| 2010 | ...authorizationRequestedScopes, | 2025 | ...authorizationRequestedScopes.filter((scope) => !workspaceOptionalScopeSet.has(scope)), |
| 2011 | ...(firstParty?.additionalAuthorizationScopes ?? []), | 2026 | ...(firstParty?.additionalAuthorizationScopes ?? []), |
| 2012 | ...additionalAuthorizationLifecycleScopes(client), | 2027 | ...additionalAuthorizationLifecycleScopes(client), |
| 2013 | ]); | 2028 | ]); |
| 2029 | const completeRequestedScopes = dedupeScopes([ | ||
| 2030 | ...completeAuthorizationScopes, | ||
| 2031 | ...workspaceOptionalScopes, | ||
| 2032 | ]); | ||
| 2014 | 2033 | ||
| 2015 | // authorization_code: persist a session + build the authorize URL. | 2034 | // authorization_code: persist a session + build the authorize URL. |
| 2016 | const verifier = createPkceCodeVerifier(); | 2035 | const verifier = createPkceCodeVerifier(); |
| @@ -2072,7 +2091,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines | |||
| 2072 | payload: { | 2091 | payload: { |
| 2073 | owner: input.owner, | 2092 | owner: input.owner, |
| 2074 | clientOwner: input.clientOwner, | 2093 | clientOwner: input.clientOwner, |
| 2075 | requestedScopes: completeAuthorizationScopes, | 2094 | requestedScopes: completeRequestedScopes, |
| 2076 | }, | 2095 | }, |
| 2077 | expires_at: expiresAt, | 2096 | expires_at: expiresAt, |
| 2078 | created_at: now, | 2097 | created_at: now, |
| @@ -2094,7 +2113,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 15 unmodified lines | |||
| 2094 | // without these Google returns no refresh token and won't re-consent | 2113 | // without these Google returns no refresh token and won't re-consent |
| 2095 | // to widen scopes on reconnect. | 2114 | // to widen scopes on reconnect. |
| 2096 | extraParams: { | 2115 | extraParams: { |
| 2097 | ...providerAuthorizeExtras(client.authorizationUrl), | 2116 | ...providerExtras, |
| 2117 | ...(workspaceOptionalScopes.length > 0 | ||
| 2118 | ? { optional_scope: workspaceOptionalScopes.join(" ") } | ||
| 2119 | : {}), | ||
| 2098 | ...(firstParty?.authorizationExtraParams ?? {}), | 2120 | ...(firstParty?.authorizationExtraParams ?? {}), |
| 2099 | }, | 2121 | }, |
| 2100 | endpointUrlPolicy: deps.endpointUrlPolicy, | 2122 | endpointUrlPolicy: deps.endpointUrlPolicy, |