Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 88 additions & 8 deletions apps/sim/app/api/auth/oauth/credentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import {
getCanonicalScopesForProvider,
getServiceAccountProviderForProviderId,
} from '@/lib/oauth/utils'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'

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

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

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

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

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

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

const saProviderId = getServiceAccountProviderForProviderId(providerParam)

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

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

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

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

export const dynamic = 'force-dynamic'

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

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

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

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

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

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

const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
Expand Down
114 changes: 113 additions & 1 deletion apps/sim/app/api/auth/oauth/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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
}
Expand All @@ -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([
Comment thread
TheodoreSpeaks marked this conversation as resolved.
'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<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 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'
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Outdated

const header = { alg: 'RS256', typ: 'JWT' }
const payload: Record<string, unknown> = {
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,
})
Comment thread
TheodoreSpeaks marked this conversation as resolved.

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, {
Comment thread
cursor[bot] marked this conversation as resolved.
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.
Expand Down
Loading