From 01855ca2cadef8cec1303634914dd5f6db0f11ad Mon Sep 17 00:00:00 2001 From: samanhappy Date: Sat, 13 Dec 2025 16:46:58 +0800 Subject: [PATCH] feat: add bearer authentication key management with migration support (#503) --- frontend/src/components/ui/MultiSelect.tsx | 161 ++++ frontend/src/contexts/SettingsContext.tsx | 91 ++- frontend/src/pages/SettingsPage.tsx | 815 +++++++++++++++++++-- frontend/src/types/index.ts | 13 + locales/en.json | 27 +- locales/fr.json | 27 +- locales/tr.json | 27 +- locales/zh.json | 29 +- src/controllers/bearerKeyController.ts | 169 +++++ src/controllers/configController.ts | 31 +- src/controllers/serverController.ts | 6 + src/dao/BearerKeyDao.ts | 122 +++ src/dao/BearerKeyDaoDbImpl.ts | 77 ++ src/dao/DaoFactory.ts | 15 + src/dao/DatabaseDaoFactory.ts | 11 + src/dao/index.ts | 2 + src/db/entities/BearerKey.ts | 43 ++ src/db/entities/index.ts | 14 +- src/db/repositories/BearerKeyRepository.ts | 75 ++ src/db/repositories/index.ts | 2 + src/middlewares/auth.ts | 32 +- src/routes/index.ts | 12 + src/services/groupService.ts | 6 +- src/services/sseService.test.ts | 24 + src/services/sseService.ts | 174 ++++- src/types/index.ts | 14 + src/utils/migration.test.ts | 122 +++ src/utils/migration.ts | 81 ++ tests/controllers/configController.test.ts | 75 +- tests/services/keepalive.test.ts | 34 +- 30 files changed, 2138 insertions(+), 193 deletions(-) create mode 100644 frontend/src/components/ui/MultiSelect.tsx create mode 100644 src/controllers/bearerKeyController.ts create mode 100644 src/dao/BearerKeyDao.ts create mode 100644 src/dao/BearerKeyDaoDbImpl.ts create mode 100644 src/db/entities/BearerKey.ts create mode 100644 src/db/repositories/BearerKeyRepository.ts create mode 100644 src/utils/migration.test.ts diff --git a/frontend/src/components/ui/MultiSelect.tsx b/frontend/src/components/ui/MultiSelect.tsx new file mode 100644 index 0000000..66ff99e --- /dev/null +++ b/frontend/src/components/ui/MultiSelect.tsx @@ -0,0 +1,161 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Check, ChevronDown, X } from 'lucide-react'; + +interface MultiSelectProps { + options: { value: string; label: string }[]; + selected: string[]; + onChange: (selected: string[]) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export const MultiSelect: React.FC = ({ + options, + selected, + onChange, + placeholder = 'Select items...', + disabled = false, + className = '', +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const dropdownRef = useRef(null); + const inputRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + setSearchTerm(''); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const filteredOptions = options.filter((option) => + option.label.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + const handleToggleOption = (value: string) => { + if (disabled) return; + + const newSelected = selected.includes(value) + ? selected.filter((item) => item !== value) + : [...selected, value]; + + onChange(newSelected); + }; + + const handleRemoveItem = (value: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (disabled) return; + onChange(selected.filter((item) => item !== value)); + }; + + const handleToggleDropdown = () => { + if (disabled) return; + setIsOpen(!isOpen); + if (!isOpen) { + setTimeout(() => inputRef.current?.focus(), 0); + } + }; + + const getSelectedLabels = () => { + return selected + .map((value) => options.find((opt) => opt.value === value)?.label || value) + .filter(Boolean); + }; + + return ( +
+ {/* Selected items display */} +
+ {selected.length > 0 ? ( + <> + {getSelectedLabels().map((label, index) => ( + + {label} + {!disabled && ( + + )} + + ))} + + ) : ( + {placeholder} + )} +
+ +
+ + {/* Dropdown menu */} + {isOpen && !disabled && ( +
+ {/* Search input */} +
+ setSearchTerm(e.target.value)} + placeholder="Search..." + className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + onClick={(e) => e.stopPropagation()} + /> +
+ + {/* Options list */} +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => { + const isSelected = selected.includes(option.value); + return ( +
handleToggleOption(option.value)} + className={` + px-3 py-2 cursor-pointer flex items-center justify-between + transition-colors duration-150 + ${isSelected ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'} + `} + > + {option.label} + {isSelected && } +
+ ); + }) + ) : ( +
+ {searchTerm ? 'No results found' : 'No options available'} +
+ )} +
+
+ )} +
+ ); +}; diff --git a/frontend/src/contexts/SettingsContext.tsx b/frontend/src/contexts/SettingsContext.tsx index 85395e2..c58588b 100644 --- a/frontend/src/contexts/SettingsContext.tsx +++ b/frontend/src/contexts/SettingsContext.tsx @@ -7,9 +7,9 @@ import React, { ReactNode, } from 'react'; import { useTranslation } from 'react-i18next'; -import { ApiResponse } from '@/types'; +import { ApiResponse, BearerKey } from '@/types'; import { useToast } from '@/contexts/ToastContext'; -import { apiGet, apiPut } from '@/utils/fetchInterceptor'; +import { apiGet, apiPut, apiPost, apiDelete } from '@/utils/fetchInterceptor'; // Define types for the settings data interface RoutingConfig { @@ -66,6 +66,7 @@ interface SystemSettings { oauthServer?: OAuthServerConfig; enableSessionRebuild?: boolean; }; + bearerKeys?: BearerKey[]; } interface TempRoutingConfig { @@ -82,6 +83,7 @@ interface SettingsContextValue { oauthServerConfig: OAuthServerConfig; nameSeparator: string; enableSessionRebuild: boolean; + bearerKeys: BearerKey[]; loading: boolean; error: string | null; setError: React.Dispatch>; @@ -109,6 +111,14 @@ interface SettingsContextValue { updateNameSeparator: (value: string) => Promise; updateSessionRebuild: (value: boolean) => Promise; exportMCPSettings: (serverName?: string) => Promise; + // Bearer key management + refreshBearerKeys: () => Promise; + createBearerKey: (payload: Omit) => Promise; + updateBearerKey: ( + id: string, + updates: Partial>, + ) => Promise; + deleteBearerKey: (id: string) => Promise; } const getDefaultOAuthServerConfig = (): OAuthServerConfig => ({ @@ -183,6 +193,7 @@ export const SettingsProvider: React.FC = ({ children }) const [nameSeparator, setNameSeparator] = useState('-'); const [enableSessionRebuild, setEnableSessionRebuild] = useState(false); + const [bearerKeys, setBearerKeys] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -279,6 +290,10 @@ export const SettingsProvider: React.FC = ({ children }) if (data.success && data.data?.systemConfig?.enableSessionRebuild !== undefined) { setEnableSessionRebuild(data.data.systemConfig.enableSessionRebuild); } + + if (data.success && Array.isArray(data.data?.bearerKeys)) { + setBearerKeys(data.data.bearerKeys); + } } catch (error) { console.error('Failed to fetch settings:', error); setError(error instanceof Error ? error.message : 'Failed to fetch settings'); @@ -659,6 +674,73 @@ export const SettingsProvider: React.FC = ({ children }) } }; + // Bearer key management helpers + const refreshBearerKeys = async () => { + try { + const data: ApiResponse = await apiGet('/auth/keys'); + if (data.success && Array.isArray(data.data)) { + setBearerKeys(data.data); + } + } catch (error) { + console.error('Failed to refresh bearer keys:', error); + showToast(t('errors.failedToFetchSettings')); + } + }; + + const createBearerKey = async (payload: Omit): Promise => { + try { + const data: ApiResponse = await apiPost('/auth/keys', payload as any); + if (data.success && data.data) { + await refreshBearerKeys(); + showToast(t('settings.systemConfigUpdated')); + return data.data; + } + showToast(data.message || t('errors.failedToUpdateRoutingConfig')); + return null; + } catch (error) { + console.error('Failed to create bearer key:', error); + showToast(t('errors.failedToUpdateRoutingConfig')); + return null; + } + }; + + const updateBearerKey = async ( + id: string, + updates: Partial>, + ): Promise => { + try { + const data: ApiResponse = await apiPut(`/auth/keys/${id}`, updates as any); + if (data.success && data.data) { + await refreshBearerKeys(); + showToast(t('settings.systemConfigUpdated')); + return data.data; + } + showToast(data.message || t('errors.failedToUpdateRoutingConfig')); + return null; + } catch (error) { + console.error('Failed to update bearer key:', error); + showToast(t('errors.failedToUpdateRoutingConfig')); + return null; + } + }; + + const deleteBearerKey = async (id: string): Promise => { + try { + const data: ApiResponse = await apiDelete(`/auth/keys/${id}`); + if (data.success) { + await refreshBearerKeys(); + showToast(t('settings.systemConfigUpdated')); + return true; + } + showToast(data.message || t('errors.failedToUpdateRoutingConfig')); + return false; + } catch (error) { + console.error('Failed to delete bearer key:', error); + showToast(t('errors.failedToUpdateRoutingConfig')); + return false; + } + }; + // Fetch settings when the component mounts or refreshKey changes useEffect(() => { fetchSettings(); @@ -682,6 +764,7 @@ export const SettingsProvider: React.FC = ({ children }) oauthServerConfig, nameSeparator, enableSessionRebuild, + bearerKeys, loading, error, setError, @@ -699,6 +782,10 @@ export const SettingsProvider: React.FC = ({ children }) updateNameSeparator, updateSessionRebuild, exportMCPSettings, + refreshBearerKeys, + createBearerKey, + updateBearerKey, + deleteBearerKey, }; return {children}; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 839940c..9d688d9 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -3,17 +3,317 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import ChangePasswordForm from '@/components/ChangePasswordForm'; import { Switch } from '@/components/ui/ToggleGroup'; +import { MultiSelect } from '@/components/ui/MultiSelect'; import { useSettingsData } from '@/hooks/useSettingsData'; import { useToast } from '@/contexts/ToastContext'; import { generateRandomKey } from '@/utils/key'; import { PermissionChecker } from '@/components/PermissionChecker'; import { PERMISSIONS } from '@/constants/permissions'; -import { Copy, Check, Download } from 'lucide-react'; +import { Copy, Check, Download, Edit, Trash2 } from 'lucide-react'; +import type { BearerKey } from '@/types'; +import { useServerContext } from '@/contexts/ServerContext'; +import { useGroupData } from '@/hooks/useGroupData'; + +interface BearerKeyRowProps { + keyData: BearerKey; + loading: boolean; + availableServers: { value: string; label: string }[]; + availableGroups: { value: string; label: string }[]; + onSave: ( + id: string, + payload: { + name: string; + token: string; + enabled: boolean; + accessType: 'all' | 'groups' | 'servers'; + allowedGroups: string; + allowedServers: string; + }, + ) => Promise; + onDelete: (id: string) => Promise; +} + +const BearerKeyRow: React.FC = ({ + keyData, + loading, + availableServers, + availableGroups, + onSave, + onDelete, +}) => { + const { t } = useTranslation(); + const { showToast } = useToast(); + const [isEditing, setIsEditing] = useState(false); + const [name, setName] = useState(keyData.name); + const [token, setToken] = useState(keyData.token); + const [enabled, setEnabled] = useState(keyData.enabled); + const [accessType, setAccessType] = useState<'all' | 'groups' | 'servers'>( + keyData.accessType || 'all', + ); + const [selectedGroups, setSelectedGroups] = useState(keyData.allowedGroups || []); + const [selectedServers, setSelectedServers] = useState(keyData.allowedServers || []); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + if (!isEditing) { + setName(keyData.name); + setToken(keyData.token); + setEnabled(keyData.enabled); + setAccessType(keyData.accessType || 'all'); + setSelectedGroups(keyData.allowedGroups || []); + setSelectedServers(keyData.allowedServers || []); + } + }, [keyData, isEditing]); + + const handleCopyToken = async () => { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(keyData.token); + showToast(t('common.copySuccess') || 'Copied to clipboard', 'success'); + } else { + const textArea = document.createElement('textarea'); + textArea.value = keyData.token; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + showToast(t('common.copySuccess') || 'Copied to clipboard', 'success'); + } catch (err) { + showToast(t('common.copyFailed') || 'Copy failed', 'error'); + } + document.body.removeChild(textArea); + } + } catch (error) { + console.error('Failed to copy', error); + showToast(t('common.copyFailed') || 'Copy failed', 'error'); + } + }; + + const handleSave = async () => { + if (accessType === 'groups' && selectedGroups.length === 0) { + showToast(t('settings.selectAtLeastOneGroup') || 'Please select at least one group', 'error'); + return; + } + if (accessType === 'servers' && selectedServers.length === 0) { + showToast( + t('settings.selectAtLeastOneServer') || 'Please select at least one server', + 'error', + ); + return; + } + + setSaving(true); + try { + await onSave(keyData.id, { + name, + token, + enabled, + accessType, + allowedGroups: selectedGroups.join(', '), + allowedServers: selectedServers.join(', '), + }); + setIsEditing(false); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!window.confirm(t('settings.deleteBearerKeyConfirm') || 'Delete this key?')) { + return; + } + setDeleting(true); + try { + await onDelete(keyData.id); + } finally { + setDeleting(false); + } + }; + + const isGroupsMode = accessType === 'groups'; + + if (isEditing) { + return ( + + +
+
+
+ + setName(e.target.value)} + disabled={loading} + /> +
+
+ + setToken(e.target.value)} + disabled={loading} + /> +
+
+ +
+
+ +
+ + {enabled ? 'Active' : 'Inactive'} + + setEnabled(checked)} + /> +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + ); + } + + return ( + + + {keyData.name} + + +
+ + {keyData.token.length > 12 + ? `${keyData.token.substring(0, 8)}...${keyData.token.substring(keyData.token.length - 4)}` + : keyData.token} + + +
+ + + + {keyData.enabled ? t('common.active') || 'Active' : t('common.inactive') || 'Inactive'} + + + + {keyData.accessType === 'all' + ? t('settings.bearerKeyAccessAll') || 'All Resources' + : keyData.accessType === 'groups' + ? `${t('settings.bearerKeyAccessGroups') || 'Groups'}: ${keyData.allowedGroups}` + : `${t('settings.bearerKeyAccessServers') || 'Servers'}: ${keyData.allowedServers}`} + + + + + + + ); +}; const SettingsPage: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { showToast } = useToast(); + const { servers } = useServerContext(); + const { groups } = useGroupData(); const [installConfig, setInstallConfig] = useState<{ pythonIndexUrl: string; @@ -64,6 +364,7 @@ const SettingsPage: React.FC = () => { }); const [tempNameSeparator, setTempNameSeparator] = useState('-'); + const [showAddBearerKeyForm, setShowAddBearerKeyForm] = useState(false); const { routingConfig, @@ -76,6 +377,7 @@ const SettingsPage: React.FC = () => { nameSeparator, enableSessionRebuild, loading, + bearerKeys, updateRoutingConfig, updateRoutingConfigBatch, updateInstallConfig, @@ -86,6 +388,10 @@ const SettingsPage: React.FC = () => { updateNameSeparator, updateSessionRebuild, exportMCPSettings, + createBearerKey, + updateBearerKey, + deleteBearerKey, + refreshBearerKeys, } = useSettingsData(); // Update local installConfig when savedInstallConfig changes @@ -151,6 +457,11 @@ const SettingsPage: React.FC = () => { setTempNameSeparator(nameSeparator); }, [nameSeparator]); + // Refresh bearer keys when component mounts + useEffect(() => { + refreshBearerKeys(); + }, []); + const [sectionsVisible, setSectionsVisible] = useState({ routingConfig: false, installConfig: false, @@ -160,6 +471,7 @@ const SettingsPage: React.FC = () => { nameSeparator: false, password: false, exportConfig: false, + bearerKeys: false, }); const toggleSection = ( @@ -171,7 +483,8 @@ const SettingsPage: React.FC = () => { | 'mcpRouterConfig' | 'nameSeparator' | 'password' - | 'exportConfig', + | 'exportConfig' + | 'bearerKeys', ) => { setSectionsVisible((prev) => ({ ...prev, @@ -221,10 +534,6 @@ const SettingsPage: React.FC = () => { })); }; - const saveBearerAuthKey = async () => { - await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey); - }; - const handleInstallConfigChange = ( key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl', value: string, @@ -405,6 +714,46 @@ const SettingsPage: React.FC = () => { const [copiedConfig, setCopiedConfig] = useState(false); const [mcpSettingsJson, setMcpSettingsJson] = useState(''); + const [newBearerKey, setNewBearerKey] = useState<{ + name: string; + token: string; + enabled: boolean; + accessType: 'all' | 'groups' | 'servers'; + allowedGroups: string; + allowedServers: string; + }>({ + name: '', + token: '', + enabled: true, + accessType: 'all', + allowedGroups: '', + allowedServers: '', + }); + + const [newSelectedGroups, setNewSelectedGroups] = useState([]); + const [newSelectedServers, setNewSelectedServers] = useState([]); + + // Prepare options for MultiSelect + const availableServers = servers.map((server) => ({ + value: server.name, + label: server.name, + })); + + const availableGroups = groups.map((group) => ({ + value: group.name, + label: group.name, + })); + + // Reset selected arrays when accessType changes + useEffect(() => { + if (newBearerKey.accessType !== 'groups') { + setNewSelectedGroups([]); + } + if (newBearerKey.accessType !== 'servers') { + setNewSelectedServers([]); + } + }, [newBearerKey.accessType]); + const fetchMcpSettings = async () => { try { const result = await exportMCPSettings(); @@ -473,15 +822,374 @@ const SettingsPage: React.FC = () => { showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success'); }; + const parseCommaSeparated = (value: string): string[] | undefined => { + const parts = value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return parts.length > 0 ? parts : undefined; + }; + + const handleCreateBearerKey = async () => { + if (!newBearerKey.name || !newBearerKey.token) { + showToast(t('settings.bearerKeyRequired') || 'Name and token are required', 'error'); + return; + } + + if (newBearerKey.accessType === 'groups' && newSelectedGroups.length === 0) { + showToast(t('settings.selectAtLeastOneGroup') || 'Please select at least one group', 'error'); + return; + } + if (newBearerKey.accessType === 'servers' && newSelectedServers.length === 0) { + showToast( + t('settings.selectAtLeastOneServer') || 'Please select at least one server', + 'error', + ); + return; + } + + await createBearerKey({ + name: newBearerKey.name, + token: newBearerKey.token, + enabled: newBearerKey.enabled, + accessType: newBearerKey.accessType, + allowedGroups: + newBearerKey.accessType === 'groups' && newSelectedGroups.length > 0 + ? newSelectedGroups + : undefined, + allowedServers: + newBearerKey.accessType === 'servers' && newSelectedServers.length > 0 + ? newSelectedServers + : undefined, + } as any); + + setNewBearerKey({ + name: '', + token: '', + enabled: true, + accessType: 'all', + allowedGroups: '', + allowedServers: '', + }); + setNewSelectedGroups([]); + setNewSelectedServers([]); + await refreshBearerKeys(); + }; + + const handleSaveExistingBearerKey = async ( + id: string, + payload: { + name: string; + token: string; + enabled: boolean; + accessType: 'all' | 'groups' | 'servers'; + allowedGroups: string; + allowedServers: string; + }, + ) => { + await updateBearerKey(id, { + name: payload.name, + token: payload.token, + enabled: payload.enabled, + accessType: payload.accessType, + allowedGroups: parseCommaSeparated(payload.allowedGroups), + allowedServers: parseCommaSeparated(payload.allowedServers), + } as any); + await refreshBearerKeys(); + }; + + const handleDeleteExistingBearerKey = async (id: string) => { + await deleteBearerKey(id); + await refreshBearerKeys(); + }; + return (

{t('pages.settings.title')}

+ {/* Bearer Keys Settings */} + +
+
toggleSection('bearerKeys')} + > +

+ {t('settings.bearerKeysSectionTitle') || 'Bearer authentication keys'} +

+ + {sectionsVisible.bearerKeys ? '▼' : '►'} + +
+ + {sectionsVisible.bearerKeys && ( +
+
+

+ {t('settings.bearerKeysSectionDescription') || + 'Manage multiple bearer authentication keys with different access scopes.'} +

+ {!showAddBearerKeyForm && ( + + )} +
+ + {/* Existing keys */} + {bearerKeys.length === 0 ? ( +

+ {t('settings.noBearerKeys') || 'No bearer keys configured yet.'} +

+ ) : ( +
+ + + + + + + + + + + + {bearerKeys.map((key) => ( + + ))} + +
+ {t('settings.bearerKeyName') || 'Name'} + + {t('settings.bearerKeyToken') || 'Token'} + + {t('settings.bearerKeyEnabled') || 'Status'} + + {t('settings.bearerKeyAccessType') || 'Access Scope'} + + {t('common.actions') || 'Actions'} +
+
+ )} + + {/* New key form */} + {showAddBearerKeyForm && ( +
+
+

+ + + + + + {t('settings.addBearerKey') || 'Add bearer key'} +

+ +
+
+ + + setNewBearerKey((prev) => ({ ...prev, name: e.target.value })) + } + disabled={loading} + /> +
+
+ +
+ + setNewBearerKey((prev) => ({ ...prev, token: e.target.value })) + } + disabled={loading} + /> + +
+
+
+ +
+
+ +
+ + {newBearerKey.enabled ? 'Active' : 'Inactive'} + + + setNewBearerKey((prev) => ({ ...prev, enabled: checked })) + } + /> +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ )} +
+ )} +
+
+ {/* Smart Routing Configuration Settings */} -
+
toggleSection('smartRoutingConfig')} >

