Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions apps/docs/content/docs/en/credentials/google-service-account.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
---
title: Google Workspace Delegated Accounts
description: Set up Google service accounts with domain-wide delegation for Gmail, Sheets, Drive, Calendar, and other Google services
---

import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { FAQ } from '@/components/ui/faq'

Google service accounts with domain-wide delegation let your workflows access Google APIs on behalf of users in your Google Workspace domain — without requiring each user to complete an OAuth consent flow. This is ideal for automated workflows that need to send emails, read spreadsheets, or manage files across your organization.

## Prerequisites

Before adding a service account to Sim, you need to configure it in the Google Cloud Console and Google Workspace Admin Console.

### 1. Create a Service Account in Google Cloud

<Steps>
<Step>
Go to the [Google Cloud Console](https://console.cloud.google.com/) and select your project (or create one)
</Step>
<Step>
Navigate to **IAM & Admin** → **Service Accounts**
</Step>
<Step>
Click **Create Service Account**, give it a name and description, then click **Create and Continue**
</Step>
<Step>
Skip the optional role and user access steps and click **Done**
</Step>
<Step>
Click on the newly created service account, go to the **Keys** tab, and click **Add Key** → **Create new key**
</Step>
<Step>
Select **JSON** as the key type and click **Create**. A JSON key file will download — keep this safe
</Step>
</Steps>

<Callout type="warn">
The JSON key file contains your service account's private key. Treat it like a password — do not commit it to source control or share it publicly.
</Callout>

### 2. Enable the Required APIs

Enable the Google APIs your workflows will use. In the Google Cloud Console, go to **APIs & Services** → **Library** and enable the relevant APIs:

- **Gmail API** — for sending and reading emails
- **Google Sheets API** — for reading and writing spreadsheets
- **Google Drive API** — for managing files and folders
- **Google Calendar API** — for managing calendar events
- **Google Docs API** — for reading and creating documents
- **BigQuery API** — for running queries

### 3. Set Up Domain-Wide Delegation

<Steps>
<Step>
In the Google Cloud Console, go to **IAM & Admin** → **Service Accounts**, click on your service account, and copy the **Client ID** (the numeric ID, not the email)
</Step>
<Step>
Open the [Google Workspace Admin Console](https://admin.google.com/) and navigate to **Security** → **Access and data control** → **API controls**
</Step>
<Step>
Click **Manage Domain Wide Delegation**, then click **Add new**
</Step>
<Step>
Paste the **Client ID** from your service account
</Step>
<Step>
Add the OAuth scopes your workflows need. Common scopes include:

- `https://mail.google.com/` — full Gmail access
- `https://www.googleapis.com/auth/spreadsheets` — Google Sheets
- `https://www.googleapis.com/auth/drive` — Google Drive
- `https://www.googleapis.com/auth/calendar` — Google Calendar
- `https://www.googleapis.com/auth/documents` — Google Docs
- `https://www.googleapis.com/auth/bigquery` — BigQuery
</Step>
<Step>
Click **Authorize**
</Step>
</Steps>

<Callout type="info">
Domain-wide delegation must be configured by a Google Workspace admin. If you are not an admin, send the Client ID and required scopes to your admin.
</Callout>

## Adding the Service Account to Sim

Once Google Cloud and Workspace are configured, add the service account as a credential in Sim.

<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
Select the Google service you want to use (e.g., Gmail, Google Sheets)
</Step>
<Step>
Choose **Service Account** as the authentication method
</Step>
<Step>
Paste the full contents of your JSON key file into the text area
</Step>
<Step>
Give the credential a display name (the service account email is used by default)
</Step>
<Step>
Click **Save**
</Step>
</Steps>

The JSON key file is validated for the required fields (`type`, `client_email`, `private_key`, `project_id`) and encrypted before being stored.

## Using Delegated Access in Workflows

When you use a Google block (Gmail, Sheets, Drive, etc.) in a workflow and select a service account credential, an **Impersonate User Email** field appears below the credential selector.

Enter the email address of the Google Workspace user you want the service account to act as. For example, if you enter `alice@yourcompany.com`, the workflow will send emails from Alice's account, read her spreadsheets, or access her calendar — depending on the scopes you authorized.

<Callout type="warn">
The impersonated email must belong to a user in the Google Workspace domain where you configured domain-wide delegation. Impersonating external email addresses will fail.
</Callout>

## How It Works

When a workflow runs with a service account credential:

1. Sim creates a signed JWT using the service account's private key
2. If an impersonation email is set, it is included as the `sub` (subject) claim in the JWT
3. The JWT is exchanged with Google's OAuth2 token endpoint for a short-lived access token (1 hour)
4. The access token is used to call the Google API on behalf of the impersonated user

This flow does not require a browser-based consent screen, making it suitable for fully automated, server-side workflows.

## Service Account vs. OAuth

| | Service Account | OAuth |
|---|---|---|
| **Best for** | Automated server-side workflows | Interactive, user-initiated workflows |
| **Setup** | JSON key + Workspace admin delegation | User clicks "Connect" and authorizes |
| **User consent** | Not required | Required per user |
| **Impersonation** | Can act as any user in the domain | Acts as the connected user only |
| **Token refresh** | New tokens generated via JWT signing | Automatic refresh via refresh token |
| **Scope** | Domain-wide (all authorized users) | Single user |

<FAQ items={[
{ question: "Can I use a service account without domain-wide delegation?", answer: "Yes, but it will only be able to access resources owned by the service account itself (e.g., spreadsheets shared directly with the service account email). Without delegation, you cannot impersonate users or access their personal data like Gmail." },
{ question: "What happens if the impersonation email field is left blank?", answer: "The service account will authenticate as itself. This works for accessing shared resources (like a Google Sheet shared with the service account email) but will fail for user-specific APIs like Gmail." },
{ question: "Can I use the same service account for multiple Google services?", answer: "Yes. A single service account can be used across Gmail, Sheets, Drive, Calendar, and other Google services — as long as the required API is enabled in Google Cloud and the corresponding scopes are authorized in the Workspace admin console." },
{ question: "How do I rotate the service account key?", answer: "Create a new JSON key in the Google Cloud Console under your service account's Keys tab, then update the credential in Sim with the new key. Delete the old key from Google Cloud once the new one is working." },
{ question: "Does the impersonated user need a Google Workspace license?", answer: "Yes. Domain-wide delegation only works with users who have a Google Workspace account in the domain. Consumer Gmail accounts (e.g., @gmail.com) cannot be impersonated." },
]} />
5 changes: 5 additions & 0 deletions apps/docs/content/docs/en/credentials/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"title": "Credentials",
"pages": ["index", "google-service-account"],
"defaultOpen": false
}
96 changes: 88 additions & 8 deletions apps/sim/app/api/auth/oauth/credentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import {
getCanonicalScopesForProvider,
getServiceAccountProviderForProviderId,
} from '@/lib/oauth/utils'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'

Expand Down Expand Up @@ -149,6 +152,7 @@ export async function GET(request: NextRequest) {
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
updatedAt: credential.updatedAt,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
Expand All @@ -159,6 +163,45 @@ export async function GET(request: NextRequest) {
.limit(1)

if (platformCredential) {
if (platformCredential.type === 'service_account') {
if (workflowId) {
if (!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)

if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}

return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.providerId || 'google-service-account',
platformCredential.updatedAt,
null
),
],
},
{ status: 200 }
)
}

