Skip to content

Commit d014680

Browse files
committed
fix(credentials): end a refused token, read legacy refusals as refusals, and keep throttle delays through SailPoint and CrowdStrike
- A refusal of the credentials drops the per-instance cached token and expires the shared token minted with that secret, so a later outage cannot serve it again. - A previous build's provider_unavailable memo with a non-transient 4xx status replays as a refusal instead of serving the stored token. - The SailPoint saved-credential minter returns the first 429 with its Retry-After instead of retrying until the exchange timeout swallows it. - A CrowdStrike error whose body cannot be read keeps its status and Retry-After. - Vanta logs a malformed or oversized token response apart from an unreadable stream.
1 parent 24a4664 commit d014680

10 files changed

Lines changed: 162 additions & 32 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ describe('CrowdStrike API client token exchange', () => {
9898
})
9999
})
100100

101+
it("keeps a throttled answer's status and Retry-After when its error body cannot be read", async () => {
102+
mockFetch.mockResolvedValue(
103+
new Response(
104+
new ReadableStream({
105+
start(controller) {
106+
controller.error(new TypeError('terminated'))
107+
},
108+
}),
109+
{ status: 429, headers: { 'Retry-After': '7' } }
110+
)
111+
)
112+
113+
await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({
114+
code: 'provider_unavailable',
115+
status: 429,
116+
retryAfterMs: 7_000,
117+
})
118+
})
119+
101120
it("reports a redirect to the API client's own cloud as a wrong cloud and never follows it", async () => {
102121
mockFetch.mockResolvedValue(
103122
new Response(null, {

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,19 @@ describe('SailPoint API client token exchange', () => {
113113
})
114114
})
115115

116+
it('returns the first throttled answer with its Retry-After instead of retrying it', async () => {
117+
mockFetch.mockImplementation(async () =>
118+
jsonResponse({ error: 'rate limited' }, { status: 429, headers: { 'Retry-After': '120' } })
119+
)
120+
121+
await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({
122+
code: 'provider_unavailable',
123+
status: 429,
124+
retryAfterMs: 120_000,
125+
})
126+
expect(mockFetch).toHaveBeenCalledOnce()
127+
})
128+
116129
it('treats a network failure or refused redirect as a provider failure', async () => {
117130
mockFetch.mockRejectedValue(new TypeError('fetch failed: unexpected redirect'))
118131

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
} from '@/lib/internal/sailpoint/client'
1919

2020
const STEP = 'sailpoint_token_mint'
21-
/** Bounds the whole exchange, including its 429 backoff retries. */
21+
/** Bounds the whole exchange. */
2222
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000
2323

2424
/**
@@ -32,6 +32,8 @@ const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000
3232
* from `expires_in`; the credential cache re-mints before it lapses.
3333
*
3434
* The token response names no identity, so the connect-time check is the mint.
35+
* A 429 is returned at once with its `Retry-After` rather than retried here, so
36+
* the credential resolver's throttling backoff waits as long as SailPoint asks.
3537
*
3638
* @see https://developer.sailpoint.com/docs/api/authentication
3739
*/
@@ -57,7 +59,8 @@ export async function mintSailPointServiceAccountToken(
5759
tokenUrl,
5860
fields.clientId,
5961
clientSecret,
60-
options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout
62+
options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
63+
{ maxThrottleRetries: 0 }
6164
)
6265
} catch (error) {
6366
options?.signal?.throwIfAborted()

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { isRecordLike } from '@sim/utils/object'
2-
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
2+
import { PayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
33
import { VANTA_PERMISSION_OPTIONS } from '@/lib/credentials/client-credential-accounts/descriptors'
44
import {
55
invalidateCoordinatedClientToken,
@@ -108,9 +108,13 @@ async function exchangeToken(
108108
label: 'Vanta authentication response',
109109
signal,
110110
})
111-
} catch {
111+
} catch (error) {
112112
signal?.throwIfAborted()
113-
throw unavailable('provider returned an unreadable token response')
113+
throw unavailable(
114+
error instanceof SyntaxError || error instanceof PayloadSizeLimitError
115+
? 'provider returned an invalid token response'
116+
: 'provider token response could not be read'
117+
)
114118
}
115119
if (
116120
!isRecordLike(payload) ||

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

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,32 @@ describe('coordinated token resolution while the provider fails', () => {
333333
expect(exchange).toHaveBeenCalledTimes(2)
334334
})
335335

336+
it('never serves a refused token again once the refusal memo ends and an outage follows', async () => {
337+
const exchange = issuingOnce('refused-then-outage', 4 * 60_000, rejecting())
338+
await resolveCoordinatedClientToken(input('refused-then-outage', exchange))
339+
await expect(
340+
resolveCoordinatedClientToken(input('refused-then-outage', exchange))
341+
).rejects.toMatchObject({ code: 'invalid_credentials' })
342+
await expireFailureRowsOf('refused-then-outage')
343+
exchange.mockImplementation(failing(503))
344+
345+
await expect(
346+
resolveCoordinatedClientToken(input('refused-then-outage', exchange))
347+
).rejects.toMatchObject({ code: 'provider_unavailable' })
348+
})
349+
350+
it('keeps the token another secret minted when a different secret is refused', async () => {
351+
const exchange = issuingOnce('refused-other-secret', 4 * 60_000, failing(503))
352+
await resolveCoordinatedClientToken(input('refused-other-secret', exchange))
353+
await expect(
354+
resolveCoordinatedClientToken(input('refused-other-secret', rejecting(), 'revoked'))
355+
).rejects.toMatchObject({ code: 'invalid_credentials' })
356+
357+
await expect(
358+
resolveCoordinatedClientToken(input('refused-other-secret', exchange))
359+
).resolves.toMatchObject({ accessToken: 'refused-other-secret:token-1' })
360+
})
361+
336362
it('keeps refusing for every worker a refusal whose code the shared row cannot store', async () => {
337363
const exchange = issuingOnce('consent-refresh', 4 * 60_000, async () => {
338364
throw new TokenServiceAccountValidationError('consent_required', 400, { step: 'mint' })
@@ -562,16 +588,14 @@ describe('coordinated token resolution while the provider fails', () => {
562588
})
563589

564590
describe('default token rows shared with the previous build during a rolling deploy', () => {
565-
it('serves a still-valid token when the previous build remembered an outage', async () => {
566-
const application = 'legacy-outage'
591+
/** Seeds a still-valid token and the previous build's failure memo of `status` for one secret. */
592+
async function seedPreviousBuildFailure(application: string, status: number) {
567593
const appKey = applicationKey(NAMESPACE, [application])
568594
const fingerprint = sha256Json([application, 'secret-1'])
569595
const token = await encryptSecret(
570596
JSON.stringify({ accessToken: 'legacy-token', secretFingerprint: fingerprint })
571597
)
572-
const outage = await encryptSecret(
573-
JSON.stringify({ code: 'provider_unavailable', status: 503 })
574-
)
598+
const failure = await encryptSecret(JSON.stringify({ code: 'provider_unavailable', status }))
575599
await db.insert(clientCredentialToken).values([
576600
{
577601
id: appKey,
@@ -581,10 +605,15 @@ describe('default token rows shared with the previous build during a rolling dep
581605
},
582606
{
583607
id: `${appKey}:${sha256Json(fingerprint)}`,
584-
encryptedValue: outage.encrypted,
608+
encryptedValue: failure.encrypted,
585609
expiresAt: new Date(Date.now() + 20_000),
586610
},
587611
])
612+
}
613+
614+
it('serves a still-valid token when the previous build remembered an outage', async () => {
615+
const application = 'legacy-outage'
616+
await seedPreviousBuildFailure(application, 503)
588617
const { exchange } = provider(application)
589618

590619
await expect(
@@ -593,6 +622,17 @@ describe('default token rows shared with the previous build during a rolling dep
593622
expect(exchange).not.toHaveBeenCalled()
594623
})
595624

625+
it('replays a refusal the previous build stored as provider_unavailable without serving the token', async () => {
626+
const application = 'legacy-unmapped-refusal'
627+
await seedPreviousBuildFailure(application, 400)
628+
const { exchange } = provider(application)
629+
630+
await expect(resolveCoordinatedClientToken(input(application, exchange))).rejects.toMatchObject(
631+
{ code: 'provider_unavailable', status: 400 }
632+
)
633+
expect(exchange).not.toHaveBeenCalled()
634+
})
635+
596636
it('stores every outage and throttled answer as a failure the previous build reads', async () => {
597637
for (const [application, exchange] of [
598638
['legacy-readable-outage', failing(502)],

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

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import {
1313
TOKEN_FAILURE_STREAK_RETENTION_MS,
1414
type TokenRequestFailure,
1515
} from '@/lib/credentials/token-request-resilience'
16-
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
16+
import {
17+
providerFailureCode,
18+
TokenServiceAccountValidationError,
19+
} from '@/lib/credentials/token-service-accounts/errors'
1720
import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
1821

1922
const logger = createLogger('CoordinatedClientToken')
@@ -214,9 +217,18 @@ function decodeFailure(value: string, step: string): StoredFailure {
214217
}
215218
}
216219

217-
/** Whether a remembered failure refused the credentials, so no stored token is served for it. */
220+
/**
221+
* Whether a remembered failure refused the credentials, so no stored token is served for it. A
222+
* build before failure kinds stored a refusal whose code it could not store as
223+
* `provider_unavailable` with the provider's status and no streak count, so such a row whose
224+
* status names a refusal (a non-transient 4xx) is one too.
225+
*/
218226
function isRememberedRefusal(failure: StoredFailure): boolean {
219-
return failure.refusal === true || isPermanentTokenFailureCode(failure.code)
227+
if (failure.refusal === true || isPermanentTokenFailureCode(failure.code)) return true
228+
return (
229+
failure.consecutiveFailures === undefined &&
230+
isPermanentTokenFailureCode(providerFailureCode(failure.status))
231+
)
220232
}
221233

222234
/** The error a remembered failure is replayed as, with how long it is still replayed. */
@@ -279,7 +291,8 @@ function isServable(expiresAt: Date, minRemainingMs: number): boolean {
279291
* for a minute growing to five without one, and an outage for 5 seconds growing to 30 while it
280292
* continues, so a hot workflow neither hammers the token endpoint with a revoked secret nor floods
281293
* a struggling one. While an outage or throttling is remembered, the stored token for the same
282-
* secret is still served until shortly before it actually expires; a refusal is reported at once.
294+
* secret is still served until shortly before it actually expires; a refusal is reported at once
295+
* and expires that secret's stored token, so no later outage serves it again.
283296
* A remembered failure, and that fallback, are served from committed rows without taking the
284297
* application lock. Tokens are encrypted at rest.
285298
*/
@@ -335,6 +348,13 @@ export async function resolveCoordinatedClientToken(
335348
const fallbackFrom = (row: StoredRow | undefined) =>
336349
serveStored(row, fallbackRemainingMs).catch(() => undefined)
337350

351+
/**
352+
* Whether `row` holds a token minted with this secret, expired or not, so a refusal of the
353+
* secret ends that token too and a token another secret minted stays in service.
354+
*/
355+
const holdsTokenForSecret = async (row: StoredRow | undefined) =>
356+
(await serveStored(row, Number.NEGATIVE_INFINITY).catch(() => undefined)) !== undefined
357+
338358
/**
339359
* Replays the application's unexpired failure memo for this secret: the still-valid token for a
340360
* remembered outage or throttling, the failure otherwise. `undefined` when nothing is remembered.
@@ -453,16 +473,23 @@ export async function resolveCoordinatedClientToken(
453473
target: clientCredentialToken.id,
454474
set: { encryptedValue: encrypted, expiresAt, updatedAt: new Date() },
455475
})
456-
if (assessed.kind !== 'permanent') {
457-
const fallback = await fallbackFrom(applicationRow)
458-
if (fallback) {
459-
logger.warn('Serving a still-valid client-credentials token after its refresh failed', {
460-
namespace,
461-
failure: assessed.kind,
462-
retryInMs: replayMs,
463-
})
464-
return fallback
476+
if (assessed.kind === 'permanent') {
477+
if (await holdsTokenForSecret(applicationRow)) {
478+
await tx
479+
.update(clientCredentialToken)
480+
.set({ expiresAt: new Date(0), updatedAt: new Date() })
481+
.where(eq(clientCredentialToken.id, applicationKey))
465482
}
483+
return { failure: replayedFailure(failure, step, replayMs) }
484+
}
485+
const fallback = await fallbackFrom(applicationRow)
486+
if (fallback) {
487+
logger.warn('Serving a still-valid client-credentials token after its refresh failed', {
488+
namespace,
489+
failure: assessed.kind,
490+
retryInMs: replayMs,
491+
})
492+
return fallback
466493
}
467494
return { failure: replayedFailure(failure, step, replayMs) }
468495
}

‎apps/sim/lib/internal/crowdstrike/client.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ export class CrowdStrikeTokenRedirectError extends Error {
226226
* With `followRedirects: false` a redirect is never followed and throws
227227
* {@link CrowdStrikeTokenRedirectError}, so the secret reaches no other host;
228228
* the legacy block's inline path keeps following one as it always has. A
229-
* refusal throws {@link CrowdStrikeAuthError} with Falcon's status.
229+
* refusal throws {@link CrowdStrikeAuthError} with Falcon's status and
230+
* `Retry-After`, even when its error body cannot be read.
230231
*
231232
* @see https://developer.crowdstrike.com/api-reference/collections/oauth2.md
232233
*/
@@ -264,7 +265,9 @@ export async function requestCrowdStrikeToken(
264265
)
265266
}
266267

267-
const data = await readFalconJson(response, signal)
268+
const data = response.ok
269+
? await readFalconJson(response, signal)
270+
: await readFalconJson(response, signal).catch(() => null)
268271
signal?.throwIfAborted()
269272
if (!response.ok) {
270273
throw new CrowdStrikeAuthError(

‎apps/sim/lib/internal/sailpoint/client.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,18 @@ async function readBoundedBody(
183183
/**
184184
* Exchanges API client credentials for an access token at a tenant's token
185185
* endpoint (`client_credentials` grant, credentials in the form body), with
186-
* redirects refused and up to four backoff retries on 429. A refusal throws
187-
* {@link SailPointAuthError} with the provider's status and message.
186+
* redirects refused and up to four backoff retries on 429 (`maxThrottleRetries`).
187+
* A refusal throws {@link SailPointAuthError} with the provider's status,
188+
* message, and `Retry-After`.
188189
*
189190
* @see https://developer.sailpoint.com/docs/api/authentication
190191
*/
191192
export async function requestSailPointToken(
192193
tokenUrl: string,
193194
clientId: string,
194195
clientSecret: string,
195-
signal: AbortSignal
196+
signal: AbortSignal,
197+
{ maxThrottleRetries = MAX_FETCH_RETRIES }: { maxThrottleRetries?: number } = {}
196198
): Promise<{ token: string; expiresInSeconds: number }> {
197199
let attempt = 0
198200

@@ -214,7 +216,7 @@ export async function requestSailPointToken(
214216
signal,
215217
})
216218

217-
if (response.status === 429 && attempt < MAX_FETCH_RETRIES) {
219+
if (response.status === 429 && attempt < maxThrottleRetries) {
218220
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
219221
await consumeOrCancelBody(response, DEFAULT_MAX_ERROR_BODY_BYTES)
220222
attempt += 1

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,20 @@ describe('client credential token refresh after a failed mint', () => {
257257
expect(mint).toHaveBeenCalledTimes(2)
258258
})
259259

260+
it('never serves a refused token again when a later refresh fails with an outage', async () => {
261+
const mint = minter()
262+
mint.mockResolvedValueOnce({ accessToken: 'current-token', expiresInSeconds: 600 })
263+
await resolveFor('ramp-refused-then-outage')
264+
mint.mockRejectedValueOnce(refused()).mockRejectedValue(unavailable())
265+
vi.advanceTimersByTime(6 * 60_000)
266+
await resolveFor('ramp-refused-then-outage')
267+
vi.advanceTimersByTime(31_000)
268+
269+
await expect(resolveFor('ramp-refused-then-outage')).resolves.toEqual({
270+
error: expect.objectContaining({ code: 'provider_unavailable' }),
271+
})
272+
})
273+
260274
it('retries a quick transient failure once inside the mint', async () => {
261275
const mint = minter()
262276
mint.mockRejectedValueOnce(unavailable(503)).mockResolvedValueOnce(token)

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,8 @@ function secretFingerprintOf(encryptedServiceAccountKey: string): string {
746746
* per-instance cache: re-reads the credential row, serves a cached token minted
747747
* from the same ciphertext while enough of it remains, replays a recent mint
748748
* failure, and otherwise decrypts and mints, falling back to the cached token
749-
* while it is still valid when the refresh fails with an outage. Wrapped in
749+
* while it is still valid when the refresh fails with an outage and dropping it
750+
* when the refresh is refused, so no later outage serves it again. Wrapped in
750751
* `coalesceLocally` so concurrent block executions on one instance share a
751752
* single mint. `sharedToken` providers coordinate one token per application
752753
* themselves (including the in-request retry and the fallback), so their tokens
@@ -829,7 +830,11 @@ async function resolveMintedServiceAccountToken(
829830
retryAtMs,
830831
expiresAtMs: retryAtMs + TOKEN_FAILURE_STREAK_RETENTION_MS,
831832
})
832-
const fallback = failure.kind === 'permanent' ? undefined : stillValid()
833+
if (failure.kind === 'permanent') {
834+
clientCredentialTokenCache.delete(cacheIdentity)
835+
throw error
836+
}
837+
const fallback = stillValid()
833838
if (fallback) {
834839
logger.warn('Serving a still-valid client-credential token after its refresh failed', {
835840
...(options?.privacyMode === 'selector' ? {} : { credentialId }),

0 commit comments

Comments
 (0)