From 47006e54d2f6120955e90ed28fc092316864c5c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 20:56:33 +0000 Subject: [PATCH 1/6] feat(oauth): support confidential + PKCE token exchange for Canva Connect Canva requires BOTH S256 PKCE and a client secret on token exchange, but /connect/oauth treated flow as pkce XOR confidential. PKCE is now an orthogonal usePkce switch (pkce=true|false query param, persisted in the integration record when it differs from the flow default), and a new basic-form token exchange style sends HTTP Basic client auth with an urlencoded body. api.canva.com defaults to confidential flow + PKCE + basic-form, mirroring the Notion basic-json host default. --- docs/guides/oauth.md | 42 ++++-- .../client/routes/connect-oauth.node.test.ts | 135 ++++++++++++++++++ .../worker/client/routes/connect-oauth.tsx | 84 +++++++++-- .../app/handlers/account-secrets.node.test.ts | 133 +++++++++++++++++ .../src/app/handlers/account-secrets.ts | 9 ++ .../src/app/oauth-token-exchange.node.test.ts | 82 +++++++++++ .../worker/src/app/oauth-token-exchange.ts | 79 +++++++--- .../integration-save.node.test.ts | 65 +++++++++ .../integrations/integration-save.ts | 1 + .../integrations/integration-shared.ts | 23 ++- 10 files changed, 615 insertions(+), 38 deletions(-) diff --git a/docs/guides/oauth.md b/docs/guides/oauth.md index 488cb50235..e9491106f1 100644 --- a/docs/guides/oauth.md +++ b/docs/guides/oauth.md @@ -46,16 +46,38 @@ with `allowedHosts` when needed. ## Common optional parameters -| Param | Purpose | -| --------------------------- | ------------------------------------------ | -| `flow` | `pkce` (default) or `confidential`. | -| `scopes` | Space- or separator-separated scopes. | -| `scopeSeparator` | Defaults to a single space. | -| `allowedHosts` | Extra API hosts beyond the token host. | -| `apiBaseUrl` | Optional API base URL hint. | -| `dashboardUrl` | Provider settings link. | -| `extraAuthorizeParams` | Provider-specific authorize params. | -| `providerSetupInstructions` | Free-form setup hints shown in the wizard. | +| Param | Purpose | +| --------------------------- | ---------------------------------------------------------------------------- | +| `flow` | `pkce` (default) or `confidential`. | +| `pkce` | `true` or `false`; overrides the PKCE default (see below). | +| `tokenExchangeStyle` | `form` (default), `basic-json`, or `basic-form`; overrides the host default. | +| `scopes` | Space- or separator-separated scopes. | +| `scopeSeparator` | Defaults to a single space. | +| `allowedHosts` | Extra API hosts beyond the token host. | +| `apiBaseUrl` | Optional API base URL hint. | +| `dashboardUrl` | Provider settings link. | +| `extraAuthorizeParams` | Provider-specific authorize params. | +| `providerSetupInstructions` | Free-form setup hints shown in the wizard. | + +## PKCE and client secrets are orthogonal + +`flow` decides whether a client secret is collected and sent (`confidential`) or +not (`pkce`). PKCE itself is a separate switch: it defaults to on for the `pkce` +flow and off for `confidential`, and `pkce=true` enables S256 PKCE on top of a +confidential flow for providers that require both. + +`tokenExchangeStyle` decides how confidential credentials reach the token +endpoint: `form` puts `client_secret` in the urlencoded body (GitHub, Slack, +Google), `basic-json` sends HTTP Basic with a JSON body (Notion), and +`basic-form` sends HTTP Basic with an urlencoded body (Canva). + +Known host defaults (no extra params needed): + +- `api.notion.com`: `basic-json` token exchange. +- `api.canva.com` (Canva Connect): `confidential` flow with S256 PKCE and + `basic-form` token exchange. Authorize URL is + `https://www.canva.com/api/oauth/authorize`, token URL is + `https://api.canva.com/rest/v1/oauth/token`. Client ID, access token, and refresh token names are derived from a normalized slug of `provider`. diff --git a/packages/worker/client/routes/connect-oauth.node.test.ts b/packages/worker/client/routes/connect-oauth.node.test.ts index 90136b5630..c2f0751a94 100644 --- a/packages/worker/client/routes/connect-oauth.node.test.ts +++ b/packages/worker/client/routes/connect-oauth.node.test.ts @@ -36,6 +36,7 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com/', flow: 'confidential', + usePkce: null, clientIdValueName: 'github-client-id', clientSecretSecretName: 'githubClientSecret', accessTokenSecretName: 'githubAccessToken', @@ -61,6 +62,8 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, apiBaseUrl: null, scopes: ['repo', 'read:user'], flow: null, + usePkce: null, + tokenExchangeStyle: null, scopeSeparator: ' ', extraAuthorizeParams: { prompt: 'consent' }, providerSetupInstructions: 'Open the GitHub app settings.', @@ -90,6 +93,7 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, apiBaseUrl: 'https://api.github.com', scopes: ['repo', 'read:user'], flow: 'confidential', + usePkce: false, tokenExchangeStyle: 'form', scopeSeparator: ' ', extraAuthorizeParams: { prompt: 'consent' }, @@ -111,6 +115,8 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, apiBaseUrl: null, scopes: null, flow: null, + usePkce: null, + tokenExchangeStyle: null, scopeSeparator: null, extraAuthorizeParams: null, providerSetupInstructions: null, @@ -169,6 +175,8 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, apiBaseUrl: null, scopes: [], flow: null, + usePkce: null, + tokenExchangeStyle: null, scopeSeparator: null, extraAuthorizeParams: {}, providerSetupInstructions: null, @@ -215,6 +223,8 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, apiBaseUrl: null, scopes: [], flow: 'pkce', + usePkce: null, + tokenExchangeStyle: null, scopeSeparator: ' ', extraAuthorizeParams: {}, providerSetupInstructions: null, @@ -230,6 +240,7 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, tokenHost: 'accounts.spotify.com', tokenUrl: 'https://accounts.spotify.com/api/token', flow: 'pkce', + usePkce: true, tokenExchangeStyle: 'form', clientIdValueName: 'spotify-client-id', clientSecretSecretName: null, @@ -265,6 +276,8 @@ test('connect OAuth derives Notion basic-json exchange and surfaces provider fai apiBaseUrl: 'https://api.notion.com/v1', scopes: [], flow: 'confidential', + usePkce: null, + tokenExchangeStyle: null, scopeSeparator: ' ', extraAuthorizeParams: { owner: 'user', response_type: 'code' }, providerSetupInstructions: null, @@ -278,6 +291,7 @@ test('connect OAuth derives Notion basic-json exchange and surfaces provider fai provider: 'notion', tokenUrl: 'https://api.notion.com/v1/oauth/token', flow: 'confidential', + usePkce: false, tokenExchangeStyle: 'basic-json', clientSecretSecretName: 'notionClientSecret', accessTokenSecretName: 'notionAccessToken', @@ -346,3 +360,124 @@ test('connect OAuth derives Notion basic-json exchange and surfaces provider fai }), ).toBe(false) }) + +test('connect OAuth derives Canva confidential + PKCE basic-form defaults and honors explicit overrides', () => { + const canvaQueryConfig = { + provider: 'canva', + providerKey: 'canva', + authorizeHost: 'www.canva.com', + authorizeUrl: 'https://www.canva.com/api/oauth/authorize', + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + apiBaseUrl: 'https://api.canva.com/rest/v1', + scopes: ['design:content:read'], + flow: null, + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: ' ', + extraAuthorizeParams: {}, + providerSetupInstructions: null, + dashboardUrl: null, + allowedHosts: ['api.canva.com'], + } + + // Canva requires BOTH S256 PKCE and a client secret on token exchange, so + // the host defaults must combine a confidential flow with PKCE enabled. + const canvaConfig = mergeConnectOauthConfig({ + queryConfig: canvaQueryConfig, + storedIntegration: null, + }) + expect(canvaConfig).toMatchObject({ + provider: 'canva', + tokenHost: 'api.canva.com', + flow: 'confidential', + usePkce: true, + tokenExchangeStyle: 'basic-form', + clientSecretSecretName: 'canvaClientSecret', + accessTokenSecretName: 'canvaAccessToken', + }) + + // Explicit query params still win over host defaults. + const overriddenConfig = mergeConnectOauthConfig({ + queryConfig: { + ...canvaQueryConfig, + usePkce: false, + tokenExchangeStyle: 'form', + }, + storedIntegration: null, + }) + expect(overriddenConfig).toMatchObject({ + flow: 'confidential', + usePkce: false, + tokenExchangeStyle: 'form', + }) + + // PKCE can be enabled on top of a confidential flow for any provider. + const confidentialPkceConfig = mergeConnectOauthConfig({ + queryConfig: { + ...canvaQueryConfig, + provider: 'acme', + providerKey: 'acme', + authorizeHost: 'auth.acme.test', + authorizeUrl: 'https://auth.acme.test/oauth/authorize', + tokenUrl: 'https://auth.acme.test/oauth/token', + apiBaseUrl: null, + flow: 'confidential', + usePkce: true, + allowedHosts: ['auth.acme.test'], + }, + storedIntegration: null, + }) + expect(confidentialPkceConfig).toMatchObject({ + flow: 'confidential', + usePkce: true, + tokenExchangeStyle: 'form', + clientSecretSecretName: 'acmeClientSecret', + }) + + // Reconnects read the persisted PKCE choice back from the stored config. + const storedCanva = parseStoredIntegrationConfig( + JSON.stringify({ + name: 'canva', + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + apiBaseUrl: 'https://api.canva.com/rest/v1', + flow: 'confidential', + usePkce: true, + clientIdValueName: 'canva-client-id', + clientSecretSecretName: 'canvaClientSecret', + accessTokenSecretName: 'canvaAccessToken', + refreshTokenSecretName: 'canvaRefreshToken', + requiredHosts: ['api.canva.com'], + tokenExchangeStyle: 'basic-form', + authorization: { + authorizeUrl: 'https://www.canva.com/api/oauth/authorize', + scopes: ['design:content:read'], + scopeSeparator: null, + extraAuthorizeParams: {}, + }, + }), + null, + ) + expect(storedCanva?.usePkce).toBe(true) + expect(storedCanva?.tokenExchangeStyle).toBe('basic-form') + + const reconnectConfig = mergeConnectOauthConfig({ + queryConfig: { + ...canvaQueryConfig, + authorizeHost: null, + authorizeUrl: null, + tokenUrl: null, + apiBaseUrl: null, + scopes: null, + allowedHosts: [], + }, + storedIntegration: storedCanva, + }) + expect(reconnectConfig).toMatchObject({ + provider: 'canva', + authorizeUrl: 'https://www.canva.com/api/oauth/authorize', + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + flow: 'confidential', + usePkce: true, + tokenExchangeStyle: 'basic-form', + }) +}) diff --git a/packages/worker/client/routes/connect-oauth.tsx b/packages/worker/client/routes/connect-oauth.tsx index aa4e7b8252..8500745a5b 100644 --- a/packages/worker/client/routes/connect-oauth.tsx +++ b/packages/worker/client/routes/connect-oauth.tsx @@ -38,7 +38,7 @@ import { } from '#client/styles/style-primitives.ts' type OAuthFlow = 'pkce' | 'confidential' -type TokenExchangeStyle = 'form' | 'basic-json' +type TokenExchangeStyle = 'form' | 'basic-json' | 'basic-form' type ConnectOauthQueryConfig = { provider: string @@ -49,6 +49,8 @@ type ConnectOauthQueryConfig = { apiBaseUrl: string | null scopes: Array | null flow: OAuthFlow | null + usePkce: boolean | null + tokenExchangeStyle: TokenExchangeStyle | null scopeSeparator: string | null extraAuthorizeParams: Record | null providerSetupInstructions: string | null @@ -66,6 +68,11 @@ type ConnectOauthConfig = { apiBaseUrl: string | null scopes: Array flow: OAuthFlow + /** + * PKCE is orthogonal to `flow`: providers like Canva require S256 PKCE + * *and* a client secret on token exchange. + */ + usePkce: boolean tokenExchangeStyle: TokenExchangeStyle scopeSeparator: string extraAuthorizeParams: Record @@ -83,6 +90,7 @@ type StoredIntegrationConfig = { tokenUrl: string apiBaseUrl: string | null flow: OAuthFlow + usePkce?: boolean | null clientIdValueName: string clientSecretSecretName: string | null accessTokenSecretName: string @@ -231,8 +239,13 @@ export function ConnectOauthRoute(handle: Handle) { setStatus('Token URL must be valid when provided.', 'error') return null } - let flow = (readOptional('flow') ?? 'pkce').toLowerCase() - if (flow !== 'pkce' && flow !== 'confidential') flow = 'pkce' + const rawFlow = readOptional('flow')?.toLowerCase() ?? null + const flow: OAuthFlow | null = + rawFlow === 'pkce' || rawFlow === 'confidential' ? rawFlow : null + const usePkce = parseOptionalBoolean(readOptional('pkce')) + const tokenExchangeStyle = parseTokenExchangeStyle( + readOptional('tokenExchangeStyle'), + ) const rawScopes = readOptional('scopes') const scopes = rawScopes == null ? null : parseScopes(rawScopes) const scopeSeparator = readOptional('scopeSeparator') @@ -263,7 +276,9 @@ export function ConnectOauthRoute(handle: Handle) { tokenUrl, apiBaseUrl, scopes, - flow: flow as OAuthFlow, + flow, + usePkce, + tokenExchangeStyle, scopeSeparator, extraAuthorizeParams, providerSetupInstructions, @@ -310,6 +325,7 @@ export function ConnectOauthRoute(handle: Handle) { typeof record.authorizeHost === 'string' && typeof record.tokenHost === 'string' && typeof record.flow === 'string' && + typeof record.usePkce === 'boolean' && typeof record.scopeSeparator === 'string' && typeof record.clientIdValueName === 'string' && typeof record.accessTokenSecretName === 'string' && @@ -380,7 +396,7 @@ export function ConnectOauthRoute(handle: Handle) { } const state = createState(getStateKey(nextConfig.providerKey)) url.searchParams.set('state', state) - if (nextConfig.flow === 'pkce') { + if (nextConfig.usePkce) { const verifier = createCodeVerifier() sessionStorage.setItem(getPkceKey(nextConfig.providerKey), verifier) const challenge = await createCodeChallenge(verifier) @@ -583,7 +599,7 @@ export function ConnectOauthRoute(handle: Handle) { params.set('client_id', clientId) params.set('code', code) params.set('redirect_uri', getRedirectUri()) - if (nextConfig.flow === 'pkce') { + if (nextConfig.usePkce) { const verifier = sessionStorage.getItem( getPkceKey(nextConfig.providerKey), ) @@ -756,6 +772,7 @@ export function ConnectOauthRoute(handle: Handle) { scopeSeparator: config.scopeSeparator, extraAuthorizeParams: config.extraAuthorizeParams, flow: config.flow, + usePkce: config.usePkce, tokenExchangeStyle: config.tokenExchangeStyle, clientIdValueName: config.clientIdValueName, clientSecretSecretName: config.clientSecretSecretName, @@ -1011,6 +1028,12 @@ export function ConnectOauthRoute(handle: Handle) { Flow {config.flow} +
+ PKCE + + {config.usePkce ? 'S256' : 'off'} + +
Scopes @@ -1290,6 +1313,7 @@ export function parseStoredIntegrationConfig( const tokenUrl = typeof parsed.tokenUrl === 'string' ? parsed.tokenUrl.trim() : '' const flow = parsed.flow === 'confidential' ? 'confidential' : 'pkce' + const usePkce = typeof parsed.usePkce === 'boolean' ? parsed.usePkce : null const clientIdValueName = typeof parsed.clientIdValueName === 'string' ? parsed.clientIdValueName.trim() @@ -1330,6 +1354,7 @@ export function parseStoredIntegrationConfig( ? parsed.apiBaseUrl.trim() : null, flow, + usePkce, clientIdValueName, clientSecretSecretName, accessTokenSecretName, @@ -1414,7 +1439,14 @@ export function mergeConnectOauthConfig(input: { ) { return null } - const flow = input.storedIntegration?.flow ?? input.queryConfig.flow ?? 'pkce' + const flow = + input.storedIntegration?.flow ?? + input.queryConfig.flow ?? + defaultConnectOauthFlow(tokenUrl) + const usePkce = + input.queryConfig.usePkce ?? + input.storedIntegration?.usePkce ?? + defaultConnectOauthUsePkce({ flow, tokenUrl }) const scopes = resolveConnectOauthScopes(input) const extraAuthorizeParams = resolveConnectOauthExtraAuthorizeParams(input) const allowedHosts = normalizeHosts([ @@ -1434,8 +1466,10 @@ export function mergeConnectOauthConfig(input: { input.storedIntegration?.apiBaseUrl ?? input.queryConfig.apiBaseUrl, scopes, flow, + usePkce, tokenExchangeStyle: resolveConnectOauthTokenExchangeStyle({ tokenUrl, + queryStyle: input.queryConfig.tokenExchangeStyle, storedStyle: input.storedIntegration?.tokenExchangeStyle ?? null, }), scopeSeparator: @@ -1550,16 +1584,45 @@ function hasProviderOAuthExchangeError(data: Record | null) { function resolveConnectOauthTokenExchangeStyle(input: { tokenUrl: string + queryStyle: TokenExchangeStyle | null storedStyle: TokenExchangeStyle | null }): TokenExchangeStyle { + if (input.queryStyle) return input.queryStyle if (input.storedStyle) return input.storedStyle const host = safeParseHost(input.tokenUrl) if (host === 'api.notion.com') return 'basic-json' + if (host === 'api.canva.com') return 'basic-form' return 'form' } +/** + * Hosts that require a confidential client even though the default flow is + * PKCE-only. Canva requires both S256 PKCE and a client secret. + */ +function defaultConnectOauthFlow(tokenUrl: string): OAuthFlow { + return safeParseHost(tokenUrl) === 'api.canva.com' ? 'confidential' : 'pkce' +} + +function defaultConnectOauthUsePkce(input: { + flow: OAuthFlow + tokenUrl: string +}): boolean { + if (input.flow === 'pkce') return true + return safeParseHost(input.tokenUrl) === 'api.canva.com' +} + function parseTokenExchangeStyle(raw: unknown): TokenExchangeStyle | null { - return raw === 'form' || raw === 'basic-json' ? raw : null + return raw === 'form' || raw === 'basic-json' || raw === 'basic-form' + ? raw + : null +} + +function parseOptionalBoolean(raw: string | null): boolean | null { + if (raw == null) return null + const normalized = raw.trim().toLowerCase() + if (normalized === 'true' || normalized === '1') return true + if (normalized === 'false' || normalized === '0') return false + return null } function formatMissingSetupFields(missingFields: Array) { @@ -1676,7 +1739,10 @@ const pageCss = { const headerCss = pageHeaderCss const eyebrowCss = pageEyebrowCss -const primaryButtonCss = getPrimaryButtonCss({ size: 'lg', weight: 'semibold' }) +const primaryButtonCss = getPrimaryButtonCss({ + size: 'lg', + weight: 'semibold', +}) const secondaryButtonCss = getSecondaryButtonCss({ size: 'lg', weight: 'semibold', diff --git a/packages/worker/src/app/handlers/account-secrets.node.test.ts b/packages/worker/src/app/handlers/account-secrets.node.test.ts index 9bb61c4ccc..4304d2f7c3 100644 --- a/packages/worker/src/app/handlers/account-secrets.node.test.ts +++ b/packages/worker/src/app/handlers/account-secrets.node.test.ts @@ -871,3 +871,136 @@ test('oauth_exchange supports Notion basic-json and confidential form-body style vi.unstubAllGlobals() }) + +test('oauth_exchange supports Canva basic-form with PKCE code_verifier and a client secret together', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const handler = createAccountSecretsApiHandler(createEnv()) + + mockModule.resolveSecret.mockResolvedValueOnce({ + found: true, + value: 'canva-client-secret', + }) + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'canva-access', + refresh_token: 'canva-refresh', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + + const canvaSuccess = await handler.handler({ + request: new Request('https://example.com/account/secrets.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'oauth_exchange', + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + params: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: 'canva-client-id', + code: 'canva-code', + redirect_uri: 'https://example.com/connect/oauth', + code_verifier: 'pkce-verifier', + }).toString(), + flow: 'confidential', + clientSecretSecretName: 'canvaClientSecret', + allowedHosts: ['api.canva.com'], + }), + }), + params: {}, + } as never) + + expect(canvaSuccess.status).toBe(200) + await expect(canvaSuccess.json()).resolves.toMatchObject({ + access_token: 'canva-access', + refresh_token: 'canva-refresh', + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://api.canva.com/rest/v1/oauth/token', + ) + const canvaRequest = fetchMock.mock.calls[0]?.[1] as RequestInit + expect(canvaRequest.method).toBe('POST') + expect(canvaRequest.headers).toMatchObject({ + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${btoa('canva-client-id:canva-client-secret')}`, + }) + const canvaBody = new URLSearchParams(String(canvaRequest.body)) + expect(canvaBody.get('grant_type')).toBe('authorization_code') + expect(canvaBody.get('code')).toBe('canva-code') + expect(canvaBody.get('code_verifier')).toBe('pkce-verifier') + expect(canvaBody.get('client_id')).toBeNull() + expect(canvaBody.get('client_secret')).toBeNull() + + vi.unstubAllGlobals() +}) + +test('connect oauth persists usePkce for confidential + PKCE providers like Canva', async () => { + mockModule.saveValue.mockClear() + const handler = createAccountSecretsApiHandler(createEnv()) + + const canvaResponse = await handler.handler({ + request: new Request('https://example.com/account/secrets.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'connect_oauth', + provider: 'canva', + authorizeUrl: 'https://www.canva.com/api/oauth/authorize', + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + apiBaseUrl: 'https://api.canva.com/rest/v1', + scopes: ['design:content:read'], + scopeSeparator: ' ', + flow: 'confidential', + usePkce: true, + tokenExchangeStyle: 'basic-form', + clientIdValueName: 'canva-client-id', + clientSecretSecretName: 'canvaClientSecret', + accessTokenSecretName: 'canvaAccessToken', + refreshTokenSecretName: 'canvaRefreshToken', + allowedHosts: ['api.canva.com'], + tokenPayload: { + access_token: 'access-token', + refresh_token: 'refresh-token', + }, + }), + }), + params: {}, + } as never) + + expect(canvaResponse.status).toBe(200) + await expect(canvaResponse.json()).resolves.toMatchObject({ + ok: true, + accessTokenSaved: true, + refreshTokenSaved: true, + integrationName: 'canva', + }) + expect(mockModule.saveValue).toHaveBeenCalledWith( + expect.objectContaining({ + name: '_integration:canva', + value: JSON.stringify({ + name: 'canva', + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + apiBaseUrl: 'https://api.canva.com/rest/v1', + flow: 'confidential', + usePkce: true, + clientIdValueName: 'canva-client-id', + clientSecretSecretName: 'canvaClientSecret', + accessTokenSecretName: 'canvaAccessToken', + refreshTokenSecretName: 'canvaRefreshToken', + requiredHosts: ['api.canva.com'], + tokenExchangeStyle: 'basic-form', + authorization: { + authorizeUrl: 'https://www.canva.com/api/oauth/authorize', + scopes: ['design:content:read'], + scopeSeparator: null, + extraAuthorizeParams: {}, + }, + }), + }), + ) +}) diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts index 050de69c7c..9903294a67 100644 --- a/packages/worker/src/app/handlers/account-secrets.ts +++ b/packages/worker/src/app/handlers/account-secrets.ts @@ -247,6 +247,7 @@ async function handleConnectOauthAction(input: { const apiBaseUrl = readOptionalString(input.body, 'apiBaseUrl') const authorizeUrl = readOptionalString(input.body, 'authorizeUrl') const flow = readOptionalString(input.body, 'flow') + const usePkce = readOptionalBoolean(input.body, 'usePkce') const clientIdValueName = readOptionalString(input.body, 'clientIdValueName') const clientSecretSecretName = readOptionalString( input.body, @@ -359,6 +360,7 @@ async function handleConnectOauthAction(input: { tokenUrl, apiBaseUrl, flow: flow === 'confidential' ? 'confidential' : 'pkce', + usePkce, clientIdValueName, clientSecretSecretName, accessTokenSecretName, @@ -601,6 +603,7 @@ async function saveIntegrationConfig(input: { tokenUrl: string apiBaseUrl: string | null flow: 'pkce' | 'confidential' + usePkce: boolean | null clientIdValueName: string clientSecretSecretName: string | null accessTokenSecretName: string @@ -625,6 +628,7 @@ async function saveIntegrationConfig(input: { tokenUrl: input.tokenUrl, apiBaseUrl: input.apiBaseUrl, flow: input.flow, + ...(input.usePkce == null ? {} : { usePkce: input.usePkce }), clientIdValueName: input.clientIdValueName, clientSecretSecretName: input.flow === 'confidential' @@ -997,6 +1001,11 @@ function readOptionalString(body: object, key: string) { return typeof value === 'string' ? value.trim() : null } +function readOptionalBoolean(body: object, key: string) { + const value = (body as Record)[key] + return typeof value === 'boolean' ? value : null +} + function readRawOptionalString(body: object, key: string) { const value = (body as Record)[key] return typeof value === 'string' ? value : null diff --git a/packages/worker/src/app/oauth-token-exchange.node.test.ts b/packages/worker/src/app/oauth-token-exchange.node.test.ts index 87b8c2e11f..3d4c349d41 100644 --- a/packages/worker/src/app/oauth-token-exchange.node.test.ts +++ b/packages/worker/src/app/oauth-token-exchange.node.test.ts @@ -65,6 +65,24 @@ test('token exchange style resolves Notion basic-json and builds both request sh 'client-secret', ) + const formPkceConfidentialRequest = buildOAuthTokenExchangeRequest({ + params: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: 'client-id', + code: 'code', + redirect_uri: 'https://example.com/connect/oauth', + code_verifier: 'pkce-verifier', + }), + flow: 'confidential', + clientSecret: 'client-secret', + style: 'form', + }) + const formPkceConfidentialBody = new URLSearchParams( + formPkceConfidentialRequest.body, + ) + expect(formPkceConfidentialBody.get('client_secret')).toBe('client-secret') + expect(formPkceConfidentialBody.get('code_verifier')).toBe('pkce-verifier') + expect(oauthTokenExchangeFailureHttpStatus()).toBe(502) expect( buildOAuthTokenExchangeFailurePayload({ @@ -81,3 +99,67 @@ test('token exchange style resolves Notion basic-json and builds both request sh providerStatus: 401, }) }) + +test('token exchange style resolves Canva basic-form and keeps PKCE code_verifier alongside Basic client auth', () => { + expect( + resolveTokenExchangeStyle({ + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + }), + ).toBe('basic-form') + expect( + resolveTokenExchangeStyle({ + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + tokenExchangeStyle: 'form', + }), + ).toBe('form') + + const canvaRequest = buildOAuthTokenExchangeRequest({ + params: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: 'canva-client-id', + code: 'canva-code', + redirect_uri: 'https://example.com/connect/oauth', + code_verifier: 'pkce-verifier', + }), + flow: 'confidential', + clientSecret: 'canva-client-secret', + style: 'basic-form', + }) + expect(canvaRequest.headers).toEqual({ + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${btoa('canva-client-id:canva-client-secret')}`, + }) + const canvaBody = new URLSearchParams(canvaRequest.body) + expect(canvaBody.get('grant_type')).toBe('authorization_code') + expect(canvaBody.get('code')).toBe('canva-code') + expect(canvaBody.get('code_verifier')).toBe('pkce-verifier') + expect(canvaBody.get('client_id')).toBeNull() + expect(canvaBody.get('client_secret')).toBeNull() + + expect(() => + buildOAuthTokenExchangeRequest({ + params: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: 'canva-client-id', + code: 'canva-code', + }), + flow: 'pkce', + clientSecret: null, + style: 'basic-form', + }), + ).toThrow( + 'basic-form token exchange requires confidential flow with a client secret.', + ) + expect(() => + buildOAuthTokenExchangeRequest({ + params: new URLSearchParams({ + grant_type: 'authorization_code', + code: 'canva-code', + }), + flow: 'confidential', + clientSecret: 'canva-client-secret', + style: 'basic-form', + }), + ).toThrow('basic-form token exchange requires client_id in params.') +}) diff --git a/packages/worker/src/app/oauth-token-exchange.ts b/packages/worker/src/app/oauth-token-exchange.ts index 7add4e9aaf..abffc29f14 100644 --- a/packages/worker/src/app/oauth-token-exchange.ts +++ b/packages/worker/src/app/oauth-token-exchange.ts @@ -8,8 +8,15 @@ import { safeParseHost } from '@kody-internal/shared/url-hosts.ts' * `client_secret` in the body (GitHub/Slack-style). * - `basic-json`: HTTP Basic (client_id:client_secret) + JSON body without * credentials in the body (Notion-style). + * - `basic-form`: HTTP Basic (client_id:client_secret) + + * application/x-www-form-urlencoded body without credentials in the body + * (Canva-style). + * + * Client-secret authentication and PKCE are orthogonal: `params` may carry a + * PKCE `code_verifier` in any style, so providers like Canva that require both + * S256 PKCE and a client secret on token exchange are supported. */ -export const tokenExchangeStyles = ['form', 'basic-json'] as const +export const tokenExchangeStyles = ['form', 'basic-json', 'basic-form'] as const export type TokenExchangeStyle = (typeof tokenExchangeStyles)[number] export function isTokenExchangeStyle( @@ -28,6 +35,9 @@ export function resolveTokenExchangeStyle(input: { // Notion's token endpoint rejects form-body client_secret and requires // Basic Auth + JSON: https://developers.notion.com/guides/get-started/authorization if (host === 'api.notion.com') return 'basic-json' + // Canva's token endpoint takes Basic Auth (or form-body credentials) with + // an urlencoded body: https://www.canva.dev/docs/connect/authentication/ + if (host === 'api.canva.com') return 'basic-form' return 'form' } @@ -40,31 +50,38 @@ export function buildOAuthTokenExchangeRequest(input: { const params = new URLSearchParams(input.params) switch (input.style) { case 'basic-json': { - if (input.flow !== 'confidential' || !input.clientSecret) { - throw new Error( - 'basic-json token exchange requires confidential flow with a client secret.', - ) - } - const clientId = params.get('client_id')?.trim() ?? '' - if (!clientId) { - throw new Error( - 'basic-json token exchange requires client_id in params.', - ) - } - params.delete('client_id') - params.delete('client_secret') + const authorization = buildBasicAuthorization({ + params, + flow: input.flow, + clientSecret: input.clientSecret, + style: input.style, + }) const bodyObject = Object.fromEntries(params.entries()) return { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: `Basic ${bytesToBase64( - new TextEncoder().encode(`${clientId}:${input.clientSecret}`), - )}`, + Authorization: authorization, }, body: JSON.stringify(bodyObject), } } + case 'basic-form': { + const authorization = buildBasicAuthorization({ + params, + flow: input.flow, + clientSecret: input.clientSecret, + style: input.style, + }) + return { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: authorization, + }, + body: params.toString(), + } + } case 'form': { if (input.flow === 'confidential') { if (!input.clientSecret) { @@ -89,6 +106,34 @@ export function buildOAuthTokenExchangeRequest(input: { } } +/** + * Builds the HTTP Basic Authorization header for the basic-* styles and strips + * the credentials from the body params. Mutates `params`. + */ +function buildBasicAuthorization(input: { + params: URLSearchParams + flow: 'pkce' | 'confidential' + clientSecret: string | null + style: TokenExchangeStyle +}): string { + if (input.flow !== 'confidential' || !input.clientSecret) { + throw new Error( + `${input.style} token exchange requires confidential flow with a client secret.`, + ) + } + const clientId = input.params.get('client_id')?.trim() ?? '' + if (!clientId) { + throw new Error( + `${input.style} token exchange requires client_id in params.`, + ) + } + input.params.delete('client_id') + input.params.delete('client_secret') + return `Basic ${bytesToBase64( + new TextEncoder().encode(`${clientId}:${input.clientSecret}`), + )}` +} + /** * Provider token-endpoint failures must not reuse HTTP 401 — the connect UI * treats 401 from `/account/secrets.json` as an expired Kody session. diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts index 9dc5bb549f..67ba5d3811 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts @@ -283,3 +283,68 @@ test('integration config helpers and integration_save persist validated integrat requiredHosts: ['api.spotify.com'], }) }) + +test('integration_save persists usePkce only when it differs from the flow default', async () => { + // Canva-style: confidential flow with PKCE enabled must round-trip. + const canvaDb = createValueTestDb() + const canvaResult = await integrationSaveCapability.handler( + { + name: 'canva', + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + apiBaseUrl: 'https://api.canva.com/rest/v1', + flow: 'confidential', + usePkce: true, + clientIdValueName: 'canva-client-id', + clientSecretSecretName: 'canvaClientSecret', + accessTokenSecretName: 'canvaAccessToken', + refreshTokenSecretName: 'canvaRefreshToken', + requiredHosts: ['api.canva.com'], + tokenExchangeStyle: 'basic-form', + }, + { + env: { APP_DB: canvaDb.db } as unknown as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-123' }, + }), + }, + ) + expect(canvaResult.integration).toMatchObject({ + name: 'canva', + flow: 'confidential', + usePkce: true, + tokenExchangeStyle: 'basic-form', + }) + expect( + JSON.parse(canvaDb.entries.get('_integration:canva') ?? '{}'), + ).toMatchObject({ + flow: 'confidential', + usePkce: true, + tokenExchangeStyle: 'basic-form', + }) + + // Flow-default PKCE choices normalize away instead of being stored. + const defaultDb = createValueTestDb() + const defaultResult = await integrationSaveCapability.handler( + { + name: 'spotify', + tokenUrl: 'https://accounts.spotify.com/api/token', + flow: 'pkce', + usePkce: true, + clientIdValueName: 'spotify-client-id', + accessTokenSecretName: 'spotifyAccessToken', + requiredHosts: ['api.spotify.com'], + }, + { + env: { APP_DB: defaultDb.db } as unknown as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-123' }, + }), + }, + ) + expect(defaultResult.integration).not.toHaveProperty('usePkce') + expect( + JSON.parse(defaultDb.entries.get('_integration:spotify') ?? '{}'), + ).not.toHaveProperty('usePkce') +}) diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-save.ts b/packages/worker/src/mcp/capabilities/integrations/integration-save.ts index 7a9e83b6fb..7af5f2ae0b 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-save.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-save.ts @@ -91,6 +91,7 @@ function createNewIntegrationConfig(args: z.infer) { tokenUrl: args.tokenUrl, apiBaseUrl: args.apiBaseUrl ?? null, flow: args.flow, + ...(args.usePkce !== undefined ? { usePkce: args.usePkce } : {}), clientIdValueName: args.clientIdValueName, clientSecretSecretName: args.clientSecretSecretName ?? null, accessTokenSecretName: args.accessTokenSecretName, diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts b/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts index b599bf5d5f..8cef370adc 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts @@ -5,7 +5,11 @@ export const integrationFlowValues = ['pkce', 'confidential'] as const const defaultIntegrationScopeSeparator = ' ' -export const tokenExchangeStyleValues = ['form', 'basic-json'] as const +export const tokenExchangeStyleValues = [ + 'form', + 'basic-json', + 'basic-form', +] as const export const integrationAuthorizationSchema = z .object({ @@ -30,6 +34,12 @@ export const integrationConfigSchema = z.object({ tokenUrl: z.string().url(), apiBaseUrl: z.string().url().optional().nullable(), flow: z.enum(integrationFlowValues), + /** + * PKCE is orthogonal to `flow`. Absent means the flow default: PKCE on for + * `pkce` flow, off for `confidential`. Canva-style providers store + * `usePkce: true` with `confidential` flow. + */ + usePkce: z.boolean().optional().nullable(), clientIdValueName: z.string().min(1), clientSecretSecretName: z.string().min(1).optional().nullable(), accessTokenSecretName: z.string().min(1), @@ -48,6 +58,7 @@ export const integrationSaveSchema = z tokenUrl: z.string().url().optional(), apiBaseUrl: z.string().url().nullable().optional(), flow: z.enum(integrationFlowValues).optional(), + usePkce: z.boolean().nullable().optional(), clientIdValueName: z.string().min(1).optional(), clientSecretSecretName: z.string().min(1).nullable().optional(), accessTokenSecretName: z.string().min(1).optional(), @@ -67,11 +78,19 @@ export function normalizeIntegrationConfig( ? normalizeIntegrationAuthorization(value.authorization) : null const tokenExchangeStyle = value.tokenExchangeStyle ?? null + // Store usePkce only when it differs from the flow default so existing + // integration records keep their canonical shape. + const usePkce = + typeof value.usePkce === 'boolean' && + value.usePkce !== (value.flow === 'pkce') + ? value.usePkce + : null return { - ...value, name: value.name.trim(), tokenUrl: value.tokenUrl.trim(), apiBaseUrl: value.apiBaseUrl?.trim() || null, + flow: value.flow, + ...(usePkce == null ? {} : { usePkce }), clientIdValueName: value.clientIdValueName.trim(), clientSecretSecretName: value.clientSecretSecretName?.trim() || null, accessTokenSecretName: value.accessTokenSecretName.trim(), From 0eec38af28823911a0436de3df2c00549a426314 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 21:34:25 +0000 Subject: [PATCH 2/6] fix(oauth): form-urlencode Basic-auth client credentials per RFC 6749 Addresses CodeRabbit review on #716: percent-encode client_id and client_secret before joining with ':' and base64-encoding so reserved characters survive, and strengthen basic-form tests to cover body credential stripping and each validation condition independently. --- .../src/app/oauth-token-exchange.node.test.ts | 33 +++++++++++++++++++ .../worker/src/app/oauth-token-exchange.ts | 16 ++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/app/oauth-token-exchange.node.test.ts b/packages/worker/src/app/oauth-token-exchange.node.test.ts index 3d4c349d41..3c30b99d49 100644 --- a/packages/worker/src/app/oauth-token-exchange.node.test.ts +++ b/packages/worker/src/app/oauth-token-exchange.node.test.ts @@ -117,6 +117,7 @@ test('token exchange style resolves Canva basic-form and keeps PKCE code_verifie params: new URLSearchParams({ grant_type: 'authorization_code', client_id: 'canva-client-id', + client_secret: 'stale-body-secret', code: 'canva-code', redirect_uri: 'https://example.com/connect/oauth', code_verifier: 'pkce-verifier', @@ -137,6 +138,24 @@ test('token exchange style resolves Canva basic-form and keeps PKCE code_verifie expect(canvaBody.get('client_id')).toBeNull() expect(canvaBody.get('client_secret')).toBeNull() + // Basic-auth credentials are form-urlencoded per RFC 6749 §2.3.1, so + // reserved characters like ":" and "%" survive the Basic header. + const reservedCharacterRequest = buildOAuthTokenExchangeRequest({ + params: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: 'client:id', + code: 'canva-code', + }), + flow: 'confidential', + clientSecret: 'secret%value', + style: 'basic-form', + }) + expect(reservedCharacterRequest.headers.Authorization).toBe( + `Basic ${btoa('client%3Aid:secret%25value')}`, + ) + + // Each validation condition fails independently: PKCE-only flow, missing + // secret with confidential flow, and missing client_id. expect(() => buildOAuthTokenExchangeRequest({ params: new URLSearchParams({ @@ -145,6 +164,20 @@ test('token exchange style resolves Canva basic-form and keeps PKCE code_verifie code: 'canva-code', }), flow: 'pkce', + clientSecret: 'canva-client-secret', + style: 'basic-form', + }), + ).toThrow( + 'basic-form token exchange requires confidential flow with a client secret.', + ) + expect(() => + buildOAuthTokenExchangeRequest({ + params: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: 'canva-client-id', + code: 'canva-code', + }), + flow: 'confidential', clientSecret: null, style: 'basic-form', }), diff --git a/packages/worker/src/app/oauth-token-exchange.ts b/packages/worker/src/app/oauth-token-exchange.ts index abffc29f14..7de5d98dd1 100644 --- a/packages/worker/src/app/oauth-token-exchange.ts +++ b/packages/worker/src/app/oauth-token-exchange.ts @@ -130,10 +130,24 @@ function buildBasicAuthorization(input: { input.params.delete('client_id') input.params.delete('client_secret') return `Basic ${bytesToBase64( - new TextEncoder().encode(`${clientId}:${input.clientSecret}`), + new TextEncoder().encode( + `${formUrlEncodeBasicCredential(clientId)}:${formUrlEncodeBasicCredential( + input.clientSecret, + )}`, + ), )}` } +/** + * RFC 6749 §2.3.1: Basic-auth client credentials are + * application/x-www-form-urlencoded before being joined with ":" and + * base64-encoded, so ids or secrets containing ":" or "%" survive intact. + * A no-op for the alphanumeric credentials real providers issue. + */ +function formUrlEncodeBasicCredential(value: string) { + return encodeURIComponent(value).replace(/%20/g, '+') +} + /** * Provider token-endpoint failures must not reuse HTTP 401 — the connect UI * treats 401 from `/account/secrets.json` as an expired Kody session. From f5b31d741e83fa3e247b744547609e3cebc98092 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 21:45:09 +0000 Subject: [PATCH 3/6] fix(oauth): backfill usePkce for session configs persisted mid-flight Bugbot flagged that requiring usePkce in the sessionStorage config guard would reject configs persisted before the orthogonal-PKCE change, failing in-flight connects at the callback leg. Backfill the flow/host default instead of rejecting, and cover the legacy shapes with unit tests. --- .../client/routes/connect-oauth.node.test.ts | 71 ++++++++++++++++++ .../worker/client/routes/connect-oauth.tsx | 74 +++++++++++-------- 2 files changed, 115 insertions(+), 30 deletions(-) diff --git a/packages/worker/client/routes/connect-oauth.node.test.ts b/packages/worker/client/routes/connect-oauth.node.test.ts index c2f0751a94..7a2b5720ef 100644 --- a/packages/worker/client/routes/connect-oauth.node.test.ts +++ b/packages/worker/client/routes/connect-oauth.node.test.ts @@ -5,6 +5,7 @@ import { getIntegrationValueCandidates, isOAuthExchangeSessionExpired, mergeConnectOauthConfig, + parseSessionConnectOauthConfig, parseStoredIntegrationConfig, summarizeStoredSetupState, } from './connect-oauth.tsx' @@ -481,3 +482,73 @@ test('connect OAuth derives Canva confidential + PKCE basic-form defaults and ho tokenExchangeStyle: 'basic-form', }) }) + +test('session config parsing backfills usePkce for configs persisted before the orthogonal-PKCE change', () => { + const legacyConfig = { + provider: 'spotify', + providerKey: 'spotify', + authorizeHost: 'accounts.spotify.com', + tokenHost: 'accounts.spotify.com', + authorizeUrl: 'https://accounts.spotify.com/authorize', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: null, + scopes: [], + flow: 'pkce', + tokenExchangeStyle: 'form', + scopeSeparator: ' ', + extraAuthorizeParams: {}, + providerSetupInstructions: null, + dashboardUrl: null, + clientIdValueName: 'spotify-client-id', + clientSecretSecretName: null, + accessTokenSecretName: 'spotifyAccessToken', + refreshTokenSecretName: 'spotifyRefreshToken', + allowedHosts: ['accounts.spotify.com'], + } + + // Mid-flight configs without usePkce derive the flow default so callbacks + // keep working across the deploy boundary. + expect( + parseSessionConnectOauthConfig(JSON.stringify(legacyConfig)), + ).toMatchObject({ provider: 'spotify', flow: 'pkce', usePkce: true }) + + expect( + parseSessionConnectOauthConfig( + JSON.stringify({ + ...legacyConfig, + provider: 'github', + flow: 'confidential', + }), + ), + ).toMatchObject({ flow: 'confidential', usePkce: false }) + + // Canva host default enables PKCE even for legacy confidential configs. + expect( + parseSessionConnectOauthConfig( + JSON.stringify({ + ...legacyConfig, + provider: 'canva', + flow: 'confidential', + tokenHost: 'api.canva.com', + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + }), + ), + ).toMatchObject({ flow: 'confidential', usePkce: true }) + + // An explicit persisted choice is respected as-is. + expect( + parseSessionConnectOauthConfig( + JSON.stringify({ ...legacyConfig, usePkce: false }), + ), + ).toMatchObject({ flow: 'pkce', usePkce: false }) + + expect(parseSessionConnectOauthConfig('not json')).toBeNull() + expect( + parseSessionConnectOauthConfig(JSON.stringify({ provider: 'x' })), + ).toBeNull() + expect( + parseSessionConnectOauthConfig( + JSON.stringify({ ...legacyConfig, flow: 'implicit' }), + ), + ).toBeNull() +}) diff --git a/packages/worker/client/routes/connect-oauth.tsx b/packages/worker/client/routes/connect-oauth.tsx index 8500745a5b..a9b263ab8d 100644 --- a/packages/worker/client/routes/connect-oauth.tsx +++ b/packages/worker/client/routes/connect-oauth.tsx @@ -312,30 +312,6 @@ export function ConnectOauthRoute(handle: Handle) { const configStorageKey = 'connect-oauth:config' - const isConnectOauthConfig = ( - value: unknown, - ): value is ConnectOauthConfig => { - if (!value || typeof value !== 'object') return false - const record = value as Record - return ( - typeof record.provider === 'string' && - typeof record.providerKey === 'string' && - typeof record.authorizeUrl === 'string' && - typeof record.tokenUrl === 'string' && - typeof record.authorizeHost === 'string' && - typeof record.tokenHost === 'string' && - typeof record.flow === 'string' && - typeof record.usePkce === 'boolean' && - typeof record.scopeSeparator === 'string' && - typeof record.clientIdValueName === 'string' && - typeof record.accessTokenSecretName === 'string' && - Array.isArray(record.scopes) && - Array.isArray(record.allowedHosts) && - record.scopes.every((value) => typeof value === 'string') && - record.allowedHosts.every((value) => typeof value === 'string') - ) - } - const persistConfig = (nextConfig: ConnectOauthConfig) => { try { sessionStorage.setItem(configStorageKey, JSON.stringify(nextConfig)) @@ -346,12 +322,7 @@ export function ConnectOauthRoute(handle: Handle) { if (typeof window === 'undefined') return null const raw = sessionStorage.getItem(configStorageKey) if (!raw) return null - try { - const parsed = JSON.parse(raw) - return isConnectOauthConfig(parsed) ? parsed : null - } catch { - return null - } + return parseSessionConnectOauthConfig(raw) } const createState = (key: string) => { @@ -1517,6 +1488,49 @@ function resolveConnectOauthExtraAuthorizeParams(input: { return input.storedIntegration?.authorization?.extraAuthorizeParams ?? {} } +/** + * Parses the sessionStorage config persisted before redirecting to the + * provider. Configs written before the orthogonal-PKCE change lack `usePkce`, + * and the callback leg cannot rebuild settings from the URL alone, so backfill + * the flow/host default instead of rejecting mid-flight connects. + */ +export function parseSessionConnectOauthConfig( + raw: string, +): ConnectOauthConfig | null { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') return null + const record = parsed as Record + const isValid = + typeof record.provider === 'string' && + typeof record.providerKey === 'string' && + typeof record.authorizeUrl === 'string' && + typeof record.tokenUrl === 'string' && + typeof record.authorizeHost === 'string' && + typeof record.tokenHost === 'string' && + (record.flow === 'pkce' || record.flow === 'confidential') && + typeof record.scopeSeparator === 'string' && + typeof record.clientIdValueName === 'string' && + typeof record.accessTokenSecretName === 'string' && + Array.isArray(record.scopes) && + Array.isArray(record.allowedHosts) && + record.scopes.every((value) => typeof value === 'string') && + record.allowedHosts.every((value) => typeof value === 'string') + if (!isValid) return null + const config = record as unknown as ConnectOauthConfig + if (typeof record.usePkce !== 'boolean') { + config.usePkce = defaultConnectOauthUsePkce({ + flow: config.flow, + tokenUrl: config.tokenUrl, + }) + } + return config +} + export function summarizeStoredSetupState(input: { flow: OAuthFlow clientId: string | null From 4bdd257ce7194e9db68994353862ad96b7933a13 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 21:54:12 +0000 Subject: [PATCH 4/6] refactor(oauth): drop usePkce session-config backfill in favor of strict validation The sessionStorage snapshot lives for a single authorize round trip, so a shape without usePkce can only exist for a flow in-flight across the one deploy that ships this change; recovery is restarting the connect flow. Keeping a permanent backfill for that transient window is not worth it. Validation now requires usePkce and rejects stale shapes deliberately. --- .../client/routes/connect-oauth.node.test.ts | 39 ++++++------------- .../worker/client/routes/connect-oauth.tsx | 17 +++----- 2 files changed, 18 insertions(+), 38 deletions(-) diff --git a/packages/worker/client/routes/connect-oauth.node.test.ts b/packages/worker/client/routes/connect-oauth.node.test.ts index 7a2b5720ef..8b10da2caf 100644 --- a/packages/worker/client/routes/connect-oauth.node.test.ts +++ b/packages/worker/client/routes/connect-oauth.node.test.ts @@ -483,8 +483,8 @@ test('connect OAuth derives Canva confidential + PKCE basic-form defaults and ho }) }) -test('session config parsing backfills usePkce for configs persisted before the orthogonal-PKCE change', () => { - const legacyConfig = { +test('session config parsing is strict: usePkce is required and stale shapes are rejected', () => { + const sessionConfig = { provider: 'spotify', providerKey: 'spotify', authorizeHost: 'accounts.spotify.com', @@ -494,6 +494,7 @@ test('session config parsing backfills usePkce for configs persisted before the apiBaseUrl: null, scopes: [], flow: 'pkce', + usePkce: true, tokenExchangeStyle: 'form', scopeSeparator: ' ', extraAuthorizeParams: {}, @@ -506,41 +507,25 @@ test('session config parsing backfills usePkce for configs persisted before the allowedHosts: ['accounts.spotify.com'], } - // Mid-flight configs without usePkce derive the flow default so callbacks - // keep working across the deploy boundary. expect( - parseSessionConnectOauthConfig(JSON.stringify(legacyConfig)), + parseSessionConnectOauthConfig(JSON.stringify(sessionConfig)), ).toMatchObject({ provider: 'spotify', flow: 'pkce', usePkce: true }) - expect( parseSessionConnectOauthConfig( JSON.stringify({ - ...legacyConfig, - provider: 'github', + ...sessionConfig, flow: 'confidential', + usePkce: false, }), ), ).toMatchObject({ flow: 'confidential', usePkce: false }) - // Canva host default enables PKCE even for legacy confidential configs. - expect( - parseSessionConnectOauthConfig( - JSON.stringify({ - ...legacyConfig, - provider: 'canva', - flow: 'confidential', - tokenHost: 'api.canva.com', - tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', - }), - ), - ).toMatchObject({ flow: 'confidential', usePkce: true }) - - // An explicit persisted choice is respected as-is. + // No back-compat: a snapshot without usePkce (persisted by pre-change code) + // is rejected and the user restarts the flow. + const { usePkce: _omitted, ...withoutUsePkce } = sessionConfig expect( - parseSessionConnectOauthConfig( - JSON.stringify({ ...legacyConfig, usePkce: false }), - ), - ).toMatchObject({ flow: 'pkce', usePkce: false }) + parseSessionConnectOauthConfig(JSON.stringify(withoutUsePkce)), + ).toBeNull() expect(parseSessionConnectOauthConfig('not json')).toBeNull() expect( @@ -548,7 +533,7 @@ test('session config parsing backfills usePkce for configs persisted before the ).toBeNull() expect( parseSessionConnectOauthConfig( - JSON.stringify({ ...legacyConfig, flow: 'implicit' }), + JSON.stringify({ ...sessionConfig, flow: 'implicit' }), ), ).toBeNull() }) diff --git a/packages/worker/client/routes/connect-oauth.tsx b/packages/worker/client/routes/connect-oauth.tsx index a9b263ab8d..8c8ad912d2 100644 --- a/packages/worker/client/routes/connect-oauth.tsx +++ b/packages/worker/client/routes/connect-oauth.tsx @@ -1490,9 +1490,10 @@ function resolveConnectOauthExtraAuthorizeParams(input: { /** * Parses the sessionStorage config persisted before redirecting to the - * provider. Configs written before the orthogonal-PKCE change lack `usePkce`, - * and the callback leg cannot rebuild settings from the URL alone, so backfill - * the flow/host default instead of rejecting mid-flight connects. + * provider. Validation is deliberately strict with no back-compat for older + * shapes: the snapshot lives for a single authorize round trip, so a stale + * shape can only exist for a flow in-flight across a deploy, and the recovery + * is simply restarting the connect flow from its URL. */ export function parseSessionConnectOauthConfig( raw: string, @@ -1513,6 +1514,7 @@ export function parseSessionConnectOauthConfig( typeof record.authorizeHost === 'string' && typeof record.tokenHost === 'string' && (record.flow === 'pkce' || record.flow === 'confidential') && + typeof record.usePkce === 'boolean' && typeof record.scopeSeparator === 'string' && typeof record.clientIdValueName === 'string' && typeof record.accessTokenSecretName === 'string' && @@ -1521,14 +1523,7 @@ export function parseSessionConnectOauthConfig( record.scopes.every((value) => typeof value === 'string') && record.allowedHosts.every((value) => typeof value === 'string') if (!isValid) return null - const config = record as unknown as ConnectOauthConfig - if (typeof record.usePkce !== 'boolean') { - config.usePkce = defaultConnectOauthUsePkce({ - flow: config.flow, - tokenUrl: config.tokenUrl, - }) - } - return config + return record as unknown as ConnectOauthConfig } export function summarizeStoredSetupState(input: { From 4a04d7edf8013a113bdb5e6b93d2a0e18fd2b2fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 22:25:09 +0000 Subject: [PATCH 5/6] refactor(integrations): make the canonical provider key the single integration identity Integration records were written under the raw provider string while the connect wizard probed both the raw name and the normalized key, and every other lookup (integration_get/delete, runtime helpers, OpenAPI bindings) was silently case-sensitive. Now canonicalIntegrationName (lowercase kebab via normalizeProviderKey) is applied by buildIntegrationValueName and normalizeIntegrationConfig, so every read and write path shares one key derivation; the client probe is deleted and parseIntegrationValueName only recognizes canonical keys. Kent's 17 stored integrations are all already canonical (verified via live audit), so no data migration is needed. --- docs/guides/oauth.md | 6 ++ .../client/routes/connect-oauth.node.test.ts | 14 ++--- .../worker/client/routes/connect-oauth.tsx | 47 +++++---------- .../app/handlers/account-secrets.node.test.ts | 8 +-- .../integration-save.node.test.ts | 60 +++++++++++++++++++ .../integrations/integration-save.ts | 2 +- .../integrations/integration-shared.ts | 38 ++++++++++-- 7 files changed, 124 insertions(+), 51 deletions(-) diff --git a/docs/guides/oauth.md b/docs/guides/oauth.md index e9491106f1..bb3f14ebf9 100644 --- a/docs/guides/oauth.md +++ b/docs/guides/oauth.md @@ -99,6 +99,12 @@ and the current client credentials. ## Integration naming convention +Integration identity is the canonical provider key: names are normalized to +lowercase kebab (letters, numbers, `.`, `_`, `-`) on every save and lookup, so +`GitHub`, `github`, and `Git Hub` all resolve to the same `github` record. There +is exactly one stored value per integration, keyed +`_integration:`. + Prefer integration names like `-` when multiple accounts may exist: `google` for a default account, `google-business` for a business account, or `google-youtube-brand` for a brand identity. Agents should call diff --git a/packages/worker/client/routes/connect-oauth.node.test.ts b/packages/worker/client/routes/connect-oauth.node.test.ts index 8b10da2caf..b128dd6f5e 100644 --- a/packages/worker/client/routes/connect-oauth.node.test.ts +++ b/packages/worker/client/routes/connect-oauth.node.test.ts @@ -2,7 +2,6 @@ import { expect, test } from 'vitest' import { buildIntegrationValueName, formatOAuthExchangeFailure, - getIntegrationValueCandidates, isOAuthExchangeSessionExpired, mergeConnectOauthConfig, parseSessionConnectOauthConfig, @@ -45,13 +44,12 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, requiredHosts: ['api.github.com', 'github.com'], authorization: null, }) - expect(getIntegrationValueCandidates('GitHub', 'github')).toEqual([ - buildIntegrationValueName('GitHub'), - buildIntegrationValueName('github'), - ]) - expect(getIntegrationValueCandidates('github', 'github')).toEqual([ - buildIntegrationValueName('github'), - ]) + // One canonical value key regardless of how the caller cases the provider. + expect(buildIntegrationValueName('GitHub')).toBe('_integration:github') + expect(buildIntegrationValueName('github')).toBe('_integration:github') + expect(buildIntegrationValueName('Spotify Family')).toBe( + '_integration:spotify-family', + ) const githubConfig = mergeConnectOauthConfig({ queryConfig: { diff --git a/packages/worker/client/routes/connect-oauth.tsx b/packages/worker/client/routes/connect-oauth.tsx index 8c8ad912d2..b4b216078f 100644 --- a/packages/worker/client/routes/connect-oauth.tsx +++ b/packages/worker/client/routes/connect-oauth.tsx @@ -435,24 +435,16 @@ export function ConnectOauthRoute(handle: Handle) { const readExistingIntegrationConfig = async ( queryConfig: ConnectOauthQueryConfig, ) => { - for (const valueName of getIntegrationValueCandidates( - queryConfig.provider, - queryConfig.providerKey, - )) { - const raw = await readValue(valueName) - if (!raw) continue - const parsed = parseStoredIntegrationConfig(raw, queryConfig.provider) - if (parsed) { - return { - valueName, - integration: parsed, - } - } - } - return { - valueName: null, - integration: null, - } + // Integration identity is the canonical provider key, so a single + // deterministic lookup replaces the historical raw-name probe. + const valueName = buildIntegrationValueName(queryConfig.providerKey) + const raw = await readValue(valueName) + const parsed = raw + ? parseStoredIntegrationConfig(raw, queryConfig.provider) + : null + return parsed + ? { valueName, integration: parsed } + : { valueName: null, integration: null } } const initializeSetupState = async (nextConfig: ConnectOauthConfig) => { @@ -1254,21 +1246,12 @@ function normalizeHosts(hosts: Array) { ).sort() } +/** + * Integration identity is the canonical provider key; mirrors + * buildIntegrationValueName in integration-shared.ts. + */ export function buildIntegrationValueName(provider: string) { - return `_integration:${provider}` -} - -export function getIntegrationValueCandidates( - provider: string, - providerKey: string, -) { - return Array.from( - new Set( - [provider.trim(), providerKey.trim()] - .filter((value) => value.length > 0) - .map((value) => buildIntegrationValueName(value)), - ), - ) + return `_integration:${normalizeProviderKey(provider)}` } export function parseStoredIntegrationConfig( diff --git a/packages/worker/src/app/handlers/account-secrets.node.test.ts b/packages/worker/src/app/handlers/account-secrets.node.test.ts index 4304d2f7c3..d2621c9dd8 100644 --- a/packages/worker/src/app/handlers/account-secrets.node.test.ts +++ b/packages/worker/src/app/handlers/account-secrets.node.test.ts @@ -179,15 +179,15 @@ test('connect oauth saves tokens, integration metadata, and host approval links' 'https://example.com/account/secrets/user/githubRefreshToken?allowed-host=github.com', }, ], - integrationName: 'GitHub', + integrationName: 'github', }) expect(mockModule.buildSecretHostApprovalUrl).toHaveBeenCalledTimes(4) expect(mockModule.setSecretAllowedHosts).not.toHaveBeenCalled() expect(mockModule.saveValue).toHaveBeenCalledWith( expect.objectContaining({ - name: '_integration:GitHub', + name: '_integration:github', value: JSON.stringify({ - name: 'GitHub', + name: 'github', tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com', flow: 'pkce', @@ -316,7 +316,7 @@ test('connect oauth saves tokens, integration metadata, and host approval links' accessTokenSaved: true, refreshTokenSaved: true, hostApprovalLinks: [], - integrationName: 'Tesla', + integrationName: 'tesla', }) expect(teslaPayload.allowedHosts).toEqual( expect.arrayContaining([ diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts index 67ba5d3811..1f71f79895 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts @@ -2,9 +2,11 @@ import { expect, test } from 'vitest' import { createMcpCallerContext } from '#mcp/context.ts' import { integrationSaveCapability } from './integration-save.ts' import { + buildIntegrationValueName, integrationConfigSchema, mergeIntegrationConfig, parseIntegrationConfig, + parseIntegrationValueName, } from './integration-shared.ts' function createValueTestDb() { @@ -348,3 +350,61 @@ test('integration_save persists usePkce only when it differs from the flow defau JSON.parse(defaultDb.entries.get('_integration:spotify') ?? '{}'), ).not.toHaveProperty('usePkce') }) + +test('integration identity is the canonical provider key across save, lookup, and value-name parsing', async () => { + // Saving with display casing stores and returns the canonical name. + const casedDb = createValueTestDb() + const saved = await integrationSaveCapability.handler( + { + name: 'GitHub', + tokenUrl: 'https://github.com/login/oauth/access_token', + flow: 'confidential', + clientIdValueName: 'github-client-id', + clientSecretSecretName: 'githubClientSecret', + accessTokenSecretName: 'githubAccessToken', + requiredHosts: ['api.github.com'], + }, + { + env: { APP_DB: casedDb.db } as unknown as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-123' }, + }), + }, + ) + expect(saved.integration.name).toBe('github') + expect(casedDb.entries.has('_integration:github')).toBe(true) + expect(casedDb.entries.has('_integration:GitHub')).toBe(false) + + // Every value-key derivation goes through the same canonicalization. + expect(buildIntegrationValueName('GitHub')).toBe('_integration:github') + expect(buildIntegrationValueName('Spotify Family')).toBe( + '_integration:spotify-family', + ) + + // Only canonical stored keys parse as integrations. + expect(parseIntegrationValueName('_integration:github')).toBe('github') + expect(parseIntegrationValueName('_integration:GitHub')).toBeNull() + expect(parseIntegrationValueName('_integration:')).toBeNull() + expect(parseIntegrationValueName('value:github')).toBeNull() + + // Names without any letters or numbers are rejected outright. + await expect( + integrationSaveCapability.handler( + { + name: '!!!', + tokenUrl: 'https://example.com/token', + flow: 'pkce', + clientIdValueName: 'x-client-id', + accessTokenSecretName: 'xAccessToken', + }, + { + env: { APP_DB: createValueTestDb().db } as unknown as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-123' }, + }), + }, + ), + ).rejects.toThrow(/letters or numbers/i) +}) diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-save.ts b/packages/worker/src/mcp/capabilities/integrations/integration-save.ts index 7af5f2ae0b..c81c98d6ce 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-save.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-save.ts @@ -25,7 +25,7 @@ export const integrationSaveCapability = defineDomainCapability( { name: 'integration_save', description: - 'Create or update an OAuth integration configuration for the signed-in user. Stored as a user-scoped value with a _integration: prefix.', + 'Create or update an OAuth integration configuration for the signed-in user. Names are normalized to a canonical lowercase-kebab provider key and stored as a user-scoped value with a _integration: prefix.', keywords: [ 'integration', 'oauth', diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts b/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts index 8cef370adc..8772fe08fc 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts @@ -1,8 +1,26 @@ +import { normalizeProviderKey } from '@kody-internal/shared/url-hosts.ts' import { z } from 'zod' import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts' export const integrationFlowValues = ['pkce', 'confidential'] as const +/** + * Integration identity is the canonical provider key (lowercase kebab via + * normalizeProviderKey). Every read and write path derives names and value + * keys through this one function, so lookups never depend on how a caller + * cased or spaced the provider name. + */ +export function canonicalIntegrationName(name: string) { + return normalizeProviderKey(name) +} + +const integrationNameSchema = z + .string() + .min(1) + .refine((name) => canonicalIntegrationName(name).length > 0, { + message: 'Integration name must contain letters or numbers.', + }) + const defaultIntegrationScopeSeparator = ' ' export const tokenExchangeStyleValues = [ @@ -30,7 +48,7 @@ export const integrationAuthorizationSchema = z .strict() export const integrationConfigSchema = z.object({ - name: z.string().min(1), + name: integrationNameSchema, tokenUrl: z.string().url(), apiBaseUrl: z.string().url().optional().nullable(), flow: z.enum(integrationFlowValues), @@ -54,7 +72,7 @@ type IntegrationAuthorization = z.infer export const integrationSaveSchema = z .object({ - name: z.string().min(1), + name: integrationNameSchema, tokenUrl: z.string().url().optional(), apiBaseUrl: z.string().url().nullable().optional(), flow: z.enum(integrationFlowValues).optional(), @@ -86,7 +104,7 @@ export function normalizeIntegrationConfig( ? value.usePkce : null return { - name: value.name.trim(), + name: canonicalIntegrationName(value.name), tokenUrl: value.tokenUrl.trim(), apiBaseUrl: value.apiBaseUrl?.trim() || null, flow: value.flow, @@ -142,13 +160,21 @@ export function mergeIntegrationConfig( const integrationValuePrefix = '_integration:' export function buildIntegrationValueName(name: string) { - return `${integrationValuePrefix}${name}` + return `${integrationValuePrefix}${canonicalIntegrationName(name)}` } export function parseIntegrationValueName(name: string) { if (!name.startsWith(integrationValuePrefix)) return null - const integrationName = name.slice(integrationValuePrefix.length).trim() - return integrationName.length > 0 ? integrationName : null + const integrationName = name.slice(integrationValuePrefix.length) + // Only canonical keys count as integrations; a non-canonical suffix cannot + // have been written by any current write path. + if ( + integrationName.length === 0 || + integrationName !== canonicalIntegrationName(integrationName) + ) { + return null + } + return integrationName } export function parseIntegrationConfig( From 6008f78ef1da0c45ecd4f33fe10c4a1f674c48f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 22:45:01 +0000 Subject: [PATCH 6/6] fix(integrations): require a letter or number in canonical integration names Addresses CodeRabbit review on #719: names made only of dots, underscores, or hyphens survive canonicalization non-empty, so the schema now requires at least one alphanumeric character in the canonical form. --- .../integration-save.node.test.ts | 21 ++++++++++++++++++- .../integrations/integration-shared.ts | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts index 1f71f79895..d74e46da99 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts @@ -388,7 +388,26 @@ test('integration identity is the canonical provider key across save, lookup, an expect(parseIntegrationValueName('_integration:')).toBeNull() expect(parseIntegrationValueName('value:github')).toBeNull() - // Names without any letters or numbers are rejected outright. + // Names without any letters or numbers are rejected outright — including + // ones made only of characters the canonical form preserves (. _ -). + await expect( + integrationSaveCapability.handler( + { + name: '._-', + tokenUrl: 'https://example.com/token', + flow: 'pkce', + clientIdValueName: 'x-client-id', + accessTokenSecretName: 'xAccessToken', + }, + { + env: { APP_DB: createValueTestDb().db } as unknown as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-123' }, + }), + }, + ), + ).rejects.toThrow(/letters or numbers/i) await expect( integrationSaveCapability.handler( { diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts b/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts index 8772fe08fc..a5eb3fc666 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts @@ -17,7 +17,7 @@ export function canonicalIntegrationName(name: string) { const integrationNameSchema = z .string() .min(1) - .refine((name) => canonicalIntegrationName(name).length > 0, { + .refine((name) => /[a-z0-9]/.test(canonicalIntegrationName(name)), { message: 'Integration name must contain letters or numbers.', })