Skip to content

Commit d26b61f

Browse files
authored
fix(credentials): age-gate 403 token eviction and tidy the client-credential paths (#9918)
* fix(credentials): age-gate 403 token eviction and stop memoizing transient mint failures An internal operation's 403 now drops this instance's cached client-credential token only once it is older than a minute, matching the external-request path, so a 403 from a file-access refusal or per-record permission no longer re-mints on every call. The coordinated token's failure memo keeps only refusals of the credentials themselves; provider_unavailable propagates with no memo row. * chore(credentials): drop redundant redirect options and correct SailPoint and HubSpot docs fetchProvider already refuses redirects by default. Zendesk's hidden token param uses savedCredentialTokenParam, the SailPoint minter takes its token URL from resolveSailPointHosts, and the SailPoint host and HubSpot fallback TSDoc now match the code. * fix(credentials): keep the coordinated token's 30-second backoff for every failed exchange Remembering only credential refusals in the shared row left the per-instance mint cache and the Vanta chain still backing off on outages, so the policy differed by layer. Keep the long-standing 30-second backoff everywhere and document it.
1 parent 05a5990 commit d26b61f

15 files changed

Lines changed: 90 additions & 41 deletions

File tree

‎apps/sim/lib/credentials/client-credential-accounts/descriptors.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,8 @@ export function resolveZohoDeskDataCenter(rawDataCenter?: string): {
413413

414414
/**
415415
* A SailPoint Identity Security Cloud API host: one tenant label under `api.identitynow.com`, or
416-
* under `api.identitynowgov.com` for government tenants (the same hosts the legacy block accepts).
416+
* under `api.identitynowgov.com` for government tenants. Stricter than the legacy block's inline
417+
* tenant, which also admits a dotted prefix under either domain.
417418
*/
418419
const SAILPOINT_API_HOST_REGEX =
419420
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.api\.identitynow(?:gov)?\.com$/

‎apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ export async function mintDatadogServiceAccountToken(
6767
datadogApiUrl(site, '/api/v1/validate'),
6868
{
6969
method: 'GET',
70-
redirect: 'error',
7170
signal: options?.signal,
7271
headers: { Accept: 'application/json', 'DD-API-KEY': apiKey },
7372
},

‎apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import {
1111
requireClientSecret,
1212
TokenServiceAccountValidationError,
1313
} from '@/lib/credentials/token-service-accounts/errors'
14-
import { requestSailPointToken, SailPointAuthError } from '@/lib/internal/sailpoint/client'
14+
import {
15+
requestSailPointToken,
16+
resolveSailPointHosts,
17+
SailPointAuthError,
18+
} from '@/lib/internal/sailpoint/client'
1519

1620
const STEP = 'sailpoint_token_mint'
1721
/** Bounds the whole exchange, including its 429 backoff retries. */
@@ -45,11 +49,12 @@ export async function mintSailPointServiceAccountToken(
4549
})
4650
}
4751

52+
const { tokenUrl } = resolveSailPointHosts(host)
4853
const timeout = AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS)
4954
let minted: { token: string; expiresInSeconds: number }
5055
try {
5156
minted = await requestSailPointToken(
52-
`https://${host}/oauth/token`,
57+
tokenUrl,
5358
fields.clientId,
5459
clientSecret,
5560
options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout

‎apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ async function exchangeClientCredentials(
9494
scope,
9595
expires_in: REQUESTED_TOKEN_LIFETIME_SECONDS,
9696
}),
97-
redirect: 'error',
9897
cache: 'no-store',
9998
signal,
10099
},
@@ -135,7 +134,6 @@ async function verifyStaffUser(
135134
{
136135
method: 'GET',
137136
headers: { Accept: 'application/json', Authorization: authorization },
138-
redirect: 'error',
139137
cache: 'no-store',
140138
signal,
141139
},

‎apps/sim/lib/credentials/client-credentials-grant.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ export async function exchangeClientCredentialsGrant(
7070
client_secret: request.clientSecret,
7171
...extraFields,
7272
}).toString(),
73-
redirect: 'error',
7473
cache: 'no-store',
7574
signal,
7675
},