if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
Expand Down Expand Up @@ -238,14 +281,51 @@ export async function GET(request: NextRequest) {
)
)

return NextResponse.json(
{
credentials: credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
),
},
{ status: 200 }
const results = credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
)

const saProviderId = getServiceAccountProviderForProviderId(providerParam)

if (saProviderId) {
const serviceAccountCreds = await db
.select({
id: credential.id,
displayName: credential.displayName,
providerId: credential.providerId,
updatedAt: credential.updatedAt,
})
.from(credential)
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'service_account'),
eq(credential.providerId, saProviderId)
)
)

for (const sa of serviceAccountCreds) {
results.push(
toCredentialResponse(
sa.id,
sa.displayName,
sa.providerId || saProviderId,
sa.updatedAt,
null
)
)
}
}

return NextResponse.json({ credentials: results }, { status: 200 })
}

return NextResponse.json({ credentials: [] }, { status: 200 })
Expand Down
45 changes: 43 additions & 2 deletions apps/sim/app/api/auth/oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { z } from 'zod'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import {
getCredential,
getOAuthToken,
getServiceAccountToken,
refreshTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'

export const dynamic = 'force-dynamic'

Expand All @@ -18,6 +24,8 @@ const tokenRequestSchema = z
credentialAccountUserId: z.string().min(1).optional(),
providerId: z.string().min(1).optional(),
workflowId: z.string().min(1).nullish(),
scopes: z.array(z.string()).optional(),
impersonateEmail: z.string().email().optional(),
})
.refine(
(data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
Expand Down Expand Up @@ -63,7 +71,14 @@ export async function POST(request: NextRequest) {
)
}

const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data
const {
credentialId,
credentialAccountUserId,
providerId,
workflowId,
scopes,
impersonateEmail,
} = parseResult.data

if (credentialAccountUserId && providerId) {
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
Expand Down Expand Up @@ -112,6 +127,32 @@ export async function POST(request: NextRequest) {

const callerUserId = new URL(request.url).searchParams.get('userId') || undefined

const resolved = await resolveOAuthAccountId(credentialId)
if (resolved?.credentialType === 'service_account' && resolved.credentialId) {
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false,
callerUserId,
})
if (!authz.ok) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}

try {
const defaultScopes = ['https://www.googleapis.com/auth/cloud-platform']
const accessToken = await getServiceAccountToken(
resolved.credentialId,
scopes && scopes.length > 0 ? scopes : defaultScopes,
impersonateEmail
)
return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Service account token error:`, error)
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
}
}

const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
Expand Down
Loading