Skip to content

Commit a635fdf

Browse files
committed
fix(credentials): close wave 4 review findings on keyless Google Cloud credentials
- Drop the tokeninfo check: a token from generateAccessToken already proves the provider accepted Sim's token and the impersonation binding, and tokeninfo puts a live token in a query string. - Test that saving fails closed when the stray-subject pin probe hits an STS outage or an unreachable IAM endpoint. - Pass --project=<project number> on all three gcloud setup commands, drop the unused Resource Manager API from the docs, and raise the shared validation error for an empty scope list.
1 parent 41caf90 commit a635fdf

5 files changed

Lines changed: 57 additions & 48 deletions

File tree

‎apps/docs/content/docs/integrations/google-service-account.mdx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ Keyless credentials work with Google Cloud APIs that the service account calls a
185185

186186
<Steps>
187187
<Step>
188-
In the [Google Cloud Console](https://console.cloud.google.com/), enable the IAM, Resource Manager, Service Account Credentials, and Security Token Service APIs in the project that will hold the workload identity pool, and note its **project number** (on the project's Dashboard; it's numeric, not the project ID).
188+
In the [Google Cloud Console](https://console.cloud.google.com/), enable the IAM, Service Account Credentials, and Security Token Service APIs in the project that will hold the workload identity pool, and note its **project number** (on the project's Dashboard; it's numeric, not the project ID).
189189
</Step>
190190
<Step>
191191
Create or pick a service account, and grant it the roles your workflows need (for example, BigQuery Data Viewer and BigQuery Job User, or Vertex AI User). No key is needed.
@@ -194,29 +194,32 @@ Keyless credentials work with Google Cloud APIs that the service account calls a
194194
In Sim, open the **Google BigQuery** integration, choose **Add service account**, and set **Authentication** to **Workload Identity Federation (keyless, via Sim OIDC)**. Enter the project number, a pool ID and provider ID (4 to 32 lowercase letters, digits, or hyphens, not starting with `gcp-`), and the service account email (`name@project-id.iam.gserviceaccount.com`).
195195
</Step>
196196
<Step>
197-
The dialog shows the **Issuer**, **Audience**, and **Subject**, and three `gcloud` commands with your values filled in. Run them in that project. They create the pool, an OIDC provider for Sim's issuer with the attribute mapping `google.subject=assertion.sub` and an attribute condition that accepts only this credential's subject, and grant that subject `roles/iam.workloadIdentityUser` on the service account:
197+
The dialog shows the **Issuer**, **Audience**, and **Subject**, and three `gcloud` commands with your values filled in. Run them in that project. They run against the project number you entered and create the pool, an OIDC provider for Sim's issuer with the attribute mapping `google.subject=assertion.sub` and an attribute condition that accepts only this credential's subject, and grant that subject `roles/iam.workloadIdentityUser` on the service account:
198198

199199
```bash
200200
gcloud iam workload-identity-pools create POOL_ID \
201+
--project="PROJECT_NUMBER" \
201202
--location="global" \
202203
--display-name="Sim"
203204

204205
gcloud iam workload-identity-pools providers create-oidc PROVIDER_ID \
206+
--project="PROJECT_NUMBER" \
205207
--location="global" \
206208
--workload-identity-pool="POOL_ID" \
207209
--issuer-uri="ISSUER" \
208210
--attribute-mapping="google.subject=assertion.sub" \
209211
--attribute-condition="assertion.sub=='SUBJECT'"
210212

211213
gcloud iam service-accounts add-iam-policy-binding SERVICE_ACCOUNT_EMAIL \
214+
--project="PROJECT_NUMBER" \
212215
--role="roles/iam.workloadIdentityUser" \
213216
--member="principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/subject/SUBJECT"
214217
```
215218

216219
The provider sets no allowed audiences, so it accepts its own resource name, which is the **Audience** the dialog shows.
217220
</Step>
218221
<Step>
219-
Save in Sim. Sim exchanges a token at STS, impersonates the service account, and confirms Google accepts the resulting token. It also checks that a subject no credential holds can't impersonate the service account, so only this credential can use it. New IAM bindings can take a few minutes to apply; if saving fails right after you run the commands, wait and try again.
222+
Save in Sim. Sim exchanges a token at STS and impersonates the service account; a token from that step proves the setup works. It also checks that a subject no credential holds can't impersonate the service account, so only this credential can use it. New IAM bindings can take a few minutes to apply; if saving fails right after you run the commands, wait and try again.
220223
</Step>
221224
</Steps>
222225

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/google-keyless-fields.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export function GoogleKeylessFields({ input, onChange, setup }: GoogleKeylessFie
125125
<ChipModalField
126126
type='custom'
127127
title='Setup commands'
128-
hint='Run in the project above. They create the pool and an OIDC provider that accepts only this credential’s subject, then let that subject impersonate the service account.'
128+
hint='They run in the project above and create the pool and an OIDC provider that accepts only this credential’s subject, then let that subject impersonate the service account.'
129129
>
130130
{(aria) => (
131131
<ChipTextarea

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

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ interface RecordedRequest {
5454
}
5555

5656
/**
57-
* A fake Google STS, IAM Credentials, and tokeninfo. STS accepts a Sim token
57+
* A fake Google STS and IAM Credentials. STS accepts a Sim token
5858
* only for the provider's own audience and for subjects `acceptSubject`
5959
* allows, and returns a federated token naming the subject; IAM lets the
6060
* subjects `canImpersonate` allows impersonate the service account.
@@ -114,9 +114,6 @@ function fakeGoogle(
114114
expireTime: new Date(Date.now() + 3600_000).toISOString(),
115115
})
116116
}
117-
if (url.startsWith('https://oauth2.googleapis.com/tokeninfo?')) {
118-
return jsonResponse({ scope: CLOUD_PLATFORM, expires_in: 3599 })
119-
}
120117
return jsonResponse({}, 404)
121118
})
122119
return { sts, iam }
@@ -213,6 +210,35 @@ describe('saving a keyless Google Cloud credential', () => {
213210
expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled()
214211
})
215212

213+
it.each([
214+
{
215+
label: 'STS is unavailable for the stray subject',
216+
fail: (url: string, body: string) =>
217+
url.startsWith('https://sts.') && !body.includes(SUBJECT)
218+
? jsonResponse({ error: 'temporarily_unavailable' }, 503)
219+
: undefined,
220+
},
221+
{
222+
label: 'IAM is unreachable for the stray subject',
223+
fail: (url: string, _body: string, auth: string) => {
224+
if (url.startsWith('https://iamcredentials.') && auth !== `Bearer fed:${SUBJECT}`) {
225+
throw new TypeError('fetch failed')
226+
}
227+
return undefined
228+
},
229+
},
230+
])('refuses to save when it cannot confirm the subject is pinned: $label', async ({ fail }) => {
231+
fakeGoogle(fetch, { canImpersonate: (subject) => subject === SUBJECT })
232+
const google = fetch.getMockImplementation()
233+
fetch.mockImplementation(async (url: string, init: RequestInit = {}) => {
234+
const auth = new Headers(init.headers).get('authorization') ?? ''
235+
return fail(url, String(init.body ?? ''), auth) ?? google?.(url, init)
236+
})
237+
238+
await expect(save({})).rejects.toThrow(/could not confirm this service account trusts only/)
239+
expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled()
240+
})
241+
216242
it('saves when the provider’s attribute condition refuses every other subject', async () => {
217243
const google = fakeGoogle(fetch, { acceptSubject: (subject) => subject === SUBJECT })
218244

@@ -344,6 +370,15 @@ describe('getting a keyless Google Cloud token for a run', () => {
344370
}
345371
)
346372

373+
it('refuses a request with no scopes in the shared validation shape', async () => {
374+
fakeGoogle(fetch)
375+
376+
await expect(resolve({ credentialId: 'cred-no-scopes', scopes: [] })).rejects.toMatchObject({
377+
code: 'invalid_credentials',
378+
})
379+
expect(fetch).not.toHaveBeenCalled()
380+
})
381+
347382
it('reuses a minted token until the stored secret changes', async () => {
348383
const google = fakeGoogle(fetch)
349384
await resolve({ credentialId: 'cred-cache' })

‎apps/sim/lib/credentials/google-workload-identity.server.ts‎

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ const SUBMITTED_AUTH_TYPE = 'workload_identity' as const
5252

5353
const STS_TOKEN_URL = 'https://sts.googleapis.com/v1/token'
5454
const IAM_CREDENTIALS_ORIGIN = 'https://iamcredentials.googleapis.com'
55-
const TOKENINFO_URL = 'https://oauth2.googleapis.com/tokeninfo'
5655
const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'
5756
const TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'
5857
const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'
@@ -284,38 +283,6 @@ async function mintServiceAccountToken(
284283
return impersonateServiceAccount(config, federatedToken, scopes)
285284
}
286285

287-
/**
288-
* Asks Google's tokeninfo endpoint about the impersonated token. It needs no
289-
* IAM role and no enabled API, unlike a Resource Manager or BigQuery read, so
290-
* it works for a service account that only holds BigQuery or Vertex AI roles.
291-
* @see https://docs.cloud.google.com/docs/authentication/token-types
292-
*/
293-
async function confirmTokenAccepted(accessToken: string): Promise<void> {
294-
let response: Response
295-
try {
296-
response = await fetch(`${TOKENINFO_URL}?access_token=${encodeURIComponent(accessToken)}`, {
297-
headers: { Accept: 'application/json' },
298-
redirect: 'error',
299-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
300-
})
301-
} catch {
302-
throw new GoogleWorkloadIdentityError('Could not reach Google Cloud. Try again.')
303-
}
304-
const info = (await response.json().catch(() => ({}))) as { scope?: unknown }
305-
if (
306-
response.status !== 200 ||
307-
typeof info.scope !== 'string' ||
308-
!info.scope.split(' ').includes(CLOUD_PLATFORM_SCOPE)
309-
) {
310-
throw new GoogleWorkloadIdentityError(
311-
isProviderOutage(response.status)
312-
? 'Google Cloud is unavailable. Try again.'
313-
: 'Google Cloud did not accept the service account token it issued. Try again.',
314-
response.status
315-
)
316-
}
317-
}
318-
319286
function isRefusal(error: unknown): boolean {
320287
return (
321288
error instanceof GoogleWorkloadIdentityError &&
@@ -385,8 +352,7 @@ export interface GoogleWorkloadIdentitySaveResult {
385352
/**
386353
* Validates the submitted configuration, then proves it end to end: Sim's
387354
* token is exchanged at STS, the federated token impersonates the service
388-
* account, and Google accepts the resulting token. Alongside, a subject no
389-
* credential holds must be refused. Returns the encrypted blob.
355+
* account. Alongside, a subject no credential holds must be refused. Returns the encrypted blob.
390356
*/
391357
export async function verifyAndEncryptGoogleWorkloadIdentityCredential(
392358
serialized: string,
@@ -415,10 +381,9 @@ export async function verifyAndEncryptGoogleWorkloadIdentityCredential(
415381
throw new GoogleWorkloadIdentityError('This credential’s subject is too long for Google Cloud.')
416382
}
417383

418-
const verify = async () => {
419-
const token = await mintServiceAccountToken(config, owner, [CLOUD_PLATFORM_SCOPE])
420-
await confirmTokenAccepted(token.accessToken)
421-
}
384+
// A token from generateAccessToken proves the provider accepted Sim's token and the
385+
// service account lets this subject impersonate it.
386+
const verify = () => mintServiceAccountToken(config, owner, [CLOUD_PLATFORM_SCOPE])
422387
// The pin probe is independent of the verification, so both run at once; a
423388
// verification failure still reports first.
424389
const [verified, pinned] = await Promise.allSettled([
@@ -485,7 +450,10 @@ export async function resolveGoogleWorkloadIdentityToken(
485450
})
486451
}
487452
if (input.scopes.length === 0) {
488-
throw new Error('Scopes are required for service account credentials')
453+
throw new TokenServiceAccountValidationError('invalid_credentials', 400, {
454+
step: 'google_keyless_scopes',
455+
reason: 'scopes are required for service account credentials',
456+
})
489457
}
490458
const scopeKey = [...new Set(input.scopes)].sort().join(' ')
491459
const cacheKey = `${input.credentialId}:${input.secretVersion}:${scopeKey}`

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,17 +130,20 @@ export function googleWorkloadIdentitySetupCommands(input: {
130130
const { ref, serviceAccountEmail, issuer, subject } = input
131131
return [
132132
`gcloud iam workload-identity-pools create ${ref.poolId} \\`,
133+
` --project="${ref.projectNumber}" \\`,
133134
' --location="global" \\',
134135
' --display-name="Sim"',
135136
'',
136137
`gcloud iam workload-identity-pools providers create-oidc ${ref.providerId} \\`,
138+
` --project="${ref.projectNumber}" \\`,
137139
' --location="global" \\',
138140
` --workload-identity-pool="${ref.poolId}" \\`,
139141
` --issuer-uri="${issuer}" \\`,
140142
' --attribute-mapping="google.subject=assertion.sub" \\',
141143
` --attribute-condition="assertion.sub=='${subject}'"`,
142144
'',
143145
`gcloud iam service-accounts add-iam-policy-binding ${serviceAccountEmail} \\`,
146+
` --project="${ref.projectNumber}" \\`,
144147
' --role="roles/iam.workloadIdentityUser" \\',
145148
` --member="${googleWorkloadIdentityPrincipal(ref, subject)}"`,
146149
].join('\n')

0 commit comments

Comments
 (0)