‎apps/sim/lib/credentials/coordinated-client-token.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,11 @@ function isServable(expiresAt: Date): boolean {
154154
* to reuse one token rather than accumulate them. Minting happens only under a
155155
* transaction-scoped advisory lock keyed by the application, so two workers never race two
156156
* tokens into existence; a token minted with a different secret is never served, and a token the
157-
* provider rejects is expired through {@link invalidateCoordinatedClientToken}. A rejected
158-
* exchange is remembered for 30 seconds so a hot workflow on a revoked secret does not hammer the
159-
* token endpoint. Tokens are encrypted at rest.
157+
* provider rejects is expired through {@link invalidateCoordinatedClientToken}. An exchange that
158+
* fails validation, whether refused for the credentials or answered with an outage, is remembered
159+
* for 30 seconds, the same backoff the per-instance mint cache applies, so a hot workflow neither
160+
* hammers the token endpoint with a revoked secret nor floods a struggling one. Tokens are
161+
* encrypted at rest.
160162
*/
161163
export async function resolveCoordinatedClientToken(
162164
input: CoordinatedClientTokenInput

‎apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,12 @@ const HUBSPOT_KEY_PREFIX = /^pat-/
101101
* tokens, so one code path covers both regions.
102102
*
103103
* That endpoint is documented only for private app tokens, and live probing
104-
* shows it returns a bare 404 (HTML) for a token it does not recognize. So a
105-
* 400, 403 or 404, a 401 for a `pat-` key (which may be a service key), or a
106-
* 200 without a portal falls back to the Account Information API, and a key
107-
* that route refuses is settled by a contacts read, the route HubSpot documents
108-
* for service keys. A token-info 401 for any other token is a rejection.
104+
* shows it returns a bare 404 (HTML) for a token it does not recognize. So any
105+
* non-transient 4xx other than 401 (408 and 429 are transient), a 401 for a
106+
* `pat-` key (which may be a service key), or a 200 without a portal falls back
107+
* to the Account Information API, and a key that route refuses is settled by a
108+
* contacts read, the route HubSpot documents for service keys. A token-info 401
109+
* for any other token is a rejection.
109110
*
110111
* The display name is `HubSpot portal {hubId}` when the portal is known — the
111112
* account-info `uiDomain` is the shared regional host (e.g. `app.hubspot.com`),

‎apps/sim/lib/credentials/token-service-accounts/validators/linear.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ export async function validateLinearServiceAccount(
8282
'Content-Type': 'application/json',
8383
},
8484
body: JSON.stringify({ query: VIEWER_QUERY }),
85-
redirect: 'error',
8685
},
8786
'viewer'
8887
)

‎apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ export async function validateShopifyServiceAccount(
9595
'X-Shopify-Access-Token': fields.apiToken,
9696
},
9797
body: JSON.stringify({ query: SHOP_QUERY }),
98-
redirect: 'error',
9998
},
10099
'shop_query',
101100
{ dnsFailureCode: 'site_not_found', dnsFailureReason: 'store domain does not resolve' }

‎apps/sim/lib/oauth/credential-service-cancellation.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,30 @@ describe('client credential token cache', () => {
125125
await expect(resolve()).resolves.toEqual({ accessToken: 'replacement-token' })
126126
expect(mint).toHaveBeenCalledTimes(2)
127127
})
128+
it('keeps a token minted within the minimum age, and drops it once it is older', async () => {
129+
vi.useFakeTimers({ toFake: ['Date'] })
130+
try {
131+
const mint = vi.fn<clientAccounts.ClientCredentialAccountMinter>()
132+
mint
133+
.mockResolvedValueOnce({ ...token, accessToken: 'refused-token' })
134+
.mockResolvedValueOnce({ ...token, accessToken: 'replacement-token' })
135+
vi.spyOn(clientAccounts, 'getClientCredentialAccountMinter').mockReturnValue(mint)
136+
const resolve = () => {
137+
queueTableRows(credential, [storedCredential])
138+
return resolveServiceAccountToken('ramp-refused-aged', RAMP_SERVICE_ACCOUNT_PROVIDER_ID)
139+
}
140+
141+
await expect(resolve()).resolves.toEqual({ accessToken: 'refused-token' })
142+
vi.advanceTimersByTime(30_000)
143+
forgetRejectedClientCredentialToken('refused-token', { minimumAgeMs: 60_000 })
144+
await expect(resolve()).resolves.toEqual({ accessToken: 'refused-token' })
145+
146+
vi.advanceTimersByTime(31_000)
147+
forgetRejectedClientCredentialToken('refused-token', { minimumAgeMs: 60_000 })
148+
await expect(resolve()).resolves.toEqual({ accessToken: 'replacement-token' })
149+
expect(mint).toHaveBeenCalledTimes(2)
150+
} finally {
151+
vi.useRealTimers()
152+
}
153+
})
128154
})

0 commit comments

Comments
 (0)