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 @@ -413,7 +413,8 @@ export function resolveZohoDeskDataCenter(rawDataCenter?: string): {

/**
* A SailPoint Identity Security Cloud API host: one tenant label under `api.identitynow.com`, or
* under `api.identitynowgov.com` for government tenants (the same hosts the legacy block accepts).
* under `api.identitynowgov.com` for government tenants. Stricter than the legacy block's inline
* tenant, which also admits a dotted prefix under either domain.
*/
const SAILPOINT_API_HOST_REGEX =
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.api\.identitynow(?:gov)?\.com$/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ export async function mintDatadogServiceAccountToken(
datadogApiUrl(site, '/api/v1/validate'),
{
method: 'GET',
redirect: 'error',
signal: options?.signal,
headers: { Accept: 'application/json', 'DD-API-KEY': apiKey },
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
requireClientSecret,
TokenServiceAccountValidationError,
} from '@/lib/credentials/token-service-accounts/errors'
import { requestSailPointToken, SailPointAuthError } from '@/lib/internal/sailpoint/client'
import {
requestSailPointToken,
resolveSailPointHosts,
SailPointAuthError,
} from '@/lib/internal/sailpoint/client'

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

const { tokenUrl } = resolveSailPointHosts(host)
const timeout = AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS)
let minted: { token: string; expiresInSeconds: number }
try {
minted = await requestSailPointToken(
`https://${host}/oauth/token`,
tokenUrl,
fields.clientId,
clientSecret,
options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ async function exchangeClientCredentials(
scope,
expires_in: REQUESTED_TOKEN_LIFETIME_SECONDS,
}),
redirect: 'error',
cache: 'no-store',
signal,
},
Expand Down Expand Up @@ -135,7 +134,6 @@ async function verifyStaffUser(
{
method: 'GET',
headers: { Accept: 'application/json', Authorization: authorization },
redirect: 'error',
cache: 'no-store',
signal,
},
Expand Down
1 change: 0 additions & 1 deletion apps/sim/lib/credentials/client-credentials-grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ export async function exchangeClientCredentialsGrant(
client_secret: request.clientSecret,
...extraFields,
}).toString(),
redirect: 'error',
cache: 'no-store',
signal,
},
Expand Down
8 changes: 5 additions & 3 deletions apps/sim/lib/credentials/coordinated-client-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,11 @@ function isServable(expiresAt: Date): boolean {
* to reuse one token rather than accumulate them. Minting happens only under a
* transaction-scoped advisory lock keyed by the application, so two workers never race two
* tokens into existence; a token minted with a different secret is never served, and a token the
* provider rejects is expired through {@link invalidateCoordinatedClientToken}. A rejected
* exchange is remembered for 30 seconds so a hot workflow on a revoked secret does not hammer the
* token endpoint. Tokens are encrypted at rest.
* provider rejects is expired through {@link invalidateCoordinatedClientToken}. An exchange that
* fails validation, whether refused for the credentials or answered with an outage, is remembered
* for 30 seconds, the same backoff the per-instance mint cache applies, so a hot workflow neither
* hammers the token endpoint with a revoked secret nor floods a struggling one. Tokens are
* encrypted at rest.
*/
export async function resolveCoordinatedClientToken(
input: CoordinatedClientTokenInput
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,12 @@ const HUBSPOT_KEY_PREFIX = /^pat-/
* tokens, so one code path covers both regions.
*
* That endpoint is documented only for private app tokens, and live probing
* shows it returns a bare 404 (HTML) for a token it does not recognize. So a
* 400, 403 or 404, a 401 for a `pat-` key (which may be a service key), or a
* 200 without a portal falls back to the Account Information API, and a key
* that route refuses is settled by a contacts read, the route HubSpot documents
* for service keys. A token-info 401 for any other token is a rejection.
* shows it returns a bare 404 (HTML) for a token it does not recognize. So any
* non-transient 4xx other than 401 (408 and 429 are transient), a 401 for a
* `pat-` key (which may be a service key), or a 200 without a portal falls back
* to the Account Information API, and a key that route refuses is settled by a
* contacts read, the route HubSpot documents for service keys. A token-info 401
* for any other token is a rejection.
*
* The display name is `HubSpot portal {hubId}` when the portal is known — the
* account-info `uiDomain` is the shared regional host (e.g. `app.hubspot.com`),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ export async function validateLinearServiceAccount(
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: VIEWER_QUERY }),
redirect: 'error',
},
'viewer'
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ export async function validateShopifyServiceAccount(
'X-Shopify-Access-Token': fields.apiToken,
},
body: JSON.stringify({ query: SHOP_QUERY }),
redirect: 'error',
},
'shop_query',
{ dnsFailureCode: 'site_not_found', dnsFailureReason: 'store domain does not resolve' }
Expand Down
26 changes: 26 additions & 0 deletions apps/sim/lib/oauth/credential-service-cancellation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,30 @@ describe('client credential token cache', () => {
await expect(resolve()).resolves.toEqual({ accessToken: 'replacement-token' })
expect(mint).toHaveBeenCalledTimes(2)
})
it('keeps a token minted within the minimum age, and drops it once it is older', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
try {
const mint = vi.fn<clientAccounts.ClientCredentialAccountMinter>()
mint
.mockResolvedValueOnce({ ...token, accessToken: 'refused-token' })
.mockResolvedValueOnce({ ...token, accessToken: 'replacement-token' })
vi.spyOn(clientAccounts, 'getClientCredentialAccountMinter').mockReturnValue(mint)
const resolve = () => {
queueTableRows(credential, [storedCredential])
return resolveServiceAccountToken('ramp-refused-aged', RAMP_SERVICE_ACCOUNT_PROVIDER_ID)
}

await expect(resolve()).resolves.toEqual({ accessToken: 'refused-token' })
vi.advanceTimersByTime(30_000)
forgetRejectedClientCredentialToken('refused-token', { minimumAgeMs: 60_000 })
await expect(resolve()).resolves.toEqual({ accessToken: 'refused-token' })

vi.advanceTimersByTime(31_000)
forgetRejectedClientCredentialToken('refused-token', { minimumAgeMs: 60_000 })
await expect(resolve()).resolves.toEqual({ accessToken: 'replacement-token' })
expect(mint).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
})
16 changes: 13 additions & 3 deletions apps/sim/lib/oauth/credential-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,7 @@ export interface ServiceAccountTokenResult {
interface CachedClientCredentialToken {
/** The minted token and the destination fields it is bound to, exactly as tools receive them. */
token: ServiceAccountTokenResult
mintedAtMs: number
expiresAtMs: number
/**
* Fingerprint of the encrypted secret the token was minted from (see
Expand Down Expand Up @@ -694,11 +695,19 @@ function pruneExpiredClientCredentialCaches(nowMs: number): void {
* refused it (a revoked API client, or a token minted before the client was
* granted a scope), so the next resolution mints a replacement instead of
* serving the refused token until it nears expiry. Matching on the token value
* leaves alone a replacement another call has already minted.
* leaves alone a replacement another call has already minted. With
* `minimumAgeMs`, a token minted more recently is kept: a refusal that may come
* from the endpoint rather than the token then re-mints at most once per that
* interval instead of once per call.
*/
export function forgetRejectedClientCredentialToken(accessToken: string): void {
export function forgetRejectedClientCredentialToken(
accessToken: string,
options: { minimumAgeMs?: number } = {}
): void {
const mintedBeforeMs = Date.now() - (options.minimumAgeMs ?? 0)
for (const [identity, entry] of clientCredentialTokenCache) {
if (entry.token.accessToken === accessToken) clientCredentialTokenCache.delete(identity)
if (entry.token.accessToken === accessToken && entry.mintedAtMs <= mintedBeforeMs)
clientCredentialTokenCache.delete(identity)
}
}

Expand Down Expand Up @@ -778,6 +787,7 @@ async function resolveMintedServiceAccountToken(
if (!sharedToken)
clientCredentialTokenCache.set(cacheIdentity, {
token: minted.token,
mintedAtMs: Date.now(),
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
expiresAtMs: Date.now() + minted.expiresInSeconds * 1000,
secretFingerprint,
})
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-metadata.ts

Large diffs are not rendered by default.

22 changes: 14 additions & 8 deletions apps/sim/tools/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3823,15 +3823,21 @@ describe('Internal Route Trust', () => {
)
}

it.each([401, 403])(
'drops the cached token after a %i so the next call mints one',
async (status) => {
const result = await runWith('service_account', status)
it('drops the cached token after a 401 so the next call mints one', async () => {
const result = await runWith('service_account', 401)

expect(result.success).toBe(false)
expect(mockForgetRejectedClientCredentialToken).toHaveBeenCalledWith('minted-token')
}
)
expect(result.success).toBe(false)
expect(mockForgetRejectedClientCredentialToken).toHaveBeenCalledWith('minted-token')
})

it('drops the cached token after a 403 only once it is old enough to blame', async () => {
const result = await runWith('service_account', 403)

expect(result.success).toBe(false)
expect(mockForgetRejectedClientCredentialToken).toHaveBeenCalledWith('minted-token', {
minimumAgeMs: 60_000,
})
})

it('keeps the token after a call the provider accepts', async () => {
const result = await runWith('service_account', 200)
Expand Down
20 changes: 14 additions & 6 deletions apps/sim/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2957,7 +2957,11 @@ async function executeDeclaredInternalOperation({
authorizedServiceAccountCredential &&
typeof params.accessToken === 'string'
) {
await forgetRefusedServiceAccountToken(params.accessToken, requestId)
await forgetRefusedServiceAccountToken(
params.accessToken,
requestId,
result.status === 403 ? REJECTED_TOKEN_MINIMUM_AGE_MS : undefined
)
}
if (result instanceof Response && result.status === 401 && refreshCredential) {
await result.body?.cancel()
Expand Down Expand Up @@ -3042,8 +3046,8 @@ async function executeDeclaredInternalOperation({
*/
const SCOPE_REFUSAL_PATTERN = /scope does not match|insufficient_scope/i
/**
* A tool call's 401 can come from the endpoint rather than the token, so a token minted within the
* last minute is kept: a call that keeps failing re-mints its application's token at most once a
* A tool call's 401 (and an internal operation's 403) can come from the endpoint rather than the
* token, so a token minted within the last minute is kept: a call that keeps failing re-mints its application's token at most once a
* minute, instead of once per call.
*/
const REJECTED_TOKEN_MINIMUM_AGE_MS = 60_000
Expand Down Expand Up @@ -3094,15 +3098,19 @@ async function expireRejectedServiceAccountToken(
* Drops a refused service-account token from this instance's client-credential cache: an internal
* operation answers 401 or 403, and an external request 401, when the provider refused it. The
* next call mints a replacement: providers such as CrowdStrike Falcon and SailPoint fix a token's
* scopes when it is minted, so a scope granted since then only reaches a new token.
* scopes when it is minted, so a scope granted since then only reaches a new token. A 403 can also
* come from Sim's own file-access refusal or a per-record permission, so the internal-operation
* path passes `minimumAgeMs` for it and a token minted within that window is kept.
*/
async function forgetRefusedServiceAccountToken(
accessToken: string,
requestId: string
requestId: string,
minimumAgeMs?: number
): Promise<void> {
try {
const { forgetRejectedClientCredentialToken } = await import('@/lib/oauth/credential-service')
forgetRejectedClientCredentialToken(accessToken)
if (minimumAgeMs === undefined) forgetRejectedClientCredentialToken(accessToken)
else forgetRejectedClientCredentialToken(accessToken, { minimumAgeMs })
} catch (error) {
logger.warn('Could not drop a service-account token the provider refused', {
requestId,
Expand Down
8 changes: 2 additions & 6 deletions apps/sim/tools/zendesk/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { savedCredentialTokenParam } from '@/tools/shared/saved-credentials'
import type { OutputProperty, ToolConfig } from '@/tools/types'

const logger = createLogger('Zendesk')
Expand Down Expand Up @@ -57,12 +58,7 @@ export function zendeskAuthorization(params: ZendeskAuthParams): string {

/** Hidden params a saved Zendesk credential fills; every Zendesk tool declares them. */
export const ZENDESK_CREDENTIAL_PARAMS = {
accessToken: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'Token injected by the executor from the selected Zendesk credential',
},
accessToken: savedCredentialTokenParam('Zendesk'),
authStyle: {
type: 'string',
required: false,
Expand Down
Loading