mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(scopes): add scopes warning hook (#1842)
* fix(billing): should allow restoring subscription (#1728) * fix(already-cancelled-sub): UI should allow restoring subscription * restore functionality fixed * fix * Server side logic to check auth scopes * Fix scopes code * Remove frontend changes * Fix tests * Lint * Remove log for lint * Fix scopes check * Fix conflict --------- Co-authored-by: Waleed <walif6@gmail.com> Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
This commit is contained in:
committed by
GitHub
parent
d3e81e97d5
commit
6cdee5351c
@@ -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(() => {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
45
apps/sim/hooks/use-oauth-scope-status.ts
Normal file
45
apps/sim/hooks/use-oauth-scope-status.ts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user