From 764959eaca00feddf256ce8566ba962461ad48a6 Mon Sep 17 00:00:00 2001 From: samanhappy Date: Mon, 1 Dec 2025 16:02:55 +0800 Subject: [PATCH] Implement OAuth client and token management with settings updates (#464) --- frontend/src/App.tsx | 62 +- frontend/src/components/ui/PromptCard.tsx | 219 +++--- frontend/src/contexts/SettingsContext.tsx | 705 ++++++++++++++++++ frontend/src/hooks/useSettingsData.ts | 662 +--------------- locales/en.json | 4 +- locales/fr.json | 4 +- locales/tr.json | 4 +- locales/zh.json | 4 +- src/controllers/oauthClientController.ts | 36 +- .../oauthDynamicRegistrationController.ts | 18 +- src/controllers/oauthServerController.ts | 2 +- src/controllers/serverController.ts | 80 +- src/dao/DaoFactory.ts | 30 + src/dao/DatabaseDaoFactory.ts | 31 +- src/dao/OAuthClientDao.ts | 146 ++++ src/dao/OAuthClientDaoDbImpl.ts | 109 +++ src/dao/OAuthTokenDao.ts | 259 +++++++ src/dao/OAuthTokenDaoDbImpl.ts | 122 +++ src/dao/index.ts | 4 + src/db/entities/OAuthClient.ts | 60 ++ src/db/entities/OAuthToken.ts | 51 ++ src/db/entities/index.ts | 15 +- src/db/repositories/GroupRepository.ts | 4 +- src/db/repositories/OAuthClientRepository.ts | 80 ++ src/db/repositories/OAuthTokenRepository.ts | 183 +++++ src/db/repositories/ServerRepository.ts | 6 +- src/db/repositories/UserRepository.ts | 4 +- src/db/repositories/index.ts | 4 + src/middlewares/auth.ts | 2 +- src/models/OAuth.ts | 233 +++--- src/services/oauthServerService.ts | 16 +- src/utils/migration.ts | 51 ++ src/utils/oauthBearer.ts | 2 +- tests/models/oauth.test.ts | 145 ++-- 34 files changed, 2306 insertions(+), 1051 deletions(-) create mode 100644 frontend/src/contexts/SettingsContext.tsx create mode 100644 src/dao/OAuthClientDao.ts create mode 100644 src/dao/OAuthClientDaoDbImpl.ts create mode 100644 src/dao/OAuthTokenDao.ts create mode 100644 src/dao/OAuthTokenDaoDbImpl.ts create mode 100644 src/db/entities/OAuthClient.ts create mode 100644 src/db/entities/OAuthToken.ts create mode 100644 src/db/repositories/OAuthClientRepository.ts create mode 100644 src/db/repositories/OAuthTokenRepository.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e6a04b0..dd87b1d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { AuthProvider } from './contexts/AuthContext'; import { ToastProvider } from './contexts/ToastContext'; import { ThemeProvider } from './contexts/ThemeContext'; import { ServerProvider } from './contexts/ServerContext'; +import { SettingsProvider } from './contexts/SettingsContext'; import MainLayout from './layouts/MainLayout'; import ProtectedRoute from './components/ProtectedRoute'; import LoginPage from './pages/LoginPage'; @@ -27,42 +28,41 @@ function App() { return ( - - - - - {/* 公共路由 */} - } /> + + + + + + {/* 公共路由 */} + } /> - {/* 受保护的路由,使用 MainLayout 作为布局容器 */} - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - {/* Legacy cloud routes redirect to market with cloud tab */} - } /> - } - /> - } /> - } /> - - + {/* 受保护的路由,使用 MainLayout 作为布局容器 */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Legacy cloud routes redirect to market with cloud tab */} + } /> + } /> + } /> + } /> + + - {/* 未匹配的路由重定向到首页 */} - } /> - - - + {/* 未匹配的路由重定向到首页 */} + } /> + + + + ); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/components/ui/PromptCard.tsx b/frontend/src/components/ui/PromptCard.tsx index c7ef685..1b818d1 100644 --- a/frontend/src/components/ui/PromptCard.tsx +++ b/frontend/src/components/ui/PromptCard.tsx @@ -1,152 +1,174 @@ -import { useState, useCallback, useRef, useEffect } from 'react' -import { useTranslation } from 'react-i18next' -import { Prompt } from '@/types' -import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons' -import { Switch } from './ToggleGroup' -import { getPrompt, PromptCallResult } from '@/services/promptService' -import { useSettingsData } from '@/hooks/useSettingsData' -import DynamicForm from './DynamicForm' -import PromptResult from './PromptResult' +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Prompt } from '@/types'; +import { + ChevronDown, + ChevronRight, + Play, + Loader, + Edit, + Check, +} from '@/components/icons/LucideIcons'; +import { Switch } from './ToggleGroup'; +import { getPrompt, updatePromptDescription, PromptCallResult } from '@/services/promptService'; +import { useSettingsData } from '@/hooks/useSettingsData'; +import DynamicForm from './DynamicForm'; +import PromptResult from './PromptResult'; +import { useToast } from '@/contexts/ToastContext'; interface PromptCardProps { - server: string - prompt: Prompt - onToggle?: (promptName: string, enabled: boolean) => void - onDescriptionUpdate?: (promptName: string, description: string) => void + server: string; + prompt: Prompt; + onToggle?: (promptName: string, enabled: boolean) => void; + onDescriptionUpdate?: (promptName: string, description: string) => void; } const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCardProps) => { - const { t } = useTranslation() - const { nameSeparator } = useSettingsData() - const [isExpanded, setIsExpanded] = useState(false) - const [showRunForm, setShowRunForm] = useState(false) - const [isRunning, setIsRunning] = useState(false) - const [result, setResult] = useState(null) - const [isEditingDescription, setIsEditingDescription] = useState(false) - const [customDescription, setCustomDescription] = useState(prompt.description || '') - const descriptionInputRef = useRef(null) - const descriptionTextRef = useRef(null) - const [textWidth, setTextWidth] = useState(0) + const { t } = useTranslation(); + const { showToast } = useToast(); + const { nameSeparator } = useSettingsData(); + const [isExpanded, setIsExpanded] = useState(false); + const [showRunForm, setShowRunForm] = useState(false); + const [isRunning, setIsRunning] = useState(false); + const [result, setResult] = useState(null); + const [isEditingDescription, setIsEditingDescription] = useState(false); + const [customDescription, setCustomDescription] = useState(prompt.description || ''); + const descriptionInputRef = useRef(null); + const descriptionTextRef = useRef(null); + const [textWidth, setTextWidth] = useState(0); // Focus the input when editing mode is activated useEffect(() => { if (isEditingDescription && descriptionInputRef.current) { - descriptionInputRef.current.focus() + descriptionInputRef.current.focus(); // Set input width to match text width if (textWidth > 0) { - descriptionInputRef.current.style.width = `${textWidth + 20}px` // Add some padding + descriptionInputRef.current.style.width = `${textWidth + 20}px`; // Add some padding } } - }, [isEditingDescription, textWidth]) + }, [isEditingDescription, textWidth]); // Measure text width when not editing useEffect(() => { if (!isEditingDescription && descriptionTextRef.current) { - setTextWidth(descriptionTextRef.current.offsetWidth) + setTextWidth(descriptionTextRef.current.offsetWidth); } - }, [isEditingDescription, customDescription]) + }, [isEditingDescription, customDescription]); // Generate a unique key for localStorage based on prompt name and server const getStorageKey = useCallback(() => { - return `mcphub_prompt_form_${server ? `${server}_` : ''}${prompt.name}` - }, [prompt.name, server]) + return `mcphub_prompt_form_${server ? `${server}_` : ''}${prompt.name}`; + }, [prompt.name, server]); // Clear form data from localStorage const clearStoredFormData = useCallback(() => { - localStorage.removeItem(getStorageKey()) - }, [getStorageKey]) + localStorage.removeItem(getStorageKey()); + }, [getStorageKey]); const handleToggle = (enabled: boolean) => { if (onToggle) { - onToggle(prompt.name, enabled) + onToggle(prompt.name, enabled); } - } + }; const handleDescriptionEdit = () => { - setIsEditingDescription(true) - } + setIsEditingDescription(true); + }; const handleDescriptionSave = async () => { - // For now, we'll just update the local state - // In a real implementation, you would call an API to update the description - setIsEditingDescription(false) - if (onDescriptionUpdate) { - onDescriptionUpdate(prompt.name, customDescription) + setIsEditingDescription(false); + try { + const result = await updatePromptDescription(server, prompt.name, customDescription); + if (result.success) { + showToast(t('prompt.descriptionUpdateSuccess'), 'success'); + if (onDescriptionUpdate) { + onDescriptionUpdate(prompt.name, customDescription); + } + } else { + showToast(result.error || t('prompt.descriptionUpdateFailed'), 'error'); + // Revert to original description on failure + setCustomDescription(prompt.description || ''); + } + } catch (error) { + console.error('Error updating prompt description:', error); + showToast(t('prompt.descriptionUpdateFailed'), 'error'); + // Revert to original description on failure + setCustomDescription(prompt.description || ''); } - } + }; const handleDescriptionChange = (e: React.ChangeEvent) => { - setCustomDescription(e.target.value) - } + setCustomDescription(e.target.value); + }; const handleDescriptionKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { - handleDescriptionSave() + handleDescriptionSave(); } else if (e.key === 'Escape') { - setCustomDescription(prompt.description || '') - setIsEditingDescription(false) + setCustomDescription(prompt.description || ''); + setIsEditingDescription(false); } - } + }; const handleGetPrompt = async (arguments_: Record) => { - setIsRunning(true) + setIsRunning(true); try { - const result = await getPrompt({ promptName: prompt.name, arguments: arguments_ }, server) - console.log('GetPrompt result:', result) + const result = await getPrompt({ promptName: prompt.name, arguments: arguments_ }, server); + console.log('GetPrompt result:', result); setResult({ success: result.success, data: result.data, - error: result.error - }) + error: result.error, + }); // Clear form data on successful submission // clearStoredFormData() } catch (error) { setResult({ success: false, error: error instanceof Error ? error.message : 'Unknown error occurred', - }) + }); } finally { - setIsRunning(false) + setIsRunning(false); } - } + }; const handleCancelRun = () => { - setShowRunForm(false) + setShowRunForm(false); // Clear form data when cancelled - clearStoredFormData() - setResult(null) - } + clearStoredFormData(); + setResult(null); + }; const handleCloseResult = () => { - setResult(null) - } + setResult(null); + }; // Convert prompt arguments to ToolInputSchema format for DynamicForm const convertToSchema = () => { if (!prompt.arguments || prompt.arguments.length === 0) { - return { type: 'object', properties: {}, required: [] } + return { type: 'object', properties: {}, required: [] }; } - const properties: Record = {} - const required: string[] = [] + const properties: Record = {}; + const required: string[] = []; - prompt.arguments.forEach(arg => { + prompt.arguments.forEach((arg) => { properties[arg.name] = { type: 'string', // Default to string for prompts - description: arg.description || '' - } + description: arg.description || '', + }; if (arg.required) { - required.push(arg.name) + required.push(arg.name); } - }) + }); return { type: 'object', properties, - required - } - } + required, + }; + }; return (
@@ -158,9 +180,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar

{prompt.name.replace(server + nameSeparator, '')} {prompt.title && ( - - {prompt.title} - + {prompt.title} )} {isEditingDescription ? ( @@ -175,14 +195,14 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar onClick={(e) => e.stopPropagation()} style={{ minWidth: '100px', - width: textWidth > 0 ? `${textWidth + 20}px` : 'auto' + width: textWidth > 0 ? `${textWidth + 20}px` : 'auto', }} />

-
e.stopPropagation()} - > +
e.stopPropagation()}> {prompt.enabled !== undefined && (
-
- {arg.title || ''} -
+
{arg.title || ''}
))}
@@ -296,7 +311,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar )} - ) -} + ); +}; -export default PromptCard +export default PromptCard; diff --git a/frontend/src/contexts/SettingsContext.tsx b/frontend/src/contexts/SettingsContext.tsx new file mode 100644 index 0000000..85395e2 --- /dev/null +++ b/frontend/src/contexts/SettingsContext.tsx @@ -0,0 +1,705 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, + ReactNode, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { ApiResponse } from '@/types'; +import { useToast } from '@/contexts/ToastContext'; +import { apiGet, apiPut } from '@/utils/fetchInterceptor'; + +// Define types for the settings data +interface RoutingConfig { + enableGlobalRoute: boolean; + enableGroupNameRoute: boolean; + enableBearerAuth: boolean; + bearerAuthKey: string; + skipAuth: boolean; +} + +interface InstallConfig { + pythonIndexUrl: string; + npmRegistry: string; + baseUrl: string; +} + +interface SmartRoutingConfig { + enabled: boolean; + dbUrl: string; + openaiApiBaseUrl: string; + openaiApiKey: string; + openaiApiEmbeddingModel: string; +} + +interface MCPRouterConfig { + apiKey: string; + referer: string; + title: string; + baseUrl: string; +} + +interface OAuthServerConfig { + enabled: boolean; + accessTokenLifetime: number; + refreshTokenLifetime: number; + authorizationCodeLifetime: number; + requireClientSecret: boolean; + allowedScopes: string[]; + requireState: boolean; + dynamicRegistration: { + enabled: boolean; + allowedGrantTypes: string[]; + requiresAuthentication: boolean; + }; +} + +interface SystemSettings { + systemConfig?: { + routing?: RoutingConfig; + install?: InstallConfig; + smartRouting?: SmartRoutingConfig; + mcpRouter?: MCPRouterConfig; + nameSeparator?: string; + oauthServer?: OAuthServerConfig; + enableSessionRebuild?: boolean; + }; +} + +interface TempRoutingConfig { + bearerAuthKey: string; +} + +interface SettingsContextValue { + routingConfig: RoutingConfig; + tempRoutingConfig: TempRoutingConfig; + setTempRoutingConfig: React.Dispatch>; + installConfig: InstallConfig; + smartRoutingConfig: SmartRoutingConfig; + mcpRouterConfig: MCPRouterConfig; + oauthServerConfig: OAuthServerConfig; + nameSeparator: string; + enableSessionRebuild: boolean; + loading: boolean; + error: string | null; + setError: React.Dispatch>; + triggerRefresh: () => void; + fetchSettings: () => Promise; + updateRoutingConfig: (key: keyof RoutingConfig, value: any) => Promise; + updateInstallConfig: (key: keyof InstallConfig, value: any) => Promise; + updateSmartRoutingConfig: ( + key: keyof SmartRoutingConfig, + value: any, + ) => Promise; + updateSmartRoutingConfigBatch: ( + updates: Partial, + ) => Promise; + updateRoutingConfigBatch: (updates: Partial) => Promise; + updateMCPRouterConfig: (key: keyof MCPRouterConfig, value: any) => Promise; + updateMCPRouterConfigBatch: (updates: Partial) => Promise; + updateOAuthServerConfig: ( + key: keyof OAuthServerConfig, + value: any, + ) => Promise; + updateOAuthServerConfigBatch: ( + updates: Partial, + ) => Promise; + updateNameSeparator: (value: string) => Promise; + updateSessionRebuild: (value: boolean) => Promise; + exportMCPSettings: (serverName?: string) => Promise; +} + +const getDefaultOAuthServerConfig = (): OAuthServerConfig => ({ + enabled: true, + accessTokenLifetime: 3600, + refreshTokenLifetime: 1209600, + authorizationCodeLifetime: 300, + requireClientSecret: false, + allowedScopes: ['read', 'write'], + requireState: false, + dynamicRegistration: { + enabled: true, + allowedGrantTypes: ['authorization_code', 'refresh_token'], + requiresAuthentication: false, + }, +}); + +const SettingsContext = createContext(undefined); + +export const useSettings = () => { + const context = useContext(SettingsContext); + if (!context) { + throw new Error('useSettings must be used within a SettingsProvider'); + } + return context; +}; + +interface SettingsProviderProps { + children: ReactNode; +} + +export const SettingsProvider: React.FC = ({ children }) => { + const { t } = useTranslation(); + const { showToast } = useToast(); + + const [routingConfig, setRoutingConfig] = useState({ + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: false, + bearerAuthKey: '', + skipAuth: false, + }); + + const [tempRoutingConfig, setTempRoutingConfig] = useState({ + bearerAuthKey: '', + }); + + const [installConfig, setInstallConfig] = useState({ + pythonIndexUrl: '', + npmRegistry: '', + baseUrl: 'http://localhost:3000', + }); + + const [smartRoutingConfig, setSmartRoutingConfig] = useState({ + enabled: false, + dbUrl: '', + openaiApiBaseUrl: '', + openaiApiKey: '', + openaiApiEmbeddingModel: '', + }); + + const [mcpRouterConfig, setMCPRouterConfig] = useState({ + apiKey: '', + referer: 'https://www.mcphubx.com', + title: 'MCPHub', + baseUrl: 'https://api.mcprouter.to/v1', + }); + + const [oauthServerConfig, setOAuthServerConfig] = useState( + getDefaultOAuthServerConfig(), + ); + + const [nameSeparator, setNameSeparator] = useState('-'); + const [enableSessionRebuild, setEnableSessionRebuild] = useState(false); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + + // Trigger a refresh of the settings data + const triggerRefresh = useCallback(() => { + setRefreshKey((prev) => prev + 1); + }, []); + + // Fetch current settings + const fetchSettings = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const data: ApiResponse = await apiGet('/settings'); + + if (data.success && data.data?.systemConfig?.routing) { + setRoutingConfig({ + enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true, + enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true, + enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false, + bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '', + skipAuth: data.data.systemConfig.routing.skipAuth ?? false, + }); + } + if (data.success && data.data?.systemConfig?.install) { + setInstallConfig({ + pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '', + npmRegistry: data.data.systemConfig.install.npmRegistry || '', + baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000', + }); + } + if (data.success && data.data?.systemConfig?.smartRouting) { + setSmartRoutingConfig({ + enabled: data.data.systemConfig.smartRouting.enabled ?? false, + dbUrl: data.data.systemConfig.smartRouting.dbUrl || '', + openaiApiBaseUrl: data.data.systemConfig.smartRouting.openaiApiBaseUrl || '', + openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '', + openaiApiEmbeddingModel: + data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '', + }); + } + if (data.success && data.data?.systemConfig?.mcpRouter) { + setMCPRouterConfig({ + apiKey: data.data.systemConfig.mcpRouter.apiKey || '', + referer: data.data.systemConfig.mcpRouter.referer || 'https://www.mcphubx.com', + title: data.data.systemConfig.mcpRouter.title || 'MCPHub', + baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1', + }); + } + if (data.success) { + if (data.data?.systemConfig?.oauthServer) { + const oauth = data.data.systemConfig.oauthServer; + const defaultOauthConfig = getDefaultOAuthServerConfig(); + const defaultDynamic = defaultOauthConfig.dynamicRegistration; + const allowedScopes = Array.isArray(oauth.allowedScopes) + ? [...oauth.allowedScopes] + : [...defaultOauthConfig.allowedScopes]; + const dynamicAllowedGrantTypes = Array.isArray( + oauth.dynamicRegistration?.allowedGrantTypes, + ) + ? [...oauth.dynamicRegistration!.allowedGrantTypes!] + : [...defaultDynamic.allowedGrantTypes]; + + setOAuthServerConfig({ + enabled: oauth.enabled ?? defaultOauthConfig.enabled, + accessTokenLifetime: + oauth.accessTokenLifetime ?? defaultOauthConfig.accessTokenLifetime, + refreshTokenLifetime: + oauth.refreshTokenLifetime ?? defaultOauthConfig.refreshTokenLifetime, + authorizationCodeLifetime: + oauth.authorizationCodeLifetime ?? defaultOauthConfig.authorizationCodeLifetime, + requireClientSecret: + oauth.requireClientSecret ?? defaultOauthConfig.requireClientSecret, + requireState: oauth.requireState ?? defaultOauthConfig.requireState, + allowedScopes, + dynamicRegistration: { + enabled: oauth.dynamicRegistration?.enabled ?? defaultDynamic.enabled, + allowedGrantTypes: dynamicAllowedGrantTypes, + requiresAuthentication: + oauth.dynamicRegistration?.requiresAuthentication ?? + defaultDynamic.requiresAuthentication, + }, + }); + } else { + setOAuthServerConfig(getDefaultOAuthServerConfig()); + } + } + if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) { + setNameSeparator(data.data.systemConfig.nameSeparator); + } + if (data.success && data.data?.systemConfig?.enableSessionRebuild !== undefined) { + setEnableSessionRebuild(data.data.systemConfig.enableSessionRebuild); + } + } catch (error) { + console.error('Failed to fetch settings:', error); + setError(error instanceof Error ? error.message : 'Failed to fetch settings'); + showToast(t('errors.failedToFetchSettings')); + } finally { + setLoading(false); + } + }, [t, showToast]); + + // Update routing configuration + const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + routing: { + [key]: value, + }, + }); + + if (data.success) { + setRoutingConfig({ + ...routingConfig, + [key]: value, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + setError(data.error || 'Failed to update routing config'); + showToast(data.error || t('errors.failedToUpdateRoutingConfig')); + return false; + } + } catch (error) { + console.error('Failed to update routing config:', error); + setError(error instanceof Error ? error.message : 'Failed to update routing config'); + showToast(t('errors.failedToUpdateRoutingConfig')); + return false; + } finally { + setLoading(false); + } + }; + + // Update install configuration + const updateInstallConfig = async (key: keyof InstallConfig, value: any) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + install: { + [key]: value, + }, + }); + + if (data.success) { + setInstallConfig({ + ...installConfig, + [key]: value, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + setError(data.error || 'Failed to update install config'); + showToast(data.error || t('errors.failedToUpdateInstallConfig')); + return false; + } + } catch (error) { + console.error('Failed to update install config:', error); + setError(error instanceof Error ? error.message : 'Failed to update install config'); + showToast(t('errors.failedToUpdateInstallConfig')); + return false; + } finally { + setLoading(false); + } + }; + + // Update smart routing configuration + const updateSmartRoutingConfig = async (key: keyof SmartRoutingConfig, value: any) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + smartRouting: { + [key]: value, + }, + }); + + if (data.success) { + setSmartRoutingConfig({ + ...smartRoutingConfig, + [key]: value, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + setError(data.error || 'Failed to update smart routing config'); + showToast(data.error || t('errors.failedToUpdateSmartRoutingConfig')); + return false; + } + } catch (error) { + console.error('Failed to update smart routing config:', error); + setError(error instanceof Error ? error.message : 'Failed to update smart routing config'); + showToast(t('errors.failedToUpdateSmartRoutingConfig')); + return false; + } finally { + setLoading(false); + } + }; + + // Batch update smart routing configuration + const updateSmartRoutingConfigBatch = async (updates: Partial) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + smartRouting: updates, + }); + + if (data.success) { + setSmartRoutingConfig({ + ...smartRoutingConfig, + ...updates, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + setError(data.error || 'Failed to update smart routing config'); + showToast(data.error || t('errors.failedToUpdateSmartRoutingConfig')); + return false; + } + } catch (error) { + console.error('Failed to update smart routing config:', error); + setError(error instanceof Error ? error.message : 'Failed to update smart routing config'); + showToast(t('errors.failedToUpdateSmartRoutingConfig')); + return false; + } finally { + setLoading(false); + } + }; + + // Batch update routing configuration + const updateRoutingConfigBatch = async (updates: Partial) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + routing: updates, + }); + + if (data.success) { + setRoutingConfig({ + ...routingConfig, + ...updates, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + setError(data.error || 'Failed to update routing config'); + showToast(data.error || t('errors.failedToUpdateRoutingConfig')); + return false; + } + } catch (error) { + console.error('Failed to update routing config:', error); + setError(error instanceof Error ? error.message : 'Failed to update routing config'); + showToast(t('errors.failedToUpdateRoutingConfig')); + return false; + } finally { + setLoading(false); + } + }; + + // Update MCP Router configuration + const updateMCPRouterConfig = async (key: keyof MCPRouterConfig, value: any) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + mcpRouter: { + [key]: value, + }, + }); + + if (data.success) { + setMCPRouterConfig({ + ...mcpRouterConfig, + [key]: value, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + setError(data.error || 'Failed to update MCP Router config'); + showToast(data.error || t('errors.failedToUpdateMCPRouterConfig')); + return false; + } + } catch (error) { + console.error('Failed to update MCP Router config:', error); + setError(error instanceof Error ? error.message : 'Failed to update MCP Router config'); + showToast(t('errors.failedToUpdateMCPRouterConfig')); + return false; + } finally { + setLoading(false); + } + }; + + // Batch update MCP Router configuration + const updateMCPRouterConfigBatch = async (updates: Partial) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + mcpRouter: updates, + }); + + if (data.success) { + setMCPRouterConfig({ + ...mcpRouterConfig, + ...updates, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + setError(data.error || 'Failed to update MCP Router config'); + showToast(data.error || t('errors.failedToUpdateMCPRouterConfig')); + return false; + } + } catch (error) { + console.error('Failed to update MCP Router config:', error); + setError(error instanceof Error ? error.message : 'Failed to update MCP Router config'); + showToast(t('errors.failedToUpdateMCPRouterConfig')); + return false; + } finally { + setLoading(false); + } + }; + + // Update OAuth server configuration + const updateOAuthServerConfig = async (key: keyof OAuthServerConfig, value: any) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + oauthServer: { + [key]: value, + }, + }); + + if (data.success) { + setOAuthServerConfig({ + ...oauthServerConfig, + [key]: value, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + setError(data.error || 'Failed to update OAuth server config'); + showToast(data.error || t('errors.failedToUpdateOAuthServerConfig')); + return false; + } + } catch (error) { + console.error('Failed to update OAuth server config:', error); + setError(error instanceof Error ? error.message : 'Failed to update OAuth server config'); + showToast(t('errors.failedToUpdateOAuthServerConfig')); + return false; + } finally { + setLoading(false); + } + }; + + // Batch update OAuth server configuration + const updateOAuthServerConfigBatch = async (updates: Partial) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + oauthServer: updates, + }); + + if (data.success) { + setOAuthServerConfig({ + ...oauthServerConfig, + ...updates, + }); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + setError(data.error || 'Failed to update OAuth server config'); + showToast(data.error || t('errors.failedToUpdateOAuthServerConfig')); + return false; + } + } catch (error) { + console.error('Failed to update OAuth server config:', error); + setError(error instanceof Error ? error.message : 'Failed to update OAuth server config'); + showToast(t('errors.failedToUpdateOAuthServerConfig')); + return false; + } finally { + setLoading(false); + } + }; + + // Update name separator + const updateNameSeparator = async (value: string) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + nameSeparator: value, + }); + + if (data.success) { + setNameSeparator(value); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + setError(data.error || 'Failed to update name separator'); + showToast(data.error || t('errors.failedToUpdateNameSeparator')); + return false; + } + } catch (error) { + console.error('Failed to update name separator:', error); + setError(error instanceof Error ? error.message : 'Failed to update name separator'); + showToast(t('errors.failedToUpdateNameSeparator')); + return false; + } finally { + setLoading(false); + } + }; + + // Update session rebuild flag + const updateSessionRebuild = async (value: boolean) => { + setLoading(true); + setError(null); + + try { + const data = await apiPut('/system-config', { + enableSessionRebuild: value, + }); + + if (data.success) { + setEnableSessionRebuild(value); + showToast(t('settings.systemConfigUpdated')); + return true; + } else { + setError(data.error || 'Failed to update session rebuild setting'); + showToast(data.error || t('errors.failedToUpdateSessionRebuild')); + return false; + } + } catch (error) { + console.error('Failed to update session rebuild setting:', error); + setError(error instanceof Error ? error.message : 'Failed to update session rebuild setting'); + showToast(t('errors.failedToUpdateSessionRebuild')); + return false; + } finally { + setLoading(false); + } + }; + + const exportMCPSettings = async (serverName?: string) => { + setLoading(true); + setError(null); + try { + return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`); + } catch (error) { + console.error('Failed to export MCP settings:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings'; + setError(errorMessage); + showToast(errorMessage); + } finally { + setLoading(false); + } + }; + + // Fetch settings when the component mounts or refreshKey changes + useEffect(() => { + fetchSettings(); + }, [fetchSettings, refreshKey]); + + useEffect(() => { + if (routingConfig) { + setTempRoutingConfig({ + bearerAuthKey: routingConfig.bearerAuthKey, + }); + } + }, [routingConfig]); + + const value: SettingsContextValue = { + routingConfig, + tempRoutingConfig, + setTempRoutingConfig, + installConfig, + smartRoutingConfig, + mcpRouterConfig, + oauthServerConfig, + nameSeparator, + enableSessionRebuild, + loading, + error, + setError, + triggerRefresh, + fetchSettings, + updateRoutingConfig, + updateInstallConfig, + updateSmartRoutingConfig, + updateSmartRoutingConfigBatch, + updateRoutingConfigBatch, + updateMCPRouterConfig, + updateMCPRouterConfigBatch, + updateOAuthServerConfig, + updateOAuthServerConfigBatch, + updateNameSeparator, + updateSessionRebuild, + exportMCPSettings, + }; + + return {children}; +}; diff --git a/frontend/src/hooks/useSettingsData.ts b/frontend/src/hooks/useSettingsData.ts index 9ea7d14..59f9a33 100644 --- a/frontend/src/hooks/useSettingsData.ts +++ b/frontend/src/hooks/useSettingsData.ts @@ -1,658 +1,10 @@ -import { useState, useCallback, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ApiResponse } from '@/types'; -import { useToast } from '@/contexts/ToastContext'; -import { apiGet, apiPut } from '../utils/fetchInterceptor'; - -// Define types for the settings data -interface RoutingConfig { - enableGlobalRoute: boolean; - enableGroupNameRoute: boolean; - enableBearerAuth: boolean; - bearerAuthKey: string; - skipAuth: boolean; -} - -interface InstallConfig { - pythonIndexUrl: string; - npmRegistry: string; - baseUrl: string; -} - -interface SmartRoutingConfig { - enabled: boolean; - dbUrl: string; - openaiApiBaseUrl: string; - openaiApiKey: string; - openaiApiEmbeddingModel: string; -} - -interface MCPRouterConfig { - apiKey: string; - referer: string; - title: string; - baseUrl: string; -} - -interface OAuthServerConfig { - enabled: boolean; - accessTokenLifetime: number; - refreshTokenLifetime: number; - authorizationCodeLifetime: number; - requireClientSecret: boolean; - allowedScopes: string[]; - requireState: boolean; - dynamicRegistration: { - enabled: boolean; - allowedGrantTypes: string[]; - requiresAuthentication: boolean; - }; -} - -interface SystemSettings { - systemConfig?: { - routing?: RoutingConfig; - install?: InstallConfig; - smartRouting?: SmartRoutingConfig; - mcpRouter?: MCPRouterConfig; - nameSeparator?: string; - oauthServer?: OAuthServerConfig; - enableSessionRebuild?: boolean; - }; -} - -interface TempRoutingConfig { - bearerAuthKey: string; -} - -const getDefaultOAuthServerConfig = (): OAuthServerConfig => ({ - enabled: true, - accessTokenLifetime: 3600, - refreshTokenLifetime: 1209600, - authorizationCodeLifetime: 300, - requireClientSecret: false, - allowedScopes: ['read', 'write'], - requireState: false, - dynamicRegistration: { - enabled: true, - allowedGrantTypes: ['authorization_code', 'refresh_token'], - requiresAuthentication: false, - }, -}); +import { useSettings } from '@/contexts/SettingsContext'; +/** + * Hook that provides access to settings data via SettingsContext. + * This hook is a thin wrapper around useSettings to maintain backward compatibility. + * The actual data fetching happens once in SettingsProvider, avoiding duplicate API calls. + */ export const useSettingsData = () => { - const { t } = useTranslation(); - const { showToast } = useToast(); - - const [routingConfig, setRoutingConfig] = useState({ - enableGlobalRoute: true, - enableGroupNameRoute: true, - enableBearerAuth: false, - bearerAuthKey: '', - skipAuth: false, - }); - - const [tempRoutingConfig, setTempRoutingConfig] = useState({ - bearerAuthKey: '', - }); - - const [installConfig, setInstallConfig] = useState({ - pythonIndexUrl: '', - npmRegistry: '', - baseUrl: 'http://localhost:3000', - }); - - const [smartRoutingConfig, setSmartRoutingConfig] = useState({ - enabled: false, - dbUrl: '', - openaiApiBaseUrl: '', - openaiApiKey: '', - openaiApiEmbeddingModel: '', - }); - - const [mcpRouterConfig, setMCPRouterConfig] = useState({ - apiKey: '', - referer: 'https://www.mcphubx.com', - title: 'MCPHub', - baseUrl: 'https://api.mcprouter.to/v1', - }); - - const [oauthServerConfig, setOAuthServerConfig] = useState( - getDefaultOAuthServerConfig(), - ); - - const [nameSeparator, setNameSeparator] = useState('-'); - const [enableSessionRebuild, setEnableSessionRebuild] = useState(false); - - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [refreshKey, setRefreshKey] = useState(0); - - // Trigger a refresh of the settings data - const triggerRefresh = useCallback(() => { - setRefreshKey((prev) => prev + 1); - }, []); - - // Fetch current settings - const fetchSettings = useCallback(async () => { - setLoading(true); - setError(null); - - try { - const data: ApiResponse = await apiGet('/settings'); - - if (data.success && data.data?.systemConfig?.routing) { - setRoutingConfig({ - enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true, - enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true, - enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false, - bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '', - skipAuth: data.data.systemConfig.routing.skipAuth ?? false, - }); - } - if (data.success && data.data?.systemConfig?.install) { - setInstallConfig({ - pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '', - npmRegistry: data.data.systemConfig.install.npmRegistry || '', - baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000', - }); - } - if (data.success && data.data?.systemConfig?.smartRouting) { - setSmartRoutingConfig({ - enabled: data.data.systemConfig.smartRouting.enabled ?? false, - dbUrl: data.data.systemConfig.smartRouting.dbUrl || '', - openaiApiBaseUrl: data.data.systemConfig.smartRouting.openaiApiBaseUrl || '', - openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '', - openaiApiEmbeddingModel: - data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '', - }); - } - if (data.success && data.data?.systemConfig?.mcpRouter) { - setMCPRouterConfig({ - apiKey: data.data.systemConfig.mcpRouter.apiKey || '', - referer: data.data.systemConfig.mcpRouter.referer || 'https://www.mcphubx.com', - title: data.data.systemConfig.mcpRouter.title || 'MCPHub', - baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1', - }); - } - if (data.success) { - if (data.data?.systemConfig?.oauthServer) { - const oauth = data.data.systemConfig.oauthServer; - const defaultOauthConfig = getDefaultOAuthServerConfig(); - const defaultDynamic = defaultOauthConfig.dynamicRegistration; - const allowedScopes = Array.isArray(oauth.allowedScopes) - ? [...oauth.allowedScopes] - : [...defaultOauthConfig.allowedScopes]; - const dynamicAllowedGrantTypes = Array.isArray( - oauth.dynamicRegistration?.allowedGrantTypes, - ) - ? [...oauth.dynamicRegistration!.allowedGrantTypes!] - : [...defaultDynamic.allowedGrantTypes]; - - setOAuthServerConfig({ - enabled: oauth.enabled ?? defaultOauthConfig.enabled, - accessTokenLifetime: - oauth.accessTokenLifetime ?? defaultOauthConfig.accessTokenLifetime, - refreshTokenLifetime: - oauth.refreshTokenLifetime ?? defaultOauthConfig.refreshTokenLifetime, - authorizationCodeLifetime: - oauth.authorizationCodeLifetime ?? defaultOauthConfig.authorizationCodeLifetime, - requireClientSecret: - oauth.requireClientSecret ?? defaultOauthConfig.requireClientSecret, - requireState: oauth.requireState ?? defaultOauthConfig.requireState, - allowedScopes, - dynamicRegistration: { - enabled: oauth.dynamicRegistration?.enabled ?? defaultDynamic.enabled, - allowedGrantTypes: dynamicAllowedGrantTypes, - requiresAuthentication: - oauth.dynamicRegistration?.requiresAuthentication ?? - defaultDynamic.requiresAuthentication, - }, - }); - } else { - setOAuthServerConfig(getDefaultOAuthServerConfig()); - } - } - if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) { - setNameSeparator(data.data.systemConfig.nameSeparator); - } - if (data.success && data.data?.systemConfig?.enableSessionRebuild !== undefined) { - setEnableSessionRebuild(data.data.systemConfig.enableSessionRebuild); - } - } catch (error) { - console.error('Failed to fetch settings:', error); - setError(error instanceof Error ? error.message : 'Failed to fetch settings'); - // 使用一个稳定的 showToast 引用,避免将其加入依赖数组 - showToast(t('errors.failedToFetchSettings')); - } finally { - setLoading(false); - } - }, [t]); // 移除 showToast 依赖 - - // Update routing configuration - const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => { - setLoading(true); - setError(null); - - try { - const data = await apiPut('/system-config', { - routing: { - [key]: value, - }, - }); - - if (data.success) { - setRoutingConfig({ - ...routingConfig, - [key]: value, - }); - showToast(t('settings.systemConfigUpdated')); - return true; - } else { - showToast(data.message || t('errors.failedToUpdateRouteConfig')); - return false; - } - } catch (error) { - console.error('Failed to update routing config:', error); - setError(error instanceof Error ? error.message : 'Failed to update routing config'); - showToast(t('errors.failedToUpdateRouteConfig')); - return false; - } finally { - setLoading(false); - } - }; - - // Update install configuration - const updateInstallConfig = async (key: keyof InstallConfig, value: string) => { - setLoading(true); - setError(null); - - try { - const data = await apiPut('/system-config', { - install: { - [key]: value, - }, - }); - - if (data.success) { - setInstallConfig({ - ...installConfig, - [key]: value, - }); - showToast(t('settings.systemConfigUpdated')); - return true; - } else { - showToast(data.message || t('errors.failedToUpdateSystemConfig')); - return false; - } - } catch (error) { - console.error('Failed to update system config:', error); - setError(error instanceof Error ? error.message : 'Failed to update system config'); - showToast(t('errors.failedToUpdateSystemConfig')); - return false; - } finally { - setLoading(false); - } - }; - - // Update smart routing configuration - const updateSmartRoutingConfig = async ( - key: T, - value: SmartRoutingConfig[T], - ) => { - setLoading(true); - setError(null); - - try { - const data = await apiPut('/system-config', { - smartRouting: { - [key]: value, - }, - }); - - if (data.success) { - setSmartRoutingConfig({ - ...smartRoutingConfig, - [key]: value, - }); - showToast(t('settings.systemConfigUpdated')); - return true; - } else { - showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig')); - return false; - } - } catch (error) { - console.error('Failed to update smart routing config:', error); - const errorMessage = - error instanceof Error ? error.message : 'Failed to update smart routing config'; - setError(errorMessage); - showToast(errorMessage); - return false; - } finally { - setLoading(false); - } - }; - - // Update multiple smart routing configuration fields at once - const updateSmartRoutingConfigBatch = async (updates: Partial) => { - setLoading(true); - setError(null); - - try { - const data = await apiPut('/system-config', { - smartRouting: updates, - }); - - if (data.success) { - setSmartRoutingConfig({ - ...smartRoutingConfig, - ...updates, - }); - showToast(t('settings.systemConfigUpdated')); - return true; - } else { - showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig')); - return false; - } - } catch (error) { - console.error('Failed to update smart routing config:', error); - const errorMessage = - error instanceof Error ? error.message : 'Failed to update smart routing config'; - setError(errorMessage); - showToast(errorMessage); - return false; - } finally { - setLoading(false); - } - }; - - // Update multiple routing configuration fields at once - const updateRoutingConfigBatch = async (updates: Partial) => { - setLoading(true); - setError(null); - - try { - const data = await apiPut('/system-config', { - routing: updates, - }); - - if (data.success) { - setRoutingConfig({ - ...routingConfig, - ...updates, - }); - showToast(t('settings.systemConfigUpdated')); - return true; - } else { - showToast(data.message || t('errors.failedToUpdateRouteConfig')); - return false; - } - } catch (error) { - console.error('Failed to update routing config:', error); - setError(error instanceof Error ? error.message : 'Failed to update routing config'); - showToast(t('errors.failedToUpdateRouteConfig')); - return false; - } finally { - setLoading(false); - } - }; - - // Update MCPRouter configuration - const updateMCPRouterConfig = async ( - key: T, - value: MCPRouterConfig[T], - ) => { - setLoading(true); - setError(null); - - try { - const data = await apiPut('/system-config', { - mcpRouter: { - [key]: value, - }, - }); - - if (data.success) { - setMCPRouterConfig({ - ...mcpRouterConfig, - [key]: value, - }); - showToast(t('settings.systemConfigUpdated')); - return true; - } else { - showToast(data.message || t('errors.failedToUpdateSystemConfig')); - return false; - } - } catch (error) { - console.error('Failed to update MCPRouter config:', error); - const errorMessage = - error instanceof Error ? error.message : 'Failed to update MCPRouter config'; - setError(errorMessage); - showToast(errorMessage); - return false; - } finally { - setLoading(false); - } - }; - - // Update multiple MCPRouter configuration fields at once - const updateMCPRouterConfigBatch = async (updates: Partial) => { - setLoading(true); - setError(null); - - try { - const data = await apiPut('/system-config', { - mcpRouter: updates, - }); - - if (data.success) { - setMCPRouterConfig({ - ...mcpRouterConfig, - ...updates, - }); - showToast(t('settings.systemConfigUpdated')); - return true; - } else { - showToast(data.message || t('errors.failedToUpdateSystemConfig')); - return false; - } - } catch (error) { - console.error('Failed to update MCPRouter config:', error); - const errorMessage = - error instanceof Error ? error.message : 'Failed to update MCPRouter config'; - setError(errorMessage); - showToast(errorMessage); - return false; - } finally { - setLoading(false); - } - }; - - // Update OAuth server configuration - const updateOAuthServerConfig = async ( - key: T, - value: OAuthServerConfig[T], - ) => { - setLoading(true); - setError(null); - - try { - const data = await apiPut('/system-config', { - oauthServer: { - [key]: value, - }, - }); - - if (data.success) { - setOAuthServerConfig((prev) => ({ - ...prev, - [key]: value, - })); - showToast(t('settings.systemConfigUpdated')); - return true; - } else { - showToast(data.message || t('errors.failedToUpdateSystemConfig')); - return false; - } - } catch (error) { - console.error('Failed to update OAuth server config:', error); - const errorMessage = - error instanceof Error ? error.message : 'Failed to update OAuth server config'; - setError(errorMessage); - showToast(errorMessage); - return false; - } finally { - setLoading(false); - } - }; - - // Update multiple OAuth server config fields - const updateOAuthServerConfigBatch = async (updates: Partial) => { - setLoading(true); - setError(null); - - try { - const data = await apiPut('/system-config', { - oauthServer: updates, - }); - - if (data.success) { - setOAuthServerConfig((prev) => ({ - ...prev, - ...updates, - })); - showToast(t('settings.systemConfigUpdated')); - return true; - } else { - showToast(data.message || t('errors.failedToUpdateSystemConfig')); - return false; - } - } catch (error) { - console.error('Failed to update OAuth server config:', error); - const errorMessage = - error instanceof Error ? error.message : 'Failed to update OAuth server config'; - setError(errorMessage); - showToast(errorMessage); - return false; - } finally { - setLoading(false); - } - }; - - // Update name separator - const updateNameSeparator = async (value: string) => { - setLoading(true); - setError(null); - - try { - const data = await apiPut('/system-config', { - nameSeparator: value, - }); - - if (data.success) { - setNameSeparator(value); - showToast(t('settings.restartRequired'), 'info'); - return true; - } else { - showToast(data.message || t('errors.failedToUpdateSystemConfig')); - return false; - } - } catch (error) { - console.error('Failed to update name separator:', error); - const errorMessage = - error instanceof Error ? error.message : 'Failed to update name separator'; - setError(errorMessage); - showToast(errorMessage); - return false; - } finally { - setLoading(false); - } - }; - - // Update session rebuild setting - const updateSessionRebuild = async (value: boolean) => { - setLoading(true); - setError(null); - - try { - const data = await apiPut('/system-config', { - enableSessionRebuild: value, - }); - - if (data.success) { - setEnableSessionRebuild(value); - showToast(t('settings.restartRequired'), 'info'); - return true; - } else { - showToast(data.message || t('errors.failedToUpdateSystemConfig')); - return false; - } - } catch (error) { - console.error('Failed to update session rebuild setting:', error); - const errorMessage = - error instanceof Error ? error.message : 'Failed to update session rebuild setting'; - setError(errorMessage); - showToast(errorMessage); - return false; - } finally { - setLoading(false); - } - }; - - const exportMCPSettings = async (serverName?: string) => { - setLoading(true); - setError(null); - try { - return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`); - } catch (error) { - console.error('Failed to export MCP settings:', error); - const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings'; - setError(errorMessage); - showToast(errorMessage); - } finally { - setLoading(false); - } - }; - - // Fetch settings when the component mounts or refreshKey changes - useEffect(() => { - fetchSettings(); - }, [fetchSettings, refreshKey]); - - useEffect(() => { - if (routingConfig) { - setTempRoutingConfig({ - bearerAuthKey: routingConfig.bearerAuthKey, - }); - } - }, [routingConfig]); - - return { - routingConfig, - tempRoutingConfig, - setTempRoutingConfig, - installConfig, - smartRoutingConfig, - mcpRouterConfig, - oauthServerConfig, - nameSeparator, - enableSessionRebuild, - loading, - error, - setError, - triggerRefresh, - fetchSettings, - updateRoutingConfig, - updateInstallConfig, - updateSmartRoutingConfig, - updateSmartRoutingConfigBatch, - updateRoutingConfigBatch, - updateMCPRouterConfig, - updateMCPRouterConfigBatch, - updateOAuthServerConfig, - updateOAuthServerConfigBatch, - updateNameSeparator, - updateSessionRebuild, - exportMCPSettings, - }; + return useSettings(); }; diff --git a/locales/en.json b/locales/en.json index 13b5b02..f87e0ac 100644 --- a/locales/en.json +++ b/locales/en.json @@ -536,7 +536,9 @@ "description": "Description", "messages": "Messages", "noDescription": "No description available", - "runPromptWithName": "Get Prompt: {{name}}" + "runPromptWithName": "Get Prompt: {{name}}", + "descriptionUpdateSuccess": "Prompt description updated successfully", + "descriptionUpdateFailed": "Failed to update prompt description" }, "settings": { "enableGlobalRoute": "Enable Global Route", diff --git a/locales/fr.json b/locales/fr.json index 7a36945..e276522 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -536,7 +536,9 @@ "description": "Description", "messages": "Messages", "noDescription": "Aucune description disponible", - "runPromptWithName": "Obtenir l'invite : {{name}}" + "runPromptWithName": "Obtenir l'invite : {{name}}", + "descriptionUpdateSuccess": "Description de l'invite mise à jour avec succès", + "descriptionUpdateFailed": "Échec de la mise à jour de la description de l'invite" }, "settings": { "enableGlobalRoute": "Activer la route globale", diff --git a/locales/tr.json b/locales/tr.json index 4a6bf74..4aada45 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -536,7 +536,9 @@ "description": "Açıklama", "messages": "Mesajlar", "noDescription": "Kullanılabilir açıklama yok", - "runPromptWithName": "İsteği Getir: {{name}}" + "runPromptWithName": "İsteği Getir: {{name}}", + "descriptionUpdateSuccess": "İstek açıklaması başarıyla güncellendi", + "descriptionUpdateFailed": "İstek açıklaması güncellenemedi" }, "settings": { "enableGlobalRoute": "Global Yönlendirmeyi Etkinleştir", diff --git a/locales/zh.json b/locales/zh.json index e2a80f4..50c3780 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -537,7 +537,9 @@ "description": "描述", "messages": "消息", "noDescription": "无描述信息", - "runPromptWithName": "获取提示词: {{name}}" + "runPromptWithName": "获取提示词: {{name}}", + "descriptionUpdateSuccess": "提示词描述更新成功", + "descriptionUpdateFailed": "更新提示词描述失败" }, "settings": { "enableGlobalRoute": "启用全局路由", diff --git a/src/controllers/oauthClientController.ts b/src/controllers/oauthClientController.ts index 8c08cde..17b4398 100644 --- a/src/controllers/oauthClientController.ts +++ b/src/controllers/oauthClientController.ts @@ -14,10 +14,10 @@ import { IOAuthClient } from '../types/index.js'; * GET /api/oauth/clients * Get all OAuth clients */ -export const getAllClients = (req: Request, res: Response): void => { +export const getAllClients = async (req: Request, res: Response): Promise => { try { - const clients = getOAuthClients(); - + const clients = await getOAuthClients(); + // Don't expose client secrets in the list const sanitizedClients = clients.map((client) => ({ clientId: client.clientId, @@ -45,10 +45,10 @@ export const getAllClients = (req: Request, res: Response): void => { * GET /api/oauth/clients/:clientId * Get a specific OAuth client */ -export const getClient = (req: Request, res: Response): void => { +export const getClient = async (req: Request, res: Response): Promise => { try { const { clientId } = req.params; - const client = findOAuthClientById(clientId); + const client = await findOAuthClientById(clientId); if (!client) { res.status(404).json({ @@ -85,7 +85,7 @@ export const getClient = (req: Request, res: Response): void => { * POST /api/oauth/clients * Create a new OAuth client */ -export const createClient = (req: Request, res: Response): void => { +export const createClient = async (req: Request, res: Response): Promise => { try { // Validate request const errors = validationResult(req); @@ -105,7 +105,8 @@ export const createClient = (req: Request, res: Response): void => { const clientId = crypto.randomBytes(16).toString('hex'); // Generate client secret if required - const clientSecret = requireSecret !== false ? crypto.randomBytes(32).toString('hex') : undefined; + const clientSecret = + requireSecret !== false ? crypto.randomBytes(32).toString('hex') : undefined; // Create client const client: IOAuthClient = { @@ -118,7 +119,7 @@ export const createClient = (req: Request, res: Response): void => { owner: user?.username || 'admin', }; - const createdClient = createOAuthClient(client); + const createdClient = await createOAuthClient(client); // Return client with secret (only shown once) res.status(201).json({ @@ -139,7 +140,7 @@ export const createClient = (req: Request, res: Response): void => { }); } catch (error) { console.error('Create OAuth client error:', error); - + if (error instanceof Error && error.message.includes('already exists')) { res.status(409).json({ success: false, @@ -158,18 +159,19 @@ export const createClient = (req: Request, res: Response): void => { * PUT /api/oauth/clients/:clientId * Update an OAuth client */ -export const updateClient = (req: Request, res: Response): void => { +export const updateClient = async (req: Request, res: Response): Promise => { try { const { clientId } = req.params; const { name, redirectUris, grants, scopes } = req.body; const updates: Partial = {}; if (name) updates.name = name; - if (redirectUris) updates.redirectUris = Array.isArray(redirectUris) ? redirectUris : [redirectUris]; + if (redirectUris) + updates.redirectUris = Array.isArray(redirectUris) ? redirectUris : [redirectUris]; if (grants) updates.grants = grants; if (scopes) updates.scopes = scopes; - const updatedClient = updateOAuthClient(clientId, updates); + const updatedClient = await updateOAuthClient(clientId, updates); if (!updatedClient) { res.status(404).json({ @@ -205,10 +207,10 @@ export const updateClient = (req: Request, res: Response): void => { * DELETE /api/oauth/clients/:clientId * Delete an OAuth client */ -export const deleteClient = (req: Request, res: Response): void => { +export const deleteClient = async (req: Request, res: Response): Promise => { try { const { clientId } = req.params; - const deleted = deleteOAuthClient(clientId); + const deleted = await deleteOAuthClient(clientId); if (!deleted) { res.status(404).json({ @@ -235,10 +237,10 @@ export const deleteClient = (req: Request, res: Response): void => { * POST /api/oauth/clients/:clientId/regenerate-secret * Regenerate client secret */ -export const regenerateSecret = (req: Request, res: Response): void => { +export const regenerateSecret = async (req: Request, res: Response): Promise => { try { const { clientId } = req.params; - const client = findOAuthClientById(clientId); + const client = await findOAuthClientById(clientId); if (!client) { res.status(404).json({ @@ -250,7 +252,7 @@ export const regenerateSecret = (req: Request, res: Response): void => { // Generate new secret const newSecret = crypto.randomBytes(32).toString('hex'); - const updatedClient = updateOAuthClient(clientId, { clientSecret: newSecret }); + const updatedClient = await updateOAuthClient(clientId, { clientSecret: newSecret }); if (!updatedClient) { res.status(500).json({ diff --git a/src/controllers/oauthDynamicRegistrationController.ts b/src/controllers/oauthDynamicRegistrationController.ts index c6bf0ff..f382afb 100644 --- a/src/controllers/oauthDynamicRegistrationController.ts +++ b/src/controllers/oauthDynamicRegistrationController.ts @@ -48,7 +48,7 @@ const verifyRegistrationToken = (token: string): string | null => { * RFC 7591 Dynamic Client Registration * Public endpoint for registering new OAuth clients */ -export const registerClient = (req: Request, res: Response): void => { +export const registerClient = async (req: Request, res: Response): Promise => { try { const settings = loadSettings(); const oauthConfig = settings.systemConfig?.oauthServer; @@ -183,7 +183,7 @@ export const registerClient = (req: Request, res: Response): void => { }, }; - const createdClient = createOAuthClient(client); + const createdClient = await createOAuthClient(client); // Build response according to RFC 7591 const response: any = { @@ -238,7 +238,7 @@ export const registerClient = (req: Request, res: Response): void => { * RFC 7591 Client Configuration Endpoint * Read client configuration */ -export const getClientConfiguration = (req: Request, res: Response): void => { +export const getClientConfiguration = async (req: Request, res: Response): Promise => { try { const { clientId } = req.params; const authHeader = req.headers.authorization; @@ -262,7 +262,7 @@ export const getClientConfiguration = (req: Request, res: Response): void => { return; } - const client = findOAuthClientById(clientId); + const client = await findOAuthClientById(clientId); if (!client) { res.status(404).json({ error: 'invalid_client', @@ -311,7 +311,7 @@ export const getClientConfiguration = (req: Request, res: Response): void => { * RFC 7591 Client Update Endpoint * Update client configuration */ -export const updateClientConfiguration = (req: Request, res: Response): void => { +export const updateClientConfiguration = async (req: Request, res: Response): Promise => { try { const { clientId } = req.params; const authHeader = req.headers.authorization; @@ -335,7 +335,7 @@ export const updateClientConfiguration = (req: Request, res: Response): void => return; } - const client = findOAuthClientById(clientId); + const client = await findOAuthClientById(clientId); if (!client) { res.status(404).json({ error: 'invalid_client', @@ -443,7 +443,7 @@ export const updateClientConfiguration = (req: Request, res: Response): void => }; } - const updatedClient = updateOAuthClient(clientId, updates); + const updatedClient = await updateOAuthClient(clientId, updates); if (!updatedClient) { res.status(500).json({ @@ -495,7 +495,7 @@ export const updateClientConfiguration = (req: Request, res: Response): void => * RFC 7591 Client Delete Endpoint * Delete client registration */ -export const deleteClientRegistration = (req: Request, res: Response): void => { +export const deleteClientRegistration = async (req: Request, res: Response): Promise => { try { const { clientId } = req.params; const authHeader = req.headers.authorization; @@ -519,7 +519,7 @@ export const deleteClientRegistration = (req: Request, res: Response): void => { return; } - const deleted = deleteOAuthClient(clientId); + const deleted = await deleteOAuthClient(clientId); if (!deleted) { res.status(404).json({ diff --git a/src/controllers/oauthServerController.ts b/src/controllers/oauthServerController.ts index 4c08f9a..17b26ed 100644 --- a/src/controllers/oauthServerController.ts +++ b/src/controllers/oauthServerController.ts @@ -212,7 +212,7 @@ export const getAuthorize = async (req: Request, res: Response): Promise = } // Verify client - const client = findOAuthClientById(client_id as string); + const client = await findOAuthClientById(client_id as string); if (!client) { res.status(400).json({ error: 'invalid_client', error_description: 'Client not found' }); return; diff --git a/src/controllers/serverController.ts b/src/controllers/serverController.ts index f4d2da8..79d3835 100644 --- a/src/controllers/serverController.ts +++ b/src/controllers/serverController.ts @@ -9,7 +9,7 @@ import { syncToolEmbedding, toggleServerStatus, } from '../services/mcpService.js'; -import { loadSettings, saveSettings } from '../config/index.js'; +import { loadSettings } from '../config/index.js'; import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js'; import { createSafeJSON } from '../utils/serialization.js'; import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js'; @@ -439,8 +439,10 @@ export const toggleTool = async (req: Request, res: Response): Promise => return; } - const settings = loadSettings(); - if (!settings.mcpServers[serverName]) { + const serverDao = getServerDao(); + const server = await serverDao.findById(serverName); + + if (!server) { res.status(404).json({ success: false, message: 'Server not found', @@ -449,14 +451,15 @@ export const toggleTool = async (req: Request, res: Response): Promise => } // Initialize tools config if it doesn't exist - if (!settings.mcpServers[serverName].tools) { - settings.mcpServers[serverName].tools = {}; - } + const tools = server.tools || {}; - // Set the tool's enabled state - settings.mcpServers[serverName].tools![toolName] = { enabled }; + // Set the tool's enabled state (preserve existing description if any) + tools[toolName] = { ...tools[toolName], enabled }; - if (!saveSettings(settings)) { + // Update via DAO (supports both file and database modes) + const result = await serverDao.updateTools(serverName, tools); + + if (!result) { res.status(500).json({ success: false, message: 'Failed to save settings', @@ -503,8 +506,10 @@ export const updateToolDescription = async (req: Request, res: Response): Promis return; } - const settings = loadSettings(); - if (!settings.mcpServers[serverName]) { + const serverDao = getServerDao(); + const server = await serverDao.findById(serverName); + + if (!server) { res.status(404).json({ success: false, message: 'Server not found', @@ -513,18 +518,18 @@ export const updateToolDescription = async (req: Request, res: Response): Promis } // Initialize tools config if it doesn't exist - if (!settings.mcpServers[serverName].tools) { - settings.mcpServers[serverName].tools = {}; - } + const tools = server.tools || {}; // Set the tool's description - if (!settings.mcpServers[serverName].tools![toolName]) { - settings.mcpServers[serverName].tools![toolName] = { enabled: true }; + if (!tools[toolName]) { + tools[toolName] = { enabled: true }; } + tools[toolName].description = description; - settings.mcpServers[serverName].tools![toolName].description = description; + // Update via DAO (supports both file and database modes) + const result = await serverDao.updateTools(serverName, tools); - if (!saveSettings(settings)) { + if (!result) { res.status(500).json({ success: false, message: 'Failed to save settings', @@ -939,8 +944,10 @@ export const togglePrompt = async (req: Request, res: Response): Promise = return; } - const settings = loadSettings(); - if (!settings.mcpServers[serverName]) { + const serverDao = getServerDao(); + const server = await serverDao.findById(serverName); + + if (!server) { res.status(404).json({ success: false, message: 'Server not found', @@ -949,14 +956,15 @@ export const togglePrompt = async (req: Request, res: Response): Promise = } // Initialize prompts config if it doesn't exist - if (!settings.mcpServers[serverName].prompts) { - settings.mcpServers[serverName].prompts = {}; - } + const prompts = server.prompts || {}; - // Set the prompt's enabled state - settings.mcpServers[serverName].prompts![promptName] = { enabled }; + // Set the prompt's enabled state (preserve existing description if any) + prompts[promptName] = { ...prompts[promptName], enabled }; - if (!saveSettings(settings)) { + // Update via DAO (supports both file and database modes) + const result = await serverDao.updatePrompts(serverName, prompts); + + if (!result) { res.status(500).json({ success: false, message: 'Failed to save settings', @@ -1003,8 +1011,10 @@ export const updatePromptDescription = async (req: Request, res: Response): Prom return; } - const settings = loadSettings(); - if (!settings.mcpServers[serverName]) { + const serverDao = getServerDao(); + const server = await serverDao.findById(serverName); + + if (!server) { res.status(404).json({ success: false, message: 'Server not found', @@ -1013,18 +1023,18 @@ export const updatePromptDescription = async (req: Request, res: Response): Prom } // Initialize prompts config if it doesn't exist - if (!settings.mcpServers[serverName].prompts) { - settings.mcpServers[serverName].prompts = {}; - } + const prompts = server.prompts || {}; // Set the prompt's description - if (!settings.mcpServers[serverName].prompts![promptName]) { - settings.mcpServers[serverName].prompts![promptName] = { enabled: true }; + if (!prompts[promptName]) { + prompts[promptName] = { enabled: true }; } + prompts[promptName].description = description; - settings.mcpServers[serverName].prompts![promptName].description = description; + // Update via DAO (supports both file and database modes) + const result = await serverDao.updatePrompts(serverName, prompts); - if (!saveSettings(settings)) { + if (!result) { res.status(500).json({ success: false, message: 'Failed to save settings', diff --git a/src/dao/DaoFactory.ts b/src/dao/DaoFactory.ts index a6999ec..db9b375 100644 --- a/src/dao/DaoFactory.ts +++ b/src/dao/DaoFactory.ts @@ -3,6 +3,8 @@ import { ServerDao, ServerDaoImpl } from './ServerDao.js'; import { GroupDao, GroupDaoImpl } from './GroupDao.js'; import { SystemConfigDao, SystemConfigDaoImpl } from './SystemConfigDao.js'; import { UserConfigDao, UserConfigDaoImpl } from './UserConfigDao.js'; +import { OAuthClientDao, OAuthClientDaoImpl } from './OAuthClientDao.js'; +import { OAuthTokenDao, OAuthTokenDaoImpl } from './OAuthTokenDao.js'; /** * DAO Factory interface for creating DAO instances @@ -13,6 +15,8 @@ export interface DaoFactory { getGroupDao(): GroupDao; getSystemConfigDao(): SystemConfigDao; getUserConfigDao(): UserConfigDao; + getOAuthClientDao(): OAuthClientDao; + getOAuthTokenDao(): OAuthTokenDao; } /** @@ -26,6 +30,8 @@ export class JsonFileDaoFactory implements DaoFactory { private groupDao: GroupDao | null = null; private systemConfigDao: SystemConfigDao | null = null; private userConfigDao: UserConfigDao | null = null; + private oauthClientDao: OAuthClientDao | null = null; + private oauthTokenDao: OAuthTokenDao | null = null; /** * Get singleton instance @@ -76,6 +82,20 @@ export class JsonFileDaoFactory implements DaoFactory { return this.userConfigDao; } + getOAuthClientDao(): OAuthClientDao { + if (!this.oauthClientDao) { + this.oauthClientDao = new OAuthClientDaoImpl(); + } + return this.oauthClientDao; + } + + getOAuthTokenDao(): OAuthTokenDao { + if (!this.oauthTokenDao) { + this.oauthTokenDao = new OAuthTokenDaoImpl(); + } + return this.oauthTokenDao; + } + /** * Reset all cached DAO instances (useful for testing) */ @@ -85,6 +105,8 @@ export class JsonFileDaoFactory implements DaoFactory { this.groupDao = null; this.systemConfigDao = null; this.userConfigDao = null; + this.oauthClientDao = null; + this.oauthTokenDao = null; } } @@ -149,3 +171,11 @@ export function getSystemConfigDao(): SystemConfigDao { export function getUserConfigDao(): UserConfigDao { return getDaoFactory().getUserConfigDao(); } + +export function getOAuthClientDao(): OAuthClientDao { + return getDaoFactory().getOAuthClientDao(); +} + +export function getOAuthTokenDao(): OAuthTokenDao { + return getDaoFactory().getOAuthTokenDao(); +} diff --git a/src/dao/DatabaseDaoFactory.ts b/src/dao/DatabaseDaoFactory.ts index 728138d..a2c5510 100644 --- a/src/dao/DatabaseDaoFactory.ts +++ b/src/dao/DatabaseDaoFactory.ts @@ -1,9 +1,20 @@ -import { DaoFactory, UserDao, ServerDao, GroupDao, SystemConfigDao, UserConfigDao } from './index.js'; +import { + DaoFactory, + UserDao, + ServerDao, + GroupDao, + SystemConfigDao, + UserConfigDao, + OAuthClientDao, + OAuthTokenDao, +} from './index.js'; import { UserDaoDbImpl } from './UserDaoDbImpl.js'; import { ServerDaoDbImpl } from './ServerDaoDbImpl.js'; import { GroupDaoDbImpl } from './GroupDaoDbImpl.js'; import { SystemConfigDaoDbImpl } from './SystemConfigDaoDbImpl.js'; import { UserConfigDaoDbImpl } from './UserConfigDaoDbImpl.js'; +import { OAuthClientDaoDbImpl } from './OAuthClientDaoDbImpl.js'; +import { OAuthTokenDaoDbImpl } from './OAuthTokenDaoDbImpl.js'; /** * Database-backed DAO factory implementation @@ -16,6 +27,8 @@ export class DatabaseDaoFactory implements DaoFactory { private groupDao: GroupDao | null = null; private systemConfigDao: SystemConfigDao | null = null; private userConfigDao: UserConfigDao | null = null; + private oauthClientDao: OAuthClientDao | null = null; + private oauthTokenDao: OAuthTokenDao | null = null; /** * Get singleton instance @@ -66,6 +79,20 @@ export class DatabaseDaoFactory implements DaoFactory { return this.userConfigDao!; } + getOAuthClientDao(): OAuthClientDao { + if (!this.oauthClientDao) { + this.oauthClientDao = new OAuthClientDaoDbImpl(); + } + return this.oauthClientDao!; + } + + getOAuthTokenDao(): OAuthTokenDao { + if (!this.oauthTokenDao) { + this.oauthTokenDao = new OAuthTokenDaoDbImpl(); + } + return this.oauthTokenDao!; + } + /** * Reset all cached DAO instances (useful for testing) */ @@ -75,5 +102,7 @@ export class DatabaseDaoFactory implements DaoFactory { this.groupDao = null; this.systemConfigDao = null; this.userConfigDao = null; + this.oauthClientDao = null; + this.oauthTokenDao = null; } } diff --git a/src/dao/OAuthClientDao.ts b/src/dao/OAuthClientDao.ts new file mode 100644 index 0000000..0dcedd1 --- /dev/null +++ b/src/dao/OAuthClientDao.ts @@ -0,0 +1,146 @@ +import { IOAuthClient } from '../types/index.js'; +import { BaseDao } from './base/BaseDao.js'; +import { JsonFileBaseDao } from './base/JsonFileBaseDao.js'; + +/** + * OAuth Client DAO interface with OAuth client-specific operations + */ +export interface OAuthClientDao extends BaseDao { + /** + * Find OAuth client by client ID + */ + findByClientId(clientId: string): Promise; + + /** + * Find OAuth clients by owner + */ + findByOwner(owner: string): Promise; + + /** + * Validate client credentials + */ + validateCredentials(clientId: string, clientSecret?: string): Promise; +} + +/** + * JSON file-based OAuth Client DAO implementation + */ +export class OAuthClientDaoImpl extends JsonFileBaseDao implements OAuthClientDao { + protected async getAll(): Promise { + const settings = await this.loadSettings(); + return settings.oauthClients || []; + } + + protected async saveAll(clients: IOAuthClient[]): Promise { + const settings = await this.loadSettings(); + settings.oauthClients = clients; + await this.saveSettings(settings); + } + + protected getEntityId(client: IOAuthClient): string { + return client.clientId; + } + + protected createEntity(_data: Omit): IOAuthClient { + throw new Error('clientId must be provided'); + } + + protected updateEntity(existing: IOAuthClient, updates: Partial): IOAuthClient { + return { + ...existing, + ...updates, + clientId: existing.clientId, // clientId should not be updated + }; + } + + async findAll(): Promise { + return this.getAll(); + } + + async findById(clientId: string): Promise { + return this.findByClientId(clientId); + } + + async findByClientId(clientId: string): Promise { + const clients = await this.getAll(); + return clients.find((client) => client.clientId === clientId) || null; + } + + async findByOwner(owner: string): Promise { + const clients = await this.getAll(); + return clients.filter((client) => client.owner === owner); + } + + async create(data: IOAuthClient): Promise { + const clients = await this.getAll(); + + // Check if client already exists + if (clients.find((client) => client.clientId === data.clientId)) { + throw new Error(`OAuth client ${data.clientId} already exists`); + } + + const newClient: IOAuthClient = { + ...data, + owner: data.owner || 'admin', + }; + + clients.push(newClient); + await this.saveAll(clients); + + return newClient; + } + + async update(clientId: string, updates: Partial): Promise { + const clients = await this.getAll(); + const index = clients.findIndex((client) => client.clientId === clientId); + + if (index === -1) { + return null; + } + + // Don't allow clientId changes + const { clientId: _, ...allowedUpdates } = updates; + const updatedClient = this.updateEntity(clients[index], allowedUpdates); + clients[index] = updatedClient; + + await this.saveAll(clients); + return updatedClient; + } + + async delete(clientId: string): Promise { + const clients = await this.getAll(); + const index = clients.findIndex((client) => client.clientId === clientId); + if (index === -1) { + return false; + } + + clients.splice(index, 1); + await this.saveAll(clients); + return true; + } + + async exists(clientId: string): Promise { + const client = await this.findByClientId(clientId); + return client !== null; + } + + async count(): Promise { + const clients = await this.getAll(); + return clients.length; + } + + async validateCredentials(clientId: string, clientSecret?: string): Promise { + const client = await this.findByClientId(clientId); + if (!client) { + return false; + } + + // If client has no secret (public client), accept if no secret provided + if (!client.clientSecret) { + return !clientSecret; + } + + // If client has a secret, it must match + return client.clientSecret === clientSecret; + } +} diff --git a/src/dao/OAuthClientDaoDbImpl.ts b/src/dao/OAuthClientDaoDbImpl.ts new file mode 100644 index 0000000..5e805f2 --- /dev/null +++ b/src/dao/OAuthClientDaoDbImpl.ts @@ -0,0 +1,109 @@ +import { OAuthClientDao } from './OAuthClientDao.js'; +import { OAuthClientRepository } from '../db/repositories/OAuthClientRepository.js'; +import { IOAuthClient } from '../types/index.js'; + +/** + * Database-backed implementation of OAuthClientDao + */ +export class OAuthClientDaoDbImpl implements OAuthClientDao { + private repository: OAuthClientRepository; + + constructor() { + this.repository = new OAuthClientRepository(); + } + + async findAll(): Promise { + const clients = await this.repository.findAll(); + return clients.map((c) => this.mapToOAuthClient(c)); + } + + async findById(clientId: string): Promise { + const client = await this.repository.findByClientId(clientId); + return client ? this.mapToOAuthClient(client) : null; + } + + async findByClientId(clientId: string): Promise { + return this.findById(clientId); + } + + async findByOwner(owner: string): Promise { + const clients = await this.repository.findByOwner(owner); + return clients.map((c) => this.mapToOAuthClient(c)); + } + + async create(entity: IOAuthClient): Promise { + const client = await this.repository.create({ + clientId: entity.clientId, + clientSecret: entity.clientSecret, + name: entity.name, + redirectUris: entity.redirectUris, + grants: entity.grants, + scopes: entity.scopes, + owner: entity.owner || 'admin', + metadata: entity.metadata, + }); + return this.mapToOAuthClient(client); + } + + async update(clientId: string, entity: Partial): Promise { + const client = await this.repository.update(clientId, { + clientSecret: entity.clientSecret, + name: entity.name, + redirectUris: entity.redirectUris, + grants: entity.grants, + scopes: entity.scopes, + owner: entity.owner, + metadata: entity.metadata, + }); + return client ? this.mapToOAuthClient(client) : null; + } + + async delete(clientId: string): Promise { + return await this.repository.delete(clientId); + } + + async exists(clientId: string): Promise { + return await this.repository.exists(clientId); + } + + async count(): Promise { + return await this.repository.count(); + } + + async validateCredentials(clientId: string, clientSecret?: string): Promise { + const client = await this.findByClientId(clientId); + if (!client) { + return false; + } + + // If client has no secret (public client), accept if no secret provided + if (!client.clientSecret) { + return !clientSecret; + } + + // If client has a secret, it must match + return client.clientSecret === clientSecret; + } + + private mapToOAuthClient(client: { + clientId: string; + clientSecret?: string; + name: string; + redirectUris: string[]; + grants: string[]; + scopes?: string[]; + owner?: string; + metadata?: Record; + }): IOAuthClient { + return { + clientId: client.clientId, + clientSecret: client.clientSecret, + name: client.name, + redirectUris: client.redirectUris, + grants: client.grants, + scopes: client.scopes, + owner: client.owner, + metadata: client.metadata as IOAuthClient['metadata'], + }; + } +} diff --git a/src/dao/OAuthTokenDao.ts b/src/dao/OAuthTokenDao.ts new file mode 100644 index 0000000..a233c80 --- /dev/null +++ b/src/dao/OAuthTokenDao.ts @@ -0,0 +1,259 @@ +import { IOAuthToken } from '../types/index.js'; +import { BaseDao } from './base/BaseDao.js'; +import { JsonFileBaseDao } from './base/JsonFileBaseDao.js'; + +/** + * OAuth Token DAO interface with OAuth token-specific operations + */ +export interface OAuthTokenDao extends BaseDao { + /** + * Find token by access token + */ + findByAccessToken(accessToken: string): Promise; + + /** + * Find token by refresh token + */ + findByRefreshToken(refreshToken: string): Promise; + + /** + * Find tokens by client ID + */ + findByClientId(clientId: string): Promise; + + /** + * Find tokens by username + */ + findByUsername(username: string): Promise; + + /** + * Revoke token (delete by access token or refresh token) + */ + revokeToken(token: string): Promise; + + /** + * Revoke all tokens for a user + */ + revokeUserTokens(username: string): Promise; + + /** + * Revoke all tokens for a client + */ + revokeClientTokens(clientId: string): Promise; + + /** + * Clean up expired tokens + */ + cleanupExpired(): Promise; + + /** + * Check if access token is valid (exists and not expired) + */ + isAccessTokenValid(accessToken: string): Promise; + + /** + * Check if refresh token is valid (exists and not expired) + */ + isRefreshTokenValid(refreshToken: string): Promise; +} + +/** + * JSON file-based OAuth Token DAO implementation + */ +export class OAuthTokenDaoImpl extends JsonFileBaseDao implements OAuthTokenDao { + protected async getAll(): Promise { + const settings = await this.loadSettings(); + // Convert stored dates back to Date objects + return (settings.oauthTokens || []).map((token) => ({ + ...token, + accessTokenExpiresAt: new Date(token.accessTokenExpiresAt), + refreshTokenExpiresAt: token.refreshTokenExpiresAt + ? new Date(token.refreshTokenExpiresAt) + : undefined, + })); + } + + protected async saveAll(tokens: IOAuthToken[]): Promise { + const settings = await this.loadSettings(); + settings.oauthTokens = tokens; + await this.saveSettings(settings); + } + + protected getEntityId(token: IOAuthToken): string { + return token.accessToken; + } + + protected createEntity(_data: Omit): IOAuthToken { + throw new Error('accessToken must be provided'); + } + + protected updateEntity(existing: IOAuthToken, updates: Partial): IOAuthToken { + return { + ...existing, + ...updates, + accessToken: existing.accessToken, // accessToken should not be updated + }; + } + + async findAll(): Promise { + return this.getAll(); + } + + async findById(accessToken: string): Promise { + return this.findByAccessToken(accessToken); + } + + async findByAccessToken(accessToken: string): Promise { + const tokens = await this.getAll(); + return tokens.find((token) => token.accessToken === accessToken) || null; + } + + async findByRefreshToken(refreshToken: string): Promise { + const tokens = await this.getAll(); + return tokens.find((token) => token.refreshToken === refreshToken) || null; + } + + async findByClientId(clientId: string): Promise { + const tokens = await this.getAll(); + return tokens.filter((token) => token.clientId === clientId); + } + + async findByUsername(username: string): Promise { + const tokens = await this.getAll(); + return tokens.filter((token) => token.username === username); + } + + async create(data: IOAuthToken): Promise { + const tokens = await this.getAll(); + + // Remove any existing tokens with the same access token or refresh token + const filteredTokens = tokens.filter( + (t) => t.accessToken !== data.accessToken && t.refreshToken !== data.refreshToken, + ); + + const newToken: IOAuthToken = { + ...data, + }; + + filteredTokens.push(newToken); + await this.saveAll(filteredTokens); + + return newToken; + } + + async update(accessToken: string, updates: Partial): Promise { + const tokens = await this.getAll(); + const index = tokens.findIndex((token) => token.accessToken === accessToken); + + if (index === -1) { + return null; + } + + // Don't allow accessToken changes + const { accessToken: _, ...allowedUpdates } = updates; + const updatedToken = this.updateEntity(tokens[index], allowedUpdates); + tokens[index] = updatedToken; + + await this.saveAll(tokens); + return updatedToken; + } + + async delete(accessToken: string): Promise { + const tokens = await this.getAll(); + const index = tokens.findIndex((token) => token.accessToken === accessToken); + if (index === -1) { + return false; + } + + tokens.splice(index, 1); + await this.saveAll(tokens); + return true; + } + + async exists(accessToken: string): Promise { + const token = await this.findByAccessToken(accessToken); + return token !== null; + } + + async count(): Promise { + const tokens = await this.getAll(); + return tokens.length; + } + + async revokeToken(token: string): Promise { + const tokens = await this.getAll(); + const tokenData = tokens.find((t) => t.accessToken === token || t.refreshToken === token); + + if (!tokenData) { + return false; + } + + const filteredTokens = tokens.filter( + (t) => t.accessToken !== tokenData.accessToken && t.refreshToken !== tokenData.refreshToken, + ); + + await this.saveAll(filteredTokens); + return true; + } + + async revokeUserTokens(username: string): Promise { + const tokens = await this.getAll(); + const userTokens = tokens.filter((token) => token.username === username); + const remainingTokens = tokens.filter((token) => token.username !== username); + + await this.saveAll(remainingTokens); + return userTokens.length; + } + + async revokeClientTokens(clientId: string): Promise { + const tokens = await this.getAll(); + const clientTokens = tokens.filter((token) => token.clientId === clientId); + const remainingTokens = tokens.filter((token) => token.clientId !== clientId); + + await this.saveAll(remainingTokens); + return clientTokens.length; + } + + async cleanupExpired(): Promise { + const tokens = await this.getAll(); + const now = new Date(); + + const validTokens = tokens.filter((token) => { + // Keep if access token is still valid + if (token.accessTokenExpiresAt > now) { + return true; + } + // Or if refresh token exists and is still valid + if (token.refreshToken && token.refreshTokenExpiresAt && token.refreshTokenExpiresAt > now) { + return true; + } + return false; + }); + + const expiredCount = tokens.length - validTokens.length; + if (expiredCount > 0) { + await this.saveAll(validTokens); + } + + return expiredCount; + } + + async isAccessTokenValid(accessToken: string): Promise { + const token = await this.findByAccessToken(accessToken); + if (!token) { + return false; + } + return token.accessTokenExpiresAt > new Date(); + } + + async isRefreshTokenValid(refreshToken: string): Promise { + const token = await this.findByRefreshToken(refreshToken); + if (!token) { + return false; + } + if (!token.refreshTokenExpiresAt) { + return true; // No expiration means always valid + } + return token.refreshTokenExpiresAt > new Date(); + } +} diff --git a/src/dao/OAuthTokenDaoDbImpl.ts b/src/dao/OAuthTokenDaoDbImpl.ts new file mode 100644 index 0000000..93d431a --- /dev/null +++ b/src/dao/OAuthTokenDaoDbImpl.ts @@ -0,0 +1,122 @@ +import { OAuthTokenDao } from './OAuthTokenDao.js'; +import { OAuthTokenRepository } from '../db/repositories/OAuthTokenRepository.js'; +import { IOAuthToken } from '../types/index.js'; + +/** + * Database-backed implementation of OAuthTokenDao + */ +export class OAuthTokenDaoDbImpl implements OAuthTokenDao { + private repository: OAuthTokenRepository; + + constructor() { + this.repository = new OAuthTokenRepository(); + } + + async findAll(): Promise { + const tokens = await this.repository.findAll(); + return tokens.map((t) => this.mapToOAuthToken(t)); + } + + async findById(accessToken: string): Promise { + const token = await this.repository.findByAccessToken(accessToken); + return token ? this.mapToOAuthToken(token) : null; + } + + async findByAccessToken(accessToken: string): Promise { + return this.findById(accessToken); + } + + async findByRefreshToken(refreshToken: string): Promise { + const token = await this.repository.findByRefreshToken(refreshToken); + return token ? this.mapToOAuthToken(token) : null; + } + + async findByClientId(clientId: string): Promise { + const tokens = await this.repository.findByClientId(clientId); + return tokens.map((t) => this.mapToOAuthToken(t)); + } + + async findByUsername(username: string): Promise { + const tokens = await this.repository.findByUsername(username); + return tokens.map((t) => this.mapToOAuthToken(t)); + } + + async create(entity: IOAuthToken): Promise { + const token = await this.repository.create({ + accessToken: entity.accessToken, + accessTokenExpiresAt: entity.accessTokenExpiresAt, + refreshToken: entity.refreshToken, + refreshTokenExpiresAt: entity.refreshTokenExpiresAt, + scope: entity.scope, + clientId: entity.clientId, + username: entity.username, + }); + return this.mapToOAuthToken(token); + } + + async update(accessToken: string, entity: Partial): Promise { + const token = await this.repository.update(accessToken, { + accessTokenExpiresAt: entity.accessTokenExpiresAt, + refreshToken: entity.refreshToken, + refreshTokenExpiresAt: entity.refreshTokenExpiresAt, + scope: entity.scope, + }); + return token ? this.mapToOAuthToken(token) : null; + } + + async delete(accessToken: string): Promise { + return await this.repository.delete(accessToken); + } + + async exists(accessToken: string): Promise { + return await this.repository.exists(accessToken); + } + + async count(): Promise { + return await this.repository.count(); + } + + async revokeToken(token: string): Promise { + return await this.repository.revokeToken(token); + } + + async revokeUserTokens(username: string): Promise { + return await this.repository.revokeUserTokens(username); + } + + async revokeClientTokens(clientId: string): Promise { + return await this.repository.revokeClientTokens(clientId); + } + + async cleanupExpired(): Promise { + return await this.repository.cleanupExpired(); + } + + async isAccessTokenValid(accessToken: string): Promise { + return await this.repository.isAccessTokenValid(accessToken); + } + + async isRefreshTokenValid(refreshToken: string): Promise { + return await this.repository.isRefreshTokenValid(refreshToken); + } + + private mapToOAuthToken(token: { + accessToken: string; + accessTokenExpiresAt: Date; + refreshToken?: string; + refreshTokenExpiresAt?: Date; + scope?: string; + clientId: string; + username: string; + }): IOAuthToken { + return { + accessToken: token.accessToken, + accessTokenExpiresAt: token.accessTokenExpiresAt, + refreshToken: token.refreshToken, + refreshTokenExpiresAt: token.refreshTokenExpiresAt, + scope: token.scope, + clientId: token.clientId, + username: token.username, + }; + } +} diff --git a/src/dao/index.ts b/src/dao/index.ts index a3e9536..2d4493e 100644 --- a/src/dao/index.ts +++ b/src/dao/index.ts @@ -6,6 +6,8 @@ export * from './ServerDao.js'; export * from './GroupDao.js'; export * from './SystemConfigDao.js'; export * from './UserConfigDao.js'; +export * from './OAuthClientDao.js'; +export * from './OAuthTokenDao.js'; // Export database implementations export * from './UserDaoDbImpl.js'; @@ -13,6 +15,8 @@ export * from './ServerDaoDbImpl.js'; export * from './GroupDaoDbImpl.js'; export * from './SystemConfigDaoDbImpl.js'; export * from './UserConfigDaoDbImpl.js'; +export * from './OAuthClientDaoDbImpl.js'; +export * from './OAuthTokenDaoDbImpl.js'; // Export the DAO factory and convenience functions export * from './DaoFactory.js'; diff --git a/src/db/entities/OAuthClient.ts b/src/db/entities/OAuthClient.ts new file mode 100644 index 0000000..2cbc9c6 --- /dev/null +++ b/src/db/entities/OAuthClient.ts @@ -0,0 +1,60 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +/** + * OAuth Client entity for database storage + * Represents OAuth clients registered with MCPHub's authorization server + */ +@Entity({ name: 'oauth_clients' }) +export class OAuthClient { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'client_id', type: 'varchar', length: 255, unique: true }) + clientId: string; + + @Column({ name: 'client_secret', type: 'varchar', length: 255, nullable: true }) + clientSecret?: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'redirect_uris', type: 'simple-json' }) + redirectUris: string[]; + + @Column({ type: 'simple-json' }) + grants: string[]; + + @Column({ type: 'simple-json', nullable: true }) + scopes?: string[]; + + @Column({ type: 'varchar', length: 255, nullable: true }) + owner?: string; + + @Column({ type: 'simple-json', nullable: true }) + metadata?: { + application_type?: 'web' | 'native'; + response_types?: string[]; + token_endpoint_auth_method?: string; + contacts?: string[]; + logo_uri?: string; + client_uri?: string; + policy_uri?: string; + tos_uri?: string; + jwks_uri?: string; + jwks?: object; + }; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' }) + updatedAt: Date; +} + +export default OAuthClient; diff --git a/src/db/entities/OAuthToken.ts b/src/db/entities/OAuthToken.ts new file mode 100644 index 0000000..2cad85d --- /dev/null +++ b/src/db/entities/OAuthToken.ts @@ -0,0 +1,51 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +/** + * OAuth Token entity for database storage + * Represents OAuth tokens issued by MCPHub's authorization server + */ +@Entity({ name: 'oauth_tokens' }) +export class OAuthToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'access_token', type: 'varchar', length: 512, unique: true }) + accessToken: string; + + @Column({ name: 'access_token_expires_at', type: 'timestamp' }) + accessTokenExpiresAt: Date; + + @Index() + @Column({ name: 'refresh_token', type: 'varchar', length: 512, nullable: true, unique: true }) + refreshToken?: string; + + @Column({ name: 'refresh_token_expires_at', type: 'timestamp', nullable: true }) + refreshTokenExpiresAt?: Date; + + @Column({ type: 'varchar', length: 512, nullable: true }) + scope?: string; + + @Index() + @Column({ name: 'client_id', type: 'varchar', length: 255 }) + clientId: string; + + @Index() + @Column({ type: 'varchar', length: 255 }) + username: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' }) + updatedAt: Date; +} + +export default OAuthToken; diff --git a/src/db/entities/index.ts b/src/db/entities/index.ts index 0ce3a09..93f9ab7 100644 --- a/src/db/entities/index.ts +++ b/src/db/entities/index.ts @@ -4,9 +4,20 @@ import Server from './Server.js'; import Group from './Group.js'; import SystemConfig from './SystemConfig.js'; import UserConfig from './UserConfig.js'; +import OAuthClient from './OAuthClient.js'; +import OAuthToken from './OAuthToken.js'; // Export all entities -export default [VectorEmbedding, User, Server, Group, SystemConfig, UserConfig]; +export default [ + VectorEmbedding, + User, + Server, + Group, + SystemConfig, + UserConfig, + OAuthClient, + OAuthToken, +]; // Export individual entities for direct use -export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig }; +export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig, OAuthClient, OAuthToken }; diff --git a/src/db/repositories/GroupRepository.ts b/src/db/repositories/GroupRepository.ts index 39a96b2..c5515e0 100644 --- a/src/db/repositories/GroupRepository.ts +++ b/src/db/repositories/GroupRepository.ts @@ -16,7 +16,7 @@ export class GroupRepository { * Find all groups */ async findAll(): Promise { - return await this.repository.find(); + return await this.repository.find({ order: { createdAt: 'ASC' } }); } /** @@ -88,7 +88,7 @@ export class GroupRepository { * Find groups by owner */ async findByOwner(owner: string): Promise { - return await this.repository.find({ where: { owner } }); + return await this.repository.find({ where: { owner }, order: { createdAt: 'ASC' } }); } } diff --git a/src/db/repositories/OAuthClientRepository.ts b/src/db/repositories/OAuthClientRepository.ts new file mode 100644 index 0000000..f18b67b --- /dev/null +++ b/src/db/repositories/OAuthClientRepository.ts @@ -0,0 +1,80 @@ +import { Repository } from 'typeorm'; +import { OAuthClient } from '../entities/OAuthClient.js'; +import { getAppDataSource } from '../connection.js'; + +/** + * Repository for OAuthClient entity + */ +export class OAuthClientRepository { + private repository: Repository; + + constructor() { + this.repository = getAppDataSource().getRepository(OAuthClient); + } + + /** + * Find all OAuth clients + */ + async findAll(): Promise { + return await this.repository.find(); + } + + /** + * Find OAuth client by client ID + */ + async findByClientId(clientId: string): Promise { + return await this.repository.findOne({ where: { clientId } }); + } + + /** + * Find OAuth clients by owner + */ + async findByOwner(owner: string): Promise { + return await this.repository.find({ where: { owner } }); + } + + /** + * Create a new OAuth client + */ + async create(client: Omit): Promise { + const newClient = this.repository.create(client); + return await this.repository.save(newClient); + } + + /** + * Update an existing OAuth client + */ + async update(clientId: string, clientData: Partial): Promise { + const client = await this.findByClientId(clientId); + if (!client) { + return null; + } + const updated = this.repository.merge(client, clientData); + return await this.repository.save(updated); + } + + /** + * Delete an OAuth client + */ + async delete(clientId: string): Promise { + const result = await this.repository.delete({ clientId }); + return (result.affected ?? 0) > 0; + } + + /** + * Check if OAuth client exists + */ + async exists(clientId: string): Promise { + const count = await this.repository.count({ where: { clientId } }); + return count > 0; + } + + /** + * Count total OAuth clients + */ + async count(): Promise { + return await this.repository.count(); + } +} + +export default OAuthClientRepository; diff --git a/src/db/repositories/OAuthTokenRepository.ts b/src/db/repositories/OAuthTokenRepository.ts new file mode 100644 index 0000000..166158d --- /dev/null +++ b/src/db/repositories/OAuthTokenRepository.ts @@ -0,0 +1,183 @@ +import { Repository, MoreThan } from 'typeorm'; +import { OAuthToken } from '../entities/OAuthToken.js'; +import { getAppDataSource } from '../connection.js'; + +/** + * Repository for OAuthToken entity + */ +export class OAuthTokenRepository { + private repository: Repository; + + constructor() { + this.repository = getAppDataSource().getRepository(OAuthToken); + } + + /** + * Find all OAuth tokens + */ + async findAll(): Promise { + return await this.repository.find(); + } + + /** + * Find OAuth token by access token + */ + async findByAccessToken(accessToken: string): Promise { + return await this.repository.findOne({ where: { accessToken } }); + } + + /** + * Find OAuth token by refresh token + */ + async findByRefreshToken(refreshToken: string): Promise { + return await this.repository.findOne({ where: { refreshToken } }); + } + + /** + * Find OAuth tokens by client ID + */ + async findByClientId(clientId: string): Promise { + return await this.repository.find({ where: { clientId } }); + } + + /** + * Find OAuth tokens by username + */ + async findByUsername(username: string): Promise { + return await this.repository.find({ where: { username } }); + } + + /** + * Create a new OAuth token + */ + async create(token: Omit): Promise { + // Remove any existing tokens with the same access token or refresh token + if (token.accessToken) { + await this.repository.delete({ accessToken: token.accessToken }); + } + if (token.refreshToken) { + await this.repository.delete({ refreshToken: token.refreshToken }); + } + + const newToken = this.repository.create(token); + return await this.repository.save(newToken); + } + + /** + * Update an existing OAuth token + */ + async update(accessToken: string, tokenData: Partial): Promise { + const token = await this.findByAccessToken(accessToken); + if (!token) { + return null; + } + const updated = this.repository.merge(token, tokenData); + return await this.repository.save(updated); + } + + /** + * Delete an OAuth token by access token + */ + async delete(accessToken: string): Promise { + const result = await this.repository.delete({ accessToken }); + return (result.affected ?? 0) > 0; + } + + /** + * Check if OAuth token exists by access token + */ + async exists(accessToken: string): Promise { + const count = await this.repository.count({ where: { accessToken } }); + return count > 0; + } + + /** + * Count total OAuth tokens + */ + async count(): Promise { + return await this.repository.count(); + } + + /** + * Revoke token by access token or refresh token + */ + async revokeToken(token: string): Promise { + // Try to find by access token first + let tokenEntity = await this.findByAccessToken(token); + if (!tokenEntity) { + // Try to find by refresh token + tokenEntity = await this.findByRefreshToken(token); + } + + if (!tokenEntity) { + return false; + } + + const result = await this.repository.delete({ id: tokenEntity.id }); + return (result.affected ?? 0) > 0; + } + + /** + * Revoke all tokens for a user + */ + async revokeUserTokens(username: string): Promise { + const result = await this.repository.delete({ username }); + return result.affected ?? 0; + } + + /** + * Revoke all tokens for a client + */ + async revokeClientTokens(clientId: string): Promise { + const result = await this.repository.delete({ clientId }); + return result.affected ?? 0; + } + + /** + * Clean up expired tokens + */ + async cleanupExpired(): Promise { + const now = new Date(); + + // Delete tokens where both access token and refresh token are expired + // (or refresh token doesn't exist) + const result = await this.repository + .createQueryBuilder() + .delete() + .from(OAuthToken) + .where('access_token_expires_at < :now', { now }) + .andWhere('(refresh_token_expires_at IS NULL OR refresh_token_expires_at < :now)', { now }) + .execute(); + + return result.affected ?? 0; + } + + /** + * Check if access token is valid (exists and not expired) + */ + async isAccessTokenValid(accessToken: string): Promise { + const count = await this.repository.count({ + where: { + accessToken, + accessTokenExpiresAt: MoreThan(new Date()), + }, + }); + return count > 0; + } + + /** + * Check if refresh token is valid (exists and not expired) + */ + async isRefreshTokenValid(refreshToken: string): Promise { + const token = await this.findByRefreshToken(refreshToken); + if (!token) { + return false; + } + if (!token.refreshTokenExpiresAt) { + return true; // No expiration means always valid + } + return token.refreshTokenExpiresAt > new Date(); + } +} + +export default OAuthTokenRepository; diff --git a/src/db/repositories/ServerRepository.ts b/src/db/repositories/ServerRepository.ts index be8c91f..56efdf4 100644 --- a/src/db/repositories/ServerRepository.ts +++ b/src/db/repositories/ServerRepository.ts @@ -16,7 +16,7 @@ export class ServerRepository { * Find all servers */ async findAll(): Promise { - return await this.repository.find(); + return await this.repository.find({ order: { createdAt: 'ASC' } }); } /** @@ -73,14 +73,14 @@ export class ServerRepository { * Find servers by owner */ async findByOwner(owner: string): Promise { - return await this.repository.find({ where: { owner } }); + return await this.repository.find({ where: { owner }, order: { createdAt: 'ASC' } }); } /** * Find enabled servers */ async findEnabled(): Promise { - return await this.repository.find({ where: { enabled: true } }); + return await this.repository.find({ where: { enabled: true }, order: { createdAt: 'ASC' } }); } /** diff --git a/src/db/repositories/UserRepository.ts b/src/db/repositories/UserRepository.ts index 1f8041e..9b115ef 100644 --- a/src/db/repositories/UserRepository.ts +++ b/src/db/repositories/UserRepository.ts @@ -16,7 +16,7 @@ export class UserRepository { * Find all users */ async findAll(): Promise { - return await this.repository.find(); + return await this.repository.find({ order: { createdAt: 'ASC' } }); } /** @@ -73,7 +73,7 @@ export class UserRepository { * Find all admin users */ async findAdmins(): Promise { - return await this.repository.find({ where: { isAdmin: true } }); + return await this.repository.find({ where: { isAdmin: true }, order: { createdAt: 'ASC' } }); } } diff --git a/src/db/repositories/index.ts b/src/db/repositories/index.ts index b79d5c0..5a59b04 100644 --- a/src/db/repositories/index.ts +++ b/src/db/repositories/index.ts @@ -4,6 +4,8 @@ import { ServerRepository } from './ServerRepository.js'; import { GroupRepository } from './GroupRepository.js'; import { SystemConfigRepository } from './SystemConfigRepository.js'; import { UserConfigRepository } from './UserConfigRepository.js'; +import { OAuthClientRepository } from './OAuthClientRepository.js'; +import { OAuthTokenRepository } from './OAuthTokenRepository.js'; // Export all repositories export { @@ -13,4 +15,6 @@ export { GroupRepository, SystemConfigRepository, UserConfigRepository, + OAuthClientRepository, + OAuthTokenRepository, }; diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index 582bbbc..404ddde 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -67,7 +67,7 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ') && isOAuthServerEnabled()) { const accessToken = authHeader.substring(7); - const oauthToken = getToken(accessToken); + const oauthToken = await getToken(accessToken); if (oauthToken && oauthToken.accessToken === accessToken) { // Valid OAuth token - look up user to get admin status diff --git a/src/models/OAuth.ts b/src/models/OAuth.ts index a7dee54..7eea756 100644 --- a/src/models/OAuth.ts +++ b/src/models/OAuth.ts @@ -1,112 +1,89 @@ import crypto from 'crypto'; -import { loadSettings, saveSettings } from '../config/index.js'; +import { getOAuthClientDao, getOAuthTokenDao } from '../dao/index.js'; import { IOAuthClient, IOAuthAuthorizationCode, IOAuthToken } from '../types/index.js'; -// In-memory storage for authorization codes and tokens -// Authorization codes are short-lived and kept in memory only. -// Tokens are mirrored to settings (mcp_settings.json) for persistence. +// In-memory storage for authorization codes (short-lived, no persistence needed) const authorizationCodes = new Map(); -const tokens = new Map(); -// Initialize token store from settings on first import -(() => { +// In-memory cache for tokens (also persisted via DAO) +const tokensCache = new Map(); + +// Flag to track if we've initialized from DAO +let initialized = false; + +/** + * Initialize token cache from DAO (async) + */ +const initializeTokenCache = async (): Promise => { + if (initialized) return; + initialized = true; + try { - const settings = loadSettings(); - if (Array.isArray(settings.oauthTokens)) { - for (const stored of settings.oauthTokens) { - const token: IOAuthToken = { - ...stored, - accessTokenExpiresAt: new Date(stored.accessTokenExpiresAt), - refreshTokenExpiresAt: stored.refreshTokenExpiresAt - ? new Date(stored.refreshTokenExpiresAt) - : undefined, - }; - tokens.set(token.accessToken, token); - if (token.refreshToken) { - tokens.set(token.refreshToken, token); - } + const tokenDao = getOAuthTokenDao(); + const allTokens = await tokenDao.findAll(); + for (const token of allTokens) { + tokensCache.set(token.accessToken, token); + if (token.refreshToken) { + tokensCache.set(token.refreshToken, token); } } } catch (error) { - console.error('Failed to initialize OAuth tokens from settings:', error); + console.error('Failed to initialize OAuth tokens from DAO:', error); } -})(); +}; + +// Initialize on module load (fire and forget for backward compatibility) +initializeTokenCache().catch(console.error); /** * Get all OAuth clients from configuration */ -export const getOAuthClients = (): IOAuthClient[] => { - const settings = loadSettings(); - return settings.oauthClients || []; +export const getOAuthClients = async (): Promise => { + const clientDao = getOAuthClientDao(); + return clientDao.findAll(); }; /** * Find OAuth client by client ID */ -export const findOAuthClientById = (clientId: string): IOAuthClient | undefined => { - const clients = getOAuthClients(); - return clients.find((c) => c.clientId === clientId); +export const findOAuthClientById = async (clientId: string): Promise => { + const clientDao = getOAuthClientDao(); + const client = await clientDao.findByClientId(clientId); + return client || undefined; }; /** * Create a new OAuth client */ -export const createOAuthClient = (client: IOAuthClient): IOAuthClient => { - const settings = loadSettings(); - if (!settings.oauthClients) { - settings.oauthClients = []; - } +export const createOAuthClient = async (client: IOAuthClient): Promise => { + const clientDao = getOAuthClientDao(); // Check if client already exists - const existing = settings.oauthClients.find((c) => c.clientId === client.clientId); + const existing = await clientDao.findByClientId(client.clientId); if (existing) { throw new Error(`OAuth client with ID ${client.clientId} already exists`); } - settings.oauthClients.push(client); - saveSettings(settings); - return client; + return clientDao.create(client); }; /** * Update an existing OAuth client */ -export const updateOAuthClient = ( +export const updateOAuthClient = async ( clientId: string, updates: Partial, -): IOAuthClient | null => { - const settings = loadSettings(); - if (!settings.oauthClients) { - return null; - } - - const index = settings.oauthClients.findIndex((c) => c.clientId === clientId); - if (index === -1) { - return null; - } - - settings.oauthClients[index] = { ...settings.oauthClients[index], ...updates }; - saveSettings(settings); - return settings.oauthClients[index]; +): Promise => { + const clientDao = getOAuthClientDao(); + return clientDao.update(clientId, updates); }; /** * Delete an OAuth client */ -export const deleteOAuthClient = (clientId: string): boolean => { - const settings = loadSettings(); - if (!settings.oauthClients) { - return false; - } - - const index = settings.oauthClients.findIndex((c) => c.clientId === clientId); - if (index === -1) { - return false; - } - - settings.oauthClients.splice(index, 1); - saveSettings(settings); - return true; +export const deleteOAuthClient = async (clientId: string): Promise => { + const clientDao = getOAuthClientDao(); + return clientDao.delete(clientId); }; /** @@ -163,11 +140,11 @@ export const revokeAuthorizationCode = (code: string): void => { /** * Save access token and optionally refresh token */ -export const saveToken = ( +export const saveToken = async ( tokenData: Omit, accessTokenLifetime: number = 3600, refreshTokenLifetime?: number, -): IOAuthToken => { +): Promise => { const accessToken = generateToken(); const accessTokenExpiresAt = new Date(Date.now() + accessTokenLifetime * 1000); @@ -187,30 +164,18 @@ export const saveToken = ( ...tokenData, }; - tokens.set(accessToken, token); + // Update cache + tokensCache.set(accessToken, token); if (refreshToken) { - tokens.set(refreshToken, token); + tokensCache.set(refreshToken, token); } - // Persist tokens to settings + // Persist to DAO try { - const settings = loadSettings(); - const existing = settings.oauthTokens || []; - const filtered = existing.filter( - (t) => t.accessToken !== token.accessToken && t.refreshToken !== token.refreshToken, - ); - const updated = [ - ...filtered, - { - ...token, - accessTokenExpiresAt: token.accessTokenExpiresAt, - refreshTokenExpiresAt: token.refreshTokenExpiresAt, - }, - ]; - settings.oauthTokens = updated; - saveSettings(settings); + const tokenDao = getOAuthTokenDao(); + await tokenDao.create(token); } catch (error) { - console.error('Failed to persist OAuth token to settings:', error); + console.error('Failed to persist OAuth token to DAO:', error); } return token; @@ -219,8 +184,27 @@ export const saveToken = ( /** * Get token by access token or refresh token */ -export const getToken = (token: string): IOAuthToken | undefined => { - const tokenData = tokens.get(token); +export const getToken = async (token: string): Promise => { + // First check cache + let tokenData = tokensCache.get(token); + + // If not in cache, try DAO + if (!tokenData) { + const tokenDao = getOAuthTokenDao(); + tokenData = + (await tokenDao.findByAccessToken(token)) || + (await tokenDao.findByRefreshToken(token)) || + undefined; + + // Update cache if found + if (tokenData) { + tokensCache.set(tokenData.accessToken, tokenData); + if (tokenData.refreshToken) { + tokensCache.set(tokenData.refreshToken, tokenData); + } + } + } + if (!tokenData) { return undefined; } @@ -245,34 +229,28 @@ export const getToken = (token: string): IOAuthToken | undefined => { /** * Revoke token (both access and refresh tokens) */ -export const revokeToken = (token: string): void => { - const tokenData = tokens.get(token); +export const revokeToken = async (token: string): Promise => { + const tokenData = tokensCache.get(token); if (tokenData) { - tokens.delete(tokenData.accessToken); + tokensCache.delete(tokenData.accessToken); if (tokenData.refreshToken) { - tokens.delete(tokenData.refreshToken); + tokensCache.delete(tokenData.refreshToken); } + } - // Also remove from persisted settings - try { - const settings = loadSettings(); - if (Array.isArray(settings.oauthTokens)) { - settings.oauthTokens = settings.oauthTokens.filter( - (t) => - t.accessToken !== tokenData.accessToken && t.refreshToken !== tokenData.refreshToken, - ); - saveSettings(settings); - } - } catch (error) { - console.error('Failed to remove OAuth token from settings:', error); - } + // Also remove from DAO + try { + const tokenDao = getOAuthTokenDao(); + await tokenDao.revokeToken(token); + } catch (error) { + console.error('Failed to remove OAuth token from DAO:', error); } }; /** * Clean up expired codes and tokens (should be called periodically) */ -export const cleanupExpired = (): void => { +export const cleanupExpired = async (): Promise => { const now = new Date(); // Clean up expired authorization codes @@ -282,9 +260,9 @@ export const cleanupExpired = (): void => { } } - // Clean up expired tokens + // Clean up expired tokens from cache const processedTokens = new Set(); - for (const [_key, token] of tokens.entries()) { + for (const [_key, token] of tokensCache.entries()) { // Skip if we've already processed this token if (processedTokens.has(token.accessToken)) { continue; @@ -294,35 +272,19 @@ export const cleanupExpired = (): void => { const accessExpired = token.accessTokenExpiresAt < now; const refreshExpired = token.refreshTokenExpiresAt && token.refreshTokenExpiresAt < now; - // If both are expired, remove the token + // If both are expired, remove from cache if (accessExpired && (!token.refreshToken || refreshExpired)) { - tokens.delete(token.accessToken); + tokensCache.delete(token.accessToken); if (token.refreshToken) { - tokens.delete(token.refreshToken); + tokensCache.delete(token.refreshToken); } } } - // Sync persisted tokens: keep only non-expired ones + // Clean up expired tokens from DAO try { - const settings = loadSettings(); - if (Array.isArray(settings.oauthTokens)) { - const validTokens: IOAuthToken[] = []; - for (const stored of settings.oauthTokens) { - const accessExpiresAt = new Date(stored.accessTokenExpiresAt); - const refreshExpiresAt = stored.refreshTokenExpiresAt - ? new Date(stored.refreshTokenExpiresAt) - : undefined; - const accessExpired = accessExpiresAt < now; - const refreshExpired = refreshExpiresAt && refreshExpiresAt < now; - - if (!accessExpired || (stored.refreshToken && !refreshExpired)) { - validTokens.push(stored); - } - } - settings.oauthTokens = validTokens; - saveSettings(settings); - } + const tokenDao = getOAuthTokenDao(); + await tokenDao.cleanupExpired(); } catch (error) { console.error('Failed to cleanup persisted OAuth tokens:', error); } @@ -331,7 +293,12 @@ export const cleanupExpired = (): void => { // Run cleanup every 5 minutes in production let cleanupIntervalId: NodeJS.Timeout | null = null; if (process.env.NODE_ENV !== 'test') { - cleanupIntervalId = setInterval(cleanupExpired, 5 * 60 * 1000); + cleanupIntervalId = setInterval( + () => { + cleanupExpired().catch(console.error); + }, + 5 * 60 * 1000, + ); // Allow the interval to not keep the process alive cleanupIntervalId.unref(); } diff --git a/src/services/oauthServerService.ts b/src/services/oauthServerService.ts index 8cf6861..505d02d 100644 --- a/src/services/oauthServerService.ts +++ b/src/services/oauthServerService.ts @@ -21,7 +21,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke * Get client by client ID */ getClient: async (clientId: string, clientSecret?: string) => { - const client = findOAuthClientById(clientId); + const client = await findOAuthClientById(clientId); if (!client) { return false; } @@ -92,7 +92,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke return false; } - const client = findOAuthClientById(code.clientId); + const client = await findOAuthClientById(code.clientId); if (!client) { return false; } @@ -143,7 +143,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke const scopeString = Array.isArray(token.scope) ? token.scope.join(' ') : token.scope; - const savedToken = saveToken( + const savedToken = await saveToken( { scope: scopeString, clientId: client.id, @@ -172,12 +172,12 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke * Get access token */ getAccessToken: async (accessToken: string) => { - const token = getToken(accessToken); + const token = await getToken(accessToken); if (!token) { return false; } - const client = findOAuthClientById(token.clientId); + const client = await findOAuthClientById(token.clientId); if (!client) { return false; } @@ -205,12 +205,12 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke * Get refresh token */ getRefreshToken: async (refreshToken: string) => { - const token = getToken(refreshToken); + const token = await getToken(refreshToken); if (!token || token.refreshToken !== refreshToken) { return false; } - const client = findOAuthClientById(token.clientId); + const client = await findOAuthClientById(token.clientId); if (!client) { return false; } @@ -240,7 +240,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke revokeToken: async (token: OAuth2Server.Token | OAuth2Server.RefreshToken) => { const refreshToken = 'refreshToken' in token ? token.refreshToken : undefined; if (refreshToken) { - revokeToken(refreshToken); + await revokeToken(refreshToken); } return true; }, diff --git a/src/utils/migration.ts b/src/utils/migration.ts index e26b683..a7e3a78 100644 --- a/src/utils/migration.ts +++ b/src/utils/migration.ts @@ -7,6 +7,8 @@ import { ServerRepository } from '../db/repositories/ServerRepository.js'; import { GroupRepository } from '../db/repositories/GroupRepository.js'; import { SystemConfigRepository } from '../db/repositories/SystemConfigRepository.js'; import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js'; +import { OAuthClientRepository } from '../db/repositories/OAuthClientRepository.js'; +import { OAuthTokenRepository } from '../db/repositories/OAuthTokenRepository.js'; /** * Migrate from file-based configuration to database @@ -29,6 +31,8 @@ export async function migrateToDatabase(): Promise { const groupRepo = new GroupRepository(); const systemConfigRepo = new SystemConfigRepository(); const userConfigRepo = new UserConfigRepository(); + const oauthClientRepo = new OAuthClientRepository(); + const oauthTokenRepo = new OAuthTokenRepository(); // Migrate users if (settings.users && settings.users.length > 0) { @@ -129,6 +133,53 @@ export async function migrateToDatabase(): Promise { } } + // Migrate OAuth clients + if (settings.oauthClients && settings.oauthClients.length > 0) { + console.log(`Migrating ${settings.oauthClients.length} OAuth clients...`); + for (const client of settings.oauthClients) { + const exists = await oauthClientRepo.exists(client.clientId); + if (!exists) { + await oauthClientRepo.create({ + clientId: client.clientId, + clientSecret: client.clientSecret, + name: client.name, + redirectUris: client.redirectUris, + grants: client.grants, + scopes: client.scopes, + owner: client.owner, + metadata: client.metadata, + }); + console.log(` - Created OAuth client: ${client.clientId}`); + } else { + console.log(` - OAuth client already exists: ${client.clientId}`); + } + } + } + + // Migrate OAuth tokens + if (settings.oauthTokens && settings.oauthTokens.length > 0) { + console.log(`Migrating ${settings.oauthTokens.length} OAuth tokens...`); + for (const token of settings.oauthTokens) { + const exists = await oauthTokenRepo.exists(token.accessToken); + if (!exists) { + await oauthTokenRepo.create({ + accessToken: token.accessToken, + refreshToken: token.refreshToken, + accessTokenExpiresAt: new Date(token.accessTokenExpiresAt), + refreshTokenExpiresAt: token.refreshTokenExpiresAt + ? new Date(token.refreshTokenExpiresAt) + : undefined, + scope: token.scope, + clientId: token.clientId, + username: token.username, + }); + console.log(` - Created OAuth token for client: ${token.clientId}`); + } else { + console.log(` - OAuth token already exists: ${token.accessToken.substring(0, 8)}...`); + } + } + } + console.log('✅ Migration completed successfully'); return true; } catch (error) { diff --git a/src/utils/oauthBearer.ts b/src/utils/oauthBearer.ts index d962485..a7fe8eb 100644 --- a/src/utils/oauthBearer.ts +++ b/src/utils/oauthBearer.ts @@ -11,7 +11,7 @@ export const resolveOAuthUserFromToken = async (token?: string): Promise ({ - loadSettings: jest.fn(() => ({ ...mockSettings })), - saveSettings: jest.fn((settings: any) => { - mockSettings = { ...settings }; - return true; - }), - loadOriginalSettings: jest.fn(() => ({ ...mockSettings })), -})); +// Mock the DAO factory to use in-memory storage for tests +jest.mock('../../src/dao/index.js', () => { + const originalModule = jest.requireActual('../../src/dao/index.js'); + + return { + ...originalModule, + getOAuthClientDao: jest.fn(() => ({ + findAll: jest.fn(async () => [...mockOAuthClients]), + findByClientId: jest.fn( + async (clientId: string) => mockOAuthClients.find((c) => c.clientId === clientId) || null, + ), + create: jest.fn(async (client: IOAuthClient) => { + mockOAuthClients.push(client); + return client; + }), + update: jest.fn(async (clientId: string, updates: Partial) => { + const index = mockOAuthClients.findIndex((c) => c.clientId === clientId); + if (index === -1) return null; + mockOAuthClients[index] = { ...mockOAuthClients[index], ...updates }; + return mockOAuthClients[index]; + }), + delete: jest.fn(async (clientId: string) => { + const index = mockOAuthClients.findIndex((c) => c.clientId === clientId); + if (index === -1) return false; + mockOAuthClients.splice(index, 1); + return true; + }), + })), + getOAuthTokenDao: jest.fn(() => ({ + findAll: jest.fn(async () => [...mockOAuthTokens]), + findByAccessToken: jest.fn( + async (accessToken: string) => + mockOAuthTokens.find((t) => t.accessToken === accessToken) || null, + ), + findByRefreshToken: jest.fn( + async (refreshToken: string) => + mockOAuthTokens.find((t) => t.refreshToken === refreshToken) || null, + ), + create: jest.fn(async (token: IOAuthToken) => { + mockOAuthTokens.push(token); + return token; + }), + revokeToken: jest.fn(async (token: string) => { + const index = mockOAuthTokens.findIndex( + (t) => t.accessToken === token || t.refreshToken === token, + ); + if (index === -1) return false; + mockOAuthTokens.splice(index, 1); + return true; + }), + cleanupExpired: jest.fn(async () => { + const now = new Date(); + mockOAuthTokens = mockOAuthTokens.filter((t) => { + const accessExpired = t.accessTokenExpiresAt < now; + const refreshExpired = + !t.refreshToken || (t.refreshTokenExpiresAt && t.refreshTokenExpiresAt < now); + return !accessExpired || !refreshExpired; + }); + }), + })), + }; +}); describe('OAuth Model', () => { beforeEach(() => { jest.clearAllMocks(); - // Reset mock settings before each test - mockSettings = { mcpServers: {}, users: [], oauthClients: [] }; + // Reset mock storage before each test + mockOAuthClients = []; + mockOAuthTokens = []; }); describe('OAuth Client Management', () => { - test('should create a new OAuth client', () => { - const client = { + test('should create a new OAuth client', async () => { + const client: IOAuthClient = { clientId: 'test-client', clientSecret: 'test-secret', name: 'Test Client', @@ -41,15 +98,15 @@ describe('OAuth Model', () => { scopes: ['read', 'write'], }; - const created = createOAuthClient(client); + const created = await createOAuthClient(client); expect(created).toEqual(client); - const found = findOAuthClientById('test-client'); + const found = await findOAuthClientById('test-client'); expect(found).toEqual(client); }); - test('should not create duplicate OAuth client', () => { - const client = { + test('should not create duplicate OAuth client', async () => { + const client: IOAuthClient = { clientId: 'test-client', clientSecret: 'test-secret', name: 'Test Client', @@ -58,12 +115,12 @@ describe('OAuth Model', () => { scopes: ['read'], }; - createOAuthClient(client); - expect(() => createOAuthClient(client)).toThrow(); + await createOAuthClient(client); + await expect(createOAuthClient(client)).rejects.toThrow(); }); - test('should update an OAuth client', () => { - const client = { + test('should update an OAuth client', async () => { + const client: IOAuthClient = { clientId: 'test-client', clientSecret: 'test-secret', name: 'Test Client', @@ -72,9 +129,9 @@ describe('OAuth Model', () => { scopes: ['read'], }; - createOAuthClient(client); + await createOAuthClient(client); - const updated = updateOAuthClient('test-client', { + const updated = await updateOAuthClient('test-client', { name: 'Updated Client', scopes: ['read', 'write'], }); @@ -83,8 +140,8 @@ describe('OAuth Model', () => { expect(updated?.scopes).toEqual(['read', 'write']); }); - test('should delete an OAuth client', () => { - const client = { + test('should delete an OAuth client', async () => { + const client: IOAuthClient = { clientId: 'test-client', clientSecret: 'test-secret', name: 'Test Client', @@ -93,12 +150,12 @@ describe('OAuth Model', () => { scopes: ['read'], }; - createOAuthClient(client); - expect(findOAuthClientById('test-client')).toBeDefined(); + await createOAuthClient(client); + expect(await findOAuthClientById('test-client')).toBeDefined(); - const deleted = deleteOAuthClient('test-client'); + const deleted = await deleteOAuthClient('test-client'); expect(deleted).toBe(true); - expect(findOAuthClientById('test-client')).toBeUndefined(); + expect(await findOAuthClientById('test-client')).toBeUndefined(); }); }); @@ -157,8 +214,8 @@ describe('OAuth Model', () => { }); describe('Token Management', () => { - test('should save and retrieve token', () => { - const token = saveToken( + test('should save and retrieve token', async () => { + const token = await saveToken( { scope: 'read write', clientId: 'test-client', @@ -172,14 +229,14 @@ describe('OAuth Model', () => { expect(token.refreshToken).toBeDefined(); expect(token.accessTokenExpiresAt).toBeInstanceOf(Date); - const retrieved = getToken(token.accessToken); + const retrieved = await getToken(token.accessToken); expect(retrieved).toBeDefined(); expect(retrieved?.clientId).toBe('test-client'); expect(retrieved?.username).toBe('testuser'); }); - test('should retrieve token by refresh token', () => { - const token = saveToken( + test('should retrieve token by refresh token', async () => { + const token = await saveToken( { scope: 'read', clientId: 'test-client', @@ -191,13 +248,13 @@ describe('OAuth Model', () => { expect(token.refreshToken).toBeDefined(); - const retrieved = getToken(token.refreshToken!); + const retrieved = await getToken(token.refreshToken!); expect(retrieved).toBeDefined(); expect(retrieved?.accessToken).toBe(token.accessToken); }); test('should not retrieve expired access token', async () => { - const token = saveToken( + const token = await saveToken( { scope: 'read', clientId: 'test-client', @@ -208,12 +265,12 @@ describe('OAuth Model', () => { await new Promise((resolve) => setTimeout(resolve, 100)); - const retrieved = getToken(token.accessToken); + const retrieved = await getToken(token.accessToken); expect(retrieved).toBeUndefined(); }); - test('should revoke token', () => { - const token = saveToken( + test('should revoke token', async () => { + const token = await saveToken( { scope: 'read', clientId: 'test-client', @@ -223,13 +280,13 @@ describe('OAuth Model', () => { 86400, ); - expect(getToken(token.accessToken)).toBeDefined(); + expect(await getToken(token.accessToken)).toBeDefined(); - revokeToken(token.accessToken); - expect(getToken(token.accessToken)).toBeUndefined(); + await revokeToken(token.accessToken); + expect(await getToken(token.accessToken)).toBeUndefined(); if (token.refreshToken) { - expect(getToken(token.refreshToken)).toBeUndefined(); + expect(await getToken(token.refreshToken)).toBeUndefined(); } }); });