Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
219a065
v0.4.12: guardrails, mistral models, privacy policy updates (#1608)
waleedlatif1 Oct 12, 2025
7f82ed3
v0.4.13: bugfixes for dev containers, posthog redirect, helm updates
icecrasher321 Oct 13, 2025
fb0fa1f
v0.4.14: canvas speedup and copilot context window
Sg312 Oct 14, 2025
2bc8c7b
v0.4.15: helm chart updates, telegram tools, youtube tools, file uplo…
waleedlatif1 Oct 15, 2025
04f109c
v0.4.16: executions dashboard, UI fixes, zep tools, slack fixes
icecrasher321 Oct 16, 2025
da091df
v0.4.17: input format + files support for webhooks, docs updates, das…
waleedlatif1 Oct 16, 2025
e4ddeb0
v0.4.18: file upload tools, copilot upgrade, docs changes, model filt…
icecrasher321 Oct 19, 2025
641e353
v0.4.19: landing page fix
icecrasher321 Oct 19, 2025
9751c9f
v0.4.20: internal request, kb url fixes, docs styling
icecrasher321 Oct 21, 2025
1b7437a
v0.4.21: more internal auth changes, supabase vector search tool
icecrasher321 Oct 22, 2025
71ae27b
v0.4.22: fix execution context pass for google sheets
icecrasher321 Oct 22, 2025
9b2490c
v0.4.23: webflow tools + triggers, copilot api key fix (#1723)
waleedlatif1 Oct 23, 2025
7f1ff7f
fix(billing): should allow restoring subscription (#1728)
icecrasher321 Oct 25, 2025
a02016e
v0.4.24: sso for chat deployment, usage indicator for file storage, m…
icecrasher321 Oct 27, 2025
9a4b9e2
v0.4.25: variables block, sort ordering for kb, careers page, storage…
waleedlatif1 Oct 29, 2025
c113fb9
Server side logic to check auth scopes
Sg312 Nov 7, 2025
fd80772
Fix scopes code
Sg312 Nov 7, 2025
7dff6c2
Remove frontend changes
Sg312 Nov 7, 2025
0163b10
Fix tests
Sg312 Nov 7, 2025
b0e1571
Lint
Sg312 Nov 7, 2025
8f63f98
Remove log for lint
Sg312 Nov 7, 2025
5bfd494
Fix scopes check
Sg312 Nov 7, 2025
3d1846b
Fix conflict
Sg312 Nov 7, 2025
f3b169e
Merge remote-tracking branch 'origin' into improvement/auth-scopes-wa…
Sg312 Nov 8, 2025
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
22 changes: 22 additions & 0 deletions apps/sim/app/api/auth/oauth/connections/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ describe('OAuth Connections API Route', () => {
error: vi.fn(),
debug: vi.fn(),
}
const mockParseProvider = vi.fn()
const mockEvaluateScopeCoverage = vi.fn()

const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'

Expand Down Expand Up @@ -52,6 +54,26 @@ describe('OAuth Connections API Route', () => {
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))

mockParseProvider.mockImplementation((providerId: string) => ({
baseProvider: providerId.split('-')[0] || providerId,
featureType: providerId.split('-')[1] || 'default',
}))

mockEvaluateScopeCoverage.mockImplementation(
(_providerId: string, _grantedScopes: string[]) => ({
canonicalScopes: ['email', 'profile'],
grantedScopes: ['email', 'profile'],
missingScopes: [],
extraScopes: [],
requiresReauthorization: false,
})
)

vi.doMock('@/lib/oauth/oauth', () => ({
parseProvider: mockParseProvider,
evaluateScopeCoverage: mockEvaluateScopeCoverage,
}))
})

afterEach(() => {
Expand Down
67 changes: 50 additions & 17 deletions apps/sim/app/api/auth/oauth/connections/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { jwtDecode } from 'jwt-decode'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import type { OAuthProvider } from '@/lib/oauth/oauth'
import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth/oauth'
import { generateRequestId } from '@/lib/utils'

const logger = createLogger('OAuthConnectionsAPI')
Expand Down Expand Up @@ -46,10 +48,11 @@ export async function GET(request: NextRequest) {
const connections: any[] = []

for (const acc of accounts) {
// Extract the base provider and feature type from providerId (e.g., 'google-email' -> 'google', 'email')
const [provider, featureType = 'default'] = acc.providerId.split('-')
const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider)
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)

if (provider) {
if (baseProvider) {
// Try multiple methods to get a user-friendly display name
let displayName = ''

Expand All @@ -70,7 +73,7 @@ export async function GET(request: NextRequest) {
}

// Method 2: For GitHub, the accountId might be the username
if (!displayName && provider === 'github') {
if (!displayName && baseProvider === 'github') {
displayName = `${acc.accountId} (GitHub)`
}

Expand All @@ -81,7 +84,7 @@ export async function GET(request: NextRequest) {

// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${provider})`
displayName = `${acc.accountId} (${baseProvider})`
}

// Create a unique connection key that includes the full provider ID
Expand All @@ -90,28 +93,58 @@ export async function GET(request: NextRequest) {
// Find existing connection for this specific provider ID
const existingConnection = connections.find((conn) => conn.provider === connectionKey)

const accountSummary = {
id: acc.id,
name: displayName,
scopes: scopeEvaluation.grantedScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}

if (existingConnection) {
// Add account to existing connection
existingConnection.accounts = existingConnection.accounts || []
existingConnection.accounts.push({
id: acc.id,
name: displayName,
})
existingConnection.accounts.push(accountSummary)

existingConnection.scopes = Array.from(
new Set([...(existingConnection.scopes || []), ...scopeEvaluation.grantedScopes])
)
existingConnection.missingScopes = Array.from(
new Set([...(existingConnection.missingScopes || []), ...scopeEvaluation.missingScopes])
)
existingConnection.extraScopes = Array.from(
new Set([...(existingConnection.extraScopes || []), ...scopeEvaluation.extraScopes])
)
existingConnection.canonicalScopes =
existingConnection.canonicalScopes && existingConnection.canonicalScopes.length > 0
? existingConnection.canonicalScopes
: scopeEvaluation.canonicalScopes
existingConnection.requiresReauthorization =
existingConnection.requiresReauthorization || scopeEvaluation.requiresReauthorization

const existingTimestamp = existingConnection.lastConnected
? new Date(existingConnection.lastConnected).getTime()
: 0
const candidateTimestamp = acc.updatedAt.getTime()

if (candidateTimestamp > existingTimestamp) {
existingConnection.lastConnected = acc.updatedAt.toISOString()
}
} else {
// Create new connection
connections.push({
provider: connectionKey,
baseProvider: provider,
baseProvider,
featureType,
isConnected: true,
scopes: acc.scope ? acc.scope.split(' ') : [],
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
lastConnected: acc.updatedAt.toISOString(),
accounts: [
{
id: acc.id,
name: displayName,
},
],
accounts: [accountSummary],
})
}
}
Expand Down
18 changes: 17 additions & 1 deletion apps/sim/app/api/auth/oauth/credentials/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('OAuth Credentials API Route', () => {
const mockGetSession = vi.fn()
const mockParseProvider = vi.fn()
const mockEvaluateScopeCoverage = vi.fn()
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
Expand Down Expand Up @@ -41,8 +42,9 @@ describe('OAuth Credentials API Route', () => {
getSession: mockGetSession,
}))

vi.doMock('@/lib/oauth', () => ({
vi.doMock('@/lib/oauth/oauth', () => ({
parseProvider: mockParseProvider,
evaluateScopeCoverage: mockEvaluateScopeCoverage,
}))

vi.doMock('@sim/db', () => ({
Expand All @@ -66,6 +68,20 @@ describe('OAuth Credentials API Route', () => {
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))

mockParseProvider.mockImplementation((providerId: string) => ({
baseProvider: providerId.split('-')[0] || providerId,
}))

mockEvaluateScopeCoverage.mockImplementation(
(_providerId: string, grantedScopes: string[]) => ({
canonicalScopes: grantedScopes,
grantedScopes,
missingScopes: [],
extraScopes: [],
requiresReauthorization: false,
})
)
})

afterEach(() => {
Expand Down
11 changes: 10 additions & 1 deletion apps/sim/app/api/auth/oauth/credentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger'
import { parseProvider } from '@/lib/oauth/oauth'
import type { OAuthService } from '@/lib/oauth/oauth'
import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth/oauth'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { generateRequestId } from '@/lib/utils'

Expand Down Expand Up @@ -206,12 +207,20 @@ export async function GET(request: NextRequest) {
displayName = `${acc.accountId} (${baseProvider})`
}

const grantedScopes = acc.scope ? acc.scope.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,
}
})
)
Expand Down
45 changes: 45 additions & 0 deletions apps/sim/hooks/use-oauth-scope-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client'

import type { Credential } from '@/lib/oauth/oauth'

export interface OAuthScopeStatus {
requiresReauthorization: boolean
missingScopes: string[]
extraScopes: string[]
canonicalScopes: string[]
grantedScopes: string[]
}

/**
* Extract scope status from a credential
*/
export function getCredentialScopeStatus(credential: Credential): OAuthScopeStatus {
return {
requiresReauthorization: credential.requiresReauthorization || false,
missingScopes: credential.missingScopes || [],
extraScopes: credential.extraScopes || [],
canonicalScopes: credential.canonicalScopes || [],
grantedScopes: credential.scopes || [],
}
}

/**
* Check if a credential needs reauthorization
*/
export function credentialNeedsReauth(credential: Credential): boolean {
return credential.requiresReauthorization || false
}

/**
* Check if any credentials in a list need reauthorization
*/
export function anyCredentialNeedsReauth(credentials: Credential[]): boolean {
return credentials.some(credentialNeedsReauth)
}

/**
* Get all credentials that need reauthorization
*/
export function getCredentialsNeedingReauth(credentials: Credential[]): Credential[] {
return credentials.filter(credentialNeedsReauth)
}
67 changes: 67 additions & 0 deletions apps/sim/lib/oauth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,68 @@ export function getProviderIdFromServiceId(serviceId: string): string {
return serviceId
}

// Helper to locate a service configuration by its providerId
export function getServiceConfigByProviderId(providerId: string): OAuthServiceConfig | null {
for (const provider of Object.values(OAUTH_PROVIDERS)) {
for (const service of Object.values(provider.services)) {
if (service.providerId === providerId || service.id === providerId) {
return service
}
}
}

return null
}

// Get the canonical scopes for a given providerId (service instance)
export function getCanonicalScopesForProvider(providerId: string): string[] {
const service = getServiceConfigByProviderId(providerId)
return service?.scopes ? [...service.scopes] : []
}

// Normalize scopes by trimming, filtering empties, and deduplicating
export function normalizeScopes(scopes: string[]): string[] {
const seen = new Set<string>()
for (const scope of scopes) {
const trimmed = scope.trim()
if (trimmed && !seen.has(trimmed)) {
seen.add(trimmed)
}
}
return Array.from(seen)
}

export interface ScopeEvaluation {
canonicalScopes: string[]
grantedScopes: string[]
missingScopes: string[]
extraScopes: string[]
requiresReauthorization: boolean
}

// Compare granted scopes with canonical ones for a providerId
export function evaluateScopeCoverage(
providerId: string,
grantedScopes: string[]
): ScopeEvaluation {
const canonicalScopes = getCanonicalScopesForProvider(providerId)
const normalizedGranted = normalizeScopes(grantedScopes)

const canonicalSet = new Set(canonicalScopes)
const grantedSet = new Set(normalizedGranted)

const missingScopes = canonicalScopes.filter((scope) => !grantedSet.has(scope))
const extraScopes = normalizedGranted.filter((scope) => !canonicalSet.has(scope))

return {
canonicalScopes,
grantedScopes: normalizedGranted,
missingScopes,
extraScopes,
requiresReauthorization: missingScopes.length > 0,
}
}

// Interface for credential objects
export interface Credential {
id: string
Expand All @@ -668,6 +730,11 @@ export interface Credential {
serviceId?: string
lastUsed?: string
isDefault?: boolean
scopes?: string[]
canonicalScopes?: string[]
missingScopes?: string[]
extraScopes?: string[]
requiresReauthorization?: boolean
}

// Interface for provider configuration
Expand Down