feat(auth): allow google service account

This commit is contained in:
Theodore Li
2026-03-27 16:29:40 -07:00
parent dda012eae9
commit e0da2852bd
14 changed files with 15936 additions and 62 deletions

View File

@@ -149,6 +149,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 +160,48 @@ 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,52 @@ 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 isGoogleProvider =
providerParam.startsWith('google') || providerParam === 'gmail'
if (isGoogleProvider) {
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, 'google-service-account')
)
)
for (const sa of serviceAccountCreds) {
results.push(
toCredentialResponse(
sa.id,
sa.displayName,
sa.providerId || 'google-service-account',
sa.updatedAt,
null
)
)
}
}
return NextResponse.json({ credentials: results }, { status: 200 })
}
return NextResponse.json({ credentials: [] }, { status: 200 })

View File

@@ -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,35 @@ 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,

View File

@@ -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<ResolvedCredential | null> {
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,83 @@ 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).
*/
export async function getServiceAccountToken(
credentialId: string,
scopes: string[],
impersonateEmail?: string
): Promise<string> {
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 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<string, unknown> = {
iss: keyData.client_email,
scope: scopes.join(' '),
aud: tokenUri,
iat: now,
exp: now + 3600,
}
if (impersonateEmail) {
payload.sub = impersonateEmail
}
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.

View File

@@ -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,42 @@ 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.',

View File

@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getServiceConfigByProviderId } from '@/lib/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) {

View File

