Skip to content

Commit 09ca74e

Browse files
committed
fix(credentials): Splunk password sign-in, Snowflake key pair in the CLI, Apollo OAuth people search, and one short SailPoint throttle wait
1 parent 309539e commit 09ca74e

19 files changed

Lines changed: 448 additions & 55 deletions

File tree

‎apps/docs/content/docs/integrations/splunk.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import { ReferenceDetails } from "@/components/ui/reference-details"
1010

1111

1212
{/* MANUAL-CONTENT-START:intro */}
13-
Use [Splunk](https://www.splunk.com/) with a bearer token to search logs, metrics, and events in Splunk Enterprise or Splunk Cloud. **Run Search** returns a synchronous result; for longer searches, create a job, inspect its status, page through its results, and cancel it when needed.
13+
Use [Splunk](https://www.splunk.com/) with an authentication token or a username and password to search logs, metrics, and events in Splunk Enterprise or Splunk Cloud. **Run Search** returns a synchronous result; for longer searches, create a job, inspect its status, page through its results, and cancel it when needed.
1414

1515
Saved-search actions list, read, and dispatch existing searches. Alert actions inspect fired alerts and their instances. Index and app lookups help identify the data and configuration available to the credential.
1616

17-
Connect Splunk once as a Splunk credential: create an authentication token in Splunk Web under **Settings > Tokens** (token authentication must be turned on), and enter the management URL with its port, such as `https://splunk.example.com:8089`. Sim checks the token by reading the user it authenticates as. The management URL must be reachable from Sim over https with a certificate from a public certificate authority; Sim never skips certificate verification, so a management port serving Splunk's default self-signed certificate cannot be saved. Workflows built with the earlier Splunk block keep the URL, token, or username and password they entered in the block.
17+
Connect Splunk once as a Splunk credential, and enter the management URL with its port, such as `https://splunk.example.com:8089`. To sign in with a token, create an authentication token in Splunk Web under **Settings > Tokens** (token authentication must be turned on) and leave the username blank. Token authentication is off by default on Splunk Enterprise, so you can instead enter a username and its password, which Sim sends with HTTP Basic authentication. Sim checks either sign-in by reading the user it authenticates as. The management URL must be reachable from Sim over https with a certificate from a public certificate authority; Sim never skips certificate verification, so a management port serving Splunk's default self-signed certificate cannot be saved. Workflows built with the earlier Splunk block keep the URL, token, or username and password they entered in the block.
1818
{/* MANUAL-CONTENT-END */}
1919

2020
## Use the block [#usage-instructions]

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,50 @@ 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 () => {
116+
it('waits out one short throttle and mints on the retry', async () => {
117+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
118+
try {
119+
mockFetch
120+
.mockResolvedValueOnce(
121+
jsonResponse({ error: 'rate limited' }, { status: 429, headers: { 'Retry-After': '2' } })
122+
)
123+
.mockResolvedValueOnce(jsonResponse({ access_token: 'sp-access', expires_in: 749 }))
124+
125+
const minted = mintSailPointServiceAccountToken(FIELDS)
126+
await vi.advanceTimersByTimeAsync(1_999)
127+
expect(mockFetch).toHaveBeenCalledOnce()
128+
await vi.advanceTimersByTimeAsync(1)
129+
130+
await expect(minted).resolves.toMatchObject({ accessToken: 'sp-access' })
131+
expect(mockFetch).toHaveBeenCalledTimes(2)
132+
} finally {
133+
vi.useRealTimers()
134+
}
135+
})
136+
137+
it('retries a short throttle only once and returns the second answer with its Retry-After', async () => {
138+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
139+
try {
140+
mockFetch.mockImplementation(async () =>
141+
jsonResponse({ error: 'rate limited' }, { status: 429, headers: { 'Retry-After': '1' } })
142+
)
143+
144+
const minted = mintSailPointServiceAccountToken(FIELDS)
145+
const settled = expect(minted).rejects.toMatchObject({
146+
code: 'provider_unavailable',
147+
status: 429,
148+
retryAfterMs: 1_000,
149+
})
150+
await vi.advanceTimersByTimeAsync(10_000)
151+
152+
await settled
153+
expect(mockFetch).toHaveBeenCalledTimes(2)
154+
} finally {
155+
vi.useRealTimers()
156+
}
157+
})
158+
159+
it('returns a long throttle with its Retry-After instead of retrying it', async () => {
117160
mockFetch.mockImplementation(async () =>
118161
jsonResponse({ error: 'rate limited' }, { status: 429, headers: { 'Retry-After': '120' } })
119162
)

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
const STEP = 'sailpoint_token_mint'
2121
/** Bounds the whole exchange. */
2222
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000
23+
/** The longest throttle the mint waits out in place, leaving both requests well inside the exchange budget. */
24+
const MAX_IN_PLACE_THROTTLE_WAIT_MS = 5_000
2325

2426
/**
2527
* Mints a SailPoint Identity Security Cloud access token for an API client or
@@ -32,8 +34,10 @@ const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000
3234
* from `expires_in`; the credential cache re-mints before it lapses.
3335
*
3436
* 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.
37+
* A 429 asking for at most {@link MAX_IN_PLACE_THROTTLE_WAIT_MS} is retried once
38+
* here, so a brief throttle does not fail a cold mint or a connect-time check.
39+
* A longer one, or a second 429, is returned at once with its `Retry-After`, and
40+
* the credential resolver replays it for that long (within its own bounds).
3741
*
3842
* @see https://developer.sailpoint.com/docs/api/authentication
3943
*/
@@ -60,7 +64,7 @@ export async function mintSailPointServiceAccountToken(
6064
fields.clientId,
6165
clientSecret,
6266
options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
63-
{ maxThrottleRetries: 0 }
67+
{ maxThrottleRetries: 1, maxThrottleWaitMs: MAX_IN_PLACE_THROTTLE_WAIT_MS }
6468
)
6569
} catch (error) {
6670
options?.signal?.throwIfAborted()

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ export interface TokenServiceAccountDescriptor {
113113
* token route surfaces this so tool header builders can switch schemes.
114114
*/
115115
authStyle?: 'bearer' | 'x-api-token'
116+
/**
117+
* The pasted secret is a password whenever the connection also stores a username in
118+
* `providerWorkspaceId` (Splunk, whose token sign-in is off by default): such a credential
119+
* resolves with `authStyle: 'basic'` so its tools send HTTP Basic. One saved without a username
120+
* resolves exactly as a pasted token.
121+
*/
122+
passwordWithUsername?: true
116123
/**
117124
* A client-credentials grant the provider offers beside the pasted token. A credential saved
118125
* with `authMethod: 'client_credentials'` stores the client ID and secret (and `domain`, when
@@ -2732,10 +2739,17 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
27322739
fields: [
27332740
{
27342741
id: 'apiToken',
2735-
label: 'Authentication token',
2736-
placeholder: 'Paste a Splunk authentication token',
2742+
label: 'Authentication token or password',
2743+
placeholder: 'Paste a Splunk authentication token, or the password',
27372744
secret: true,
27382745
},
2746+
{
2747+
id: 'providerWorkspaceId',
2748+
label: 'Username',
2749+
placeholder: 'Only to sign in with a password',
2750+
secret: false,
2751+
optional: true,
2752+
},
27392753
{
27402754
id: 'domain',
27412755
label: 'Management URL',
@@ -2744,11 +2758,12 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
27442758
url: true,
27452759
},
27462760
],
2761+
passwordWithUsername: true,
27472762
docsUrl: 'https://docs.sim.ai/integrations/splunk',
27482763
helpText:
2749-
'Create a token in Splunk Web under Settings > Tokens, and enter the management URL with its port (8089 by default). The URL must be reachable from Sim over https with a certificate from a public certificate authority; Sim does not skip certificate checks.',
2764+
'Create a token in Splunk Web under Settings > Tokens and leave the username blank, or enter a username and its password when token authentication is turned off. Enter the management URL with its port (8089 by default). The URL must be reachable from Sim over https with a certificate from a public certificate authority; Sim does not skip certificate checks.',
27502765
invalidCredentialsHelp:
2751-
'Splunk rejected this token. Check that token authentication is turned on, that the token is enabled and not expired, and that the management URL points at the same deployment.',
2766+
'Splunk rejected these credentials. For a token, check that token authentication is turned on and that the token is enabled and not expired; for a password, check the username and password. Also check that the management URL points at the same deployment.',
27522767
},
27532768
[CBINSIGHTS_SERVICE_ACCOUNT_PROVIDER_ID]: {
27542769
providerId: CBINSIGHTS_SERVICE_ACCOUNT_PROVIDER_ID,

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,43 @@ describe('Splunk saved authentication token verification', () => {
879879
validateSplunkServiceAccount({ apiToken: 'any', domain: 'https://splunk.example.com:8089' })
880880
).rejects.toMatchObject({ code: 'provider_unavailable' })
881881
})
882+
883+
it('signs a username and password in with HTTP Basic on the same check, and stores the username', async () => {
884+
reply(jsonResponse({ entry: [{ content: { username: 'svc-sim' } }] }))
885+
886+
await expect(
887+
validateSplunkServiceAccount({
888+
apiToken: 'p@ss word',
889+
providerWorkspaceId: ' svc-sim ',
890+
domain: 'https://splunk.example.com:8089',
891+
})
892+
).resolves.toMatchObject({
893+
normalizedDomain: 'https://splunk.example.com:8089',
894+
normalizedWorkspaceId: 'svc-sim',
895+
})
896+
expect(secureFetch).toHaveBeenCalledWith(
897+
'https://splunk.example.com:8089/services/authentication/current-context?output_mode=json',
898+
'203.0.113.7',
899+
expect.objectContaining({
900+
headers: expect.objectContaining({
901+
Authorization: `Basic ${Buffer.from('svc-sim:p@ss word').toString('base64')}`,
902+
}),
903+
})
904+
)
905+
})
906+
907+
it('refuses a username HTTP Basic cannot carry without sending it', async () => {
908+
reply(jsonResponse({ entry: [{ content: { username: 'svc-sim' } }] }))
909+
910+
await expect(
911+
validateSplunkServiceAccount({
912+
apiToken: 'password',
913+
providerWorkspaceId: 'svc:sim',
914+
domain: 'https://splunk.example.com:8089',
915+
})
916+
).rejects.toMatchObject({ code: 'invalid_credentials' })
917+
expect(secureFetch).not.toHaveBeenCalled()
918+
})
882919
})
883920

884921
describe('CB Insights saved API client verification', () => {

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,18 +1270,30 @@ export const validateKalshiServiceAccount = tokenCheckValidator({
12701270

12711271
/**
12721272
* Splunk's `authentication/current-context` returns the user a request authenticates as and
1273-
* needs no capability, so any valid authentication token reads it; a refused token answers 401.
1274-
* The management URL is one the user chose, so the check runs through the guarded egress, and
1275-
* an answer without a user context (a proxy or login page) is not taken as proof.
1273+
* needs no capability, so any valid authentication token, or username and password sent as HTTP
1274+
* Basic the way the tools send them, reads it; refused credentials answer 401. A username is the
1275+
* Basic username, so one with a colon is refused unsent. The management URL is one the user
1276+
* chose, so the check runs through the guarded egress, and an answer without a user context (a
1277+
* proxy or login page) is not taken as proof.
12761278
* https://help.splunk.com/en/splunk-enterprise/leverage-rest-apis/rest-api-reference/10.6/access-endpoints/access-endpoint-descriptions
12771279
*/
12781280
export const validateSplunkServiceAccount = tokenCheckValidator({
12791281
serviceLabel: 'Splunk',
12801282
domain: ({ domain }) => normalizeHttpBaseUrl(domain),
1283+
workspaceId: ({ providerWorkspaceId }) => {
1284+
const username = (providerWorkspaceId ?? '').trim()
1285+
if (!username) return undefined
1286+
if (!/^[^:\x00-\x1f\x7f]+$/.test(username)) throw new Error('invalid username')
1287+
return username
1288+
},
12811289
egress: 'configuredEndpoint',
1282-
request: ({ apiToken, domain = '' }) => ({
1290+
request: ({ apiToken, domain = '', providerWorkspaceId }) => ({
12831291
url: buildSplunkUrl({ baseUrl: domain }, '/authentication/current-context'),
1284-
headers: buildSplunkHeaders({ baseUrl: domain, authToken: apiToken }),
1292+
headers: buildSplunkHeaders(
1293+
providerWorkspaceId
1294+
? { baseUrl: domain, username: providerWorkspaceId, password: apiToken }
1295+
: { baseUrl: domain, authToken: apiToken }
1296+
),
12851297
}),
12861298
acceptsBody: (body) => {
12871299
const [entry] = toArray(toRecord(body).entry)

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,10 @@ async function readBoundedBody(
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
186186
* 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`, even when its error body cannot be read.
187+
* A 429 whose wait (its `Retry-After`, or the backoff when it names none)
188+
* exceeds `maxThrottleWaitMs` is not retried. A refusal throws
189+
* {@link SailPointAuthError} with the provider's status, message, and
190+
* `Retry-After`, even when its error body cannot be read.
189191
*
190192
* @see https://developer.sailpoint.com/docs/api/authentication
191193
*/
@@ -194,7 +196,10 @@ export async function requestSailPointToken(
194196
clientId: string,
195197
clientSecret: string,
196198
signal: AbortSignal,
197-
{ maxThrottleRetries = MAX_FETCH_RETRIES }: { maxThrottleRetries?: number } = {}
199+
{
200+
maxThrottleRetries = MAX_FETCH_RETRIES,
201+
maxThrottleWaitMs = Number.POSITIVE_INFINITY,
202+
}: { maxThrottleRetries?: number; maxThrottleWaitMs?: number } = {}
198203
): Promise<{ token: string; expiresInSeconds: number }> {
199204
let attempt = 0
200205

@@ -216,11 +221,14 @@ export async function requestSailPointToken(
216221
signal,
217222
})
218223

219-
if (response.status === 429 && attempt < maxThrottleRetries) {
220-
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
224+
const throttleWaitMs =
225+
response.status === 429 && attempt < maxThrottleRetries
226+
? backoffWithJitter(attempt + 1, parseRetryAfter(response.headers.get('retry-after')))
227+
: undefined
228+
if (throttleWaitMs !== undefined && throttleWaitMs <= maxThrottleWaitMs) {
221229
await consumeOrCancelBody(response, DEFAULT_MAX_ERROR_BODY_BYTES)
222230
attempt += 1
223-
await interruptibleSleep(backoffWithJitter(attempt, retryAfterMs), signal)
231+
await interruptibleSleep(throttleWaitMs, signal)
224232
signal?.throwIfAborted()
225233
continue
226234
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,6 +1480,38 @@ describe('token provider credentials saved in client-credentials mode', () => {
14801480
})
14811481
})
14821482

1483+
it('marks a saved Splunk username and password as basic, and a saved token as before', async () => {
1484+
storeBlob('splunk-login', {
1485+
type: 'token_service_account',
1486+
providerId: 'splunk-service-account',
1487+
apiToken: 'saved-password',
1488+
providerWorkspaceId: 'svc-sim',
1489+
domain: 'https://splunk.example.com:8089',
1490+
})
1491+
await expect(
1492+
resolveServiceAccountToken('splunk-login', 'splunk-service-account')
1493+
).resolves.toEqual({
1494+
accessToken: 'saved-password',
1495+
domain: 'https://splunk.example.com:8089',
1496+
providerWorkspaceId: 'svc-sim',
1497+
authStyle: 'basic',
1498+
})
1499+
1500+
storeBlob('splunk-token', {
1501+
type: 'token_service_account',
1502+
providerId: 'splunk-service-account',
1503+
apiToken: 'splunk-token',
1504+
domain: 'https://splunk.example.com:8089',
1505+
})
1506+
await expect(
1507+
resolveServiceAccountToken('splunk-token', 'splunk-service-account')
1508+
).resolves.toEqual({
1509+
accessToken: 'splunk-token',
1510+
domain: 'https://splunk.example.com:8089',
1511+
providerWorkspaceId: undefined,
1512+
})
1513+
})
1514+
14831515
it('still returns a pasted Shopify token as is', async () => {
14841516
storeBlob(
14851517
'shopify-pasted',

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ export interface ServiceAccountTokenResult {
612612
* Sim-signed OIDC token a keyless Snowflake credential presents through
613613
* workload identity federation, or a JWT a key-pair Snowflake credential
614614
* signs with its private key, or `basic` for an HTTP Basic credential (a
615-
* Zendesk API token sign-in), or `bearer` for a token minted for a provider
615+
* Zendesk API token sign-in, or a Splunk username and password), or `bearer` for a token minted for a provider
616616
* whose pasted mode is not a bearer token (ServiceNow client credentials).
617617
* Absent means Bearer, except to tools whose pasted mode says otherwise;
618618
* OAuth credentials never carry it.
@@ -1117,12 +1117,17 @@ export async function resolveServiceAccountToken(
11171117
if ('authMethod' in secret) {
11181118
return resolveTokenClientCredentialToken(credentialId, secret, stored, options)
11191119
}
1120-
const descriptorAuthStyle = getTokenServiceAccountDescriptor(providerId)?.authStyle
1120+
const descriptor = getTokenServiceAccountDescriptor(providerId)
1121+
const descriptorAuthStyle = descriptor?.authStyle
11211122
return {
11221123
accessToken: secret.apiToken,
11231124
domain: secret.domain,
11241125
providerWorkspaceId: secret.providerWorkspaceId,
1125-
...(descriptorAuthStyle === 'x-api-token' ? { authStyle: descriptorAuthStyle } : {}),
1126+
...(descriptor?.passwordWithUsername && secret.providerWorkspaceId
1127+
? { authStyle: 'basic' as const }
1128+
: descriptorAuthStyle === 'x-api-token'
1129+
? { authStyle: descriptorAuthStyle }
1130+
: {}),
11261131
}
11271132
}
11281133
if (providerId && isClientCredentialAccountProviderId(providerId)) {

0 commit comments

Comments
 (0)