Skip to content

Commit f9f20b5

Browse files
committed
fix(credentials): accept scoped keys on save, isolate the retiring signing key, surface Databricks token errors
1 parent ba644c0 commit f9f20b5

5 files changed

Lines changed: 113 additions & 10 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { describe, expect, it, vi } from 'vitest'
33
import {
44
validateBrexServiceAccount,
55
validateFlintServiceAccount,
6+
validateInstantlyServiceAccount,
7+
validateYouTubeServiceAccount,
68
} from '@/lib/credentials/token-service-accounts/validators/token-checks'
79

810
describe('Flint saved API key verification', () => {
@@ -92,3 +94,42 @@ describe('Brex saved token verification', () => {
9294
})
9395
})
9496
})
97+
98+
describe('saved key verification for vendors with scoped keys', () => {
99+
it('accepts a valid Instantly key that lacks the campaigns scope', async () => {
100+
vi.stubGlobal(
101+
'fetch',
102+
vi.fn().mockResolvedValue(jsonResponse({ message: 'Insufficient scope' }, 403))
103+
)
104+
105+
await expect(validateInstantlyServiceAccount({ apiToken: 'leads-only' })).resolves.toBeTruthy()
106+
})
107+
108+
it('accepts a valid YouTube key that is out of quota for today', async () => {
109+
vi.stubGlobal(
110+
'fetch',
111+
vi
112+
.fn()
113+
.mockResolvedValue(
114+
jsonResponse({ error: { code: 403, errors: [{ reason: 'quotaExceeded' }] } }, 403)
115+
)
116+
)
117+
118+
await expect(validateYouTubeServiceAccount({ apiToken: 'AIza-valid' })).resolves.toBeTruthy()
119+
})
120+
121+
it('rejects a YouTube key whose project has the API turned off', async () => {
122+
vi.stubGlobal(
123+
'fetch',
124+
vi
125+
.fn()
126+
.mockResolvedValue(
127+
jsonResponse({ error: { code: 403, errors: [{ reason: 'accessNotConfigured' }] } }, 403)
128+
)
129+
)
130+
131+
await expect(validateYouTubeServiceAccount({ apiToken: 'AIza-off' })).rejects.toMatchObject({
132+
code: 'invalid_credentials',
133+
})
134+
})
135+
})

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

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,13 +225,17 @@ export const validateDubServiceAccount = tokenCheckValidator({
225225
forbiddenMeansScoped: true,
226226
})
227227

228-
/** ElevenLabs returns the key owner's account for a valid key and 401 otherwise. */
228+
/**
229+
* ElevenLabs returns the key owner's account for a valid key and 400/401 for a bad one. A key
230+
* restricted to other endpoints gets 403, which still proves it authenticated.
231+
*/
229232
export const validateElevenLabsServiceAccount = tokenCheckValidator({
230233
serviceLabel: 'ElevenLabs',
231234
request: ({ apiToken }) => ({
232235
url: 'https://api.elevenlabs.io/v1/user',
233236
headers: { 'xi-api-key': apiToken, Accept: 'application/json' },
234237
}),
238+
forbiddenMeansScoped: true,
235239
})
236240

237241
/** Enrich's credit-balance endpoint costs no credits and succeeds only for a valid key. */
@@ -355,13 +359,17 @@ export const validateIncidentioServiceAccount = tokenCheckValidator({
355359
}),
356360
})
357361

358-
/** Instantly's campaign list (the one the Instantly tools call) answers 200 for a valid key and 401 otherwise. */
362+
/**
363+
* Instantly's campaign list (the one the Instantly tools call) answers 200 for a valid key and
364+
* 401 for a bad one; a valid key without the campaigns scope gets 403.
365+
*/
359366
export const validateInstantlyServiceAccount = tokenCheckValidator({
360367
serviceLabel: 'Instantly',
361368
request: ({ apiToken }) => ({
362369
url: 'https://api.instantly.ai/api/v2/campaigns?limit=1',
363370
headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' },
364371
}),
372+
forbiddenMeansScoped: true,
365373
})
366374

367375
/** Intercom's identify-admin endpoint answers 200 with the token's admin and 401 to an unknown token. */
@@ -597,22 +605,30 @@ export const validateSportmonksServiceAccount = tokenCheckValidator({
597605
}),
598606
})
599607

600-
/** Square's list-locations read answers 200 for a valid production access token and 401 otherwise. */
608+
/**
609+
* Square's list-locations read answers 200 for a valid production access token and 401 for a bad
610+
* one; a token without the merchant-profile scope gets 403.
611+
*/
601612
export const validateSquareServiceAccount = tokenCheckValidator({
602613
serviceLabel: 'Square',
603614
request: ({ apiToken }) => ({
604615
url: 'https://connect.squareup.com/v2/locations',
605616
headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' },
606617
}),
618+
forbiddenMeansScoped: true,
607619
})
608620

609-
/** Listing devices in the token's default tailnet (`-`) answers 200 for a valid token and 401 otherwise. */
621+
/**
622+
* Listing devices in the token's default tailnet (`-`) answers 200 for a valid token and 401 for a
623+
* bad one; an OAuth token without the devices scope gets 403.
624+
*/
610625
export const validateTailscaleServiceAccount = tokenCheckValidator({
611626
serviceLabel: 'Tailscale',
612627
request: ({ apiToken }) => ({
613628
url: 'https://api.tailscale.com/api/v2/tailnet/-/devices',
614629
headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' },
615630
}),
631+
forbiddenMeansScoped: true,
616632
})
617633