@@ -91,6 +91,12 @@ 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<string | null>(null)
const [saIsSubmitting, setSaIsSubmitting] = useState(false)
const { data: session } = useSession()
const currentUserId = session?.user?.id || ''
@@ -110,7 +116,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 +354,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 +368,10 @@ export function IntegrationsManager() {
setCreateError(null)
setCreateStep(1)
setServiceSearch('')
setSaJsonInput('')
setSaDisplayName('')
setSaDescription('')
setSaError(null)
pendingReturnOriginRef.current = undefined
}
@@ -456,25 +462,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 +635,84 @@ export function IntegrationsManager() {
setShowCreateModal(true)
}, [])
const validateServiceAccountJson = (raw: string): { valid: boolean; error?: string } => {
let parsed: Record<string, unknown>
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 handleSaFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) 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)
event.target.value = ''
}
const filteredServices = useMemo(() => {
if (!serviceSearch.trim()) return oauthServiceOptions
const q = serviceSearch.toLowerCase()
@@ -700,7 +789,7 @@ export function IntegrationsManager() {
</Button>
</ModalFooter>
</>
) : (
) : selectedOAuthService?.authType !== 'service_account' ? (
<>
<ModalHeader>
<div className='flex items-center gap-2.5'>
@@ -827,6 +916,131 @@ export function IntegrationsManager() {
</Button>
</ModalFooter>
</>
) : (
<>
<ModalHeader>
<div className='flex items-center gap-2.5'>
<button
type='button'
onClick={() => {
setCreateStep(1)
setSaError(null)
}}
className='flex h-6 w-6 items-center justify-center rounded-[4px] text-[var(--text-muted)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
aria-label='Back'
>
</button>
<span>
Add{' '}
{selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
</span>
</div>
</ModalHeader>
<ModalBody>
{saError && (
<div className='mb-3'>
<Badge variant='red' size='lg' dot className='max-w-full'>
{saError}
</Badge>
</div>
)}
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
{selectedOAuthService &&
createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })}
</div>
<div>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
Add {selectedOAuthService?.name || 'service account'}
</p>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{selectedOAuthService?.description || 'Paste or upload the JSON key file'}
</p>
</div>
</div>
<div>
<Label>
JSON Key<span className='ml-1'>*</span>
</Label>
<Textarea
value={saJsonInput}
onChange={(event) => {
setSaJsonInput(event.target.value)
setSaError(null)
if (!saDisplayName.trim()) {
try {
const parsed = JSON.parse(event.target.value)
if (parsed.client_email) setSaDisplayName(parsed.client_email)
} catch {
// not valid yet
}
}
}}
placeholder='Paste your service account JSON key here...'
autoComplete='off'
data-lpignore='true'
className='mt-1.5 min-h-[120px] resize-none font-mono text-[12px]'
autoFocus
/>
<div className='mt-1.5'>
<label className='inline-flex cursor-pointer items-center gap-1.5 text-[12px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'>
<input
type='file'
accept='.json'
onChange={handleSaFileUpload}
className='hidden'
/>
Or upload a .json file
</label>
</div>
</div>
<div>
<Label>Display name</Label>
<Input
value={saDisplayName}
onChange={(event) => setSaDisplayName(event.target.value)}
placeholder='Auto-populated from client_email'
autoComplete='off'
data-lpignore='true'
className='mt-1.5'
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={saDescription}
onChange={(event) => setSaDescription(event.target.value)}
placeholder='Optional description'
maxLength={500}
autoComplete='off'
data-lpignore='true'
className='mt-1.5 min-h-[80px] resize-none'
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setCreateStep(1)
setSaError(null)
}}
>
Back
</Button>
<Button
variant='primary'
onClick={handleCreateServiceAccount}
disabled={!saJsonInput.trim() || saIsSubmitting}
>
{saIsSubmitting ? 'Adding...' : 'Add Service Account'}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
@@ -869,7 +1083,7 @@ export function IntegrationsManager() {
<Button
variant='destructive'
onClick={handleConfirmDelete}
disabled={disconnectOAuthService.isPending}
disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
>
{disconnectOAuthService.isPending ? 'Disconnecting...' : 'Disconnect'}
</Button>
@@ -920,10 +1134,14 @@ export function IntegrationsManager() {
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<p className='truncate font-medium text-[var(--text-primary)] text-base'>
{resolveProviderLabel(selectedCredential.providerId) || 'Unknown service'}
{selectedOAuthServiceConfig?.name ||
resolveProviderLabel(selectedCredential.providerId) ||
'Unknown service'}
</p>
<Badge variant='gray-secondary' size='sm'>
oauth
{selectedOAuthServiceConfig?.authType === 'service_account'
? 'service account'
: 'oauth'}
</Badge>
{selectedCredential.role && (
<Badge variant='gray-secondary' size='sm'>
@@ -931,7 +1149,9 @@ export function IntegrationsManager() {
</Badge>
)}
</div>
<p className='text-[var(--text-muted)] text-small'>Connected service</p>
<p className='text-[var(--text-muted)] text-small'>
{selectedOAuthServiceConfig?.description || 'Connected service'}
</p>
</div>
</div>
@@ -1116,15 +1336,17 @@ export function IntegrationsManager() {
<div className='flex items-center gap-2'>
{isSelectedAdmin && (
<>
<Button
variant='default'
onClick={handleReconnectOAuth}
disabled={connectOAuthService.isPending}
>
{`Reconnect to ${
resolveProviderLabel(selectedCredential.providerId) || 'service'
}`}
</Button>
{selectedOAuthServiceConfig?.authType !== 'service_account' && (
<Button
variant='default'
onClick={handleReconnectOAuth}
disabled={connectOAuthService.isPending}
>
{`Reconnect to ${
resolveProviderLabel(selectedCredential.providerId) || 'service'
}`}
</Button>
)}
{(workspaceUserOptions.length > 0 || isShareingWithWorkspace) && (
<Button
variant='default'
@@ -1138,7 +1360,7 @@ export function IntegrationsManager() {
<Button
variant='ghost'
onClick={() => handleDeleteClick(selectedCredential)}
disabled={disconnectOAuthService.isPending}
disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
>
Disconnect
</Button>
@@ -1234,7 +1456,11 @@ export function IntegrationsManager() {
<Button
variant='ghost'
onClick={() => handleDeleteClick(credential)}
disabled={disconnectOAuthService.isPending}
disabled={
credential.type === 'service_account'
? deleteCredential.isPending
: disconnectOAuthService.isPending
}
>
Disconnect
</Button>

View File

@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { environmentKeys } from '@/hooks/queries/environment'
import { fetchJson } from '@/hooks/selectors/helpers'
export type WorkspaceCredentialType = 'oauth' | 'env_workspace' | 'env_personal'
export type WorkspaceCredentialType = 'oauth' | 'env_workspace' | 'env_personal' | 'service_account'
export type WorkspaceCredentialRole = 'admin' | 'member'
export type WorkspaceCredentialMemberStatus = 'active' | 'pending' | 'revoked'
@@ -173,6 +173,7 @@ export function useCreateWorkspaceCredential() {
accountId?: string
envKey?: string
envOwnerUserId?: string
serviceAccountJson?: string
}) => {
const response = await fetch('/api/credentials', {
method: 'POST',
@@ -204,6 +205,7 @@ export function useUpdateWorkspaceCredential() {
displayName?: string
description?: string | null
accountId?: string
serviceAccountJson?: string
}) => {
const response = await fetch(`/api/credentials/${payload.credentialId}`, {
method: 'PUT',
@@ -212,6 +214,7 @@ export function useUpdateWorkspaceCredential() {
displayName: payload.displayName,
description: payload.description,
accountId: payload.accountId,
serviceAccountJson: payload.serviceAccountJson,
}),
})
if (!response.ok) {

View File

@@ -65,6 +65,52 @@ export async function authorizeCredentialUse(
.limit(1)
if (platformCredential) {
if (platformCredential.type === 'service_account') {
if (workflowContext && workflowContext.workspaceId !== platformCredential.workspaceId) {
return { ok: false, error: 'Credential is not accessible from this workflow workspace' }
}
if (actingUserId) {
const requesterPerm = await getUserEntityPermissions(
actingUserId,
'workspace',
platformCredential.workspaceId
)
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, actingUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return {
ok: false,
error:
'You do not have access to this credential. Ask the credential admin to add you as a member.',
}
}
if (requesterPerm === null) {
return { ok: false, error: 'You do not have access to this workspace.' }
}
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId: actingUserId || auth.userId,
workspaceId: platformCredential.workspaceId,
resolvedCredentialId: platformCredential.id,
}
}
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return { ok: false, error: 'Unsupported credential type for OAuth access' }
}

View File

@@ -225,6 +225,15 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/meetings.space.readonly',
],
},
'google-service-account': {
name: 'Google Service Account',
description: 'Authenticate with a JSON key file from Google Cloud Console.',
providerId: 'google-service-account',
icon: GoogleIcon,
baseProviderIcon: GoogleIcon,
scopes: [],
authType: 'service_account',
},
'vertex-ai': {
name: 'Vertex AI',
description: 'Access Google Cloud Vertex AI for Gemini models with OAuth.',

View File

@@ -109,6 +109,8 @@ export interface OAuthProviderConfig {
defaultService: string
}
export type OAuthAuthType = 'oauth' | 'service_account'
export interface OAuthServiceConfig {
name: string
description: string
@@ -116,6 +118,7 @@ export interface OAuthServiceConfig {
icon: (props: { className?: string }) => ReactNode
baseProviderIcon: (props: { className?: string }) => ReactNode
scopes: string[]
authType?: OAuthAuthType
}
/**

View File

@@ -0,0 +1,4 @@
ALTER TYPE "public"."credential_type" ADD VALUE 'service_account';--> statement-breakpoint
ALTER TABLE "credential" ADD COLUMN "encrypted_service_account_key" text;--> statement-breakpoint
CREATE UNIQUE INDEX "credential_workspace_service_account_unique" ON "credential" USING btree ("workspace_id","type","provider_id","display_name") WHERE type = 'service_account';--> statement-breakpoint
ALTER TABLE "credential" ADD CONSTRAINT "credential_service_account_source_check" CHECK ((type <> 'service_account') OR (encrypted_service_account_key IS NOT NULL AND provider_id IS NOT NULL));

File diff suppressed because it is too large Load Diff

View File

@@ -1268,6 +1268,13 @@
"when": 1774411211528,
"tag": "0181_dazzling_the_leader",
"breakpoints": true
},
{
"idx": 182,
"version": "7",
"when": 1774643404890,
"tag": "0182_cute_emma_frost",
"breakpoints": true
}
]
}
}

View File

@@ -2311,6 +2311,7 @@ export const credentialTypeEnum = pgEnum('credential_type', [
'oauth',
'env_workspace',
'env_personal',
'service_account',
])
export const credential = pgTable(
@@ -2327,6 +2328,7 @@ export const credential = pgTable(
accountId: text('account_id').references(() => account.id, { onDelete: 'cascade' }),
envKey: text('env_key'),
envOwnerUserId: text('env_owner_user_id').references(() => user.id, { onDelete: 'cascade' }),
encryptedServiceAccountKey: text('encrypted_service_account_key'),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
@@ -2348,6 +2350,9 @@ export const credential = pgTable(
workspacePersonalEnvUnique: uniqueIndex('credential_workspace_personal_env_unique')
.on(table.workspaceId, table.type, table.envKey, table.envOwnerUserId)
.where(sql`type = 'env_personal'`),
workspaceServiceAccountUnique: uniqueIndex('credential_workspace_service_account_unique')
.on(table.workspaceId, table.type, table.providerId, table.displayName)
.where(sql`type = 'service_account'`),
oauthSourceConstraint: check(
'credential_oauth_source_check',
sql`(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)`
@@ -2360,6 +2365,10 @@ export const credential = pgTable(
'credential_personal_env_source_check',
sql`(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)`
),
serviceAccountSourceConstraint: check(
'credential_service_account_source_check',
sql`(type <> 'service_account') OR (encrypted_service_account_key IS NOT NULL AND provider_id IS NOT NULL)`
),
})
)