Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading