Skip to content
Merged
Show file tree
Hide file tree
Changes from 47 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
253161a
feat(mult-credentials): progress
icecrasher321 Feb 11, 2026
7314675
checkpoint
icecrasher321 Feb 12, 2026
508772c
make it autoselect personal secret when create secret is clicked
icecrasher321 Feb 12, 2026
aefa281
improve collaborative UX
icecrasher321 Feb 12, 2026
319768c
remove add member ui for workspace secrets
icecrasher321 Feb 12, 2026
622023d
bulk entry of .env
icecrasher321 Feb 13, 2026
5e19226
promote to workspace secret
icecrasher321 Feb 13, 2026
8ed8a5a
more ux improvmeent
icecrasher321 Feb 13, 2026
bdd1483
share with workspace for oauth
icecrasher321 Feb 13, 2026
17710b3
remove new badge
icecrasher321 Feb 13, 2026
77bb048
share button
icecrasher321 Feb 13, 2026
dcf40be
copilot + oauth name comflict
icecrasher321 Feb 13, 2026
fa32b9e
reconnect option to connect diff account
icecrasher321 Feb 13, 2026
ff13b1f
remove credential no access marker
icecrasher321 Feb 13, 2026
3ad0f62
canonical credential id entry
icecrasher321 Feb 13, 2026
084ff9c
remove migration to prep stagin migration
icecrasher321 Feb 13, 2026
7092c88
Merge remote-tracking branch 'origin/staging' into feat/mult-credenti…
icecrasher321 Feb 13, 2026
93826cb
migration readded
icecrasher321 Feb 13, 2026
d70a5d4
backfill improvements
icecrasher321 Feb 14, 2026
ea42e64
run lint
icecrasher321 Feb 14, 2026
08b908f
fix tests
icecrasher321 Feb 14, 2026
6053050
remove unused code
icecrasher321 Feb 14, 2026
cd1ccf1
autoselect provider when connecting from block
icecrasher321 Feb 14, 2026
41cdca2
address bugbot comments
icecrasher321 Feb 14, 2026
3769da8
remove some dead code
icecrasher321 Feb 14, 2026
d235d74
more permissions stuff
icecrasher321 Feb 14, 2026
140f870
remove more unused code
icecrasher321 Feb 14, 2026
9584b99
address bugbot
icecrasher321 Feb 14, 2026
d8bbd7e
add filter
icecrasher321 Feb 14, 2026
6c3f3a4
remove migration to prep migration
icecrasher321 Feb 18, 2026
80282c3
Merge remote-tracking branch 'origin/staging' into feat/mult-credenti…
icecrasher321 Feb 18, 2026
ed9c35f
fix migration
icecrasher321 Feb 18, 2026
9b20e76
fix migration issues
icecrasher321 Feb 18, 2026
3f6fa6b
remove migration prep merge
icecrasher321 Feb 20, 2026
c50d194
Merge remote-tracking branch 'origin/staging' into feat/mult-credenti…
icecrasher321 Feb 20, 2026
c2edb50
readd migration
icecrasher321 Feb 20, 2026
abae19c
include user tables triggers
icecrasher321 Feb 20, 2026
c53fcb8
extract shared code
icecrasher321 Feb 20, 2026
34ca49a
fix
icecrasher321 Feb 20, 2026
4f5500f
fix tx issue
icecrasher321 Feb 20, 2026
1e4d255
remove migration to prep merge
icecrasher321 Feb 23, 2026
23e24b4
Merge branch 'staging' into feat/mult-credentials-rv
icecrasher321 Feb 23, 2026
94d5268
readd migration
icecrasher321 Feb 23, 2026
cc790aa
fix agent tool input
icecrasher321 Feb 23, 2026
ef358d0
agent with tool input deletion case
icecrasher321 Feb 23, 2026
35d6e17
fix credential subblock saving
icecrasher321 Feb 23, 2026
d117400
remove dead code
icecrasher321 Feb 23, 2026
e23ef7c
fix tests
icecrasher321 Feb 23, 2026
4e1d607
address bugbot comments
icecrasher321 Feb 23, 2026
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
8 changes: 3 additions & 5 deletions apps/sim/app/api/auth/accounts/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'

Expand Down Expand Up @@ -31,15 +31,13 @@ export async function GET(request: NextRequest) {
})
.from(account)
.where(and(...whereConditions))

// Use the user's email as the display name (consistent with credential selector)
const userEmail = session.user.email
.orderBy(desc(account.updatedAt))

const accountsWithDisplayName = accounts.map((acc) => ({
id: acc.id,
accountId: acc.accountId,
providerId: acc.providerId,
displayName: userEmail || acc.providerId,
displayName: acc.accountId || acc.providerId,
}))

