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

Filter by extension

Filter by extension

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

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

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

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

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

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

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

const saProviderId = getServiceAccountProviderForProviderId(providerParam)

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

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

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

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

export const dynamic = 'force-dynamic'

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

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

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

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

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

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

const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
Expand Down
135 changes: 132 additions & 3 deletions 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([
'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'

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,
})

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.
Expand Down Expand Up @@ -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<string | null> {
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)

Expand Down
Loading