From ddcf489f5be9ad7ab696d3759c845ea51b9b451a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 16:40:53 -0800 Subject: [PATCH] fix(credentials): match JSON 403s case-insensitively and refuse a malformed ZoomInfo access token - isVendorJsonForbidden lowercases Content-Type before matching, since media types are case-insensitive; a vendor 403 sent as Application/JSON no longer rejects a live key that only lacks a scope. - requestZoomInfoToken requires a non-blank string access_token, so the saved-credential minter and the legacy inline path never cache or send a malformed token. --- .../token-service-accounts/errors.ts | 2 +- .../validators/hubspot.test.ts | 8 ++++++++ apps/sim/lib/internal/zoominfo/client.test.ts | 20 +++++++++++++++++++ apps/sim/lib/internal/zoominfo/client.ts | 9 +++------ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index bf2209dfa75..0dff3b071b1 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -222,7 +222,7 @@ export interface ProviderResponse { export function isVendorJsonForbidden(response: ProviderResponse): boolean { return ( response.status === 403 && - (response.headers.get('content-type') ?? '').includes('application/json') + (response.headers.get('content-type') ?? '').toLowerCase().includes('application/json') ) } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts index 85d9242d575..03dd47ccd05 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts @@ -156,6 +156,14 @@ describe('validateHubspotServiceAccount', () => { 'lacks the contacts scope', () => jsonResponse({ status: 'error', category: 'MISSING_SCOPES' }, 403), ], + [ + 'lacks the contacts scope, answered with a mixed-case JSON media type', + () => + new Response(JSON.stringify({ status: 'error', category: 'MISSING_SCOPES' }), { + status: 403, + headers: { 'Content-Type': 'Application/JSON; charset=UTF-8' }, + }), + ], ])( 'accepts a service key that account-info refuses but that %s', async (_case, contactsAnswer) => { diff --git a/apps/sim/lib/internal/zoominfo/client.test.ts b/apps/sim/lib/internal/zoominfo/client.test.ts index deae32584f4..7782917e57b 100644 --- a/apps/sim/lib/internal/zoominfo/client.test.ts +++ b/apps/sim/lib/internal/zoominfo/client.test.ts @@ -37,6 +37,26 @@ describe('ZoomInfo requests', () => { expect(mockSecureFetchWithValidation).not.toHaveBeenCalled() }) + it.each([ + ['a blank', ' ', 'client-blank-token'], + ['a non-string', 123, 'client-numeric-token'], + ])( + 'refuses %s access token in the token response before calling the API', + async (_case, token, clientId) => { + mockSecureFetchWithValidation.mockResolvedValue( + Response.json({ access_token: token, expires_in: 3600 }) + ) + + await expect( + requestZoomInfo({ ...REQUEST, clientId, clientSecret: 'secret' }, 'request-4') + ).rejects.toMatchObject({ + message: 'ZoomInfo token response missing access_token', + status: 500, + }) + expect(mockSecureFetchWithValidation).toHaveBeenCalledTimes(1) + } + ) + it('keeps reporting an inline token refusal exactly as before', async () => { mockSecureFetchWithValidation.mockResolvedValue( Response.json({ error: 'invalid_client' }, { status: 401 }) diff --git a/apps/sim/lib/internal/zoominfo/client.ts b/apps/sim/lib/internal/zoominfo/client.ts index f9c75e17bb7..223100b23c0 100644 --- a/apps/sim/lib/internal/zoominfo/client.ts +++ b/apps/sim/lib/internal/zoominfo/client.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import { MAX_JSON_API_RESPONSE_BYTES, secureFetchWithValidation, @@ -130,12 +131,8 @@ export async function requestZoomInfoToken( logger.warn('ZoomInfo token fetch failed', { requestId, status: response.status, error: text }) throw new ZoomInfoTokenError(response.status) } - const data = (await response.json()) as { - access_token?: string - expires_in?: number - scope?: string - } - if (!data.access_token) { + const data: unknown = await response.json() + if (!isRecordLike(data) || typeof data.access_token !== 'string' || !data.access_token.trim()) { throw new ZoomInfoOperationError('ZoomInfo token response missing access_token', 500) } return {