* Add registered first-party OAuth providers * Redact submitted client secret from token-request failures
Unified · Split
| @@ -42,19 +42,14 @@ 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"; | |
| 46 | −import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; | |
| 47 | 45 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; |
| 48 | −import { | |
| 49 | − IntegrationSlug, | |
| 50 | − type AnyPlugin, | |
| 51 | − type FirstPartyOAuthClientConfig, | |
| 52 | −} from "@executor-js/sdk"; | |
| 46 | +import { type AnyPlugin } from "@executor-js/sdk"; | |
| 53 | 47 | |
| 54 | 48 | import executorConfig from "../../executor.config"; |
| 55 | 49 | import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout"; |
| 56 | 50 | import { DbService } from "../db/db"; |
| 57 | 51 | import { cloudDbProviderLayer } from "../db/fuma"; |
| 52 | +import { firstPartyOAuthClientsFor } from "./first-party-oauth-clients"; | |
| 58 | 53 | |
| 59 | 54 | export { makeExecutionStack } from "@executor-js/api/server"; |
| 60 | 55 | |
| @@ -95,98 +90,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 34 unmodified lines | ||
| 95 | 90 | */ |
| 96 | 91 | export const CLOUD_MOUNT_PREFIX = "/api" as const; |
| 97 | 92 | |
| 98 | −// Consumer Google launch boundary. Keep this list aligned with the scopes | |
| 99 | −// submitted for the Executor-owned production app: ordinary Workspace services | |
| 100 | −// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script, | |
| 101 | −// BigQuery, and Cloud Resource Manager have materially different audiences or | |
| 102 | −// provider requirements and remain BYO OAuth. The same scope source builds each | |
| 103 | −// catalog auth template, preventing picker/start drift. | |
| 104 | −const GOOGLE_FIRST_PARTY_PRESET_IDS = [ | |
| 105 | − "google-calendar", | |
| 106 | − "google-meet", | |
| 107 | − "google-gmail", | |
| 108 | − "google-sheets", | |
| 109 | − "google-drive", | |
| 110 | − "google-docs", | |
| 111 | − "google-slides", | |
| 112 | − "google-forms", | |
| 113 | − "google-tasks", | |
| 114 | − "google-people", | |
| 115 | − "google-photos-library", | |
| 116 | − "google-photos-picker", | |
| 117 | − "google-search-console", | |
| 118 | −] as const; | |
| 119 | − | |
| 120 | −const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [ | |
| 121 | − ...new Set([ | |
| 122 | − ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset), | |
| 123 | − // Connections created before the full-Gmail review retain this declared | |
| 124 | − // scope on reconnect. New Gmail presets request `mail.google.com`. | |
| 125 | − "https://www.googleapis.com/auth/gmail.modify", | |
| 126 | − ]), | |
| 127 | −]; | |
| 128 | − | |
| 129 | −// Executor-owned provider apps, enabled per provider by setting BOTH env vars | |
| 130 | −// (id + secret). Each provider-side registration must list | |
| 131 | −// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug | |
| 132 | −// travels inside OAuth `state`, so the single static callback serves every org. | |
| 133 | −// | |
| 134 | −// The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` / | |
| 135 | −// `_TOKEN_URL` overrides exist so tests and dev instances can point the app at | |
| 136 | −// an emulated provider (`@executor-js/emulate`) and run the complete flow. | |
| 137 | −// Production leaves them unset. | |
| 138 | −export const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [ | |
| 139 | − ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET | |
| 140 | − ? [ | |
| 141 | − { | |
| 142 | − name: "github", | |
| 143 | − authorizationUrl: | |
| 144 | − env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize", | |
| 145 | − tokenUrl: | |
| 146 | − env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token", | |
| 147 | − clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID, | |
| 148 | − clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET, | |
| 149 | − integrations: [IntegrationSlug.make("github_rest")], | |
| 150 | − // GitHub App user access tokens do not use classic OAuth scopes; | |
| 151 | − // their capabilities come from the app's registered permissions. | |
| 152 | − authorizationScopes: [], | |
| 153 | − }, | |
| 154 | − ] | |
| 155 | − : []), | |
| 156 | − ...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET | |
| 157 | − ? [ | |
| 158 | − { | |
| 159 | − name: "google", | |
| 160 | − authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 161 | − tokenUrl: "https://oauth2.googleapis.com/token", | |
| 162 | − clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID, | |
| 163 | − clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, | |
| 164 | − allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES, | |
| 165 | − // Withdrawn from the connect picker: no new connection is offered the | |
| 166 | − // Executor-owned Google app. The entry stays declared on purpose — | |
| 167 | − // every connection already minted against it keeps refreshing and | |
| 168 | − // reconnecting through it. Deleting this block, or unsetting the env | |
| 169 | − // vars, would strand those connections instead. | |
| 170 | − unlisted: true, | |
| 171 | − }, | |
| 172 | − ] | |
| 173 | − : []), | |
| 174 | − ...(env.FIRST_PARTY_SLACK_CLIENT_ID && env.FIRST_PARTY_SLACK_CLIENT_SECRET | |
| 175 | − ? [ | |
| 176 | − { | |
| 177 | − name: "slack", | |
| 178 | − authorizationUrl: "https://slack.com/oauth/v2_user/authorize", | |
| 179 | − tokenUrl: "https://slack.com/api/oauth.v2.user.access", | |
| 180 | − resource: "https://mcp.slack.com", | |
| 181 | − clientId: env.FIRST_PARTY_SLACK_CLIENT_ID, | |
| 182 | − clientSecret: env.FIRST_PARTY_SLACK_CLIENT_SECRET, | |
| 183 | − integrations: [IntegrationSlug.make("slack")], | |
| 184 | − allowedScopes: slackMcpUserScopes, | |
| 185 | − }, | |
| 186 | − ] | |
| 187 | − : []), | |
| 188 | −]; | |
| 189 | − | |
| 190 | 93 | export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ |
| 191 | 94 | // SSRF / private-network egress guard. Config-driven, NOT a test flag: |
| 192 | 95 | // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); |
| @@ -198,7 +101,7 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 5 unmodified lines | ||
| 198 | 101 | // WorkOS Vault is cloud's credential storage implementation detail, not a |
| 199 | 102 | // user-selectable provider surface. |
| 200 | 103 | exposeCredentialProviders: false, |
| 201 | − firstPartyOAuthClients: cloudFirstPartyOAuthClients(), | |
| 104 | + firstPartyOAuthClients: firstPartyOAuthClientsFor(env), | |
| 202 | 105 | // Workers cancel request-scoped I/O once the response settles; the ambient |
| 203 | 106 | // `waitUntil` binds to the in-flight invocation (HTTP request or DO call), |
| 204 | 107 | // so stale tool-catalog rebuilds that outlive a read still converge. |
| @@ -1,7 +1,100 @@ | ||
| 1 | −import { env } from "cloudflare:workers"; | |
| 2 | −import { beforeAll, describe, expect, it } from "@effect/vitest"; | |
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 3 | 2 | |
| 4 | −import { cloudFirstPartyOAuthClients } from "./execution-stack"; | |
| 3 | +import { | |
| 4 | + firstPartyOAuthClientsFor, | |
| 5 | + type FirstPartyOAuthClientEnv, | |
| 6 | +} from "./first-party-oauth-clients"; | |
| 7 | + | |
| 8 | +const completeEnv: FirstPartyOAuthClientEnv = { | |
| 9 | + FIRST_PARTY_AIRTABLE_CLIENT_ID: "airtable-id", | |
| 10 | + FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "airtable-secret", | |
| 11 | + FIRST_PARTY_ATLASSIAN_CLIENT_ID: "atlassian-id", | |
| 12 | + FIRST_PARTY_ATLASSIAN_CLIENT_SECRET: "atlassian-secret", | |
| 13 | + FIRST_PARTY_BOX_CLIENT_ID: "box-id", | |
| 14 | + FIRST_PARTY_BOX_CLIENT_SECRET: "box-secret", | |
| 15 | + FIRST_PARTY_CLICKUP_CLIENT_ID: "clickup-id", | |
| 16 | + FIRST_PARTY_CLICKUP_CLIENT_SECRET: "clickup-secret", | |
| 17 | + FIRST_PARTY_FIGMA_CLIENT_ID: "figma-id", | |
| 18 | + FIRST_PARTY_FIGMA_CLIENT_SECRET: "figma-secret", | |
| 19 | + FIRST_PARTY_GITHUB_CLIENT_ID: "github-id", | |
| 20 | + FIRST_PARTY_GITHUB_CLIENT_SECRET: "github-secret", | |
| 21 | + FIRST_PARTY_GITLAB_CLIENT_ID: "gitlab-id", | |
| 22 | + FIRST_PARTY_GITLAB_CLIENT_SECRET: "gitlab-secret", | |
| 23 | + FIRST_PARTY_GOOGLE_CLIENT_ID: "google-id", | |
| 24 | + FIRST_PARTY_GOOGLE_CLIENT_SECRET: "google-secret", | |
| 25 | + FIRST_PARTY_HUBSPOT_CLIENT_ID: "hubspot-id", | |
| 26 | + FIRST_PARTY_HUBSPOT_CLIENT_SECRET: "hubspot-secret", | |
| 27 | + FIRST_PARTY_LINEAR_CLIENT_ID: "linear-id", | |
| 28 | + FIRST_PARTY_LINEAR_CLIENT_SECRET: "linear-secret", | |
| 29 | + FIRST_PARTY_MICROSOFT_CLIENT_ID: "microsoft-id", | |
| 30 | + FIRST_PARTY_MICROSOFT_CLIENT_SECRET: "microsoft-secret", | |
| 31 | + FIRST_PARTY_NOTION_CLIENT_ID: "notion-id", | |
| 32 | + FIRST_PARTY_NOTION_CLIENT_SECRET: "notion-secret", | |
| 33 | + FIRST_PARTY_SLACK_CLIENT_ID: "slack-id", | |
| 34 | + FIRST_PARTY_SLACK_CLIENT_SECRET: "slack-secret", | |
| 35 | +}; | |
| 36 | + | |
| 37 | +describe("cloud first-party OAuth clients", () => { | |
| 38 | + it("enables every registered OAuth 2 provider from complete secret pairs", () => { | |
| 39 | + const clients = firstPartyOAuthClientsFor(completeEnv); | |
| 40 | + | |
| 41 | + expect(clients.map((client) => client.name)).toEqual([ | |
| 42 | + "airtable", | |
| 43 | + "atlassian", | |
| 44 | + "box", | |
| 45 | + "clickup", | |
| 46 | + "figma", | |
| 47 | + "github", | |
| 48 | + "gitlab", | |
| 49 | + "google", | |
| 50 | + "hubspot", | |
| 51 | + "linear", | |
| 52 | + "microsoft", | |
| 53 | + "notion", | |
| 54 | + "slack", | |
| 55 | + ]); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + it("fails closed when either half of a provider secret pair is absent", () => { | |
| 59 | + expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_ID: "id" })).toEqual([]); | |
| 60 | + expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "secret" })).toEqual([]); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it("carries provider-specific authorization and token contracts", () => { | |
| 64 | + const byName = new Map( | |
| 65 | + firstPartyOAuthClientsFor(completeEnv).map((client) => [client.name, client]), | |
| 66 | + ); | |
| 67 | + | |
| 68 | + expect(byName.get("airtable")).toMatchObject({ | |
| 69 | + tokenEndpointAuthMethod: "basic", | |
| 70 | + }); | |
| 71 | + expect(byName.get("atlassian")).toMatchObject({ | |
| 72 | + tokenRequestFormat: "json", | |
| 73 | + authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" }, | |
| 74 | + }); | |
| 75 | + expect(byName.get("figma")).toMatchObject({ | |
| 76 | + tokenEndpointAuthMethod: "basic", | |
| 77 | + allowedScopes: expect.arrayContaining(["folder_metadata:read", "folders:read"]), | |
| 78 | + }); | |
| 79 | + expect(byName.get("hubspot")).toMatchObject({ | |
| 80 | + tokenUrl: "https://api.hubapi.com/oauth/v3/token", | |
| 81 | + authorizationExtraParams: { | |
| 82 | + optional_scope: "content crm.objects.custom.read crm.schemas.custom.read", | |
| 83 | + }, | |
| 84 | + }); | |
| 85 | + expect(byName.get("linear")).toMatchObject({ authorizationScopeSeparator: "," }); | |
| 86 | + expect(byName.get("microsoft")).toMatchObject({ | |
| 87 | + additionalAuthorizationScopes: ["offline_access"], | |
| 88 | + allowedScopes: expect.arrayContaining(["Mail.ReadWrite", "Files.ReadWrite.All"]), | |
| 89 | + }); | |
| 90 | + expect(byName.get("notion")).toMatchObject({ | |
| 91 | + authorizationScopes: [], | |
| 92 | + authorizationExtraParams: { owner: "user" }, | |
| 93 | + tokenEndpointAuthMethod: "basic", | |
| 94 | + tokenRequestFormat: "json", | |
| 95 | + }); | |
| 96 | + }); | |
| 97 | +}); | |
| 5 | 98 | |
| 6 | 99 | // The reviewed consumer scope boundary of the Executor-owned Google app. |
| 7 | 100 | // |
| @@ -12,13 +105,9 @@ import { cloudFirstPartyOAuthClients } from "./execution-stack"; · 4 unmodified lines | ||
| 12 | 105 | // scopes an `oauth.start` requests, and that admin scopes are refused). |
| 13 | 106 | const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`; |
| 14 | 107 | |
| 15 | −describe("cloud first-party oauth clients", () => { | |
| 16 | − beforeAll(() => { | |
| 17 | − env.FIRST_PARTY_GOOGLE_CLIENT_ID = "test-google-client"; | |
| 18 | − env.FIRST_PARTY_GOOGLE_CLIENT_SECRET = "test-google-secret"; | |
| 19 | − }); | |
| 20 | − | |
| 21 | − const google = () => cloudFirstPartyOAuthClients().find((client) => client.name === "google"); | |
| 108 | +describe("cloud first-party Google app", () => { | |
| 109 | + const google = () => | |
| 110 | + firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google"); | |
| 22 | 111 | |
| 23 | 112 | it("declares the Google app but withholds it from every listing", () => { |
| 24 | 113 | const client = google(); |
| @@ -0,0 +1,336 @@ | ||
| 1 | +import { FIGMA_SUPPORTED_OAUTH_SCOPES } from "@executor-js/plugin-openapi/presets"; | |
| 2 | +import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google"; | |
| 3 | +import { | |
| 4 | + MICROSOFT_AUTHORIZATION_URL, | |
| 5 | + MICROSOFT_TOKEN_URL, | |
| 6 | +} from "@executor-js/plugin-openapi/providers/microsoft"; | |
| 7 | +import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; | |
| 8 | +import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk"; | |
| 9 | + | |
| 10 | +/** Cloud secret bindings that enable host-operated OAuth clients. A provider | |
| 11 | + * is absent unless both values in its pair are present. */ | |
| 12 | +export interface FirstPartyOAuthClientEnv { | |
| 13 | + readonly FIRST_PARTY_AIRTABLE_CLIENT_ID?: string; | |
| 14 | + readonly FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string; | |
| 15 | + readonly FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string; | |
| 16 | + readonly FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string; | |
| 17 | + readonly FIRST_PARTY_BOX_CLIENT_ID?: string; | |
| 18 | + readonly FIRST_PARTY_BOX_CLIENT_SECRET?: string; | |
| 19 | + readonly FIRST_PARTY_CLICKUP_CLIENT_ID?: string; | |
| 20 | + readonly FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string; | |
| 21 | + readonly FIRST_PARTY_FIGMA_CLIENT_ID?: string; | |
| 22 | + readonly FIRST_PARTY_FIGMA_CLIENT_SECRET?: string; | |
| 23 | + readonly FIRST_PARTY_GITHUB_CLIENT_ID?: string; | |
| 24 | + readonly FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; | |
| 25 | + readonly FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; | |
| 26 | + readonly FIRST_PARTY_GITHUB_TOKEN_URL?: string; | |
| 27 | + readonly FIRST_PARTY_GITLAB_CLIENT_ID?: string; | |
| 28 | + readonly FIRST_PARTY_GITLAB_CLIENT_SECRET?: string; | |
| 29 | + readonly FIRST_PARTY_GOOGLE_CLIENT_ID?: string; | |
| 30 | + readonly FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; | |
| 31 | + readonly FIRST_PARTY_HUBSPOT_CLIENT_ID?: string; | |
| 32 | + readonly FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string; | |
| 33 | + readonly FIRST_PARTY_LINEAR_CLIENT_ID?: string; | |
| 34 | + readonly FIRST_PARTY_LINEAR_CLIENT_SECRET?: string; | |
| 35 | + readonly FIRST_PARTY_MICROSOFT_CLIENT_ID?: string; | |
| 36 | + readonly FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string; | |
| 37 | + readonly FIRST_PARTY_NOTION_CLIENT_ID?: string; | |
| 38 | + readonly FIRST_PARTY_NOTION_CLIENT_SECRET?: string; | |
| 39 | + readonly FIRST_PARTY_SLACK_CLIENT_ID?: string; | |
| 40 | + readonly FIRST_PARTY_SLACK_CLIENT_SECRET?: string; | |
| 41 | +} | |
| 42 | + | |
| 43 | +const AIRTABLE_SCOPES = [ | |
| 44 | + "data.recordComments:read", | |
| 45 | + "data.recordComments:write", | |
| 46 | + "data.records:read", | |
| 47 | + "data.records:write", | |
| 48 | + "data.records:manage", | |
| 49 | + "schema.bases:read", | |
| 50 | + "schema.bases:write", | |
| 51 | + "user.email:read", | |
| 52 | + "workspacesAndBases:read", | |
| 53 | + "workspacesAndBases:write", | |
| 54 | + "workspacesAndBases.shares:manage", | |
| 55 | + "webhook:manage", | |
| 56 | +] as const; | |
| 57 | + | |
| 58 | +const ATLASSIAN_SCOPES = [ | |
| 59 | + "read:me", | |
| 60 | + "read:account", | |
| 61 | + "read:jira-work", | |
| 62 | + "manage:jira-project", | |
| 63 | + "manage:jira-configuration", | |
| 64 | + "read:jira-user", | |
| 65 | + "write:jira-work", | |
| 66 | + "manage:jira-webhook", | |
| 67 | + "read:servicedesk-request", | |
| 68 | + "manage:servicedesk-customer", | |
| 69 | + "write:servicedesk-request", | |
| 70 | + "write:confluence-content", | |
| 71 | + "read:confluence-space.summary", | |
| 72 | + "write:confluence-space", | |
| 73 | + "write:confluence-file", | |
| 74 | + "read:confluence-props", | |
| 75 | + "write:confluence-props", | |
| 76 | + "manage:confluence-configuration", | |
| 77 | + "read:confluence-content.all", | |
| 78 | + "read:confluence-content.summary", | |
| 79 | + "search:confluence", | |
| 80 | + "read:confluence-content.permission", | |
| 81 | + "read:confluence-user", | |
| 82 | + "read:confluence-groups", | |
| 83 | + "write:confluence-groups", | |
| 84 | + "offline_access", | |
| 85 | +] as const; | |
| 86 | + | |
| 87 | +const BOX_SCOPES = [ | |
| 88 | + "root_readonly", | |
| 89 | + "root_readwrite", | |
| 90 | + "sign_requests.readwrite", | |
| 91 | + "ai.readwrite", | |
| 92 | + "manage_webhook", | |
| 93 | + "manage_triggers", | |
| 94 | +] as const; | |
| 95 | + | |
| 96 | +const GITLAB_SCOPES = [ | |
| 97 | + "api", | |
| 98 | + "read_api", | |
| 99 | + "read_user", | |
| 100 | + "create_runner", | |
| 101 | + "manage_runner", | |
| 102 | + "k8s_proxy", | |
| 103 | + "mcp", | |
| 104 | + "mcp_orbit", | |
| 105 | + "read_repository", | |
| 106 | + "write_repository", | |
| 107 | + "read_registry", | |
| 108 | + "write_registry", | |
| 109 | + "read_virtual_registry", | |
| 110 | + "write_virtual_registry", | |
| 111 | + "read_observability", | |
| 112 | + "write_observability", | |
| 113 | + "ai_features", | |
| 114 | + "openid", | |
| 115 | + "profile", | |
| 116 | + "email", | |
| 117 | +] as const; | |
| 118 | + | |
| 119 | +const HUBSPOT_REQUIRED_SCOPES = [ | |
| 120 | + "oauth", | |
| 121 | + "account-info.security.read", | |
| 122 | + "cms.domains.read", | |
| 123 | + "cms.domains.write", | |
| 124 | + "crm.export", | |
| 125 | + "crm.import", | |
| 126 | + "crm.lists.read", | |
| 127 | + "crm.lists.write", | |
| 128 | + "crm.objects.companies.read", | |
| 129 | + "crm.objects.companies.write", | |
| 130 | + "crm.objects.contacts.read", | |
| 131 | + "crm.objects.contacts.write", | |
| 132 | + "crm.objects.deals.read", | |
| 133 | + "crm.objects.deals.write", | |
| 134 | + "crm.objects.marketing_events.read", | |
| 135 | + "crm.objects.marketing_events.write", | |
| 136 | + "crm.objects.owners.read", | |
| 137 | + "crm.objects.quotes.read", | |
| 138 | + "crm.objects.quotes.write", | |
| 139 | + "crm.schemas.companies.read", | |
| 140 | + "crm.schemas.companies.write", | |
| 141 | + "crm.schemas.contacts.read", | |
| 142 | + "crm.schemas.contacts.write", | |
| 143 | + "sales-email-read", | |
| 144 | + "settings.users.read", | |
| 145 | + "settings.users.write", | |
| 146 | + "tickets", | |
| 147 | + "timeline", | |
| 148 | +] as const; | |
| 149 | + | |
| 150 | +const HUBSPOT_OPTIONAL_SCOPES = [ | |
| 151 | + "content", | |
| 152 | + "crm.objects.custom.read", | |
| 153 | + "crm.schemas.custom.read", | |
| 154 | +] as const; | |
| 155 | + | |
| 156 | +const MICROSOFT_SCOPES = [ | |
| 157 | + "User.Read", | |
| 158 | + "Calendars.ReadWrite", | |
| 159 | + "Channel.ReadBasic.All", | |
| 160 | + "ChannelMessage.Read.All", | |
| 161 | + "ChannelMessage.Send", | |
| 162 | + "Chat.ReadWrite", | |
| 163 | + "Files.ReadWrite.All", | |
| 164 | + "Mail.ReadWrite", | |
| 165 | + "Mail.Send", | |
| 166 | + "MailboxSettings.ReadWrite", | |
| 167 | + "OnlineMeetings.ReadWrite", | |
| 168 | + "Sites.ReadWrite.All", | |
| 169 | + "Team.ReadBasic.All", | |
| 170 | + "offline_access", | |
| 171 | +] as const; | |
| 172 | + | |
| 173 | +// Consumer Google launch boundary. Keep this list aligned with the scopes | |
| 174 | +// submitted for the Executor-owned production app: ordinary Workspace services | |
| 175 | +// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script, | |
| 176 | +// BigQuery, and Cloud Resource Manager have materially different audiences or | |
| 177 | +// provider requirements and remain BYO OAuth. The same scope source builds each | |
| 178 | +// catalog auth template, preventing picker/start drift. | |
| 179 | +const GOOGLE_FIRST_PARTY_PRESET_IDS = [ | |
| 180 | + "google-calendar", | |
| 181 | + "google-meet", | |
| 182 | + "google-gmail", | |
| 183 | + "google-sheets", | |
| 184 | + "google-drive", | |
| 185 | + "google-docs", | |
| 186 | + "google-slides", | |
| 187 | + "google-forms", | |
| 188 | + "google-tasks", | |
| 189 | + "google-people", | |
| 190 | + "google-photos-library", | |
| 191 | + "google-photos-picker", | |
| 192 | + "google-search-console", | |
| 193 | +] as const; | |
| 194 | + | |
| 195 | +const GOOGLE_ALLOWED_SCOPES: readonly string[] = [ | |
| 196 | + ...new Set([ | |
| 197 | + ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset), | |
| 198 | + // Connections created before the full-Gmail review retain this declared | |
| 199 | + // scope on reconnect. New Gmail presets request `mail.google.com`. | |
| 200 | + "https://www.googleapis.com/auth/gmail.modify", | |
| 201 | + ]), | |
| 202 | +]; | |
| 203 | + | |
| 204 | +const client = ( | |
| 205 | + clientId: string | undefined, | |
| 206 | + clientSecret: string | undefined, | |
| 207 | + config: Omit<FirstPartyOAuthClientConfig, "clientId" | "clientSecret">, | |
| 208 | +): readonly FirstPartyOAuthClientConfig[] => | |
| 209 | + clientId && clientSecret ? [{ ...config, clientId, clientSecret }] : []; | |
| 210 | + | |
| 211 | +/** Build the enabled first-party registry from secret bindings. Provider | |
| 212 | + * protocol details and scope ceilings live here so the cloud composition root | |
| 213 | + * cannot drift from the registered production clients. | |
| 214 | + * | |
| 215 | + * Each provider-side registration must list | |
| 216 | + * `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug | |
| 217 | + * travels inside OAuth `state`, so the single static callback serves every | |
| 218 | + * org. | |
| 219 | + * | |
| 220 | + * The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` / | |
| 221 | + * `_TOKEN_URL` overrides exist so tests and dev instances can point the app at | |
| 222 | + * an emulated provider (`@executor-js/emulate`) and run the complete flow. | |
| 223 | + * Production leaves them unset. */ | |
| 224 | +export const firstPartyOAuthClientsFor = ( | |
| 225 | + env: FirstPartyOAuthClientEnv, | |
| 226 | +): readonly FirstPartyOAuthClientConfig[] => [ | |
| 227 | + ...client(env.FIRST_PARTY_AIRTABLE_CLIENT_ID, env.FIRST_PARTY_AIRTABLE_CLIENT_SECRET, { | |
| 228 | + name: "airtable", | |
| 229 | + authorizationUrl: "https://airtable.com/oauth2/v1/authorize", | |
| 230 | + tokenUrl: "https://airtable.com/oauth2/v1/token", | |
| 231 | + tokenEndpointAuthMethod: "basic", | |
| 232 | + authorizationScopes: AIRTABLE_SCOPES, | |
| 233 | + allowedScopes: AIRTABLE_SCOPES, | |
| 234 | + }), | |
| 235 | + ...client(env.FIRST_PARTY_ATLASSIAN_CLIENT_ID, env.FIRST_PARTY_ATLASSIAN_CLIENT_SECRET, { | |
| 236 | + name: "atlassian", | |
| 237 | + authorizationUrl: "https://auth.atlassian.com/authorize", | |
| 238 | + tokenUrl: "https://auth.atlassian.com/oauth/token", | |
| 239 | + tokenRequestFormat: "json", | |
| 240 | + authorizationScopes: ATLASSIAN_SCOPES, | |
| 241 | + allowedScopes: ATLASSIAN_SCOPES, | |
| 242 | + authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" }, | |
| 243 | + }), | |
| 244 | + ...client(env.FIRST_PARTY_BOX_CLIENT_ID, env.FIRST_PARTY_BOX_CLIENT_SECRET, { | |
| 245 | + name: "box", | |
| 246 | + authorizationUrl: "https://account.box.com/api/oauth2/authorize", | |
| 247 | + tokenUrl: "https://api.box.com/oauth2/token", | |
| 248 | + authorizationScopes: BOX_SCOPES, | |
| 249 | + allowedScopes: BOX_SCOPES, | |
| 250 | + }), | |
| 251 | + ...client(env.FIRST_PARTY_CLICKUP_CLIENT_ID, env.FIRST_PARTY_CLICKUP_CLIENT_SECRET, { | |
| 252 | + name: "clickup", | |
| 253 | + authorizationUrl: "https://app.clickup.com/api", | |
| 254 | + tokenUrl: "https://api.clickup.com/api/v2/oauth/token", | |
| 255 | + tokenRequestFormat: "json", | |
| 256 | + authorizationScopes: [], | |
| 257 | + }), | |
| 258 | + ...client(env.FIRST_PARTY_FIGMA_CLIENT_ID, env.FIRST_PARTY_FIGMA_CLIENT_SECRET, { | |
| 259 | + name: "figma", | |
| 260 | + authorizationUrl: "https://www.figma.com/oauth", | |
| 261 | + tokenUrl: "https://api.figma.com/v1/oauth/token", | |
| 262 | + tokenEndpointAuthMethod: "basic", | |
| 263 | + integrations: [IntegrationSlug.make("figma_api")], | |
| 264 | + authorizationScopes: FIGMA_SUPPORTED_OAUTH_SCOPES, | |
| 265 | + allowedScopes: FIGMA_SUPPORTED_OAUTH_SCOPES, | |
| 266 | + }), | |
| 267 | + ...client(env.FIRST_PARTY_GITHUB_CLIENT_ID, env.FIRST_PARTY_GITHUB_CLIENT_SECRET, { | |
| 268 | + name: "github", | |
| 269 | + authorizationUrl: | |
| 270 | + env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize", | |
| 271 | + tokenUrl: env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token", | |
| 272 | + integrations: [IntegrationSlug.make("github_rest")], | |
| 273 | + // GitHub App user access tokens do not use classic OAuth scopes; their | |
| 274 | + // capabilities come from the app's registered permissions. | |
| 275 | + authorizationScopes: [], | |
| 276 | + }), | |
| 277 | + ...client(env.FIRST_PARTY_GITLAB_CLIENT_ID, env.FIRST_PARTY_GITLAB_CLIENT_SECRET, { | |
| 278 | + name: "gitlab", | |
| 279 | + authorizationUrl: "https://gitlab.com/oauth/authorize", | |
| 280 | + tokenUrl: "https://gitlab.com/oauth/token", | |
| 281 | + authorizationScopes: GITLAB_SCOPES, | |
| 282 | + allowedScopes: GITLAB_SCOPES, | |
| 283 | + }), | |
| 284 | + ...client(env.FIRST_PARTY_GOOGLE_CLIENT_ID, env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, { | |
| 285 | + name: "google", | |
| 286 | + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 287 | + tokenUrl: "https://oauth2.googleapis.com/token", | |
| 288 | + allowedScopes: GOOGLE_ALLOWED_SCOPES, | |
| 289 | + // Withdrawn from the connect picker: no new connection is offered the | |
| 290 | + // Executor-owned Google app. The entry stays declared on purpose — every | |
| 291 | + // connection already minted against it keeps refreshing and reconnecting | |
| 292 | + // through it. Deleting this block, or unsetting the env vars, would strand | |
| 293 | + // those connections instead. | |
| 294 | + unlisted: true, | |
| 295 | + }), | |
| 296 | + ...client(env.FIRST_PARTY_HUBSPOT_CLIENT_ID, env.FIRST_PARTY_HUBSPOT_CLIENT_SECRET, { | |
| 297 | + name: "hubspot", | |
| 298 | + authorizationUrl: "https://app.hubspot.com/oauth/authorize", | |
| 299 | + tokenUrl: "https://api.hubapi.com/oauth/v3/token", | |
| 300 | + authorizationScopes: HUBSPOT_REQUIRED_SCOPES, | |
| 301 | + allowedScopes: [...HUBSPOT_REQUIRED_SCOPES, ...HUBSPOT_OPTIONAL_SCOPES], | |
| 302 | + authorizationExtraParams: { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") }, | |
| 303 | + }), | |
| 304 | + ...client(env.FIRST_PARTY_LINEAR_CLIENT_ID, env.FIRST_PARTY_LINEAR_CLIENT_SECRET, { | |
| 305 | + name: "linear", | |
| 306 | + authorizationUrl: "https://linear.app/oauth/authorize", | |
| 307 | + tokenUrl: "https://api.linear.app/oauth/token", | |
| 308 | + authorizationScopes: ["read", "write"], | |
| 309 | + authorizationScopeSeparator: ",", | |
| 310 | + allowedScopes: ["read", "write"], | |
| 311 | + }), | |
| 312 | + ...client(env.FIRST_PARTY_MICROSOFT_CLIENT_ID, env.FIRST_PARTY_MICROSOFT_CLIENT_SECRET, { | |
| 313 | + name: "microsoft", | |
| 314 | + authorizationUrl: MICROSOFT_AUTHORIZATION_URL, | |
| 315 | + tokenUrl: MICROSOFT_TOKEN_URL, | |
| 316 | + allowedScopes: MICROSOFT_SCOPES, | |
| 317 | + additionalAuthorizationScopes: ["offline_access"], | |
| 318 | + }), | |
| 319 | + ...client(env.FIRST_PARTY_NOTION_CLIENT_ID, env.FIRST_PARTY_NOTION_CLIENT_SECRET, { | |
| 320 | + name: "notion", | |
| 321 | + authorizationUrl: "https://api.notion.com/v1/oauth/authorize", | |
| 322 | + tokenUrl: "https://api.notion.com/v1/oauth/token", | |
| 323 | + authorizationExtraParams: { owner: "user" }, | |
| 324 | + tokenEndpointAuthMethod: "basic", | |
| 325 | + tokenRequestFormat: "json", | |
| 326 | + authorizationScopes: [], | |
| 327 | + }), | |
| 328 | + ...client(env.FIRST_PARTY_SLACK_CLIENT_ID, env.FIRST_PARTY_SLACK_CLIENT_SECRET, { | |
| 329 | + name: "slack", | |
| 330 | + authorizationUrl: "https://slack.com/oauth/v2_user/authorize", | |
| 331 | + tokenUrl: "https://slack.com/api/oauth.v2.user.access", | |
| 332 | + resource: "https://mcp.slack.com", | |
| 333 | + integrations: [IntegrationSlug.make("slack")], | |
| 334 | + allowedScopes: slackMcpUserScopes, | |
| 335 | + }), | |
| 336 | +]; | |
| @@ -72,6 +72,16 @@ declare global { · 71 unmodified lines | ||
| 72 | 72 | // unset pair simply ships no first-party app for that provider. The |
| 73 | 73 | // registered callback on the provider side must be |
| 74 | 74 | // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`. |
| 75 | + FIRST_PARTY_AIRTABLE_CLIENT_ID?: string; | |
| 76 | + FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string; | |
| 77 | + FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string; | |
| 78 | + FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string; | |
| 79 | + FIRST_PARTY_BOX_CLIENT_ID?: string; | |
| 80 | + FIRST_PARTY_BOX_CLIENT_SECRET?: string; | |
| 81 | + FIRST_PARTY_CLICKUP_CLIENT_ID?: string; | |
| 82 | + FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string; | |
| 83 | + FIRST_PARTY_FIGMA_CLIENT_ID?: string; | |
| 84 | + FIRST_PARTY_FIGMA_CLIENT_SECRET?: string; | |
| 75 | 85 | FIRST_PARTY_GITHUB_CLIENT_ID?: string; |
| 76 | 86 | FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; |
| 77 | 87 | // Endpoint overrides for the GitHub first-party app, so tests/dev can |
| @@ -79,8 +89,18 @@ declare global { · 1 unmodified line | ||
| 79 | 89 | // production (the real github.com endpoints are the defaults). |
| 80 | 90 | FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; |
| 81 | 91 | FIRST_PARTY_GITHUB_TOKEN_URL?: string; |
| 92 | + FIRST_PARTY_GITLAB_CLIENT_ID?: string; | |
| 93 | + FIRST_PARTY_GITLAB_CLIENT_SECRET?: string; | |
| 82 | 94 | FIRST_PARTY_GOOGLE_CLIENT_ID?: string; |
| 83 | 95 | FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; |
| 96 | + FIRST_PARTY_HUBSPOT_CLIENT_ID?: string; | |
| 97 | + FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string; | |
| 98 | + FIRST_PARTY_LINEAR_CLIENT_ID?: string; | |
| 99 | + FIRST_PARTY_LINEAR_CLIENT_SECRET?: string; | |
| 100 | + FIRST_PARTY_MICROSOFT_CLIENT_ID?: string; | |
| 101 | + FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string; | |
| 102 | + FIRST_PARTY_NOTION_CLIENT_ID?: string; | |
| 103 | + FIRST_PARTY_NOTION_CLIENT_SECRET?: string; | |
| 84 | 104 | FIRST_PARTY_SLACK_CLIENT_ID?: string; |
| 85 | 105 | FIRST_PARTY_SLACK_CLIENT_SECRET?: string; |
| 86 | 106 | |
| @@ -2000,6 +2000,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1999 unmodified lines | ||
| 2000 | 2000 | readonly tokenUrl: string; |
| 2001 | 2001 | readonly grant: string; |
| 2002 | 2002 | readonly resource: string | null; |
| 2003 | + readonly tokenEndpointAuthMethod?: "body" | "basic"; | |
| 2004 | + readonly tokenRequestFormat?: "form" | "json"; | |
| 2003 | 2005 | } |
| 2004 | 2006 | |
| 2005 | 2007 | /** What drove a refresh: the pre-call expiry check (`proactive`), or an |
| @@ -2307,7 +2309,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 301 unmodified lines | ||
| 2307 | 2309 | clientSecret: firstParty.clientSecret, |
| 2308 | 2310 | tokenUrl: firstParty.tokenUrl, |
| 2309 | 2311 | grant: "authorization_code", |
| 2310 | − resource: null, | |
| 2312 | + resource: firstParty.resource ?? null, | |
| 2313 | + ...(firstParty.tokenEndpointAuthMethod === undefined | |
| 2314 | + ? {} | |
| 2315 | + : { tokenEndpointAuthMethod: firstParty.tokenEndpointAuthMethod }), | |
| 2316 | + ...(firstParty.tokenRequestFormat === undefined | |
| 2317 | + ? {} | |
| 2318 | + : { tokenRequestFormat: firstParty.tokenRequestFormat }), | |
| 2311 | 2319 | } satisfies RefreshClient; |
| 2312 | 2320 | } |
| 2313 | 2321 | const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner; |
| @@ -2439,6 +2447,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 125 unmodified lines | ||
| 2439 | 2447 | // RFC 8707: keep the re-minted token bound to the same resource |
| 2440 | 2448 | // (MCP servers require this on refresh). |
| 2441 | 2449 | resource: clientRow.resource ?? undefined, |
| 2450 | + clientAuth: clientRow.tokenEndpointAuthMethod, | |
| 2451 | + requestFormat: clientRow.tokenRequestFormat, | |
| 2442 | 2452 | endpointUrlPolicy: config.oauthEndpointUrlPolicy, |
| 2443 | 2453 | fetch: config.fetch, |
| 2444 | 2454 | }).pipe( |
| @@ -187,6 +187,25 @@ export interface FirstPartyOAuthClientConfig { · 186 unmodified lines | ||
| 187 | 187 | * GitHub Apps, whose capabilities are configured on the app and whose OAuth |
| 188 | 188 | * user-token flow does not use scopes. Omit for normal OAuth clients. */ |
| 189 | 189 | readonly authorizationScopes?: readonly string[]; |
| 190 | + /** Scopes the host always adds to the integration-declared set. Use this for | |
| 191 | + * provider lifecycle scopes such as Atlassian and Microsoft | |
| 192 | + * `offline_access`; unlike `authorizationScopes`, this preserves the | |
| 193 | + * integration's least-privilege scopes. */ | |
| 194 | + readonly additionalAuthorizationScopes?: readonly string[]; | |
| 195 | + /** Separator used in the authorize URL's `scope` parameter. OAuth defaults | |
| 196 | + * to a space; legacy providers such as Linear require a comma. */ | |
| 197 | + readonly authorizationScopeSeparator?: string; | |
| 198 | + /** Provider-specific, non-secret authorize parameters fixed by the host | |
| 199 | + * registration contract (for example Atlassian's `audience`). These are | |
| 200 | + * merged after generic provider defaults and may intentionally override | |
| 201 | + * them. */ | |
| 202 | + readonly authorizationExtraParams?: Readonly<Record<string, string>>; | |
| 203 | + /** Token endpoint client-auth transport. Omitted means | |
| 204 | + * `client_secret_post`; `basic` sends the secret only in HTTP Basic auth. */ | |
| 205 | + readonly tokenEndpointAuthMethod?: "body" | "basic"; | |
| 206 | + /** Token endpoint request encoding. OAuth defaults to URL-encoded form; | |
| 207 | + * providers such as Atlassian, ClickUp, and Notion require JSON. */ | |
| 208 | + readonly tokenRequestFormat?: "form" | "json"; | |
| 190 | 209 | /** Withdraw the app from every listing surface without retiring it. It stops |
| 191 | 210 | * appearing in `listClients` — so connect pickers and the agent-facing |
| 192 | 211 | * client list never offer it — while remaining fully resolvable by slug. |
| @@ -199,6 +199,42 @@ describe("first-party oauth clients", () => { · 198 unmodified lines | ||
| 199 | 199 | ), |
| 200 | 200 | ); |
| 201 | 201 | |
| 202 | + it.effect("applies first-party lifecycle scopes, separators, and authorize parameters", () => | |
| 203 | + Effect.scoped( | |
| 204 | + Effect.gen(function* () { | |
| 205 | + const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] }); | |
| 206 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 207 | + plugins, | |
| 208 | + firstPartyOAuthClients: [ | |
| 209 | + { | |
| 210 | + ...firstPartyClientFor(server), | |
| 211 | + allowedScopes: ["read", "offline_access"], | |
| 212 | + additionalAuthorizationScopes: ["offline_access"], | |
| 213 | + authorizationScopeSeparator: ",", | |
| 214 | + authorizationExtraParams: { audience: "api.example.com", prompt: "consent" }, | |
| 215 | + }, | |
| 216 | + ], | |
| 217 | + }); | |
| 218 | + yield* executor.acme.seed(["read"]); | |
| 219 | + | |
| 220 | + const started = yield* executor.oauth.start({ | |
| 221 | + owner: "org", | |
| 222 | + client: FIRST_PARTY, | |
| 223 | + clientOwner: "org", | |
| 224 | + name: ConnectionName.make("lifecycle"), | |
| 225 | + integration: INTEG, | |
| 226 | + template: TEMPLATE, | |
| 227 | + }); | |
| 228 | + expect(started.status).toBe("redirect"); | |
| 229 | + if (started.status !== "redirect") return; | |
| 230 | + const authorizationUrl = new URL(started.authorizationUrl); | |
| 231 | + expect(authorizationUrl.searchParams.get("scope")).toBe("read,offline_access"); | |
| 232 | + expect(authorizationUrl.searchParams.get("audience")).toBe("api.example.com"); | |
| 233 | + expect(authorizationUrl.searchParams.get("prompt")).toBe("consent"); | |
| 234 | + }), | |
| 235 | + ), | |
| 236 | + ); | |
| 237 | + | |
| 202 | 238 | it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () => |
| 203 | 239 | Effect.scoped( |
| 204 | 240 | Effect.gen(function* () { |
| @@ -6,7 +6,8 @@ · 5 unmodified lines | ||
| 6 | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | |
| 8 | 8 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | −import { Cause, Effect, Exit, Ref } from "effect"; | |
| 9 | +import { Cause, Effect, Exit, Ref, Schema } from "effect"; | |
| 10 | +import type * as Tracer from "effect/Tracer"; | |
| 10 | 11 | import { HttpServerResponse } from "effect/unstable/http"; |
| 11 | 12 | |
| 12 | 13 | import { |
| @@ -34,8 +35,11 @@ interface TokenCall { · 21 unmodified lines | ||
| 34 | 35 | readonly url: string; |
| 35 | 36 | readonly headers: Readonly<Record<string, string>>; |
| 36 | 37 | readonly body: URLSearchParams; |
| 38 | + readonly jsonBody: unknown; | |
| 37 | 39 | } |
| 38 | 40 | |
| 41 | +const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | |
| 42 | + | |
| 39 | 43 | type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse; |
| 40 | 44 | |
| 41 | 45 | const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => |
| @@ -52,6 +56,9 @@ const serveTokenEndpoint = (handler: TokenHandler) => · 10 unmodified lines | ||
| 52 | 56 | url: request.url ?? "/", |
| 53 | 57 | headers: request.headers, |
| 54 | 58 | body: new URLSearchParams(bodyText), |
| 59 | + jsonBody: request.headers["content-type"]?.startsWith("application/json") | |
| 60 | + ? decodeJsonBody(bodyText) | |
| 61 | + : null, | |
| 55 | 62 | }; |
| 56 | 63 | yield* Ref.update(calls, (all) => [...all, call]); |
| 57 | 64 | return handler(call); |
| @@ -103,6 +110,45 @@ const tokenResponse = · 45 unmodified lines | ||
| 103 | 110 | () => |
| 104 | 111 | json(200, body); |
| 105 | 112 | |
| 113 | +/** Records each span's attributes and its ending exit, so a test can assert on | |
| 114 | + * exactly what telemetry would export for the token-request span. */ | |
| 115 | +interface RecordedSpan { | |
| 116 | + readonly attributes: Map<string, unknown>; | |
| 117 | + endExit?: unknown; | |
| 118 | +} | |
| 119 | + | |
| 120 | +const makeRecordingTracer = (spans: Map<string, RecordedSpan>): Tracer.Tracer => ({ | |
| 121 | + span: (options) => { | |
| 122 | + const record: RecordedSpan = { attributes: new Map() }; | |
| 123 | + spans.set(options.name, record); | |
| 124 | + let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; | |
| 125 | + return { | |
| 126 | + _tag: "Span", | |
| 127 | + name: options.name, | |
| 128 | + spanId: "0000000000000001", | |
| 129 | + traceId: "00000000000000000000000000000001", | |
| 130 | + parent: options.parent, | |
| 131 | + annotations: options.annotations, | |
| 132 | + get status() { | |
| 133 | + return status; | |
| 134 | + }, | |
| 135 | + attributes: record.attributes, | |
| 136 | + links: options.links, | |
| 137 | + sampled: options.sampled, | |
| 138 | + kind: options.kind, | |
| 139 | + end: (endTime, exit) => { | |
| 140 | + record.endExit = exit; | |
| 141 | + status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; | |
| 142 | + }, | |
| 143 | + attribute: (key, value) => { | |
| 144 | + record.attributes.set(key, value); | |
| 145 | + }, | |
| 146 | + event: () => undefined, | |
| 147 | + addLinks: () => undefined, | |
| 148 | + }; | |
| 149 | + }, | |
| 150 | +}); | |
| 151 | + | |
| 106 | 152 | const tokenResponseFetch = |
| 107 | 153 | (body: unknown): typeof globalThis.fetch => |
| 108 | 154 | async () => |
| @@ -260,6 +306,56 @@ describe("buildAuthorizationUrl", () => { · 151 unmodified lines | ||
| 260 | 306 | }); |
| 261 | 307 | |
| 262 | 308 | describe("exchangeAuthorizationCode", () => { |
| 309 | + it.effect("supports JSON token exchange with HTTP Basic client authentication", () => | |
| 310 | + withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => | |
| 311 | + Effect.gen(function* () { | |
| 312 | + yield* exchangeAuthorizationCode({ | |
| 313 | + tokenUrl, | |
| 314 | + clientId: "cid", | |
| 315 | + clientSecret: "csecret", | |
| 316 | + redirectUrl: "https://app.example.com/cb", | |
| 317 | + codeVerifier: "verifier", | |
| 318 | + code: "abc", | |
| 319 | + clientAuth: "basic", | |
| 320 | + requestFormat: "json", | |
| 321 | + }); | |
| 322 | + const call = (yield* calls)[0]!; | |
| 323 | + expect(call.headers["content-type"]).toBe("application/json"); | |
| 324 | + expect(call.headers["authorization"]).toBe("Basic Y2lkOmNzZWNyZXQ="); | |
| 325 | + expect(call.jsonBody).toEqual({ | |
| 326 | + grant_type: "authorization_code", | |
| 327 | + code: "abc", | |
| 328 | + redirect_uri: "https://app.example.com/cb", | |
| 329 | + code_verifier: "verifier", | |
| 330 | + }); | |
| 331 | + }), | |
| 332 | + ), | |
| 333 | + ); | |
| 334 | + | |
| 335 | + it.effect("supports JSON token exchange with client credentials in the body", () => | |
| 336 | + withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => | |
| 337 | + Effect.gen(function* () { | |
| 338 | + yield* exchangeAuthorizationCode({ | |
| 339 | + tokenUrl, | |
| 340 | + clientId: "cid", | |
| 341 | + clientSecret: "csecret", | |
| 342 | + redirectUrl: "https://app.example.com/cb", | |
| 343 | + codeVerifier: "verifier", | |
| 344 | + code: "abc", | |
| 345 | + requestFormat: "json", | |
| 346 | + }); | |
| 347 | + expect((yield* calls)[0]!.jsonBody).toEqual({ | |
| 348 | + grant_type: "authorization_code", | |
| 349 | + code: "abc", | |
| 350 | + redirect_uri: "https://app.example.com/cb", | |
| 351 | + code_verifier: "verifier", | |
| 352 | + client_id: "cid", | |
| 353 | + client_secret: "csecret", | |
| 354 | + }); | |
| 355 | + }), | |
| 356 | + ), | |
| 357 | + ); | |
| 358 | + | |
| 263 | 359 | it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () => |
| 264 | 360 | withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => |
| 265 | 361 | Effect.gen(function* () { |
| @@ -765,6 +861,9 @@ describe("exchangeAuthorizationCode", () => { · 499 unmodified lines | ||
| 765 | 861 | }), |
| 766 | 862 | ); |
| 767 | 863 | |
| 864 | + // Non-secret description text must keep propagating even from a confidential | |
| 865 | + // client: the scrub below removes ONLY the exact submitted secret, never the | |
| 866 | + // AS's verdict prose around it. | |
| 768 | 867 | it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () => |
| 769 | 868 | withTokenEndpoint( |
| 770 | 869 | () => |
| @@ -778,6 +877,7 @@ describe("exchangeAuthorizationCode", () => { · 7 unmodified lines | ||
| 778 | 877 | exchangeAuthorizationCode({ |
| 779 | 878 | tokenUrl, |
| 780 | 879 | clientId: "cid", |
| 880 | + clientSecret: "csecret", | |
| 781 | 881 | redirectUrl: "https://cb", |
| 782 | 882 | codeVerifier: "v", |
| 783 | 883 | code: "c", |
| @@ -786,10 +886,67 @@ describe("exchangeAuthorizationCode", () => { · 2 unmodified lines | ||
| 786 | 886 | expect(Exit.isFailure(exit)).toBe(true); |
| 787 | 887 | if (!Exit.isFailure(exit)) return; |
| 788 | 888 | expect(JSON.stringify(exit.cause)).toContain("Code expired"); |
| 889 | + const failure = Cause.squash(exit.cause) as OAuth2Error; | |
| 890 | + expect(failure.error).toBe("invalid_grant"); | |
| 789 | 891 | }), |
| 790 | 892 | ), |
| 791 | 893 | ); |
| 792 | 894 | |
| 895 | + // The review's canary for the first-party secret leak. A provider that | |
| 896 | + // echoes the submitted client_secret inside error_description would carry a | |
| 897 | + // DEPLOYMENT-WIDE credential into the OAuth2Error message — and from there | |
| 898 | + // into OAuthCompleteError, the popup's browser-visible errorDetails, and the | |
| 899 | + // token-request span. Assert on the WHOLE rendered failure, not just the | |
| 900 | + // message: what a log line or a `JSON.stringify` prints includes the retained | |
| 901 | + // rejection, so an echo surviving anywhere in the cause is still a leak. | |
| 902 | + it.effect("scrubs a client secret echoed in error_description from the whole failure", () => { | |
| 903 | + const spans = new Map<string, RecordedSpan>(); | |
| 904 | + return withTokenEndpoint( | |
| 905 | + () => | |
| 906 | + json(400, { | |
| 907 | + error: "invalid_client", | |
| 908 | + error_description: | |
| 909 | + "authentication failed for secret SECRET-CANARY-must-not-escape, check your credentials", | |
| 910 | + }), | |
| 911 | + ({ tokenUrl }) => | |
| 912 | + Effect.gen(function* () { | |
| 913 | + const exit = yield* Effect.exit( | |
| 914 | + exchangeAuthorizationCode({ | |
| 915 | + tokenUrl, | |
| 916 | + clientId: "cid", | |
| 917 | + clientSecret: "SECRET-CANARY-must-not-escape", | |
| 918 | + redirectUrl: "https://cb", | |
| 919 | + codeVerifier: "v", | |
| 920 | + code: "c", | |
| 921 | + }), | |
| 922 | + ); | |
| 923 | + expect(Exit.isFailure(exit)).toBe(true); | |
| 924 | + if (!Exit.isFailure(exit)) return; | |
| 925 | + for (const rendered of [Cause.pretty(exit.cause), JSON.stringify(exit.cause)]) { | |
| 926 | + expect(rendered).not.toContain("SECRET-CANARY-must-not-escape"); | |
| 927 | + } | |
| 928 | + const failure = Cause.squash(exit.cause) as OAuth2Error; | |
| 929 | + expect(failure).toBeInstanceOf(OAuth2Error); | |
| 930 | + // Masked, not dropped: the verdict prose around the secret survives, | |
| 931 | + // and so does everything classification and the span read. | |
| 932 | + expect(failure.message).toContain("[redacted]"); | |
| 933 | + expect(failure.message).toContain("check your credentials"); | |
| 934 | + expect(failure.message).not.toContain("SECRET-CANARY-must-not-escape"); | |
| 935 | + expect(failure.error).toBe("invalid_client"); | |
| 936 | + expect(failure.status).toBe(400); | |
| 937 | + // The token-request span — attributes AND ending exit — is what the | |
| 938 | + // exporter sees; the echo must not survive there either. | |
| 939 | + const tokenSpan = spans.get("executor.oauth.token_request"); | |
| 940 | + expect(tokenSpan?.attributes.get("executor.oauth.error_code")).toBe("invalid_client"); | |
| 941 | + const spanRendered = JSON.stringify({ | |
| 942 | + attributes: [...(tokenSpan?.attributes ?? [])], | |
| 943 | + exit: tokenSpan?.endExit, | |
| 944 | + }); | |
| 945 | + expect(spanRendered).not.toContain("SECRET-CANARY-must-not-escape"); | |
| 946 | + }), | |
| 947 | + ).pipe(Effect.withTracer(makeRecordingTracer(spans))); | |
| 948 | + }); | |
| 949 | + | |
| 793 | 950 | it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () => |
| 794 | 951 | withTokenEndpoint( |
| 795 | 952 | () => HttpServerResponse.text("route not found", { status: 404 }), |
| @@ -1192,6 +1349,31 @@ describe("exchangeClientCredentials", () => { · 396 unmodified lines | ||
| 1192 | 1349 | }); |
| 1193 | 1350 | |
| 1194 | 1351 | describe("refreshAccessToken", () => { |
| 1352 | + it.effect("persists provider-compatible JSON refresh rotation requests", () => | |
| 1353 | + withTokenEndpoint( | |
| 1354 | + tokenResponse({ ...validRefreshBody, refresh_token: "rotated" }), | |
| 1355 | + ({ tokenUrl, calls }) => | |
| 1356 | + Effect.gen(function* () { | |
| 1357 | + const result = yield* refreshAccessToken({ | |
| 1358 | + tokenUrl, | |
| 1359 | + clientId: "cid", | |
| 1360 | + clientSecret: "csecret", | |
| 1361 | + refreshToken: "old", | |
| 1362 | + scopes: ["read", "offline_access"], | |
| 1363 | + requestFormat: "json", | |
| 1364 | + }); | |
| 1365 | + expect(result.refresh_token).toBe("rotated"); | |
| 1366 | + expect((yield* calls)[0]!.jsonBody).toEqual({ | |
| 1367 | + grant_type: "refresh_token", | |
| 1368 | + refresh_token: "old", | |
| 1369 | + scope: "read offline_access", | |
| 1370 | + client_id: "cid", | |
| 1371 | + client_secret: "csecret", | |
| 1372 | + }); | |
| 1373 | + }), | |
| 1374 | + ), | |
| 1375 | + ); | |
| 1376 | + | |
| 1195 | 1377 | it.effect("normalizes Slack's comma-delimited scopes on refresh", () => |
| 1196 | 1378 | Effect.gen(function* () { |
| 1197 | 1379 | const result = yield* refreshAccessToken({ |
| @@ -641,8 +641,23 @@ const oauthErrorFromResponseBody = ( · 640 unmodified lines | ||
| 641 | 641 | }); |
| 642 | 642 | }; |
| 643 | 643 | |
| 644 | −const toOAuth2Error = (cause: unknown): OAuth2Error => { | |
| 644 | +/** Exact-string scrub of the submitted client secret from text bound for an | |
| 645 | + * `OAuth2Error`. A misbehaving authorization server can echo the credential it | |
| 646 | + * was just sent back inside `error_description` — a field that is deliberately | |
| 647 | + * previewable everywhere else in this module because it is the AS's | |
| 648 | + * human-readable verdict. The message it lands in reaches the OAuth popup's | |
| 649 | + * browser-visible error details, persisted connection health, and telemetry, | |
| 650 | + * and for a first-party client the secret is deployment-wide. The helper knows | |
| 651 | + * exactly which string it submitted, so this is a targeted replacement of that | |
| 652 | + * one value, not a heuristic. */ | |
| 653 | +const redactSubmittedClientSecret = ( | |
| 654 | + text: string, | |
| 655 | + clientSecret: string | null | undefined, | |
| 656 | +): string => (clientSecret ? text.replaceAll(clientSecret, "[redacted]") : text); | |
| 657 | + | |
| 658 | +const toOAuth2Error = (cause: unknown, clientSecret?: string | null): OAuth2Error => { | |
| 645 | 659 | if (isOAuth2Error(cause)) return cause; |
| 660 | + const scrub = (text: string): string => redactSubmittedClientSecret(text, clientSecret); | |
| 646 | 661 | if (typeof cause === "object" && cause !== null) { |
| 647 | 662 | const c = cause as { |
| 648 | 663 | error?: unknown; |
| @@ -657,8 +672,10 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 8 unmodified lines | ||
| 657 | 672 | ? c.message |
| 658 | 673 | : undefined; |
| 659 | 674 | return new OAuth2Error({ |
| 660 | − message: `OAuth token exchange failed: ${description ?? code ?? "unknown error"}`, | |
| 661 | − error: code, | |
| 675 | + message: scrub(`OAuth token exchange failed: ${description ?? code ?? "unknown error"}`), | |
| 676 | + // The code is scrubbed too: a conform envelope's `error` is an arbitrary | |
| 677 | + // string the AS chose, and it flows into the token-request span attribute. | |
| 678 | + error: code === undefined ? undefined : scrub(code), | |
| 662 | 679 | cause, |
| 663 | 680 | }); |
| 664 | 681 | } |
| @@ -674,13 +691,18 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 9 unmodified lines | ||
| 674 | 691 | * |
| 675 | 692 | * `fallbackMessage` is for the paths that hold the error Response directly |
| 676 | 693 | * rather than catching a thrown oauth4webapi error: there is no library |
| 677 | − * message to build on, so the caller names the step instead. */ | |
| 694 | + * message to build on, so the caller names the step instead. | |
| 695 | + * | |
| 696 | + * `clientSecret` is the secret the failed request submitted, when the client | |
| 697 | + * is confidential. Every constructed message is scrubbed of it, because the | |
| 698 | + * body it is built from is the AS's and may echo the credential back. */ | |
| 678 | 699 | const toOAuth2ErrorWithHttpSummary = ( |
| 679 | 700 | cause: unknown, |
| 680 | − options?: { readonly fallbackMessage?: string }, | |
| 701 | + options?: { readonly fallbackMessage?: string; readonly clientSecret?: string | null }, | |
| 681 | 702 | ): Effect.Effect<OAuth2Error> => { |
| 682 | 703 | if (isOAuth2Error(cause)) return Effect.succeed(cause); |
| 683 | − const base = toOAuth2Error(cause); | |
| 704 | + const scrub = (text: string): string => redactSubmittedClientSecret(text, options?.clientSecret); | |
| 705 | + const base = toOAuth2Error(cause, options?.clientSecret); | |
| 684 | 706 | const response = responseFromOAuthErrorCause(cause); |
| 685 | 707 | if (!response) { |
| 686 | 708 | // No Response, but possibly a body the library already parsed off one it |
| @@ -704,8 +726,8 @@ const toOAuth2ErrorWithHttpSummary = ( · 17 unmodified lines | ||
| 704 | 726 | // exactly the leak the message allowlist exists to prevent. The other |
| 705 | 727 | // branches keep their cause because theirs is diagnostic, not a body. |
| 706 | 728 | new OAuth2Error({ |
| 707 | − message: `${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`, | |
| 708 | − error: base.error ?? envelope?.error, | |
| 729 | + message: scrub(`${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`), | |
| 730 | + error: base.error ?? (envelope?.error === undefined ? undefined : scrub(envelope.error)), | |
| 709 | 731 | status: PARSED_BODY_CAUSE_STATUS, |
| 710 | 732 | }), |
| 711 | 733 | ); |
| @@ -727,20 +749,37 @@ const toOAuth2ErrorWithHttpSummary = ( · 15 unmodified lines | ||
| 727 | 749 | : `${headline}: ${recovered.code}${ |
| 728 | 750 | recovered.description === undefined ? "" : ` — ${recovered.description}` |
| 729 | 751 | }`; |
| 752 | + const code = base.error ?? recovered?.code; | |
| 753 | + // The diagnostic rejection is dropped when the submitted secret is visible | |
| 754 | + // in its serialised form — oauth4webapi's ResponseBodyError carries the | |
| 755 | + // AS's `error_description` as an own enumerable field, so anything that | |
| 756 | + // renders the WHOLE failure (`Cause.pretty`, `JSON.stringify`) would | |
| 757 | + // replay the echo straight past the message scrub above. Everything | |
| 758 | + // classification reads — status, code, HTTP summary — is already lifted | |
| 759 | + // out, mirroring the malformed-200 branch's treatment of token-bearing | |
| 760 | + // rejections; the ordinary no-echo failure keeps its cause untouched. | |
| 761 | + const causeEchoesSecret = | |
| 762 | + Boolean(options?.clientSecret) && safeStringify(cause).includes(options?.clientSecret ?? ""); | |
| 730 | 763 | return new OAuth2Error({ |
| 731 | − message: `${described} (${summary})`, | |
| 732 | − error: base.error ?? recovered?.code, | |
| 764 | + message: scrub(`${described} (${summary})`), | |
| 765 | + error: code === undefined ? undefined : scrub(code), | |
| 733 | 766 | // Carried even when no code was recovered: the status is what tells a |
| 734 | 767 | // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx |
| 735 | 768 | // — retry). Most real refusals arrive with no code at all. |
| 736 | 769 | status: response.status, |
| 737 | − cause, | |
| 770 | + ...(causeEchoesSecret ? {} : { cause }), | |
| 738 | 771 | }); |
| 739 | 772 | }); |
| 740 | 773 | }; |
| 741 | 774 | |
| 742 | −const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> => | |
| 743 | − toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error))); | |
| 775 | +/** Curried on the submitted client secret so every `Effect.catch` site names | |
| 776 | + * the credential its request carried — the scrub cannot work without it. */ | |
| 777 | +const failOAuth2WithHttpSummary = | |
| 778 | + (clientSecret: string | null | undefined) => | |
| 779 | + (cause: unknown): Effect.Effect<never, OAuth2Error> => | |
| 780 | + toOAuth2ErrorWithHttpSummary(cause, { clientSecret }).pipe( | |
| 781 | + Effect.flatMap((error) => Effect.fail(error)), | |
| 782 | + ); | |
| 744 | 783 | |
| 745 | 784 | /** Fail from a token-endpoint error Response the caller holds directly — the |
| 746 | 785 | * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw |
| @@ -751,8 +790,9 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2E · 4 unmodified lines | ||
| 751 | 790 | const failOAuth2FromErrorResponse = ( |
| 752 | 791 | response: Response, |
| 753 | 792 | fallbackMessage: string, |
| 793 | + clientSecret: string | null | undefined, | |
| 754 | 794 | ): Effect.Effect<never, OAuth2Error> => |
| 755 | − toOAuth2ErrorWithHttpSummary(response, { fallbackMessage }).pipe( | |
| 795 | + toOAuth2ErrorWithHttpSummary(response, { fallbackMessage, clientSecret }).pipe( | |
| 756 | 796 | Effect.flatMap((error) => Effect.fail(error)), |
| 757 | 797 | ); |
| 758 | 798 | |
| @@ -1069,6 +1109,9 @@ export type ExchangeAuthorizationCodeInput = { · 310 unmodified lines | ||
| 1069 | 1109 | readonly codeVerifier: string; |
| 1070 | 1110 | readonly code: string; |
| 1071 | 1111 | readonly clientAuth?: ClientAuthMethod; |
| 1112 | + /** Encoding required by the provider's token endpoint. OAuth defaults to | |
| 1113 | + * URL-encoded form; a small set of providers require a JSON object. */ | |
| 1114 | + readonly requestFormat?: "form" | "json"; | |
| 1072 | 1115 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; |
| 1073 | 1116 | /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on |
| 1074 | 1117 | * the token request when the client knows the resource it intends |
| @@ -1079,6 +1122,59 @@ export type ExchangeAuthorizationCodeInput = { · 4 unmodified lines | ||
| 1079 | 1122 | readonly fetch?: typeof globalThis.fetch; |
| 1080 | 1123 | }; |
| 1081 | 1124 | |
| 1125 | +const base64BasicCredentials = (clientId: string, clientSecret: string): string => { | |
| 1126 | + const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`); | |
| 1127 | + let binary = ""; | |
| 1128 | + for (const byte of bytes) binary += String.fromCharCode(byte); | |
| 1129 | + return globalThis.btoa(binary); | |
| 1130 | +}; | |
| 1131 | + | |
| 1132 | +const jsonTokenEndpointRequest = async (input: { | |
| 1133 | + readonly tokenUrl: string; | |
| 1134 | + readonly clientId: string; | |
| 1135 | + readonly clientSecret?: string | null; | |
| 1136 | + readonly clientAuth: ClientAuthMethod; | |
| 1137 | + readonly grantType: "authorization_code" | "refresh_token"; | |
| 1138 | + readonly parameters: Readonly<Record<string, string>>; | |
| 1139 | + readonly timeoutMs?: number; | |
| 1140 | + readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; | |
| 1141 | + readonly fetch?: typeof globalThis.fetch; | |
| 1142 | +}): Promise<Response> => { | |
| 1143 | + const tokenUrl = assertSupportedOAuthEndpointUrl( | |
| 1144 | + input.tokenUrl, | |
| 1145 | + "Token URL", | |
| 1146 | + input.endpointUrlPolicy, | |
| 1147 | + ); | |
| 1148 | + const headers = new Headers({ | |
| 1149 | + accept: "application/json", | |
| 1150 | + "content-type": "application/json", | |
| 1151 | + }); | |
| 1152 | + const confidential = Boolean(input.clientSecret); | |
| 1153 | + if (confidential && input.clientAuth === "basic") { | |
| 1154 | + headers.set( | |
| 1155 | + "authorization", | |
| 1156 | + `Basic ${base64BasicCredentials(input.clientId, input.clientSecret ?? "")}`, | |
| 1157 | + ); | |
| 1158 | + } | |
| 1159 | + const body = { | |
| 1160 | + grant_type: input.grantType, | |
| 1161 | + ...input.parameters, | |
| 1162 | + ...(confidential && input.clientAuth === "basic" | |
| 1163 | + ? {} | |
| 1164 | + : { | |
| 1165 | + client_id: input.clientId, | |
| 1166 | + ...(confidential ? { client_secret: input.clientSecret ?? "" } : {}), | |
| 1167 | + }), | |
| 1168 | + }; | |
| 1169 | + // oxlint-disable-next-line executor/no-raw-fetch -- boundary: provider token exchange is the SDK's HTTP boundary and preserves its injected fetch seam | |
| 1170 | + return await (input.fetch ?? globalThis.fetch)(tokenUrl, { | |
| 1171 | + method: "POST", | |
| 1172 | + headers, | |
| 1173 | + body: JSON.stringify(body), | |
| 1174 | + signal: AbortSignal.timeout(input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS), | |
| 1175 | + }); | |
| 1176 | +}; | |
| 1177 | + | |
| 1082 | 1178 | export const exchangeAuthorizationCode = ( |
| 1083 | 1179 | input: ExchangeAuthorizationCodeInput, |
| 1084 | 1180 | ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => |
| @@ -1106,24 +1202,37 @@ export const exchangeAuthorizationCode = ( · 21 unmodified lines | ||
| 1106 | 1202 | if (input.resource) { |
| 1107 | 1203 | params.set("resource", input.resource); |
| 1108 | 1204 | } |
| 1109 | − const response = await oauth.genericTokenEndpointRequest( | |
| 1110 | − as, | |
| 1111 | − client, | |
| 1112 | − clientAuth, | |
| 1113 | − "authorization_code", | |
| 1114 | − params, | |
| 1115 | − oauth4webapiRequestOptions( | |
| 1116 | − input.tokenUrl, | |
| 1117 | − input.timeoutMs, | |
| 1118 | − input.endpointUrlPolicy, | |
| 1119 | − input.fetch, | |
| 1120 | − ), | |
| 1121 | − ); | |
| 1205 | + const response = | |
| 1206 | + input.requestFormat === "json" | |
| 1207 | + ? await jsonTokenEndpointRequest({ | |
| 1208 | + tokenUrl: input.tokenUrl, | |
| 1209 | + clientId: input.clientId, | |
| 1210 | + clientSecret: input.clientSecret, | |
| 1211 | + clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, | |
| 1212 | + grantType: "authorization_code", | |
| 1213 | + parameters: Object.fromEntries(params), | |
| 1214 | + timeoutMs: input.timeoutMs, | |
| 1215 | + endpointUrlPolicy: input.endpointUrlPolicy, | |
| 1216 | + fetch: input.fetch, | |
| 1217 | + }) | |
| 1218 | + : await oauth.genericTokenEndpointRequest( | |
| 1219 | + as, | |
| 1220 | + client, | |
| 1221 | + clientAuth, | |
| 1222 | + "authorization_code", | |
| 1223 | + params, | |
| 1224 | + oauth4webapiRequestOptions( | |
| 1225 | + input.tokenUrl, | |
| 1226 | + input.timeoutMs, | |
| 1227 | + input.endpointUrlPolicy, | |
| 1228 | + input.fetch, | |
| 1229 | + ), | |
| 1230 | + ); | |
| 1122 | 1231 | return await processTokenEndpointResponse(as, client, response); |
| 1123 | 1232 | }, |
| 1124 | 1233 | catch: (cause) => cause, |
| 1125 | 1234 | }).pipe( |
| 1126 | − Effect.catch(failOAuth2WithHttpSummary), | |
| 1235 | + Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), | |
| 1127 | 1236 | withTokenRequestSpan({ |
| 1128 | 1237 | grantType: "authorization_code", |
| 1129 | 1238 | tokenUrl: input.tokenUrl, |
| @@ -1186,7 +1295,7 @@ export const exchangeClientCredentials = ( · 56 unmodified lines | ||
| 1186 | 1295 | }, |
| 1187 | 1296 | catch: (cause) => cause, |
| 1188 | 1297 | }).pipe( |
| 1189 | − Effect.catch(failOAuth2WithHttpSummary), | |
| 1298 | + Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), | |
| 1190 | 1299 | withTokenRequestSpan({ |
| 1191 | 1300 | grantType: "client_credentials", |
| 1192 | 1301 | tokenUrl: input.tokenUrl, |
| @@ -1208,6 +1317,9 @@ export type RefreshAccessTokenInput = { · 15 unmodified lines | ||
| 1208 | 1317 | readonly scopes?: readonly string[]; |
| 1209 | 1318 | readonly scopeSeparator?: string; |
| 1210 | 1319 | readonly clientAuth?: ClientAuthMethod; |
| 1320 | + /** Encoding required by the provider's token endpoint. OAuth defaults to | |
| 1321 | + * URL-encoded form; a small set of providers require a JSON object. */ | |
| 1322 | + readonly requestFormat?: "form" | "json"; | |
| 1211 | 1323 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; |
| 1212 | 1324 | /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on |
| 1213 | 1325 | * refresh requests so the new access token's audience is bound to |
| @@ -1241,6 +1353,26 @@ export const refreshAccessToken = ( · 27 unmodified lines | ||
| 1241 | 1353 | } |
| 1242 | 1354 | const additionalParameters = |
| 1243 | 1355 | Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; |
| 1356 | + if (input.requestFormat === "json") { | |
| 1357 | + const response = await jsonTokenEndpointRequest({ | |
| 1358 | + tokenUrl: input.tokenUrl, | |
| 1359 | + clientId: input.clientId, | |
| 1360 | + clientSecret: input.clientSecret, | |
| 1361 | + clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, | |
| 1362 | + grantType: "refresh_token", | |
| 1363 | + parameters: { | |
| 1364 | + refresh_token: input.refreshToken, | |
| 1365 | + ...(input.scopes && input.scopes.length > 0 | |
| 1366 | + ? { scope: input.scopes.join(input.scopeSeparator ?? " ") } | |
| 1367 | + : {}), | |
| 1368 | + ...(input.resource ? { resource: input.resource } : {}), | |
| 1369 | + }, | |
| 1370 | + timeoutMs: input.timeoutMs, | |
| 1371 | + endpointUrlPolicy: input.endpointUrlPolicy, | |
| 1372 | + fetch: input.fetch, | |
| 1373 | + }); | |
| 1374 | + return await processTokenEndpointResponse(as, client, response); | |
| 1375 | + } | |
| 1244 | 1376 | const response = await oauth.refreshTokenGrantRequest( |
| 1245 | 1377 | as, |
| 1246 | 1378 | client, |
| @@ -1265,7 +1397,7 @@ export const refreshAccessToken = ( · 18 unmodified lines | ||
| 1265 | 1397 | }, |
| 1266 | 1398 | catch: (cause) => cause, |
| 1267 | 1399 | }).pipe( |
| 1268 | − Effect.catch(failOAuth2WithHttpSummary), | |
| 1400 | + Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), | |
| 1269 | 1401 | withTokenRequestSpan({ |
| 1270 | 1402 | grantType: "refresh_token", |
| 1271 | 1403 | tokenUrl: input.tokenUrl, |
| @@ -1375,10 +1507,14 @@ export const exchangeSubjectTokenForIdJag = ( · 103 unmodified lines | ||
| 1375 | 1507 | ); |
| 1376 | 1508 | }, |
| 1377 | 1509 | catch: (cause) => cause, |
| 1378 | − }).pipe(Effect.catch(failOAuth2WithHttpSummary)); | |
| 1510 | + }).pipe(Effect.catch(failOAuth2WithHttpSummary(input.clientSecret))); | |
| 1379 | 1511 | |
| 1380 | 1512 | if (!response.ok) { |
| 1381 | − return yield* failOAuth2FromErrorResponse(response, "ID-JAG token exchange was rejected"); | |
| 1513 | + return yield* failOAuth2FromErrorResponse( | |
| 1514 | + response, | |
| 1515 | + "ID-JAG token exchange was rejected", | |
| 1516 | + input.clientSecret, | |
| 1517 | + ); | |
| 1382 | 1518 | } |
| 1383 | 1519 | |
| 1384 | 1520 | // Nothing else reads this body, so it is consumed directly. A read failure |
| @@ -1485,7 +1621,7 @@ export const redeemIdJagAssertion = ( · 100 unmodified lines | ||
| 1485 | 1621 | }, |
| 1486 | 1622 | catch: (cause) => cause, |
| 1487 | 1623 | }).pipe( |
| 1488 | − Effect.catch(failOAuth2WithHttpSummary), | |
| 1624 | + Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), | |
| 1489 | 1625 | withTokenRequestSpan({ |
| 1490 | 1626 | grantType: JWT_BEARER_GRANT_TYPE, |
| 1491 | 1627 | tokenUrl: input.tokenUrl, |
| @@ -509,6 +509,8 @@ interface LoadedOAuthClient { · 508 unmodified lines | ||
| 509 | 509 | /** Resolved literal secret (read from the provider via the stored item id). */ |
| 510 | 510 | readonly clientSecret: string; |
| 511 | 511 | readonly resource: string | null; |
| 512 | + readonly tokenEndpointAuthMethod?: "body" | "basic"; | |
| 513 | + readonly tokenRequestFormat?: "form" | "json"; | |
| 512 | 514 | } |
| 513 | 515 | |
| 514 | 516 | /** Where an OAuth app's client secret is stored in the default writable |
| @@ -611,6 +613,8 @@ export const loadedFirstPartyClient = ( · 96 unmodified lines | ||
| 611 | 613 | readonly clientId: string; |
| 612 | 614 | readonly clientSecret: string; |
| 613 | 615 | readonly resource: string | null; |
| 616 | + readonly tokenEndpointAuthMethod?: "body" | "basic"; | |
| 617 | + readonly tokenRequestFormat?: "form" | "json"; | |
| 614 | 618 | } => ({ |
| 615 | 619 | slug: String(firstPartyOAuthClientSlug(config.name)), |
| 616 | 620 | authorizationUrl: config.authorizationUrl, |
| @@ -619,6 +623,12 @@ export const loadedFirstPartyClient = ( · 2 unmodified lines | ||
| 619 | 623 | clientId: config.clientId, |
| 620 | 624 | clientSecret: config.clientSecret, |
| 621 | 625 | resource: config.resource ?? null, |
| 626 | + ...(config.tokenEndpointAuthMethod === undefined | |
| 627 | + ? {} | |
| 628 | + : { tokenEndpointAuthMethod: config.tokenEndpointAuthMethod }), | |
| 629 | + ...(config.tokenRequestFormat === undefined | |
| 630 | + ? {} | |
| 631 | + : { tokenRequestFormat: config.tokenRequestFormat }), | |
| 622 | 632 | }); |
| 623 | 633 | |
| 624 | 634 | export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { |
| @@ -1683,6 +1693,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1058 unmodified lines | ||
| 1683 | 1693 | : scopePolicy.kind === "discover" |
| 1684 | 1694 | ? requestedScopes |
| 1685 | 1695 | : yield* filterAuthorizationCodeScopes(client, requestedScopes); |
| 1696 | + const completeAuthorizationScopes = dedupeScopes([ | |
| 1697 | + ...authorizationRequestedScopes, | |
| 1698 | + ...(firstParty?.additionalAuthorizationScopes ?? []), | |
| 1699 | + ]); | |
| 1686 | 1700 | |
| 1687 | 1701 | // authorization_code: persist a session + build the authorize URL. |
| 1688 | 1702 | const verifier = createPkceCodeVerifier(); |
| @@ -1744,7 +1758,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines | ||
| 1744 | 1758 | payload: { |
| 1745 | 1759 | owner: input.owner, |
| 1746 | 1760 | clientOwner: input.clientOwner, |
| 1747 | − requestedScopes: authorizationRequestedScopes, | |
| 1761 | + requestedScopes: completeAuthorizationScopes, | |
| 1748 | 1762 | }, |
| 1749 | 1763 | expires_at: expiresAt, |
| 1750 | 1764 | created_at: now, |
| @@ -1757,14 +1771,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 6 unmodified lines | ||
| 1757 | 1771 | authorizationUrl: client.authorizationUrl, |
| 1758 | 1772 | clientId: client.clientId, |
| 1759 | 1773 | redirectUrl: flowRedirectUri, |
| 1760 | − scopes: authorizationRequestedScopes, | |
| 1774 | + scopes: completeAuthorizationScopes, | |
| 1761 | 1775 | state: providerState, |
| 1762 | 1776 | codeChallenge: challenge, |
| 1777 | + scopeSeparator: firstParty?.authorizationScopeSeparator, | |
| 1763 | 1778 | resource: client.resource ?? undefined, |
| 1764 | 1779 | // Provider quirks (Google: access_type=offline + prompt=consent) — |
| 1765 | 1780 | // without these Google returns no refresh token and won't re-consent |
| 1766 | 1781 | // to widen scopes on reconnect. |
| 1767 | − extraParams: providerAuthorizeExtras(client.authorizationUrl), | |
| 1782 | + extraParams: { | |
| 1783 | + ...providerAuthorizeExtras(client.authorizationUrl), | |
| 1784 | + ...(firstParty?.authorizationExtraParams ?? {}), | |
| 1785 | + }, | |
| 1768 | 1786 | endpointUrlPolicy: deps.endpointUrlPolicy, |
| 1769 | 1787 | }), |
| 1770 | 1788 | catch: (cause) => |
| @@ -1883,6 +1901,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 112 unmodified lines | ||
| 1883 | 1901 | redirectUrl: session.redirectUrl, |
| 1884 | 1902 | codeVerifier: session.pkceVerifier, |
| 1885 | 1903 | code: input.code, |
| 1904 | + clientAuth: client.tokenEndpointAuthMethod, | |
| 1905 | + requestFormat: client.tokenRequestFormat, | |
| 1886 | 1906 | resource: client.resource ?? undefined, |
| 1887 | 1907 | endpointUrlPolicy: deps.endpointUrlPolicy, |
| 1888 | 1908 | fetch, |
| @@ -40,10 +40,10 @@ export const FIGMA_SUPPORTED_OAUTH_SCOPES = [ · 39 unmodified lines | ||
| 40 | 40 | "file_dev_resources:write", |
| 41 | 41 | "file_metadata:read", |
| 42 | 42 | "file_versions:read", |
| 43 | + "folder_metadata:read", | |
| 44 | + "folders:read", | |
| 43 | 45 | "library_assets:read", |
| 44 | 46 | "library_content:read", |
| 45 | − "project_metadata:read", | |
| 46 | − "projects:read", | |
| 47 | 47 | "team_library_content:read", |
| 48 | 48 | "webhooks:read", |
| 49 | 49 | "webhooks:write", |