return NextResponse.json({ accounts: accountsWithDisplayName })
Expand Down
233 changes: 138 additions & 95 deletions apps/sim/app/api/auth/oauth/credentials/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { db } from '@sim/db'
import { account, user } from '@sim/db/schema'
import { account, credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { jwtDecode } from 'jwt-decode'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { evaluateScopeCoverage } from '@/lib/oauth'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'

export const dynamic = 'force-dynamic'

Expand All @@ -18,6 +19,7 @@ const credentialsQuerySchema = z
.object({
provider: z.string().nullish(),
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
workspaceId: z.string().uuid('Workspace ID must be a valid UUID').nullish(),
credentialId: z
.string()
.min(1, 'Credential ID must not be empty')
Expand All @@ -29,10 +31,30 @@ const credentialsQuerySchema = z
path: ['provider'],
})

interface GoogleIdToken {
email?: string
sub?: string
name?: string
function toCredentialResponse(
id: string,
displayName: string,
providerId: string,
updatedAt: Date,
scope: string | null
) {
const storedScope = scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes)
const [_, featureType = 'default'] = providerId.split('-')

return {
id,
name: displayName,
provider: providerId,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
}

/**
Expand All @@ -46,6 +68,7 @@ export async function GET(request: NextRequest) {
const rawQuery = {
provider: searchParams.get('provider'),
workflowId: searchParams.get('workflowId'),
workspaceId: searchParams.get('workspaceId'),
credentialId: searchParams.get('credentialId'),
}

Expand Down Expand Up @@ -78,7 +101,7 @@ export async function GET(request: NextRequest) {
)
}

const { provider: providerParam, workflowId, credentialId } = parseResult.data
const { provider: providerParam, workflowId, workspaceId, credentialId } = parseResult.data

// Authenticate requester (supports session and internal JWT)
const authResult = await checkSessionOrInternalAuth(request)
Expand All @@ -88,7 +111,7 @@ export async function GET(request: NextRequest) {
}
const requesterUserId = authResult.userId

const effectiveUserId = requesterUserId
let effectiveWorkspaceId = workspaceId ?? undefined
if (workflowId) {
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
Expand All @@ -106,105 +129,125 @@ export async function GET(request: NextRequest) {
{ status: workflowAuthorization.status }
)
}
effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined
}

// Parse the provider to get base provider and feature type (if provider is present)
const { baseProvider } = parseProvider((providerParam || 'google') as OAuthProvider)

let accountsData

if (credentialId && workflowId) {
// When both workflowId and credentialId are provided, fetch by ID only.
// Workspace authorization above already proves access; the credential
// may belong to another workspace member (e.g. for display name resolution).
accountsData = await db.select().from(account).where(eq(account.id, credentialId))
} else if (credentialId) {
accountsData = await db
.select()
.from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId)))
} else {
// Fetch all credentials for provider and effective user
accountsData = await db
.select()
.from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.providerId, providerParam!)))
if (effectiveWorkspaceId) {
const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}

// Transform accounts into credentials
const credentials = await Promise.all(
accountsData.map(async (acc) => {
// Extract the feature type from providerId (e.g., 'google-default' -> 'default')
const [_, featureType = 'default'] = acc.providerId.split('-')

// Try multiple methods to get a user-friendly display name
let displayName = ''

// Method 1: Try to extract email from ID token (works for Google, etc.)
if (acc.idToken) {
try {
const decoded = jwtDecode<GoogleIdToken>(acc.idToken)
if (decoded.email) {
displayName = decoded.email
} else if (decoded.name) {
displayName = decoded.name
}
} catch (_error) {
logger.warn(`[${requestId}] Error decoding ID token`, {
accountId: acc.id,
})
}
}

// Method 2: For GitHub, the accountId might be the username
if (!displayName && baseProvider === 'github') {
displayName = `${acc.accountId} (GitHub)`
if (credentialId) {
const [platformCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
})
.from(credential)
.leftJoin(account, eq(credential.accountId, account.id))
.where(eq(credential.id, credentialId))
.limit(1)

if (platformCredential) {
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}

// Method 3: Try to get the user's email from our database
if (!displayName) {
try {
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, acc.userId))
.limit(1)

if (userRecord.length > 0) {
displayName = userRecord[0].email
}
} catch (_error) {
logger.warn(`[${requestId}] Error fetching user email`, {
userId: acc.userId,
})
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 })
}
}

// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${baseProvider})`
if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}

const storedScope = acc.scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)

return {
id: acc.id,
name: displayName,
provider: acc.providerId,
lastUsed: acc.updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.accountProviderId,
platformCredential.accountUpdatedAt,
platformCredential.accountScope
),
],
},
{ status: 200 }
)
}
}

if (effectiveWorkspaceId && providerParam) {
await syncWorkspaceOAuthCredentialsForUser({
workspaceId: effectiveWorkspaceId,
userId: requesterUserId,
})
)

return NextResponse.json({ credentials }, { status: 200 })
const credentialsData = await db
.select({
id: credential.id,
displayName: credential.displayName,
providerId: account.providerId,
scope: account.scope,
updatedAt: account.updatedAt,
})
.from(credential)
.innerJoin(account, eq(credential.accountId, account.id))
.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, 'oauth'),
eq(account.providerId, providerParam)
)
)

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

Comment thread
icecrasher321 marked this conversation as resolved.
return NextResponse.json({ credentials: [] }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching OAuth credentials`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
Expand Down
12 changes: 9 additions & 3 deletions apps/sim/app/api/auth/oauth/disconnect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const logger = createLogger('OAuthDisconnectAPI')
const disconnectSchema = z.object({
provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'),
providerId: z.string().optional(),
accountId: z.string().optional(),
})

/**
Expand Down Expand Up @@ -51,15 +52,20 @@ export async function POST(request: NextRequest) {
)
}

const { provider, providerId } = parseResult.data
const { provider, providerId, accountId } = parseResult.data

logger.info(`[${requestId}] Processing OAuth disconnect request`, {
provider,
hasProviderId: !!providerId,
})

// If a specific providerId is provided, delete only that account
if (providerId) {
// If a specific account row ID is provided, delete that exact account
if (accountId) {
await db
.delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.id, accountId)))
} else if (providerId) {
Comment thread
icecrasher321 marked this conversation as resolved.
// If a specific providerId is provided, delete accounts for that provider ID
await db
.delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId)))
Expand Down
9 changes: 7 additions & 2 deletions apps/sim/app/api/auth/oauth/microsoft/file/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,18 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}

const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}

const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
resolvedCredentialId,
authz.credentialOwnerUserId,
requestId
)
Expand Down
Loading
Loading