Skip to content

Commit d655227

Browse files
authored
Merge pull request #3231 from trycompai/main
[comp] Production Deploy
2 parents 06860e7 + be592f7 commit d655227

3 files changed

Lines changed: 198 additions & 0 deletions

File tree

‎apps/api/src/integration-platform/controllers/oauth.controller.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,9 @@ export class OAuthController {
517517
token_type?: string;
518518
expires_in?: number;
519519
scope?: string;
520+
// Multi-DC providers (e.g. Zoho) return the data-center-specific API host
521+
// here; it is persisted so the check runtime targets the correct region.
522+
api_domain?: string;
520523
}> {
521524
const callbackUrl = `${process.env.BASE_URL || 'http://localhost:3333'}/v1/integrations/oauth/callback`;
522525

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
jest.mock('@db', () => ({
2+
db: {},
3+
}));
4+
5+
import { CredentialVaultService } from './credential-vault.service';
6+
import { CredentialRepository } from '../repositories/credential.repository';
7+
import { ConnectionRepository } from '../repositories/connection.repository';
8+
import type { IntegrationConnection, IntegrationCredentialVersion } from '@db';
9+
10+
const encrypted = (value: string) => ({
11+
encrypted: value,
12+
iv: 'iv',
13+
tag: 'tag',
14+
salt: 'salt',
15+
});
16+
17+
const makeConnection = (): IntegrationConnection => ({
18+
id: 'conn_1',
19+
providerId: 'prv_1',
20+
organizationId: 'org_1',
21+
status: 'active',
22+
authStrategy: 'oauth2',
23+
activeCredentialVersionId: 'cred_1',
24+
lastSyncAt: null,
25+
nextSyncAt: null,
26+
syncCadence: null,
27+
metadata: {},
28+
variables: {},
29+
errorMessage: null,
30+
refreshLeaseUntil: null,
31+
refreshLeaseToken: null,
32+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
33+
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
34+
});
35+
36+
const makeCredentialVersion = (): IntegrationCredentialVersion => ({
37+
id: 'cred_1',
38+
connectionId: 'conn_1',
39+
encryptedPayload: {},
40+
version: 1,
41+
expiresAt: null,
42+
rotatedAt: null,
43+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
44+
});
45+
46+
const buildService = () => {
47+
const credentialRepository = new CredentialRepository();
48+
const connectionRepository = new ConnectionRepository();
49+
const createSpy = jest
50+
.spyOn(credentialRepository, 'create')
51+
.mockResolvedValue(makeCredentialVersion());
52+
jest.spyOn(credentialRepository, 'deleteOldVersions').mockResolvedValue(0);
53+
jest.spyOn(connectionRepository, 'update').mockResolvedValue(makeConnection());
54+
const service = new CredentialVaultService(
55+
credentialRepository,
56+
connectionRepository,
57+
);
58+
jest
59+
.spyOn(service, 'encrypt')
60+
.mockImplementation(async (value) => encrypted(value));
61+
return { service, createSpy };
62+
};
63+
64+
describe('CredentialVaultService multi-DC api_domain handling', () => {
65+
afterEach(() => {
66+
jest.restoreAllMocks();
67+
});
68+
69+
it('captures api_domain from a token response and stores it in plaintext', async () => {
70+
const { service, createSpy } = buildService();
71+
// When the response carries api_domain we must not need the stored value.
72+
const getCredsSpy = jest.spyOn(service, 'getDecryptedCredentials');
73+
74+
await service.storeOAuthTokens('conn_1', {
75+
access_token: 'zoho-access',
76+
refresh_token: 'zoho-refresh',
77+
expires_in: 3600,
78+
token_type: 'Bearer',
79+
api_domain: 'https://www.zohoapis.eu',
80+
});
81+
82+
const createInput = createSpy.mock.calls[0]?.[0];
83+
if (!createInput) throw new Error('Expected credential version to be created');
84+
85+
// Stored as a plaintext string (not encrypted) so the check runtime can
86+
// read it as ctx.credentials.api_domain and route to the right region.
87+
expect(createInput.encryptedPayload.api_domain).toBe(
88+
'https://www.zohoapis.eu',
89+
);
90+
// Secrets are still encrypted.
91+
expect(createInput.encryptedPayload.access_token).toEqual(
92+
encrypted('zoho-access'),
93+
);
94+
// No need to read the prior credential when the response already has it.
95+
expect(getCredsSpy).not.toHaveBeenCalled();
96+
});
97+
98+
it('preserves an existing api_domain when a refresh response omits it', async () => {
99+
const { service, createSpy } = buildService();
100+
jest
101+
.spyOn(service, 'getDecryptedCredentials')
102+
.mockResolvedValue({ api_domain: 'https://www.zohoapis.in' });
103+
104+
// A refresh response that does not echo api_domain.
105+
await service.storeOAuthTokens('conn_1', {
106+
access_token: 'refreshed-access',
107+
refresh_token: 'zoho-refresh',
108+
expires_in: 3600,
109+
token_type: 'Bearer',
110+
});
111+
112+
const createInput = createSpy.mock.calls[0]?.[0];
113+
if (!createInput) throw new Error('Expected credential version to be created');
114+
115+
expect(createInput.encryptedPayload.api_domain).toBe(
116+
'https://www.zohoapis.in',
117+
);
118+
});
119+
120+
it('stores no api_domain for providers that never send one (backward compatible)', async () => {
121+
const { service, createSpy } = buildService();
122+
// Typical single-DC provider: nothing in the response, nothing stored.
123+
jest.spyOn(service, 'getDecryptedCredentials').mockResolvedValue({});
124+
125+
await service.storeOAuthTokens('conn_1', {
126+
access_token: 'github-access',
127+
refresh_token: 'github-refresh',
128+
expires_in: 3600,
129+
token_type: 'Bearer',
130+
});
131+
132+
const createInput = createSpy.mock.calls[0]?.[0];
133+
if (!createInput) throw new Error('Expected credential version to be created');
134+
135+
expect(createInput.encryptedPayload).not.toHaveProperty('api_domain');
136+
});
137+
138+
it('does not fail token storage if the prior api_domain cannot be read', async () => {
139+
const { service, createSpy } = buildService();
140+
jest
141+
.spyOn(service, 'getDecryptedCredentials')
142+
.mockRejectedValue(new Error('decrypt failed'));
143+
144+
await expect(
145+
service.storeOAuthTokens('conn_1', {
146+
access_token: 'access',
147+
refresh_token: 'refresh',
148+
expires_in: 3600,
149+
token_type: 'Bearer',
150+
}),
151+
).resolves.toBeUndefined();
152+
153+
const createInput = createSpy.mock.calls[0]?.[0];
154+
if (!createInput) throw new Error('Expected credential version to be created');
155+
expect(createInput.encryptedPayload).not.toHaveProperty('api_domain');
156+
});
157+
});

‎apps/api/src/integration-platform/services/credential-vault.service.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ export interface OAuthTokens {
5555
token_type?: string;
5656
expires_in?: number;
5757
scope?: string;
58+
/**
59+
* Data-center-specific API host returned by multi-DC OAuth providers. Zoho,
60+
* for example, returns `api_domain: "https://www.zohoapis.eu"` and the access
61+
* token is only valid against that host. When present it is persisted so the
62+
* check runtime targets the correct regional host. Single-DC providers never
63+
* send this field and are unaffected.
64+
*/
65+
api_domain?: string;
5866
}
5967

6068
export interface TokenRefreshConfig {
@@ -179,6 +187,32 @@ export class CredentialVaultService {
179187
encryptedPayload.scope = tokens.scope;
180188
}
181189

190+
// Multi-data-center OAuth providers (e.g. Zoho) return a data-center-
191+
// specific API host as `api_domain` in their token/refresh responses, and
192+
// the access token only works against that host — calling a hardcoded
193+
// default host makes the provider reject the token (Zoho responds
194+
// INVALID_TOKEN). Persist it so the check runtime can route requests to the
195+
// correct region. Providers that don't send `api_domain` (the vast
196+
// majority) store nothing here and behave exactly as before.
197+
let apiDomain =
198+
typeof tokens.api_domain === 'string' ? tokens.api_domain : undefined;
199+
if (!apiDomain) {
200+
// A later refresh response may omit api_domain even when the original
201+
// grant included it; preserve the previously captured value instead of
202+
// dropping back to the runtime's default host.
203+
try {
204+
const existing = await this.getDecryptedCredentials(connectionId);
205+
if (typeof existing?.api_domain === 'string') {
206+
apiDomain = existing.api_domain;
207+
}
208+
} catch {
209+
// Best-effort preservation; if the prior value can't be read, skip it.
210+
}
211+
}
212+
if (apiDomain) {
213+
encryptedPayload.api_domain = apiDomain;
214+
}
215+
182216
// Calculate expiration
183217
let expiresAt: Date | undefined;
184218
if (tokens.expires_in) {
@@ -446,6 +480,10 @@ export class CredentialVaultService {
446480
token_type: tokens.token_type,
447481
expires_in: tokens.expires_in,
448482
scope: tokens.scope,
483+
// Carry through the data-center host on refresh. storeOAuthTokens
484+
// preserves the previously captured value when a refresh response omits
485+
// it, so a working api_domain is never downgraded.
486+
api_domain: tokens.api_domain,
449487
};
450488

451489
await this.storeOAuthTokens(connectionId, tokensToStore);

0 commit comments

Comments
 (0)