618634
/** Tavily's usage endpoint answers 200 for a valid key and 401 otherwise. */
@@ -651,13 +667,28 @@ export const validateYouComServiceAccount = tokenCheckValidator({
651667
}),
652668
})
653669

654-
/** Listing video categories costs one quota unit; Google answers 400/403 for an invalid or restricted key. */
670+
/** Google's 403 reasons that a valid key gets when it is only out of quota for now. */
671+
const YOUTUBE_QUOTA_REASONS = new Set(['quotaExceeded', 'dailyLimitExceeded', 'rateLimitExceeded'])
672+
673+
/**
674+
* Listing video categories costs one quota unit. Google answers 400 for an invalid key and 403
675+
* for a restricted one or a project without the YouTube API; a 403 for spent quota still proves
676+
* the key, so only that one is accepted.
677+
*/
655678
export const validateYouTubeServiceAccount = tokenCheckValidator({
656679
serviceLabel: 'YouTube',
657680
request: ({ apiToken }) => ({
658681
url: `https://www.googleapis.com/youtube/v3/videoCategories?part=snippet&regionCode=US&key=${encodeURIComponent(apiToken)}`,
659682
headers: { Accept: 'application/json' },
660683
}),
684+
forbiddenMeansScoped: true,
685+
rejectsKey: (errorBody) => {
686+
const error = toRecord(toRecord(errorBody).error)
687+
if (error.code !== 400 && error.code !== 403) return false
688+
return !toArray(error.errors).some((entry) =>
689+
YOUTUBE_QUOTA_REASONS.has(String(toRecord(entry).reason))
690+
)
691+
},
661692
})
662693

663694
/** Listing one thread needs a valid project key; Zep answers 401 for any other. */

‎apps/sim/lib/credentials/workload-identity/issuer.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@ describe('workload identity issuer', () => {
101101
expect((await workloadIdentityJwksResponse()).status).toBe(404)
102102
})
103103

104+
it('keeps signing with the current key when the retiring key is malformed', async () => {
105+
setEnv('WORKLOAD_IDENTITY_PREVIOUS_KEY', 'not a PEM key')
106+
const issuer = await loadIssuer()
107+
const token = await issuer.mintWorkloadIdentityToken({
108+
subject: 'sim:workspace:ws-1:credential:abc123def456',
109+
audience: 'sts.amazonaws.com',
110+
owner: { workspaceId: 'ws-1' },
111+
})
112+
const jwks = await issuer.getWorkloadIdentityJwks()
113+
114+
expect(await issuer.isWorkloadIdentityConfigured()).toBe(true)
115+
await expect(jwtVerify(token, createLocalJWKSet(jwks))).resolves.toBeTruthy()
116+
})
117+
104118
it('publishes nothing and refuses to sign when no signing key is configured', async () => {
105119
setEnv('WORKLOAD_IDENTITY_SIGNING_KEY', undefined)
106120
const issuer = await loadIssuer()

‎apps/sim/lib/credentials/workload-identity/issuer.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ async function toPublicJwk(key: KeyObject): Promise<JWK> {
5555
return { ...exported, kid, alg: WORKLOAD_IDENTITY_ALGORITHM, use: 'sig' }
5656
}
5757

58+
/**
59+
* The retiring key's public JWK, or null when it is unset or unusable. A bad
60+
* retiring key only drops that key from the JWKS; the current key keeps signing.
61+
*/
62+
async function loadPreviousPublicJwk(): Promise<JWK | null> {
63+
const previousKey = env.WORKLOAD_IDENTITY_PREVIOUS_KEY
64+
if (!previousKey) return null
65+
try {
66+
return await toPublicJwk(createPublicKey(normalizePem(previousKey)))
67+
} catch (error) {
68+
logger.error('Workload identity previous key is set but unusable', { error })
69+
return null
70+
}
71+
}
72+
5873
let signingMaterial: Promise<SigningMaterial> | undefined
5974

6075
/**
@@ -70,11 +85,8 @@ function loadSigningMaterial(): Promise<SigningMaterial> {
7085
const privateKey = createPrivateKey(normalizePem(signingKey))
7186
const current = await toPublicJwk(privateKey)
7287
const keys = [current]
73-
const previousKey = env.WORKLOAD_IDENTITY_PREVIOUS_KEY
74-
if (previousKey) {
75-
const previous = await toPublicJwk(createPublicKey(normalizePem(previousKey)))
76-
if (previous.kid !== current.kid) keys.push(previous)
77-
}
88+
const previous = await loadPreviousPublicJwk()
89+
if (previous && previous.kid !== current.kid) keys.push(previous)
7890
return { privateKey, kid: current.kid as string, jwks: { keys } }
7991
})()
8092
signingMaterial.catch((error: unknown) => {

‎apps/sim/lib/oauth/token-resolution.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,11 @@ export async function resolveCredentialToken(
435435
}
436436
}
437437
}
438+
// Its messages name the trust, secret, or host problem and carry no secrets. Matched by
439+
// name because the Databricks module is loaded lazily, off this module's graph.
440+
if (error instanceof Error && error.name === 'DatabricksCredentialError') {
441+
return { ok: false, status: 401, error: error.message }
442+
}
438443
return { ok: false, status: 401, error: 'Failed to get service account token' }
439444
}
440445
}

0 commit comments

Comments
 (0)