{t('pages.settings.smartRouting')}

@@ -491,7 +1199,7 @@ const SettingsPage: React.FC = () => {
{sectionsVisible.smartRoutingConfig && ( -
+

{t('settings.enableSmartRouting')}

@@ -616,9 +1324,9 @@ const SettingsPage: React.FC = () => { {/* OAuth Server Configuration Settings */} -
+
toggleSection('oauthServerConfig')} >

{t('pages.settings.oauthServer')}

@@ -626,7 +1334,7 @@ const SettingsPage: React.FC = () => {
{sectionsVisible.oauthServerConfig && ( -
+

{t('settings.enableOauthServer')}

@@ -870,9 +1578,9 @@ const SettingsPage: React.FC = () => { {/* MCPRouter Configuration Settings */} -
+
toggleSection('mcpRouterConfig')} >

{t('settings.mcpRouterConfig')}

@@ -882,7 +1590,7 @@ const SettingsPage: React.FC = () => {
{sectionsVisible.mcpRouterConfig && ( -
+

{t('settings.mcpRouterApiKey')}

@@ -941,9 +1649,9 @@ const SettingsPage: React.FC = () => { {/* System Settings */} -
+
toggleSection('nameSeparator')} >

{t('settings.systemSettings')}

@@ -951,7 +1659,7 @@ const SettingsPage: React.FC = () => {
{sectionsVisible.nameSeparator && ( -
+

{t('settings.nameSeparatorLabel')}

@@ -999,9 +1707,9 @@ const SettingsPage: React.FC = () => { {/* Route Configuration Settings */} -
+
toggleSection('routingConfig')} >

{t('pages.settings.routeConfig')}

@@ -1009,51 +1717,7 @@ const SettingsPage: React.FC = () => {
{sectionsVisible.routingConfig && ( -
-
-
-

{t('settings.enableBearerAuth')}

-

- {t('settings.enableBearerAuthDescription')} -

-
- - handleRoutingConfigChange('enableBearerAuth', checked) - } - /> -
- - {routingConfig.enableBearerAuth && ( -
-
-

{t('settings.bearerAuthKey')}

-

- {t('settings.bearerAuthKeyDescription')} -

-
-
- handleBearerAuthKeyChange(e.target.value)} - placeholder={t('settings.bearerAuthKeyPlaceholder')} - className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input" - disabled={loading || !routingConfig.enableBearerAuth} - /> - -
-
- )} - +

{t('settings.enableGlobalRoute')}

@@ -1106,9 +1770,9 @@ const SettingsPage: React.FC = () => { {/* Installation Configuration Settings */} -
+
toggleSection('installConfig')} >

{t('settings.installConfig')}

@@ -1116,7 +1780,7 @@ const SettingsPage: React.FC = () => {
{sectionsVisible.installConfig && ( -
+

{t('settings.baseUrl')}

@@ -1194,12 +1858,9 @@ const SettingsPage: React.FC = () => { {/* Change Password */} -
+
toggleSection('password')} role="button" > @@ -1208,7 +1869,7 @@ const SettingsPage: React.FC = () => {
{sectionsVisible.password && ( -
+
)} @@ -1216,9 +1877,9 @@ const SettingsPage: React.FC = () => { {/* Export MCP Settings */} -
+
toggleSection('exportConfig')} >

{t('settings.exportMcpSettings')}

@@ -1226,7 +1887,7 @@ const SettingsPage: React.FC = () => {
{sectionsVisible.exportConfig && ( -
+

{t('settings.mcpSettingsJson')}

diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e6b5aa5..1373489 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -309,6 +309,19 @@ export interface ApiResponse { data?: T; } +// Bearer authentication key configuration (frontend view model) +export type BearerKeyAccessType = 'all' | 'groups' | 'servers'; + +export interface BearerKey { + id: string; + name: string; + token: string; + enabled: boolean; + accessType: BearerKeyAccessType; + allowedGroups?: string[]; + allowedServers?: string[]; +} + // Auth types export interface IUser { username: string; diff --git a/locales/en.json b/locales/en.json index 01fbcc6..b81c2b4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -253,7 +253,11 @@ "type": "Type", "repeated": "Repeated", "valueHint": "Value Hint", - "choices": "Choices" + "choices": "Choices", + "actions": "Actions", + "saving": "Saving...", + "active": "Active", + "inactive": "Inactive" }, "nav": { "dashboard": "Dashboard", @@ -553,6 +557,27 @@ "bearerAuthKey": "Bearer Authentication Key", "bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token", "bearerAuthKeyPlaceholder": "Enter bearer authentication key", + "bearerKeysSectionTitle": "Keys", + "bearerKeysSectionDescription": "Manage multiple keys with different access scopes.", + "noBearerKeys": "No keys configured yet.", + "bearerKeyName": "Name", + "bearerKeyToken": "Token", + "bearerKeyEnabled": "Enabled", + "bearerKeyAccessType": "Access scope", + "bearerKeyAccessAll": "All", + "bearerKeyAccessGroups": "Groups", + "bearerKeyAccessServers": "Servers", + "bearerKeyAllowedGroups": "Allowed groups", + "bearerKeyAllowedServers": "Allowed servers", + "addBearerKey": "Add key", + "addBearerKeyButton": "Create", + "bearerKeyRequired": "Name and token are required", + "deleteBearerKeyConfirm": "Are you sure you want to delete this key?", + "generate": "Generate", + "selectGroups": "Select Groups", + "selectServers": "Select Servers", + "selectAtLeastOneGroup": "Please select at least one group", + "selectAtLeastOneServer": "Please select at least one server", "skipAuth": "Skip Authentication", "skipAuthDescription": "Bypass login requirement for frontend and API access (DEFAULT OFF for security)", "pythonIndexUrl": "Python Package Repository URL", diff --git a/locales/fr.json b/locales/fr.json index 49c9638..6faf1b4 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -254,7 +254,11 @@ "type": "Type", "repeated": "Répété", "valueHint": "Indice de valeur", - "choices": "Choix" + "choices": "Choix", + "actions": "Actions", + "saving": "Enregistrement...", + "active": "Actif", + "inactive": "Inactif" }, "nav": { "dashboard": "Tableau de bord", @@ -554,6 +558,27 @@ "bearerAuthKey": "Clé d'authentification Bearer", "bearerAuthKeyDescription": "La clé d'authentification qui sera requise dans le jeton Bearer", "bearerAuthKeyPlaceholder": "Entrez la clé d'authentification Bearer", + "bearerKeysSectionTitle": "Clés", + "bearerKeysSectionDescription": "Gérez plusieurs clés avec différentes portées d’accès.", + "noBearerKeys": "Aucune clé configurée pour le moment.", + "bearerKeyName": "Nom", + "bearerKeyToken": "Jeton", + "bearerKeyEnabled": "Activée", + "bearerKeyAccessType": "Portée d’accès", + "bearerKeyAccessAll": "Toutes", + "bearerKeyAccessGroups": "Groupes", + "bearerKeyAccessServers": "Serveurs", + "bearerKeyAllowedGroups": "Groupes autorisés", + "bearerKeyAllowedServers": "Serveurs autorisés", + "addBearerKey": "Ajouter une clé", + "addBearerKeyButton": "Créer", + "bearerKeyRequired": "Le nom et le jeton sont obligatoires", + "deleteBearerKeyConfirm": "Voulez-vous vraiment supprimer cette clé ?", + "generate": "Générer", + "selectGroups": "Sélectionner des groupes", + "selectServers": "Sélectionner des serveurs", + "selectAtLeastOneGroup": "Veuillez sélectionner au moins un groupe", + "selectAtLeastOneServer": "Veuillez sélectionner au moins un serveur", "skipAuth": "Ignorer l'authentification", "skipAuthDescription": "Contourner l'exigence de connexion pour l'accès au frontend et à l'API (DÉSACTIVÉ PAR DÉFAUT pour des raisons de sécurité)", "pythonIndexUrl": "URL du dépôt de paquets Python", diff --git a/locales/tr.json b/locales/tr.json index 14409a4..46d2639 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -254,7 +254,11 @@ "type": "Tür", "repeated": "Tekrarlanan", "valueHint": "Değer İpucu", - "choices": "Seçenekler" + "choices": "Seçenekler", + "actions": "Eylemler", + "saving": "Kaydediliyor...", + "active": "Aktif", + "inactive": "Pasif" }, "nav": { "dashboard": "Kontrol Paneli", @@ -554,6 +558,27 @@ "bearerAuthKey": "Bearer Kimlik Doğrulama Anahtarı", "bearerAuthKeyDescription": "Bearer token'da gerekli olacak kimlik doğrulama anahtarı", "bearerAuthKeyPlaceholder": "Bearer kimlik doğrulama anahtarını girin", + "bearerKeysSectionTitle": "Anahtarlar", + "bearerKeysSectionDescription": "Farklı erişim kapsamlarına sahip birden fazla anahtarı yönetin.", + "noBearerKeys": "Henüz yapılandırılmış herhangi bir anahtar yok.", + "bearerKeyName": "Ad", + "bearerKeyToken": "Token", + "bearerKeyEnabled": "Etkin", + "bearerKeyAccessType": "Erişim kapsamı", + "bearerKeyAccessAll": "Tümü", + "bearerKeyAccessGroups": "Gruplar", + "bearerKeyAccessServers": "Sunucular", + "bearerKeyAllowedGroups": "İzin verilen gruplar", + "bearerKeyAllowedServers": "İzin verilen sunucular", + "addBearerKey": "Anahtar ekle", + "addBearerKeyButton": "Oluştur", + "bearerKeyRequired": "Ad ve token zorunludur", + "deleteBearerKeyConfirm": "Bu anahtarı silmek istediğinizden emin misiniz?", + "generate": "Oluştur", + "selectGroups": "Grupları Seç", + "selectServers": "Sunucuları Seç", + "selectAtLeastOneGroup": "Lütfen en az bir grup seçin", + "selectAtLeastOneServer": "Lütfen en az bir sunucu seçin", "skipAuth": "Kimlik Doğrulamayı Atla", "skipAuthDescription": "Arayüz ve API erişimi için giriş gereksinimini atla (Güvenlik için VARSAYILAN KAPALI)", "pythonIndexUrl": "Python Paket Deposu URL'si", diff --git a/locales/zh.json b/locales/zh.json index 430baa3..e88b3d8 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -255,7 +255,11 @@ "type": "类型", "repeated": "可重复", "valueHint": "值提示", - "choices": "可选值" + "choices": "可选值", + "actions": "操作", + "saving": "保存中...", + "active": "已激活", + "inactive": "未激活" }, "nav": { "dashboard": "仪表盘", @@ -289,7 +293,7 @@ "routeConfig": "安全配置", "installConfig": "安装", "smartRouting": "智能路由", - "oauthServer": "OAuth 服务器" + "oauthServer": "OAuth" }, "groups": { "title": "分组管理" @@ -555,6 +559,27 @@ "bearerAuthKey": "Bearer 认证密钥", "bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥", "bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥", + "bearerKeysSectionTitle": "密钥", + "bearerKeysSectionDescription": "管理多条密钥,并为不同密钥配置不同的访问范围。", + "noBearerKeys": "当前还没有配置任何密钥。", + "bearerKeyName": "名称", + "bearerKeyToken": "密钥值", + "bearerKeyEnabled": "启用", + "bearerKeyAccessType": "访问范围", + "bearerKeyAccessAll": "全部", + "bearerKeyAccessGroups": "指定分组", + "bearerKeyAccessServers": "指定服务器", + "bearerKeyAllowedGroups": "允许访问的分组", + "bearerKeyAllowedServers": "允许访问的服务器", + "addBearerKey": "新增密钥", + "addBearerKeyButton": "创建", + "bearerKeyRequired": "名称和密钥值为必填项", + "deleteBearerKeyConfirm": "确定要删除这条密钥吗?", + "generate": "生成", + "selectGroups": "选择分组", + "selectServers": "选择服务器", + "selectAtLeastOneGroup": "请至少选择一个分组", + "selectAtLeastOneServer": "请至少选择一个服务", "skipAuth": "免登录开关", "skipAuthDescription": "跳过前端和 API 访问的登录要求(默认关闭确保安全性)", "pythonIndexUrl": "Python 包仓库地址", diff --git a/src/controllers/bearerKeyController.ts b/src/controllers/bearerKeyController.ts new file mode 100644 index 0000000..9b78b7f --- /dev/null +++ b/src/controllers/bearerKeyController.ts @@ -0,0 +1,169 @@ +import { Request, Response } from 'express'; +import { ApiResponse, BearerKey } from '../types/index.js'; +import { getBearerKeyDao, getSystemConfigDao } from '../dao/index.js'; + +const requireAdmin = async (req: Request, res: Response): Promise => { + const systemConfigDao = getSystemConfigDao(); + const systemConfig = await systemConfigDao.get(); + if (systemConfig?.routing?.skipAuth) { + return true; + } + + const user = (req as any).user; + if (!user || !user.isAdmin) { + res.status(403).json({ + success: false, + message: 'Admin privileges required', + }); + return false; + } + return true; +}; + +export const getBearerKeys = async (req: Request, res: Response): Promise => { + if (!(await requireAdmin(req, res))) return; + + try { + const dao = getBearerKeyDao(); + const keys = await dao.findAll(); + const response: ApiResponse = { + success: true, + data: keys, + }; + res.json(response); + } catch (error) { + console.error('Failed to get bearer keys:', error); + res.status(500).json({ + success: false, + message: 'Failed to get bearer keys', + }); + } +}; + +export const createBearerKey = async (req: Request, res: Response): Promise => { + if (!(await requireAdmin(req, res))) return; + + try { + const { name, token, enabled, accessType, allowedGroups, allowedServers } = + req.body as Partial; + + if (!name || typeof name !== 'string') { + res.status(400).json({ success: false, message: 'Key name is required' }); + return; + } + + if (!token || typeof token !== 'string') { + res.status(400).json({ success: false, message: 'Token value is required' }); + return; + } + + if (!accessType || !['all', 'groups', 'servers'].includes(accessType)) { + res.status(400).json({ success: false, message: 'Invalid accessType' }); + return; + } + + const dao = getBearerKeyDao(); + const key = await dao.create({ + name, + token, + enabled: enabled ?? true, + accessType, + allowedGroups: Array.isArray(allowedGroups) ? allowedGroups : [], + allowedServers: Array.isArray(allowedServers) ? allowedServers : [], + }); + + const response: ApiResponse = { + success: true, + data: key, + }; + res.status(201).json(response); + } catch (error) { + console.error('Failed to create bearer key:', error); + res.status(500).json({ + success: false, + message: 'Failed to create bearer key', + }); + } +}; + +export const updateBearerKey = async (req: Request, res: Response): Promise => { + if (!(await requireAdmin(req, res))) return; + + try { + const { id } = req.params; + if (!id) { + res.status(400).json({ success: false, message: 'Key id is required' }); + return; + } + + const { name, token, enabled, accessType, allowedGroups, allowedServers } = + req.body as Partial; + + const updates: Partial = {}; + if (name !== undefined) updates.name = name; + if (token !== undefined) updates.token = token; + if (enabled !== undefined) updates.enabled = enabled; + if (accessType !== undefined) { + if (!['all', 'groups', 'servers'].includes(accessType)) { + res.status(400).json({ success: false, message: 'Invalid accessType' }); + return; + } + updates.accessType = accessType as BearerKey['accessType']; + } + if (allowedGroups !== undefined) { + updates.allowedGroups = Array.isArray(allowedGroups) ? allowedGroups : []; + } + if (allowedServers !== undefined) { + updates.allowedServers = Array.isArray(allowedServers) ? allowedServers : []; + } + + const dao = getBearerKeyDao(); + const updated = await dao.update(id, updates); + if (!updated) { + res.status(404).json({ success: false, message: 'Bearer key not found' }); + return; + } + + const response: ApiResponse = { + success: true, + data: updated, + }; + res.json(response); + } catch (error) { + console.error('Failed to update bearer key:', error); + res.status(500).json({ + success: false, + message: 'Failed to update bearer key', + }); + } +}; + +export const deleteBearerKey = async (req: Request, res: Response): Promise => { + if (!(await requireAdmin(req, res))) return; + + try { + const { id } = req.params; + if (!id) { + res.status(400).json({ success: false, message: 'Key id is required' }); + return; + } + + const dao = getBearerKeyDao(); + const deleted = await dao.delete(id); + if (!deleted) { + res.status(404).json({ success: false, message: 'Bearer key not found' }); + return; + } + + const response: ApiResponse = { + success: true, + }; + res.json(response); + } catch (error) { + console.error('Failed to delete bearer key:', error); + res.status(500).json({ + success: false, + message: 'Failed to delete bearer key', + }); + } +}; diff --git a/src/controllers/configController.ts b/src/controllers/configController.ts index 1537cd3..12097b2 100644 --- a/src/controllers/configController.ts +++ b/src/controllers/configController.ts @@ -12,6 +12,7 @@ import { getSystemConfigDao, getUserConfigDao, getUserDao, + getBearerKeyDao, } from '../dao/DaoFactory.js'; const dataService: DataService = getDataService(); @@ -137,16 +138,25 @@ export const getMcpSettingsJson = async (req: Request, res: Response): Promise = {}; for (const { name: serverConfigName, ...config } of servers) { @@ -161,6 +171,7 @@ export const getMcpSettingsJson = async (req: Request, res: Response): Promise => { try { @@ -65,12 +66,17 @@ export const getAllSettings = async (_: Request, res: Response): Promise = const systemConfigDao = getSystemConfigDao(); const systemConfig = await systemConfigDao.get(); + // Get bearer auth keys from DAO + const bearerKeyDao = getBearerKeyDao(); + const bearerKeys = await bearerKeyDao.findAll(); + // Merge all data into settings object const settings: McpSettings = { ...fileSettings, mcpServers, groups, systemConfig, + bearerKeys, }; const response: ApiResponse = { diff --git a/src/dao/BearerKeyDao.ts b/src/dao/BearerKeyDao.ts new file mode 100644 index 0000000..56738e2 --- /dev/null +++ b/src/dao/BearerKeyDao.ts @@ -0,0 +1,122 @@ +import { randomUUID } from 'node:crypto'; +import { BearerKey } from '../types/index.js'; +import { JsonFileBaseDao } from './base/JsonFileBaseDao.js'; + +/** + * DAO interface for bearer authentication keys + */ +export interface BearerKeyDao { + findAll(): Promise; + findEnabled(): Promise; + findById(id: string): Promise; + findByToken(token: string): Promise; + create(data: Omit): Promise; + update(id: string, data: Partial>): Promise; + delete(id: string): Promise; +} + +/** + * JSON file-based BearerKey DAO implementation + * Stores keys under the top-level `bearerKeys` field in mcp_settings.json + * and performs one-time migration from legacy routing.enableBearerAuth/bearerAuthKey. + */ +export class BearerKeyDaoImpl extends JsonFileBaseDao implements BearerKeyDao { + private async loadKeysWithMigration(): Promise { + const settings = await this.loadSettings(); + + if (Array.isArray(settings.bearerKeys) && settings.bearerKeys.length > 0) { + return settings.bearerKeys; + } + + // Perform one-time migration from legacy routing config if present + const routing = settings.systemConfig?.routing || {}; + const enableBearerAuth: boolean = !!routing.enableBearerAuth; + const rawKey: string = (routing.bearerAuthKey || '').trim(); + + let migrated: BearerKey[] = []; + + if (rawKey) { + // Cases 2 and 3 in migration rules + migrated = [ + { + id: randomUUID(), + name: 'default', + token: rawKey, + enabled: enableBearerAuth, + accessType: 'all', + allowedGroups: [], + allowedServers: [], + }, + ]; + } + + // Cases 1 and 4 both result in empty keys list + settings.bearerKeys = migrated; + await this.saveSettings(settings); + + return migrated; + } + + private async saveKeys(keys: BearerKey[]): Promise { + const settings = await this.loadSettings(); + settings.bearerKeys = keys; + await this.saveSettings(settings); + } + + async findAll(): Promise { + return await this.loadKeysWithMigration(); + } + + async findEnabled(): Promise { + const keys = await this.loadKeysWithMigration(); + return keys.filter((key) => key.enabled); + } + + async findById(id: string): Promise { + const keys = await this.loadKeysWithMigration(); + return keys.find((key) => key.id === id); + } + + async findByToken(token: string): Promise { + const keys = await this.loadKeysWithMigration(); + return keys.find((key) => key.token === token); + } + + async create(data: Omit): Promise { + const keys = await this.loadKeysWithMigration(); + const newKey: BearerKey = { + id: randomUUID(), + ...data, + }; + keys.push(newKey); + await this.saveKeys(keys); + return newKey; + } + + async update(id: string, data: Partial>): Promise { + const keys = await this.loadKeysWithMigration(); + const index = keys.findIndex((key) => key.id === id); + if (index === -1) { + return null; + } + + const updated: BearerKey = { + ...keys[index], + ...data, + id: keys[index].id, + }; + keys[index] = updated; + await this.saveKeys(keys); + return updated; + } + + async delete(id: string): Promise { + const keys = await this.loadKeysWithMigration(); + const next = keys.filter((key) => key.id !== id); + if (next.length === keys.length) { + return false; + } + await this.saveKeys(next); + return true; + } +} diff --git a/src/dao/BearerKeyDaoDbImpl.ts b/src/dao/BearerKeyDaoDbImpl.ts new file mode 100644 index 0000000..80a7448 --- /dev/null +++ b/src/dao/BearerKeyDaoDbImpl.ts @@ -0,0 +1,77 @@ +import { BearerKeyDao } from './BearerKeyDao.js'; +import { BearerKey as BearerKeyModel } from '../types/index.js'; +import { BearerKeyRepository } from '../db/repositories/BearerKeyRepository.js'; + +/** + * Database-backed implementation of BearerKeyDao + */ +export class BearerKeyDaoDbImpl implements BearerKeyDao { + private repository: BearerKeyRepository; + + constructor() { + this.repository = new BearerKeyRepository(); + } + + private toModel(entity: import('../db/entities/BearerKey.js').BearerKey): BearerKeyModel { + return { + id: entity.id, + name: entity.name, + token: entity.token, + enabled: entity.enabled, + accessType: entity.accessType, + allowedGroups: entity.allowedGroups ?? [], + allowedServers: entity.allowedServers ?? [], + }; + } + + async findAll(): Promise { + const entities = await this.repository.findAll(); + return entities.map((e) => this.toModel(e)); + } + + async findEnabled(): Promise { + const entities = await this.repository.findAll(); + return entities.filter((e) => e.enabled).map((e) => this.toModel(e)); + } + + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + return entity ? this.toModel(entity) : undefined; + } + + async findByToken(token: string): Promise { + const entity = await this.repository.findByToken(token); + return entity ? this.toModel(entity) : undefined; + } + + async create(data: Omit): Promise { + const entity = await this.repository.create({ + name: data.name, + token: data.token, + enabled: data.enabled, + accessType: data.accessType, + allowedGroups: data.allowedGroups ?? [], + allowedServers: data.allowedServers ?? [], + } as any); + return this.toModel(entity as any); + } + + async update( + id: string, + data: Partial>, + ): Promise { + const entity = await this.repository.update(id, { + name: data.name, + token: data.token, + enabled: data.enabled, + accessType: data.accessType, + allowedGroups: data.allowedGroups, + allowedServers: data.allowedServers, + } as any); + return entity ? this.toModel(entity as any) : null; + } + + async delete(id: string): Promise { + return await this.repository.delete(id); + } +} diff --git a/src/dao/DaoFactory.ts b/src/dao/DaoFactory.ts index db9b375..c0ebc0b 100644 --- a/src/dao/DaoFactory.ts +++ b/src/dao/DaoFactory.ts @@ -5,6 +5,7 @@ import { SystemConfigDao, SystemConfigDaoImpl } from './SystemConfigDao.js'; import { UserConfigDao, UserConfigDaoImpl } from './UserConfigDao.js'; import { OAuthClientDao, OAuthClientDaoImpl } from './OAuthClientDao.js'; import { OAuthTokenDao, OAuthTokenDaoImpl } from './OAuthTokenDao.js'; +import { BearerKeyDao, BearerKeyDaoImpl } from './BearerKeyDao.js'; /** * DAO Factory interface for creating DAO instances @@ -17,6 +18,7 @@ export interface DaoFactory { getUserConfigDao(): UserConfigDao; getOAuthClientDao(): OAuthClientDao; getOAuthTokenDao(): OAuthTokenDao; + getBearerKeyDao(): BearerKeyDao; } /** @@ -32,6 +34,7 @@ export class JsonFileDaoFactory implements DaoFactory { private userConfigDao: UserConfigDao | null = null; private oauthClientDao: OAuthClientDao | null = null; private oauthTokenDao: OAuthTokenDao | null = null; + private bearerKeyDao: BearerKeyDao | null = null; /** * Get singleton instance @@ -96,6 +99,13 @@ export class JsonFileDaoFactory implements DaoFactory { return this.oauthTokenDao; } + getBearerKeyDao(): BearerKeyDao { + if (!this.bearerKeyDao) { + this.bearerKeyDao = new BearerKeyDaoImpl(); + } + return this.bearerKeyDao; + } + /** * Reset all cached DAO instances (useful for testing) */ @@ -107,6 +117,7 @@ export class JsonFileDaoFactory implements DaoFactory { this.userConfigDao = null; this.oauthClientDao = null; this.oauthTokenDao = null; + this.bearerKeyDao = null; } } @@ -179,3 +190,7 @@ export function getOAuthClientDao(): OAuthClientDao { export function getOAuthTokenDao(): OAuthTokenDao { return getDaoFactory().getOAuthTokenDao(); } + +export function getBearerKeyDao(): BearerKeyDao { + return getDaoFactory().getBearerKeyDao(); +} diff --git a/src/dao/DatabaseDaoFactory.ts b/src/dao/DatabaseDaoFactory.ts index a2c5510..7ab6c5b 100644 --- a/src/dao/DatabaseDaoFactory.ts +++ b/src/dao/DatabaseDaoFactory.ts @@ -7,6 +7,7 @@ import { UserConfigDao, OAuthClientDao, OAuthTokenDao, + BearerKeyDao, } from './index.js'; import { UserDaoDbImpl } from './UserDaoDbImpl.js'; import { ServerDaoDbImpl } from './ServerDaoDbImpl.js'; @@ -15,6 +16,7 @@ import { SystemConfigDaoDbImpl } from './SystemConfigDaoDbImpl.js'; import { UserConfigDaoDbImpl } from './UserConfigDaoDbImpl.js'; import { OAuthClientDaoDbImpl } from './OAuthClientDaoDbImpl.js'; import { OAuthTokenDaoDbImpl } from './OAuthTokenDaoDbImpl.js'; +import { BearerKeyDaoDbImpl } from './BearerKeyDaoDbImpl.js'; /** * Database-backed DAO factory implementation @@ -29,6 +31,7 @@ export class DatabaseDaoFactory implements DaoFactory { private userConfigDao: UserConfigDao | null = null; private oauthClientDao: OAuthClientDao | null = null; private oauthTokenDao: OAuthTokenDao | null = null; + private bearerKeyDao: BearerKeyDao | null = null; /** * Get singleton instance @@ -93,6 +96,13 @@ export class DatabaseDaoFactory implements DaoFactory { return this.oauthTokenDao!; } + getBearerKeyDao(): BearerKeyDao { + if (!this.bearerKeyDao) { + this.bearerKeyDao = new BearerKeyDaoDbImpl(); + } + return this.bearerKeyDao!; + } + /** * Reset all cached DAO instances (useful for testing) */ @@ -104,5 +114,6 @@ export class DatabaseDaoFactory implements DaoFactory { this.userConfigDao = null; this.oauthClientDao = null; this.oauthTokenDao = null; + this.bearerKeyDao = null; } } diff --git a/src/dao/index.ts b/src/dao/index.ts index 2d4493e..9f7584e 100644 --- a/src/dao/index.ts +++ b/src/dao/index.ts @@ -8,6 +8,7 @@ export * from './SystemConfigDao.js'; export * from './UserConfigDao.js'; export * from './OAuthClientDao.js'; export * from './OAuthTokenDao.js'; +export * from './BearerKeyDao.js'; // Export database implementations export * from './UserDaoDbImpl.js'; @@ -17,6 +18,7 @@ export * from './SystemConfigDaoDbImpl.js'; export * from './UserConfigDaoDbImpl.js'; export * from './OAuthClientDaoDbImpl.js'; export * from './OAuthTokenDaoDbImpl.js'; +export * from './BearerKeyDaoDbImpl.js'; // Export the DAO factory and convenience functions export * from './DaoFactory.js'; diff --git a/src/db/entities/BearerKey.ts b/src/db/entities/BearerKey.ts new file mode 100644 index 0000000..ef09c3a --- /dev/null +++ b/src/db/entities/BearerKey.ts @@ -0,0 +1,43 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +/** + * Bearer authentication key entity + * Stores multiple bearer keys with per-key enable/disable and scoped access control + */ +@Entity({ name: 'bearer_keys' }) +export class BearerKey { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'varchar', length: 512 }) + token: string; + + @Column({ type: 'boolean', default: true }) + enabled: boolean; + + @Column({ type: 'varchar', length: 20, default: 'all' }) + accessType: 'all' | 'groups' | 'servers'; + + @Column({ type: 'simple-json', nullable: true }) + allowedGroups?: string[]; + + @Column({ type: 'simple-json', nullable: true }) + allowedServers?: string[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' }) + updatedAt: Date; +} + +export default BearerKey; diff --git a/src/db/entities/index.ts b/src/db/entities/index.ts index 93f9ab7..34997b3 100644 --- a/src/db/entities/index.ts +++ b/src/db/entities/index.ts @@ -6,6 +6,7 @@ import SystemConfig from './SystemConfig.js'; import UserConfig from './UserConfig.js'; import OAuthClient from './OAuthClient.js'; import OAuthToken from './OAuthToken.js'; +import BearerKey from './BearerKey.js'; // Export all entities export default [ @@ -17,7 +18,18 @@ export default [ UserConfig, OAuthClient, OAuthToken, + BearerKey, ]; // Export individual entities for direct use -export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig, OAuthClient, OAuthToken }; +export { + VectorEmbedding, + User, + Server, + Group, + SystemConfig, + UserConfig, + OAuthClient, + OAuthToken, + BearerKey, +}; diff --git a/src/db/repositories/BearerKeyRepository.ts b/src/db/repositories/BearerKeyRepository.ts new file mode 100644 index 0000000..e2b5278 --- /dev/null +++ b/src/db/repositories/BearerKeyRepository.ts @@ -0,0 +1,75 @@ +import { Repository } from 'typeorm'; +import { BearerKey } from '../entities/BearerKey.js'; +import { getAppDataSource } from '../connection.js'; + +/** + * Repository for BearerKey entity + */ +export class BearerKeyRepository { + private repository: Repository; + + constructor() { + this.repository = getAppDataSource().getRepository(BearerKey); + } + + /** + * Find all bearer keys + */ + async findAll(): Promise { + return await this.repository.find({ order: { createdAt: 'ASC' } }); + } + + /** + * Count bearer keys + */ + async count(): Promise { + return await this.repository.count(); + } + + /** + * Find bearer key by id + */ + async findById(id: string): Promise { + return await this.repository.findOne({ where: { id } }); + } + + /** + * Find bearer key by token value + */ + async findByToken(token: string): Promise { + return await this.repository.findOne({ where: { token } }); + } + + /** + * Create a new bearer key + */ + async create(data: Omit): Promise { + const entity = this.repository.create(data); + return await this.repository.save(entity); + } + + /** + * Update an existing bearer key + */ + async update( + id: string, + updates: Partial>, + ): Promise { + const existing = await this.findById(id); + if (!existing) { + return null; + } + const merged = this.repository.merge(existing, updates); + return await this.repository.save(merged); + } + + /** + * Delete a bearer key + */ + async delete(id: string): Promise { + const result = await this.repository.delete({ id }); + return (result.affected ?? 0) > 0; + } +} + +export default BearerKeyRepository; diff --git a/src/db/repositories/index.ts b/src/db/repositories/index.ts index 5a59b04..3367431 100644 --- a/src/db/repositories/index.ts +++ b/src/db/repositories/index.ts @@ -6,6 +6,7 @@ import { SystemConfigRepository } from './SystemConfigRepository.js'; import { UserConfigRepository } from './UserConfigRepository.js'; import { OAuthClientRepository } from './OAuthClientRepository.js'; import { OAuthTokenRepository } from './OAuthTokenRepository.js'; +import { BearerKeyRepository } from './BearerKeyRepository.js'; // Export all repositories export { @@ -17,4 +18,5 @@ export { UserConfigRepository, OAuthClientRepository, OAuthTokenRepository, + BearerKeyRepository, }; diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index 404ddde..3ec3d55 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -5,9 +5,15 @@ import defaultConfig from '../config/index.js'; import { JWT_SECRET } from '../config/jwt.js'; import { getToken } from '../models/OAuth.js'; import { isOAuthServerEnabled } from '../services/oauthServerService.js'; +import { getBearerKeyDao } from '../dao/index.js'; +import { BearerKey } from '../types/index.js'; -const validateBearerAuth = (req: Request, routingConfig: any): boolean => { - if (!routingConfig.enableBearerAuth) { +const validateBearerAuth = async (req: Request): Promise => { + const bearerKeyDao = getBearerKeyDao(); + const enabledKeys = await bearerKeyDao.findEnabled(); + + // If there are no enabled keys, bearer auth via static keys is disabled + if (enabledKeys.length === 0) { return false; } @@ -16,7 +22,21 @@ const validateBearerAuth = (req: Request, routingConfig: any): boolean => { return false; } - return authHeader.substring(7) === routingConfig.bearerAuthKey; + const token = authHeader.substring(7).trim(); + if (!token) { + return false; + } + + const matchingKey: BearerKey | undefined = enabledKeys.find((key) => key.token === token); + if (!matchingKey) { + console.warn('Bearer auth failed: token did not match any configured bearer key'); + return false; + } + + console.log( + `Bearer auth succeeded with key id=${matchingKey.id}, name=${matchingKey.name}, accessType=${matchingKey.accessType}`, + ); + return true; }; const readonlyAllowPaths = ['/tools/call/']; @@ -47,8 +67,6 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro const routingConfig = loadSettings().systemConfig?.routing || { enableGlobalRoute: true, enableGroupNameRoute: true, - enableBearerAuth: false, - bearerAuthKey: '', skipAuth: false, }; @@ -57,8 +75,8 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro return; } - // Check if bearer auth is enabled and validate it - if (validateBearerAuth(req, routingConfig)) { + // Check if bearer auth via configured keys can validate this request + if (await validateBearerAuth(req)) { next(); return; } diff --git a/src/routes/index.ts b/src/routes/index.ts index 50707a0..8af6e93 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -106,6 +106,12 @@ import { updateClientConfiguration, deleteClientRegistration, } from '../controllers/oauthDynamicRegistrationController.js'; +import { + getBearerKeys, + createBearerKey, + updateBearerKey, + deleteBearerKey, +} from '../controllers/bearerKeyController.js'; import { auth } from '../middlewares/auth.js'; const router = express.Router(); @@ -187,6 +193,12 @@ export const initRoutes = (app: express.Application): void => { router.delete('/oauth/clients/:clientId', deleteClient); router.post('/oauth/clients/:clientId/regenerate-secret', regenerateSecret); + // Bearer authentication key management (admin only) + router.get('/auth/keys', getBearerKeys); + router.post('/auth/keys', createBearerKey); + router.put('/auth/keys/:id', updateBearerKey); + router.delete('/auth/keys/:id', deleteBearerKey); + // Tool management routes router.post('/tools/call/:server', callTool); diff --git a/src/services/groupService.ts b/src/services/groupService.ts index 544ec6a..b5025a8 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -29,9 +29,9 @@ export const getGroupByIdOrName = async (key: string): Promise ({ getSystemConfigDao: jest.fn(() => ({ get: jest.fn().mockImplementation(() => Promise.resolve(currentSystemConfig)), })), + getBearerKeyDao: jest.fn(() => ({ + // Keep these unit tests aligned with legacy routing semantics: + // enableBearerAuth + bearerAuthKey -> one enabled key (token=bearerAuthKey) + // otherwise -> no enabled keys (bearer auth effectively disabled) + findEnabled: jest.fn().mockImplementation(async () => { + const routing = (currentSystemConfig as any)?.routing || {}; + const enabled = !!routing.enableBearerAuth; + const token = String(routing.bearerAuthKey || '').trim(); + if (!enabled || !token) { + return []; + } + return [ + { + id: 'test-key-id', + name: 'default', + token, + enabled: true, + accessType: 'all', + allowedGroups: [], + allowedServers: [], + }, + ]; + }), + })), })); // Mock oauthBearer diff --git a/src/services/sseService.ts b/src/services/sseService.ts index 0219cde..14b00d4 100644 --- a/src/services/sseService.ts +++ b/src/services/sseService.ts @@ -6,10 +6,10 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { deleteMcpServer, getMcpServer } from './mcpService.js'; import config from '../config/index.js'; -import { getSystemConfigDao } from '../dao/index.js'; +import { getBearerKeyDao, getGroupDao, getServerDao, getSystemConfigDao } from '../dao/index.js'; import { UserContextService } from './userContextService.js'; import { RequestContextService } from './requestContextService.js'; -import { IUser } from '../types/index.js'; +import { IUser, BearerKey } from '../types/index.js'; import { resolveOAuthUserFromToken } from '../utils/oauthBearer.js'; export const transports: { @@ -30,40 +30,164 @@ type BearerAuthResult = reason: 'missing' | 'invalid'; }; +/** + * Check if a string is a valid UUID v4 format + */ +const isValidUUID = (str: string): boolean => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(str); +}; + +const isBearerKeyAllowedForRequest = async (req: Request, key: BearerKey): Promise => { + const paramValue = (req.params as any)?.group as string | undefined; + + // accessType 'all' allows all requests + if (key.accessType === 'all') { + return true; + } + + // No parameter value means global route + if (!paramValue) { + // Only accessType 'all' allows global routes + return false; + } + + try { + const groupDao = getGroupDao(); + const serverDao = getServerDao(); + + // Step 1: Try to match as a group (by name or id), since group has higher priority + let matchedGroup = await groupDao.findByName(paramValue); + if (!matchedGroup && isValidUUID(paramValue)) { + // Only try findById if the parameter is a valid UUID + matchedGroup = await groupDao.findById(paramValue); + } + + if (matchedGroup) { + // Matched as a group + if (key.accessType === 'groups') { + // For group-scoped keys, check if the matched group is in allowedGroups + const allowedGroups = key.allowedGroups || []; + return allowedGroups.includes(matchedGroup.name) || allowedGroups.includes(matchedGroup.id); + } + + if (key.accessType === 'servers') { + // For server-scoped keys, check if any server in the group is allowed + const allowedServers = key.allowedServers || []; + if (allowedServers.length === 0) { + return false; + } + + if (!Array.isArray(matchedGroup.servers)) { + return false; + } + + const groupServerNames = matchedGroup.servers.map((server) => + typeof server === 'string' ? server : server.name, + ); + return groupServerNames.some((name) => allowedServers.includes(name)); + } + + // Unknown accessType with matched group + return false; + } + + // Step 2: Not a group, try to match as a server name + const matchedServer = await serverDao.findById(paramValue); + + if (matchedServer) { + // Matched as a server + if (key.accessType === 'groups') { + // For group-scoped keys, server access is not allowed + return false; + } + + if (key.accessType === 'servers') { + // For server-scoped keys, check if the server is in allowedServers + const allowedServers = key.allowedServers || []; + return allowedServers.includes(matchedServer.name); + } + + // Unknown accessType with matched server + return false; + } + + // Step 3: Not a valid group or server, deny access + console.warn( + `Bearer key access denied: parameter '${paramValue}' does not match any group or server`, + ); + return false; + } catch (error) { + console.error('Error checking bearer key request access:', error); + return false; + } +}; + const validateBearerAuth = async (req: Request): Promise => { - const systemConfigDao = getSystemConfigDao(); - const systemConfig = await systemConfigDao.get(); - const routingConfig = systemConfig?.routing || { - enableGlobalRoute: true, - enableGroupNameRoute: true, - enableBearerAuth: false, - bearerAuthKey: '', - }; + const bearerKeyDao = getBearerKeyDao(); + const enabledKeys = await bearerKeyDao.findEnabled(); - if (routingConfig.enableBearerAuth) { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return { valid: false, reason: 'missing' }; + const authHeader = req.headers.authorization; + const hasBearerHeader = !!authHeader && authHeader.startsWith('Bearer '); + + // If no enabled keys are configured, bearer auth is effectively disabled. + // We still allow OAuth bearer tokens to attach user context in this case. + if (enabledKeys.length === 0) { + if (!hasBearerHeader) { + return { valid: true }; } - const token = authHeader.substring(7); // Remove "Bearer " prefix - if (token.trim().length === 0) { - return { valid: false, reason: 'missing' }; - } - - if (token === routingConfig.bearerAuthKey) { + const token = authHeader!.substring(7).trim(); + if (!token) { return { valid: true }; } const oauthUser = await resolveOAuthUserFromToken(token); if (oauthUser) { + console.log('Authenticated request using OAuth bearer token without configured keys'); return { valid: true, user: oauthUser }; } - return { valid: false, reason: 'invalid' }; + // When there are no keys, a non-OAuth bearer token should not block access + return { valid: true }; } - return { valid: true }; + // When keys exist, bearer header is required + if (!hasBearerHeader) { + return { valid: false, reason: 'missing' }; + } + + const token = authHeader!.substring(7).trim(); + if (!token) { + return { valid: false, reason: 'missing' }; + } + + // First, try to match a configured bearer key + const matchingKey = enabledKeys.find((key) => key.token === token); + if (matchingKey) { + const allowed = await isBearerKeyAllowedForRequest(req, matchingKey); + if (!allowed) { + console.warn( + `Bearer key rejected due to scope restrictions: id=${matchingKey.id}, name=${matchingKey.name}, accessType=${matchingKey.accessType}`, + ); + return { valid: false, reason: 'invalid' }; + } + + console.log( + `Bearer key authenticated: id=${matchingKey.id}, name=${matchingKey.name}, accessType=${matchingKey.accessType}`, + ); + return { valid: true }; + } + + // Fallback: treat token as potential OAuth access token + const oauthUser = await resolveOAuthUserFromToken(token); + if (oauthUser) { + console.log('Authenticated request using OAuth bearer token (no matching static key)'); + return { valid: true, user: oauthUser }; + } + + console.warn('Bearer authentication failed: token did not match any key or OAuth user'); + return { valid: false, reason: 'invalid' }; }; const attachUserContextFromBearer = (result: BearerAuthResult, res: Response): void => { @@ -398,9 +522,9 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise // Get filtered settings based on user context (after setting user context) const systemConfigDao = getSystemConfigDao(); const systemConfig = await systemConfigDao.get(); - const routingConfig = systemConfig?.routing || { - enableGlobalRoute: true, - enableGroupNameRoute: true, + const routingConfig = { + enableGlobalRoute: systemConfig?.routing?.enableGlobalRoute ?? true, + enableGroupNameRoute: systemConfig?.routing?.enableGroupNameRoute ?? true, }; if (!group && !routingConfig.enableGlobalRoute) { res.status(403).send('Global routes are disabled. Please specify a group ID.'); diff --git a/src/types/index.ts b/src/types/index.ts index 8c1794f..495d34a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -243,6 +243,19 @@ export interface OAuthServerConfig { }; } +// Bearer authentication key configuration +export type BearerKeyAccessType = 'all' | 'groups' | 'servers'; + +export interface BearerKey { + id: string; // Unique identifier for the key + name: string; // Human readable key name + token: string; // Bearer token value + enabled: boolean; // Whether this key is enabled + accessType: BearerKeyAccessType; // Access scope type + allowedGroups?: string[]; // Allowed group names when accessType === 'groups' + allowedServers?: string[]; // Allowed server names when accessType === 'servers' +} + // Represents the settings for MCP servers export interface McpSettings { users?: IUser[]; // Array of user credentials and permissions @@ -254,6 +267,7 @@ export interface McpSettings { userConfigs?: Record; // User-specific configurations oauthClients?: IOAuthClient[]; // OAuth clients for MCPHub's authorization server oauthTokens?: IOAuthToken[]; // Persisted OAuth tokens (access + refresh) for authorization server + bearerKeys?: BearerKey[]; // Bearer authentication keys (multi-key configuration) } // Configuration details for an individual server diff --git a/src/utils/migration.test.ts b/src/utils/migration.test.ts new file mode 100644 index 0000000..35b228a --- /dev/null +++ b/src/utils/migration.test.ts @@ -0,0 +1,122 @@ +import { jest } from '@jest/globals'; + +// Mocks must be defined before importing the module under test. + +const initializeDatabaseMock = jest.fn(async () => undefined); +jest.mock('../db/connection.js', () => ({ + initializeDatabase: initializeDatabaseMock, +})); + +const setDaoFactoryMock = jest.fn(); +jest.mock('../dao/DaoFactory.js', () => ({ + setDaoFactory: setDaoFactoryMock, +})); + +jest.mock('../dao/DatabaseDaoFactory.js', () => ({ + DatabaseDaoFactory: { + getInstance: jest.fn(() => ({ + /* noop */ + })), + }, +})); + +const loadOriginalSettingsMock = jest.fn(() => ({ users: [] })); +jest.mock('../config/index.js', () => ({ + loadOriginalSettings: loadOriginalSettingsMock, +})); + +const userRepoCountMock = jest.fn<() => Promise>(); +jest.mock('../db/repositories/UserRepository.js', () => ({ + UserRepository: jest.fn().mockImplementation(() => ({ + count: userRepoCountMock, + })), +})); + +const bearerKeyCountMock = jest.fn<() => Promise>(); +const bearerKeyCreateMock = + jest.fn< + (data: { + name: string; + token: string; + enabled: boolean; + accessType: string; + allowedGroups: string[]; + allowedServers: string[]; + }) => Promise + >(); +jest.mock('../db/repositories/BearerKeyRepository.js', () => ({ + BearerKeyRepository: jest.fn().mockImplementation(() => ({ + count: bearerKeyCountMock, + create: bearerKeyCreateMock, + })), +})); + +const systemConfigGetMock = jest.fn<() => Promise>(); +jest.mock('../db/repositories/SystemConfigRepository.js', () => ({ + SystemConfigRepository: jest.fn().mockImplementation(() => ({ + get: systemConfigGetMock, + })), +})); + +describe('initializeDatabaseMode legacy bearer auth migration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('skips legacy migration when bearerKeys table already has data', async () => { + userRepoCountMock.mockResolvedValue(1); + bearerKeyCountMock.mockResolvedValue(2); + systemConfigGetMock.mockResolvedValue({ + routing: { enableBearerAuth: true, bearerAuthKey: 'db-key' }, + }); + + const { initializeDatabaseMode } = await import('./migration.js'); + const ok = await initializeDatabaseMode(); + + expect(ok).toBe(true); + expect(initializeDatabaseMock).toHaveBeenCalled(); + expect(loadOriginalSettingsMock).not.toHaveBeenCalled(); + expect(systemConfigGetMock).not.toHaveBeenCalled(); + expect(bearerKeyCreateMock).not.toHaveBeenCalled(); + }); + + it('migrates legacy routing bearerAuthKey into bearerKeys when users exist and keys table is empty', async () => { + userRepoCountMock.mockResolvedValue(3); + bearerKeyCountMock.mockResolvedValue(0); + systemConfigGetMock.mockResolvedValue({ + routing: { enableBearerAuth: true, bearerAuthKey: 'db-key' }, + }); + + const { initializeDatabaseMode } = await import('./migration.js'); + const ok = await initializeDatabaseMode(); + + expect(ok).toBe(true); + expect(loadOriginalSettingsMock).not.toHaveBeenCalled(); + expect(systemConfigGetMock).toHaveBeenCalledTimes(1); + expect(bearerKeyCreateMock).toHaveBeenCalledTimes(1); + expect(bearerKeyCreateMock).toHaveBeenCalledWith({ + name: 'default', + token: 'db-key', + enabled: true, + accessType: 'all', + allowedGroups: [], + allowedServers: [], + }); + }); + + it('does not migrate when routing has no bearerAuthKey', async () => { + userRepoCountMock.mockResolvedValue(1); + bearerKeyCountMock.mockResolvedValue(0); + systemConfigGetMock.mockResolvedValue({ + routing: { enableBearerAuth: true, bearerAuthKey: ' ' }, + }); + + const { initializeDatabaseMode } = await import('./migration.js'); + const ok = await initializeDatabaseMode(); + + expect(ok).toBe(true); + expect(loadOriginalSettingsMock).not.toHaveBeenCalled(); + expect(systemConfigGetMock).toHaveBeenCalledTimes(1); + expect(bearerKeyCreateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/migration.ts b/src/utils/migration.ts index 9fc1c01..4e059eb 100644 --- a/src/utils/migration.ts +++ b/src/utils/migration.ts @@ -9,6 +9,7 @@ import { SystemConfigRepository } from '../db/repositories/SystemConfigRepositor import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js'; import { OAuthClientRepository } from '../db/repositories/OAuthClientRepository.js'; import { OAuthTokenRepository } from '../db/repositories/OAuthTokenRepository.js'; +import { BearerKeyRepository } from '../db/repositories/BearerKeyRepository.js'; /** * Migrate from file-based configuration to database @@ -33,6 +34,7 @@ export async function migrateToDatabase(): Promise { const userConfigRepo = new UserConfigRepository(); const oauthClientRepo = new OAuthClientRepository(); const oauthTokenRepo = new OAuthTokenRepository(); + const bearerKeyRepo = new BearerKeyRepository(); // Migrate users if (settings.users && settings.users.length > 0) { @@ -120,6 +122,52 @@ export async function migrateToDatabase(): Promise { console.log(' - System configuration updated'); } + // Migrate bearer auth keys + console.log('Migrating bearer authentication keys...'); + + // Prefer explicit bearerKeys if present in settings + if (Array.isArray(settings.bearerKeys) && settings.bearerKeys.length > 0) { + for (const key of settings.bearerKeys) { + await bearerKeyRepo.create({ + name: key.name, + token: key.token, + enabled: key.enabled, + accessType: key.accessType, + allowedGroups: key.allowedGroups ?? [], + allowedServers: key.allowedServers ?? [], + } as any); + console.log(` - Migrated bearer key: ${key.name} (${key.id ?? 'no-id'})`); + } + } else if (settings.systemConfig?.routing) { + // Fallback to legacy routing.enableBearerAuth / bearerAuthKey + const routing = settings.systemConfig.routing as any; + const enableBearerAuth: boolean = !!routing.enableBearerAuth; + const rawKey: string = (routing.bearerAuthKey || '').trim(); + + // Migration rules: + // 1) enable=false, key empty -> no keys + // 2) enable=false, key present -> one disabled key (name=default) + // 3) enable=true, key present -> one enabled key (name=default) + // 4) enable=true, key empty -> no keys + if (rawKey) { + await bearerKeyRepo.create({ + name: 'default', + token: rawKey, + enabled: enableBearerAuth, + accessType: 'all', + allowedGroups: [], + allowedServers: [], + } as any); + console.log( + ` - Migrated legacy bearer auth config to key: default (enabled=${enableBearerAuth})`, + ); + } else { + console.log(' - No legacy bearer auth key found, skipping bearer key migration'); + } + } else { + console.log(' - No bearer auth configuration found, skipping bearer key migration'); + } + // Migrate user configs if (settings.userConfigs) { const usernames = Object.keys(settings.userConfigs); @@ -207,6 +255,9 @@ export async function initializeDatabaseMode(): Promise { // Check if migration is needed const userRepo = new UserRepository(); + const bearerKeyRepo = new BearerKeyRepository(); + const systemConfigRepo = new SystemConfigRepository(); + const userCount = await userRepo.count(); if (userCount === 0) { @@ -217,6 +268,36 @@ export async function initializeDatabaseMode(): Promise { } } else { console.log(`Database already contains ${userCount} users, skipping migration`); + + // One-time migration for legacy bearer auth config stored inside DB routing settings. + // If bearerKeys table already has data, do nothing. + const bearerKeyCount = await bearerKeyRepo.count(); + if (bearerKeyCount > 0) { + console.log( + `Bearer keys table already contains ${bearerKeyCount} keys, skipping legacy bearer auth migration`, + ); + } else { + const systemConfig = await systemConfigRepo.get(); + const routing = (systemConfig as any)?.routing || {}; + const enableBearerAuth: boolean = !!routing.enableBearerAuth; + const rawKey: string = (routing.bearerAuthKey || '').trim(); + + if (rawKey) { + await bearerKeyRepo.create({ + name: 'default', + token: rawKey, + enabled: enableBearerAuth, + accessType: 'all', + allowedGroups: [], + allowedServers: [], + } as any); + console.log( + ` - Migrated legacy DB routing bearer auth config to key: default (enabled=${enableBearerAuth})`, + ); + } else { + console.log('No legacy DB routing bearer auth key found, skipping bearer key migration'); + } + } } console.log('✅ Database mode initialized successfully'); diff --git a/tests/controllers/configController.test.ts b/tests/controllers/configController.test.ts index f219ec8..0fd423b 100644 --- a/tests/controllers/configController.test.ts +++ b/tests/controllers/configController.test.ts @@ -2,7 +2,6 @@ import { getMcpSettingsJson } from '../../src/controllers/configController.js'; import * as DaoFactory from '../../src/dao/DaoFactory.js'; import { Request, Response } from 'express'; -// Mock the DaoFactory module jest.mock('../../src/dao/DaoFactory.js'); describe('ConfigController - getMcpSettingsJson', () => { @@ -17,6 +16,7 @@ describe('ConfigController - getMcpSettingsJson', () => { let mockUserConfigDao: { getAll: jest.Mock }; let mockOAuthClientDao: { findAll: jest.Mock }; let mockOAuthTokenDao: { findAll: jest.Mock }; + let mockBearerKeyDao: { findAll: jest.Mock }; beforeEach(() => { jest.clearAllMocks(); @@ -30,6 +30,7 @@ describe('ConfigController - getMcpSettingsJson', () => { json: mockJson, status: mockStatus, }; + mockServerDao = { findById: jest.fn(), findAll: jest.fn(), @@ -40,68 +41,17 @@ describe('ConfigController - getMcpSettingsJson', () => { mockUserConfigDao = { getAll: jest.fn() }; mockOAuthClientDao = { findAll: jest.fn() }; mockOAuthTokenDao = { findAll: jest.fn() }; + mockBearerKeyDao = { findAll: jest.fn() }; - // Setup ServerDao mock - (DaoFactory.getServerDao as jest.Mock).mockReturnValue(mockServerDao); - (DaoFactory.getUserDao as jest.Mock).mockReturnValue(mockUserDao); - (DaoFactory.getGroupDao as jest.Mock).mockReturnValue(mockGroupDao); - (DaoFactory.getSystemConfigDao as jest.Mock).mockReturnValue(mockSystemConfigDao); - (DaoFactory.getUserConfigDao as jest.Mock).mockReturnValue(mockUserConfigDao); - (DaoFactory.getOAuthClientDao as jest.Mock).mockReturnValue(mockOAuthClientDao); - (DaoFactory.getOAuthTokenDao as jest.Mock).mockReturnValue(mockOAuthTokenDao); - }); - - describe('Full Settings Export', () => { - it('should return settings aggregated from DAOs', async () => { - mockServerDao.findAll.mockResolvedValue([ - { name: 'server-a', command: 'node', args: ['index.js'], env: { A: '1' } }, - { name: 'server-b', command: 'npx', args: ['run'], env: null }, - ]); - mockUserDao.findAll.mockResolvedValue([ - { username: 'admin', password: 'hash', isAdmin: true }, - ]); - mockGroupDao.findAll.mockResolvedValue([{ id: 'g1', name: 'Group', servers: [] }]); - mockSystemConfigDao.get.mockResolvedValue({ routing: { skipAuth: false } }); - mockUserConfigDao.getAll.mockResolvedValue({ admin: { routing: {} } }); - mockOAuthClientDao.findAll.mockResolvedValue([ - { clientId: 'c1', clientSecret: 's', name: 'client' }, - ]); - mockOAuthTokenDao.findAll.mockResolvedValue([ - { - accessToken: 'a', - accessTokenExpiresAt: new Date('2024-01-01T00:00:00Z'), - clientId: 'c1', - username: 'admin', - }, - ]); - - await getMcpSettingsJson(mockRequest as Request, mockResponse as Response); - - expect(mockServerDao.findAll).toHaveBeenCalled(); - expect(mockUserDao.findAll).toHaveBeenCalled(); - expect(mockJson).toHaveBeenCalledWith({ - success: true, - data: { - mcpServers: { - 'server-a': { command: 'node', args: ['index.js'], env: { A: '1' } }, - 'server-b': { command: 'npx', args: ['run'] }, - }, - users: [{ username: 'admin', password: 'hash', isAdmin: true }], - groups: [{ id: 'g1', name: 'Group', servers: [] }], - systemConfig: { routing: { skipAuth: false } }, - userConfigs: { admin: { routing: {} } }, - oauthClients: [{ clientId: 'c1', clientSecret: 's', name: 'client' }], - oauthTokens: [ - { - accessToken: 'a', - accessTokenExpiresAt: new Date('2024-01-01T00:00:00Z'), - clientId: 'c1', - username: 'admin', - }, - ], - }, - }); - }); + // Wire DaoFactory convenience functions to our mocks + (DaoFactory.getServerDao as unknown as jest.Mock).mockReturnValue(mockServerDao); + (DaoFactory.getUserDao as unknown as jest.Mock).mockReturnValue(mockUserDao); + (DaoFactory.getGroupDao as unknown as jest.Mock).mockReturnValue(mockGroupDao); + (DaoFactory.getSystemConfigDao as unknown as jest.Mock).mockReturnValue(mockSystemConfigDao); + (DaoFactory.getUserConfigDao as unknown as jest.Mock).mockReturnValue(mockUserConfigDao); + (DaoFactory.getOAuthClientDao as unknown as jest.Mock).mockReturnValue(mockOAuthClientDao); + (DaoFactory.getOAuthTokenDao as unknown as jest.Mock).mockReturnValue(mockOAuthTokenDao); + (DaoFactory.getBearerKeyDao as unknown as jest.Mock).mockReturnValue(mockBearerKeyDao); }); describe('Individual Server Export', () => { @@ -196,6 +146,7 @@ describe('ConfigController - getMcpSettingsJson', () => { mockUserConfigDao.getAll.mockResolvedValue({}); mockOAuthClientDao.findAll.mockResolvedValue([]); mockOAuthTokenDao.findAll.mockResolvedValue([]); + mockBearerKeyDao.findAll.mockResolvedValue([]); await getMcpSettingsJson(mockRequest as Request, mockResponse as Response); diff --git a/tests/services/keepalive.test.ts b/tests/services/keepalive.test.ts index 0c8f958..f2d22b6 100644 --- a/tests/services/keepalive.test.ts +++ b/tests/services/keepalive.test.ts @@ -31,14 +31,28 @@ jest.mock('../../src/utils/oauthBearer.js', () => ({ resolveOAuthUserFromToken: jest.fn(), })); +// Mock DAO accessors used by sseService (avoid file-based DAOs and migrations) +jest.mock('../../src/dao/index.js', () => ({ + getBearerKeyDao: jest.fn(), + getGroupDao: jest.fn(), + getSystemConfigDao: jest.fn(), +})); + +// Mock config module default export used by sseService +jest.mock('../../src/config/index.js', () => ({ + __esModule: true, + default: { basePath: '' }, + loadSettings: jest.fn(), +})); + import { Request, Response } from 'express'; import { handleSseConnection, transports } from '../../src/services/sseService.js'; import * as mcpService from '../../src/services/mcpService.js'; import * as configModule from '../../src/config/index.js'; +import * as daoIndex from '../../src/dao/index.js'; // Mock remaining dependencies jest.mock('../../src/services/mcpService.js'); -jest.mock('../../src/config/index.js'); // Mock UserContextService with getInstance pattern const mockUserContextService = { @@ -141,6 +155,24 @@ describe('Keepalive Functionality', () => { }; (mcpService.getMcpServer as jest.Mock).mockReturnValue(mockMcpServer); + // Mock bearer key + system config DAOs used by sseService + const mockBearerKeyDao = { + findEnabled: jest.fn().mockResolvedValue([]), + }; + (daoIndex.getBearerKeyDao as unknown as jest.Mock).mockReturnValue(mockBearerKeyDao); + + const mockSystemConfigDao = { + get: jest.fn().mockResolvedValue({ + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: false, + bearerAuthKey: '', + }, + }), + }; + (daoIndex.getSystemConfigDao as unknown as jest.Mock).mockReturnValue(mockSystemConfigDao); + // Mock loadSettings (configModule.loadSettings as jest.Mock).mockReturnValue({ systemConfig: {