Skip to content

Commit e1b8784

Browse files
committed
fix(sailpoint): bind the token cache to the client secret
Include a hash of client_secret in the token cache key so a caller with a matching tenant/clientId but the wrong secret cannot reuse another principal's cached bearer token - a mismatched secret now misses the cache and fails the token exchange. Regression test added.
1 parent 7b93cde commit e1b8784

2 files changed

Lines changed: 30 additions & 1 deletion

File tree

‎apps/sim/app/api/tools/sailpoint/client.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from 'node:crypto'
12
import { sleep } from '@sim/utils/helpers'
23
import { isRecordLike } from '@sim/utils/object'
34
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
@@ -126,7 +127,11 @@ const tokenCache = new Map<string, CachedToken>()
126127

127128
function cacheKey(creds: SailPointServerCredentials): string {
128129
const { host } = resolveSailPointHosts(creds.tenant, creds.apiVersion)
129-
return `${host}:${creds.clientId}:${creds.apiVersion}`
130+
// Bind the cache entry to the exact client_secret (hashed) so a caller with a matching
131+
// tenant/clientId but the wrong secret can never reuse another principal's cached token - a
132+
// mismatched secret produces a different key, misses the cache, and fails the token exchange.
133+
const secretHash = createHash('sha256').update(creds.clientSecret).digest('hex').slice(0, 16)
134+
return `${host}:${creds.clientId}:${creds.apiVersion}:${secretHash}`
130135
}
131136

132137
/** Drops any cached token for these credentials so the next call re-exchanges. */

‎apps/sim/app/api/tools/sailpoint/query/route.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,30 @@ describe('SailPoint query route', () => {
197197
expect(fetchMock.mock.calls[2]?.[0]).toContain('/v2025/accounts')
198198
})
199199

200+
it('does not share a cached token across different client secrets', async () => {
201+
fetchMock
202+
.mockResolvedValueOnce(tokenResponse())
203+
.mockResolvedValueOnce(jsonResponse([{ id: 'a1' }]))
204+
.mockResolvedValueOnce(tokenResponse())
205+
.mockResolvedValueOnce(jsonResponse([{ id: 'a2' }]))
206+
207+
const makeRequest = (secret: string) =>
208+
createMockRequest('POST', {
209+
clientId: 'shared-client',
210+
clientSecret: secret,
211+
tenant: 'acme-secret',
212+
operation: 'sailpoint_list_accounts',
213+
})
214+
215+
await POST(makeRequest('secret-A'))
216+
await POST(makeRequest('secret-B'))
217+
218+
// A different secret must not reuse the first principal's token: 2 exchanges + 2 API calls.
219+
expect(fetchMock).toHaveBeenCalledTimes(4)
220+
expect(fetchMock.mock.calls[0]?.[0]).toContain('/oauth/token')
221+
expect(fetchMock.mock.calls[2]?.[0]).toContain('/oauth/token')
222+
})
223+
200224
it('backs off and retries on a 429 response', async () => {
201225
fetchMock
202226
.mockResolvedValueOnce(tokenResponse())

0 commit comments

Comments
 (0)