diff --git a/apps/docs/content/docs/en/credentials/google-service-account.mdx b/apps/docs/content/docs/en/credentials/google-service-account.mdx new file mode 100644 index 00000000000..4ada7afa9cd --- /dev/null +++ b/apps/docs/content/docs/en/credentials/google-service-account.mdx @@ -0,0 +1,206 @@ +--- +title: Google Service 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 { Image } from '@/components/ui/image' +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 + + + + Go to the [Google Cloud Console](https://console.cloud.google.com/) and select your project (or create one) + + + Navigate to **IAM & Admin** → **Service Accounts** + + + Click **Create Service Account**, give it a name and description, then click **Create and Continue** + +
+ Google Cloud Console — Create service account form +
+
+ + Skip the optional role and user access steps and click **Done** + + + Click on the newly created service account, go to the **Keys** tab, and click **Add Key** → **Create new key** + + + Select **JSON** as the key type and click **Create**. A JSON key file will download — keep this safe + +
+ Google Cloud Console — Create private key dialog with JSON selected +
+
+
+ + +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. + + +### 2. Enable the Required APIs + +In the Google Cloud Console, go to **APIs & Services** → **Library** and enable the APIs for the services your workflows will use. See the [scopes reference](#scopes-reference) below for the full list of APIs by service. + +### 3. Set Up Domain-Wide Delegation + + + + 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) + + + Open the [Google Workspace Admin Console](https://admin.google.com/) and navigate to **Security** → **Access and data control** → **API controls** + + + Click **Manage Domain Wide Delegation**, then click **Add new** + + + Paste the **Client ID** from your service account, then add the OAuth scopes for the services your workflows need. Copy the full scope URLs from the [scopes reference](#scopes-reference) below — only authorize scopes for services you plan to use. + +
+ Google Workspace Admin Console — Add a new client ID with OAuth scopes +
+
+ + Click **Authorize** + +
+ + +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. + + +### Scopes Reference + +The table below lists every Google service that supports service account authentication in Sim, the API to enable in Google Cloud Console, and the delegation scopes to authorize. Copy the scope string for each service you need and paste it into the Google Workspace Admin Console. + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServiceAPI to EnableDelegation Scopes
GmailGmail API{'https://www.googleapis.com/auth/gmail.send'}
{'https://www.googleapis.com/auth/gmail.modify'}
{'https://www.googleapis.com/auth/gmail.labels'}
Google SheetsGoogle Sheets API, Google Drive API{'https://www.googleapis.com/auth/drive'}
Google DriveGoogle Drive API{'https://www.googleapis.com/auth/drive'}
Google DocsGoogle Docs API, Google Drive API{'https://www.googleapis.com/auth/drive'}
Google SlidesGoogle Slides API, Google Drive API{'https://www.googleapis.com/auth/drive'}
Google FormsGoogle Forms API, Google Drive API{'https://www.googleapis.com/auth/drive'}
{'https://www.googleapis.com/auth/forms.body'}
{'https://www.googleapis.com/auth/forms.responses.readonly'}
Google CalendarGoogle Calendar API{'https://www.googleapis.com/auth/calendar'}
Google ContactsPeople API{'https://www.googleapis.com/auth/contacts'}
Google AdsGoogle Ads API{'https://www.googleapis.com/auth/adwords'}
BigQueryBigQuery API{'https://www.googleapis.com/auth/bigquery'}
Google TasksTasks API{'https://www.googleapis.com/auth/tasks'}
Google VaultVault API, Cloud Storage API{'https://www.googleapis.com/auth/ediscovery'}
{'https://www.googleapis.com/auth/devstorage.read_only'}
Google GroupsAdmin SDK API{'https://www.googleapis.com/auth/admin.directory.group'}
{'https://www.googleapis.com/auth/admin.directory.group.member'}
Google MeetGoogle Meet API{'https://www.googleapis.com/auth/meetings.space.created'}
{'https://www.googleapis.com/auth/meetings.space.readonly'}
Vertex AIVertex AI API{'https://www.googleapis.com/auth/cloud-platform'}
+ + +You only need to enable APIs and authorize scopes for the services you plan to use. When authorizing multiple services, combine their scope strings with commas into a single entry in the Admin Console. + + +## Adding the Service Account to Sim + +Once Google Cloud and Workspace are configured, add the service account as a credential in Sim. + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "Google Service Account" and click **Connect** + +
+ Integrations page showing Google Service Account +
+
+ + Paste the full contents of your JSON key file into the text area +
+ Add Google Service Account dialog +
+
+ + Give the credential a display name (the service account email is used by default) + + + Click **Save** + +
+ +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. + +
+ Gmail block in a workflow showing the Impersonated Account field with a service account credential +
+ + +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. + + + diff --git a/apps/docs/content/docs/en/credentials/meta.json b/apps/docs/content/docs/en/credentials/meta.json new file mode 100644 index 00000000000..78cd836b509 --- /dev/null +++ b/apps/docs/content/docs/en/credentials/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Credentials", + "pages": ["index", "google-service-account"], + "defaultOpen": false +} diff --git a/apps/docs/public/static/credentials/add-service-account.png b/apps/docs/public/static/credentials/add-service-account.png new file mode 100644 index 00000000000..fed97166106 Binary files /dev/null and b/apps/docs/public/static/credentials/add-service-account.png differ diff --git a/apps/docs/public/static/credentials/gcp-add-client-id.png b/apps/docs/public/static/credentials/gcp-add-client-id.png new file mode 100644 index 00000000000..df1a1e69530 Binary files /dev/null and b/apps/docs/public/static/credentials/gcp-add-client-id.png differ diff --git a/apps/docs/public/static/credentials/gcp-create-private-key.png b/apps/docs/public/static/credentials/gcp-create-private-key.png new file mode 100644 index 00000000000..183d932562f Binary files /dev/null and b/apps/docs/public/static/credentials/gcp-create-private-key.png differ diff --git a/apps/docs/public/static/credentials/gcp-create-service-account.png b/apps/docs/public/static/credentials/gcp-create-service-account.png new file mode 100644 index 00000000000..db3a45bf3bc Binary files /dev/null and b/apps/docs/public/static/credentials/gcp-create-service-account.png differ diff --git a/apps/docs/public/static/credentials/integrations-service-account.png b/apps/docs/public/static/credentials/integrations-service-account.png new file mode 100644 index 00000000000..83f232afea3 Binary files /dev/null and b/apps/docs/public/static/credentials/integrations-service-account.png differ diff --git a/apps/docs/public/static/credentials/workflow-impersonated-account.png b/apps/docs/public/static/credentials/workflow-impersonated-account.png new file mode 100644 index 00000000000..12a23fc56e8 Binary files /dev/null and b/apps/docs/public/static/credentials/workflow-impersonated-account.png differ diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index eab12f41f86..8ea44c37dd7 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -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' @@ -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, @@ -159,6 +163,48 @@ export async function GET(request: NextRequest) { .limit(1) if (platformCredential) { + if (platformCredential.type === 'service_account') { + if ( + workflowId && + (!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId) + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + if (!workflowId) { + 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 }) } @@ -238,14 +284,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 }) diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index 4f3d06d3a5a..7970a9b5180 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -11,6 +11,8 @@ const { mockGetCredential, mockRefreshTokenIfNeeded, mockGetOAuthToken, + mockResolveOAuthAccountId, + mockGetServiceAccountToken, mockAuthorizeCredentialUse, mockCheckSessionOrInternalAuth, mockLogger, @@ -29,6 +31,8 @@ const { mockGetCredential: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), mockGetOAuthToken: vi.fn(), + mockResolveOAuthAccountId: vi.fn(), + mockGetServiceAccountToken: vi.fn(), mockAuthorizeCredentialUse: vi.fn(), mockCheckSessionOrInternalAuth: vi.fn(), mockLogger: logger, @@ -40,6 +44,8 @@ vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: mockGetCredential, refreshTokenIfNeeded: mockRefreshTokenIfNeeded, getOAuthToken: mockGetOAuthToken, + resolveOAuthAccountId: mockResolveOAuthAccountId, + getServiceAccountToken: mockGetServiceAccountToken, })) vi.mock('@sim/logger', () => ({ @@ -50,6 +56,10 @@ vi.mock('@/lib/auth/credential-access', () => ({ authorizeCredentialUse: mockAuthorizeCredentialUse, })) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('test-request-id'), +})) + vi.mock('@/lib/auth/hybrid', () => ({ AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' }, checkHybridAuth: vi.fn(), @@ -62,6 +72,7 @@ import { GET, POST } from '@/app/api/auth/oauth/token/route' describe('OAuth Token API Routes', () => { beforeEach(() => { vi.clearAllMocks() + mockResolveOAuthAccountId.mockResolvedValue(null) }) /** diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index fcc6f128702..4dc048c334b 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -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' @@ -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), @@ -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`, { @@ -112,6 +127,31 @@ 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 accessToken = await getServiceAccountToken( + resolved.credentialId, + scopes ?? [], + 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, diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 7320a7bb9a7..417a26400be 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -160,6 +160,12 @@ describe('OAuth Utils', () => { describe('refreshAccessTokenIfNeeded', () => { it('should return valid access token without refresh if not expired', async () => { + const mockResolvedCredential = { + id: 'credential-id', + type: 'oauth', + accountId: 'account-id', + workspaceId: 'workspace-id', + } const mockCredentialRow = { type: 'oauth', accountId: 'account-id' } const mockAccountRow = { id: 'account-id', @@ -169,6 +175,7 @@ describe('OAuth Utils', () => { providerId: 'google', userId: 'test-user-id', } + mockSelectChain([mockResolvedCredential]) mockSelectChain([mockCredentialRow]) mockSelectChain([mockAccountRow]) @@ -179,6 +186,12 @@ describe('OAuth Utils', () => { }) it('should refresh token when expired', async () => { + const mockResolvedCredential = { + id: 'credential-id', + type: 'oauth', + accountId: 'account-id', + workspaceId: 'workspace-id', + } const mockCredentialRow = { type: 'oauth', accountId: 'account-id' } const mockAccountRow = { id: 'account-id', @@ -188,6 +201,7 @@ describe('OAuth Utils', () => { providerId: 'google', userId: 'test-user-id', } + mockSelectChain([mockResolvedCredential]) mockSelectChain([mockCredentialRow]) mockSelectChain([mockAccountRow]) mockUpdateChain() @@ -215,6 +229,12 @@ describe('OAuth Utils', () => { }) it('should return null if refresh fails', async () => { + const mockResolvedCredential = { + id: 'credential-id', + type: 'oauth', + accountId: 'account-id', + workspaceId: 'workspace-id', + } const mockCredentialRow = { type: 'oauth', accountId: 'account-id' } const mockAccountRow = { id: 'account-id', @@ -224,6 +244,7 @@ describe('OAuth Utils', () => { providerId: 'google', userId: 'test-user-id', } + mockSelectChain([mockResolvedCredential]) mockSelectChain([mockCredentialRow]) mockSelectChain([mockAccountRow]) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 4228c3f3f2b..4b3431649dc 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -1,7 +1,9 @@ +import { createSign } from 'crypto' import { db } from '@sim/db' import { account, credential, credentialSetMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, desc, eq, inArray } from 'drizzle-orm' +import { decryptSecret } from '@/lib/core/security/encryption' import { refreshOAuthToken } from '@/lib/oauth' import { getMicrosoftRefreshTokenExpiry, @@ -11,6 +13,16 @@ import { const logger = createLogger('OAuthUtilsAPI') +export class ServiceAccountTokenError extends Error { + constructor( + public readonly statusCode: number, + public readonly errorDescription: string + ) { + super(errorDescription) + this.name = 'ServiceAccountTokenError' + } +} + interface AccountInsertData { id: string userId: string @@ -25,16 +37,26 @@ interface AccountInsertData { accessTokenExpiresAt?: Date } +export interface ResolvedCredential { + accountId: string + workspaceId?: string + usedCredentialTable: boolean + credentialType?: string + credentialId?: string +} + /** * Resolves a credential ID to its underlying account ID. * If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`. + * For service_account credentials, returns credentialId and type instead of accountId. * Otherwise assumes `credentialId` is already a raw `account.id` (legacy). */ export async function resolveOAuthAccountId( credentialId: string -): Promise<{ accountId: string; workspaceId?: string; usedCredentialTable: boolean } | null> { +): Promise { const [credentialRow] = await db .select({ + id: credential.id, type: credential.type, accountId: credential.accountId, workspaceId: credential.workspaceId, @@ -44,6 +66,16 @@ export async function resolveOAuthAccountId( .limit(1) if (credentialRow) { + if (credentialRow.type === 'service_account') { + return { + accountId: '', + credentialId: credentialRow.id, + credentialType: 'service_account', + workspaceId: credentialRow.workspaceId, + usedCredentialTable: true, + } + } + if (credentialRow.type !== 'oauth' || !credentialRow.accountId) { return null } @@ -57,6 +89,115 @@ 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', +]) + +/** + * Generates a short-lived access token for a Google service account credential + * using the two-legged OAuth JWT flow (RFC 7523). + * + * @param impersonateEmail - Optional. Required for Google Workspace APIs (Gmail, Drive, Calendar, etc.) + * where the service account must impersonate a domain user via domain-wide delegation. + * Not needed for project-scoped APIs like BigQuery or Vertex AI where the service account + * authenticates directly with its own IAM permissions. + */ +export async function getServiceAccountToken( + credentialId: string, + scopes: string[], + impersonateEmail?: string +): Promise { + const [credentialRow] = await db + .select({ + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + + if (!credentialRow?.encryptedServiceAccountKey) { + throw new Error('Service account key not found') + } + + const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) + const keyData = JSON.parse(decrypted) as { + client_email: string + private_key: string + token_uri?: string + } + + const filteredScopes = scopes.filter((s) => !SA_EXCLUDED_SCOPES.has(s)) + + const now = Math.floor(Date.now() / 1000) + const tokenUri = keyData.token_uri || 'https://oauth2.googleapis.com/token' + + const header = { alg: 'RS256', typ: 'JWT' } + const payload: Record = { + iss: keyData.client_email, + scope: filteredScopes.join(' '), + aud: tokenUri, + iat: now, + exp: now + 3600, + } + + if (impersonateEmail) { + payload.sub = impersonateEmail + } + + logger.info('Service account JWT payload', { + iss: keyData.client_email, + sub: impersonateEmail || '(none)', + scopes: filteredScopes.join(' '), + aud: tokenUri, + }) + + const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url') + + const signingInput = `${toBase64Url(header)}.${toBase64Url(payload)}` + + const signer = createSign('RSA-SHA256') + signer.update(signingInput) + const signature = signer.sign(keyData.private_key, 'base64url') + + const jwt = `${signingInput}.${signature}` + + const response = await fetch(tokenUri, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwt, + }), + }) + + if (!response.ok) { + const errorBody = await response.text() + logger.error('Service account token exchange failed', { + status: response.status, + body: errorBody, + }) + let description = `Token exchange failed: ${response.status}` + try { + const parsed = JSON.parse(errorBody) as { error_description?: string } + if (parsed.error_description) { + description = parsed.error_description + } + } catch { + // use default description + } + throw new ServiceAccountTokenError(response.status, description) + } + + const tokenData = (await response.json()) as { access_token: string } + return tokenData.access_token +} + /** * Safely inserts an account record, handling duplicate constraint violations gracefully. * If a duplicate is detected (unique constraint violation), logs a warning and returns success. @@ -196,17 +337,34 @@ export async function getOAuthToken(userId: string, providerId: string): Promise } /** - * Refreshes an OAuth token if needed based on credential information + * Refreshes an OAuth token if needed based on credential information. + * Also handles service account credentials by generating a JWT-based token. * @param credentialId The ID of the credential to check and potentially refresh * @param userId The user ID who owns the credential (for security verification) * @param requestId Request ID for log correlation + * @param scopes Optional scopes for service account token generation * @returns The valid access token or null if refresh fails */ export async function refreshAccessTokenIfNeeded( credentialId: string, userId: string, - requestId: string + requestId: string, + scopes?: string[], + impersonateEmail?: string ): Promise { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + return null + } + + if (resolved.credentialType === 'service_account' && resolved.credentialId) { + if (!scopes?.length) { + throw new Error('Scopes are required for service account credentials') + } + logger.info(`[${requestId}] Using service account token for credential`) + return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail) + } + // Get the credential directly using the getCredential helper const credential = await getCredential(requestId, credentialId, userId) diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 7da93846c75..37cc3cae23e 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { encryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { syncPersonalEnvCredentialsForUser, @@ -17,12 +18,19 @@ const updateCredentialSchema = z .object({ displayName: z.string().trim().min(1).max(255).optional(), description: z.string().trim().max(500).nullish(), + serviceAccountJson: z.string().min(1).optional(), }) .strict() - .refine((data) => data.displayName !== undefined || data.description !== undefined, { - message: 'At least one field must be provided', - path: ['displayName'], - }) + .refine( + (data) => + data.displayName !== undefined || + data.description !== undefined || + data.serviceAccountJson !== undefined, + { + message: 'At least one field must be provided', + path: ['displayName'], + } + ) async function getCredentialResponse(credentialId: string, userId: string) { const [row] = await db @@ -106,12 +114,36 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ updates.description = parseResult.data.description ?? null } - if (parseResult.data.displayName !== undefined && access.credential.type === 'oauth') { + if ( + parseResult.data.displayName !== undefined && + (access.credential.type === 'oauth' || access.credential.type === 'service_account') + ) { updates.displayName = parseResult.data.displayName } + if ( + parseResult.data.serviceAccountJson !== undefined && + access.credential.type === 'service_account' + ) { + try { + const parsed = JSON.parse(parseResult.data.serviceAccountJson) + if ( + parsed.type !== 'service_account' || + typeof parsed.client_email !== 'string' || + typeof parsed.private_key !== 'string' || + typeof parsed.project_id !== 'string' + ) { + return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 }) + } + const { encrypted } = await encryptSecret(parseResult.data.serviceAccountJson) + updates.encryptedServiceAccountKey = encrypted + } catch { + return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 }) + } + } + if (Object.keys(updates).length === 0) { - if (access.credential.type === 'oauth') { + if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { return NextResponse.json( { error: 'No updatable fields provided.', @@ -134,6 +166,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const row = await getCredentialResponse(id, session.user.id) return NextResponse.json({ credential: row }, { status: 200 }) } catch (error) { + if (error instanceof Error && error.message.includes('unique')) { + return NextResponse.json( + { error: 'A service account credential with this name already exists in the workspace' }, + { status: 409 } + ) + } logger.error('Failed to update credential', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index e0fea07f3e3..3184a82ba9f 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' @@ -14,7 +15,7 @@ import { isValidEnvVarName } from '@/executor/constants' const logger = createLogger('CredentialsAPI') -const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal']) +const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal', 'service_account']) function normalizeEnvKeyInput(raw: string): string { const trimmed = raw.trim() @@ -29,6 +30,56 @@ const listCredentialsSchema = z.object({ credentialId: z.string().optional(), }) +const serviceAccountJsonSchema = z + .string() + .min(1, 'Service account JSON key is required') + .transform((val, ctx) => { + try { + const parsed = JSON.parse(val) + if (parsed.type !== 'service_account') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JSON key must have type "service_account"', + }) + return z.NEVER + } + if (!parsed.client_email || typeof parsed.client_email !== 'string') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JSON key must contain a valid client_email', + }) + return z.NEVER + } + if (!parsed.private_key || typeof parsed.private_key !== 'string') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JSON key must contain a valid private_key', + }) + return z.NEVER + } + if (!parsed.project_id || typeof parsed.project_id !== 'string') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JSON key must contain a valid project_id', + }) + return z.NEVER + } + return parsed as { + type: 'service_account' + client_email: string + private_key: string + project_id: string + [key: string]: unknown + } + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid JSON format', + }) + return z.NEVER + } + }) + const createCredentialSchema = z .object({ workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), @@ -39,6 +90,7 @@ const createCredentialSchema = z accountId: z.string().trim().min(1).optional(), envKey: z.string().trim().min(1).optional(), envOwnerUserId: z.string().trim().min(1).optional(), + serviceAccountJson: z.string().optional(), }) .superRefine((data, ctx) => { if (data.type === 'oauth') { @@ -66,6 +118,17 @@ const createCredentialSchema = z return } + if (data.type === 'service_account') { + if (!data.serviceAccountJson) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'serviceAccountJson is required for service account credentials', + path: ['serviceAccountJson'], + }) + } + return + } + const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : '' if (!normalizedEnvKey) { ctx.addIssue({ @@ -87,14 +150,16 @@ const createCredentialSchema = z interface ExistingCredentialSourceParams { workspaceId: string - type: 'oauth' | 'env_workspace' | 'env_personal' + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' accountId?: string | null envKey?: string | null envOwnerUserId?: string | null + displayName?: string | null + providerId?: string | null } async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) { - const { workspaceId, type, accountId, envKey, envOwnerUserId } = params + const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params if (type === 'oauth' && accountId) { const [row] = await db @@ -142,6 +207,22 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa return row ?? null } + if (type === 'service_account' && displayName && providerId) { + const [row] = await db + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, providerId), + eq(credential.displayName, displayName) + ) + ) + .limit(1) + return row ?? null + } + return null } @@ -288,6 +369,7 @@ export async function POST(request: NextRequest) { accountId, envKey, envOwnerUserId, + serviceAccountJson, } = parseResult.data const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) @@ -301,6 +383,7 @@ export async function POST(request: NextRequest) { let resolvedAccountId: string | null = accountId ?? null const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null let resolvedEnvOwnerUserId: string | null = null + let resolvedEncryptedServiceAccountKey: string | null = null if (type === 'oauth') { const [accountRow] = await db @@ -335,6 +418,33 @@ export async function POST(request: NextRequest) { resolvedDisplayName = getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId } + } else if (type === 'service_account') { + if (!serviceAccountJson) { + return NextResponse.json( + { error: 'serviceAccountJson is required for service account credentials' }, + { status: 400 } + ) + } + + const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson) + if (!jsonParseResult.success) { + return NextResponse.json( + { error: jsonParseResult.error.errors[0]?.message || 'Invalid service account JSON' }, + { status: 400 } + ) + } + + const parsed = jsonParseResult.data + resolvedProviderId = 'google-service-account' + resolvedAccountId = null + resolvedEnvOwnerUserId = null + + if (!resolvedDisplayName) { + resolvedDisplayName = parsed.client_email + } + + const { encrypted } = await encryptSecret(serviceAccountJson) + resolvedEncryptedServiceAccountKey = encrypted } else if (type === 'env_personal') { resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id if (resolvedEnvOwnerUserId !== session.user.id) { @@ -363,6 +473,8 @@ export async function POST(request: NextRequest) { accountId: resolvedAccountId, envKey: resolvedEnvKey, envOwnerUserId: resolvedEnvOwnerUserId, + displayName: resolvedDisplayName, + providerId: resolvedProviderId, }) if (existingCredential) { @@ -441,12 +553,13 @@ export async function POST(request: NextRequest) { accountId: resolvedAccountId, envKey: resolvedEnvKey, envOwnerUserId: resolvedEnvOwnerUserId, + encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey, createdBy: session.user.id, createdAt: now, updatedAt: now, }) - if (type === 'env_workspace' && workspaceRow?.ownerId) { + if ((type === 'env_workspace' || type === 'service_account') && workspaceRow?.ownerId) { const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId) if (workspaceUserIds.length > 0) { for (const memberUserId of workspaceUserIds) { diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index 556240f33b7..b1ad3317728 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -6,7 +6,11 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { + getServiceAccountToken, + refreshTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' import type { StreamingExecution } from '@/executor/types' import { executeProviderRequest } from '@/providers' @@ -365,6 +369,14 @@ async function resolveVertexCredential(requestId: string, credentialId: string): throw new Error(`Vertex AI credential not found: ${credentialId}`) } + if (resolved.credentialType === 'service_account' && resolved.credentialId) { + const accessToken = await getServiceAccountToken(resolved.credentialId, [ + 'https://www.googleapis.com/auth/cloud-platform', + ]) + logger.info(`[${requestId}] Successfully resolved Vertex AI service account credential`) + return accessToken + } + const credential = await db.query.account.findFirst({ where: eq(account.id, resolved.accountId), }) diff --git a/apps/sim/app/api/tools/drive/file/route.ts b/apps/sim/app/api/tools/drive/file/route.ts index 1acd0c292b6..78fc6bdce91 100644 --- a/apps/sim/app/api/tools/drive/file/route.ts +++ b/apps/sim/app/api/tools/drive/file/route.ts @@ -4,7 +4,8 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getScopesForService } from '@/lib/oauth/utils' +import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GoogleDriveFileAPI') @@ -26,6 +27,7 @@ export async function GET(request: NextRequest) { const credentialId = searchParams.get('credentialId') const fileId = searchParams.get('fileId') const workflowId = searchParams.get('workflowId') || undefined + const impersonateEmail = searchParams.get('impersonateEmail') || undefined if (!credentialId || !fileId) { logger.warn(`[${requestId}] Missing required parameters`) @@ -46,7 +48,9 @@ export async function GET(request: NextRequest) { const accessToken = await refreshAccessTokenIfNeeded( credentialId, authz.credentialOwnerUserId, - requestId + requestId, + getScopesForService('google-drive'), + impersonateEmail ) if (!accessToken) { @@ -157,6 +161,10 @@ export async function GET(request: NextRequest) { return NextResponse.json({ file }, { status: 200 }) } catch (error) { + if (error instanceof ServiceAccountTokenError) { + logger.warn(`[${requestId}] Service account token error`, { message: error.message }) + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error(`[${requestId}] Error fetching file from Google Drive`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/drive/files/route.ts b/apps/sim/app/api/tools/drive/files/route.ts index 89b2e4936c4..2cdb0505ebc 100644 --- a/apps/sim/app/api/tools/drive/files/route.ts +++ b/apps/sim/app/api/tools/drive/files/route.ts @@ -4,7 +4,8 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getScopesForService } from '@/lib/oauth/utils' +import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GoogleDriveFilesAPI') @@ -85,6 +86,7 @@ export async function GET(request: NextRequest) { const query = searchParams.get('query') || '' const folderId = searchParams.get('folderId') || searchParams.get('parentId') || '' const workflowId = searchParams.get('workflowId') || undefined + const impersonateEmail = searchParams.get('impersonateEmail') || undefined if (!credentialId) { logger.warn(`[${requestId}] Missing credential ID`) @@ -100,7 +102,9 @@ export async function GET(request: NextRequest) { const accessToken = await refreshAccessTokenIfNeeded( credentialId!, authz.credentialOwnerUserId, - requestId + requestId, + getScopesForService('google-drive'), + impersonateEmail ) if (!accessToken) { @@ -175,6 +179,10 @@ export async function GET(request: NextRequest) { return NextResponse.json({ files }, { status: 200 }) } catch (error) { + if (error instanceof ServiceAccountTokenError) { + logger.warn(`[${requestId}] Service account token error`, { message: error.message }) + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error(`[${requestId}] Error fetching files from Google Drive`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/gmail/label/route.ts b/apps/sim/app/api/tools/gmail/label/route.ts index 26437d267af..d7829bdaad9 100644 --- a/apps/sim/app/api/tools/gmail/label/route.ts +++ b/apps/sim/app/api/tools/gmail/label/route.ts @@ -6,7 +6,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { getScopesForService } from '@/lib/oauth/utils' +import { + getServiceAccountToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, + ServiceAccountTokenError, +} from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -26,6 +32,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const credentialId = searchParams.get('credentialId') const labelId = searchParams.get('labelId') + const impersonateEmail = searchParams.get('impersonateEmail') || undefined if (!credentialId || !labelId) { logger.warn(`[${requestId}] Missing required parameters`) @@ -58,28 +65,39 @@ export async function GET(request: NextRequest) { } } - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) + let accessToken: string | null = null - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } + if (resolved.credentialType === 'service_account' && resolved.credentialId) { + accessToken = await getServiceAccountToken( + resolved.credentialId, + getScopesForService('gmail'), + impersonateEmail + ) + } else { + const credentials = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + + if (!credentials.length) { + logger.warn(`[${requestId}] Credential not found`) + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } - const accountRow = credentials[0] + const accountRow = credentials[0] - logger.info( - `[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}` - ) + logger.info( + `[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}` + ) - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, - requestId - ) + accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId, + getScopesForService('gmail') + ) + } if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) @@ -127,6 +145,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ label: formattedLabel }, { status: 200 }) } catch (error) { + if (error instanceof ServiceAccountTokenError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error(`[${requestId}] Error fetching Gmail label:`, error) return NextResponse.json({ error: 'Failed to fetch Gmail label' }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/gmail/labels/route.ts b/apps/sim/app/api/tools/gmail/labels/route.ts index 6aed016040c..a34df7d2679 100644 --- a/apps/sim/app/api/tools/gmail/labels/route.ts +++ b/apps/sim/app/api/tools/gmail/labels/route.ts @@ -6,7 +6,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { getScopesForService } from '@/lib/oauth/utils' +import { + getServiceAccountToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, + ServiceAccountTokenError, +} from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GmailLabelsAPI') @@ -33,6 +39,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const credentialId = searchParams.get('credentialId') const query = searchParams.get('query') + const impersonateEmail = searchParams.get('impersonateEmail') || undefined if (!credentialId) { logger.warn(`[${requestId}] Missing credentialId parameter`) @@ -62,28 +69,39 @@ export async function GET(request: NextRequest) { } } - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) + let accessToken: string | null = null - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } + if (resolved.credentialType === 'service_account' && resolved.credentialId) { + accessToken = await getServiceAccountToken( + resolved.credentialId, + getScopesForService('gmail'), + impersonateEmail + ) + } else { + const credentials = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + + if (!credentials.length) { + logger.warn(`[${requestId}] Credential not found`) + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } - const accountRow = credentials[0] + const accountRow = credentials[0] - logger.info( - `[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}` - ) + logger.info( + `[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}` + ) - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, - requestId - ) + accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId, + getScopesForService('gmail') + ) + } if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) @@ -139,6 +157,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ labels: filteredLabels }, { status: 200 }) } catch (error) { + if (error instanceof ServiceAccountTokenError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error(`[${requestId}] Error fetching Gmail labels:`, error) return NextResponse.json({ error: 'Failed to fetch Gmail labels' }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts index ffc4ef7235d..5f6ba8c10ce 100644 --- a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts @@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getScopesForService } from '@/lib/oauth/utils' +import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' const logger = createLogger('GoogleBigQueryDatasetsAPI') @@ -20,7 +21,7 @@ export async function POST(request: Request) { const requestId = generateRequestId() try { const body = await request.json() - const { credential, workflowId, projectId } = body + const { credential, workflowId, projectId, impersonateEmail } = body if (!credential) { logger.error('Missing credential in request') @@ -43,7 +44,9 @@ export async function POST(request: Request) { const accessToken = await refreshAccessTokenIfNeeded( credential, authz.credentialOwnerUserId, - requestId + requestId, + getScopesForService('google-bigquery'), + impersonateEmail ) if (!accessToken) { logger.error('Failed to get access token', { @@ -91,6 +94,9 @@ export async function POST(request: Request) { return NextResponse.json({ datasets }) } catch (error) { + if (error instanceof ServiceAccountTokenError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error('Error processing BigQuery datasets request:', error) return NextResponse.json( { error: 'Failed to retrieve BigQuery datasets', details: (error as Error).message }, diff --git a/apps/sim/app/api/tools/google_bigquery/tables/route.ts b/apps/sim/app/api/tools/google_bigquery/tables/route.ts index f2f7c6c43c4..2489d87821d 100644 --- a/apps/sim/app/api/tools/google_bigquery/tables/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/tables/route.ts @@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getScopesForService } from '@/lib/oauth/utils' +import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' const logger = createLogger('GoogleBigQueryTablesAPI') @@ -12,7 +13,7 @@ export async function POST(request: Request) { const requestId = generateRequestId() try { const body = await request.json() - const { credential, workflowId, projectId, datasetId } = body + const { credential, workflowId, projectId, datasetId, impersonateEmail } = body if (!credential) { logger.error('Missing credential in request') @@ -40,7 +41,9 @@ export async function POST(request: Request) { const accessToken = await refreshAccessTokenIfNeeded( credential, authz.credentialOwnerUserId, - requestId + requestId, + getScopesForService('google-bigquery'), + impersonateEmail ) if (!accessToken) { logger.error('Failed to get access token', { @@ -85,6 +88,9 @@ export async function POST(request: Request) { return NextResponse.json({ tables }) } catch (error) { + if (error instanceof ServiceAccountTokenError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error('Error processing BigQuery tables request:', error) return NextResponse.json( { error: 'Failed to retrieve BigQuery tables', details: (error as Error).message }, diff --git a/apps/sim/app/api/tools/google_calendar/calendars/route.ts b/apps/sim/app/api/tools/google_calendar/calendars/route.ts index 0493825399b..f0f38b63251 100644 --- a/apps/sim/app/api/tools/google_calendar/calendars/route.ts +++ b/apps/sim/app/api/tools/google_calendar/calendars/route.ts @@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getScopesForService } from '@/lib/oauth/utils' +import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GoogleCalendarAPI') @@ -28,6 +29,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const credentialId = searchParams.get('credentialId') const workflowId = searchParams.get('workflowId') || undefined + const impersonateEmail = searchParams.get('impersonateEmail') || undefined if (!credentialId) { logger.warn(`[${requestId}] Missing credentialId parameter`) @@ -41,7 +43,9 @@ export async function GET(request: NextRequest) { const accessToken = await refreshAccessTokenIfNeeded( credentialId, authz.credentialOwnerUserId, - requestId + requestId, + getScopesForService('google-calendar'), + impersonateEmail ) if (!accessToken) { @@ -98,6 +102,10 @@ export async function GET(request: NextRequest) { })), }) } catch (error) { + if (error instanceof ServiceAccountTokenError) { + logger.warn(`[${requestId}] Service account token error`, { message: error.message }) + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error(`[${requestId}] Error fetching Google calendars`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/google_sheets/sheets/route.ts b/apps/sim/app/api/tools/google_sheets/sheets/route.ts index 6eb9c9fc8ad..d5aae20e3e0 100644 --- a/apps/sim/app/api/tools/google_sheets/sheets/route.ts +++ b/apps/sim/app/api/tools/google_sheets/sheets/route.ts @@ -3,7 +3,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getScopesForService } from '@/lib/oauth/utils' +import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -40,6 +41,7 @@ export async function GET(request: NextRequest) { const credentialId = searchParams.get('credentialId') const spreadsheetId = searchParams.get('spreadsheetId') const workflowId = searchParams.get('workflowId') || undefined + const impersonateEmail = searchParams.get('impersonateEmail') || undefined if (!credentialId) { logger.warn(`[${requestId}] Missing credentialId parameter`) @@ -59,7 +61,9 @@ export async function GET(request: NextRequest) { const accessToken = await refreshAccessTokenIfNeeded( credentialId, authz.credentialOwnerUserId, - requestId + requestId, + getScopesForService('google-sheets'), + impersonateEmail ) if (!accessToken) { @@ -114,6 +118,10 @@ export async function GET(request: NextRequest) { })), }) } catch (error) { + if (error instanceof ServiceAccountTokenError) { + logger.warn(`[${requestId}] Service account token error`, { message: error.message }) + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error(`[${requestId}] Error fetching Google Sheets sheets`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts index 6448f216505..a4b831c9f3a 100644 --- a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts +++ b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts @@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getScopesForService } from '@/lib/oauth/utils' +import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' const logger = createLogger('GoogleTasksTaskListsAPI') @@ -12,7 +13,7 @@ export async function POST(request: Request) { const requestId = generateRequestId() try { const body = await request.json() - const { credential, workflowId } = body + const { credential, workflowId, impersonateEmail } = body if (!credential) { logger.error('Missing credential in request') @@ -30,7 +31,9 @@ export async function POST(request: Request) { const accessToken = await refreshAccessTokenIfNeeded( credential, authz.credentialOwnerUserId, - requestId + requestId, + getScopesForService('google-tasks'), + impersonateEmail ) if (!accessToken) { logger.error('Failed to get access token', { @@ -70,6 +73,9 @@ export async function POST(request: Request) { return NextResponse.json({ taskLists }) } catch (error) { + if (error instanceof ServiceAccountTokenError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } logger.error('Error processing Google Tasks task lists request:', error) return NextResponse.json( { error: 'Failed to retrieve Google Tasks task lists', details: (error as Error).message }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx index c59f5cc0fc5..d818238e7b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx @@ -23,6 +23,7 @@ import { } from '@/components/emcn' import { Input as UiInput } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' +import { cn } from '@/lib/core/utils/cn' import { clearPendingCredentialCreateRequest, PENDING_CREDENTIAL_CREATE_REQUEST_EVENT, @@ -91,6 +92,13 @@ export function IntegrationsManager() { | { type: 'kb-connectors'; knowledgeBaseId: string } | undefined >(undefined) + const [saJsonInput, setSaJsonInput] = useState('') + const [saDisplayName, setSaDisplayName] = useState('') + const [saDescription, setSaDescription] = useState('') + const [saError, setSaError] = useState(null) + const [saIsSubmitting, setSaIsSubmitting] = useState(false) + const [saDragActive, setSaDragActive] = useState(false) + const { data: session } = useSession() const currentUserId = session?.user?.id || '' @@ -110,7 +118,7 @@ export function IntegrationsManager() { const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId || null) const oauthCredentials = useMemo( - () => credentials.filter((c) => c.type === 'oauth'), + () => credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account'), [credentials] ) @@ -348,11 +356,7 @@ export function IntegrationsManager() { const isSelectedAdmin = selectedCredential?.role === 'admin' const selectedOAuthServiceConfig = useMemo(() => { - if ( - !selectedCredential || - selectedCredential.type !== 'oauth' || - !selectedCredential.providerId - ) { + if (!selectedCredential?.providerId) { return null } @@ -366,6 +370,10 @@ export function IntegrationsManager() { setCreateError(null) setCreateStep(1) setServiceSearch('') + setSaJsonInput('') + setSaDisplayName('') + setSaDescription('') + setSaError(null) pendingReturnOriginRef.current = undefined } @@ -456,25 +464,30 @@ export function IntegrationsManager() { setDeleteError(null) try { - if (!credentialToDelete.accountId || !credentialToDelete.providerId) { - const errorMessage = - 'Cannot disconnect: missing account information. Please try reconnecting this credential first.' - setDeleteError(errorMessage) - logger.error('Cannot disconnect OAuth credential: missing accountId or providerId') - return - } - await disconnectOAuthService.mutateAsync({ - provider: credentialToDelete.providerId.split('-')[0] || credentialToDelete.providerId, - providerId: credentialToDelete.providerId, - serviceId: credentialToDelete.providerId, - accountId: credentialToDelete.accountId, - }) - await refetchCredentials() - window.dispatchEvent( - new CustomEvent('oauth-credentials-updated', { - detail: { providerId: credentialToDelete.providerId, workspaceId }, + if (credentialToDelete.type === 'service_account') { + await deleteCredential.mutateAsync(credentialToDelete.id) + await refetchCredentials() + } else { + if (!credentialToDelete.accountId || !credentialToDelete.providerId) { + const errorMessage = + 'Cannot disconnect: missing account information. Please try reconnecting this credential first.' + setDeleteError(errorMessage) + logger.error('Cannot disconnect OAuth credential: missing accountId or providerId') + return + } + await disconnectOAuthService.mutateAsync({ + provider: credentialToDelete.providerId.split('-')[0] || credentialToDelete.providerId, + providerId: credentialToDelete.providerId, + serviceId: credentialToDelete.providerId, + accountId: credentialToDelete.accountId, }) - ) + await refetchCredentials() + window.dispatchEvent( + new CustomEvent('oauth-credentials-updated', { + detail: { providerId: credentialToDelete.providerId, workspaceId }, + }) + ) + } if (selectedCredentialId === credentialToDelete.id) { setSelectedCredentialId(null) @@ -624,6 +637,117 @@ export function IntegrationsManager() { setShowCreateModal(true) }, []) + const validateServiceAccountJson = (raw: string): { valid: boolean; error?: string } => { + let parsed: Record + try { + parsed = JSON.parse(raw) + } catch { + return { valid: false, error: 'Invalid JSON. Paste the full service account key file.' } + } + if (parsed.type !== 'service_account') { + return { valid: false, error: 'JSON key must have "type": "service_account".' } + } + if (!parsed.client_email || typeof parsed.client_email !== 'string') { + return { valid: false, error: 'Missing "client_email" field.' } + } + if (!parsed.private_key || typeof parsed.private_key !== 'string') { + return { valid: false, error: 'Missing "private_key" field.' } + } + if (!parsed.project_id || typeof parsed.project_id !== 'string') { + return { valid: false, error: 'Missing "project_id" field.' } + } + return { valid: true } + } + + const handleCreateServiceAccount = async () => { + setSaError(null) + const trimmed = saJsonInput.trim() + if (!trimmed) { + setSaError('Paste the service account JSON key.') + return + } + const validation = validateServiceAccountJson(trimmed) + if (!validation.valid) { + setSaError(validation.error ?? 'Invalid JSON') + return + } + setSaIsSubmitting(true) + try { + await createCredential.mutateAsync({ + workspaceId, + type: 'service_account', + displayName: saDisplayName.trim() || undefined, + description: saDescription.trim() || undefined, + serviceAccountJson: trimmed, + }) + setShowCreateModal(false) + resetCreateForm() + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to add service account' + setSaError(message) + logger.error('Failed to create service account credential', error) + } finally { + setSaIsSubmitting(false) + } + } + + const readSaJsonFile = useCallback( + (file: File) => { + if (!file.name.endsWith('.json')) { + setSaError('Only .json files are supported') + return + } + const reader = new FileReader() + reader.onload = (e) => { + const text = e.target?.result + if (typeof text === 'string') { + setSaJsonInput(text) + setSaError(null) + try { + const parsed = JSON.parse(text) + if (parsed.client_email && !saDisplayName.trim()) { + setSaDisplayName(parsed.client_email) + } + } catch { + // validation will catch this on submit + } + } + } + reader.readAsText(file) + }, + [saDisplayName] + ) + + const handleSaFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + readSaJsonFile(file) + event.target.value = '' + } + + const handleSaDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + setSaDragActive(true) + }, []) + + const handleSaDragLeave = useCallback((event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + setSaDragActive(false) + }, []) + + const handleSaDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + setSaDragActive(false) + const file = event.dataTransfer.files[0] + if (file) readSaJsonFile(file) + }, + [readSaJsonFile] + ) + const filteredServices = useMemo(() => { if (!serviceSearch.trim()) return oauthServiceOptions const q = serviceSearch.toLowerCase() @@ -700,7 +824,7 @@ export function IntegrationsManager() { - ) : ( + ) : selectedOAuthService?.authType !== 'service_account' ? ( <>
@@ -827,6 +951,160 @@ export function IntegrationsManager() { + ) : ( + <> + +
+ + + Add {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)} + +
+
+ + {saError && ( +
+ + {saError} + +
+ )} +
+
+
+ {selectedOAuthService && + createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })} +
+
+

+ Add {selectedOAuthService?.name || 'service account'} +

+

+ {selectedOAuthService?.description || 'Paste or upload the JSON key file'} +

+ + View setup guide + +
+
+ +
+ +
+ {saDragActive && ( +
+

+ Drop JSON key file here +

+
+ )} +