Compare commits

..

12 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
f63c61db65 fix: Address code review feedback for OAuth SSO
- Add proper lifecycle management for state cleanup interval
- Fix host header injection vulnerability by validating forwarded headers
- Add type safety for GitHub API responses
- Add stopStateCleanup function for test cleanup
- Document scaling limitations of in-memory state store

Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-31 15:23:14 +00:00
copilot-swe-agent[bot]
7f1e4d5de1 feat: Add OAuth 2.0 / OIDC SSO login support
- Add OAuth SSO provider configuration types (OAuthSsoProviderConfig, OAuthSsoConfig)
- Create OAuth SSO service with support for Google, Microsoft, GitHub, and custom OIDC providers
- Implement OAuth SSO controller with endpoints for SSO configuration, login initiation, and callback handling
- Add routes for /api/auth/sso/* endpoints
- Update User entity and DAOs to support OAuth-linked accounts (oauthProvider, oauthSubject, email, displayName, avatarUrl)
- Update SystemConfig entity to include oauthSso field
- Update migration utility to handle OAuth SSO configuration and user fields
- Add OAuth callback page for frontend token handling
- Update LoginPage with SSO provider buttons and hybrid auth support
- Add i18n translations for OAuth SSO (English and Chinese)
- Add comprehensive tests for OAuth SSO service (13 new tests)

Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-31 15:17:07 +00:00
copilot-swe-agent[bot]
9319ea47e6 Initial plan 2025-12-31 14:57:01 +00:00
samanhappy
8ae542bdab Add server renaming functionality (#533) 2025-12-30 18:45:33 +08:00
samanhappy
88ce94b988 docs: update and expand MCPHub development guide and agent instructions (#532) 2025-12-28 14:25:28 +08:00
samanhappy
7cc330e721 fix: ensure database is initialized before saving tool embeddings (#531) 2025-12-28 12:19:21 +08:00
Copilot
ab338e80a7 Add custom access type for bearer keys to support combined group and server scoping (#530)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-12-27 16:16:50 +08:00
cheezmil
b00e1c81fc fix: Found 1 error in src/services/keepAliveService.ts:51 #521 (#522)
Co-authored-by: cheestard <134115886+cheestard@users.noreply.github.com>
2025-12-25 09:15:07 +08:00
samanhappy
33eae50bd3 Refactor smart routing configuration and async database handling (#519) 2025-12-20 12:16:09 +08:00
samanhappy
eb1a965e45 feat: add authentication status listener to refresh settings on user login (#518) 2025-12-17 18:34:07 +08:00
samanhappy
97114dcabb feat: implement batch saving for smart routing configuration (#517) 2025-12-17 15:26:53 +08:00
samanhappy
350a022ea3 feat: enhance login error handling and add server unavailable message (#516) 2025-12-17 13:24:07 +08:00
42 changed files with 2473 additions and 604 deletions

View File

@@ -1,272 +0,0 @@
# MCPHub Coding Instructions
**ALWAYS follow these instructions first and only fallback to additional search and context gathering if the information here is incomplete or found to be in error.**
## Project Overview
MCPHub is a TypeScript/Node.js MCP (Model Context Protocol) server management hub that provides unified access through HTTP endpoints. It serves as a centralized dashboard for managing multiple MCP servers with real-time monitoring, authentication, and flexible routing.
**Core Components:**
- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`)
- **Frontend**: React/Vite + Tailwind CSS (`frontend/`)
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
- **Authentication**: JWT-based with bcrypt password hashing
- **Configuration**: JSON-based MCP server definitions (`mcp_settings.json`)
- **Documentation**: API docs and usage instructions(`docs/`)
## Working Effectively
### Bootstrap and Setup (CRITICAL - Follow Exact Steps)
```bash
# Install pnpm if not available
npm install -g pnpm
# Install dependencies - takes ~30 seconds
pnpm install
# Setup environment (optional)
cp .env.example .env
# Build and test to verify setup
pnpm lint # ~3 seconds - NEVER CANCEL
pnpm backend:build # ~5 seconds - NEVER CANCEL
pnpm test:ci # ~16 seconds - NEVER CANCEL. Set timeout to 60+ seconds
pnpm frontend:build # ~5 seconds - NEVER CANCEL
pnpm build # ~10 seconds total - NEVER CANCEL. Set timeout to 60+ seconds
```
**CRITICAL TIMING**: These commands are fast but NEVER CANCEL them. Always wait for completion.
### Development Environment
```bash
# Start both backend and frontend (recommended for most development)
pnpm dev # Backend on :3001, Frontend on :5173
# OR start separately (required on Windows, optional on Linux/macOS)
# Terminal 1: Backend only
pnpm backend:dev # Runs on port 3000 (or PORT env var)
# Terminal 2: Frontend only
pnpm frontend:dev # Runs on port 5173, proxies API to backend
```
**NEVER CANCEL**: Development servers may take 10-15 seconds to fully initialize all MCP servers.
### Build Commands (Production)
```bash
# Full production build - takes ~10 seconds total
pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
# Individual builds
pnpm backend:build # TypeScript compilation - ~5 seconds
pnpm frontend:build # Vite build - ~5 seconds
# Start production server
pnpm start # Requires dist/ and frontend/dist/ to exist
```
### Testing and Validation
```bash
# Run all tests - takes ~16 seconds with 73 tests
pnpm test:ci # NEVER CANCEL - Set timeout to 60+ seconds
# Development testing
pnpm test # Interactive mode
pnpm test:watch # Watch mode for development
pnpm test:coverage # With coverage report
# Code quality
pnpm lint # ESLint - ~3 seconds
pnpm format # Prettier formatting - ~3 seconds
```
**CRITICAL**: All tests MUST pass before committing. Do not modify tests to make them pass unless specifically required for your changes.
## Manual Validation Requirements
**ALWAYS perform these validation steps after making changes:**
### 1. Basic Application Functionality
```bash
# Start the application
pnpm dev
# Verify backend responds (in another terminal)
curl http://localhost:3000/api/health
# Expected: Should return health status
# Verify frontend serves
curl -I http://localhost:3000/
# Expected: HTTP 200 OK with HTML content
```
### 2. MCP Server Integration Test
```bash
# Check MCP servers are loading (look for log messages)
# Expected log output should include:
# - "Successfully connected client for server: [name]"
# - "Successfully listed [N] tools for server: [name]"
# - Some servers may fail due to missing API keys (normal in dev)
```
### 3. Build Verification
```bash
# Verify production build works
pnpm build
node scripts/verify-dist.js
# Expected: "✅ Verification passed! Frontend and backend dist files are present."
```
**NEVER skip these validation steps**. If any fail, debug and fix before proceeding.
## Project Structure and Key Files
### Critical Backend Files
- `src/index.ts` - Application entry point
- `src/server.ts` - Express server setup and middleware
- `src/services/mcpService.ts` - **Core MCP server management logic**
- `src/config/index.ts` - Configuration management
- `src/routes/` - HTTP route definitions
- `src/controllers/` - HTTP request handlers
- `src/dao/` - Data access layer (supports JSON file & PostgreSQL)
- `src/db/` - TypeORM entities & repositories (for PostgreSQL mode)
- `src/types/index.ts` - TypeScript type definitions
### DAO Layer (Dual Data Source)
MCPHub supports **JSON file** (default) and **PostgreSQL** storage:
- Set `USE_DB=true` + `DB_URL=postgresql://...` to use database
- When modifying data structures, update: `src/types/`, `src/dao/`, `src/db/entities/`, `src/db/repositories/`, `src/utils/migration.ts`
- See `AGENTS.md` for detailed DAO modification checklist
### Critical Frontend Files
- `frontend/src/` - React application source
- `frontend/src/pages/` - Page components (development entry point)
- `frontend/src/components/` - Reusable UI components
- `frontend/src/utils/fetchInterceptor.js` - Backend API interaction
### Configuration Files
- `mcp_settings.json` - **MCP server definitions and user accounts**
- `package.json` - Dependencies and scripts
- `tsconfig.json` - TypeScript configuration
- `jest.config.cjs` - Test configuration
- `.eslintrc.json` - Linting rules
### Docker and Deployment
- `Dockerfile` - Multi-stage build with Python base + Node.js
- `entrypoint.sh` - Docker startup script
- `bin/cli.js` - NPM package CLI entry point
## Development Process and Conventions
### Code Style Requirements
- **ESM modules**: Always use `.js` extensions in imports, not `.ts`
- **English only**: All code comments must be written in English
- **TypeScript strict**: Follow strict type checking rules
- **Import style**: `import { something } from './file.js'` (note .js extension)
### Key Configuration Notes
- **MCP servers**: Defined in `mcp_settings.json` with command/args
- **Endpoints**: `/mcp/{group|server}` and `/mcp/$smart` for routing
- **i18n**: Frontend uses react-i18next with files in `locales/` folder
- **Authentication**: JWT tokens with bcrypt password hashing
- **Default credentials**: admin/admin123 (configured in mcp_settings.json)
### Development Entry Points
- **Add MCP server**: Modify `mcp_settings.json` and restart
- **New API endpoint**: Add route in `src/routes/`, controller in `src/controllers/`
- **Frontend feature**: Start from `frontend/src/pages/` or `frontend/src/components/`
- **Add tests**: Follow patterns in `tests/` directory
### Common Development Tasks
#### Adding a new MCP server:
1. Add server definition to `mcp_settings.json`
2. Restart backend to load new server
3. Check logs for successful connection
4. Test via dashboard or API endpoints
#### API development:
1. Define route in `src/routes/`
2. Implement controller in `src/controllers/`
3. Add types in `src/types/index.ts` if needed
4. Write tests in `tests/controllers/`
#### Frontend development:
1. Create/modify components in `frontend/src/components/`
2. Add pages in `frontend/src/pages/`
3. Update routing if needed
4. Test in development mode with `pnpm frontend:dev`
#### Documentation:
1. Update or add docs in `docs/` folder
2. Ensure README.md reflects any major changes
## Validation and CI Requirements
### Before Committing - ALWAYS Run:
```bash
pnpm lint # Must pass - ~3 seconds
pnpm backend:build # Must compile - ~5 seconds
pnpm test:ci # All tests must pass - ~16 seconds
pnpm build # Full build must work - ~10 seconds
```
**CRITICAL**: CI will fail if any of these commands fail. Fix issues locally first.
### CI Pipeline (.github/workflows/ci.yml)
- Runs on Node.js 20.x
- Tests: linting, type checking, unit tests with coverage
- **NEVER CANCEL**: CI builds may take 2-3 minutes total
## Troubleshooting
### Common Issues
- **"uvx command not found"**: Some MCP servers require `uvx` (Python package manager) - this is expected in development
- **Port already in use**: Change PORT environment variable or kill existing processes
- **Frontend not loading**: Ensure frontend was built with `pnpm frontend:build`
- **MCP server connection failed**: Check server command/args in `mcp_settings.json`
### Build Failures
- **TypeScript errors**: Run `pnpm backend:build` to see compilation errors
- **Test failures**: Run `pnpm test:verbose` for detailed test output
- **Lint errors**: Run `pnpm lint` and fix reported issues
### Development Issues
- **Backend not starting**: Check for port conflicts, verify `mcp_settings.json` syntax
- **Frontend proxy errors**: Ensure backend is running before starting frontend
- **Hot reload not working**: Restart development server
## Performance Notes
- **Install time**: pnpm install takes ~30 seconds
- **Build time**: Full build takes ~10 seconds
- **Test time**: Complete test suite takes ~16 seconds
- **Startup time**: Backend initialization takes 10-15 seconds (MCP server connections)
**Remember**: NEVER CANCEL any build or test commands. Always wait for completion even if they seem slow.

374
AGENTS.md
View File

@@ -1,26 +1,214 @@
# Repository Guidelines
# MCPHub Development Guide & Agent Instructions
These notes align current contributors around the code layout, daily commands, and collaboration habits that keep `@samanhappy/mcphub` moving quickly.
**ALWAYS follow these instructions first and only fallback to additional search and context gathering if the information here is incomplete or found to be in error.**
This document serves as the primary reference for all contributors and AI agents working on `@samanhappy/mcphub`. It provides comprehensive guidance on code organization, development workflow, and project conventions.
## Project Overview
MCPHub is a TypeScript/Node.js MCP (Model Context Protocol) server management hub that provides unified access through HTTP endpoints. It serves as a centralized dashboard for managing multiple MCP servers with real-time monitoring, authentication, and flexible routing.
**Core Components:**
- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`)
- **Frontend**: React/Vite + Tailwind CSS (`frontend/`)
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
- **Authentication**: JWT-based with bcrypt password hashing
- **Configuration**: JSON-based MCP server definitions (`mcp_settings.json`)
- **Documentation**: API docs and usage instructions(`docs/`)
## Bootstrap and Setup (CRITICAL - Follow Exact Steps)
```bash
# Install pnpm if not available
npm install -g pnpm
# Install dependencies - takes ~30 seconds
pnpm install
# Setup environment (optional)
cp .env.example .env
# Build and test to verify setup
pnpm lint # ~3 seconds - NEVER CANCEL
pnpm backend:build # ~5 seconds - NEVER CANCEL
pnpm test:ci # ~16 seconds - NEVER CANCEL. Set timeout to 60+ seconds
pnpm frontend:build # ~5 seconds - NEVER CANCEL
pnpm build # ~10 seconds total - NEVER CANCEL. Set timeout to 60+ seconds
```
**CRITICAL TIMING**: These commands are fast but NEVER CANCEL them. Always wait for completion.
## Manual Validation Requirements
**ALWAYS perform these validation steps after making changes:**
### 1. Basic Application Functionality
```bash
# Start the application
pnpm dev
# Verify backend responds (in another terminal)
curl http://localhost:3000/api/health
# Expected: Should return health status
# Verify frontend serves
curl -I http://localhost:3000/
# Expected: HTTP 200 OK with HTML content
```
### 2. MCP Server Integration Test
```bash
# Check MCP servers are loading (look for log messages)
# Expected log output should include:
# - "Successfully connected client for server: [name]"
# - "Successfully listed [N] tools for server: [name]"
# - Some servers may fail due to missing API keys (normal in dev)
```
### 3. Build Verification
```bash
# Verify production build works
pnpm build
node scripts/verify-dist.js
# Expected: "✅ Verification passed! Frontend and backend dist files are present."
```
**NEVER skip these validation steps**. If any fail, debug and fix before proceeding.
## Project Structure & Module Organization
- Backend services live in `src`, grouped by responsibility (`controllers/`, `services/`, `dao/`, `routes/`, `utils/`), with `server.ts` orchestrating HTTP bootstrap.
- `frontend/src` contains the Vite + React dashboard; `frontend/public` hosts static assets and translations sit in `locales/`.
- Jest-aware test code is split between colocated specs (`src/**/*.{test,spec}.ts`) and higher-level suites in `tests/`; use `tests/utils/` helpers when exercising the CLI or SSE flows.
- Build artifacts and bundles are generated into `dist/`, `frontend/dist/`, and `coverage/`; never edit these manually.
### Critical Backend Files
- `src/index.ts` - Application entry point
- `src/server.ts` - Express server setup and middleware (orchestrating HTTP bootstrap)
- `src/services/mcpService.ts` - **Core MCP server management logic**
- `src/config/index.ts` - Configuration management
- `src/routes/` - HTTP route definitions
- `src/controllers/` - HTTP request handlers
- `src/dao/` - Data access layer (supports JSON file & PostgreSQL)
- `src/db/` - TypeORM entities & repositories (for PostgreSQL mode)
- `src/types/index.ts` - TypeScript type definitions and shared DTOs
- `src/utils/` - Utility functions and helpers
### Critical Frontend Files
- `frontend/src/` - React application source (Vite + React dashboard)
- `frontend/src/pages/` - Page components (development entry point)
- `frontend/src/components/` - Reusable UI components
- `frontend/src/utils/fetchInterceptor.js` - Backend API interaction
- `frontend/public/` - Static assets
### Configuration Files
- `mcp_settings.json` - **MCP server definitions and user accounts**
- `package.json` - Dependencies and scripts
- `tsconfig.json` - TypeScript configuration
- `jest.config.cjs` - Test configuration
- `.eslintrc.json` - Linting rules
### Test Organization
- Jest-aware test code is split between colocated specs (`src/**/*.{test,spec}.ts`) and higher-level suites in `tests/`
- Use `tests/utils/` helpers when exercising the CLI or SSE flows
- Mirror production directory names when adding new suites
- End filenames with `.test.ts` or `.spec.ts` for automatic discovery
### Build Artifacts
- `dist/` - Backend build output (TypeScript compilation)
- `frontend/dist/` - Frontend build output (Vite bundle)
- `coverage/` - Test coverage reports
- **Never edit these manually**
### Localization
- Translations sit in `locales/` (en.json, fr.json, tr.json, zh.json)
- Frontend uses react-i18next
### Docker and Deployment
- `Dockerfile` - Multi-stage build with Python base + Node.js
- `entrypoint.sh` - Docker startup script
- `bin/cli.js` - NPM package CLI entry point
## Build, Test, and Development Commands
- `pnpm dev` runs backend (`tsx watch src/index.ts`) and frontend (`vite`) together for local iteration.
- `pnpm backend:dev`, `pnpm frontend:dev`, and `pnpm frontend:preview` target each surface independently; prefer them when debugging one stack.
- `pnpm build` executes `pnpm backend:build` (TypeScript to `dist/`) and `pnpm frontend:build`; run before release or publishing.
- `pnpm test`, `pnpm test:watch`, and `pnpm test:coverage` drive Jest; `pnpm lint` and `pnpm format` enforce style via ESLint and Prettier.
### Development Environment
```bash
# Start both backend and frontend (recommended for most development)
pnpm dev # Backend on :3001, Frontend on :5173
# OR start separately (required on Windows, optional on Linux/macOS)
# Terminal 1: Backend only
pnpm backend:dev # Runs on port 3000 (or PORT env var)
# Terminal 2: Frontend only
pnpm frontend:dev # Runs on port 5173, proxies API to backend
# Frontend preview (production build)
pnpm frontend:preview # Preview production build
```
**NEVER CANCEL**: Development servers may take 10-15 seconds to fully initialize all MCP servers.
### Production Build
```bash
# Full production build - takes ~10 seconds total
pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
# Individual builds
pnpm backend:build # TypeScript compilation to dist/ - ~5 seconds
pnpm frontend:build # Vite build to frontend/dist/ - ~5 seconds
# Start production server
pnpm start # Requires dist/ and frontend/dist/ to exist
```
Run `pnpm build` before release or publishing.
### Testing and Validation
```bash
# Run all tests - takes ~16 seconds with 73 tests
pnpm test:ci # NEVER CANCEL - Set timeout to 60+ seconds
# Development testing
pnpm test # Interactive mode
pnpm test:watch # Watch mode for development
pnpm test:coverage # With coverage report
# Code quality
pnpm lint # ESLint - ~3 seconds
pnpm format # Prettier formatting - ~3 seconds
```
**CRITICAL**: All tests MUST pass before committing. Do not modify tests to make them pass unless specifically required for your changes.
### Performance Notes
- **Install time**: pnpm install takes ~30 seconds
- **Build time**: Full build takes ~10 seconds
- **Test time**: Complete test suite takes ~16 seconds
- **Startup time**: Backend initialization takes 10-15 seconds (MCP server connections)
## Coding Style & Naming Conventions
- TypeScript everywhere; default to 2-space indentation and single quotes, letting Prettier settle formatting. ESLint configuration assumes ES modules.
- Name services and data access layers with suffixes (`UserService`, `AuthDao`), React components and files in `PascalCase`, and utility modules in `camelCase`.
- Keep DTOs and shared types in `src/types` to avoid duplication; re-export through index files only when it clarifies imports.
- **TypeScript everywhere**: Default to 2-space indentation and single quotes, letting Prettier settle formatting
- **ESM modules**: Always use `.js` extensions in imports, not `.ts` (e.g., `import { something } from './file.js'`)
- **English only**: All code comments must be written in English
- **TypeScript strict**: Follow strict type checking rules
- **Naming conventions**:
- Services and data access layers: Use suffixes (`UserService`, `AuthDao`)
- React components and files: `PascalCase`
- Utility modules: `camelCase`
- **Types and DTOs**: Keep in `src/types` to avoid duplication; re-export through index files only when it clarifies imports
- **ESLint configuration**: Assumes ES modules
## Testing Guidelines
@@ -28,12 +216,86 @@ These notes align current contributors around the code layout, daily commands, a
- Mirror production directory names when adding new suites and end filenames with `.test.ts` or `.spec.ts` for automatic discovery.
- Aim to maintain or raise coverage when touching critical flows (auth, OAuth, SSE); add integration tests under `tests/integration/` when touching cross-service logic.
## Key Configuration Notes
- **MCP servers**: Defined in `mcp_settings.json` with command/args
- **Endpoints**: `/mcp/{group|server}` and `/mcp/$smart` for routing
- **i18n**: Frontend uses react-i18next with files in `locales/` folder
- **Authentication**: JWT tokens with bcrypt password hashing
- **Default credentials**: admin/admin123 (configured in mcp_settings.json)
## Development Entry Points
### Adding a new MCP server
1. Add server definition to `mcp_settings.json`
2. Restart backend to load new server
3. Check logs for successful connection
4. Test via dashboard or API endpoints
### API development
1. Define route in `src/routes/`
2. Implement controller in `src/controllers/`
3. Add types in `src/types/index.ts` if needed
4. Write tests in `tests/controllers/`
### Frontend development
1. Create/modify components in `frontend/src/components/`
2. Add pages in `frontend/src/pages/`
3. Update routing if needed
4. Test in development mode with `pnpm frontend:dev`
### Documentation
1. Update or add docs in `docs/` folder
2. Ensure README.md reflects any major changes
## Commit & Pull Request Guidelines
- Follow the existing Conventional Commit pattern (`feat:`, `fix:`, `chore:`, etc.) with imperative, present-tense summaries and optional multi-line context.
- Each PR should describe the behavior change, list testing performed, and link issues; include before/after screenshots or GIFs for frontend tweaks.
- Re-run `pnpm build` and `pnpm test` before requesting review, and ensure generated artifacts stay out of the diff.
### Before Committing - ALWAYS Run
```bash
pnpm lint # Must pass - ~3 seconds
pnpm backend:build # Must compile - ~5 seconds
pnpm test:ci # All tests must pass - ~16 seconds
pnpm build # Full build must work - ~10 seconds
```
**CRITICAL**: CI will fail if any of these commands fail. Fix issues locally first.
### CI Pipeline (.github/workflows/ci.yml)
- Runs on Node.js 20.x
- Tests: linting, type checking, unit tests with coverage
- **NEVER CANCEL**: CI builds may take 2-3 minutes total
## Troubleshooting
### Common Issues
- **"uvx command not found"**: Some MCP servers require `uvx` (Python package manager) - this is expected in development
- **Port already in use**: Change PORT environment variable or kill existing processes
- **Frontend not loading**: Ensure frontend was built with `pnpm frontend:build`
- **MCP server connection failed**: Check server command/args in `mcp_settings.json`
### Build Failures
- **TypeScript errors**: Run `pnpm backend:build` to see compilation errors
- **Test failures**: Run `pnpm test:verbose` for detailed test output
- **Lint errors**: Run `pnpm lint` and fix reported issues
### Development Issues
- **Backend not starting**: Check for port conflicts, verify `mcp_settings.json` syntax
- **Frontend proxy errors**: Ensure backend is running before starting frontend
- **Hot reload not working**: Restart development server
## DAO Layer & Dual Data Source
MCPHub supports **JSON file** (default) and **PostgreSQL** storage. Set `USE_DB=true` + `DB_URL` to switch.
@@ -64,15 +326,99 @@ When adding/changing fields, update **ALL** these files:
### Data Type Mapping
| Model | DAO | DB Entity | JSON Path |
| -------------- | ----------------- | -------------- | ------------------------ |
| -------------- | ----------------- | -------------- | ------------------------- |
| `IUser` | `UserDao` | `User` | `settings.users[]` |
| `ServerConfig` | `ServerDao` | `Server` | `settings.mcpServers{}` |
| `IGroup` | `GroupDao` | `Group` | `settings.groups[]` |
| `SystemConfig` | `SystemConfigDao` | `SystemConfig` | `settings.systemConfig` |
| `UserConfig` | `UserConfigDao` | `UserConfig` | `settings.userConfigs{}` |
| `BearerKey` | `BearerKeyDao` | `BearerKey` | `settings.bearerKeys[]` |
| `IOAuthClient` | `OAuthClientDao` | `OAuthClient` | `settings.oauthClients[]` |
| `IOAuthToken` | `OAuthTokenDao` | `OAuthToken` | `settings.oauthTokens[]` |
### Common Pitfalls
- Forgetting migration script → fields won't migrate to DB
- Optional fields need `nullable: true` in entity
- Complex objects need `simple-json` column type
## Auto-Evolution Guidelines for AI Agents
**This section provides guidelines for AI agents to automatically maintain and improve this document.**
### When to Update AGENTS.md
AI agents MUST update this document in the following situations:
#### 1. Code-Documentation Mismatch Detected
When executing tasks, if you discover that:
- The actual code structure differs from descriptions in this document
- File paths, imports, or module organization has changed
- New critical files or directories exist that aren't documented
- Documented files or patterns no longer exist
**Action**: Immediately update the relevant section to reflect the current codebase state.
**Example scenarios**:
- A controller is now in `src/api/controllers/` instead of `src/controllers/`
- New middleware files exist that should be documented
- The DAO implementation has been refactored with a different structure
- Build output directories have changed
#### 2. User Preferences and Requirements
During conversation, if the user expresses:
- **Coding preferences**: Indentation style, naming conventions, code organization patterns
- **Workflow requirements**: Required validation steps, commit procedures, testing expectations
- **Tool preferences**: Preferred libraries, frameworks, or development tools
- **Quality standards**: Code review criteria, documentation requirements, error handling patterns
- **Development principles**: Architecture decisions, design patterns, best practices
**Action**: Add or update the relevant section to capture these preferences for future reference.
**Example scenarios**:
- User prefers async/await over promises → Update coding style section
- User requires specific test coverage thresholds → Update testing guidelines
- User has strong opinions about error handling → Add to development process section
- User establishes new deployment procedures → Update deployment section
### How to Update AGENTS.md
1. **Identify the Section**: Determine which section needs updating based on the type of change
2. **Make Precise Changes**: Update only the relevant content, maintaining the document structure
3. **Preserve Format**: Keep the existing markdown formatting and organization
4. **Add Context**: If adding new content, ensure it fits logically within existing sections
5. **Verify Accuracy**: After updating, ensure the new information is accurate and complete
### Update Principles
- **Accuracy First**: Documentation must reflect the actual current state
- **Clarity**: Use clear, concise language; avoid ambiguity
- **Completeness**: Include sufficient detail for agents to work effectively
- **Consistency**: Maintain consistent terminology and formatting throughout
- **Actionability**: Focus on concrete, actionable guidance rather than vague descriptions
### Self-Correction Process
Before completing any task:
1. Review relevant sections of AGENTS.md
2. During execution, note any discrepancies between documentation and reality
3. Update AGENTS.md to correct discrepancies
4. Verify the update doesn't conflict with other sections
5. Proceed with the original task using the updated information
### Meta-Update Rule
If this auto-evolution section itself needs improvement based on experience:
- Update it to better serve future agents
- Add new scenarios or principles as they emerge
- Refine the update process based on what works well
**Remember**: This document is a living guide. Keeping it accurate and current is as important as following it.

View File

@@ -8,6 +8,7 @@ import { SettingsProvider } from './contexts/SettingsContext';
import MainLayout from './layouts/MainLayout';
import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import OAuthCallbackPage from './pages/OAuthCallbackPage';
import DashboardPage from './pages/Dashboard';
import ServersPage from './pages/ServersPage';
import GroupsPage from './pages/GroupsPage';
@@ -35,6 +36,7 @@ function App() {
<Routes>
{/* 公共路由 */}
<Route path="/login" element={<LoginPage />} />
<Route path="/oauth-callback" element={<OAuthCallbackPage />} />
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
<Route element={<ProtectedRoute />}>

View File

@@ -18,7 +18,17 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
try {
setError(null);
const encodedServerName = encodeURIComponent(server.name);
const result = await apiPut(`/servers/${encodedServerName}`, payload);
// Check if name is being changed
const isRenaming = payload.name && payload.name !== server.name;
// Build the request body
const requestBody = {
config: payload.config,
...(isRenaming ? { newName: payload.name } : {}),
};
const result = await apiPut(`/servers/${encodedServerName}`, requestBody);
if (!result.success) {
// Use specific error message from the response if available

View File

@@ -429,7 +429,6 @@ const ServerForm = ({
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: time-mcp"
required
disabled={isEdit}
/>
</div>

View File

@@ -14,14 +14,17 @@ const initialState: AuthState = {
// Create auth context
const AuthContext = createContext<{
auth: AuthState;
login: (username: string, password: string) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean }>;
login: (
username: string,
password: string,
) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean; message?: string }>;
register: (username: string, password: string, isAdmin?: boolean) => Promise<boolean>;
logout: () => void;
}>({
auth: initialState,
login: async () => ({ success: false }),
register: async () => false,
logout: () => { },
logout: () => {},
});
// Auth provider component
@@ -90,7 +93,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}, []);
// Login function
const login = async (username: string, password: string): Promise<{ success: boolean; isUsingDefaultPassword?: boolean }> => {
const login = async (
username: string,
password: string,
): Promise<{ success: boolean; isUsingDefaultPassword?: boolean; message?: string }> => {
try {
const response = await authService.login({ username, password });
@@ -111,7 +117,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
loading: false,
error: response.message || 'Authentication failed',
});
return { success: false };
return { success: false, message: response.message };
}
} catch (error) {
setAuth({
@@ -119,7 +125,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
loading: false,
error: 'Authentication failed',
});
return { success: false };
return { success: false, message: error instanceof Error ? error.message : undefined };
}
};
@@ -127,7 +133,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const register = async (
username: string,
password: string,
isAdmin = false
isAdmin = false,
): Promise<boolean> => {
try {
const response = await authService.register({ username, password, isAdmin });

View File

@@ -9,6 +9,7 @@ import React, {
import { useTranslation } from 'react-i18next';
import { ApiResponse, BearerKey } from '@/types';
import { useToast } from '@/contexts/ToastContext';
import { useAuth } from '@/contexts/AuthContext';
import { apiGet, apiPut, apiPost, apiDelete } from '@/utils/fetchInterceptor';
// Define types for the settings data
@@ -153,6 +154,7 @@ interface SettingsProviderProps {
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
const { t } = useTranslation();
const { showToast } = useToast();
const { auth } = useAuth();
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
enableGlobalRoute: true,
@@ -746,6 +748,15 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
fetchSettings();
}, [fetchSettings, refreshKey]);
// Watch for authentication status changes - refetch settings after login
useEffect(() => {
if (auth.isAuthenticated) {
console.log('[SettingsContext] User authenticated, triggering settings refresh');
// When user logs in, trigger a refresh to load settings
triggerRefresh();
}
}, [auth.isAuthenticated, triggerRefresh]);
useEffect(() => {
if (routingConfig) {
setTempRoutingConfig({

View File

@@ -1,11 +1,12 @@
import React, { useState, useMemo, useCallback } from 'react';
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
import { getToken } from '../services/authService';
import { getToken, getOAuthSsoConfig, initiateOAuthSsoLogin } from '../services/authService';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import LanguageSwitch from '@/components/ui/LanguageSwitch';
import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal';
import { OAuthSsoConfig, OAuthSsoProvider } from '../types';
const sanitizeReturnUrl = (value: string | null): string | null => {
if (!value) {
@@ -29,6 +30,44 @@ const sanitizeReturnUrl = (value: string | null): string | null => {
}
};
// Provider icon component
const ProviderIcon: React.FC<{ type: string; className?: string }> = ({ type, className = 'w-5 h-5' }) => {
switch (type) {
case 'google':
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
case 'microsoft':
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M11.4 11.4H2V2h9.4v9.4z" fill="#F25022"/>
<path d="M22 11.4h-9.4V2H22v9.4z" fill="#7FBA00"/>
<path d="M11.4 22H2v-9.4h9.4V22z" fill="#00A4EF"/>
<path d="M22 22h-9.4v-9.4H22V22z" fill="#FFB900"/>
</svg>
);
case 'github':
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.009-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.115 2.504.337 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.579.688.481C19.137 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"/>
</svg>
);
default:
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
);
}
};
const LoginPage: React.FC = () => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
@@ -36,6 +75,7 @@ const LoginPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false);
const [ssoConfig, setSsoConfig] = useState<OAuthSsoConfig | null>(null);
const { login } = useAuth();
const location = useLocation();
const navigate = useNavigate();
@@ -44,6 +84,43 @@ const LoginPage: React.FC = () => {
return sanitizeReturnUrl(params.get('returnUrl'));
}, [location.search]);
// Check for OAuth error in URL params
useEffect(() => {
const params = new URLSearchParams(location.search);
const oauthError = params.get('error');
const oauthMessage = params.get('message');
if (oauthError === 'oauth_failed' && oauthMessage) {
setError(oauthMessage);
}
}, [location.search]);
// Load OAuth SSO configuration
useEffect(() => {
const loadSsoConfig = async () => {
const config = await getOAuthSsoConfig();
setSsoConfig(config);
};
loadSsoConfig();
}, []);
const isServerUnavailableError = useCallback((message?: string) => {
if (!message) return false;
const normalized = message.toLowerCase();
return (
normalized.includes('failed to fetch') ||
normalized.includes('networkerror') ||
normalized.includes('network error') ||
normalized.includes('connection refused') ||
normalized.includes('unable to connect') ||
normalized.includes('fetch error') ||
normalized.includes('econnrefused') ||
normalized.includes('http 500') ||
normalized.includes('internal server error') ||
normalized.includes('proxy error')
);
}, []);
const buildRedirectTarget = useCallback(() => {
if (!returnUrl) {
return '/';
@@ -99,21 +176,38 @@ const LoginPage: React.FC = () => {
} else {
redirectAfterLogin();
}
} else {
const message = result.message;
if (isServerUnavailableError(message)) {
setError(t('auth.serverUnavailable'));
} else {
setError(t('auth.loginFailed'));
}
}
} catch (err) {
const message = err instanceof Error ? err.message : undefined;
if (isServerUnavailableError(message)) {
setError(t('auth.serverUnavailable'));
} else {
setError(t('auth.loginError'));
}
} finally {
setLoading(false);
}
};
const handleSsoLogin = (provider: OAuthSsoProvider) => {
initiateOAuthSsoLogin(provider.id, returnUrl || undefined);
};
const handleCloseWarning = () => {
setShowDefaultPasswordWarning(false);
redirectAfterLogin();
};
const showLocalAuth = !ssoConfig?.enabled || ssoConfig.localAuthAllowed;
const showSsoProviders = ssoConfig?.enabled && ssoConfig.providers.length > 0;
return (
<div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950">
{/* Top-right controls */}
@@ -131,13 +225,21 @@ const LoginPage: React.FC = () => {
}}
/>
<div className="pointer-events-none absolute inset-0 -z-10">
<svg className="h-full w-full opacity-[0.08] dark:opacity-[0.12]" xmlns="http://www.w3.org/2000/svg">
<svg
className="h-full w-full opacity-[0.08] dark:opacity-[0.12]"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse">
<path d="M 32 0 L 0 0 0 32" fill="none" stroke="currentColor" strokeWidth="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" className="text-gray-400 dark:text-gray-300" />
<rect
width="100%"
height="100%"
fill="url(#grid)"
className="text-gray-400 dark:text-gray-300"
/>
</svg>
</div>
@@ -157,6 +259,40 @@ const LoginPage: React.FC = () => {
<div className="login-card relative w-full rounded-2xl border border-white/10 bg-white/60 p-8 shadow-xl backdrop-blur-md transition dark:border-white/10 dark:bg-gray-900/60">
<div className="absolute -top-24 right-12 h-40 w-40 -translate-y-6 rounded-full bg-indigo-500/30 blur-3xl" />
<div className="absolute -bottom-24 -left-12 h-40 w-40 translate-y-6 rounded-full bg-cyan-500/20 blur-3xl" />
{/* SSO Providers */}
{showSsoProviders && (
<div className="mt-4 space-y-3">
{ssoConfig.providers.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleSsoLogin(provider)}
className="group relative flex w-full items-center justify-center gap-3 rounded-md border border-gray-300/60 bg-white/80 px-4 py-3 text-sm font-medium text-gray-700 shadow-sm transition-all hover:bg-gray-50 hover:shadow focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:border-gray-600/60 dark:bg-gray-800/80 dark:text-gray-200 dark:hover:bg-gray-700/80"
>
<ProviderIcon type={provider.icon || provider.type} />
<span>{provider.buttonText || t('oauthSso.signInWith', { provider: provider.name })}</span>
</button>
))}
</div>
)}
{/* Divider between SSO and local auth */}
{showSsoProviders && showLocalAuth && (
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300/60 dark:border-gray-600/60" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white/60 px-4 text-gray-500 dark:bg-gray-900/60 dark:text-gray-400">
{t('oauthSso.orContinueWith')}
</span>
</div>
</div>
)}
{/* Local auth form */}
{showLocalAuth && (
<form className="mt-4 space-y-4" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
@@ -209,6 +345,14 @@ const LoginPage: React.FC = () => {
</button>
</div>
</form>
)}
{/* Error display for SSO-only mode */}
{!showLocalAuth && error && (
<div className="mt-4 error-box rounded border border-red-500/20 bg-red-500/10 p-2 text-center text-sm text-red-600 dark:text-red-400">
{error}
</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,42 @@
import React, { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { setToken } from '../services/authService';
/**
* OAuth Callback Page
*
* This page handles the callback from OAuth SSO providers.
* It receives the JWT token as a query parameter, stores it, and redirects to the app.
*/
const OAuthCallbackPage: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
useEffect(() => {
const token = searchParams.get('token');
const returnUrl = searchParams.get('returnUrl') || '/';
if (token) {
// Store the token
setToken(token);
// Redirect to the return URL
navigate(returnUrl, { replace: true });
} else {
// No token - redirect to login with error
navigate('/login?error=oauth_failed&message=No+token+received', { replace: true });
}
}, [searchParams, navigate]);
// Show loading state while processing
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Completing authentication...</p>
</div>
</div>
);
};
export default OAuthCallbackPage;

View File

@@ -25,7 +25,7 @@ interface BearerKeyRowProps {
name: string;
token: string;
enabled: boolean;
accessType: 'all' | 'groups' | 'servers';
accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string;
allowedServers: string;
},
@@ -47,7 +47,7 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
const [name, setName] = useState(keyData.name);
const [token, setToken] = useState(keyData.token);
const [enabled, setEnabled] = useState<boolean>(keyData.enabled);
const [accessType, setAccessType] = useState<'all' | 'groups' | 'servers'>(
const [accessType, setAccessType] = useState<'all' | 'groups' | 'servers' | 'custom'>(
keyData.accessType || 'all',
);
const [selectedGroups, setSelectedGroups] = useState<string[]>(keyData.allowedGroups || []);
@@ -105,6 +105,13 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
);
return;
}
if (accessType === 'custom' && selectedGroups.length === 0 && selectedServers.length === 0) {
showToast(
t('settings.selectAtLeastOneGroupOrServer') || 'Please select at least one group or server',
'error',
);
return;
}
setSaving(true);
try {
@@ -135,6 +142,31 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
};
const isGroupsMode = accessType === 'groups';
const isCustomMode = accessType === 'custom';
// Helper function to format access type display text
const formatAccessTypeDisplay = (key: BearerKey): string => {
if (key.accessType === 'all') {
return t('settings.bearerKeyAccessAll') || 'All Resources';
}
if (key.accessType === 'groups') {
return `${t('settings.bearerKeyAccessGroups') || 'Groups'}: ${key.allowedGroups}`;
}
if (key.accessType === 'servers') {
return `${t('settings.bearerKeyAccessServers') || 'Servers'}: ${key.allowedServers}`;
}
if (key.accessType === 'custom') {
const parts: string[] = [];
if (key.allowedGroups && key.allowedGroups.length > 0) {
parts.push(`${t('settings.bearerKeyAccessGroups') || 'Groups'}: ${key.allowedGroups}`);
}
if (key.allowedServers && key.allowedServers.length > 0) {
parts.push(`${t('settings.bearerKeyAccessServers') || 'Servers'}: ${key.allowedServers}`);
}
return `${t('settings.bearerKeyAccessCustom') || 'Custom'}: ${parts.join('; ')}`;
}
return '';
};
if (isEditing) {
return (
@@ -194,7 +226,9 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
<select
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-select transition-shadow duration-200"
value={accessType}
onChange={(e) => setAccessType(e.target.value as 'all' | 'groups' | 'servers')}
onChange={(e) =>
setAccessType(e.target.value as 'all' | 'groups' | 'servers' | 'custom')
}
disabled={loading}
>
<option value="all">{t('settings.bearerKeyAccessAll') || 'All Resources'}</option>
@@ -204,9 +238,14 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
<option value="servers">
{t('settings.bearerKeyAccessServers') || 'Specific Servers'}
</option>
<option value="custom">
{t('settings.bearerKeyAccessCustom') || 'Custom (Groups & Servers)'}
</option>
</select>
</div>
{/* Show single selector for groups or servers mode */}
{!isCustomMode && (
<div className="flex-1 min-w-[200px]">
<label
className={`block text-sm font-medium mb-1 ${accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`}
@@ -227,6 +266,37 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
disabled={loading || accessType === 'all'}
/>
</div>
)}
{/* Show both selectors for custom mode */}
{isCustomMode && (
<>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedGroups') || 'Allowed groups'}
</label>
<MultiSelect
options={availableGroups}
selected={selectedGroups}
onChange={setSelectedGroups}
placeholder={t('settings.selectGroups') || 'Select groups...'}
disabled={loading}
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
</label>
<MultiSelect
options={availableServers}
selected={selectedServers}
onChange={setSelectedServers}
placeholder={t('settings.selectServers') || 'Select servers...'}
disabled={loading}
/>
</div>
</>
)}
<div className="flex justify-end gap-2">
<button
@@ -281,11 +351,7 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{keyData.accessType === 'all'
? t('settings.bearerKeyAccessAll') || 'All Resources'
: keyData.accessType === 'groups'
? `${t('settings.bearerKeyAccessGroups') || 'Groups'}: ${keyData.allowedGroups}`
: `${t('settings.bearerKeyAccessServers') || 'Servers'}: ${keyData.allowedServers}`}
{formatAccessTypeDisplay(keyData)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
@@ -558,12 +624,6 @@ const SettingsPage: React.FC = () => {
});
};
const saveSmartRoutingConfig = async (
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
) => {
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
};
const handleMCPRouterConfigChange = (
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
value: string,
@@ -705,6 +765,31 @@ const SettingsPage: React.FC = () => {
}
};
const handleSaveSmartRoutingConfig = async () => {
const updates: any = {};
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
}
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
}
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
}
if (
tempSmartRoutingConfig.openaiApiEmbeddingModel !== smartRoutingConfig.openaiApiEmbeddingModel
) {
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
}
if (Object.keys(updates).length > 0) {
await updateSmartRoutingConfigBatch(updates);
} else {
showToast(t('settings.noChanges') || 'No changes to save', 'info');
}
};
const handlePasswordChangeSuccess = () => {
setTimeout(() => {
navigate('/');
@@ -718,7 +803,7 @@ const SettingsPage: React.FC = () => {
name: string;
token: string;
enabled: boolean;
accessType: 'all' | 'groups' | 'servers';
accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string;
allowedServers: string;
}>({
@@ -746,10 +831,10 @@ const SettingsPage: React.FC = () => {
// Reset selected arrays when accessType changes
useEffect(() => {
if (newBearerKey.accessType !== 'groups') {
if (newBearerKey.accessType !== 'groups' && newBearerKey.accessType !== 'custom') {
setNewSelectedGroups([]);
}
if (newBearerKey.accessType !== 'servers') {
if (newBearerKey.accessType !== 'servers' && newBearerKey.accessType !== 'custom') {
setNewSelectedServers([]);
}
}, [newBearerKey.accessType]);
@@ -847,6 +932,17 @@ const SettingsPage: React.FC = () => {
);
return;
}
if (
newBearerKey.accessType === 'custom' &&
newSelectedGroups.length === 0 &&
newSelectedServers.length === 0
) {
showToast(
t('settings.selectAtLeastOneGroupOrServer') || 'Please select at least one group or server',
'error',
);
return;
}
await createBearerKey({
name: newBearerKey.name,
@@ -854,11 +950,13 @@ const SettingsPage: React.FC = () => {
enabled: newBearerKey.enabled,
accessType: newBearerKey.accessType,
allowedGroups:
newBearerKey.accessType === 'groups' && newSelectedGroups.length > 0
(newBearerKey.accessType === 'groups' || newBearerKey.accessType === 'custom') &&
newSelectedGroups.length > 0
? newSelectedGroups
: undefined,
allowedServers:
newBearerKey.accessType === 'servers' && newSelectedServers.length > 0
(newBearerKey.accessType === 'servers' || newBearerKey.accessType === 'custom') &&
newSelectedServers.length > 0
? newSelectedServers
: undefined,
} as any);
@@ -882,7 +980,7 @@ const SettingsPage: React.FC = () => {
name: string;
token: string;
enabled: boolean;
accessType: 'all' | 'groups' | 'servers';
accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string;
allowedServers: string;
},
@@ -1109,7 +1207,7 @@ const SettingsPage: React.FC = () => {
onChange={(e) =>
setNewBearerKey((prev) => ({
...prev,
accessType: e.target.value as 'all' | 'groups' | 'servers',
accessType: e.target.value as 'all' | 'groups' | 'servers' | 'custom',
}))
}
disabled={loading}
@@ -1123,9 +1221,13 @@ const SettingsPage: React.FC = () => {
<option value="servers">
{t('settings.bearerKeyAccessServers') || 'Specific Servers'}
</option>
<option value="custom">
{t('settings.bearerKeyAccessCustom') || 'Custom (Groups & Servers)'}
</option>
</select>
</div>
{newBearerKey.accessType !== 'custom' && (
<div className="flex-1 min-w-[200px]">
<label
className={`block text-sm font-medium mb-1 ${newBearerKey.accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`}
@@ -1158,6 +1260,36 @@ const SettingsPage: React.FC = () => {
disabled={loading || newBearerKey.accessType === 'all'}
/>
</div>
)}
{newBearerKey.accessType === 'custom' && (
<>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedGroups') || 'Allowed groups'}
</label>
<MultiSelect
options={availableGroups}
selected={newSelectedGroups}
onChange={setNewSelectedGroups}
placeholder={t('settings.selectGroups') || 'Select groups...'}
disabled={loading}
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
</label>
<MultiSelect
options={availableServers}
selected={newSelectedServers}
onChange={setNewSelectedServers}
placeholder={t('settings.selectServers') || 'Select servers...'}
disabled={loading}
/>
</div>
</>
)}
<div className="flex justify-end gap-2">
<button
@@ -1214,6 +1346,8 @@ const SettingsPage: React.FC = () => {
/>
</div>
{/* hide when DB_URL env is set */}
{smartRoutingConfig.dbUrl !== '${DB_URL}' && (
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
@@ -1230,15 +1364,9 @@ const SettingsPage: React.FC = () => {
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('dbUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
)}
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
@@ -1256,13 +1384,6 @@ const SettingsPage: React.FC = () => {
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
@@ -1281,13 +1402,6 @@ const SettingsPage: React.FC = () => {
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}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
@@ -1308,16 +1422,19 @@ const SettingsPage: React.FC = () => {
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}
/>
</div>
</div>
<div className="flex justify-end pt-2">
<button
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
onClick={handleSaveSmartRoutingConfig}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
</div>
)}
</div>
</PermissionChecker>

View File

@@ -3,6 +3,7 @@ import {
LoginCredentials,
RegisterCredentials,
ChangePasswordCredentials,
OAuthSsoConfig,
} from '../types';
import { apiPost, apiGet } from '../utils/fetchInterceptor';
import { getToken, setToken, removeToken } from '../utils/interceptors';
@@ -29,7 +30,7 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
console.error('Login error:', error);
return {
success: false,
message: 'An error occurred during login',
message: error instanceof Error ? error.message : 'An error occurred during login',
};
}
};
@@ -105,6 +106,30 @@ export const changePassword = async (
}
};
// Get OAuth SSO configuration
export const getOAuthSsoConfig = async (): Promise<OAuthSsoConfig | null> => {
try {
const response = await apiGet<{ success: boolean; data: OAuthSsoConfig }>('/auth/sso/config');
if (response.success && response.data) {
return response.data;
}
return null;
} catch (error) {
console.error('Get OAuth SSO config error:', error);
return null;
}
};
// Initiate OAuth SSO login (redirects to provider)
export const initiateOAuthSsoLogin = (providerId: string, returnUrl?: string): void => {
const basePath = import.meta.env.VITE_BASE_PATH || '';
let url = `${basePath}/api/auth/sso/${providerId}`;
if (returnUrl) {
url += `?returnUrl=${encodeURIComponent(returnUrl)}`;
}
window.location.href = url;
};
// Logout user
export const logout = (): void => {
removeToken();

View File

@@ -310,7 +310,7 @@ export interface ApiResponse<T = any> {
}
// Bearer authentication key configuration (frontend view model)
export type BearerKeyAccessType = 'all' | 'groups' | 'servers';
export type BearerKeyAccessType = 'all' | 'groups' | 'servers' | 'custom';
export interface BearerKey {
id: string;
@@ -381,6 +381,21 @@ export interface AuthResponse {
isUsingDefaultPassword?: boolean;
}
// OAuth SSO types
export interface OAuthSsoProvider {
id: string;
name: string;
type: string;
icon?: string;
buttonText?: string;
}
export interface OAuthSsoConfig {
enabled: boolean;
providers: OAuthSsoProvider[];
localAuthAllowed: boolean;
}
// Official Registry types (from registry.modelcontextprotocol.io)
export interface RegistryVariable {
choices?: string[];

View File

@@ -61,6 +61,7 @@
"emptyFields": "Username and password cannot be empty",
"loginFailed": "Login failed, please check your username and password",
"loginError": "An error occurred during login",
"serverUnavailable": "Unable to connect to the server. Please check your network connection or try again later",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
@@ -567,6 +568,7 @@
"bearerKeyAccessAll": "All",
"bearerKeyAccessGroups": "Groups",
"bearerKeyAccessServers": "Servers",
"bearerKeyAccessCustom": "Custom",
"bearerKeyAllowedGroups": "Allowed groups",
"bearerKeyAllowedServers": "Allowed servers",
"addBearerKey": "Add key",
@@ -838,5 +840,25 @@
"internalError": "Internal Error",
"internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.",
"closeWindow": "Close Window"
},
"oauthSso": {
"errors": {
"providerIdRequired": "Provider ID is required",
"providerNotFound": "OAuth provider not found",
"missingState": "Missing OAuth state parameter",
"missingCode": "Missing authorization code",
"invalidState": "Invalid or expired OAuth state",
"authFailed": "OAuth authentication failed",
"userNotProvisioned": "User not found and auto-provisioning is disabled"
},
"signInWith": "Sign in with {{provider}}",
"orContinueWith": "Or continue with",
"continueWithProvider": "Continue with {{provider}}",
"loginWithSso": "Login with SSO",
"providers": {
"google": "Google",
"microsoft": "Microsoft",
"github": "GitHub"
}
}
}

View File

@@ -61,6 +61,7 @@
"emptyFields": "Le nom d'utilisateur et le mot de passe ne peuvent pas être vides",
"loginFailed": "Échec de la connexion, veuillez vérifier votre nom d'utilisateur et votre mot de passe",
"loginError": "Une erreur est survenue lors de la connexion",
"serverUnavailable": "Impossible de se connecter au serveur. Veuillez vérifier votre connexion réseau ou réessayer plus tard",
"currentPassword": "Mot de passe actuel",
"newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le mot de passe",
@@ -568,6 +569,7 @@
"bearerKeyAccessAll": "Toutes",
"bearerKeyAccessGroups": "Groupes",
"bearerKeyAccessServers": "Serveurs",
"bearerKeyAccessCustom": "Personnalisée",
"bearerKeyAllowedGroups": "Groupes autorisés",
"bearerKeyAllowedServers": "Serveurs autorisés",
"addBearerKey": "Ajouter une clé",

View File

@@ -61,6 +61,7 @@
"emptyFields": "Kullanıcı adı ve şifre boş olamaz",
"loginFailed": "Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin",
"loginError": "Giriş sırasında bir hata oluştu",
"serverUnavailable": "Sunucuya bağlanılamıyor. Lütfen ağ bağlantınızı kontrol edin veya daha sonra tekrar deneyin",
"currentPassword": "Mevcut Şifre",
"newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla",
@@ -568,6 +569,7 @@
"bearerKeyAccessAll": "Tümü",
"bearerKeyAccessGroups": "Gruplar",
"bearerKeyAccessServers": "Sunucular",
"bearerKeyAccessCustom": "Özel",
"bearerKeyAllowedGroups": "İzin verilen gruplar",
"bearerKeyAllowedServers": "İzin verilen sunucular",
"addBearerKey": "Anahtar ekle",

View File

@@ -61,6 +61,7 @@
"emptyFields": "用户名和密码不能为空",
"loginFailed": "登录失败,请检查用户名和密码",
"loginError": "登录过程中出现错误",
"serverUnavailable": "无法连接到服务器,请检查网络连接或稍后再试",
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirmPassword": "确认密码",
@@ -569,6 +570,7 @@
"bearerKeyAccessAll": "全部",
"bearerKeyAccessGroups": "指定分组",
"bearerKeyAccessServers": "指定服务器",
"bearerKeyAccessCustom": "自定义",
"bearerKeyAllowedGroups": "允许访问的分组",
"bearerKeyAllowedServers": "允许访问的服务器",
"addBearerKey": "新增密钥",
@@ -840,5 +842,25 @@
"internalError": "内部错误",
"internalErrorMessage": "处理 OAuth 回调时发生意外错误。",
"closeWindow": "关闭窗口"
},
"oauthSso": {
"errors": {
"providerIdRequired": "需要提供身份验证提供商 ID",
"providerNotFound": "未找到 OAuth 身份验证提供商",
"missingState": "缺少 OAuth 状态参数",
"missingCode": "缺少授权码",
"invalidState": "OAuth 状态无效或已过期",
"authFailed": "OAuth 身份验证失败",
"userNotProvisioned": "用户未找到且自动创建用户已禁用"
},
"signInWith": "使用 {{provider}} 登录",
"orContinueWith": "或使用以下方式继续",
"continueWithProvider": "使用 {{provider}} 继续",
"loginWithSso": "使用 SSO 登录",
"providers": {
"google": "Google",
"microsoft": "Microsoft",
"github": "GitHub"
}
}
}

View File

@@ -63,5 +63,6 @@
"requiresAuthentication": false
}
}
}
},
"bearerKeys": []
}

View File

@@ -57,7 +57,7 @@ export const createBearerKey = async (req: Request, res: Response): Promise<void
return;
}
if (!accessType || !['all', 'groups', 'servers'].includes(accessType)) {
if (!accessType || !['all', 'groups', 'servers', 'custom'].includes(accessType)) {
res.status(400).json({ success: false, message: 'Invalid accessType' });
return;
}
@@ -104,7 +104,7 @@ export const updateBearerKey = async (req: Request, res: Response): Promise<void
if (token !== undefined) updates.token = token;
if (enabled !== undefined) updates.enabled = enabled;
if (accessType !== undefined) {
if (!['all', 'groups', 'servers'].includes(accessType)) {
if (!['all', 'groups', 'servers', 'custom'].includes(accessType)) {
res.status(400).json({ success: false, message: 'Invalid accessType' });
return;
}

View File

@@ -0,0 +1,245 @@
/**
* OAuth SSO Controller
*
* Handles OAuth SSO authentication endpoints.
*/
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import {
generateAuthorizationUrl,
handleCallback,
getPublicProviderInfo,
isLocalAuthAllowed,
isOAuthSsoEnabled,
getOAuthSsoConfig as getSsoConfigFromService,
} from '../services/oauthSsoService.js';
import { JWT_SECRET } from '../config/jwt.js';
import config from '../config/index.js';
const TOKEN_EXPIRY = '24h';
/**
* Get the base URL for OAuth callbacks
* Uses configured callbackBaseUrl if available, otherwise derives from request
* This approach is more secure than blindly trusting forwarded headers
*/
async function getCallbackBaseUrl(req: Request): Promise<string> {
// First, check if a callback base URL is configured (most secure option)
const ssoConfig = await getSsoConfigFromService();
if (ssoConfig?.callbackBaseUrl) {
return ssoConfig.callbackBaseUrl;
}
// Fall back to deriving from request (less secure, but works in simpler setups)
// Only trust forwarded headers if app is configured to trust proxy
if (req.app.get('trust proxy') && req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host']) {
const proto = Array.isArray(req.headers['x-forwarded-proto'])
? req.headers['x-forwarded-proto'][0]
: req.headers['x-forwarded-proto'];
const host = Array.isArray(req.headers['x-forwarded-host'])
? req.headers['x-forwarded-host'][0]
: req.headers['x-forwarded-host'];
return `${proto}://${host}`;
}
return `${req.protocol}://${req.get('host')}`;
}
/**
* Get OAuth SSO configuration for frontend
* Returns enabled providers and whether local auth is allowed
*/
export const getOAuthSsoConfig = async (req: Request, res: Response): Promise<void> => {
try {
const enabled = await isOAuthSsoEnabled();
const providers = await getPublicProviderInfo();
const localAuthAllowed = await isLocalAuthAllowed();
res.json({
success: true,
data: {
enabled,
providers,
localAuthAllowed,
},
});
} catch (error) {
console.error('Error getting OAuth SSO config:', error);
res.status(500).json({
success: false,
message: 'Failed to get OAuth SSO configuration',
});
}
};
/**
* Initiate OAuth SSO login
* Redirects user to the OAuth provider's authorization page
*/
export const initiateOAuthLogin = async (req: Request, res: Response): Promise<void> => {
const t = (req as any).t || ((key: string) => key);
try {
const { providerId } = req.params;
const { returnUrl } = req.query;
if (!providerId) {
res.status(400).json({
success: false,
message: t('oauthSso.errors.providerIdRequired'),
});
return;
}
// Build callback URL
// Note: Use configured callback base URL from oauthSso config if available
// This avoids relying on potentially untrusted forwarded headers
const baseUrl = await getCallbackBaseUrl(req);
const callbackUrl = `${baseUrl}${config.basePath}/api/auth/sso/${providerId}/callback`;
// Generate authorization URL
const { url } = await generateAuthorizationUrl(
providerId,
callbackUrl,
typeof returnUrl === 'string' ? returnUrl : undefined,
);
// Redirect to OAuth provider
res.redirect(url);
} catch (error) {
console.error('Error initiating OAuth login:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to initiate OAuth login';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
/**
* Handle OAuth callback from provider
* Exchanges code for tokens and creates/updates user
*/
export const handleOAuthCallback = async (req: Request, res: Response): Promise<void> => {
const t = (req as any).t || ((key: string) => key);
try {
const { providerId } = req.params;
const { code, state, error, error_description } = req.query;
// Handle OAuth errors
if (error) {
console.error(`OAuth error from provider ${providerId}:`, error, error_description);
const errorUrl = buildErrorRedirectUrl(String(error_description || error), req);
return res.redirect(errorUrl);
}
// Validate required parameters
if (!state) {
const errorUrl = buildErrorRedirectUrl(t('oauthSso.errors.missingState'), req);
return res.redirect(errorUrl);
}
if (!code) {
const errorUrl = buildErrorRedirectUrl(t('oauthSso.errors.missingCode'), req);
return res.redirect(errorUrl);
}
// Build callback URL (same as used in initiate)
const baseUrl = await getCallbackBaseUrl(req);
const callbackUrl = `${baseUrl}${config.basePath}/api/auth/sso/${providerId}/callback`;
// Full current URL with query params
const currentUrl = `${callbackUrl}?${new URLSearchParams(req.query as Record<string, string>).toString()}`;
// Exchange code for tokens and get user
const { user, returnUrl } = await handleCallback(
callbackUrl,
currentUrl,
String(state),
);
// Generate JWT token
const payload = {
user: {
username: user.username,
isAdmin: user.isAdmin || false,
},
};
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
// Redirect to frontend with token
const redirectUrl = buildSuccessRedirectUrl(token, returnUrl, req);
res.redirect(redirectUrl);
} catch (error) {
console.error('Error handling OAuth callback:', error);
const errorMessage =
error instanceof Error ? error.message : 'Authentication failed';
const errorUrl = buildErrorRedirectUrl(errorMessage, req);
res.redirect(errorUrl);
}
};
/**
* Get list of available OAuth providers
*/
export const listOAuthProviders = async (req: Request, res: Response): Promise<void> => {
try {
const providers = await getPublicProviderInfo();
res.json({
success: true,
data: providers,
});
} catch (error) {
console.error('Error listing OAuth providers:', error);
res.status(500).json({
success: false,
message: 'Failed to list OAuth providers',
});
}
};
/**
* Build redirect URL for successful authentication
*/
function buildSuccessRedirectUrl(token: string, returnUrl: string | undefined, req: Request): string {
const baseUrl = getBaseUrl(req);
const targetPath = returnUrl || '/';
// Use a special OAuth callback page that stores the token
const callbackPath = `${config.basePath}/oauth-callback`;
const params = new URLSearchParams({
token,
returnUrl: targetPath,
});
return `${baseUrl}${callbackPath}?${params.toString()}`;
}
/**
* Build redirect URL for authentication errors
*/
function buildErrorRedirectUrl(error: string, req: Request): string {
const baseUrl = getBaseUrl(req);
const loginPath = `${config.basePath}/login`;
const params = new URLSearchParams({
error: 'oauth_failed',
message: error,
});
return `${baseUrl}${loginPath}?${params.toString()}`;
}
/**
* Get base URL from request
*/
function getBaseUrl(req: Request): string {
if (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host']) {
return `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}`;
}
return `${req.protocol}://${req.get('host')}`;
}

View File

@@ -66,6 +66,20 @@ export const getAllSettings = async (_: Request, res: Response): Promise<void> =
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
// Ensure smart routing config has DB URL set if environment variable is present
const dbUrlEnv = process.env.DB_URL || '';
if (!systemConfig.smartRouting) {
systemConfig.smartRouting = {
enabled: false,
dbUrl: dbUrlEnv ? '${DB_URL}' : '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
};
} else if (!systemConfig.smartRouting.dbUrl) {
systemConfig.smartRouting.dbUrl = dbUrlEnv ? '${DB_URL}' : '';
}
// Get bearer auth keys from DAO
const bearerKeyDao = getBearerKeyDao();
const bearerKeys = await bearerKeyDao.findAll();
@@ -409,7 +423,7 @@ export const deleteServer = async (req: Request, res: Response): Promise<void> =
export const updateServer = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
const { config } = req.body;
const { config, newName } = req.body;
if (!name) {
res.status(400).json({
success: false,
@@ -496,12 +510,52 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
config.owner = currentUser?.username || 'admin';
}
const result = await addOrUpdateServer(name, config, true); // Allow override for updates
// Check if server name is being changed
const isRenaming = newName && newName !== name;
// If renaming, validate the new name and update references
if (isRenaming) {
const serverDao = getServerDao();
// Check if new name already exists
if (await serverDao.exists(newName)) {
res.status(400).json({
success: false,
message: `Server name '${newName}' already exists`,
});
return;
}
// Rename the server
const renamed = await serverDao.rename(name, newName);
if (!renamed) {
res.status(404).json({
success: false,
message: 'Server not found',
});
return;
}
// Update references in groups
const groupDao = getGroupDao();
await groupDao.updateServerName(name, newName);
// Update references in bearer keys
const bearerKeyDao = getBearerKeyDao();
await bearerKeyDao.updateServerName(name, newName);
}
// Use the final server name (new name if renaming, otherwise original name)
const finalName = isRenaming ? newName : name;
const result = await addOrUpdateServer(finalName, config, true); // Allow override for updates
if (result.success) {
notifyToolChanged(name);
notifyToolChanged(finalName);
res.json({
success: true,
message: 'Server updated successfully',
message: isRenaming
? `Server renamed and updated successfully`
: 'Server updated successfully',
});
} else {
res.status(404).json({
@@ -510,9 +564,10 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
});
}
} catch (error) {
console.error('Failed to update server:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
message: error instanceof Error ? error.message : 'Internal server error',
});
}
};
@@ -978,7 +1033,8 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
if (typeof smartRouting.enabled === 'boolean') {
// If enabling Smart Routing, validate required fields
if (smartRouting.enabled) {
const currentDbUrl = smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
const currentDbUrl =
process.env.DB_URL || smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
const currentOpenaiApiKey =
smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey;

View File

@@ -13,6 +13,10 @@ export interface BearerKeyDao {
create(data: Omit<BearerKey, 'id'>): Promise<BearerKey>;
update(id: string, data: Partial<Omit<BearerKey, 'id'>>): Promise<BearerKey | null>;
delete(id: string): Promise<boolean>;
/**
* Update server name in all bearer keys (when server is renamed)
*/
updateServerName(oldName: string, newName: string): Promise<number>;
}
/**
@@ -122,4 +126,34 @@ export class BearerKeyDaoImpl extends JsonFileBaseDao implements BearerKeyDao {
await this.saveKeys(next);
return true;
}
async updateServerName(oldName: string, newName: string): Promise<number> {
const keys = await this.loadKeysWithMigration();
let updatedCount = 0;
for (const key of keys) {
let updated = false;
if (key.allowedServers && key.allowedServers.length > 0) {
const newServers = key.allowedServers.map((server) => {
if (server === oldName) {
updated = true;
return newName;
}
return server;
});
if (updated) {
key.allowedServers = newServers;
updatedCount++;
}
}
}
if (updatedCount > 0) {
await this.saveKeys(keys);
}
return updatedCount;
}
}

View File

@@ -74,4 +74,30 @@ export class BearerKeyDaoDbImpl implements BearerKeyDao {
async delete(id: string): Promise<boolean> {
return await this.repository.delete(id);
}
async updateServerName(oldName: string, newName: string): Promise<number> {
const allKeys = await this.repository.findAll();
let updatedCount = 0;
for (const key of allKeys) {
let updated = false;
if (key.allowedServers && key.allowedServers.length > 0) {
const newServers = key.allowedServers.map((server) => {
if (server === oldName) {
updated = true;
return newName;
}
return server;
});
if (updated) {
await this.repository.update(key.id, { allowedServers: newServers });
updatedCount++;
}
}
}
return updatedCount;
}
}

View File

@@ -36,6 +36,11 @@ export interface GroupDao extends BaseDao<IGroup, string> {
* Find group by name
*/
findByName(name: string): Promise<IGroup | null>;
/**
* Update server name in all groups (when server is renamed)
*/
updateServerName(oldName: string, newName: string): Promise<number>;
}
/**
@@ -218,4 +223,39 @@ export class GroupDaoImpl extends JsonFileBaseDao implements GroupDao {
const groups = await this.getAll();
return groups.find((group) => group.name === name) || null;
}
async updateServerName(oldName: string, newName: string): Promise<number> {
const groups = await this.getAll();
let updatedCount = 0;
for (const group of groups) {
let updated = false;
const newServers = group.servers.map((server) => {
if (typeof server === 'string') {
if (server === oldName) {
updated = true;
return newName;
}
return server;
} else {
if (server.name === oldName) {
updated = true;
return { ...server, name: newName };
}
return server;
}
}) as IGroup['servers'];
if (updated) {
group.servers = newServers;
updatedCount++;
}
}
if (updatedCount > 0) {
await this.saveAll(groups);
}
return updatedCount;
}
}

View File

@@ -151,4 +151,35 @@ export class GroupDaoDbImpl implements GroupDao {
owner: group.owner,
};
}
async updateServerName(oldName: string, newName: string): Promise<number> {
const allGroups = await this.repository.findAll();
let updatedCount = 0;
for (const group of allGroups) {
let updated = false;
const newServers = group.servers.map((server) => {
if (typeof server === 'string') {
if (server === oldName) {
updated = true;
return newName;
}
return server;
} else {
if (server.name === oldName) {
updated = true;
return { ...server, name: newName };
}
return server;
}
});
if (updated) {
await this.update(group.id, { servers: newServers as any });
updatedCount++;
}
}
return updatedCount;
}
}

View File

@@ -41,6 +41,11 @@ export interface ServerDao extends BaseDao<ServerConfigWithName, string> {
name: string,
prompts: Record<string, { enabled: boolean; description?: string }>,
): Promise<boolean>;
/**
* Rename a server (change its name/key)
*/
rename(oldName: string, newName: string): Promise<boolean>;
}
/**
@@ -95,7 +100,8 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
return {
...existing,
...updates,
name: existing.name, // Name should not be updated
// Keep the existing name unless explicitly updating via rename
name: updates.name ?? existing.name,
};
}
@@ -141,9 +147,7 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
return null;
}
// Don't allow name changes
const { name: _, ...allowedUpdates } = updates;
const updatedServer = this.updateEntity(servers[index], allowedUpdates);
const updatedServer = this.updateEntity(servers[index], updates);
servers[index] = updatedServer;
await this.saveAll(servers);
@@ -207,4 +211,22 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
const result = await this.update(name, { prompts });
return result !== null;
}
async rename(oldName: string, newName: string): Promise<boolean> {
const servers = await this.getAll();
const index = servers.findIndex((server) => server.name === oldName);
if (index === -1) {
return false;
}
// Check if newName already exists
if (servers.find((server) => server.name === newName)) {
throw new Error(`Server ${newName} already exists`);
}
servers[index] = { ...servers[index], name: newName };
await this.saveAll(servers);
return true;
}
}

View File

@@ -115,6 +115,15 @@ export class ServerDaoDbImpl implements ServerDao {
return result !== null;
}
async rename(oldName: string, newName: string): Promise<boolean> {
// Check if newName already exists
if (await this.repository.exists(newName)) {
throw new Error(`Server ${newName} already exists`);
}
return await this.repository.rename(oldName, newName);
}
private mapToServerConfig(server: {
name: string;
type?: string;

View File

@@ -22,6 +22,7 @@ export class SystemConfigDaoDbImpl implements SystemConfigDao {
nameSeparator: config.nameSeparator,
oauth: config.oauth as any,
oauthServer: config.oauthServer as any,
oauthSso: config.oauthSso as any,
enableSessionRebuild: config.enableSessionRebuild,
};
}
@@ -36,6 +37,7 @@ export class SystemConfigDaoDbImpl implements SystemConfigDao {
nameSeparator: updated.nameSeparator,
oauth: updated.oauth as any,
oauthServer: updated.oauthServer as any,
oauthSso: updated.oauthSso as any,
enableSessionRebuild: updated.enableSessionRebuild,
};
}
@@ -50,6 +52,7 @@ export class SystemConfigDaoDbImpl implements SystemConfigDao {
nameSeparator: config.nameSeparator,
oauth: config.oauth as any,
oauthServer: config.oauthServer as any,
oauthSso: config.oauthSso as any,
enableSessionRebuild: config.enableSessionRebuild,
};
}

View File

@@ -13,23 +13,28 @@ export class UserDaoDbImpl implements UserDao {
this.repository = new UserRepository();
}
async findAll(): Promise<IUser[]> {
const users = await this.repository.findAll();
return users.map((u) => ({
private mapToIUser(u: any): IUser {
return {
username: u.username,
password: u.password,
isAdmin: u.isAdmin,
}));
oauthProvider: u.oauthProvider,
oauthSubject: u.oauthSubject,
email: u.email,
displayName: u.displayName,
avatarUrl: u.avatarUrl,
};
}
async findAll(): Promise<IUser[]> {
const users = await this.repository.findAll();
return users.map(this.mapToIUser);
}
async findById(username: string): Promise<IUser | null> {
const user = await this.repository.findByUsername(username);
if (!user) return null;
return {
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
return this.mapToIUser(user);
}
async findByUsername(username: string): Promise<IUser | null> {
@@ -41,12 +46,13 @@ export class UserDaoDbImpl implements UserDao {
username: entity.username,
password: entity.password,
isAdmin: entity.isAdmin || false,
oauthProvider: entity.oauthProvider,
oauthSubject: entity.oauthSubject,
email: entity.email,
displayName: entity.displayName,
avatarUrl: entity.avatarUrl,
});
return {
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
return this.mapToIUser(user);
}
async createWithHashedPassword(
@@ -62,13 +68,14 @@ export class UserDaoDbImpl implements UserDao {
const user = await this.repository.update(username, {
password: entity.password,
isAdmin: entity.isAdmin,
oauthProvider: entity.oauthProvider,
oauthSubject: entity.oauthSubject,
email: entity.email,
displayName: entity.displayName,
avatarUrl: entity.avatarUrl,
});
if (!user) return null;
return {
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
return this.mapToIUser(user);
}
async delete(username: string): Promise<boolean> {
@@ -99,10 +106,6 @@ export class UserDaoDbImpl implements UserDao {
async findAdmins(): Promise<IUser[]> {
const users = await this.repository.findAdmins();
return users.map((u) => ({
username: u.username,
password: u.password,
isAdmin: u.isAdmin,
}));
return users.map(this.mapToIUser);
}
}

View File

@@ -25,46 +25,54 @@ const createRequiredExtensions = async (dataSource: DataSource): Promise<void> =
};
// Get database URL from smart routing config or fallback to environment variable
const getDatabaseUrl = (): string => {
return getSmartRoutingConfig().dbUrl;
const getDatabaseUrl = async (): Promise<string> => {
return (await getSmartRoutingConfig()).dbUrl;
};
// Default database configuration
const defaultConfig: DataSourceOptions = {
// Default database configuration (without URL - will be set during initialization)
const getDefaultConfig = async (): Promise<DataSourceOptions> => {
return {
type: 'postgres',
url: getDatabaseUrl(),
url: await getDatabaseUrl(),
synchronize: true,
entities: entities,
subscribers: [VectorEmbeddingSubscriber],
};
};
// AppDataSource is the TypeORM data source
let appDataSource = new DataSource(defaultConfig);
// AppDataSource is the TypeORM data source (initialized with empty config, will be updated)
let appDataSource: DataSource | null = null;
// Global promise to track initialization status
let initializationPromise: Promise<DataSource> | null = null;
// Function to create a new DataSource with updated configuration
export const updateDataSourceConfig = (): DataSource => {
const newConfig: DataSourceOptions = {
...defaultConfig,
url: getDatabaseUrl(),
};
export const updateDataSourceConfig = async (): Promise<DataSource> => {
const newConfig = await getDefaultConfig();
// If the configuration has changed, we need to create a new DataSource
if (appDataSource) {
const currentUrl = (appDataSource.options as any).url;
if (currentUrl !== newConfig.url) {
const newUrl = (newConfig as any).url;
if (currentUrl !== newUrl) {
console.log('Database URL configuration changed, updating DataSource...');
appDataSource = new DataSource(newConfig);
// Reset initialization promise when configuration changes
initializationPromise = null;
}
} else {
// First time initialization
appDataSource = new DataSource(newConfig);
}
return appDataSource;
};
// Get the current AppDataSource instance
export const getAppDataSource = (): DataSource => {
if (!appDataSource) {
throw new Error('Database not initialized. Call initializeDatabase() first.');
}
return appDataSource;
};
@@ -72,7 +80,7 @@ export const getAppDataSource = (): DataSource => {
export const reconnectDatabase = async (): Promise<DataSource> => {
try {
// Close existing connection if it exists
if (appDataSource.isInitialized) {
if (appDataSource && appDataSource.isInitialized) {
console.log('Closing existing database connection...');
await appDataSource.destroy();
}
@@ -81,7 +89,7 @@ export const reconnectDatabase = async (): Promise<DataSource> => {
initializationPromise = null;
// Update configuration and reconnect
appDataSource = updateDataSourceConfig();
appDataSource = await updateDataSourceConfig();
return await initializeDatabase();
} catch (error) {
console.error('Error during database reconnection:', error);
@@ -98,7 +106,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
}
// If already initialized, return the existing instance
if (appDataSource.isInitialized) {
if (appDataSource && appDataSource.isInitialized) {
console.log('Database already initialized, returning existing instance');
return Promise.resolve(appDataSource);
}
@@ -122,7 +130,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
const performDatabaseInitialization = async (): Promise<DataSource> => {
try {
// Update configuration before initializing
appDataSource = updateDataSourceConfig();
appDataSource = await updateDataSourceConfig();
if (!appDataSource.isInitialized) {
console.log('Initializing database connection...');
@@ -250,7 +258,8 @@ const performDatabaseInitialization = async (): Promise<DataSource> => {
console.log('Database connection established successfully.');
// Run one final setup check after schema synchronization is done
if (defaultConfig.synchronize) {
const config = await getDefaultConfig();
if (config.synchronize) {
try {
console.log('Running final vector configuration check...');
@@ -325,12 +334,12 @@ const performDatabaseInitialization = async (): Promise<DataSource> => {
// Get database connection status
export const isDatabaseConnected = (): boolean => {
return appDataSource.isInitialized;
return appDataSource ? appDataSource.isInitialized : false;
};
// Close database connection
export const closeDatabase = async (): Promise<void> => {
if (appDataSource.isInitialized) {
if (appDataSource && appDataSource.isInitialized) {
await appDataSource.destroy();
console.log('Database connection closed.');
}

View File

@@ -25,7 +25,7 @@ export class BearerKey {
enabled: boolean;
@Column({ type: 'varchar', length: 20, default: 'all' })
accessType: 'all' | 'groups' | 'servers';
accessType: 'all' | 'groups' | 'servers' | 'custom';
@Column({ type: 'simple-json', nullable: true })
allowedGroups?: string[];

View File

@@ -30,6 +30,9 @@ export class SystemConfig {
@Column({ type: 'simple-json', nullable: true })
oauthServer?: Record<string, any>;
@Column({ name: 'oauth_sso', type: 'simple-json', nullable: true })
oauthSso?: Record<string, any>;
@Column({ type: 'boolean', nullable: true })
enableSessionRebuild?: boolean;

View File

@@ -23,6 +23,22 @@ export class User {
@Column({ type: 'boolean', default: false })
isAdmin: boolean;
// OAuth SSO fields
@Column({ name: 'oauth_provider', type: 'varchar', length: 100, nullable: true })
oauthProvider?: string;
@Column({ name: 'oauth_subject', type: 'varchar', length: 255, nullable: true })
oauthSubject?: string;
@Column({ type: 'varchar', length: 255, nullable: true })
email?: string;
@Column({ name: 'display_name', type: 'varchar', length: 255, nullable: true })
displayName?: string;
@Column({ name: 'avatar_url', type: 'text', nullable: true })
avatarUrl?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;

View File

@@ -89,6 +89,19 @@ export class ServerRepository {
async setEnabled(name: string, enabled: boolean): Promise<Server | null> {
return await this.update(name, { enabled });
}
/**
* Rename a server
*/
async rename(oldName: string, newName: string): Promise<boolean> {
const server = await this.findByName(oldName);
if (!server) {
return false;
}
server.name = newName;
await this.repository.save(server);
return true;
}
}
export default ServerRepository;

View File

@@ -112,6 +112,12 @@ import {
updateBearerKey,
deleteBearerKey,
} from '../controllers/bearerKeyController.js';
import {
getOAuthSsoConfig,
initiateOAuthLogin,
handleOAuthCallback as handleOAuthSsoCallback,
listOAuthProviders,
} from '../controllers/oauthSsoController.js';
import { auth } from '../middlewares/auth.js';
const router = express.Router();
@@ -273,6 +279,12 @@ export const initRoutes = (app: express.Application): void => {
changePassword,
);
// OAuth SSO routes (no auth required - these are for logging in)
router.get('/auth/sso/config', getOAuthSsoConfig);
router.get('/auth/sso/providers', listOAuthProviders);
router.get('/auth/sso/:providerId', initiateOAuthLogin);
router.get('/auth/sso/:providerId/callback', handleOAuthSsoCallback);
// Runtime configuration endpoint (no auth required for frontend initialization)
app.get(`${config.basePath}/config`, getRuntimeConfig);

View File

@@ -48,7 +48,9 @@ export const setupClientKeepAlive = async (
await (serverInfo.client as any).ping();
console.log(`Keep-alive ping successful for server: ${serverInfo.name}`);
} else {
await serverInfo.client.listTools({ timeout: 5000 }).catch(() => void 0);
await serverInfo.client
.listTools({}, { ...(serverInfo.options || {}), timeout: 5000 })
.catch(() => void 0);
}
}
} catch (error) {

View File

@@ -0,0 +1,546 @@
/**
* OAuth SSO Service
*
* Handles OAuth 2.0 / OIDC SSO authentication for user login.
* Supports Google, Microsoft, GitHub, and custom OIDC providers.
*/
import * as client from 'openid-client';
import crypto from 'crypto';
import { getSystemConfigDao, getUserDao } from '../dao/index.js';
import { IUser, OAuthSsoProviderConfig, OAuthSsoConfig } from '../types/index.js';
// In-memory store for OAuth state (code verifier, state, etc.)
// NOTE: This implementation uses in-memory storage which is suitable for single-instance deployments.
// For multi-instance/scaled deployments, implement Redis or database-backed state storage
// to ensure OAuth callbacks reach the correct instance where the state was stored.
interface OAuthStateEntry {
codeVerifier: string;
providerId: string;
returnUrl?: string;
createdAt: number;
}
const stateStore = new Map<string, OAuthStateEntry>();
const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
// Cleanup old state entries periodically
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
function startStateCleanup(): void {
if (cleanupInterval) return;
cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [state, entry] of stateStore.entries()) {
if (now - entry.createdAt > STATE_TTL_MS) {
stateStore.delete(state);
}
}
}, 60 * 1000); // Cleanup every minute
}
// Start cleanup on module load
startStateCleanup();
/**
* Stop the state cleanup interval (useful for tests and graceful shutdown)
*/
export function stopStateCleanup(): void {
if (cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = null;
}
}
// GitHub API response types for type safety
interface GitHubUserResponse {
id: number;
login: string;
name?: string;
email?: string;
avatar_url?: string;
}
interface GitHubEmailResponse {
email: string;
primary: boolean;
verified: boolean;
visibility?: string;
}
// Provider configurations cache
const providerConfigsCache = new Map<
string,
{
config: client.Configuration;
provider: OAuthSsoProviderConfig;
}
>();
/**
* Get OAuth SSO configuration from system config
*/
export async function getOAuthSsoConfig(): Promise<OAuthSsoConfig | undefined> {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
return systemConfig?.oauthSso;
}
/**
* Check if OAuth SSO is enabled
*/
export async function isOAuthSsoEnabled(): Promise<boolean> {
const config = await getOAuthSsoConfig();
return config?.enabled === true && (config.providers?.length ?? 0) > 0;
}
/**
* Get enabled OAuth SSO providers
*/
export async function getEnabledProviders(): Promise<OAuthSsoProviderConfig[]> {
const config = await getOAuthSsoConfig();
if (!config?.enabled || !config.providers) {
return [];
}
return config.providers.filter((p) => p.enabled !== false);
}
/**
* Get a specific provider by ID
*/
export async function getProviderById(providerId: string): Promise<OAuthSsoProviderConfig | undefined> {
const providers = await getEnabledProviders();
return providers.find((p) => p.id === providerId);
}
/**
* Get default scopes for a provider type
*/
function getDefaultScopes(type: OAuthSsoProviderConfig['type']): string[] {
switch (type) {
case 'google':
return ['openid', 'email', 'profile'];
case 'microsoft':
return ['openid', 'email', 'profile', 'User.Read'];
case 'github':
return ['read:user', 'user:email'];
case 'oidc':
default:
return ['openid', 'email', 'profile'];
}
}
/**
* Get provider discovery URL
*/
function getDiscoveryUrl(provider: OAuthSsoProviderConfig): string | undefined {
if (provider.issuerUrl) {
return provider.issuerUrl;
}
switch (provider.type) {
case 'google':
return 'https://accounts.google.com';
case 'microsoft':
// Using common endpoint for multi-tenant
return 'https://login.microsoftonline.com/common/v2.0';
case 'github':
// GitHub doesn't support OIDC discovery, we'll use explicit endpoints
return undefined;
default:
return undefined;
}
}
/**
* Get explicit OAuth endpoints for providers without OIDC discovery
*/
function getExplicitEndpoints(provider: OAuthSsoProviderConfig): {
authorizationUrl: string;
tokenUrl: string;
userInfoUrl: string;
} | undefined {
if (provider.type === 'github') {
return {
authorizationUrl: provider.authorizationUrl || 'https://github.com/login/oauth/authorize',
tokenUrl: provider.tokenUrl || 'https://github.com/login/oauth/access_token',
userInfoUrl: provider.userInfoUrl || 'https://api.github.com/user',
};
}
// For custom providers with explicit endpoints
if (provider.authorizationUrl && provider.tokenUrl && provider.userInfoUrl) {
return {
authorizationUrl: provider.authorizationUrl,
tokenUrl: provider.tokenUrl,
userInfoUrl: provider.userInfoUrl,
};
}
return undefined;
}
/**
* Initialize and cache openid-client configuration for a provider
*/
async function getClientConfig(
provider: OAuthSsoProviderConfig,
_callbackUrl: string,
): Promise<client.Configuration> {
const cacheKey = provider.id;
const cached = providerConfigsCache.get(cacheKey);
if (cached) {
return cached.config;
}
let config: client.Configuration;
const discoveryUrl = getDiscoveryUrl(provider);
if (discoveryUrl) {
// Use OIDC discovery
config = await client.discovery(new URL(discoveryUrl), provider.clientId, provider.clientSecret);
} else {
// Use explicit endpoints for providers like GitHub
const endpoints = getExplicitEndpoints(provider);
if (!endpoints) {
throw new Error(
`Provider ${provider.id} requires either issuerUrl for OIDC discovery or explicit endpoints`,
);
}
// Create a manual server metadata configuration
const serverMetadata: client.ServerMetadata = {
issuer: provider.issuerUrl || `https://${provider.type}.oauth`,
authorization_endpoint: endpoints.authorizationUrl,
token_endpoint: endpoints.tokenUrl,
userinfo_endpoint: endpoints.userInfoUrl,
};
config = new client.Configuration(serverMetadata, provider.clientId, provider.clientSecret);
}
providerConfigsCache.set(cacheKey, { config, provider });
return config;
}
/**
* Generate the authorization URL for a provider
*/
export async function generateAuthorizationUrl(
providerId: string,
callbackUrl: string,
returnUrl?: string,
): Promise<{ url: string; state: string }> {
const provider = await getProviderById(providerId);
if (!provider) {
throw new Error(`OAuth SSO provider not found: ${providerId}`);
}
const config = await getClientConfig(provider, callbackUrl);
const scopes = provider.scopes || getDefaultScopes(provider.type);
// Generate PKCE code verifier and challenge
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
// Generate state
const state = crypto.randomBytes(32).toString('base64url');
// Store state for callback verification
stateStore.set(state, {
codeVerifier,
providerId,
returnUrl,
createdAt: Date.now(),
});
// Build authorization URL parameters
const parameters: Record<string, string> = {
redirect_uri: callbackUrl,
scope: scopes.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
};
// GitHub-specific: request user email access
if (provider.type === 'github') {
// GitHub doesn't use PKCE, but we'll still store the state
delete parameters.code_challenge;
delete parameters.code_challenge_method;
}
const url = client.buildAuthorizationUrl(config, parameters);
return { url: url.toString(), state };
}
/**
* Exchange authorization code for tokens and user info
*/
export async function handleCallback(
callbackUrl: string,
currentUrl: string,
state: string,
): Promise<{
user: IUser;
isNewUser: boolean;
returnUrl?: string;
}> {
// Verify and retrieve state
const stateEntry = stateStore.get(state);
if (!stateEntry) {
throw new Error('Invalid or expired OAuth state');
}
// Remove used state
stateStore.delete(state);
const provider = await getProviderById(stateEntry.providerId);
if (!provider) {
throw new Error(`OAuth SSO provider not found: ${stateEntry.providerId}`);
}
const config = await getClientConfig(provider, callbackUrl);
// Exchange code for tokens
let tokens: client.TokenEndpointResponse;
if (provider.type === 'github') {
// GitHub doesn't use PKCE
tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
expectedState: state,
});
} else {
// OIDC providers with PKCE
tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
pkceCodeVerifier: stateEntry.codeVerifier,
expectedState: state,
});
}
// Get user info
const userInfo = await getUserInfo(provider, config, tokens);
// Find or create user
const { user, isNewUser } = await findOrCreateUser(provider, userInfo);
return {
user,
isNewUser,
returnUrl: stateEntry.returnUrl,
};
}
/**
* Fetch user info from the provider
*/
async function getUserInfo(
provider: OAuthSsoProviderConfig,
config: client.Configuration,
tokens: client.TokenEndpointResponse,
): Promise<{
sub: string;
email?: string;
name?: string;
picture?: string;
groups?: string[];
roles?: string[];
[key: string]: unknown;
}> {
if (provider.type === 'github') {
// GitHub uses a different API for user info
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch GitHub user info: ${response.statusText}`);
}
const data = (await response.json()) as GitHubUserResponse;
// Fetch email separately if not public
let email = data.email;
if (!email) {
const emailResponse = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: 'application/json',
},
});
if (emailResponse.ok) {
const emails = (await emailResponse.json()) as GitHubEmailResponse[];
const primaryEmail = emails.find((e) => e.primary);
email = primaryEmail?.email || emails[0]?.email;
}
}
return {
sub: String(data.id),
email,
name: data.name || data.login,
picture: data.avatar_url,
};
}
// Standard OIDC userinfo endpoint
const userInfoResponse = await client.fetchUserInfo(config, tokens.access_token!, client.skipSubjectCheck);
return {
sub: userInfoResponse.sub,
email: userInfoResponse.email as string | undefined,
name: userInfoResponse.name as string | undefined,
picture: userInfoResponse.picture as string | undefined,
groups: userInfoResponse.groups as string[] | undefined,
roles: userInfoResponse.roles as string[] | undefined,
};
}
/**
* Find existing user or create new one based on OAuth profile
*/
async function findOrCreateUser(
provider: OAuthSsoProviderConfig,
userInfo: {
sub: string;
email?: string;
name?: string;
picture?: string;
groups?: string[];
roles?: string[];
[key: string]: unknown;
},
): Promise<{ user: IUser; isNewUser: boolean }> {
const userDao = getUserDao();
// Generate a unique username based on provider and subject
const oauthUsername = `${provider.id}:${userInfo.sub}`;
// Try to find existing user by OAuth identity
let user = await userDao.findByUsername(oauthUsername);
if (user) {
// Update user info if changed
const updates: Partial<IUser> = {};
if (userInfo.email && userInfo.email !== user.email) {
updates.email = userInfo.email;
}
if (userInfo.name && userInfo.name !== user.displayName) {
updates.displayName = userInfo.name;
}
if (userInfo.picture && userInfo.picture !== user.avatarUrl) {
updates.avatarUrl = userInfo.picture;
}
// Check admin status based on claims
const isAdmin = checkAdminClaim(provider, userInfo);
if (isAdmin !== user.isAdmin) {
updates.isAdmin = isAdmin;
}
if (Object.keys(updates).length > 0) {
await userDao.update(oauthUsername, updates);
user = { ...user, ...updates };
}
return { user, isNewUser: false };
}
// Check if auto-provisioning is enabled
if (provider.autoProvision === false) {
throw new Error(
`User not found and auto-provisioning is disabled for provider: ${provider.name}`,
);
}
// Create new user
const isAdmin = checkAdminClaim(provider, userInfo) || provider.defaultAdmin === true;
// Generate a random password for OAuth users (they won't use it)
const randomPassword = crypto.randomBytes(32).toString('hex');
const newUser = await userDao.createWithHashedPassword(oauthUsername, randomPassword, isAdmin);
// Update with OAuth-specific fields
const updatedUser = await userDao.update(oauthUsername, {
oauthProvider: provider.id,
oauthSubject: userInfo.sub,
email: userInfo.email,
displayName: userInfo.name,
avatarUrl: userInfo.picture,
});
return { user: updatedUser || newUser, isNewUser: true };
}
/**
* Check if user should be granted admin based on provider claims
*/
function checkAdminClaim(
provider: OAuthSsoProviderConfig,
userInfo: { groups?: string[]; roles?: string[]; [key: string]: unknown },
): boolean {
if (!provider.adminClaim || !provider.adminClaimValues?.length) {
return false;
}
const claimValue = userInfo[provider.adminClaim];
if (!claimValue) {
return false;
}
// Handle array claims (groups, roles)
if (Array.isArray(claimValue)) {
return claimValue.some((v) => provider.adminClaimValues!.includes(String(v)));
}
// Handle string claims
return provider.adminClaimValues.includes(String(claimValue));
}
/**
* Get public provider info for frontend
*/
export async function getPublicProviderInfo(): Promise<
Array<{
id: string;
name: string;
type: string;
icon?: string;
buttonText?: string;
}>
> {
const providers = await getEnabledProviders();
return providers.map((p) => ({
id: p.id,
name: p.name,
type: p.type,
icon: p.icon || p.type,
buttonText: p.buttonText,
}));
}
/**
* Check if local auth is allowed
*/
export async function isLocalAuthAllowed(): Promise<boolean> {
const config = await getOAuthSsoConfig();
// Default to true if not configured or SSO is disabled
if (!config?.enabled) {
return true;
}
return config.allowLocalAuth !== false;
}
/**
* Clear provider configuration cache
*/
export function clearProviderCache(): void {
providerConfigsCache.clear();
}

View File

@@ -88,6 +88,29 @@ const isBearerKeyAllowedForRequest = async (req: Request, key: BearerKey): Promi
return groupServerNames.some((name) => allowedServers.includes(name));
}
if (key.accessType === 'custom') {
// For custom-scoped keys, check if the group is allowed OR if any server in the group is allowed
const allowedGroups = key.allowedGroups || [];
const allowedServers = key.allowedServers || [];
// Check if the group itself is allowed
const groupAllowed =
allowedGroups.includes(matchedGroup.name) || allowedGroups.includes(matchedGroup.id);
if (groupAllowed) {
return true;
}
// Check if any server in the group is allowed
if (allowedServers.length > 0 && Array.isArray(matchedGroup.servers)) {
const groupServerNames = matchedGroup.servers.map((server) =>
typeof server === 'string' ? server : server.name,
);
return groupServerNames.some((name) => allowedServers.includes(name));
}
return false;
}
// Unknown accessType with matched group
return false;
}
@@ -102,8 +125,8 @@ const isBearerKeyAllowedForRequest = async (req: Request, key: BearerKey): Promi
return false;
}
if (key.accessType === 'servers') {
// For server-scoped keys, check if the server is in allowedServers
if (key.accessType === 'servers' || key.accessType === 'custom') {
// For server-scoped or custom-scoped keys, check if the server is in allowedServers
const allowedServers = key.allowedServers || [];
return allowedServers.includes(matchedServer.name);
}

View File

@@ -1,13 +1,13 @@
import { getRepositoryFactory } from '../db/index.js';
import { VectorEmbeddingRepository } from '../db/repositories/index.js';
import { Tool } from '../types/index.js';
import { getAppDataSource, initializeDatabase } from '../db/connection.js';
import { getAppDataSource, isDatabaseConnected, initializeDatabase } from '../db/connection.js';
import { getSmartRoutingConfig } from '../utils/smartRouting.js';
import OpenAI from 'openai';
// Get OpenAI configuration from smartRouting settings or fallback to environment variables
const getOpenAIConfig = () => {
const smartRoutingConfig = getSmartRoutingConfig();
const getOpenAIConfig = async () => {
const smartRoutingConfig = await getSmartRoutingConfig();
return {
apiKey: smartRoutingConfig.openaiApiKey,
baseURL: smartRoutingConfig.openaiApiBaseUrl,
@@ -34,8 +34,8 @@ const getDimensionsForModel = (model: string): number => {
};
// Initialize the OpenAI client with smartRouting configuration
const getOpenAIClient = () => {
const config = getOpenAIConfig();
const getOpenAIClient = async () => {
const config = await getOpenAIConfig();
return new OpenAI({
apiKey: config.apiKey, // Get API key from smartRouting settings or environment variables
baseURL: config.baseURL, // Get base URL from smartRouting settings or fallback to default
@@ -53,9 +53,8 @@ const getOpenAIClient = () => {
* @returns Promise with vector embedding as number array
*/
async function generateEmbedding(text: string): Promise<number[]> {
try {
const config = getOpenAIConfig();
const openai = getOpenAIClient();
const config = await getOpenAIConfig();
const openai = await getOpenAIClient();
// Check if API key is configured
if (!openai.apiKey) {
@@ -74,11 +73,6 @@ async function generateEmbedding(text: string): Promise<number[]> {
// Return the embedding
return response.data[0].embedding;
} catch (error) {
console.error('Error generating embedding:', error);
console.warn('Falling back to simple embedding method');
return generateFallbackEmbedding(text);
}
}
/**
@@ -198,12 +192,18 @@ export const saveToolsAsVectorEmbeddings = async (
return;
}
const smartRoutingConfig = getSmartRoutingConfig();
const smartRoutingConfig = await getSmartRoutingConfig();
if (!smartRoutingConfig.enabled) {
return;
}
const config = getOpenAIConfig();
// Ensure database is initialized before using repository
if (!isDatabaseConnected()) {
console.info('Database not initialized, initializing...');
await initializeDatabase();
}
const config = await getOpenAIConfig();
const vectorRepository = getRepositoryFactory(
'vectorEmbeddings',
)() as VectorEmbeddingRepository;
@@ -227,7 +227,6 @@ export const saveToolsAsVectorEmbeddings = async (
.filter(Boolean)
.join(' ');
try {
// Generate embedding
const embedding = await generateEmbedding(searchableText);
@@ -248,15 +247,11 @@ export const saveToolsAsVectorEmbeddings = async (
},
config.embeddingModel, // Store the model used for this embedding
);
} catch (toolError) {
console.error(`Error processing tool ${tool.name} for server ${serverName}:`, toolError);
// Continue with the next tool rather than failing the whole batch
}
}
console.log(`Saved ${tools.length} tool embeddings for server: ${serverName}`);
} catch (error) {
console.error(`Error saving tool embeddings for server ${serverName}:`, error);
console.error(`Error saving tool embeddings for server ${serverName}:${error}`);
}
};
@@ -381,7 +376,7 @@ export const getAllVectorizedTools = async (
}>
> => {
try {
const config = getOpenAIConfig();
const config = await getOpenAIConfig();
const vectorRepository = getRepositoryFactory(
'vectorEmbeddings',
)() as VectorEmbeddingRepository;

View File

@@ -10,6 +10,12 @@ export interface IUser {
username: string;
password: string;
isAdmin?: boolean;
// OAuth SSO fields
oauthProvider?: string; // OAuth provider ID (e.g., 'google', 'microsoft', 'github')
oauthSubject?: string; // OAuth subject (unique user ID from provider)
email?: string; // User email (from OAuth profile)
displayName?: string; // Display name (from OAuth profile)
avatarUrl?: string; // Avatar URL (from OAuth profile)
}
// Group interface for server grouping
@@ -124,6 +130,43 @@ export interface MCPRouterCallToolResponse {
isError: boolean;
}
// OAuth SSO Provider Configuration for user authentication
export type OAuthSsoProviderType = 'google' | 'microsoft' | 'github' | 'oidc';
export interface OAuthSsoProviderConfig {
id: string; // Unique identifier for this provider (e.g., 'google', 'my-company-sso')
type: OAuthSsoProviderType; // Provider type
name: string; // Display name (e.g., 'Google', 'Microsoft', 'Company SSO')
enabled?: boolean; // Whether this provider is enabled (default: true)
clientId: string; // OAuth client ID
clientSecret: string; // OAuth client secret
// For OIDC providers, discovery URL or explicit endpoints
issuerUrl?: string; // OIDC issuer URL for auto-discovery (e.g., 'https://accounts.google.com')
// Explicit endpoints (optional, can be auto-discovered for OIDC)
authorizationUrl?: string; // OAuth authorization endpoint
tokenUrl?: string; // OAuth token endpoint
userInfoUrl?: string; // OAuth userinfo endpoint
// Scope configuration
scopes?: string[]; // OAuth scopes to request (default varies by provider)
// Role/admin mapping
adminClaim?: string; // Claim name to check for admin role (e.g., 'groups', 'roles')
adminClaimValues?: string[]; // Values that grant admin access (e.g., ['admin', 'mcphub-admins'])
// Auto-provisioning options
autoProvision?: boolean; // Auto-create users on first login (default: true)
defaultAdmin?: boolean; // Whether auto-provisioned users are admins by default (default: false)
// UI options
icon?: string; // Icon identifier for UI (e.g., 'google', 'microsoft', 'github', 'key')
buttonText?: string; // Custom button text (e.g., 'Sign in with Google')
}
// OAuth SSO configuration in SystemConfig
export interface OAuthSsoConfig {
enabled?: boolean; // Enable/disable OAuth SSO globally
providers?: OAuthSsoProviderConfig[]; // List of configured SSO providers
allowLocalAuth?: boolean; // Allow local username/password auth alongside SSO (default: true)
callbackBaseUrl?: string; // Base URL for OAuth callbacks (auto-detected if not set)
}
// OAuth Provider Configuration for MCP Authorization Server
export interface OAuthProviderConfig {
enabled?: boolean; // Enable/disable OAuth provider
@@ -172,6 +215,7 @@ export interface SystemConfig {
nameSeparator?: string; // Separator used between server name and tool/prompt name (default: '-')
oauth?: OAuthProviderConfig; // OAuth provider configuration for upstream MCP servers
oauthServer?: OAuthServerConfig; // OAuth authorization server configuration for MCPHub itself
oauthSso?: OAuthSsoConfig; // OAuth SSO configuration for user authentication
enableSessionRebuild?: boolean; // Controls whether server session rebuild is enabled
}
@@ -244,7 +288,7 @@ export interface OAuthServerConfig {
}
// Bearer authentication key configuration
export type BearerKeyAccessType = 'all' | 'groups' | 'servers';
export type BearerKeyAccessType = 'all' | 'groups' | 'servers' | 'custom';
export interface BearerKey {
id: string; // Unique identifier for the key
@@ -252,8 +296,8 @@ export interface BearerKey {
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'
allowedGroups?: string[]; // Allowed group names when accessType === 'groups' or 'custom'
allowedServers?: string[]; // Allowed server names when accessType === 'servers' or 'custom'
}
// Represents the settings for MCP servers

View File

@@ -46,6 +46,11 @@ export async function migrateToDatabase(): Promise<boolean> {
username: user.username,
password: user.password,
isAdmin: user.isAdmin || false,
oauthProvider: user.oauthProvider,
oauthSubject: user.oauthSubject,
email: user.email,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
});
console.log(` - Created user: ${user.username}`);
} else {
@@ -116,6 +121,7 @@ export async function migrateToDatabase(): Promise<boolean> {
nameSeparator: settings.systemConfig.nameSeparator,
oauth: settings.systemConfig.oauth || {},
oauthServer: settings.systemConfig.oauthServer || {},
oauthSso: settings.systemConfig.oauthSso || {},
enableSessionRebuild: settings.systemConfig.enableSessionRebuild,
};
await systemConfigRepo.update(systemConfig);

View File

@@ -1,4 +1,5 @@
import { loadSettings, expandEnvVars } from '../config/index.js';
import { expandEnvVars } from '../config/index.js';
import { getSystemConfigDao } from '../dao/DaoFactory.js';
/**
* Smart routing configuration interface
@@ -22,10 +23,11 @@ export interface SmartRoutingConfig {
*
* @returns {SmartRoutingConfig} Complete smart routing configuration
*/
export function getSmartRoutingConfig(): SmartRoutingConfig {
const settings = loadSettings();
const smartRoutingSettings: Partial<SmartRoutingConfig> =
settings.systemConfig?.smartRouting || {};
export async function getSmartRoutingConfig(): Promise<SmartRoutingConfig> {
// Get system config from DAO
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const smartRoutingSettings: Partial<SmartRoutingConfig> = systemConfig.smartRouting || {};
return {
// Enabled status - check multiple environment variables

View File

@@ -0,0 +1,235 @@
// Mock openid-client before importing services
jest.mock('openid-client', () => ({
discovery: jest.fn(),
Configuration: jest.fn(),
randomPKCECodeVerifier: jest.fn(() => 'test-verifier'),
calculatePKCECodeChallenge: jest.fn(() => Promise.resolve('test-challenge')),
buildAuthorizationUrl: jest.fn(() => new URL('https://example.com/authorize')),
authorizationCodeGrant: jest.fn(),
fetchUserInfo: jest.fn(),
skipSubjectCheck: Symbol('skipSubjectCheck'),
}));
// Mock the DAO module
jest.mock('../../src/dao/index.js', () => ({
getSystemConfigDao: jest.fn(),
getUserDao: jest.fn(),
}));
import * as daoModule from '../../src/dao/index.js';
import {
isOAuthSsoEnabled,
getEnabledProviders,
getProviderById,
isLocalAuthAllowed,
getPublicProviderInfo,
clearProviderCache,
stopStateCleanup,
} from '../../src/services/oauthSsoService.js';
describe('OAuth SSO Service', () => {
const mockGetSystemConfigDao = daoModule.getSystemConfigDao as jest.MockedFunction<
typeof daoModule.getSystemConfigDao
>;
const mockGetUserDao = daoModule.getUserDao as jest.MockedFunction<typeof daoModule.getUserDao>;
// Stop the cleanup interval to prevent Jest from hanging
afterAll(() => {
stopStateCleanup();
});
const defaultSsoConfig = {
enabled: true,
allowLocalAuth: true,
providers: [
{
id: 'google',
type: 'google' as const,
name: 'Google',
enabled: true,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scopes: ['openid', 'email', 'profile'],
},
{
id: 'github',
type: 'github' as const,
name: 'GitHub',
enabled: true,
clientId: 'test-github-client',
clientSecret: 'test-github-secret',
},
{
id: 'disabled-provider',
type: 'oidc' as const,
name: 'Disabled',
enabled: false,
clientId: 'disabled-client',
clientSecret: 'disabled-secret',
},
],
};
beforeEach(() => {
jest.clearAllMocks();
clearProviderCache();
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: defaultSsoConfig,
}),
} as any);
mockGetUserDao.mockReturnValue({
findByUsername: jest.fn().mockResolvedValue(null),
createWithHashedPassword: jest.fn().mockResolvedValue({
username: 'google:12345',
password: 'hashed',
isAdmin: false,
}),
update: jest.fn().mockImplementation((username: string, data: any) =>
Promise.resolve({
username,
password: 'hashed',
isAdmin: false,
...data,
})
),
} as any);
});
describe('isOAuthSsoEnabled', () => {
it('should return true when OAuth SSO is enabled with providers', async () => {
const enabled = await isOAuthSsoEnabled();
expect(enabled).toBe(true);
});
it('should return false when OAuth SSO is disabled', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: { ...defaultSsoConfig, enabled: false },
}),
} as any);
const enabled = await isOAuthSsoEnabled();
expect(enabled).toBe(false);
});
it('should return false when no providers are configured', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: { ...defaultSsoConfig, providers: [] },
}),
} as any);
const enabled = await isOAuthSsoEnabled();
expect(enabled).toBe(false);
});
});
describe('getEnabledProviders', () => {
it('should return only enabled providers', async () => {
const providers = await getEnabledProviders();
expect(providers).toHaveLength(2);
expect(providers.map((p) => p.id)).toContain('google');
expect(providers.map((p) => p.id)).toContain('github');
expect(providers.map((p) => p.id)).not.toContain('disabled-provider');
});
it('should return empty array when SSO is disabled', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: { ...defaultSsoConfig, enabled: false },
}),
} as any);
const providers = await getEnabledProviders();
expect(providers).toHaveLength(0);
});
});
describe('getProviderById', () => {
it('should return the correct provider by ID', async () => {
const provider = await getProviderById('google');
expect(provider).toBeDefined();
expect(provider?.id).toBe('google');
expect(provider?.type).toBe('google');
expect(provider?.name).toBe('Google');
});
it('should return undefined for non-existent provider', async () => {
const provider = await getProviderById('non-existent');
expect(provider).toBeUndefined();
});
it('should return undefined for disabled provider', async () => {
const provider = await getProviderById('disabled-provider');
expect(provider).toBeUndefined();
});
});
describe('isLocalAuthAllowed', () => {
it('should return true when local auth is allowed', async () => {
const allowed = await isLocalAuthAllowed();
expect(allowed).toBe(true);
});
it('should return false when local auth is disabled', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: { ...defaultSsoConfig, allowLocalAuth: false },
}),
} as any);
const allowed = await isLocalAuthAllowed();
expect(allowed).toBe(false);
});
it('should return true when SSO is disabled (fallback)', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: undefined,
}),
} as any);
const allowed = await isLocalAuthAllowed();
expect(allowed).toBe(true);
});
});
describe('getPublicProviderInfo', () => {
it('should return public info for enabled providers only', async () => {
const info = await getPublicProviderInfo();
expect(info).toHaveLength(2);
const googleInfo = info.find((p) => p.id === 'google');
expect(googleInfo).toBeDefined();
expect(googleInfo?.name).toBe('Google');
expect(googleInfo?.type).toBe('google');
expect(googleInfo?.icon).toBe('google');
// Ensure sensitive data is not exposed
expect((googleInfo as any)?.clientSecret).toBeUndefined();
expect((googleInfo as any)?.clientId).toBeUndefined();
});
it('should include buttonText when specified', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: {
...defaultSsoConfig,
providers: [
{
...defaultSsoConfig.providers[0],
buttonText: 'Login with Google',
},
],
},
}),
} as any);
const info = await getPublicProviderInfo();
expect(info[0].buttonText).toBe('Login with Google');
});
});
});