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);