From 7f1e4d5de134db404b028560362f136bb180d531 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:17:07 +0000 Subject: [PATCH] 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> --- frontend/src/App.tsx | 2 + frontend/src/pages/LoginPage.tsx | 202 ++++++--- frontend/src/pages/OAuthCallbackPage.tsx | 42 ++ frontend/src/services/authService.ts | 25 ++ frontend/src/types/index.ts | 15 + locales/en.json | 20 + locales/zh.json | 20 + src/controllers/oauthSsoController.ts | 221 ++++++++++ src/dao/SystemConfigDaoDbImpl.ts | 3 + src/dao/UserDaoDbImpl.ts | 51 +-- src/db/entities/SystemConfig.ts | 3 + src/db/entities/User.ts | 16 + src/routes/index.ts | 12 + src/services/oauthSsoService.ts | 510 +++++++++++++++++++++++ src/types/index.ts | 44 ++ src/utils/migration.ts | 6 + tests/services/oauthSsoService.test.ts | 229 ++++++++++ 17 files changed, 1350 insertions(+), 71 deletions(-) create mode 100644 frontend/src/pages/OAuthCallbackPage.tsx create mode 100644 src/controllers/oauthSsoController.ts create mode 100644 src/services/oauthSsoService.ts create mode 100644 tests/services/oauthSsoService.test.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dd87b1d..5de9b66 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { {/* 公共路由 */} } /> + } /> {/* 受保护的路由,使用 MainLayout 作为布局容器 */} }> diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 0e47085..5700203 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -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 ( + + + + + + + ); + case 'microsoft': + return ( + + + + + + + ); + case 'github': + return ( + + + + ); + default: + return ( + + + + + + ); + } +}; + const LoginPage: React.FC = () => { const { t } = useTranslation(); const [username, setUsername] = useState(''); @@ -36,6 +75,7 @@ const LoginPage: React.FC = () => { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false); + const [ssoConfig, setSsoConfig] = useState(null); const { login } = useAuth(); const location = useLocation(); const navigate = useNavigate(); @@ -44,6 +84,25 @@ 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(); @@ -137,11 +196,18 @@ const LoginPage: React.FC = () => { } }; + 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 (
{/* Top-right controls */} @@ -193,58 +259,100 @@ const LoginPage: React.FC = () => {
-
-
-
- - setUsername(e.target.value)} - /> + + {/* SSO Providers */} + {showSsoProviders && ( +
+ {ssoConfig.providers.map((provider) => ( + + ))} +
+ )} + + {/* Divider between SSO and local auth */} + {showSsoProviders && showLocalAuth && ( +
+
+
-
- - setPassword(e.target.value)} - /> +
+ + {t('oauthSso.orContinueWith')} +
+ )} - {error && ( -
- {error} + {/* Local auth form */} + {showLocalAuth && ( + +
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
- )} -
- + {error && ( +
+ {error} +
+ )} + +
+ +
+ + )} + + {/* Error display for SSO-only mode */} + {!showLocalAuth && error && ( +
+ {error}
- + )}
diff --git a/frontend/src/pages/OAuthCallbackPage.tsx b/frontend/src/pages/OAuthCallbackPage.tsx new file mode 100644 index 0000000..e824aa5 --- /dev/null +++ b/frontend/src/pages/OAuthCallbackPage.tsx @@ -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 ( +
+
+
+

Completing authentication...

+
+
+ ); +}; + +export default OAuthCallbackPage; diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index a5f9255..549b054 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -3,6 +3,7 @@ import { LoginCredentials, RegisterCredentials, ChangePasswordCredentials, + OAuthSsoConfig, } from '../types'; import { apiPost, apiGet } from '../utils/fetchInterceptor'; import { getToken, setToken, removeToken } from '../utils/interceptors'; @@ -105,6 +106,30 @@ export const changePassword = async ( } }; +// Get OAuth SSO configuration +export const getOAuthSsoConfig = async (): Promise => { + 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(); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index cfef4c8..65a7fc4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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[]; diff --git a/locales/en.json b/locales/en.json index 8c58c00..95f369f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -840,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" + } } } \ No newline at end of file diff --git a/locales/zh.json b/locales/zh.json index c5848d5..49198b2 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -842,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" + } } } \ No newline at end of file diff --git a/src/controllers/oauthSsoController.ts b/src/controllers/oauthSsoController.ts new file mode 100644 index 0000000..a417f6d --- /dev/null +++ b/src/controllers/oauthSsoController.ts @@ -0,0 +1,221 @@ +/** + * OAuth SSO Controller + * + * Handles OAuth SSO authentication endpoints. + */ + +import { Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import { + generateAuthorizationUrl, + handleCallback, + getPublicProviderInfo, + isLocalAuthAllowed, + isOAuthSsoEnabled, +} from '../services/oauthSsoService.js'; +import { JWT_SECRET } from '../config/jwt.js'; +import config from '../config/index.js'; + +const TOKEN_EXPIRY = '24h'; + +/** + * Get OAuth SSO configuration for frontend + * Returns enabled providers and whether local auth is allowed + */ +export const getOAuthSsoConfig = async (req: Request, res: Response): Promise => { + 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 => { + 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 + const baseUrl = + req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host'] + ? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}` + : `${req.protocol}://${req.get('host')}`; + + 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 => { + 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 = + req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host'] + ? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}` + : `${req.protocol}://${req.get('host')}`; + + 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).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 => { + 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')}`; +} diff --git a/src/dao/SystemConfigDaoDbImpl.ts b/src/dao/SystemConfigDaoDbImpl.ts index e4c10cf..cecc177 100644 --- a/src/dao/SystemConfigDaoDbImpl.ts +++ b/src/dao/SystemConfigDaoDbImpl.ts @@ -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, }; } diff --git a/src/dao/UserDaoDbImpl.ts b/src/dao/UserDaoDbImpl.ts index 554c85c..fb15ab9 100644 --- a/src/dao/UserDaoDbImpl.ts +++ b/src/dao/UserDaoDbImpl.ts @@ -13,23 +13,28 @@ export class UserDaoDbImpl implements UserDao { this.repository = new UserRepository(); } - async findAll(): Promise { - 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 { + const users = await this.repository.findAll(); + return users.map(this.mapToIUser); } async findById(username: string): Promise { 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 { @@ -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 { @@ -99,10 +106,6 @@ export class UserDaoDbImpl implements UserDao { async findAdmins(): Promise { const users = await this.repository.findAdmins(); - return users.map((u) => ({ - username: u.username, - password: u.password, - isAdmin: u.isAdmin, - })); + return users.map(this.mapToIUser); } } diff --git a/src/db/entities/SystemConfig.ts b/src/db/entities/SystemConfig.ts index a974d6f..dcf83d4 100644 --- a/src/db/entities/SystemConfig.ts +++ b/src/db/entities/SystemConfig.ts @@ -30,6 +30,9 @@ export class SystemConfig { @Column({ type: 'simple-json', nullable: true }) oauthServer?: Record; + @Column({ name: 'oauth_sso', type: 'simple-json', nullable: true }) + oauthSso?: Record; + @Column({ type: 'boolean', nullable: true }) enableSessionRebuild?: boolean; diff --git a/src/db/entities/User.ts b/src/db/entities/User.ts index 86e2359..4d45934 100644 --- a/src/db/entities/User.ts +++ b/src/db/entities/User.ts @@ -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; diff --git a/src/routes/index.ts b/src/routes/index.ts index 8af6e93..576ec37 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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); diff --git a/src/services/oauthSsoService.ts b/src/services/oauthSsoService.ts new file mode 100644 index 0000000..a2e3929 --- /dev/null +++ b/src/services/oauthSsoService.ts @@ -0,0 +1,510 @@ +/** + * 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.) +// In production, consider using Redis or database for multi-instance deployments +interface OAuthStateEntry { + codeVerifier: string; + providerId: string; + returnUrl?: string; + createdAt: number; +} + +const stateStore = new Map(); +const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +// Cleanup old state entries periodically +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 + +// 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 { + const systemConfigDao = getSystemConfigDao(); + const systemConfig = await systemConfigDao.get(); + return systemConfig?.oauthSso; +} + +/** + * Check if OAuth SSO is enabled + */ +export async function isOAuthSsoEnabled(): Promise { + const config = await getOAuthSsoConfig(); + return config?.enabled === true && (config.providers?.length ?? 0) > 0; +} + +/** + * Get enabled OAuth SSO providers + */ +export async function getEnabledProviders(): Promise { + 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 { + 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 { + 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 = { + 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(); + + // 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(); + const primaryEmail = emails.find((e: any) => 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 = {}; + 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 { + 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(); +} diff --git a/src/types/index.ts b/src/types/index.ts index e5e8186..ad591e3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 } diff --git a/src/utils/migration.ts b/src/utils/migration.ts index 4e059eb..984d79f 100644 --- a/src/utils/migration.ts +++ b/src/utils/migration.ts @@ -46,6 +46,11 @@ export async function migrateToDatabase(): Promise { 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 { nameSeparator: settings.systemConfig.nameSeparator, oauth: settings.systemConfig.oauth || {}, oauthServer: settings.systemConfig.oauthServer || {}, + oauthSso: settings.systemConfig.oauthSso || {}, enableSessionRebuild: settings.systemConfig.enableSessionRebuild, }; await systemConfigRepo.update(systemConfig); diff --git a/tests/services/oauthSsoService.test.ts b/tests/services/oauthSsoService.test.ts new file mode 100644 index 0000000..7db846e --- /dev/null +++ b/tests/services/oauthSsoService.test.ts @@ -0,0 +1,229 @@ +// 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, +} 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; + + 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'); + }); + }); +});