>
+
export const workloadIdentitySubjectGrantShape = {
issuer: z
.string()
@@ -613,11 +620,16 @@ export const prepareWorkloadIdentityContract = defineRouteContract({
mode: 'json',
schema: z.discriminatedUnion('available', [
/** This deployment has no workload-identity signing key; offer key-based modes only. */
- z.object({ available: z.literal(false), reconnectAuthType: reconnectAuthTypeSchema }),
+ z.object({
+ available: z.literal(false),
+ reconnectAuthType: reconnectAuthTypeSchema,
+ reconnectDelegation: reconnectDelegationSchema,
+ }),
z.object({
available: z.literal(true),
...workloadIdentitySubjectGrantShape,
reconnectAuthType: reconnectAuthTypeSchema,
+ reconnectDelegation: reconnectDelegationSchema,
}),
]),
},
diff --git a/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts b/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts
index 9d6f5799f2b..9f601797ae8 100644
--- a/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts
+++ b/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts
@@ -240,6 +240,27 @@ describe('preparing a reconnect', () => {
expect(await prepare('google')).not.toHaveProperty('audience')
})
+ it('reports a keyless Google credential’s delegation so a reconnect keeps it', async () => {
+ reconnecting(
+ {
+ type: 'google_workload_identity_v1',
+ config: {
+ projectNumber: '123456789012',
+ poolId: 'sim-pool',
+ providerId: 'sim-oidc',
+ serviceAccountEmail: 'runner@analytics-prod.iam.gserviceaccount.com',
+ subject,
+ delegation: { user: 'alice@example.com', services: ['gmail', 'google-calendar'] },
+ },
+ },
+ { providerId: 'google-service-account' }
+ )
+ await expect(prepare('google')).resolves.toMatchObject({
+ reconnectAuthType: 'workload_identity',
+ reconnectDelegation: { user: 'alice@example.com', services: ['gmail', 'google-calendar'] },
+ })
+ })
+
it('reveals nothing about a credential the caller does not administer', async () => {
reconnecting(keylessSecret, { isAdmin: false })
expect(await prepare()).not.toHaveProperty('reconnectAuthType')
diff --git a/apps/sim/lib/credentials/application/prepare-workload-identity.ts b/apps/sim/lib/credentials/application/prepare-workload-identity.ts
index acf280b864e..8a2f10f7372 100644
--- a/apps/sim/lib/credentials/application/prepare-workload-identity.ts
+++ b/apps/sim/lib/credentials/application/prepare-workload-identity.ts
@@ -7,6 +7,10 @@ import { credentialOperations } from '@/lib/credentials/application/operations'
import { storedAwsAuthType } from '@/lib/credentials/aws-service-account.server'
import { storedDatabricksAuthType } from '@/lib/credentials/databricks-service-account.server'
import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity'
+import {
+ type GoogleStoredDelegation,
+ storedGoogleDelegation,
+} from '@/lib/credentials/google-workload-identity.server'
import { storedMicrosoftAppAuthType } from '@/lib/credentials/microsoft-app-service-account.server'
import { storedSnowflakeAuthType } from '@/lib/credentials/snowflake-service-account.server'
import {
@@ -30,13 +34,19 @@ export interface PrepareWorkloadIdentityInput {
credentialId?: string
}
-/** The reconnected credential's stored mode; absent without one, or for anyone but its admin. */
-interface ReconnectAuthTypeField {
- reconnectAuthType?: WorkloadIdentityProviderAuthType
+/**
+ * What a reconnect reopens on, read from the stored secret: absent without a
+ * credential being reconnected, or for anyone but its admin.
+ */
+interface ReconnectFields {
+ /** The stored mode. */
+ reconnectAuthType?: WorkloadIdentityProviderAuthType
+ /** A keyless Google credential's domain-wide delegation, so a reconnect keeps it. */
+ reconnectDelegation?: GoogleStoredDelegation
}
export type PrepareWorkloadIdentityResult =
- | ({ available: false } & ReconnectAuthTypeField)
+ | ({ available: false } & ReconnectFields)
| ({
available: true
issuer: string
@@ -45,37 +55,53 @@ export type PrepareWorkloadIdentityResult =
subject: string
subjectProof: string
expiresAt: string
- } & ReconnectAuthTypeField)
+ } & ReconnectFields)
+
+function authTypeOnly
(
+ read: (decrypted: string) => WorkloadIdentityProviderAuthType
| undefined
+): (decrypted: string) => ReconnectFields
{
+ return (decrypted) => {
+ const reconnectAuthType = read(decrypted)
+ return reconnectAuthType ? { reconnectAuthType } : {}
+ }
+}
+
+function storedGoogleReconnect(decrypted: string): ReconnectFields<'google'> {
+ const reconnectDelegation = storedGoogleDelegation(decrypted)
+ return {
+ reconnectAuthType: isGoogleWorkloadIdentitySecret(decrypted) ? 'workload_identity' : 'json_key',
+ ...(reconnectDelegation ? { reconnectDelegation } : {}),
+ }
+}
/**
* Every keyless provider also has key-based modes under the same provider ID, so a
* reconnect reads the stored secret's mode and the dialog reopens on it rather than
- * on a default that would drop the credential's role, secret, or certificate.
+ * on a default that would drop the credential's role, secret, or certificate. A
+ * provider whose mode has further non-secret settings a reconnect must keep (a
+ * Google credential's domain-wide delegation) reads those here too.
*/
-const STORED_AUTH_TYPE: {
- [P in WorkloadIdentityProvider]: (
- decrypted: string
- ) => WorkloadIdentityProviderAuthType
| undefined
+const STORED_RECONNECT: {
+ [P in WorkloadIdentityProvider]: (decrypted: string) => ReconnectFields
} = {
- aws: storedAwsAuthType,
- databricks: storedDatabricksAuthType,
- snowflake: storedSnowflakeAuthType,
- microsoft: storedMicrosoftAppAuthType,
- google: (decrypted) =>
- isGoogleWorkloadIdentitySecret(decrypted) ? 'workload_identity' : 'json_key',
+ aws: authTypeOnly<'aws'>(storedAwsAuthType),
+ databricks: authTypeOnly<'databricks'>(storedDatabricksAuthType),
+ snowflake: authTypeOnly<'snowflake'>(storedSnowflakeAuthType),
+ microsoft: authTypeOnly<'microsoft'>(storedMicrosoftAppAuthType),
+ google: storedGoogleReconnect,
}
/**
- * The stored auth mode of a credential of this provider in this workspace, which
- * this person administers. Only the mode leaves the stored secret, and only for
- * someone allowed to reconnect the credential.
+ * What a reconnect of this provider's credential in this workspace, which this
+ * person administers, reopens on. Only these non-secret fields leave the stored
+ * secret, and only for someone allowed to reconnect the credential.
*/
-async function storedReconnectAuthType(
+async function storedReconnectFields(
provider: WorkloadIdentityProvider,
credentialId: string,
workspaceId: string,
userId: string
-): Promise {
+): Promise {
const access = await getCredentialActorContext(credentialId, userId, { workspaceId })
const row = access.credential
if (
@@ -87,10 +113,10 @@ async function storedReconnectAuthType(
workloadIdentityProviderFor(row.providerId) !== provider ||
!row.encryptedServiceAccountKey
) {
- return undefined
+ return {}
}
const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey)
- return STORED_AUTH_TYPE[provider](decrypted)
+ return STORED_RECONNECT[provider](decrypted)
}
/**
@@ -110,15 +136,14 @@ export const prepareWorkloadIdentity = defineAuthorizedWorkspaceUseCase({
},
authorizationOptions: {},
execute: async ({ principal, input }): Promise => {
- const reconnectAuthType = input.credentialId
- ? await storedReconnectAuthType(
+ const reconnect = input.credentialId
+ ? await storedReconnectFields(
input.provider,
input.credentialId,
input.workspaceId,
requirePrincipalSubjectUserId(principal)
)
- : undefined
- const reconnect = reconnectAuthType ? { reconnectAuthType } : {}
+ : {}
// A deployment without a signing key offers only key- and secret-based credentials.
if (!(await isWorkloadIdentityConfigured())) return { available: false, ...reconnect }
const userId = requirePrincipalSubjectUserId(principal)
diff --git a/apps/sim/lib/credentials/google-workload-identity.server.test.ts b/apps/sim/lib/credentials/google-workload-identity.server.test.ts
index 88f8b8d8ca4..abb3a5e775c 100644
--- a/apps/sim/lib/credentials/google-workload-identity.server.test.ts
+++ b/apps/sim/lib/credentials/google-workload-identity.server.test.ts
@@ -32,6 +32,24 @@ const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789'
const IMPERSONATE_URL = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(SA_EMAIL)}:generateAccessToken`
const CLOUD_PLATFORM = 'https://www.googleapis.com/auth/cloud-platform'
const BIGQUERY = 'https://www.googleapis.com/auth/bigquery'
+const SIGN_JWT_URL = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(SA_EMAIL)}:signJwt`
+const GMAIL_SCOPES = [
+ 'https://www.googleapis.com/auth/gmail.send',
+ 'https://www.googleapis.com/auth/gmail.modify',
+ 'https://www.googleapis.com/auth/gmail.labels',
+]
+const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar'
+const DRIVE_SCOPES = [
+ 'https://www.googleapis.com/auth/drive.file',
+ 'https://www.googleapis.com/auth/drive',
+]
+const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive'
+const DRIVE_READONLY = 'https://www.googleapis.com/auth/drive.readonly'
+const GMAIL_PROFILE = 'https://gmail.googleapis.com/gmail/v1/users/me/profile'
+const CALENDAR_LIST = 'https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1'
+const DRIVE_ABOUT = 'https://www.googleapis.com/drive/v3/about?fields=user'
+const WORKSPACE_READS = [GMAIL_PROFILE, CALENDAR_LIST, DRIVE_ABOUT]
+const WORKSPACE_USER = 'alice@example.com'
const owner = { workspaceId: 'ws-1', actorUserId: 'user-1' }
const storedConfig = {
@@ -64,19 +82,65 @@ function fakeGoogle(
options: {
acceptSubject?: (subject: string) => boolean
canImpersonate?: (subject: string) => boolean
+ /** Who may call signJwt; defaults to `canImpersonate`. */
+ canSign?: (subject: string) => boolean
stsError?: Response
iamError?: Response
+ /** Answers the OAuth token endpoint instead of issuing a delegated token. */
+ tokenError?: Response
+ /** Answers the OAuth token endpoint's first request only. */
+ firstTokenError?: Response
+ /** Answers a Workspace read instead of 200. */
+ readError?: Response
} = {}
) {
const sts: RecordedRequest[] = []
const iam: RecordedRequest[] = []
+ const signJwt: RecordedRequest[] = []
+ const token: RecordedRequest[] = []
+ const reads: RecordedRequest[] = []
fetch.mockImplementation(async (url: string, init: RequestInit = {}) => {
+ const headers = Object.fromEntries(new Headers(init.headers).entries())
const record = () => ({
url,
- headers: Object.fromEntries(new Headers(init.headers).entries()),
- body: init.body ? JSON.parse(String(init.body)) : {},
+ headers,
+ body:
+ headers['content-type'] === 'application/x-www-form-urlencoded'
+ ? Object.fromEntries(new URLSearchParams(String(init.body)))
+ : init.body
+ ? JSON.parse(String(init.body))
+ : {},
redirect: init.redirect,
})
+ if (url.endsWith(':signJwt')) {
+ const request = record()
+ signJwt.push(request)
+ const subject = (request.headers.authorization ?? '').replace('Bearer fed:', '')
+ const canSign = options.canSign ?? options.canImpersonate
+ if (canSign && !canSign(subject)) {
+ return jsonResponse(
+ { error: { code: 403, status: 'PERMISSION_DENIED', message: 'denied' } },
+ 403
+ )
+ }
+ return jsonResponse({ keyId: 'key-1', signedJwt: `signed|${request.body.payload}` })
+ }
+ if (url === 'https://oauth2.googleapis.com/token') {
+ const request = record()
+ token.push(request)
+ if (options.tokenError) return options.tokenError.clone()
+ if (options.firstTokenError && token.length === 1) return options.firstTokenError.clone()
+ const claims = JSON.parse(String(request.body.assertion).replace('signed|', ''))
+ return jsonResponse({
+ access_token: `ya29.dwd-${claims.sub}-${claims.scope}`,
+ expires_in: 3599,
+ token_type: 'Bearer',
+ })
+ }
+ if (WORKSPACE_READS.some((read) => url === read)) {
+ reads.push(record())
+ return options.readError ? options.readError.clone() : jsonResponse({})
+ }
if (url === 'https://sts.googleapis.com/v1/token') {
const request = record()
sts.push(request)
@@ -116,7 +180,7 @@ function fakeGoogle(
}
return jsonResponse({}, 404)
})
- return { sts, iam }
+ return { sts, iam, signJwt, token, reads }
}
function save(
@@ -401,3 +465,552 @@ describe('getting a keyless Google Cloud token for a run', () => {
})
})
})
+
+/**
+ * Settles a resolution whose token exchange waits between retries, advancing
+ * fake timers until it does.
+ */
+async function settleRetrying(promise: Promise): Promise<{ value?: T; error?: unknown }> {
+ let outcome: { value?: T; error?: unknown } | undefined
+ promise.then(
+ (value) => {
+ outcome = { value }
+ },
+ (error: unknown) => {
+ outcome = { error }
+ }
+ )
+ for (let step = 0; !outcome && step < 200; step++) await vi.advanceTimersByTimeAsync(250)
+ if (!outcome) throw new Error('The resolution never settled')
+ return outcome
+}
+
+function claimsOf(request: RecordedRequest | undefined): Record {
+ return JSON.parse(String(request?.body.payload))
+}
+
+describe('saving a keyless credential with domain-wide delegation', () => {
+ const fetch = vi.fn()
+ const delegation = { user: WORKSPACE_USER, services: ['gmail', 'google-calendar'] }
+
+ beforeEach(() => {
+ fetch.mockReset()
+ vi.stubGlobal('fetch', fetch)
+ workloadIdentityConfigured.value = true
+ encryptionMockFns.mockEncryptSecret.mockReset()
+ encryptionMockFns.mockEncryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' })
+ })
+
+ it('signs an assertion for the Workspace user per service, exchanges it, and reads as that user', async () => {
+ const google = fakeGoogle(fetch, { canImpersonate: (subject) => subject === SUBJECT })
+
+ await expect(
+ save({ delegation: { user: 'Alice@Example.com', services: ['google-calendar', 'gmail'] } })
+ ).resolves.toMatchObject({ serviceAccountEmail: SA_EMAIL, delegation })
+
+ const own = google.signJwt.filter((r) => r.headers.authorization === `Bearer fed:${SUBJECT}`)
+ expect(own.map((r) => r.url)).toEqual([SIGN_JWT_URL, SIGN_JWT_URL])
+ expect(own.every((r) => r.redirect === 'error')).toBe(true)
+ const [gmail, calendar] = own.map(claimsOf)
+ expect(gmail).toMatchObject({
+ iss: SA_EMAIL,
+ sub: WORKSPACE_USER,
+ aud: 'https://oauth2.googleapis.com/token',
+ scope: GMAIL_SCOPES.join(' '),
+ })
+ expect(calendar).toMatchObject({ sub: WORKSPACE_USER, scope: CALENDAR_SCOPE })
+ expect(Number(gmail.exp) - Number(gmail.iat)).toBe(3600)
+
+ expect(google.token).toHaveLength(2)
+ expect(google.token[0]).toMatchObject({
+ redirect: 'error',
+ body: {
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ assertion: `signed|${own[0].body.payload}`,
+ },
+ })
+ expect(google.reads.map((r) => [r.url, r.headers.authorization])).toEqual([
+ [GMAIL_PROFILE, `Bearer ya29.dwd-${WORKSPACE_USER}-${GMAIL_SCOPES.join(' ')}`],
+ [CALENDAR_LIST, `Bearer ya29.dwd-${WORKSPACE_USER}-${CALENDAR_SCOPE}`],
+ ])
+ expect(encryptionMockFns.mockEncryptSecret).toHaveBeenCalledWith(
+ JSON.stringify({
+ type: 'google_workload_identity_v1',
+ config: { ...storedConfig, delegation },
+ })
+ )
+ })
+
+ it('checks Drive, Docs, and Sheets, which share scopes and a read, once', async () => {
+ const google = fakeGoogle(fetch, { canImpersonate: (subject) => subject === SUBJECT })
+
+ await save({
+ delegation: {
+ user: WORKSPACE_USER,
+ services: ['google-drive', 'google-docs', 'google-sheets'],
+ },
+ })
+
+ const own = google.signJwt.filter((r) => r.headers.authorization === `Bearer fed:${SUBJECT}`)
+ expect(own.map((r) => claimsOf(r).scope)).toEqual([DRIVE_SCOPES.join(' ')])
+ expect(google.reads.map((r) => r.url)).toEqual([DRIVE_ABOUT])
+ })
+
+ it('names the token-creator role when the subject may impersonate but not sign', async () => {
+ fakeGoogle(fetch, {
+ canImpersonate: (subject) => subject === SUBJECT,
+ canSign: () => false,
+ })
+
+ await expect(save({ delegation })).rejects.toThrow(
+ `Grant roles/iam.serviceAccountTokenCreator on the service account to principal://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL}/subject/${SUBJECT}`
+ )
+ expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled()
+ })
+
+ it('refuses a service account another Sim subject can sign for', async () => {
+ fakeGoogle(fetch, {
+ canImpersonate: (subject) => subject === SUBJECT,
+ canSign: () => true,
+ })
+
+ await expect(save({ delegation })).rejects.toThrow(
+ /also trusts other Sim credentials.*roles\/iam\.serviceAccountTokenCreator/
+ )
+ expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled()
+ })
+
+ it('names the Admin console step and scopes when Workspace refuses the delegation', async () => {
+ const google = fakeGoogle(fetch, {
+ canImpersonate: (subject) => subject === SUBJECT,
+ tokenError: jsonResponse(
+ { error: 'unauthorized_client', error_description: 'Client is unauthorized ya29.LEAK' },
+ 401
+ ),
+ })
+
+ const error: Error = await save({ delegation }).catch((caught) => caught)
+ expect(error.message).toMatch(/domain-wide delegation/)
+ expect(error.message).toContain(GMAIL_SCOPES.join(','))
+ expect(error.message).toContain('(unauthorized_client)')
+ expect(error.message).not.toContain('LEAK')
+ expect(error.message).not.toContain('signed|')
+ expect(google.reads).toHaveLength(0)
+ expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled()
+ })
+
+ it('names the Admin console step when Workspace denies a scope', async () => {
+ fakeGoogle(fetch, {
+ canImpersonate: (subject) => subject === SUBJECT,
+ tokenError: jsonResponse({ error: 'access_denied' }, 403),
+ })
+
+ const error: Error = await save({ delegation }).catch((caught) => caught)
+ expect(error.message).toContain('Manage Domain Wide Delegation')
+ expect(error.message).toContain('(access_denied)')
+ })
+
+ it.each([
+ { code: 'disabled_client', copy: /service account .* is disabled/ },
+ { code: 'deleted_client', copy: /service account .* was deleted/ },
+ { code: 'admin_policy_enforced', copy: /admin policies/ },
+ { code: 'invalid_client', copy: /Google refused the delegated token/ },
+ { code: 'org_internal', copy: /Google refused the delegated token/ },
+ ])('does not send the admin to authorize scopes for $code', async ({ code, copy }) => {
+ fakeGoogle(fetch, {
+ canImpersonate: (subject) => subject === SUBJECT,
+ tokenError: jsonResponse({ error: code }, 401),
+ })
+
+ const error: Error = await save({ delegation }).catch((caught) => caught)
+ expect(error.message).toMatch(copy)
+ expect(error.message).toContain(`(${code})`)
+ expect(error.message).not.toContain('Manage Domain Wide Delegation')
+ })
+
+ it('names the API to enable when a delegated read is refused', async () => {
+ fakeGoogle(fetch, {
+ canImpersonate: (subject) => subject === SUBJECT,
+ readError: jsonResponse({ error: { code: 403, status: 'PERMISSION_DENIED' } }, 403),
+ })
+
+ await expect(save({ delegation })).rejects.toThrow(/Gmail API/)
+ expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ { user: 'not-an-email', services: ['gmail'] },
+ { user: `${'a'.repeat(250)}@example.com`, services: ['gmail'] },
+ { user: WORKSPACE_USER, services: [] },
+ { user: WORKSPACE_USER, services: ['google-forms'] },
+ { user: WORKSPACE_USER, services: ['google-bigquery'] },
+ { user: WORKSPACE_USER },
+ { services: ['gmail'] },
+ { user: WORKSPACE_USER, services: ['gmail'], scopes: ['https://mail.google.com/'] },
+ ])('refuses the delegation %o without contacting Google', async (invalid) => {
+ fakeGoogle(fetch)
+
+ await expect(save({ delegation: invalid })).rejects.toThrow()
+ expect(fetch).not.toHaveBeenCalled()
+ })
+})
+
+describe('getting a delegated Google Workspace token for a run', () => {
+ const fetch = vi.fn()
+ const secret = JSON.stringify({
+ type: 'google_workload_identity_v1',
+ config: { ...storedConfig, delegation: { user: WORKSPACE_USER, services: ['gmail'] } },
+ })
+
+ function resolve(overrides: Partial[0]>) {
+ return resolveGoogleWorkloadIdentityToken({
+ credentialId: 'dwd-cred',
+ decrypted: secret,
+ secretVersion: 'v1',
+ owner: { workspaceId: 'ws-1' },
+ scopes: GMAIL_SCOPES,
+ service: 'google-email',
+ ...overrides,
+ })
+ }
+
+ beforeEach(() => {
+ fetch.mockReset()
+ vi.stubGlobal('fetch', fetch)
+ })
+
+ it.each([
+ { label: 'a tool’s provider id', service: 'google-email', credentialId: 'dwd-tool' },
+ { label: 'a selector’s service id', service: 'gmail', credentialId: 'dwd-selector' },
+ ])('acts as the delegated user for $label', async ({ service, credentialId }) => {
+ const google = fakeGoogle(fetch)
+
+ await expect(resolve({ service, credentialId })).resolves.toBe(
+ `ya29.dwd-${WORKSPACE_USER}-${[...GMAIL_SCOPES].sort().join(' ')}`
+ )
+ expect(claimsOf(google.signJwt[0])).toMatchObject({ iss: SA_EMAIL, sub: WORKSPACE_USER })
+ expect(google.iam).toHaveLength(0)
+ expect(google.reads).toHaveLength(0)
+ })
+
+ it('accepts an Impersonated Account that names the delegated user in any case', async () => {
+ fakeGoogle(fetch)
+
+ await expect(
+ resolve({ credentialId: 'dwd-same-user', impersonateEmail: 'ALICE@example.com' })
+ ).resolves.toContain(WORKSPACE_USER)
+ })
+
+ it('accepts an Impersonated Account that names the delegated user with surrounding spaces', async () => {
+ fakeGoogle(fetch)
+
+ await expect(
+ resolve({ credentialId: 'dwd-spaced-user', impersonateEmail: ' alice@example.com ' })
+ ).resolves.toContain(WORKSPACE_USER)
+ })
+
+ it('serves the Gmail label picker with the Impersonated Account set, minting gmail.modify', async () => {
+ const google = fakeGoogle(fetch)
+ const gmailModify = 'https://www.googleapis.com/auth/gmail.modify'
+
+ await expect(
+ resolve({
+ credentialId: 'dwd-gmail-labels',
+ service: 'gmail',
+ impersonateEmail: WORKSPACE_USER,
+ scopes: ['https://www.googleapis.com/auth/gmail.readonly'],
+ })
+ ).resolves.toBe(`ya29.dwd-${WORKSPACE_USER}-${gmailModify}`)
+ expect(claimsOf(google.signJwt[0])).toMatchObject({ sub: WORKSPACE_USER, scope: gmailModify })
+ })
+
+ it('serves the Calendar connector’s calendar.events.readonly, minting calendar', async () => {
+ const google = fakeGoogle(fetch)
+ const calendarSecret = JSON.stringify({
+ type: 'google_workload_identity_v1',
+ config: {
+ ...storedConfig,
+ delegation: { user: WORKSPACE_USER, services: ['google-calendar'] },
+ },
+ })
+
+ await expect(
+ resolve({
+ credentialId: 'dwd-calendar-connector',
+ decrypted: calendarSecret,
+ service: 'google-calendar',
+ scopes: ['https://www.googleapis.com/auth/calendar.events.readonly'],
+ })
+ ).resolves.toBe(`ya29.dwd-${WORKSPACE_USER}-${CALENDAR_SCOPE}`)
+ expect(claimsOf(google.signJwt[0])).toMatchObject({
+ sub: WORKSPACE_USER,
+ scope: CALENDAR_SCOPE,
+ })
+ })
+
+ it('refuses another Workspace user before contacting Google', async () => {
+ fakeGoogle(fetch)
+
+ await expect(
+ resolve({ credentialId: 'dwd-other-user', impersonateEmail: 'bob@example.com' })
+ ).rejects.toMatchObject({ code: 'unsupported_use' })
+ expect(fetch).not.toHaveBeenCalled()
+ })
+
+ it.each(['google-forms', undefined])(
+ 'refuses %s, which keyless delegation does not reach, before contacting Google',
+ async (service) => {
+ fakeGoogle(fetch)
+
+ await expect(
+ resolve({ credentialId: `dwd-${service}`, service, scopes: GMAIL_SCOPES })
+ ).rejects.toMatchObject({ code: 'unsupported_use' })
+ expect(fetch).not.toHaveBeenCalled()
+ }
+ )
+
+ it.each(['google-calendar', 'google-drive'])(
+ 'refuses %s scopes a Gmail-only delegation does not authorize, before contacting Google',
+ async (service) => {
+ fakeGoogle(fetch)
+
+ await expect(
+ resolve({ credentialId: `dwd-${service}`, service, scopes: [CALENDAR_SCOPE] })
+ ).rejects.toMatchObject({ code: 'unsupported_use' })
+ expect(fetch).not.toHaveBeenCalled()
+ }
+ )
+
+ it('authorizes by scope, as Google does: a Sheets delegation serves a Docs block', async () => {
+ const google = fakeGoogle(fetch)
+ const sheetsOnly = JSON.stringify({
+ type: 'google_workload_identity_v1',
+ config: {
+ ...storedConfig,
+ delegation: { user: WORKSPACE_USER, services: ['google-sheets'] },
+ },
+ })
+
+ await expect(
+ resolve({
+ credentialId: 'dwd-sheets-for-docs',
+ decrypted: sheetsOnly,
+ service: 'google-docs',
+ scopes: DRIVE_SCOPES,
+ })
+ ).resolves.toContain(WORKSPACE_USER)
+ expect(google.signJwt).toHaveLength(1)
+ })
+
+ it('still mints a service account token for a Google Cloud service', async () => {
+ const google = fakeGoogle(fetch)
+
+ await expect(
+ resolve({ credentialId: 'dwd-bq', service: 'google-bigquery', scopes: [BIGQUERY] })
+ ).resolves.toBe(`ya29.sa-token-for-${BIGQUERY}`)
+ expect(google.signJwt).toHaveLength(0)
+ expect(google.iam[0]?.url).toBe(IMPERSONATE_URL)
+ })
+
+ it('reuses a delegated token per credential, user, and scopes', async () => {
+ const google = fakeGoogle(fetch)
+ await resolve({ credentialId: 'dwd-cache' })
+ await resolve({ credentialId: 'dwd-cache', service: 'gmail' })
+ expect(google.signJwt).toHaveLength(1)
+
+ await resolve({ credentialId: 'dwd-cache', scopes: [GMAIL_SCOPES[1]] })
+ expect(google.signJwt).toHaveLength(2)
+
+ await resolve({ credentialId: 'dwd-cache', secretVersion: 'v2' })
+ expect(google.signJwt).toHaveLength(3)
+ })
+
+ it('never answers a service account request with a delegated token of the same scopes', async () => {
+ fakeGoogle(fetch)
+ const delegated = await resolve({ credentialId: 'dwd-identity', scopes: [GMAIL_SCOPES[1]] })
+ const own = await resolve({
+ credentialId: 'dwd-identity',
+ service: 'vertex-ai',
+ scopes: [GMAIL_SCOPES[1]],
+ })
+
+ expect(delegated).toBe(`ya29.dwd-${WORKSPACE_USER}-${GMAIL_SCOPES[1]}`)
+ expect(own).toBe(`ya29.sa-token-for-${GMAIL_SCOPES[1]}`)
+ })
+
+ it.each([
+ { label: 'a Gmail metadata-only scope', service: 'google-email', scope: 'gmail.metadata' },
+ {
+ label: 'an admin-mode connector',
+ service: 'google-email',
+ scope: 'admin.directory.user.readonly',
+ },
+ { label: 'a scope outside Google Workspace', service: 'gmail', scope: 'cloud-platform' },
+ ])(
+ 'refuses $label whose scopes the delegation does not authorize, before contacting Google',
+ async ({ service, scope }) => {
+ fakeGoogle(fetch)
+
+ await expect(
+ resolve({
+ credentialId: `dwd-scope-${scope}`,
+ service,
+ scopes: [`https://www.googleapis.com/auth/${scope}`],
+ })
+ ).rejects.toMatchObject({ code: 'unsupported_use' })
+ expect(fetch).not.toHaveBeenCalled()
+ }
+ )
+
+ it('refuses a read-only scope whose read-write scope the delegation does not authorize', async () => {
+ fakeGoogle(fetch)
+
+ await expect(
+ resolve({
+ credentialId: 'dwd-gmail-drive-readonly',
+ service: 'google-drive',
+ scopes: [DRIVE_READONLY],
+ })
+ ).rejects.toMatchObject({ code: 'unsupported_use' })
+ expect(fetch).not.toHaveBeenCalled()
+ })
+
+ it.each(['google-docs', 'google-sheets'])(
+ 'serves the Drive file picker for a credential delegated only for %s, minting the authorized Drive scope',
+ async (delegated) => {
+ const google = fakeGoogle(fetch)
+ const docsOnly = JSON.stringify({
+ type: 'google_workload_identity_v1',
+ config: { ...storedConfig, delegation: { user: WORKSPACE_USER, services: [delegated] } },
+ })
+
+ await expect(
+ resolve({
+ credentialId: `dwd-picker-${delegated}`,
+ decrypted: docsOnly,
+ service: 'google-drive',
+ scopes: [DRIVE_READONLY],
+ })
+ ).resolves.toBe(`ya29.dwd-${WORKSPACE_USER}-${DRIVE_SCOPE}`)
+ expect(claimsOf(google.signJwt[0])).toMatchObject({ sub: WORKSPACE_USER, scope: DRIVE_SCOPE })
+ }
+ )
+
+ it('retries a token endpoint outage the way a JSON key’s exchange does', async () => {
+ const google = fakeGoogle(fetch, {
+ firstTokenError: jsonResponse({ error: 'internal_failure' }, 503),
+ })
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+ try {
+ const { value } = await settleRetrying(resolve({ credentialId: 'dwd-retry' }))
+ expect(value).toContain(WORKSPACE_USER)
+ expect(google.token).toHaveLength(2)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it.each([
+ {
+ label: 'a refused signature',
+ google: { canSign: () => false },
+ code: 'invalid_credentials',
+ },
+ {
+ label: 'Workspace refusing the client',
+ google: { tokenError: jsonResponse({ error: 'unauthorized_client' }, 401) },
+ code: 'invalid_credentials',
+ },
+ {
+ label: 'an unavailable token endpoint',
+ google: { tokenError: jsonResponse({ error: 'internal_failure' }, 503) },
+ code: 'provider_unavailable',
+ },
+ ])('reports $label in the shared service-account codes', async ({ label, google, code }) => {
+ fakeGoogle(fetch, google)
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+ try {
+ const { error } = await settleRetrying(resolve({ credentialId: `dwd-${label}` }))
+ expect(error).toMatchObject({ code })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})
+
+describe('reading a stored keyless secret', () => {
+ const fetch = vi.fn()
+
+ beforeEach(() => {
+ fetch.mockReset()
+ vi.stubGlobal('fetch', fetch)
+ })
+
+ function resolveWith(decrypted: string, overrides: { service: string; scopes: string[] }) {
+ return resolveGoogleWorkloadIdentityToken({
+ credentialId: `stored-${overrides.service}-${decrypted.length}`,
+ decrypted,
+ secretVersion: 'v1',
+ owner: { workspaceId: 'ws-1' },
+ ...overrides,
+ })
+ }
+
+ const withLaterService = JSON.stringify({
+ type: 'google_workload_identity_v1',
+ config: {
+ ...storedConfig,
+ delegation: { user: WORKSPACE_USER, services: ['gmail', 'google-keep'] },
+ },
+ })
+
+ it('keeps Google Cloud tokens working when a later release delegated a service this one does not know', async () => {
+ const google = fakeGoogle(fetch)
+
+ await expect(
+ resolveWith(withLaterService, { service: 'google-bigquery', scopes: [BIGQUERY] })
+ ).resolves.toBe(`ya29.sa-token-for-${BIGQUERY}`)
+ expect(google.iam[0]?.url).toBe(IMPERSONATE_URL)
+ })
+
+ it('still acts as the delegated user for the services it knows', async () => {
+ fakeGoogle(fetch)
+
+ await expect(
+ resolveWith(withLaterService, { service: 'google-email', scopes: GMAIL_SCOPES })
+ ).resolves.toContain(WORKSPACE_USER)
+ })
+
+ it('authorizes nothing for the service it does not know', async () => {
+ fakeGoogle(fetch)
+
+ await expect(
+ resolveWith(withLaterService, {
+ service: 'google-keep',
+ scopes: ['https://www.googleapis.com/auth/keep'],
+ })
+ ).rejects.toMatchObject({ code: 'unsupported_use' })
+ expect(fetch).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ { label: 'invalid JSON', decrypted: '{' },
+ {
+ label: 'a delegation field it does not know',
+ decrypted: JSON.stringify({
+ type: 'google_workload_identity_v1',
+ config: {
+ ...storedConfig,
+ delegation: { user: WORKSPACE_USER, services: ['gmail'], onlyLabels: ['x'] },
+ },
+ }),
+ },
+ ])('refuses $label in the shared service-account codes', async ({ decrypted }) => {
+ fakeGoogle(fetch)
+
+ await expect(
+ resolveWith(decrypted, { service: 'google-bigquery', scopes: [BIGQUERY] })
+ ).rejects.toMatchObject({ code: 'invalid_credentials' })
+ expect(fetch).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/credentials/google-workload-identity.server.ts b/apps/sim/lib/credentials/google-workload-identity.server.ts
index c3ee029d898..5ea3e858dbf 100644
--- a/apps/sim/lib/credentials/google-workload-identity.server.ts
+++ b/apps/sim/lib/credentials/google-workload-identity.server.ts
@@ -1,15 +1,23 @@
import { getErrorMessage } from '@sim/utils/errors'
+import { toRecord } from '@sim/utils/object'
import { LRUCache } from 'lru-cache'
import { z } from 'zod'
import { coalesceLocally } from '@/lib/concurrency/singleflight'
import { encryptSecret } from '@/lib/core/security/encryption'
import {
+ GOOGLE_DELEGATION_SERVICES,
GOOGLE_KEYLESS_SERVICES,
GOOGLE_WORKLOAD_IDENTITY_SECRET_TYPE,
+ type GoogleDelegationService,
+ googleDelegationScopes,
+ googleDelegationScopesFor,
+ googleDelegationServiceFor,
googleStsAudience,
googleWorkloadIdentityAudience,
googleWorkloadIdentityPrincipal,
googleWorkloadIdentityProviderName,
+ googleWorkloadIdentityRole,
+ isGoogleDelegationUser,
isGoogleProjectNumber,
isGoogleServiceAccountEmail,
isGoogleWorkloadIdentityId,
@@ -30,6 +38,11 @@ import {
type WorkloadIdentityCredentialOwner,
WorkloadIdentityNotConfiguredError,
} from '@/lib/credentials/workload-identity/subject'
+import {
+ exchangeGoogleServiceAccountJwt,
+ GOOGLE_TOKEN_DIAGNOSTIC_ERROR_CODES,
+} from '@/lib/oauth/google-service-account-transport'
+import { readWriteScopeOf } from '@/lib/oauth/utils'
/**
* Keyless Google Cloud credentials: Workload Identity Federation with service
@@ -43,17 +56,28 @@ import {
* (the service account and its IAM roles) that JSON-key credentials use, so
* BigQuery and Vertex AI behave exactly as they do with a key.
*
+ * With domain-wide delegation the federated token instead asks IAM to sign a
+ * JWT-bearer assertion for the service account with `sub` set to the delegated
+ * Workspace user, and Google's OAuth token endpoint exchanges it: the same
+ * assertion a JSON key signs locally, signed by a Google-managed key instead.
+ *
* @see https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-other-providers
* @see https://docs.cloud.google.com/iam/docs/reference/sts/rest/v1/TopLevel/token
* @see https://docs.cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken
+ * @see https://docs.cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt
+ * @see https://developers.google.com/identity/protocols/oauth2/service-account
*/
const SUBMITTED_AUTH_TYPE = 'workload_identity' as const
const STS_TOKEN_URL = 'https://sts.googleapis.com/v1/token'
const IAM_CREDENTIALS_ORIGIN = 'https://iamcredentials.googleapis.com'
+/** The JWT-bearer `aud`, which Google documents as always this URL. */
+const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token'
const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'
const TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'
+/** A JWT-bearer assertion's `exp` may be at most one hour after its `iat`. */
+const ASSERTION_LIFETIME_SECONDS = 3600
const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'
const JWT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:jwt'
/** generateAccessToken's default and, without an organization policy change, its maximum. */
@@ -66,6 +90,28 @@ const MAX_GOOGLE_SUBJECT_BYTES = 127
const TOKEN_REUSE_MARGIN_MS = 5 * 60 * 1000
const TOKEN_CACHE_MAX = 10_000
+/**
+ * A cheap read per delegated service, made with the delegated token when the
+ * credential is saved: it proves the API is enabled for the service account's
+ * project and the user can use it. Docs and Sheets share Drive's scopes and are
+ * checked through the Drive API only: their own APIs have no call that needs no
+ * file, so those APIs being enabled shows up only in a run.
+ */
+const DRIVE_READ = {
+ api: 'Google Drive API',
+ url: 'https://www.googleapis.com/drive/v3/about?fields=user',
+}
+const DELEGATION_READS: Record = {
+ gmail: { api: 'Gmail API', url: 'https://gmail.googleapis.com/gmail/v1/users/me/profile' },
+ 'google-drive': DRIVE_READ,
+ 'google-docs': DRIVE_READ,
+ 'google-sheets': DRIVE_READ,
+ 'google-calendar': {
+ api: 'Google Calendar API',
+ url: 'https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1',
+ },
+}
+
/** STS error codes safe to show; descriptions stay out of the message. */
const STS_DIAGNOSTIC_ERRORS = new Set([
'invalid_grant',
@@ -112,16 +158,65 @@ const subjectSchema = z
.min(1)
.refine((value) => Buffer.byteLength(value, 'utf8') <= MAX_GOOGLE_SUBJECT_BYTES)
-const storedConfigSchema = z.object({ ...refShape, subject: subjectSchema }).strict()
+const delegationUserSchema = z.string().refine(isGoogleDelegationUser)
+
+/**
+ * The stored delegation: one Workspace user, and the services whose scopes it
+ * authorizes. Services are read as plain strings so a service a later release
+ * adds leaves the credential readable here, where it authorizes nothing; any
+ * other unknown field still fails the parse, since it could narrow what the
+ * credential may do.
+ */
+const storedDelegationSchema = z
+ .object({
+ user: delegationUserSchema,
+ services: z.array(z.string().min(1)).min(1),
+ })
+ .strict()
+
+type StoredDelegation = z.infer
+
+/** The delegated services this release knows, in their canonical order. */
+function knownDelegatedServices(delegation: StoredDelegation): GoogleDelegationService[] {
+ return GOOGLE_DELEGATION_SERVICES.filter((service) => delegation.services.includes(service))
+}
+
+/** Absent on every credential saved without delegation, so their stored secret is unchanged. */
+const storedConfigSchema = z
+ .object({ ...refShape, subject: subjectSchema, delegation: storedDelegationSchema.optional() })
+ .strict()
type GoogleWorkloadIdentityConfig = z.infer
+const submittedDelegationSchema = z
+ .object({
+ user: z
+ .string()
+ .trim()
+ .toLowerCase()
+ .refine(isGoogleDelegationUser, 'Enter the email address of the Google Workspace user'),
+ services: z
+ .array(
+ z.enum(GOOGLE_DELEGATION_SERVICES, {
+ message: `Choose Google Workspace services from: ${GOOGLE_DELEGATION_SERVICES.join(', ')}`,
+ })
+ )
+ .min(1, 'Choose at least one Google Workspace service')
+ .transform((services) =>
+ GOOGLE_DELEGATION_SERVICES.filter((service) => services.includes(service))
+ ),
+ })
+ .strict()
+
+type SubmittedDelegation = z.infer
+
const submittedConfigSchema = z
.object({
authType: z.literal(SUBMITTED_AUTH_TYPE),
...refShape,
subject: z.string().min(1).max(512).optional(),
subjectProof: z.string().min(1).max(512).optional(),
+ delegation: submittedDelegationSchema.optional(),
})
.strict()
@@ -177,6 +272,33 @@ async function postJson(
return { status: response.status, payload: payload ?? {} }
}
+/**
+ * The HTTP status of a bearer-authorized GET, with the same redirect and
+ * timeout guards as {@link postJson}. The body is discarded unread.
+ * Undefined when Google could not be reached.
+ */
+async function getStatus(url: string, bearer: string): Promise {
+ try {
+ const response = await fetch(url, {
+ headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' },
+ redirect: 'error',
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ })
+ void response.body?.cancel().catch(() => undefined)
+ return response.status
+ } catch {
+ return undefined
+ }
+}
+
+function parsePayload(body: string): Record {
+ try {
+ return toRecord(JSON.parse(body))
+ } catch {
+ return {}
+ }
+}
+
function isProviderOutage(status: number): boolean {
return providerFailureCode(status) === 'provider_unavailable'
}
@@ -231,15 +353,29 @@ async function exchangeSubjectToken(
return payload.access_token
}
-function impersonationFailureMessage(status: number, config: GoogleWorkloadIdentityConfig) {
+/**
+ * Copy for an IAM Credentials refusal. Signing needs a role the impersonation
+ * role lacks, so a refused signature always names the token-creator role.
+ */
+function iamFailureMessage(
+ status: number,
+ config: GoogleWorkloadIdentityConfig,
+ action: 'impersonate' | 'sign'
+) {
if (isProviderOutage(status)) return 'Google Cloud IAM is unavailable. Try again.'
if (status === 403) {
- return `The service account ${config.serviceAccountEmail} does not let this credential impersonate it. Grant roles/iam.workloadIdentityUser on the service account to ${googleWorkloadIdentityPrincipal(config, config.subject)}, and enable the IAM Service Account Credentials API. A new binding can take a few minutes to apply.`
+ const refusal =
+ action === 'sign'
+ ? 'does not let this credential sign for it, which domain-wide delegation needs'
+ : 'does not let this credential impersonate it'
+ const role = googleWorkloadIdentityRole(action === 'sign' || Boolean(config.delegation))
+ return `The service account ${config.serviceAccountEmail} ${refusal}. Grant ${role} on the service account to ${googleWorkloadIdentityPrincipal(config, config.subject)}, and enable the IAM Service Account Credentials API. A new binding can take a few minutes to apply.`
}
if (status === 404) {
return `Google Cloud could not find the service account ${config.serviceAccountEmail}. Check the email.`
}
- return `Google Cloud refused to impersonate ${config.serviceAccountEmail} (HTTP ${status}).`
+ const verb = action === 'sign' ? 'sign for' : 'impersonate'
+ return `Google Cloud refused to ${verb} ${config.serviceAccountEmail} (HTTP ${status}).`
}
interface ServiceAccountAccessToken {
@@ -261,7 +397,7 @@ async function impersonateServiceAccount(
{ Authorization: `Bearer ${federatedToken}` }
)
if (status !== 200) {
- throw new GoogleWorkloadIdentityError(impersonationFailureMessage(status, config), status)
+ throw new GoogleWorkloadIdentityError(iamFailureMessage(status, config, 'impersonate'), status)
}
if (typeof payload.accessToken !== 'string' || !payload.accessToken) {
throw new GoogleWorkloadIdentityError('Google Cloud IAM answered without a token.', 502)
@@ -283,6 +419,155 @@ async function mintServiceAccountToken(
return impersonateServiceAccount(config, federatedToken, scopes)
}
+/**
+ * Has IAM sign the JWT-bearer assertion for `user` with the service account's
+ * Google-managed key. Google requires `aud` to be its token URL and `exp` at
+ * most an hour after `iat`; signJwt allows up to 12 hours.
+ */
+async function signDelegationAssertion(
+ config: GoogleWorkloadIdentityConfig,
+ federatedToken: string,
+ user: string,
+ scopes: string[]
+): Promise {
+ const issuedAt = Math.floor(Date.now() / 1000)
+ const claims = {
+ iss: config.serviceAccountEmail,
+ sub: user,
+ scope: scopes.join(' '),
+ aud: OAUTH_TOKEN_URL,
+ iat: issuedAt,
+ exp: issuedAt + ASSERTION_LIFETIME_SECONDS,
+ }
+ const url = `${IAM_CREDENTIALS_ORIGIN}/v1/projects/-/serviceAccounts/${encodeURIComponent(config.serviceAccountEmail)}:signJwt`
+ const { status, payload } = await postJson(
+ url,
+ { payload: JSON.stringify(claims) },
+ { Authorization: `Bearer ${federatedToken}` }
+ )
+ if (status !== 200) {
+ throw new GoogleWorkloadIdentityError(iamFailureMessage(status, config, 'sign'), status)
+ }
+ if (typeof payload.signedJwt !== 'string' || !payload.signedJwt) {
+ throw new GoogleWorkloadIdentityError('Google Cloud IAM answered without a signature.', 502)
+ }
+ return payload.signedJwt
+}
+
+/**
+ * Copy for a refused delegated token, by Google's documented error code. Only
+ * `unauthorized_client` and `access_denied` mean the Admin console has not
+ * authorized the client ID for the scopes; the rest point elsewhere.
+ * @see https://developers.google.com/identity/protocols/oauth2/service-account#error-codes
+ */
+function delegationFailureMessage(
+ status: number,
+ payload: Record,
+ input: { serviceAccountEmail: string; user: string; scopes: string[] }
+): string {
+ if (isProviderOutage(status)) return 'Google’s OAuth token endpoint is unavailable. Try again.'
+ const { serviceAccountEmail, user, scopes } = input
+ const code = typeof payload.error === 'string' ? payload.error : ''
+ const suffix = GOOGLE_TOKEN_DIAGNOSTIC_ERROR_CODES.has(code) ? ` (${code})` : ''
+ switch (code) {
+ case 'invalid_grant':
+ return `Google refused to sign in as ${user}${suffix}. Check that the address belongs to an active user in your Google Workspace domain.`
+ case 'unauthorized_client':
+ case 'access_denied':
+ return `Google Workspace has not authorized this service account’s domain-wide delegation for ${user}${suffix}. In the Admin console, under Security, Access and data control, API controls, Manage Domain Wide Delegation, authorize the service account’s client ID (its numeric Unique ID) for these scopes: ${scopes.join(',')}. A new authorization can take a while to apply.`
+ case 'admin_policy_enforced':
+ return `Your Google Workspace admin policies block one or more of these scopes for this service account${suffix}. A Workspace administrator must allow its client ID under Security, Access and data control, API controls, third-party app access.`
+ case 'disabled_client':
+ return `Google reports the service account ${serviceAccountEmail} is disabled${suffix}. Enable it in Google Cloud under IAM & Admin, Service Accounts.`
+ case 'deleted_client':
+ return `Google reports the service account ${serviceAccountEmail} was deleted${suffix}. Restore it within 30 days of deletion, or connect another service account.`
+ default:
+ return `Google refused the delegated token for ${user}${suffix}. Check the service account’s configuration in Google Cloud and its domain-wide delegation in the Admin console.`
+ }
+}
+
+/**
+ * Exchanges a signed assertion for an access token acting as the delegated
+ * user, through the same transport a JSON key's assertion takes: retries on
+ * 429 and 5xx within a budget, and a bounded read of the answer.
+ */
+async function exchangeDelegationAssertion(
+ assertion: string,
+ context: { serviceAccountEmail: string; user: string; scopes: string[] }
+): Promise {
+ let response: Awaited>
+ try {
+ response = await exchangeGoogleServiceAccountJwt(OAUTH_TOKEN_URL, assertion)
+ } catch {
+ throw new GoogleWorkloadIdentityError('Could not reach Google. Try again.')
+ }
+ const payload = parsePayload(response.body)
+ if (!response.ok) {
+ throw new GoogleWorkloadIdentityError(
+ delegationFailureMessage(response.status, payload, context),
+ response.status
+ )
+ }
+ if (typeof payload.access_token !== 'string' || !payload.access_token) {
+ throw new GoogleWorkloadIdentityError('Google answered without a token.', 502)
+ }
+ const expiresInSeconds = typeof payload.expires_in === 'number' ? payload.expires_in : 3600
+ return { accessToken: payload.access_token, expiresAtMs: Date.now() + expiresInSeconds * 1000 }
+}
+
+async function mintDelegatedToken(
+ config: GoogleWorkloadIdentityConfig,
+ federatedToken: string,
+ user: string,
+ scopes: string[]
+): Promise {
+ const assertion = await signDelegationAssertion(config, federatedToken, user, scopes)
+ return exchangeDelegationAssertion(assertion, {
+ serviceAccountEmail: config.serviceAccountEmail,
+ user,
+ scopes,
+ })
+}
+
+/**
+ * Reads one cheap resource per service as the delegated user, with that
+ * service's token. Services with the same scopes and read (Drive, Docs, and
+ * Sheets) are checked once.
+ */
+async function verifyDelegation(
+ config: GoogleWorkloadIdentityConfig,
+ delegation: SubmittedDelegation,
+ owner: WorkloadIdentityOwner
+): Promise {
+ const checks = new Map()
+ for (const service of delegation.services) {
+ const scopes = googleDelegationScopes(service)
+ const read = DELEGATION_READS[service]
+ checks.set(`${[...scopes].sort().join(' ')}|${read.url}`, { scopes, read })
+ }
+ const federatedToken = await exchangeSubjectToken(config, owner)
+ for (const { scopes, read } of checks.values()) {
+ const { accessToken } = await mintDelegatedToken(
+ config,
+ federatedToken,
+ delegation.user,
+ scopes
+ )
+ const status = await getStatus(read.url, accessToken)
+ if (status === undefined) {
+ throw new GoogleWorkloadIdentityError(`Could not reach the ${read.api}. Try again.`)
+ }
+ if (status === 200) continue
+ if (isProviderOutage(status)) {
+ throw new GoogleWorkloadIdentityError(`The ${read.api} is unavailable. Try again.`, status)
+ }
+ throw new GoogleWorkloadIdentityError(
+ `Sim signed in as ${delegation.user}, but the ${read.api} refused the request (HTTP ${status}). Enable the ${read.api} in the project that owns ${config.serviceAccountEmail}, and check that ${delegation.user} can use the service.`,
+ status
+ )
+ }
+}
+
function isRefusal(error: unknown): boolean {
return (
error instanceof GoogleWorkloadIdentityError &&
@@ -303,22 +588,90 @@ async function assertSubjectPinned(
owner: WorkloadIdentityOwner
): Promise {
const stray = { ...config, subject: newWorkloadIdentitySubject(owner) }
- try {
- await mintServiceAccountToken(stray, owner, [CLOUD_PLATFORM_SCOPE])
- } catch (error) {
- if (isRefusal(error)) return
- throw new GoogleWorkloadIdentityError(
+ const unconfirmed = () =>
+ new GoogleWorkloadIdentityError(
'Sim could not confirm this service account trusts only this credential. Try again.'
)
+ /** Whether Google refused the stray subject this step. */
+ const refused = async (step: () => Promise): Promise => {
+ try {
+ await step()
+ } catch (error) {
+ if (isRefusal(error)) return true
+ throw unconfirmed()
+ }
+ return false
+ }
+
+ let federatedToken = ''
+ if (
+ await refused(async () => {
+ federatedToken = await exchangeSubjectToken(stray, owner)
+ })
+ ) {
+ return
+ }
+ // Delegation signs with signJwt, which the impersonation role does not grant, so a
+ // pool-wide grant of either permission must be caught.
+ const steps: Array<() => Promise> = [
+ () => impersonateServiceAccount(stray, federatedToken, [CLOUD_PLATFORM_SCOPE]),
+ ]
+ const { delegation } = config
+ if (delegation) {
+ steps.push(() =>
+ signDelegationAssertion(
+ stray,
+ federatedToken,
+ delegation.user,
+ googleDelegationScopesFor(knownDelegatedServices(delegation))
+ )
+ )
+ }
+ for (const step of steps) {
+ if (!(await refused(step))) {
+ throw new GoogleWorkloadIdentityError(
+ `This service account also trusts other Sim credentials. Grant ${googleWorkloadIdentityRole(Boolean(delegation))} to this credential’s principal:// subject only (not a principalSet for the pool), and add the attribute condition from Sim (the connect dialog or \`sim credentials keyless-subject\`) so the provider accepts only this subject.`
+ )
+ }
}
- throw new GoogleWorkloadIdentityError(
- 'This service account also trusts other Sim credentials. Grant roles/iam.workloadIdentityUser to this credential’s principal:// subject only (not a principalSet for the pool), and add the attribute condition from Sim (the connect dialog or `sim credentials keyless-subject`) so the provider accepts only this subject.'
- )
}
-function readStoredSubject(storedSecret: Record | null | undefined) {
+/** The configuration a stored keyless secret holds, or undefined for anything else. */
+function parseStoredConfig(storedSecret: unknown): GoogleWorkloadIdentityConfig | undefined {
const parsed = storedSecretSchema.safeParse(storedSecret)
- return parsed.success ? parsed.data.config.subject : undefined
+ return parsed.success ? parsed.data.config : undefined
+}
+
+/** {@link parseStoredConfig} for the decrypted secret's JSON text. */
+function parseDecryptedConfig(decrypted: string): GoogleWorkloadIdentityConfig | undefined {
+ try {
+ return parseStoredConfig(JSON.parse(decrypted))
+ } catch {
+ return undefined
+ }
+}
+
+function readStoredSubject(storedSecret: Record | null | undefined) {
+ return parseStoredConfig(storedSecret)?.subject
+}
+
+/** A keyless credential's domain-wide delegation, as a reconnect reopens on it. */
+export interface GoogleStoredDelegation {
+ user: string
+ services: GoogleDelegationService[]
+}
+
+/**
+ * The Workspace user and delegated services a stored keyless secret holds, so
+ * a reconnect keeps them instead of silently dropping them. Neither is secret.
+ * Undefined for a JSON key, a credential without delegation, or one whose
+ * services this release does not know.
+ */
+export function storedGoogleDelegation(decrypted: string): GoogleStoredDelegation | undefined {
+ const delegation = parseDecryptedConfig(decrypted)?.delegation
+ if (!delegation) return undefined
+ const services = knownDelegatedServices(delegation)
+ return services.length > 0 ? { user: delegation.user, services } : undefined
}
function bindSubject(
@@ -342,12 +695,17 @@ export interface GoogleWorkloadIdentitySaveResult {
workloadIdentityProvider: string
/** A subject this save binds that the credential did not already hold. */
newSubject?: string
+ /** The verified domain-wide delegation, when the credential was saved with one. */
+ delegation?: SubmittedDelegation
}
/**
* Validates the submitted configuration, then proves it end to end: Sim's
* token is exchanged at STS, the federated token impersonates the service
- * account. Alongside, a subject no credential holds must be refused. Returns the encrypted blob.
+ * account, or, with domain-wide delegation, signs an assertion for the
+ * Workspace user that Google exchanges for each service, and a cheap read per
+ * service succeeds as that user. Alongside, a subject no credential holds must
+ * be refused. Returns the encrypted blob.
*/
export async function verifyAndEncryptGoogleWorkloadIdentityCredential(
serialized: string,
@@ -369,14 +727,20 @@ export async function verifyAndEncryptGoogleWorkloadIdentityCredential(
providerId: submitted.providerId,
serviceAccountEmail: submitted.serviceAccountEmail,
subject: bindSubject(submitted.subject, submitted.subjectProof, owner),
+ ...(submitted.delegation ? { delegation: submitted.delegation } : {}),
}
if (!subjectSchema.safeParse(config.subject).success) {
throw new GoogleWorkloadIdentityError('This credential’s subject is too long for Google Cloud.')
}
// A token from generateAccessToken proves the provider accepted Sim's token and the
- // service account lets this subject impersonate it.
- const verify = () => mintServiceAccountToken(config, owner, [CLOUD_PLATFORM_SCOPE])
+ // service account lets this subject impersonate it; a delegated read proves the rest
+ // of the delegation chain as well.
+ const { delegation } = submitted
+ const verify = () =>
+ delegation
+ ? verifyDelegation(config, delegation, owner)
+ : mintServiceAccountToken(config, owner, [CLOUD_PLATFORM_SCOPE])
// The pin probe is independent of the verification, so both run at once; a
// verification failure still reports first.
const [verified, pinned] = await Promise.allSettled([
@@ -401,12 +765,15 @@ export async function verifyAndEncryptGoogleWorkloadIdentityCredential(
...(config.subject !== readStoredSubject(owner.storedSecret)
? { newSubject: config.subject }
: {}),
+ ...(delegation ? { delegation } : {}),
}
}
/**
- * Minted tokens per credential, stored-secret version, and scopes: a reconnect
- * re-encrypts the secret and so never reuses the old configuration's token.
+ * Minted tokens per credential, stored-secret version, delegated user (if any),
+ * and scopes: a reconnect re-encrypts the secret and so never reuses the old
+ * configuration's token, and a delegated token never answers for the service
+ * account's own.
*/
const tokenCache = new LRUCache({ max: TOKEN_CACHE_MAX })
@@ -418,32 +785,115 @@ export interface ResolveGoogleWorkloadIdentityTokenInput {
secretVersion: string
owner: WorkloadIdentityOwner
scopes: string[]
- /** A domain-wide-delegation subject, which a keyless credential refuses. */
+ /**
+ * A domain-wide-delegation subject. Only a credential delegated for the
+ * service accepts one, and only its own delegated user.
+ */
impersonateEmail?: string
/** The Google service the token is for, from server-side policy. */
service?: string
}
+function unsupportedUse(step: string, reason: string): TokenServiceAccountValidationError {
+ return new TokenServiceAccountValidationError('unsupported_use', 400, { step, reason })
+}
+
+/** Reads the stored secret, or fails in the shared service-account codes. */
+function readStoredConfig(decrypted: string): GoogleWorkloadIdentityConfig {
+ const config = parseDecryptedConfig(decrypted)
+ if (!config) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', 400, {
+ step: 'google_keyless_config',
+ reason: 'the stored keyless Google configuration could not be read; reconnect the credential',
+ })
+ }
+ return config
+}
+
+/**
+ * Read-only scopes whose read-write sibling is not a scope Google defines, by
+ * the authorized scope Google documents as covering them. `gmail.readonly`
+ * ("View your email messages and settings") is covered by `gmail.modify`
+ * ("Read, compose, and send emails"); the Gmail label picker asks for it.
+ * `calendar.events.readonly` ("View events on all your calendars") is covered
+ * by `calendar` ("See, edit, share, and permanently delete all the calendars
+ * you can access"), not by its `calendar.events` sibling, which the Calendar
+ * delegation does not authorize; the Calendar connector asks for it.
+ * @see https://developers.google.com/workspace/gmail/api/auth/scopes
+ * @see https://developers.google.com/workspace/calendar/api/auth
+ */
+const DELEGATED_READ_COVERS: ReadonlyMap = new Map([
+ [
+ 'https://www.googleapis.com/auth/gmail.readonly',
+ 'https://www.googleapis.com/auth/gmail.modify',
+ ],
+ [
+ 'https://www.googleapis.com/auth/calendar.events.readonly',
+ 'https://www.googleapis.com/auth/calendar',
+ ],
+])
+
+/**
+ * The scopes to sign a delegated assertion for, or undefined when the
+ * delegation does not authorize them. A delegation authorizes the scopes the
+ * connect dialog asked the Admin console to authorize for its services, and
+ * Google refuses an assertion naming any other scope. Authorization is by
+ * scope, as Google's is, not by the service asking: Drive, Docs, and Sheets
+ * share their scopes, so a delegation for one serves the others. A requested
+ * read-only scope is minted as the authorized scope that covers it: its
+ * read-write sibling (the Drive file picker asks for `drive.readonly`; `drive`
+ * is authorized), the same rule a connected account's granted scopes are
+ * checked by, or the scope {@link DELEGATED_READ_COVERS} names.
+ */
+function delegatedMintScopes(
+ delegation: StoredDelegation,
+ requested: readonly string[]
+): string[] | undefined {
+ const authorized = new Set(googleDelegationScopesFor(knownDelegatedServices(delegation)))
+ const minted = new Set()
+ for (const scope of requested) {
+ const mint = [scope, readWriteScopeOf(scope), DELEGATED_READ_COVERS.get(scope)].find(
+ (candidate) => candidate !== undefined && authorized.has(candidate)
+ )
+ if (!mint) return undefined
+ minted.add(mint)
+ }
+ return [...minted]
+}
+
/**
- * Resolves an authorized keyless Google credential to a service account token.
- * It refuses domain-wide delegation and any service that is not a Google Cloud
- * API before doing anything else: Workspace data needs a delegated user, which
- * this mode cannot prove.
+ * Resolves an authorized keyless Google credential to an access token. A
+ * Google Cloud service gets a service account token. A delegable Google
+ * Workspace service gets a token acting as the delegated user, only when the
+ * credential was saved with domain-wide delegation whose scopes cover the
+ * request. Anything else is refused before Google is contacted.
*/
export async function resolveGoogleWorkloadIdentityToken(
input: ResolveGoogleWorkloadIdentityTokenInput
): Promise {
- if (input.impersonateEmail) {
- throw new TokenServiceAccountValidationError('unsupported_use', 400, {
- step: 'google_keyless_delegation',
- reason: 'keyless Google Cloud credentials cannot impersonate a Google Workspace user',
- })
- }
- if (!input.service || !GOOGLE_KEYLESS_SERVICES.has(input.service)) {
- throw new TokenServiceAccountValidationError('unsupported_use', 400, {
- step: 'google_keyless_service',
- reason: 'keyless Google Cloud credentials work only with Google Cloud APIs',
- })
+ const config = readStoredConfig(input.decrypted)
+ const delegation = googleDelegationServiceFor(input.service) ? config.delegation : undefined
+
+ if (delegation) {
+ if (input.impersonateEmail && input.impersonateEmail.trim().toLowerCase() !== delegation.user) {
+ throw unsupportedUse(
+ 'google_keyless_delegation_user',
+ 'this keyless Google credential acts only as its delegated Workspace user'
+ )
+ }
+ } else {
+ if (input.impersonateEmail) {
+ throw unsupportedUse(
+ 'google_keyless_delegation',
+ 'this keyless Google credential has no domain-wide delegation for this service'
+ )
+ }
+ if (!input.service || !GOOGLE_KEYLESS_SERVICES.has(input.service)) {
+ throw unsupportedUse(
+ 'google_keyless_service',
+ 'this keyless Google credential works only with Google Cloud APIs and the Workspace services it was delegated'
+ )
+ }
}
if (input.scopes.length === 0) {
throw new TokenServiceAccountValidationError('invalid_credentials', 400, {
@@ -451,24 +901,39 @@ export async function resolveGoogleWorkloadIdentityToken(
reason: 'scopes are required for service account credentials',
})
}
- const scopeKey = [...new Set(input.scopes)].sort().join(' ')
- const cacheKey = `${input.credentialId}:${input.secretVersion}:${scopeKey}`
+ const scopes = delegation ? delegatedMintScopes(delegation, input.scopes) : input.scopes
+ if (!scopes) {
+ throw unsupportedUse(
+ 'google_keyless_delegation_scope',
+ 'this keyless Google credential’s domain-wide delegation does not authorize the scopes this use needs'
+ )
+ }
+ const scopeKey = [...new Set(scopes)].sort().join(' ')
+ const identityKey = delegation ? `delegated:${delegation.user}` : 'service-account'
+ const cacheKey = `${input.credentialId}:${input.secretVersion}:${identityKey}:${scopeKey}`
const cached = tokenCache.get(cacheKey)
if (cached) return cached
// Concurrent block runs share one mint, so it is not tied to any one caller's signal.
return coalesceLocally(`google-keyless:${cacheKey}`, async () => {
- const { config } = storedSecretSchema.parse(JSON.parse(input.decrypted))
+ const mintScopes = scopeKey.split(' ')
let token: ServiceAccountAccessToken
try {
- token = await mintServiceAccountToken(config, input.owner, scopeKey.split(' '))
+ token = delegation
+ ? await mintDelegatedToken(
+ config,
+ await exchangeSubjectToken(config, input.owner),
+ delegation.user,
+ mintScopes
+ )
+ : await mintServiceAccountToken(config, input.owner, mintScopes)
} catch (error) {
if (!(error instanceof GoogleWorkloadIdentityError)) throw error
// Report in the shared service-account codes: an outage is not a bad credential.
throw new TokenServiceAccountValidationError(
error.status === undefined ? 'provider_unavailable' : providerFailureCode(error.status),
error.status ?? 502,
- { step: 'google_keyless_token' }
+ { step: delegation ? 'google_keyless_delegated_token' : 'google_keyless_token' }
)
}
const ttl = token.expiresAtMs - Date.now() - TOKEN_REUSE_MARGIN_MS
diff --git a/apps/sim/lib/credentials/google-workload-identity.test.ts b/apps/sim/lib/credentials/google-workload-identity.test.ts
index 04e70bdccb3..38abd86567d 100644
--- a/apps/sim/lib/credentials/google-workload-identity.test.ts
+++ b/apps/sim/lib/credentials/google-workload-identity.test.ts
@@ -2,10 +2,15 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
-import { googleKeylessOffer } from '@/lib/credentials/google-workload-identity'
+import {
+ googleDelegationServiceFor,
+ googleInitialDelegatedServices,
+ googleKeylessOffer,
+ googleWorkloadIdentitySetupCommands,
+} from '@/lib/credentials/google-workload-identity'
describe('keyless setup in the Google service account dialog', () => {
- it.each(['google-drive', 'gmail', 'google-sheets'])(
+ it.each(['google-forms', 'google-contacts', 'google-tasks'])(
'never offers the keyless form when reconnecting a keyless credential from %s',
(oauthServiceId) => {
expect(googleKeylessOffer({ oauthServiceId, available: true, reconnectsKeyless: true })).toBe(
@@ -14,12 +19,36 @@ describe('keyless setup in the Google service account dialog', () => {
}
)
- it('offers JSON keys only for a new connection from a Workspace service', () => {
+ it('offers JSON keys only for a new connection from a Workspace service it cannot delegate', () => {
expect(
- googleKeylessOffer({ oauthServiceId: 'gmail', available: true, reconnectsKeyless: false })
+ googleKeylessOffer({
+ oauthServiceId: 'google-forms',
+ available: true,
+ reconnectsKeyless: false,
+ })
).toBe('none')
})
+ it.each(['gmail', 'google-drive', 'google-docs', 'google-sheets', 'google-calendar'])(
+ 'offers keyless with domain-wide delegation for %s, new or reconnected',
+ (oauthServiceId) => {
+ for (const reconnectsKeyless of [true, false]) {
+ expect(googleKeylessOffer({ oauthServiceId, available: true, reconnectsKeyless })).toBe(
+ 'offer'
+ )
+ }
+ }
+ )
+
+ it.each([true, false])(
+ 'offers Gmail keyless from the Integrations page and chat card, which name its provider id (reconnect: %s)',
+ (reconnectsKeyless) => {
+ expect(
+ googleKeylessOffer({ oauthServiceId: 'google-email', available: true, reconnectsKeyless })
+ ).toBe('offer')
+ }
+ )
+
it.each([true, false])(
'offers keyless for BigQuery whether or not the reconnected credential is keyless (%s)',
(reconnectsKeyless) => {
@@ -39,12 +68,74 @@ describe('keyless setup in the Google service account dialog', () => {
})
it('offers nothing keyless where this deployment cannot sign tokens', () => {
- expect(
- googleKeylessOffer({
- oauthServiceId: 'google-bigquery',
- available: false,
- reconnectsKeyless: true,
- })
- ).toBe('none')
+ for (const oauthServiceId of ['google-bigquery', 'gmail']) {
+ expect(
+ googleKeylessOffer({ oauthServiceId, available: false, reconnectsKeyless: true })
+ ).toBe('none')
+ }
+ })
+})
+
+describe('the services a keyless dialog opens delegating', () => {
+ it('keeps every stored service when reconnected from the credential’s own page', () => {
+ expect(googleInitialDelegatedServices(undefined, ['google-calendar', 'gmail'])).toEqual([
+ 'gmail',
+ 'google-calendar',
+ ])
+ })
+
+ it('adds the service the dialog was opened for to the stored ones', () => {
+ expect(googleInitialDelegatedServices('google-docs', ['gmail'])).toEqual([
+ 'gmail',
+ 'google-docs',
+ ])
+ })
+
+ it('delegates Gmail when opened by its provider id', () => {
+ expect(googleInitialDelegatedServices('google-email', ['google-drive'])).toEqual([
+ 'gmail',
+ 'google-drive',
+ ])
+ })
+
+ it('delegates nothing when opened for a Google Cloud service with nothing stored', () => {
+ expect(googleInitialDelegatedServices('google-bigquery')).toEqual([])
+ })
+})
+
+describe('the delegated service a tool or selector names', () => {
+ it.each([
+ ['google-email', 'gmail'],
+ ['gmail', 'gmail'],
+ ['google-drive', 'google-drive'],
+ ['google-calendar', 'google-calendar'],
+ ['google-bigquery', undefined],
+ ['google-forms', undefined],
+ [undefined, undefined],
+ ])('maps %s to %s', (service, expected) => {
+ expect(googleDelegationServiceFor(service)).toBe(expected)
+ })
+})
+
+describe('keyless setup commands', () => {
+ const input = {
+ ref: { projectNumber: '123456789012', poolId: 'sim-pool', providerId: 'sim-oidc' },
+ serviceAccountEmail: 'runner@analytics-prod.iam.gserviceaccount.com',
+ issuer: 'https://sim.example.com/oidc/workload',
+ subject: 'sim:workspace:ws-1:credential:abc',
+ }
+
+ it('grants only impersonation without domain-wide delegation', () => {
+ const commands = googleWorkloadIdentitySetupCommands(input)
+ expect(commands).toContain('--role="roles/iam.workloadIdentityUser"')
+ expect(commands).not.toContain('serviceAccountTokenCreator')
+ })
+
+ it('grants the token creator role, which includes signJwt, with domain-wide delegation', () => {
+ const commands = googleWorkloadIdentitySetupCommands({ ...input, domainWideDelegation: true })
+ expect(commands).toContain('--role="roles/iam.serviceAccountTokenCreator"')
+ expect(commands).toContain(
+ '--member="principal://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/sim-pool/subject/sim:workspace:ws-1:credential:abc"'
+ )
})
})
diff --git a/apps/sim/lib/credentials/google-workload-identity.ts b/apps/sim/lib/credentials/google-workload-identity.ts
index 42eeab2d59b..8d68e387732 100644
--- a/apps/sim/lib/credentials/google-workload-identity.ts
+++ b/apps/sim/lib/credentials/google-workload-identity.ts
@@ -5,33 +5,130 @@
* token is exchanged at Google STS against the customer's workload identity
* provider, and the federated token impersonates the service account.
*
- * Every value here ends up in a Google URL or audience, so each one is checked
- * against Google's documented format and nothing else can reach the request.
+ * Every value here ends up in a Google URL, audience, or signed claim, so each
+ * one is checked against Google's documented format and nothing else can reach
+ * the request.
+ *
+ * A keyless credential may also opt into domain-wide delegation: it then acts
+ * as one Google Workspace user, for the Workspace services its admin chose.
*
* @see https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-other-providers
+ * @see https://knowledge.workspace.google.com/admin/apps/domain-wide-delegation-best-practices
*/
+import { impersonateEmailSchema } from '@/lib/api/contracts/oauth-connections'
+import { getProviderIdFromServiceId, getScopesForService } from '@/lib/oauth/utils'
+
/**
- * The Google services a keyless credential may mint for: Google Cloud APIs the
- * service account calls as itself. Google Workspace services need domain-wide
- * delegation, which keyless credentials do not support.
+ * The Google services a keyless credential may mint for as the service account
+ * itself: Google Cloud APIs. Google Workspace services need domain-wide
+ * delegation; see {@link GOOGLE_DELEGATION_SERVICES}.
*/
export const GOOGLE_KEYLESS_SERVICES: ReadonlySet = new Set([
'google-bigquery',
'vertex-ai',
])
+/**
+ * The Google Workspace services a keyless credential can reach through
+ * domain-wide delegation, by OAuth service id.
+ */
+export const GOOGLE_DELEGATION_SERVICES = [
+ 'gmail',
+ 'google-drive',
+ 'google-docs',
+ 'google-sheets',
+ 'google-calendar',
+] as const
+
+export type GoogleDelegationService = (typeof GOOGLE_DELEGATION_SERVICES)[number]
+
+const DELEGATION_SERVICE_SET: ReadonlySet = new Set(GOOGLE_DELEGATION_SERVICES)
+
+function isGoogleDelegationService(value: string): value is GoogleDelegationService {
+ return DELEGATION_SERVICE_SET.has(value)
+}
+
+/**
+ * Whether a keyless credential can serve this OAuth service or provider id: a
+ * Google Cloud service as the service account, or a Workspace service through
+ * delegation.
+ */
+export function isGoogleKeylessService(service: string): boolean {
+ return GOOGLE_KEYLESS_SERVICES.has(service) || googleDelegationServiceFor(service) !== undefined
+}
+
+/**
+ * The delegated service a caller names. Tools and the Integrations page name
+ * the OAuth provider id (`google-email`); selectors and blocks name the
+ * service id (`gmail`).
+ */
+export function googleDelegationServiceFor(
+ service: string | undefined
+): GoogleDelegationService | undefined {
+ if (!service) return undefined
+ if (isGoogleDelegationService(service)) return service
+ return GOOGLE_DELEGATION_SERVICES.find((id) => getProviderIdFromServiceId(id) === service)
+}
+
+/**
+ * The services a keyless dialog opens delegating: a reconnected credential's
+ * stored services, so a reconnect never drops one, plus the delegable service
+ * the dialog was opened for. Anything this release does not know is left out.
+ */
+export function googleInitialDelegatedServices(
+ oauthServiceId: string | undefined,
+ storedServices: readonly string[] = []
+): GoogleDelegationService[] {
+ const services = new Set(storedServices)
+ const openedFor = googleDelegationServiceFor(oauthServiceId)
+ if (openedFor) services.add(openedFor)
+ return GOOGLE_DELEGATION_SERVICES.filter((service) => services.has(service))
+}
+
+/**
+ * User-identity scopes, which a service account can't be granted: Google
+ * rejects a service-account token request that includes them.
+ */
+export const GOOGLE_SERVICE_ACCOUNT_EXCLUDED_SCOPES: ReadonlySet = new Set([
+ 'https://www.googleapis.com/auth/userinfo.email',
+ 'https://www.googleapis.com/auth/userinfo.profile',
+])
+
+/** The scopes Sim requests for a delegated service, which the Admin console must authorize. */
+export function googleDelegationScopes(service: GoogleDelegationService): string[] {
+ return getScopesForService(service).filter(
+ (scope) => !GOOGLE_SERVICE_ACCOUNT_EXCLUDED_SCOPES.has(scope)
+ )
+}
+
+/** Every scope the given delegated services need, deduplicated and sorted. */
+export function googleDelegationScopesFor(services: readonly GoogleDelegationService[]): string[] {
+ return [...new Set(services.flatMap(googleDelegationScopes))].sort()
+}
+
+/** RFC 5321's limit on a forward path, which a Workspace sign-in address can't exceed. */
+const MAX_DELEGATION_USER_LENGTH = 254
+
+/** A Google Workspace user's email address, the `sub` a delegated token acts as. */
+export function isGoogleDelegationUser(value: string): boolean {
+ return (
+ value.length <= MAX_DELEGATION_USER_LENGTH && impersonateEmailSchema.safeParse(value).success
+ )
+}
+
/**
* What a Google service account connect dialog offers for keyless setup.
*
- * - `offer`: a Google Cloud service (new or reconnect), or a keyless credential
- * reconnected from a context that names no service (its own detail page).
- * - `cloud_only`: a keyless credential reconnected from a Google Workspace
- * service, which can never use it; the dialog explains instead of saving.
+ * - `offer`: a Google Cloud service or a delegable Google Workspace service
+ * (new or reconnect), or a keyless credential reconnected from a context that
+ * names no service (its own detail page).
+ * - `cloud_only`: a keyless credential reconnected from another Google
+ * Workspace service, which can never use it; the dialog explains instead of saving.
* - `none`: JSON key only.
*/
export function googleKeylessOffer(input: {
- /** The OAuth service the dialog was opened for, when it was opened for one. */
+ /** The OAuth service or provider id the dialog was opened for, when it was opened for one. */
oauthServiceId?: string
/** This deployment can sign workload-identity tokens for this owner. */
available: boolean
@@ -39,9 +136,10 @@ export function googleKeylessOffer(input: {
reconnectsKeyless: boolean
}): 'offer' | 'cloud_only' | 'none' {
if (!input.available) return 'none'
- if (input.oauthServiceId && GOOGLE_KEYLESS_SERVICES.has(input.oauthServiceId)) return 'offer'
+ const service = input.oauthServiceId
+ if (service && isGoogleKeylessService(service)) return 'offer'
if (!input.reconnectsKeyless) return 'none'
- return input.oauthServiceId ? 'cloud_only' : 'offer'
+ return service ? 'cloud_only' : 'offer'
}
/** The `type` of a keyless credential's stored secret; a JSON key's is `service_account`. */
@@ -151,17 +249,32 @@ export function googleWorkloadIdentityPrincipal(
return `principal://iam.googleapis.com/projects/${ref.projectNumber}/locations/global/workloadIdentityPools/${ref.poolId}/subject/${subject}`
}
+/**
+ * The role this credential's subject needs on the service account.
+ * `roles/iam.workloadIdentityUser` grants `getAccessToken` but not `signJwt`,
+ * which domain-wide delegation signs its assertion with;
+ * `roles/iam.serviceAccountTokenCreator` grants both.
+ * @see https://docs.cloud.google.com/iam/docs/roles-permissions/iam
+ */
+export function googleWorkloadIdentityRole(domainWideDelegation: boolean): string {
+ return domainWideDelegation
+ ? 'roles/iam.serviceAccountTokenCreator'
+ : 'roles/iam.workloadIdentityUser'
+}
+
/**
* The gcloud commands that set up trust for one credential: the pool, an OIDC
* provider for Sim's issuer that accepts only this credential's subject, and
- * the binding that lets that subject impersonate the service account. The
- * provider sets no allowed audiences, so it accepts its own resource name.
+ * the binding that lets that subject impersonate the service account (and, with
+ * domain-wide delegation, sign for it). The provider sets no allowed audiences,
+ * so it accepts its own resource name.
*/
export function googleWorkloadIdentitySetupCommands(input: {
ref: GoogleWorkloadIdentityProviderRef
serviceAccountEmail: string
issuer: string
subject: string
+ domainWideDelegation?: boolean
}): string {
const { ref, serviceAccountEmail, issuer, subject } = input
return [
@@ -180,7 +293,7 @@ export function googleWorkloadIdentitySetupCommands(input: {
'',
`gcloud iam service-accounts add-iam-policy-binding ${serviceAccountEmail} \\`,
` --project="${ref.projectNumber}" \\`,
- ' --role="roles/iam.workloadIdentityUser" \\',
+ ` --role="${googleWorkloadIdentityRole(input.domainWideDelegation ?? false)}" \\`,
` --member="${googleWorkloadIdentityPrincipal(ref, subject)}"`,
].join('\n')
}
diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts
index 0bb97d88280..fae0ac346c1 100644
--- a/apps/sim/lib/credentials/service-account-secret.ts
+++ b/apps/sim/lib/credentials/service-account-secret.ts
@@ -374,7 +374,10 @@ async function buildGoogleServiceAccountSecret(
}
}
-/** A keyless Google Cloud credential: Workload Identity Federation, no stored key. */
+/**
+ * A keyless Google credential: Workload Identity Federation, no stored key,
+ * optionally with domain-wide delegation to one Workspace user.
+ */
async function buildGoogleWorkloadIdentitySecret(
serviceAccountJson: string,
owner: ServiceAccountSecretFields['owner']
@@ -400,6 +403,12 @@ async function buildGoogleWorkloadIdentitySecret(
auditMetadata: {
googleClientEmail: result.serviceAccountEmail,
googleWorkloadIdentityProvider: result.workloadIdentityProvider,
+ ...(result.delegation
+ ? {
+ googleDelegatedUser: result.delegation.user,
+ googleDelegatedServices: result.delegation.services.join(','),
+ }
+ : {}),
...serviceAccountPrincipalMetadata(principal),
},
principal,
diff --git a/apps/sim/lib/credentials/workload-identity/audiences.ts b/apps/sim/lib/credentials/workload-identity/audiences.ts
index e8e1be09a51..e282d0ce4f1 100644
--- a/apps/sim/lib/credentials/workload-identity/audiences.ts
+++ b/apps/sim/lib/credentials/workload-identity/audiences.ts
@@ -84,9 +84,9 @@ export const WORKLOAD_IDENTITY_KEYLESS_CONFIGS: Record<
google: {
authType: 'workload_identity',
requiredFields: ['projectNumber', 'poolId', 'providerId', 'serviceAccountEmail'],
- optionalFields: [],
+ optionalFields: ['delegation'],
scope:
- 'Keyless works only for Google Cloud services such as BigQuery and Vertex AI, not Google Workspace (Gmail, Drive, Sheets, Calendar, and the like), which need a JSON key with domain-wide delegation.',
+ 'Keyless works for Google Cloud services such as BigQuery and Vertex AI. For Gmail, Google Drive, Google Docs, Google Sheets, and Google Calendar, add `delegation` as {"user": "", "services": ["gmail", "google-drive", "google-docs", "google-sheets", "google-calendar"]} (the services you need) for domain-wide delegation: grant the subject roles/iam.serviceAccountTokenCreator instead of roles/iam.workloadIdentityUser, authorize the service account’s client ID for those services’ scopes in the Google Workspace Admin console, and workflows act only as that user. Other Google Workspace services need a JSON key.',
},
}
diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts
index 13f8989a68b..0df6a492213 100644
--- a/apps/sim/lib/oauth/credential-service.ts
+++ b/apps/sim/lib/oauth/credential-service.ts
@@ -20,7 +20,10 @@ import {
getClientCredentialAccountMinter,
parseClientCredentialAccountSecretBlob,
} from '@/lib/credentials/client-credential-accounts/server'
-import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity'
+import {
+ GOOGLE_SERVICE_ACCOUNT_EXCLUDED_SCOPES,
+ isGoogleWorkloadIdentitySecret,
+} from '@/lib/credentials/google-workload-identity'
import { getTokenClientCredentialMinter } from '@/lib/credentials/token-service-accounts/client-credentials'
import {
type CLIENT_CREDENTIALS_AUTH_METHOD,
@@ -42,7 +45,10 @@ import {
GITHUB_INSTALLATION_PROVIDER_ID,
type GitHubInstallationRepositoryScope,
} from '@/lib/oauth/github-installation-types'
-import { exchangeGoogleServiceAccountJwt } from '@/lib/oauth/google-service-account-transport'
+import {
+ exchangeGoogleServiceAccountJwt,
+ GOOGLE_TOKEN_DIAGNOSTIC_ERROR_CODES,
+} from '@/lib/oauth/google-service-account-transport'
import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram'
import {
getMicrosoftRefreshTokenExpiry,
@@ -219,29 +225,6 @@ export async function resolveOAuthAccountId(
return { accountId: credentialId, usedCredentialTable: false }
}
-/**
- * Userinfo scopes are excluded because service accounts don't represent a user
- * and cannot request user identity information. Google rejects token requests
- * that include these scopes for service account credentials.
- */
-const SA_EXCLUDED_SCOPES = new Set([
- 'https://www.googleapis.com/auth/userinfo.email',
- 'https://www.googleapis.com/auth/userinfo.profile',
-])
-
-/** Google's documented JWT error codes are safe to retain without the provider description. */
-const SA_DIAGNOSTIC_ERROR_CODES = new Set([
- 'access_denied',
- 'admin_policy_enforced',
- 'deleted_client',
- 'disabled_client',
- 'invalid_client',
- 'invalid_grant',
- 'invalid_scope',
- 'org_internal',
- 'unauthorized_client',
-])
-
/**
* The decrypted key of a live Google service-account credential. Reads running at once (one per
* delegated subject) share one query and decrypt; a later read queries again, so it sees a
@@ -293,10 +276,11 @@ function readGoogleServiceAccountKey(credentialId: string): Promise !SA_EXCLUDED_SCOPES.has(s))
+ const filteredScopes = scopes.filter((s) => !GOOGLE_SERVICE_ACCOUNT_EXCLUDED_SCOPES.has(s))
if (isGoogleWorkloadIdentitySecret(decrypted)) {
const { resolveGoogleWorkloadIdentityToken } = await import(
@@ -420,7 +404,9 @@ export async function getServiceAccountToken(
: {
subject: impersonateEmail,
scopes: filteredScopes,
- ...(errorCode && SA_DIAGNOSTIC_ERROR_CODES.has(errorCode) ? { errorCode } : {}),
+ ...(errorCode && GOOGLE_TOKEN_DIAGNOSTIC_ERROR_CODES.has(errorCode)
+ ? { errorCode }
+ : {}),
}),
})
throw new ServiceAccountTokenError(response.status, description, errorCode)
diff --git a/apps/sim/lib/oauth/google-service-account-transport.ts b/apps/sim/lib/oauth/google-service-account-transport.ts
index f3e3b58df0d..85bd48c1849 100644
--- a/apps/sim/lib/oauth/google-service-account-transport.ts
+++ b/apps/sim/lib/oauth/google-service-account-transport.ts
@@ -6,6 +6,19 @@ import {
retryWithExponentialBackoff,
} from '@/lib/knowledge/documents/utils'
+/** Google's documented JWT-bearer error codes, safe to show or log without the provider description. */
+export const GOOGLE_TOKEN_DIAGNOSTIC_ERROR_CODES: ReadonlySet = new Set([
+ 'access_denied',
+ 'admin_policy_enforced',
+ 'deleted_client',
+ 'disabled_client',
+ 'invalid_client',
+ 'invalid_grant',
+ 'invalid_scope',
+ 'org_internal',
+ 'unauthorized_client',
+])
+
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504])
const TOKEN_EXCHANGE_BUDGET_MS = 30_000
const logger = createLogger('GoogleServiceAccountTransport')
diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts
index bbb8193a6c2..49f868cfb7e 100644
--- a/apps/sim/lib/oauth/token-resolution.ts
+++ b/apps/sim/lib/oauth/token-resolution.ts
@@ -491,7 +491,7 @@ export async function resolveCredentialToken(
status: 400,
code: error.code,
error:
- 'This credential cannot be used here. Keyless Google Cloud credentials work only with Google Cloud tools such as BigQuery and cannot impersonate a Google Workspace user; use a JSON key credential instead',
+ 'This credential cannot be used here. A keyless Google credential works only with Google Cloud tools such as BigQuery and, through domain-wide delegation, with Gmail, Google Drive, Google Docs, Google Sheets, and Google Calendar, as its own Workspace user and for the scopes it was delegated. Reconnect it to add one of those services, or use a JSON key credential instead',
}
}
if (error.code === 'grant_not_permitted') {
diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts
index b80825aa074..80319f0475c 100644
--- a/apps/sim/lib/oauth/utils.ts
+++ b/apps/sim/lib/oauth/utils.ts
@@ -918,9 +918,17 @@ export function getMissingRequiredScopes(
* `drive.readonly`, and a read-only grant never satisfies a write scope.
*/
export function isScopeSatisfiedBy(required: string, granted: ReadonlySet): boolean {
+ const readWrite = readWriteScopeOf(required)
+ return readWrite !== undefined && granted.has(readWrite)
+}
+
+/**
+ * The read-write scope that subsumes a `.readonly` scope (`drive` for
+ * `drive.readonly`), or undefined for a scope that is not read-only.
+ */
+export function readWriteScopeOf(scope: string): string | undefined {
const readonlySuffix = '.readonly'
- if (!required.endsWith(readonlySuffix)) return false
- return granted.has(required.slice(0, -readonlySuffix.length))
+ return scope.endsWith(readonlySuffix) ? scope.slice(0, -readonlySuffix.length) : undefined
}
/**