diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index aced9eed20..9ab1fd8c73 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -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' @@ -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(() => { diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index 881d50a973..0a824ebcbf 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -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') @@ -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 = '' @@ -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)` } @@ -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 @@ -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], }) } } diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index e67b2de1fd..0b17ea290e 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -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(), @@ -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', () => ({ @@ -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(() => { diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index c9fe16fb90..b959d065ae 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -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' @@ -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, } }) ) diff --git a/apps/sim/hooks/use-oauth-scope-status.ts b/apps/sim/hooks/use-oauth-scope-status.ts new file mode 100644 index 0000000000..95cba75d12 --- /dev/null +++ b/apps/sim/hooks/use-oauth-scope-status.ts @@ -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) +} diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 8fd424b9e2..e813cc9f47 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -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() + 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 @@ -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