From 2cb724eacc92813e18209d42ff2bddd4ec191793 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 5 Mar 2025 16:08:23 -0800 Subject: [PATCH] feat(oauth): clean up oauth authorization flow --- app/api/auth/oauth/check-tool/route.ts | 110 ++++++ app/api/auth/oauth/check/route.ts | 47 --- app/w/[id]/hooks/use-workflow-execution.ts | 55 +-- app/w/[id]/workflow.tsx | 102 +++--- lib/oauth.ts | 405 +++------------------ tools/index.ts | 38 +- 6 files changed, 283 insertions(+), 474 deletions(-) create mode 100644 app/api/auth/oauth/check-tool/route.ts delete mode 100644 app/api/auth/oauth/check/route.ts diff --git a/app/api/auth/oauth/check-tool/route.ts b/app/api/auth/oauth/check-tool/route.ts new file mode 100644 index 0000000000..a9e91e8e6a --- /dev/null +++ b/app/api/auth/oauth/check-tool/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server' +import { and, eq } from 'drizzle-orm' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { account } from '@/db/schema' +import { OAuthProvider } from '@/tools/types' + +/** + * Check if the user has authorized a specific OAuth provider + */ +async function hasAuthorizedProvider( + userId: string, + provider: OAuthProvider, + requiredScopes?: string[] +): Promise { + try { + // Determine the appropriate provider ID based on scopes + let featureType = 'default' + if (requiredScopes && requiredScopes.length > 0) { + if (requiredScopes.some((scope) => scope.includes('repo'))) { + featureType = 'repo' + } else if (requiredScopes.some((scope) => scope.includes('workflow'))) { + featureType = 'workflow' + } else if ( + requiredScopes.some((scope) => scope.includes('gmail') || scope.includes('mail')) + ) { + featureType = 'email' + } else if (requiredScopes.some((scope) => scope.includes('calendar'))) { + featureType = 'calendar' + } else if (requiredScopes.some((scope) => scope.includes('drive'))) { + featureType = 'drive' + } else if (requiredScopes.some((scope) => scope.includes('write'))) { + featureType = 'write' + } else if (requiredScopes.some((scope) => scope.includes('read'))) { + featureType = 'read' + } + } + + // Construct the provider ID based on the provider and feature type + const providerId = `${provider}-${featureType}` + + // Check if the user has this provider account + const accounts = await db + .select() + .from(account) + .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) + .limit(1) + + return accounts.length > 0 + } catch (error) { + console.error('Error checking OAuth authorization:', error) + return false + } +} + +/** + * API route to check if a tool requires OAuth and if the user is authorized + */ +export async function POST(request: NextRequest) { + try { + // Get the session + const session = await getSession() + + // Check if the user is authenticated + if (!session?.user?.id) { + return NextResponse.json( + { requiresAuth: true, isAuthorized: false, error: 'User not authenticated' }, + { status: 401 } + ) + } + + // Get the tool from the request body + const { tool } = await request.json() + + // Check if the tool requires OAuth + if (!tool.oauth || !tool.oauth.required) { + return NextResponse.json({ requiresAuth: false, isAuthorized: true }, { status: 200 }) + } + + // Get the provider and required scopes + const provider = tool.oauth.provider + const requiredScopes = tool.oauth.additionalScopes || [] + + // Check if the user has authorized this provider + const isAuthorized = await hasAuthorizedProvider(session.user.id, provider, requiredScopes) + + // Return the authorization status + if (isAuthorized) { + return NextResponse.json({ requiresAuth: true, isAuthorized: true }, { status: 200 }) + } else { + return NextResponse.json( + { + requiresAuth: true, + isAuthorized: false, + error: JSON.stringify({ + type: 'oauth_required', + provider, + toolId: tool.id, + toolName: tool.name, + requiredScopes, + }), + }, + { status: 200 } + ) + } + } catch (error) { + console.error('Error checking OAuth authorization:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/api/auth/oauth/check/route.ts b/app/api/auth/oauth/check/route.ts deleted file mode 100644 index 69bd8d392b..0000000000 --- a/app/api/auth/oauth/check/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { hasAuthorizedProviderServer } from '@/lib/oauth' -import { OAuthProvider } from '@/tools/types' - -/** - * API endpoint to check if a user has authorized a specific OAuth provider - * - * @param request - The request object with provider and optional scopes - * @returns JSON response with authorization status - */ -export async function GET(request: NextRequest) { - try { - // Get the session - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ isAuthorized: false, error: 'Not authenticated' }, { status: 401 }) - } - - // Get the provider from the query string - const url = new URL(request.url) - const provider = url.searchParams.get('provider') as OAuthProvider | null - - if (!provider) { - return NextResponse.json( - { isAuthorized: false, error: 'Provider is required' }, - { status: 400 } - ) - } - - // Get optional scopes from the query string - const scopesParam = url.searchParams.get('scopes') - const requiredScopes = scopesParam ? scopesParam.split(',') : undefined - - // Check if the user has authorized this provider with the required scopes - const isAuthorized = await hasAuthorizedProviderServer(provider, requiredScopes) - - return NextResponse.json({ isAuthorized }) - } catch (error) { - console.error('Error checking OAuth authorization:', error) - return NextResponse.json( - { isAuthorized: false, error: 'Internal server error' }, - { status: 500 } - ) - } -} diff --git a/app/w/[id]/hooks/use-workflow-execution.ts b/app/w/[id]/hooks/use-workflow-execution.ts index 5be5fecfdd..124ff4fbd2 100644 --- a/app/w/[id]/hooks/use-workflow-execution.ts +++ b/app/w/[id]/hooks/use-workflow-execution.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react' import { v4 as uuidv4 } from 'uuid' +import { useOAuthErrorHandler } from '@/lib/oauth' import { useConsoleStore } from '@/stores/console/store' import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' @@ -21,6 +22,9 @@ export function useWorkflowExecution() { const { isExecuting, setIsExecuting } = useExecutionStore() const [executionResult, setExecutionResult] = useState(null) + // Add OAuth error handler + const { handleOAuthError } = useOAuthErrorHandler() + const persistLogs = async (logs: any[], executionId: string) => { // Check if we're in local storage mode const useLocalStorage = @@ -176,29 +180,36 @@ export function useWorkflowExecution() { await persistLogs(blockLogs, executionId) } catch (error: any) { console.error('Workflow Execution Error:', error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - // Set error result and show notification immediately - setExecutionResult({ - success: false, - output: { response: {} }, - error: errorMessage, - logs: [], - }) - addNotification('error', `Workflow execution failed: ${errorMessage}`, activeWorkflowId) + // Check if this is an OAuth error first + const isOAuthError = handleOAuthError(error) - // Persist error log after notification - await persistLogs( - [ - { - level: 'error', - message: `Manual workflow execution failed: ${errorMessage}`, - duration: 'NA', - createdAt: new Date().toISOString(), - }, - ], - executionId - ) + // If not an OAuth error, handle normally + if (!isOAuthError) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + + // Set error result and show notification immediately + setExecutionResult({ + success: false, + output: { response: {} }, + error: errorMessage, + logs: [], + }) + addNotification('error', `Workflow execution failed: ${errorMessage}`, activeWorkflowId) + + // Persist error log after notification + await persistLogs( + [ + { + level: 'error', + message: `Manual workflow execution failed: ${errorMessage}`, + duration: 'NA', + createdAt: new Date().toISOString(), + }, + ], + executionId + ) + } } finally { setIsExecuting(false) } @@ -211,8 +222,8 @@ export function useWorkflowExecution() { isOpen, toggleConsole, getAllVariables, - isExecuting, setIsExecuting, + handleOAuthError, ]) return { isExecuting, executionResult, handleRunWorkflow } diff --git a/app/w/[id]/workflow.tsx b/app/w/[id]/workflow.tsx index e182c7d0bd..0aaa4bc72e 100644 --- a/app/w/[id]/workflow.tsx +++ b/app/w/[id]/workflow.tsx @@ -12,6 +12,8 @@ import ReactFlow, { useReactFlow, } from 'reactflow' import 'reactflow/dist/style.css' +import { OAuthRequiredModal } from '@/components/ui/oauth-required-modal' +import { useOAuthErrorHandler } from '@/lib/oauth' import { useNotificationStore } from '@/stores/notifications/store' import { useGeneralStore } from '@/stores/settings/general/store' import { getSyncManagers, initializeSyncManagers, isSyncInitialized } from '@/stores/sync-registry' @@ -50,6 +52,9 @@ function WorkflowContent() { const { blocks, edges, loops, addBlock, updateBlockPosition, addEdge, removeEdge } = useWorkflowStore() + // Add OAuth error handling + const { modalState, handleOAuthError, closeModal } = useOAuthErrorHandler() + // Initialize workflow useEffect(() => { if (typeof window !== 'undefined') { @@ -310,48 +315,61 @@ function WorkflowContent() { if (!isInitialized) return null return ( -
- - e.preventDefault()} - fitView - minZoom={0.1} - maxZoom={1} - panOnScroll - defaultEdgeOptions={{ type: 'custom' }} - proOptions={{ hideAttribution: true }} - connectionLineStyle={{ - stroke: '#94a3b8', - strokeWidth: 2, - strokeDasharray: '5,5', - }} - connectionLineType={ConnectionLineType.SmoothStep} - onNodeClick={(e) => { - e.stopPropagation() - e.preventDefault() - }} - onPaneClick={onPaneClick} - onEdgeClick={onEdgeClick} - elementsSelectable={true} - selectNodesOnDrag={false} - nodesConnectable={true} - nodesDraggable={true} - draggable={false} - noWheelClassName="allow-scroll" - edgesFocusable={true} - edgesUpdatable={true} - > - - -
+ <> + {/* Add the OAuth modal */} + {modalState.isOpen && modalState.provider && ( + + )} + +
+ + e.preventDefault()} + fitView + minZoom={0.1} + maxZoom={1} + panOnScroll + defaultEdgeOptions={{ type: 'custom' }} + proOptions={{ hideAttribution: true }} + connectionLineStyle={{ + stroke: '#94a3b8', + strokeWidth: 2, + strokeDasharray: '5,5', + }} + connectionLineType={ConnectionLineType.SmoothStep} + onNodeClick={(e) => { + e.stopPropagation() + e.preventDefault() + }} + onPaneClick={onPaneClick} + onEdgeClick={onEdgeClick} + elementsSelectable={true} + selectNodesOnDrag={false} + nodesConnectable={true} + nodesDraggable={true} + draggable={false} + noWheelClassName="allow-scroll" + edgesFocusable={true} + edgesUpdatable={true} + > + + +
+ ) } diff --git a/lib/oauth.ts b/lib/oauth.ts index 035f7456cd..08d4cf0b0d 100644 --- a/lib/oauth.ts +++ b/lib/oauth.ts @@ -1,9 +1,6 @@ -import { useCallback, useEffect, useState } from 'react' -import { and, eq } from 'drizzle-orm' -import { getSession } from '@/lib/auth' -import { useSession } from '@/lib/auth-client' -import { db } from '@/db' -import { account } from '@/db/schema' +'use client' + +import { useCallback, useState } from 'react' import { OAuthProvider } from '@/tools/types' /** @@ -18,382 +15,70 @@ export interface OAuthRequiredError { } /** - * Check if the user has authorized the required OAuth provider with necessary scopes (server-side) - * - * @param provider - The OAuth provider to check - * @param requiredScopes - Optional scopes to check - * @returns Boolean indicating if the provider is authorized with required scopes - */ -export async function hasAuthorizedProviderServer( - provider: OAuthProvider, - requiredScopes?: string[] -): Promise { - try { - // Get the session - const session = await getSession() - - // If not authenticated, return false - if (!session?.user?.id) { - return false - } - - // Determine the appropriate feature type based on the scopes - let featureType = 'default' - - if (requiredScopes && requiredScopes.length > 0) { - if (requiredScopes.some((scope) => scope.includes('repo'))) { - featureType = 'repo' - } else if (requiredScopes.some((scope) => scope.includes('workflow'))) { - featureType = 'workflow' - } else if ( - requiredScopes.some((scope) => scope.includes('gmail') || scope.includes('mail')) - ) { - featureType = 'email' - } else if (requiredScopes.some((scope) => scope.includes('calendar'))) { - featureType = 'calendar' - } else if (requiredScopes.some((scope) => scope.includes('drive'))) { - featureType = 'drive' - } else if (requiredScopes.some((scope) => scope.includes('write'))) { - featureType = 'write' - } else if (requiredScopes.some((scope) => scope.includes('read'))) { - featureType = 'read' - } - } - - // We check the appropriate provider ID based on the feature type - const providerId = `${provider}-${featureType}` - - // Check if there's an account for this provider for the user - const accounts = await db - .select() - .from(account) - .where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId))) - - return accounts.length > 0 - } catch (error) { - console.error('Error checking provider authorization:', error) - return false - } -} - -/** - * Check if a tool requires OAuth and if the user has authorized it (server-side) - * - * @param tool - The tool configuration - * @returns Object indicating if OAuth is required and if the user has authorized it - */ -export async function checkOAuthRequirementServer(tool: any): Promise<{ - requiresAuth: boolean - isAuthorized: boolean - provider?: OAuthProvider - requiredScopes?: string[] -}> { - // Skip if no OAuth config or not required - if (!tool.oauth || !tool.oauth.required) { - return { requiresAuth: false, isAuthorized: false } - } - - const provider = tool.oauth.provider - const additionalScopes = tool.oauth.additionalScopes || [] - - // Check if the user has authorized this provider with required scopes - const isAuthorized = await hasAuthorizedProviderServer(provider, additionalScopes) - - return { - requiresAuth: true, - isAuthorized, - provider, - requiredScopes: additionalScopes, - } -} - -/** - * Verify OAuth requirements before executing a tool (server-side) - * Throws an error if OAuth is required but not authorized - * - * @param tool - The tool configuration - * @throws Error with JSON.stringify(OAuthRequiredError) - */ -export async function verifyOAuthBeforeExecutionServer(tool: any): Promise { - const { requiresAuth, isAuthorized, provider, requiredScopes } = - await checkOAuthRequirementServer(tool) - - if (requiresAuth && !isAuthorized && provider) { - // Throw a structured error that can be caught and handled - throw new Error( - JSON.stringify({ - type: 'oauth_required', - provider, - toolId: tool.id, - toolName: tool.name, - requiredScopes, - }) - ) - } -} - -/** - * Get OAuth tokens for a provider if the user has authorized it - * - * @param userId - The user's ID - * @param provider - The OAuth provider to get tokens for - * @returns The OAuth tokens or null if not authorized - */ -export async function getOAuthTokens( - userId: string, - provider: OAuthProvider -): Promise<{ - accessToken: string - refreshToken?: string - expiresAt?: Date -} | null> { - try { - // Query the account table for this user and provider - const accounts = await db - .select() - .from(account) - .where(and(eq(account.userId, userId), eq(account.providerId, provider))) - .limit(1) - - if (!accounts.length || !accounts[0].accessToken) { - return null - } - - const userAccount = accounts[0] - - // Check if the token is expired - if ( - userAccount.accessTokenExpiresAt && - new Date(userAccount.accessTokenExpiresAt) < new Date() - ) { - // In a production app, we would use the refresh token to get a new access token here - // But for simplicity, we'll just return null for expired tokens - console.warn(`Token for ${provider} is expired and needs refresh`) - return null - } - - // Ensure accessToken is not null using the type guard we did earlier - const accessToken = userAccount.accessToken as string - - return { - accessToken, - refreshToken: userAccount.refreshToken || undefined, - expiresAt: userAccount.accessTokenExpiresAt - ? new Date(userAccount.accessTokenExpiresAt) - : undefined, - } - } catch (error) { - console.error('Error getting OAuth tokens:', error) - return null - } -} - -/** - * Get OAuth tokens for a specific tool if the user has authorized it - * - * @param userId - The user's ID - * @param tool - The tool configuration - * @returns The OAuth tokens or null if not required or not authorized - */ -export async function getOAuthTokensForTool( - userId: string, - tool: any -): Promise<{ - accessToken: string - refreshToken?: string - expiresAt?: Date -} | null> { - // Skip if no OAuth config or not required - if (!tool.oauth || !tool.oauth.required) { - return null - } - - // Get tokens for the provider - return getOAuthTokens(userId, tool.oauth.provider) -} - -/** - * Custom hook to check if a user has authorized an OAuth provider - * - * @param provider - The OAuth provider to check - * @param requiredScopes - Optional array of scopes required for the operation - * @returns An object with authorization status and loading state - */ -export function useProviderAuthorization(provider: OAuthProvider, requiredScopes?: string[]) { - const { data: session, isPending } = useSession() - const [isAuthorized, setIsAuthorized] = useState(false) - - useEffect(() => { - if (isPending || !session?.user) { - setIsAuthorized(false) - return - } - - // Check if the user has provider accounts in their session - // This is a client-side check, so it may not be as accurate as the server-side check - const checkAuthorization = async () => { - try { - // We'll use an API endpoint to check authorization status - const response = await fetch( - `/api/auth/oauth/check?provider=${provider}${ - requiredScopes ? `&scopes=${requiredScopes.join(',')}` : '' - }` - ) - - if (response.ok) { - const data = await response.json() - setIsAuthorized(data.isAuthorized || false) - } else { - setIsAuthorized(false) - } - } catch (error) { - console.error('Error checking OAuth authorization:', error) - setIsAuthorized(false) - } - } - - checkAuthorization() - }, [session, isPending, provider, requiredScopes]) - - return { - isAuthorized, - isLoading: isPending, - isLoggedIn: !!session?.user, - } -} - -/** - * Check if a tool requires OAuth and if the user has authorized it - * This function must be used in a client component - * - * @param tool - The tool configuration - * @returns Object indicating if OAuth is required and if the user has the necessary authorization - */ -export function useToolOAuthRequirement(tool: any) { - // Skip if no OAuth config or not required - if (!tool.oauth || !tool.oauth.required) { - return { - requiresAuth: false, - isAuthorized: true, - isLoading: false, - } - } - - const provider = tool.oauth.provider - const additionalScopes = tool.oauth.additionalScopes || [] - - // Use the provider authorization hook - const { isAuthorized, isLoading, isLoggedIn } = useProviderAuthorization( - provider, - additionalScopes - ) - - return { - requiresAuth: true, - isAuthorized: isAuthorized, - isLoading, - isLoggedIn, - provider, - requiredScopes: additionalScopes, - } -} - -/** - * Verify OAuth requirements before executing a tool - * Throws an error if OAuth is required but not authorized - * This function must be used in a client component - * - * @param toolOAuthStatus - The result from useToolOAuthRequirement - * @param tool - The tool configuration - * @throws Error with JSON stringified OAuthRequiredError - */ -export function verifyOAuthBeforeExecution( - toolOAuthStatus: ReturnType, - tool: any -): void { - const { requiresAuth, isAuthorized, isLoading, provider, requiredScopes } = toolOAuthStatus - - // Don't verify while loading - if (isLoading) { - return - } - - if (requiresAuth && !isAuthorized && provider) { - // Throw a structured error that the frontend can catch and handle - throw new Error( - JSON.stringify({ - type: 'oauth_required', - provider, - toolId: tool.id, - toolName: tool.name, - requiredScopes, - } as OAuthRequiredError) - ) - } -} - -/** - * Hook for handling OAuth errors during tool execution - * Provides a modal state and error handler function + * Custom hook to handle OAuth errors during workflow execution */ export function useOAuthErrorHandler() { - const [oauthModalState, setOAuthModalState] = useState<{ + const [modalState, setModalState] = useState<{ isOpen: boolean - provider: OAuthProvider + provider: OAuthProvider | null toolName: string + requiredScopes?: string[] }>({ isOpen: false, - provider: 'github', + provider: null, toolName: '', }) - /** - * Handle an error that might be an OAuth required error - * Returns true if it was handled as an OAuth error, false otherwise - */ - const handleError = useCallback((error: any): boolean => { - if (!error) return false - + const handleOAuthError = useCallback((error: any) => { + // Check if the error is an OAuth required error try { - // Try to parse error message as JSON - let errorObj + if (typeof error === 'string' && error.includes('oauth_required')) { + const errorData: OAuthRequiredError = JSON.parse(error) - if (typeof error === 'string') { - errorObj = JSON.parse(error) - } else if (error instanceof Error && error.message) { - try { - errorObj = JSON.parse(error.message) - } catch { - return false + if (errorData.type === 'oauth_required' && errorData.provider) { + setModalState({ + isOpen: true, + provider: errorData.provider, + toolName: errorData.toolName || 'this tool', + requiredScopes: errorData.requiredScopes, + }) + return true } - } else { - return false - } + } else if ( + error?.message && + typeof error.message === 'string' && + error.message.includes('oauth_required') + ) { + try { + const errorData: OAuthRequiredError = JSON.parse(error.message) - // Check if it's an OAuth required error - if (errorObj?.type === 'oauth_required') { - setOAuthModalState({ - isOpen: true, - provider: errorObj.provider, - toolName: errorObj.toolName, - }) - return true + if (errorData.type === 'oauth_required' && errorData.provider) { + setModalState({ + isOpen: true, + provider: errorData.provider, + toolName: errorData.toolName || 'this tool', + requiredScopes: errorData.requiredScopes, + }) + return true + } + } catch (parseError) { + console.error('Error parsing OAuth error message:', parseError) + } } - } catch (parseError) { - // Not a JSON error or not an OAuth error - return false + } catch (e) { + console.error('Error handling OAuth error:', e) } return false }, []) const closeModal = useCallback(() => { - setOAuthModalState((prev) => ({ ...prev, isOpen: false })) + setModalState((prev) => ({ ...prev, isOpen: false })) }, []) return { - oauthModalState, - handleOAuthError: handleError, - closeOAuthModal: closeModal, + modalState, + handleOAuthError, + closeModal, } } diff --git a/tools/index.ts b/tools/index.ts index 8550f7cecc..50b4948685 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -1,4 +1,4 @@ -import { verifyOAuthBeforeExecutionServer } from '@/lib/oauth' +import { OAuthRequiredError } from '@/lib/oauth' import { useCustomToolsStore } from '@/stores/custom-tools/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' import { visionTool as crewAIVision } from './crewai/vision' @@ -275,6 +275,39 @@ function getCustomTool(customToolId: string): ToolConfig | undefined { } } +// Function to check OAuth via API +async function checkOAuth(tool: any): Promise { + // Skip if no OAuth config or not required or if running in browser + if (!tool.oauth?.required || isBrowser()) { + return + } + + try { + // Call the API route for OAuth checking + const response = await fetch('/api/auth/oauth/check-tool', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tool }), + }) + + if (!response.ok) { + throw new Error('Failed to check OAuth authorization') + } + + const data = await response.json() + + // If requires auth but not authorized, throw the OAuth error + if (data.requiresAuth && !data.isAuthorized && data.error) { + throw new Error(data.error) + } + } catch (error) { + // Re-throw the error to be caught by execution error handlers + throw error + } +} + // Execute a tool by calling either the proxy for external APIs or directly for internal routes export async function executeTool( toolId: string, @@ -293,9 +326,8 @@ export async function executeTool( } // Check OAuth requirements before executing the tool - // This will throw an OAuthRequiredError if the tool requires OAuth but the user hasn't authorized it if (tool.oauth?.required && !isBrowser()) { - await verifyOAuthBeforeExecutionServer(tool) + await checkOAuth(tool) } // For custom tools, try direct execution in browser first if available