From 923128658d02057ae9a8eeeaefaf52890f464203 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 22 Jun 2026 15:07:23 -0400 Subject: [PATCH] fix(integrations): persist Zoho multi-DC api_domain so OAuth tokens hit the right region Zoho (and other multi-data-center OAuth providers) return a data-center- specific API host as `api_domain` in their token and refresh responses, and the access token only works against that host. We dropped this field, so checks fell back to the default US host (www.zohoapis.com) and Zoho rejected the token with INVALID_TOKEN for any non-US (EU/IN/AU/JP/CN) account. Capture and persist `api_domain` from the token-exchange and refresh responses (preserving a previously captured value when a refresh response omits it). The dynamic-check runtime already reads `ctx.credentials.api_domain` with a www.zohoapis.com fallback, so no check change is needed. Strictly additive and guarded: `api_domain` is a Zoho-only field, so every other OAuth provider (GitHub, Google, etc.) stores nothing new and behaves byte-for-byte as before. Stored in plaintext alongside scope/token_type so the runtime can route requests to the correct regional host. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AU5798EG3PQdRuYSPJgXmy --- .../controllers/oauth.controller.ts | 3 + .../credential-vault.api-domain.spec.ts | 157 ++++++++++++++++++ .../services/credential-vault.service.ts | 38 +++++ 3 files changed, 198 insertions(+) create mode 100644 apps/api/src/integration-platform/services/credential-vault.api-domain.spec.ts diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.ts b/apps/api/src/integration-platform/controllers/oauth.controller.ts index 087e3329d..29fec3459 100644 --- a/apps/api/src/integration-platform/controllers/oauth.controller.ts +++ b/apps/api/src/integration-platform/controllers/oauth.controller.ts @@ -517,6 +517,9 @@ export class OAuthController { token_type?: string; expires_in?: number; scope?: string; + // Multi-DC providers (e.g. Zoho) return the data-center-specific API host + // here; it is persisted so the check runtime targets the correct region. + api_domain?: string; }> { const callbackUrl = `${process.env.BASE_URL || 'http://localhost:3333'}/v1/integrations/oauth/callback`; diff --git a/apps/api/src/integration-platform/services/credential-vault.api-domain.spec.ts b/apps/api/src/integration-platform/services/credential-vault.api-domain.spec.ts new file mode 100644 index 000000000..c965933ff --- /dev/null +++ b/apps/api/src/integration-platform/services/credential-vault.api-domain.spec.ts @@ -0,0 +1,157 @@ +jest.mock('@db', () => ({ + db: {}, +})); + +import { CredentialVaultService } from './credential-vault.service'; +import { CredentialRepository } from '../repositories/credential.repository'; +import { ConnectionRepository } from '../repositories/connection.repository'; +import type { IntegrationConnection, IntegrationCredentialVersion } from '@db'; + +const encrypted = (value: string) => ({ + encrypted: value, + iv: 'iv', + tag: 'tag', + salt: 'salt', +}); + +const makeConnection = (): IntegrationConnection => ({ + id: 'conn_1', + providerId: 'prv_1', + organizationId: 'org_1', + status: 'active', + authStrategy: 'oauth2', + activeCredentialVersionId: 'cred_1', + lastSyncAt: null, + nextSyncAt: null, + syncCadence: null, + metadata: {}, + variables: {}, + errorMessage: null, + refreshLeaseUntil: null, + refreshLeaseToken: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}); + +const makeCredentialVersion = (): IntegrationCredentialVersion => ({ + id: 'cred_1', + connectionId: 'conn_1', + encryptedPayload: {}, + version: 1, + expiresAt: null, + rotatedAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), +}); + +const buildService = () => { + const credentialRepository = new CredentialRepository(); + const connectionRepository = new ConnectionRepository(); + const createSpy = jest + .spyOn(credentialRepository, 'create') + .mockResolvedValue(makeCredentialVersion()); + jest.spyOn(credentialRepository, 'deleteOldVersions').mockResolvedValue(0); + jest.spyOn(connectionRepository, 'update').mockResolvedValue(makeConnection()); + const service = new CredentialVaultService( + credentialRepository, + connectionRepository, + ); + jest + .spyOn(service, 'encrypt') + .mockImplementation(async (value) => encrypted(value)); + return { service, createSpy }; +}; + +describe('CredentialVaultService multi-DC api_domain handling', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('captures api_domain from a token response and stores it in plaintext', async () => { + const { service, createSpy } = buildService(); + // When the response carries api_domain we must not need the stored value. + const getCredsSpy = jest.spyOn(service, 'getDecryptedCredentials'); + + await service.storeOAuthTokens('conn_1', { + access_token: 'zoho-access', + refresh_token: 'zoho-refresh', + expires_in: 3600, + token_type: 'Bearer', + api_domain: 'https://www.zohoapis.eu', + }); + + const createInput = createSpy.mock.calls[0]?.[0]; + if (!createInput) throw new Error('Expected credential version to be created'); + + // Stored as a plaintext string (not encrypted) so the check runtime can + // read it as ctx.credentials.api_domain and route to the right region. + expect(createInput.encryptedPayload.api_domain).toBe( + 'https://www.zohoapis.eu', + ); + // Secrets are still encrypted. + expect(createInput.encryptedPayload.access_token).toEqual( + encrypted('zoho-access'), + ); + // No need to read the prior credential when the response already has it. + expect(getCredsSpy).not.toHaveBeenCalled(); + }); + + it('preserves an existing api_domain when a refresh response omits it', async () => { + const { service, createSpy } = buildService(); + jest + .spyOn(service, 'getDecryptedCredentials') + .mockResolvedValue({ api_domain: 'https://www.zohoapis.in' }); + + // A refresh response that does not echo api_domain. + await service.storeOAuthTokens('conn_1', { + access_token: 'refreshed-access', + refresh_token: 'zoho-refresh', + expires_in: 3600, + token_type: 'Bearer', + }); + + const createInput = createSpy.mock.calls[0]?.[0]; + if (!createInput) throw new Error('Expected credential version to be created'); + + expect(createInput.encryptedPayload.api_domain).toBe( + 'https://www.zohoapis.in', + ); + }); + + it('stores no api_domain for providers that never send one (backward compatible)', async () => { + const { service, createSpy } = buildService(); + // Typical single-DC provider: nothing in the response, nothing stored. + jest.spyOn(service, 'getDecryptedCredentials').mockResolvedValue({}); + + await service.storeOAuthTokens('conn_1', { + access_token: 'github-access', + refresh_token: 'github-refresh', + expires_in: 3600, + token_type: 'Bearer', + }); + + const createInput = createSpy.mock.calls[0]?.[0]; + if (!createInput) throw new Error('Expected credential version to be created'); + + expect(createInput.encryptedPayload).not.toHaveProperty('api_domain'); + }); + + it('does not fail token storage if the prior api_domain cannot be read', async () => { + const { service, createSpy } = buildService(); + jest + .spyOn(service, 'getDecryptedCredentials') + .mockRejectedValue(new Error('decrypt failed')); + + await expect( + service.storeOAuthTokens('conn_1', { + access_token: 'access', + refresh_token: 'refresh', + expires_in: 3600, + token_type: 'Bearer', + }), + ).resolves.toBeUndefined(); + + const createInput = createSpy.mock.calls[0]?.[0]; + if (!createInput) throw new Error('Expected credential version to be created'); + expect(createInput.encryptedPayload).not.toHaveProperty('api_domain'); + }); +}); diff --git a/apps/api/src/integration-platform/services/credential-vault.service.ts b/apps/api/src/integration-platform/services/credential-vault.service.ts index 490f3e5d5..570c6b537 100644 --- a/apps/api/src/integration-platform/services/credential-vault.service.ts +++ b/apps/api/src/integration-platform/services/credential-vault.service.ts @@ -55,6 +55,14 @@ export interface OAuthTokens { token_type?: string; expires_in?: number; scope?: string; + /** + * Data-center-specific API host returned by multi-DC OAuth providers. Zoho, + * for example, returns `api_domain: "https://www.zohoapis.eu"` and the access + * token is only valid against that host. When present it is persisted so the + * check runtime targets the correct regional host. Single-DC providers never + * send this field and are unaffected. + */ + api_domain?: string; } export interface TokenRefreshConfig { @@ -179,6 +187,32 @@ export class CredentialVaultService { encryptedPayload.scope = tokens.scope; } + // Multi-data-center OAuth providers (e.g. Zoho) return a data-center- + // specific API host as `api_domain` in their token/refresh responses, and + // the access token only works against that host — calling a hardcoded + // default host makes the provider reject the token (Zoho responds + // INVALID_TOKEN). Persist it so the check runtime can route requests to the + // correct region. Providers that don't send `api_domain` (the vast + // majority) store nothing here and behave exactly as before. + let apiDomain = + typeof tokens.api_domain === 'string' ? tokens.api_domain : undefined; + if (!apiDomain) { + // A later refresh response may omit api_domain even when the original + // grant included it; preserve the previously captured value instead of + // dropping back to the runtime's default host. + try { + const existing = await this.getDecryptedCredentials(connectionId); + if (typeof existing?.api_domain === 'string') { + apiDomain = existing.api_domain; + } + } catch { + // Best-effort preservation; if the prior value can't be read, skip it. + } + } + if (apiDomain) { + encryptedPayload.api_domain = apiDomain; + } + // Calculate expiration let expiresAt: Date | undefined; if (tokens.expires_in) { @@ -446,6 +480,10 @@ export class CredentialVaultService { token_type: tokens.token_type, expires_in: tokens.expires_in, scope: tokens.scope, + // Carry through the data-center host on refresh. storeOAuthTokens + // preserves the previously captured value when a refresh response omits + // it, so a working api_domain is never downgraded. + api_domain: tokens.api_domain, }; await this.storeOAuthTokens(connectionId, tokensToStore);