* 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 | PluginsProvider, | 42 | PluginsProvider, |
| 43 | collectTables, | 43 | collectTables, |
| 44 | } from "@executor-js/api/server"; | 44 | } from "@executor-js/api/server"; |
| 45 | import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google"; | ||
| 46 | import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; | ||
| 47 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; | 45 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; |
| 48 | import { | 46 | import { type AnyPlugin } from "@executor-js/sdk"; |
| 49 | IntegrationSlug, | ||
| 50 | type AnyPlugin, | ||
| 51 | type FirstPartyOAuthClientConfig, | ||
| 52 | } from "@executor-js/sdk"; | ||
| 53 | 47 | ||
| 54 | import executorConfig from "../../executor.config"; | 48 | import executorConfig from "../../executor.config"; |
| 55 | import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout"; | 49 | import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout"; |
| 56 | import { DbService } from "../db/db"; | 50 | import { DbService } from "../db/db"; |
| 57 | import { cloudDbProviderLayer } from "../db/fuma"; | 51 | import { cloudDbProviderLayer } from "../db/fuma"; |
| 52 | import { firstPartyOAuthClientsFor } from "./first-party-oauth-clients"; | ||
| 58 | 53 | ||
| 59 | export { makeExecutionStack } from "@executor-js/api/server"; | 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 | export const CLOUD_MOUNT_PREFIX = "/api" as const; | 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 | export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ | 93 | export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ |
| 191 | // SSRF / private-network egress guard. Config-driven, NOT a test flag: | 94 | // SSRF / private-network egress guard. Config-driven, NOT a test flag: |
| 192 | // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); | 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 | // WorkOS Vault is cloud's credential storage implementation detail, not a | 101 | // WorkOS Vault is cloud's credential storage implementation detail, not a |
| 199 | // user-selectable provider surface. | 102 | // user-selectable provider surface. |
| 200 | exposeCredentialProviders: false, | 103 | exposeCredentialProviders: false, |
| 201 | firstPartyOAuthClients: cloudFirstPartyOAuthClients(), | 104 | firstPartyOAuthClients: firstPartyOAuthClientsFor(env), |
| 202 | // Workers cancel request-scoped I/O once the response settles; the ambient | 105 | // Workers cancel request-scoped I/O once the response settles; the ambient |
| 203 | // `waitUntil` binds to the in-flight invocation (HTTP request or DO call), | 106 | // `waitUntil` binds to the in-flight invocation (HTTP request or DO call), |
| 204 | // so stale tool-catalog rebuilds that outlive a read still converge. | 107 | // so stale tool-catalog rebuilds that outlive a read still converge. |
| @@ -1,7 +1,100 @@ | |||
| 1 | import { env } from "cloudflare:workers"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { beforeAll, 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 | // The reviewed consumer scope boundary of the Executor-owned Google app. | 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 | // scopes an `oauth.start` requests, and that admin scopes are refused). | 105 | // scopes an `oauth.start` requests, and that admin scopes are refused). |
| 13 | const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`; | 106 | const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`; |
| 14 | 107 | ||
| 15 | describe("cloud first-party oauth clients", () => { | 108 | describe("cloud first-party Google app", () => { |
| 16 | beforeAll(() => { | 109 | const google = () => |
| 17 | env.FIRST_PARTY_GOOGLE_CLIENT_ID = "test-google-client"; | 110 | firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google"); |
| 18 | env.FIRST_PARTY_GOOGLE_CLIENT_SECRET = "test-google-secret"; | ||
| 19 | }); | ||
| 20 | |||
| 21 | const google = () => cloudFirstPartyOAuthClients().find((client) => client.name === "google"); | ||
| 22 | 111 | ||
| 23 | it("declares the Google app but withholds it from every listing", () => { | 112 | it("declares the Google app but withholds it from every listing", () => { |
| 24 | const client = google(); | 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 | // unset pair simply ships no first-party app for that provider. The | 72 | // unset pair simply ships no first-party app for that provider. The |
| 73 | // registered callback on the provider side must be | 73 | // registered callback on the provider side must be |
| 74 | // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`. | 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 | FIRST_PARTY_GITHUB_CLIENT_ID?: string; | 85 | FIRST_PARTY_GITHUB_CLIENT_ID?: string; |
| 76 | FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; | 86 | FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; |
| 77 | // Endpoint overrides for the GitHub first-party app, so tests/dev can | 87 | // Endpoint overrides for the GitHub first-party app, so tests/dev can |
| @@ -79,8 +89,18 @@ declare global { · 1 unmodified line | |||
| 79 | // production (the real github.com endpoints are the defaults). | 89 | // production (the real github.com endpoints are the defaults). |
| 80 | FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; | 90 | FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; |
| 81 | FIRST_PARTY_GITHUB_TOKEN_URL?: string; | 91 | FIRST_PARTY_GITHUB_TOKEN_URL?: string; |
| 92 | FIRST_PARTY_GITLAB_CLIENT_ID?: string; | ||
| 93 | FIRST_PARTY_GITLAB_CLIENT_SECRET?: string; | ||
| 82 | FIRST_PARTY_GOOGLE_CLIENT_ID?: string; | 94 | FIRST_PARTY_GOOGLE_CLIENT_ID?: string; |
| 83 | FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; | 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 | FIRST_PARTY_SLACK_CLIENT_ID?: string; | 104 | FIRST_PARTY_SLACK_CLIENT_ID?: string; |
| 85 | FIRST_PARTY_SLACK_CLIENT_SECRET?: string; | 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 | readonly tokenUrl: string; | 2000 | readonly tokenUrl: string; |
| 2001 | readonly grant: string; | 2001 | readonly grant: string; |
| 2002 | readonly resource: string | null; | 2002 | readonly resource: string | null; |
| 2003 | readonly tokenEndpointAuthMethod?: "body" | "basic"; | ||
| 2004 | readonly tokenRequestFormat?: "form" | "json"; | ||
| 2003 | } | 2005 | } |
| 2004 | 2006 | ||
| 2005 | /** What drove a refresh: the pre-call expiry check (`proactive`), or an | 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 | clientSecret: firstParty.clientSecret, | 2309 | clientSecret: firstParty.clientSecret, |
| 2308 | tokenUrl: firstParty.tokenUrl, | 2310 | tokenUrl: firstParty.tokenUrl, |
| 2309 | grant: "authorization_code", | 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 | } satisfies RefreshClient; | 2319 | } satisfies RefreshClient; |
| 2312 | } | 2320 | } |
| 2313 | const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner; | 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 | // RFC 8707: keep the re-minted token bound to the same resource | 2447 | // RFC 8707: keep the re-minted token bound to the same resource |
| 2440 | // (MCP servers require this on refresh). | 2448 | // (MCP servers require this on refresh). |
| 2441 | resource: clientRow.resource ?? undefined, | 2449 | resource: clientRow.resource ?? undefined, |
| 2450 | clientAuth: clientRow.tokenEndpointAuthMethod, | ||
| 2451 | requestFormat: clientRow.tokenRequestFormat, | ||
| 2442 | endpointUrlPolicy: config.oauthEndpointUrlPolicy, | 2452 | endpointUrlPolicy: config.oauthEndpointUrlPolicy, |
| 2443 | fetch: config.fetch, | 2453 | fetch: config.fetch, |
| 2444 | }).pipe( | 2454 | }).pipe( |
| @@ -187,6 +187,25 @@ export interface FirstPartyOAuthClientConfig { · 186 unmodified lines | |||
| 187 | * GitHub Apps, whose capabilities are configured on the app and whose OAuth | 187 | * GitHub Apps, whose capabilities are configured on the app and whose OAuth |
| 188 | * user-token flow does not use scopes. Omit for normal OAuth clients. */ | 188 | * user-token flow does not use scopes. Omit for normal OAuth clients. */ |
| 189 | readonly authorizationScopes?: readonly string[]; | 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 | /** Withdraw the app from every listing surface without retiring it. It stops | 209 | /** Withdraw the app from every listing surface without retiring it. It stops |
| 191 | * appearing in `listClients` — so connect pickers and the agent-facing | 210 | * appearing in `listClients` — so connect pickers and the agent-facing |
| 192 | * client list never offer it — while remaining fully resolvable by slug. | 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 | it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () => | 238 | it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () => |
| 203 | Effect.scoped( | 239 | Effect.scoped( |
| 204 | Effect.gen(function* () { | 240 | Effect.gen(function* () { |
| @@ -6,7 +6,8 @@ · 5 unmodified lines | |||
| 6 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | ||
| 8 | import { describe, expect, it } from "@effect/vitest"; | 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 | import { HttpServerResponse } from "effect/unstable/http"; | 11 | import { HttpServerResponse } from "effect/unstable/http"; |
| 11 | 12 | ||
| 12 | import { | 13 | import { |
| @@ -34,8 +35,11 @@ interface TokenCall { · 21 unmodified lines | |||
| 34 | readonly url: string; | 35 | readonly url: string; |
| 35 | readonly headers: Readonly<Record<string, string>>; | 36 | readonly headers: Readonly<Record<string, string>>; |
| 36 | readonly body: URLSearchParams; | 37 | readonly body: URLSearchParams; |
| 38 | readonly jsonBody: unknown; | ||
| 37 | } | 39 | } |
| 38 | 40 | ||
| 41 | const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 42 | |||
| 39 | type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse; | 43 | type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse; |
| 40 | 44 | ||
| 41 | const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => | 45 | const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => |
| @@ -52,6 +56,9 @@ const serveTokenEndpoint = (handler: TokenHandler) => · 10 unmodified lines | |||
| 52 | url: request.url ?? "/", | 56 | url: request.url ?? "/", |
| 53 | headers: request.headers, | 57 | headers: request.headers, |
| 54 | body: new URLSearchParams(bodyText), | 58 | body: new URLSearchParams(bodyText), |
| 59 | jsonBody: request.headers["content-type"]?.startsWith("application/json") | ||
| 60 | ? decodeJsonBody(bodyText) | ||
| 61 | : null, | ||
| 55 | }; | 62 | }; |
| 56 | yield* Ref.update(calls, (all) => [...all, call]); | 63 | yield* Ref.update(calls, (all) => [...all, call]); |
| 57 | return handler(call); | 64 | return handler(call); |
| @@ -103,6 +110,45 @@ const tokenResponse = · 45 unmodified lines | |||
| 103 | () => | 110 | () => |
| 104 | json(200, body); | 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 | const tokenResponseFetch = | 152 | const tokenResponseFetch = |
| 107 | (body: unknown): typeof globalThis.fetch => | 153 | (body: unknown): typeof globalThis.fetch => |
| 108 | async () => | 154 | async () => |
| @@ -260,6 +306,56 @@ describe("buildAuthorizationUrl", () => { · 151 unmodified lines | |||
| 260 | }); | 306 | }); |
| 261 | 307 | ||
| 262 | describe("exchangeAuthorizationCode", () => { | 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 | it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () => | 359 | it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () => |
| 264 | withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => | 360 | withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => |
| 265 | Effect.gen(function* () { | 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 | it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () => | 867 | it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () => |
| 769 | withTokenEndpoint( | 868 | withTokenEndpoint( |
| 770 | () => | 869 | () => |
| @@ -778,6 +877,7 @@ describe("exchangeAuthorizationCode", () => { · 7 unmodified lines | |||
| 778 | exchangeAuthorizationCode({ | 877 | exchangeAuthorizationCode({ |
| 779 | tokenUrl, | 878 | tokenUrl, |
| 780 | clientId: "cid", | 879 | clientId: "cid", |
| 880 | clientSecret: "csecret", | ||
| 781 | redirectUrl: "https://cb", | 881 | redirectUrl: "https://cb", |
| 782 | codeVerifier: "v", | 882 | codeVerifier: "v", |
| 783 | code: "c", | 883 | code: "c", |
| @@ -786,10 +886,67 @@ describe("exchangeAuthorizationCode", () => { · 2 unmodified lines | |||
| 786 | expect(Exit.isFailure(exit)).toBe(true); | 886 | expect(Exit.isFailure(exit)).toBe(true); |
| 787 | if (!Exit.isFailure(exit)) return; | 887 | if (!Exit.isFailure(exit)) return; |
| 788 | expect(JSON.stringify(exit.cause)).toContain("Code expired"); | 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 | it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () => | 950 | it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () => |
| 794 | withTokenEndpoint( | 951 | withTokenEndpoint( |
| 795 | () => HttpServerResponse.text("route not found", { status: 404 }), | 952 | () => HttpServerResponse.text("route not found", { status: 404 }), |
| @@ -1192,6 +1349,31 @@ describe("exchangeClientCredentials", () => { · 396 unmodified lines | |||
| 1192 | }); | 1349 | }); |
| 1193 | 1350 | ||
| 1194 | describe("refreshAccessToken", () => { | 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 | it.effect("normalizes Slack's comma-delimited scopes on refresh", () => | 1377 | it.effect("normalizes Slack's comma-delimited scopes on refresh", () => |
| 1196 | Effect.gen(function* () { | 1378 | Effect.gen(function* () { |
| 1197 | const result = yield* refreshAccessToken({ | 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 | if (isOAuth2Error(cause)) return cause; | 659 | if (isOAuth2Error(cause)) return cause; |
| 660 | const scrub = (text: string): string => redactSubmittedClientSecret(text, clientSecret); | ||
| 646 | if (typeof cause === "object" && cause !== null) { | 661 | if (typeof cause === "object" && cause !== null) { |
| 647 | const c = cause as { | 662 | const c = cause as { |
| 648 | error?: unknown; | 663 | error?: unknown; |
| @@ -657,8 +672,10 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 8 unmodified lines | |||
| 657 | ? c.message | 672 | ? c.message |
| 658 | : undefined; | 673 | : undefined; |
| 659 | return new OAuth2Error({ | 674 | return new OAuth2Error({ |
| 660 | message: `OAuth token exchange failed: ${description ?? code ?? "unknown error"}`, | 675 | message: scrub(`OAuth token exchange failed: ${description ?? code ?? "unknown error"}`), |
| 661 | error: code, | 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 | cause, | 679 | cause, |
| 663 | }); | 680 | }); |
| 664 | } | 681 | } |
| @@ -674,13 +691,18 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 9 unmodified lines | |||
| 674 | * | 691 | * |
| 675 | * `fallbackMessage` is for the paths that hold the error Response directly | 692 | * `fallbackMessage` is for the paths that hold the error Response directly |
| 676 | * rather than catching a thrown oauth4webapi error: there is no library | 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 | const toOAuth2ErrorWithHttpSummary = ( | 699 | const toOAuth2ErrorWithHttpSummary = ( |
| 679 | cause: unknown, | 700 | cause: unknown, |
| 680 | options?: { readonly fallbackMessage?: string }, | 701 | options?: { readonly fallbackMessage?: string; readonly clientSecret?: string | null }, |
| 681 | ): Effect.Effect<OAuth2Error> => { | 702 | ): Effect.Effect<OAuth2Error> => { |
| 682 | if (isOAuth2Error(cause)) return Effect.succeed(cause); | 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 | const response = responseFromOAuthErrorCause(cause); | 706 | const response = responseFromOAuthErrorCause(cause); |
| 685 | if (!response) { | 707 | if (!response) { |
| 686 | // No Response, but possibly a body the library already parsed off one it | 708 | // No Response, but possibly a body the library already parsed off one it |
| @@ -704,8 +726,8 @@ const toOAuth2ErrorWithHttpSummary = ( · 17 unmodified lines | |||
| 704 | // exactly the leak the message allowlist exists to prevent. The other | 726 | // exactly the leak the message allowlist exists to prevent. The other |
| 705 | // branches keep their cause because theirs is diagnostic, not a body. | 727 | // branches keep their cause because theirs is diagnostic, not a body. |
| 706 | new OAuth2Error({ | 728 | new OAuth2Error({ |
| 707 | message: `${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`, | 729 | message: scrub(`${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`), |
| 708 | error: base.error ?? envelope?.error, | 730 | error: base.error ?? (envelope?.error === undefined ? undefined : scrub(envelope.error)), |
| 709 | status: PARSED_BODY_CAUSE_STATUS, | 731 | status: PARSED_BODY_CAUSE_STATUS, |
| 710 | }), | 732 | }), |
| 711 | ); | 733 | ); |
| @@ -727,20 +749,37 @@ const toOAuth2ErrorWithHttpSummary = ( · 15 unmodified lines | |||
| 727 | : `${headline}: ${recovered.code}${ | 749 | : `${headline}: ${recovered.code}${ |
| 728 | recovered.description === undefined ? "" : ` — ${recovered.description}` | 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 | return new OAuth2Error({ | 763 | return new OAuth2Error({ |
| 731 | message: `${described} (${summary})`, | 764 | message: scrub(`${described} (${summary})`), |
| 732 | error: base.error ?? recovered?.code, | 765 | error: code === undefined ? undefined : scrub(code), |
| 733 | // Carried even when no code was recovered: the status is what tells a | 766 | // Carried even when no code was recovered: the status is what tells a |
| 734 | // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx | 767 | // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx |
| 735 | // — retry). Most real refusals arrive with no code at all. | 768 | // — retry). Most real refusals arrive with no code at all. |
| 736 | status: response.status, | 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> => | 775 | /** Curried on the submitted client secret so every `Effect.catch` site names |
| 743 | toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error))); | 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 | /** Fail from a token-endpoint error Response the caller holds directly — the | 784 | /** Fail from a token-endpoint error Response the caller holds directly — the |
| 746 | * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw | 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 | const failOAuth2FromErrorResponse = ( | 790 | const failOAuth2FromErrorResponse = ( |
| 752 | response: Response, | 791 | response: Response, |
| 753 | fallbackMessage: string, | 792 | fallbackMessage: string, |
| 793 | clientSecret: string | null | undefined, | ||
| 754 | ): Effect.Effect<never, OAuth2Error> => | 794 | ): Effect.Effect<never, OAuth2Error> => |
| 755 | toOAuth2ErrorWithHttpSummary(response, { fallbackMessage }).pipe( | 795 | toOAuth2ErrorWithHttpSummary(response, { fallbackMessage, clientSecret }).pipe( |
| 756 | Effect.flatMap((error) => Effect.fail(error)), | 796 | Effect.flatMap((error) => Effect.fail(error)), |
| 757 | ); | 797 | ); |
| 758 | 798 | ||
| @@ -1069,6 +1109,9 @@ export type ExchangeAuthorizationCodeInput = { · 310 unmodified lines | |||
| 1069 | readonly codeVerifier: string; | 1109 | readonly codeVerifier: string; |
| 1070 | readonly code: string; | 1110 | readonly code: string; |
| 1071 | readonly clientAuth?: ClientAuthMethod; | 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 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; | 1115 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; |
| 1073 | /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on | 1116 | /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on |
| 1074 | * the token request when the client knows the resource it intends | 1117 | * the token request when the client knows the resource it intends |
| @@ -1079,6 +1122,59 @@ export type ExchangeAuthorizationCodeInput = { · 4 unmodified lines | |||
| 1079 | readonly fetch?: typeof globalThis.fetch; | 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 | export const exchangeAuthorizationCode = ( | 1178 | export const exchangeAuthorizationCode = ( |
| 1083 | input: ExchangeAuthorizationCodeInput, | 1179 | input: ExchangeAuthorizationCodeInput, |
| 1084 | ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => | 1180 | ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => |
| @@ -1106,24 +1202,37 @@ export const exchangeAuthorizationCode = ( · 21 unmodified lines | |||
| 1106 | if (input.resource) { | 1202 | if (input.resource) { |
| 1107 | params.set("resource", input.resource); | 1203 | params.set("resource", input.resource); |
| 1108 | } | 1204 | } |
| 1109 | const response = await oauth.genericTokenEndpointRequest( | 1205 | const response = |
| 1110 | as, | 1206 | input.requestFormat === "json" |
| 1111 | client, | 1207 | ? await jsonTokenEndpointRequest({ |
| 1112 | clientAuth, | 1208 | tokenUrl: input.tokenUrl, |
| 1113 | "authorization_code", | 1209 | clientId: input.clientId, |
| 1114 | params, | 1210 | clientSecret: input.clientSecret, |
| 1115 | oauth4webapiRequestOptions( | 1211 | clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, |
| 1116 | input.tokenUrl, | 1212 | grantType: "authorization_code", |
| 1117 | input.timeoutMs, | 1213 | parameters: Object.fromEntries(params), |
| 1118 | input.endpointUrlPolicy, | 1214 | timeoutMs: input.timeoutMs, |
| 1119 | input.fetch, | 1215 | endpointUrlPolicy: input.endpointUrlPolicy, |
| 1120 | ), | 1216 | fetch: input.fetch, |
| 1121 | ); | 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 | return await processTokenEndpointResponse(as, client, response); | 1231 | return await processTokenEndpointResponse(as, client, response); |
| 1123 | }, | 1232 | }, |
| 1124 | catch: (cause) => cause, | 1233 | catch: (cause) => cause, |
| 1125 | }).pipe( | 1234 | }).pipe( |
| 1126 | Effect.catch(failOAuth2WithHttpSummary), | 1235 | Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), |
| 1127 | withTokenRequestSpan({ | 1236 | withTokenRequestSpan({ |
| 1128 | grantType: "authorization_code", | 1237 | grantType: "authorization_code", |
| 1129 | tokenUrl: input.tokenUrl, | 1238 | tokenUrl: input.tokenUrl, |
| @@ -1186,7 +1295,7 @@ export const exchangeClientCredentials = ( · 56 unmodified lines | |||
| 1186 | }, | 1295 | }, |
| 1187 | catch: (cause) => cause, | 1296 | catch: (cause) => cause, |
| 1188 | }).pipe( | 1297 | }).pipe( |
| 1189 | Effect.catch(failOAuth2WithHttpSummary), | 1298 | Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), |
| 1190 | withTokenRequestSpan({ | 1299 | withTokenRequestSpan({ |
| 1191 | grantType: "client_credentials", | 1300 | grantType: "client_credentials", |
| 1192 | tokenUrl: input.tokenUrl, | 1301 | tokenUrl: input.tokenUrl, |
| @@ -1208,6 +1317,9 @@ export type RefreshAccessTokenInput = { · 15 unmodified lines | |||
| 1208 | readonly scopes?: readonly string[]; | 1317 | readonly scopes?: readonly string[]; |
| 1209 | readonly scopeSeparator?: string; | 1318 | readonly scopeSeparator?: string; |
| 1210 | readonly clientAuth?: ClientAuthMethod; | 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 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; | 1323 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; |
| 1212 | /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on | 1324 | /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on |
| 1213 | * refresh requests so the new access token's audience is bound to | 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 | const additionalParameters = | 1354 | const additionalParameters = |
| 1243 | Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; | 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 | const response = await oauth.refreshTokenGrantRequest( | 1376 | const response = await oauth.refreshTokenGrantRequest( |
| 1245 | as, | 1377 | as, |
| 1246 | client, | 1378 | client, |
| @@ -1265,7 +1397,7 @@ export const refreshAccessToken = ( · 18 unmodified lines | |||
| 1265 | }, | 1397 | }, |
| 1266 | catch: (cause) => cause, | 1398 | catch: (cause) => cause, |
| 1267 | }).pipe( | 1399 | }).pipe( |
| 1268 | Effect.catch(failOAuth2WithHttpSummary), | 1400 | Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), |
| 1269 | withTokenRequestSpan({ | 1401 | withTokenRequestSpan({ |
| 1270 | grantType: "refresh_token", | 1402 | grantType: "refresh_token", |
| 1271 | tokenUrl: input.tokenUrl, | 1403 | tokenUrl: input.tokenUrl, |
| @@ -1375,10 +1507,14 @@ export const exchangeSubjectTokenForIdJag = ( · 103 unmodified lines | |||
| 1375 | ); | 1507 | ); |
| 1376 | }, | 1508 | }, |
| 1377 | catch: (cause) => cause, | 1509 | catch: (cause) => cause, |
| 1378 | }).pipe(Effect.catch(failOAuth2WithHttpSummary)); | 1510 | }).pipe(Effect.catch(failOAuth2WithHttpSummary(input.clientSecret))); |
| 1379 | 1511 | ||
| 1380 | if (!response.ok) { | 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 | // Nothing else reads this body, so it is consumed directly. A read failure | 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 | catch: (cause) => cause, | 1622 | catch: (cause) => cause, |
| 1487 | }).pipe( | 1623 | }).pipe( |
| 1488 | Effect.catch(failOAuth2WithHttpSummary), | 1624 | Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), |
| 1489 | withTokenRequestSpan({ | 1625 | withTokenRequestSpan({ |
| 1490 | grantType: JWT_BEARER_GRANT_TYPE, | 1626 | grantType: JWT_BEARER_GRANT_TYPE, |
| 1491 | tokenUrl: input.tokenUrl, | 1627 | tokenUrl: input.tokenUrl, |
| @@ -509,6 +509,8 @@ interface LoadedOAuthClient { · 508 unmodified lines | |||
| 509 | /** Resolved literal secret (read from the provider via the stored item id). */ | 509 | /** Resolved literal secret (read from the provider via the stored item id). */ |
| 510 | readonly clientSecret: string; | 510 | readonly clientSecret: string; |
| 511 | readonly resource: string | null; | 511 | readonly resource: string | null; |
| 512 | readonly tokenEndpointAuthMethod?: "body" | "basic"; | ||
| 513 | readonly tokenRequestFormat?: "form" | "json"; | ||
| 512 | } | 514 | } |
| 513 | 515 | ||
| 514 | /** Where an OAuth app's client secret is stored in the default writable | 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 | readonly clientId: string; | 613 | readonly clientId: string; |
| 612 | readonly clientSecret: string; | 614 | readonly clientSecret: string; |
| 613 | readonly resource: string | null; | 615 | readonly resource: string | null; |
| 616 | readonly tokenEndpointAuthMethod?: "body" | "basic"; | ||
| 617 | readonly tokenRequestFormat?: "form" | "json"; | ||
| 614 | } => ({ | 618 | } => ({ |
| 615 | slug: String(firstPartyOAuthClientSlug(config.name)), | 619 | slug: String(firstPartyOAuthClientSlug(config.name)), |
| 616 | authorizationUrl: config.authorizationUrl, | 620 | authorizationUrl: config.authorizationUrl, |
| @@ -619,6 +623,12 @@ export const loadedFirstPartyClient = ( · 2 unmodified lines | |||
| 619 | clientId: config.clientId, | 623 | clientId: config.clientId, |
| 620 | clientSecret: config.clientSecret, | 624 | clientSecret: config.clientSecret, |
| 621 | resource: config.resource ?? null, | 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 | export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { | 634 | export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { |
| @@ -1683,6 +1693,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1058 unmodified lines | |||
| 1683 | : scopePolicy.kind === "discover" | 1693 | : scopePolicy.kind === "discover" |
| 1684 | ? requestedScopes | 1694 | ? requestedScopes |
| 1685 | : yield* filterAuthorizationCodeScopes(client, requestedScopes); | 1695 | : yield* filterAuthorizationCodeScopes(client, requestedScopes); |
| 1696 | const completeAuthorizationScopes = dedupeScopes([ | ||
| 1697 | ...authorizationRequestedScopes, | ||
| 1698 | ...(firstParty?.additionalAuthorizationScopes ?? []), | ||
| 1699 | ]); | ||
| 1686 | 1700 | ||
| 1687 | // authorization_code: persist a session + build the authorize URL. | 1701 | // authorization_code: persist a session + build the authorize URL. |
| 1688 | const verifier = createPkceCodeVerifier(); | 1702 | const verifier = createPkceCodeVerifier(); |
| @@ -1744,7 +1758,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines | |||
| 1744 | payload: { | 1758 | payload: { |
| 1745 | owner: input.owner, | 1759 | owner: input.owner, |
| 1746 | clientOwner: input.clientOwner, | 1760 | clientOwner: input.clientOwner, |
| 1747 | requestedScopes: authorizationRequestedScopes, | 1761 | requestedScopes: completeAuthorizationScopes, |
| 1748 | }, | 1762 | }, |
| 1749 | expires_at: expiresAt, | 1763 | expires_at: expiresAt, |
| 1750 | created_at: now, | 1764 | created_at: now, |
| @@ -1757,14 +1771,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 6 unmodified lines | |||
| 1757 | authorizationUrl: client.authorizationUrl, | 1771 | authorizationUrl: client.authorizationUrl, |
| 1758 | clientId: client.clientId, | 1772 | clientId: client.clientId, |
| 1759 | redirectUrl: flowRedirectUri, | 1773 | redirectUrl: flowRedirectUri, |
| 1760 | scopes: authorizationRequestedScopes, | 1774 | scopes: completeAuthorizationScopes, |
| 1761 | state: providerState, | 1775 | state: providerState, |
| 1762 | codeChallenge: challenge, | 1776 | codeChallenge: challenge, |
| 1777 | scopeSeparator: firstParty?.authorizationScopeSeparator, | ||
| 1763 | resource: client.resource ?? undefined, | 1778 | resource: client.resource ?? undefined, |
| 1764 | // Provider quirks (Google: access_type=offline + prompt=consent) — | 1779 | // Provider quirks (Google: access_type=offline + prompt=consent) — |
| 1765 | // without these Google returns no refresh token and won't re-consent | 1780 | // without these Google returns no refresh token and won't re-consent |
| 1766 | // to widen scopes on reconnect. | 1781 | // to widen scopes on reconnect. |
| 1767 | extraParams: providerAuthorizeExtras(client.authorizationUrl), | 1782 | extraParams: { |
| 1783 | ...providerAuthorizeExtras(client.authorizationUrl), | ||
| 1784 | ...(firstParty?.authorizationExtraParams ?? {}), | ||
| 1785 | }, | ||
| 1768 | endpointUrlPolicy: deps.endpointUrlPolicy, | 1786 | endpointUrlPolicy: deps.endpointUrlPolicy, |
| 1769 | }), | 1787 | }), |
| 1770 | catch: (cause) => | 1788 | catch: (cause) => |
| @@ -1883,6 +1901,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 112 unmodified lines | |||
| 1883 | redirectUrl: session.redirectUrl, | 1901 | redirectUrl: session.redirectUrl, |
| 1884 | codeVerifier: session.pkceVerifier, | 1902 | codeVerifier: session.pkceVerifier, |
| 1885 | code: input.code, | 1903 | code: input.code, |
| 1904 | clientAuth: client.tokenEndpointAuthMethod, | ||
| 1905 | requestFormat: client.tokenRequestFormat, | ||
| 1886 | resource: client.resource ?? undefined, | 1906 | resource: client.resource ?? undefined, |
| 1887 | endpointUrlPolicy: deps.endpointUrlPolicy, | 1907 | endpointUrlPolicy: deps.endpointUrlPolicy, |
| 1888 | fetch, | 1908 | fetch, |
| @@ -40,10 +40,10 @@ export const FIGMA_SUPPORTED_OAUTH_SCOPES = [ · 39 unmodified lines | |||
| 40 | "file_dev_resources:write", | 40 | "file_dev_resources:write", |
| 41 | "file_metadata:read", | 41 | "file_metadata:read", |
| 42 | "file_versions:read", | 42 | "file_versions:read", |
| 43 | "folder_metadata:read", | ||
| 44 | "folders:read", | ||
| 43 | "library_assets:read", | 45 | "library_assets:read", |
| 44 | "library_content:read", | 46 | "library_content:read", |
| 45 | "project_metadata:read", | ||
| 46 | "projects:read", | ||
| 47 | "team_library_content:read", | 47 | "team_library_content:read", |
| 48 | "webhooks:read", | 48 | "webhooks:read", |
| 49 | "webhooks:write", | 49 | "webhooks:write", |