diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index eab12f41f86..2d1a4ce1c66 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,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 }) } @@ -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 }) diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index fcc6f128702..cc5122c9492 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,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, diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 4228c3f3f2b..e855d4d7510 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, @@ -25,16 +27,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 +56,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 +79,96 @@ export async function resolveOAuthAccountId( return { accountId: credentialId, usedCredentialTable: false } } +/** + * Generates a short-lived access token for a Google service account credential + * using the two-legged OAuth JWT flow (RFC 7523). + */ +const SA_EXCLUDED_SCOPES = new Set([ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +]) + +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, + }) + throw new Error(`Token exchange failed: ${response.status}`) + } + + 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 +308,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) { + const effectiveScopes = scopes?.length + ? scopes + : ['https://www.googleapis.com/auth/cloud-platform'] + logger.info(`[${requestId}] Using service account token for credential`) + return getServiceAccountToken(resolved.credentialId, effectiveScopes, 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..1333ffc44af 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' || + !parsed.client_email || + !parsed.private_key || + !parsed.project_id + ) { + 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.', 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..77858e26029 100644 --- a/apps/sim/app/api/tools/drive/file/route.ts +++ b/apps/sim/app/api/tools/drive/file/route.ts @@ -26,6 +26,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 +47,9 @@ export async function GET(request: NextRequest) { const accessToken = await refreshAccessTokenIfNeeded( credentialId, authz.credentialOwnerUserId, - requestId + requestId, + ['https://www.googleapis.com/auth/drive'], + impersonateEmail ) if (!accessToken) { diff --git a/apps/sim/app/api/tools/drive/files/route.ts b/apps/sim/app/api/tools/drive/files/route.ts index 89b2e4936c4..2c6239297df 100644 --- a/apps/sim/app/api/tools/drive/files/route.ts +++ b/apps/sim/app/api/tools/drive/files/route.ts @@ -85,6 +85,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 +101,9 @@ export async function GET(request: NextRequest) { const accessToken = await refreshAccessTokenIfNeeded( credentialId!, authz.credentialOwnerUserId, - requestId + requestId, + ['https://www.googleapis.com/auth/drive'], + impersonateEmail ) if (!accessToken) { diff --git a/apps/sim/app/api/tools/gmail/label/route.ts b/apps/sim/app/api/tools/gmail/label/route.ts index 26437d267af..8df55aa8db7 100644 --- a/apps/sim/app/api/tools/gmail/label/route.ts +++ b/apps/sim/app/api/tools/gmail/label/route.ts @@ -6,7 +6,11 @@ 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 { + getServiceAccountToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -26,6 +30,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 +63,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, + ['https://www.googleapis.com/auth/gmail.labels'], + 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, + ['https://www.googleapis.com/auth/gmail.labels'] + ) + } if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) diff --git a/apps/sim/app/api/tools/gmail/labels/route.ts b/apps/sim/app/api/tools/gmail/labels/route.ts index 6aed016040c..d0457570c60 100644 --- a/apps/sim/app/api/tools/gmail/labels/route.ts +++ b/apps/sim/app/api/tools/gmail/labels/route.ts @@ -6,7 +6,11 @@ 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 { + getServiceAccountToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GmailLabelsAPI') @@ -33,6 +37,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 +67,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, + ['https://www.googleapis.com/auth/gmail.labels'], + 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, + ['https://www.googleapis.com/auth/gmail.labels'] + ) + } if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) 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..5709b61f577 100644 --- a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts @@ -20,7 +20,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 +43,9 @@ export async function POST(request: Request) { const accessToken = await refreshAccessTokenIfNeeded( credential, authz.credentialOwnerUserId, - requestId + requestId, + ['https://www.googleapis.com/auth/bigquery'], + impersonateEmail ) if (!accessToken) { logger.error('Failed to get access token', { 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..d8cd010b32d 100644 --- a/apps/sim/app/api/tools/google_bigquery/tables/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/tables/route.ts @@ -12,7 +12,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 +40,9 @@ export async function POST(request: Request) { const accessToken = await refreshAccessTokenIfNeeded( credential, authz.credentialOwnerUserId, - requestId + requestId, + ['https://www.googleapis.com/auth/bigquery'], + impersonateEmail ) if (!accessToken) { logger.error('Failed to get access token', { 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..d9cc26acca4 100644 --- a/apps/sim/app/api/tools/google_calendar/calendars/route.ts +++ b/apps/sim/app/api/tools/google_calendar/calendars/route.ts @@ -28,6 +28,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 +42,9 @@ export async function GET(request: NextRequest) { const accessToken = await refreshAccessTokenIfNeeded( credentialId, authz.credentialOwnerUserId, - requestId + requestId, + ['https://www.googleapis.com/auth/calendar'], + impersonateEmail ) if (!accessToken) { 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..333c93c7db4 100644 --- a/apps/sim/app/api/tools/google_sheets/sheets/route.ts +++ b/apps/sim/app/api/tools/google_sheets/sheets/route.ts @@ -40,6 +40,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 +60,9 @@ export async function GET(request: NextRequest) { const accessToken = await refreshAccessTokenIfNeeded( credentialId, authz.credentialOwnerUserId, - requestId + requestId, + ['https://www.googleapis.com/auth/drive'], + impersonateEmail ) if (!accessToken) { 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..92d894cf49a 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 @@ -12,7 +12,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 +30,9 @@ export async function POST(request: Request) { const accessToken = await refreshAccessTokenIfNeeded( credential, authz.credentialOwnerUserId, - requestId + requestId, + ['https://www.googleapis.com/auth/tasks'], + impersonateEmail ) if (!accessToken) { logger.error('Failed to get access token', { 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..20527ae1917 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 @@ -22,6 +22,7 @@ import { Tooltip, } from '@/components/emcn' import { Input as UiInput } from '@/components/ui' +import { cn } from '@/lib/core/utils/cn' import { useSession } from '@/lib/auth/auth-client' import { clearPendingCredentialCreateRequest, @@ -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,152 @@ 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'} +

+
+
+ +
+ +
+ {saDragActive && ( +
+

+ Drop JSON key file here +

+
+ )} +