From 0e9d42a60b6f3ea421e7a1d176a8ef2bb350b8ad Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 11 Jan 2026 14:46:23 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=90=20feat:=20Admin=20Auth.=20Routes?= =?UTF-8?q?=20with=20Secure=20Cross-Origin=20Token=20Exchange=20(#11297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement admin authentication with OpenID & Local Auth proxy support * feat: implement admin OAuth exchange flow with caching support - Added caching for admin OAuth exchange codes with a short TTL. - Introduced new endpoints for generating and exchanging admin OAuth codes. - Updated relevant controllers and routes to handle admin panel redirects and token exchanges. - Enhanced logging for better traceability of OAuth operations. * refactor: enhance OpenID strategy mock to support multiple verify callbacks - Updated the OpenID strategy mock to store and retrieve verify callbacks by strategy name. - Improved backward compatibility by maintaining a method to get the last registered callback. - Adjusted tests to utilize the new callback retrieval methods, ensuring clarity in the verification process for the 'openid' strategy. * refactor: reorder import statements for better organization * refactor: admin OAuth flow with improved URL handling and validation - Added a utility function to retrieve the admin panel URL, defaulting to a local development URL if not set in the environment. - Updated the OAuth exchange endpoint to include validation for the authorization code format. - Refactored the admin panel redirect logic to handle URL parsing more robustly, ensuring accurate origin comparisons. - Removed redundant local URL definitions from the codebase for better maintainability. * refactor: remove deprecated requireAdmin middleware and migrate to TypeScript - Deleted the old requireAdmin middleware file and its references in the middleware index. - Introduced a new TypeScript version of the requireAdmin middleware with enhanced error handling and logging. - Updated routes to utilize the new requireAdmin middleware, ensuring consistent access control for admin routes. * feat: add requireAdmin middleware for admin role verification - Introduced requireAdmin middleware to enforce admin role checks for authenticated users. - Implemented comprehensive error handling and logging for unauthorized access attempts. - Added unit tests to validate middleware functionality and ensure proper behavior for different user roles. - Updated middleware index to include the new requireAdmin export. --- api/cache/getLogStores.js | 4 + api/server/controllers/auth/oauth.js | 79 ++++ api/server/index.js | 1 + api/server/routes/admin/auth.js | 127 ++++++ api/server/routes/index.js | 2 + api/server/routes/oauth.js | 34 +- api/strategies/index.js | 14 +- api/strategies/openidStrategy.js | 498 ++++++++++++---------- api/strategies/openidStrategy.spec.js | 86 ++-- packages/api/src/auth/exchange.ts | 157 +++++++ packages/api/src/auth/index.ts | 1 + packages/api/src/middleware/admin.spec.ts | 140 ++++++ packages/api/src/middleware/admin.ts | 28 ++ packages/api/src/middleware/index.ts | 1 + packages/data-provider/src/config.ts | 4 + 15 files changed, 878 insertions(+), 298 deletions(-) create mode 100644 api/server/controllers/auth/oauth.js create mode 100644 api/server/routes/admin/auth.js create mode 100644 packages/api/src/auth/exchange.ts create mode 100644 packages/api/src/middleware/admin.spec.ts create mode 100644 packages/api/src/middleware/admin.ts diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index 40aac08ee6..5940689957 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -51,6 +51,10 @@ const namespaces = { CacheKeys.OPENID_EXCHANGED_TOKENS, Time.TEN_MINUTES, ), + [CacheKeys.ADMIN_OAUTH_EXCHANGE]: standardCache( + CacheKeys.ADMIN_OAUTH_EXCHANGE, + Time.THIRTY_SECONDS, + ), }; /** diff --git a/api/server/controllers/auth/oauth.js b/api/server/controllers/auth/oauth.js new file mode 100644 index 0000000000..80c2ced002 --- /dev/null +++ b/api/server/controllers/auth/oauth.js @@ -0,0 +1,79 @@ +const { CacheKeys } = require('librechat-data-provider'); +const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas'); +const { + isEnabled, + getAdminPanelUrl, + isAdminPanelRedirect, + generateAdminExchangeCode, +} = require('@librechat/api'); +const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService'); +const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService'); +const getLogStores = require('~/cache/getLogStores'); +const { checkBan } = require('~/server/middleware'); +const { generateToken } = require('~/models'); + +const domains = { + client: process.env.DOMAIN_CLIENT, + server: process.env.DOMAIN_SERVER, +}; + +function createOAuthHandler(redirectUri = domains.client) { + /** + * A handler to process OAuth authentication results. + * @type {Function} + * @param {ServerRequest} req - Express request object. + * @param {ServerResponse} res - Express response object. + * @param {NextFunction} next - Express next middleware function. + */ + return async (req, res, next) => { + try { + if (res.headersSent) { + return; + } + + await checkBan(req, res); + if (req.banned) { + return; + } + + /** Check if this is an admin panel redirect (cross-origin) */ + if (isAdminPanelRedirect(redirectUri, getAdminPanelUrl(), domains.client)) { + /** For admin panel, generate exchange code instead of setting cookies */ + const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); + const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY; + const token = await generateToken(req.user, sessionExpiry); + + /** Get refresh token from tokenset for OpenID users */ + const refreshToken = + req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token; + + const exchangeCode = await generateAdminExchangeCode(cache, req.user, token, refreshToken); + + const callbackUrl = new URL(redirectUri); + callbackUrl.searchParams.set('code', exchangeCode); + logger.info(`[OAuth] Admin panel redirect with exchange code for user: ${req.user.email}`); + return res.redirect(callbackUrl.toString()); + } + + /** Standard OAuth flow - set cookies and redirect */ + if ( + req.user && + req.user.provider == 'openid' && + isEnabled(process.env.OPENID_REUSE_TOKENS) === true + ) { + await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token); + setOpenIDAuthTokens(req.user.tokenset, req, res, req.user._id.toString()); + } else { + await setAuthTokens(req.user._id, res); + } + res.redirect(redirectUri); + } catch (err) { + logger.error('Error in setting authentication tokens:', err); + next(err); + } + }; +} + +module.exports = { + createOAuthHandler, +}; diff --git a/api/server/index.js b/api/server/index.js index a7ddd47f37..d5129c9a7e 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -134,6 +134,7 @@ const startServer = async () => { app.use('/oauth', routes.oauth); /* API Endpoints */ app.use('/api/auth', routes.auth); + app.use('/api/admin', routes.adminAuth); app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/user', routes.user); diff --git a/api/server/routes/admin/auth.js b/api/server/routes/admin/auth.js new file mode 100644 index 0000000000..291b5eaaf8 --- /dev/null +++ b/api/server/routes/admin/auth.js @@ -0,0 +1,127 @@ +const express = require('express'); +const passport = require('passport'); +const { randomState } = require('openid-client'); +const { logger } = require('@librechat/data-schemas'); +const { CacheKeys } = require('librechat-data-provider'); +const { + requireAdmin, + getAdminPanelUrl, + exchangeAdminCode, + createSetBalanceConfig, +} = require('@librechat/api'); +const { loginController } = require('~/server/controllers/auth/LoginController'); +const { createOAuthHandler } = require('~/server/controllers/auth/oauth'); +const { getAppConfig } = require('~/server/services/Config'); +const getLogStores = require('~/cache/getLogStores'); +const { getOpenIdConfig } = require('~/strategies'); +const middleware = require('~/server/middleware'); +const { Balance } = require('~/db/models'); + +const setBalanceConfig = createSetBalanceConfig({ + getAppConfig, + Balance, +}); + +const router = express.Router(); + +router.post( + '/login/local', + middleware.logHeaders, + middleware.loginLimiter, + middleware.checkBan, + middleware.requireLocalAuth, + requireAdmin, + setBalanceConfig, + loginController, +); + +router.get('/verify', middleware.requireJwtAuth, requireAdmin, (req, res) => { + const { password: _p, totpSecret: _t, __v, ...user } = req.user; + user.id = user._id.toString(); + res.status(200).json({ user }); +}); + +router.get('/oauth/openid/check', (req, res) => { + const openidConfig = getOpenIdConfig(); + if (!openidConfig) { + return res.status(404).json({ + error: 'OpenID configuration not found', + error_code: 'OPENID_NOT_CONFIGURED', + }); + } + res.status(200).json({ message: 'OpenID check successful' }); +}); + +router.get('/oauth/openid', (req, res, next) => { + return passport.authenticate('openidAdmin', { + session: false, + state: randomState(), + })(req, res, next); +}); + +router.get( + '/oauth/openid/callback', + passport.authenticate('openidAdmin', { + failureRedirect: `${getAdminPanelUrl()}/auth/openid/callback?error=auth_failed&error_description=Authentication+failed`, + failureMessage: true, + session: false, + }), + requireAdmin, + setBalanceConfig, + middleware.checkDomainAllowed, + createOAuthHandler(`${getAdminPanelUrl()}/auth/openid/callback`), +); + +/** Regex pattern for valid exchange codes: 64 hex characters */ +const EXCHANGE_CODE_PATTERN = /^[a-f0-9]{64}$/i; + +/** + * Exchange OAuth authorization code for tokens. + * This endpoint is called server-to-server by the admin panel. + * The code is one-time-use and expires in 30 seconds. + * + * POST /api/admin/oauth/exchange + * Body: { code: string } + * Response: { token: string, refreshToken: string, user: object } + */ +router.post('/oauth/exchange', middleware.loginLimiter, async (req, res) => { + try { + const { code } = req.body; + + if (!code) { + logger.warn('[admin/oauth/exchange] Missing authorization code'); + return res.status(400).json({ + error: 'Missing authorization code', + error_code: 'MISSING_CODE', + }); + } + + if (typeof code !== 'string' || !EXCHANGE_CODE_PATTERN.test(code)) { + logger.warn('[admin/oauth/exchange] Invalid authorization code format'); + return res.status(400).json({ + error: 'Invalid authorization code format', + error_code: 'INVALID_CODE_FORMAT', + }); + } + + const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); + const result = await exchangeAdminCode(cache, code); + + if (!result) { + return res.status(401).json({ + error: 'Invalid or expired authorization code', + error_code: 'INVALID_OR_EXPIRED_CODE', + }); + } + + res.json(result); + } catch (error) { + logger.error('[admin/oauth/exchange] Error:', error); + res.status(500).json({ + error: 'Internal server error', + error_code: 'INTERNAL_ERROR', + }); + } +}); + +module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index f3571099cb..785e74bb8f 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -1,6 +1,7 @@ const accessPermissions = require('./accessPermissions'); const assistants = require('./assistants'); const categories = require('./categories'); +const adminAuth = require('./admin/auth'); const endpoints = require('./endpoints'); const staticRoute = require('./static'); const messages = require('./messages'); @@ -28,6 +29,7 @@ const mcp = require('./mcp'); module.exports = { mcp, auth, + adminAuth, keys, user, tags, diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 64d29210ac..4a2e2f70c6 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -4,10 +4,9 @@ const passport = require('passport'); const { randomState } = require('openid-client'); const { logger } = require('@librechat/data-schemas'); const { ErrorTypes } = require('librechat-data-provider'); -const { isEnabled, createSetBalanceConfig } = require('@librechat/api'); -const { checkDomainAllowed, loginLimiter, logHeaders, checkBan } = require('~/server/middleware'); -const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService'); -const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService'); +const { createSetBalanceConfig } = require('@librechat/api'); +const { checkDomainAllowed, loginLimiter, logHeaders } = require('~/server/middleware'); +const { createOAuthHandler } = require('~/server/controllers/auth/oauth'); const { getAppConfig } = require('~/server/services/Config'); const { Balance } = require('~/db/models'); @@ -26,32 +25,7 @@ const domains = { router.use(logHeaders); router.use(loginLimiter); -const oauthHandler = async (req, res, next) => { - try { - if (res.headersSent) { - return; - } - - await checkBan(req, res); - if (req.banned) { - return; - } - if ( - req.user && - req.user.provider == 'openid' && - isEnabled(process.env.OPENID_REUSE_TOKENS) === true - ) { - await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token); - setOpenIDAuthTokens(req.user.tokenset, req, res, req.user._id.toString()); - } else { - await setAuthTokens(req.user._id, res); - } - res.redirect(domains.client); - } catch (err) { - logger.error('Error in setting authentication tokens:', err); - next(err); - } -}; +const oauthHandler = createOAuthHandler(); router.get('/error', (req, res) => { /** A single error message is pushed by passport when authentication fails. */ diff --git a/api/strategies/index.js b/api/strategies/index.js index 725e04224a..b4f7bd3cac 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -1,14 +1,14 @@ -const appleLogin = require('./appleStrategy'); +const { setupOpenId, getOpenIdConfig } = require('./openidStrategy'); +const openIdJwtLogin = require('./openIdJwtStrategy'); +const facebookLogin = require('./facebookStrategy'); +const discordLogin = require('./discordStrategy'); const passportLogin = require('./localStrategy'); const googleLogin = require('./googleStrategy'); const githubLogin = require('./githubStrategy'); -const discordLogin = require('./discordStrategy'); -const facebookLogin = require('./facebookStrategy'); -const { setupOpenId, getOpenIdConfig } = require('./openidStrategy'); -const jwtLogin = require('./jwtStrategy'); -const ldapLogin = require('./ldapStrategy'); const { setupSaml } = require('./samlStrategy'); -const openIdJwtLogin = require('./openIdJwtStrategy'); +const appleLogin = require('./appleStrategy'); +const ldapLogin = require('./ldapStrategy'); +const jwtLogin = require('./jwtStrategy'); module.exports = { appleLogin, diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index a4369e601b..84458ce992 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -6,8 +6,8 @@ const client = require('openid-client'); const jwtDecode = require('jsonwebtoken/decode'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { hashToken, logger } = require('@librechat/data-schemas'); -const { CacheKeys, ErrorTypes } = require('librechat-data-provider'); const { Strategy: OpenIDStrategy } = require('openid-client/passport'); +const { CacheKeys, ErrorTypes, SystemRoles } = require('librechat-data-provider'); const { isEnabled, logHeaders, @@ -287,6 +287,274 @@ function convertToUsername(input, defaultValue = '') { return defaultValue; } +/** + * Process OpenID authentication tokenset and userinfo + * This is the core logic extracted from the passport strategy callback + * Can be reused by both the passport strategy and proxy authentication + * + * @param {Object} tokenset - The OpenID tokenset containing access_token, id_token, etc. + * @param {boolean} existingUsersOnly - If true, only existing users will be processed + * @returns {Promise} The authenticated user object with tokenset + */ +async function processOpenIDAuth(tokenset, existingUsersOnly = false) { + const claims = tokenset.claims ? tokenset.claims() : tokenset; + const userinfo = { + ...claims, + }; + + if (tokenset.access_token) { + const providerUserinfo = await getUserInfo(openidConfig, tokenset.access_token, claims.sub); + Object.assign(userinfo, providerUserinfo); + } + + const appConfig = await getAppConfig(); + /** Azure AD sometimes doesn't return email, use preferred_username as fallback */ + const email = userinfo.email || userinfo.preferred_username || userinfo.upn; + if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { + logger.error( + `[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`, + ); + throw new Error('Email domain not allowed'); + } + + const result = await findOpenIDUser({ + findUser, + email: email, + openidId: claims.sub || userinfo.sub, + idOnTheSource: claims.oid || userinfo.oid, + strategyName: 'openidStrategy', + }); + let user = result.user; + const error = result.error; + + if (error) { + throw new Error(ErrorTypes.AUTH_FAILED); + } + + const fullName = getFullName(userinfo); + + const requiredRole = process.env.OPENID_REQUIRED_ROLE; + if (requiredRole) { + const requiredRoles = requiredRole + .split(',') + .map((role) => role.trim()) + .filter(Boolean); + const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH; + const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND; + + let decodedToken = ''; + if (requiredRoleTokenKind === 'access' && tokenset.access_token) { + decodedToken = jwtDecode(tokenset.access_token); + } else if (requiredRoleTokenKind === 'id' && tokenset.id_token) { + decodedToken = jwtDecode(tokenset.id_token); + } + + let roles = get(decodedToken, requiredRoleParameterPath); + if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) { + logger.error( + `[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`, + ); + const rolesList = + requiredRoles.length === 1 + ? `"${requiredRoles[0]}"` + : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; + throw new Error(`You must have ${rolesList} role to log in.`); + } + + if (!requiredRoles.some((role) => roles.includes(role))) { + const rolesList = + requiredRoles.length === 1 + ? `"${requiredRoles[0]}"` + : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; + throw new Error(`You must have ${rolesList} role to log in.`); + } + } + + let username = ''; + if (process.env.OPENID_USERNAME_CLAIM) { + username = userinfo[process.env.OPENID_USERNAME_CLAIM]; + } else { + username = convertToUsername( + userinfo.preferred_username || userinfo.username || userinfo.email, + ); + } + + if (existingUsersOnly && !user) { + throw new Error('User does not exist'); + } + + if (!user) { + user = { + provider: 'openid', + openidId: userinfo.sub, + username, + email: email || '', + emailVerified: userinfo.email_verified || false, + name: fullName, + idOnTheSource: userinfo.oid, + }; + + const balanceConfig = getBalanceConfig(appConfig); + user = await createUser(user, balanceConfig, true, true); + } else { + user.provider = 'openid'; + user.openidId = userinfo.sub; + user.username = username; + user.name = fullName; + user.idOnTheSource = userinfo.oid; + if (email && email !== user.email) { + user.email = email; + user.emailVerified = userinfo.email_verified || false; + } + } + + const adminRole = process.env.OPENID_ADMIN_ROLE; + const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; + const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + + if (adminRole && adminRoleParameterPath && adminRoleTokenKind) { + let adminRoleObject; + switch (adminRoleTokenKind) { + case 'access': + adminRoleObject = jwtDecode(tokenset.access_token); + break; + case 'id': + adminRoleObject = jwtDecode(tokenset.id_token); + break; + case 'userinfo': + adminRoleObject = userinfo; + break; + default: + logger.error( + `[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`, + ); + throw new Error('Invalid admin role token kind'); + } + + const adminRoles = get(adminRoleObject, adminRoleParameterPath); + + if ( + adminRoles && + (adminRoles === true || + adminRoles === adminRole || + (Array.isArray(adminRoles) && adminRoles.includes(adminRole))) + ) { + user.role = SystemRoles.ADMIN; + logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`); + } else if (user.role === SystemRoles.ADMIN) { + user.role = SystemRoles.USER; + logger.info( + `[openidStrategy] User ${username} demoted from admin - role no longer present in token`, + ); + } + } + + if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { + /** @type {string | undefined} */ + const imageUrl = userinfo.picture; + + let fileName; + if (crypto) { + fileName = (await hashToken(userinfo.sub)) + '.png'; + } else { + fileName = userinfo.sub + '.png'; + } + + const imageBuffer = await downloadImage( + imageUrl, + openidConfig, + tokenset.access_token, + userinfo.sub, + ); + if (imageBuffer) { + const { saveBuffer } = getStrategyFunctions( + appConfig?.fileStrategy ?? process.env.CDN_PROVIDER, + ); + const imagePath = await saveBuffer({ + fileName, + userId: user._id.toString(), + buffer: imageBuffer, + }); + user.avatar = imagePath ?? ''; + } + } + + user = await updateUser(user._id, user); + + logger.info( + `[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `, + { + user: { + openidId: user.openidId, + username: user.username, + email: user.email, + name: user.name, + }, + }, + ); + + return { + ...user, + tokenset, + federatedTokens: { + access_token: tokenset.access_token, + refresh_token: tokenset.refresh_token, + expires_at: tokenset.expires_at, + }, + }; +} + +/** + * @param {boolean | undefined} [existingUsersOnly] + */ +function createOpenIDCallback(existingUsersOnly) { + return async (tokenset, done) => { + try { + const user = await processOpenIDAuth(tokenset, existingUsersOnly); + done(null, user); + } catch (err) { + if (err.message === 'Email domain not allowed') { + return done(null, false, { message: err.message }); + } + if (err.message === ErrorTypes.AUTH_FAILED) { + return done(null, false, { message: err.message }); + } + if (err.message && err.message.includes('role to log in')) { + return done(null, false, { message: err.message }); + } + logger.error('[openidStrategy] login failed', err); + done(err); + } + }; +} + +/** + * Sets up the OpenID strategy specifically for admin authentication. + * @param {Configuration} openidConfig + */ +const setupOpenIdAdmin = (openidConfig) => { + try { + if (!openidConfig) { + throw new Error('OpenID configuration not initialized'); + } + + const openidAdminLogin = new CustomOpenIDStrategy( + { + config: openidConfig, + scope: process.env.OPENID_SCOPE, + usePKCE: isEnabled(process.env.OPENID_USE_PKCE), + clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300, + callbackURL: process.env.DOMAIN_SERVER + '/api/admin/oauth/openid/callback', + }, + createOpenIDCallback(true), + ); + + passport.use('openidAdmin', openidAdminLogin); + } catch (err) { + logger.error('[openidStrategy] setupOpenIdAdmin', err); + } +}; + /** * Sets up the OpenID strategy for authentication. * This function configures the OpenID client, handles proxy settings, @@ -324,10 +592,6 @@ async function setupOpenId() { }, ); - const requiredRole = process.env.OPENID_REQUIRED_ROLE; - const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH; - const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND; - const usePKCE = isEnabled(process.env.OPENID_USE_PKCE); logger.info(`[openidStrategy] OpenID authentication configuration`, { generateNonce: shouldGenerateNonce, reason: shouldGenerateNonce @@ -335,241 +599,25 @@ async function setupOpenId() { : 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata', }); - // Set of env variables that specify how to set if a user is an admin - // If not set, all users will be treated as regular users - const adminRole = process.env.OPENID_ADMIN_ROLE; - const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; - const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; - const openidLogin = new CustomOpenIDStrategy( { config: openidConfig, scope: process.env.OPENID_SCOPE, callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL, clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300, - usePKCE, - }, - /** - * @param {import('openid-client').TokenEndpointResponseHelpers} tokenset - * @param {import('passport-jwt').VerifyCallback} done - */ - async (tokenset, done) => { - try { - const claims = tokenset.claims(); - const userinfo = { - ...claims, - ...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)), - }; - - const appConfig = await getAppConfig(); - /** Azure AD sometimes doesn't return email, use preferred_username as fallback */ - const email = userinfo.email || userinfo.preferred_username || userinfo.upn; - if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { - logger.error( - `[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${email}]`, - ); - return done(null, false, { message: 'Email domain not allowed' }); - } - - const result = await findOpenIDUser({ - findUser, - email: email, - openidId: claims.sub, - idOnTheSource: claims.oid, - strategyName: 'openidStrategy', - }); - let user = result.user; - const error = result.error; - - if (error) { - return done(null, false, { - message: ErrorTypes.AUTH_FAILED, - }); - } - - const fullName = getFullName(userinfo); - - if (requiredRole) { - const requiredRoles = requiredRole - .split(',') - .map((role) => role.trim()) - .filter(Boolean); - let decodedToken = ''; - if (requiredRoleTokenKind === 'access') { - decodedToken = jwtDecode(tokenset.access_token); - } else if (requiredRoleTokenKind === 'id') { - decodedToken = jwtDecode(tokenset.id_token); - } - - let roles = get(decodedToken, requiredRoleParameterPath); - if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) { - logger.error( - `[openidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`, - ); - const rolesList = - requiredRoles.length === 1 - ? `"${requiredRoles[0]}"` - : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; - return done(null, false, { - message: `You must have ${rolesList} role to log in.`, - }); - } - - if (!requiredRoles.some((role) => roles.includes(role))) { - const rolesList = - requiredRoles.length === 1 - ? `"${requiredRoles[0]}"` - : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; - return done(null, false, { - message: `You must have ${rolesList} role to log in.`, - }); - } - } - - let username = ''; - if (process.env.OPENID_USERNAME_CLAIM) { - username = userinfo[process.env.OPENID_USERNAME_CLAIM]; - } else { - username = convertToUsername( - userinfo.preferred_username || userinfo.username || userinfo.email, - ); - } - - if (!user) { - user = { - provider: 'openid', - openidId: userinfo.sub, - username, - email: email || '', - emailVerified: userinfo.email_verified || false, - name: fullName, - idOnTheSource: userinfo.oid, - }; - - const balanceConfig = getBalanceConfig(appConfig); - user = await createUser(user, balanceConfig, true, true); - } else { - user.provider = 'openid'; - user.openidId = userinfo.sub; - user.username = username; - user.name = fullName; - user.idOnTheSource = userinfo.oid; - if (email && email !== user.email) { - user.email = email; - user.emailVerified = userinfo.email_verified || false; - } - } - - if (adminRole && adminRoleParameterPath && adminRoleTokenKind) { - let adminRoleObject; - switch (adminRoleTokenKind) { - case 'access': - adminRoleObject = jwtDecode(tokenset.access_token); - break; - case 'id': - adminRoleObject = jwtDecode(tokenset.id_token); - break; - case 'userinfo': - adminRoleObject = userinfo; - break; - default: - logger.error( - `[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`, - ); - return done(new Error('Invalid admin role token kind')); - } - - const adminRoles = get(adminRoleObject, adminRoleParameterPath); - - // Accept 3 types of values for the object extracted from adminRoleParameterPath: - // 1. A boolean value indicating if the user is an admin - // 2. A string with a single role name - // 3. An array of role names - - if ( - adminRoles && - (adminRoles === true || - adminRoles === adminRole || - (Array.isArray(adminRoles) && adminRoles.includes(adminRole))) - ) { - user.role = 'ADMIN'; - logger.info( - `[openidStrategy] User ${username} is an admin based on role: ${adminRole}`, - ); - } else if (user.role === 'ADMIN') { - user.role = 'USER'; - logger.info( - `[openidStrategy] User ${username} demoted from admin - role no longer present in token`, - ); - } - } - - if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { - /** @type {string | undefined} */ - const imageUrl = userinfo.picture; - - let fileName; - if (crypto) { - fileName = (await hashToken(userinfo.sub)) + '.png'; - } else { - fileName = userinfo.sub + '.png'; - } - - const imageBuffer = await downloadImage( - imageUrl, - openidConfig, - tokenset.access_token, - userinfo.sub, - ); - if (imageBuffer) { - const { saveBuffer } = getStrategyFunctions( - appConfig?.fileStrategy ?? process.env.CDN_PROVIDER, - ); - const imagePath = await saveBuffer({ - fileName, - userId: user._id.toString(), - buffer: imageBuffer, - }); - user.avatar = imagePath ?? ''; - } - } - - user = await updateUser(user._id, user); - - logger.info( - `[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `, - { - user: { - openidId: user.openidId, - username: user.username, - email: user.email, - name: user.name, - }, - }, - ); - - done(null, { - ...user, - tokenset, - federatedTokens: { - access_token: tokenset.access_token, - refresh_token: tokenset.refresh_token, - expires_at: tokenset.expires_at, - }, - }); - } catch (err) { - logger.error('[openidStrategy] login failed', err); - done(err); - } + usePKCE: isEnabled(process.env.OPENID_USE_PKCE), }, + createOpenIDCallback(), ); passport.use('openid', openidLogin); + setupOpenIdAdmin(openidConfig); return openidConfig; } catch (err) { logger.error('[openidStrategy]', err); return null; } } + /** * @function getOpenIdConfig * @description Returns the OpenID client instance. diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 9ac22ff42f..ada27cca17 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -64,21 +64,36 @@ jest.mock('openid-client', () => { }); jest.mock('openid-client/passport', () => { - let verifyCallback; + /** Store callbacks by strategy name - 'openid' and 'openidAdmin' */ + const verifyCallbacks = {}; + let lastVerifyCallback; + const mockStrategy = jest.fn((options, verify) => { - verifyCallback = verify; + lastVerifyCallback = verify; return { name: 'openid', options, verify }; }); return { Strategy: mockStrategy, - __getVerifyCallback: () => verifyCallback, + /** Get the last registered callback (for backward compatibility) */ + __getVerifyCallback: () => lastVerifyCallback, + /** Store callback by name when passport.use is called */ + __setVerifyCallback: (name, callback) => { + verifyCallbacks[name] = callback; + }, + /** Get callback by strategy name */ + __getVerifyCallbackByName: (name) => verifyCallbacks[name], }; }); -// Mock passport +// Mock passport - capture strategy name and callback jest.mock('passport', () => ({ - use: jest.fn(), + use: jest.fn((name, strategy) => { + const passportMock = require('openid-client/passport'); + if (strategy && strategy.verify) { + passportMock.__setVerifyCallback(name, strategy.verify); + } + }), })); describe('setupOpenId', () => { @@ -159,9 +174,10 @@ describe('setupOpenId', () => { }; fetch.mockResolvedValue(fakeResponse); - // Call the setup function and capture the verify callback + // Call the setup function and capture the verify callback for the regular 'openid' strategy + // (not 'openidAdmin' which requires existing users) await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); }); it('should create a new user with correct username when preferred_username claim exists', async () => { @@ -389,7 +405,7 @@ describe('setupOpenId', () => { // Arrange process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin'; await setupOpenId(); // Re-initialize the strategy - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); jwtDecode.mockReturnValue({ roles: ['anotherRole', 'aThirdRole'], }); @@ -406,7 +422,7 @@ describe('setupOpenId', () => { // Arrange process.env.OPENID_REQUIRED_ROLE = 'someRole,anotherRole,admin'; await setupOpenId(); // Re-initialize the strategy - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); jwtDecode.mockReturnValue({ roles: ['aThirdRole', 'aFourthRole'], }); @@ -425,7 +441,7 @@ describe('setupOpenId', () => { // Arrange process.env.OPENID_REQUIRED_ROLE = ' someRole , anotherRole , admin '; await setupOpenId(); // Re-initialize the strategy - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); jwtDecode.mockReturnValue({ roles: ['someRole'], }); @@ -560,7 +576,7 @@ describe('setupOpenId', () => { delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); // Simulate an existing admin user const existingAdminUser = { @@ -611,7 +627,7 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); @@ -634,7 +650,7 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); @@ -655,14 +671,12 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user, details } = await validate(tokenset); expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining( - "Key 'resource_access.nonexistent.roles' not found or invalid type in id token!", - ), + expect.stringContaining("Key 'resource_access.nonexistent.roles' not found in id token!"), ); expect(user).toBe(false); expect(details.message).toContain('role to log in'); @@ -680,12 +694,12 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Key 'org.team.roles' not found or invalid type in id token!"), + expect.stringContaining("Key 'org.team.roles' not found in id token!"), ); expect(user).toBe(false); }); @@ -709,7 +723,7 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); @@ -739,7 +753,7 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate({ ...tokenset, @@ -759,7 +773,7 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); @@ -776,7 +790,7 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); @@ -793,7 +807,7 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); @@ -810,7 +824,7 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); @@ -827,7 +841,7 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); @@ -847,7 +861,7 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); @@ -864,12 +878,12 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Key 'access.roles' not found or invalid type in id token!"), + expect.stringContaining("Key 'access.roles' not found in id token!"), ); expect(user).toBe(false); }); @@ -884,12 +898,12 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Key 'data.roles' not found or invalid type in id token!"), + expect.stringContaining("Key 'data.roles' not found in id token!"), ); expect(user).toBe(false); }); @@ -906,7 +920,7 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); await expect(validate(tokenset)).rejects.toThrow('Invalid admin role token kind'); @@ -927,12 +941,12 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user, details } = await validate(tokenset); expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Key 'roles' not found or invalid type in id token!"), + expect.stringContaining("Key 'roles' not found in id token!"), ); expect(user).toBe(false); expect(details.message).toContain('role to log in'); @@ -948,12 +962,12 @@ describe('setupOpenId', () => { }); await setupOpenId(); - verifyCallback = require('openid-client/passport').__getVerifyCallback(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); const { user } = await validate(tokenset); expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Key 'roleCount' not found or invalid type in id token!"), + expect.stringContaining("Key 'roleCount' not found in id token!"), ); expect(user).toBe(false); }); diff --git a/packages/api/src/auth/exchange.ts b/packages/api/src/auth/exchange.ts new file mode 100644 index 0000000000..c919974523 --- /dev/null +++ b/packages/api/src/auth/exchange.ts @@ -0,0 +1,157 @@ +import crypto from 'crypto'; +import { Keyv } from 'keyv'; +import { logger } from '@librechat/data-schemas'; +import type { IUser } from '@librechat/data-schemas'; + +/** Default admin panel URL for local development */ +const DEFAULT_ADMIN_PANEL_URL = 'http://localhost:3000'; + +/** + * Gets the admin panel URL from environment or falls back to default. + * @returns The admin panel URL + */ +export function getAdminPanelUrl(): string { + return process.env.ADMIN_PANEL_URL || DEFAULT_ADMIN_PANEL_URL; +} + +/** + * User data stored in the exchange cache + */ +export interface AdminExchangeUser { + _id: string; + id: string; + email: string; + name: string; + username: string; + role: string; + avatar?: string; + provider?: string; + openidId?: string; +} + +/** + * Data stored in cache for admin OAuth exchange + */ +export interface AdminExchangeData { + userId: string; + user: AdminExchangeUser; + token: string; + refreshToken?: string; +} + +/** + * Response from the exchange endpoint + */ +export interface AdminExchangeResponse { + token: string; + refreshToken?: string; + user: AdminExchangeUser; +} + +/** + * Serializes user data for the exchange cache. + * @param user - The authenticated user object + * @returns Serialized user data for admin panel + */ +export function serializeUserForExchange(user: IUser): AdminExchangeUser { + const userId = String(user._id); + return { + _id: userId, + id: userId, + email: user.email, + name: user.name ?? '', + username: user.username ?? '', + role: user.role ?? 'USER', + avatar: user.avatar, + provider: user.provider, + openidId: user.openidId, + }; +} + +/** + * Generates an exchange code and stores user data for admin panel OAuth flow. + * @param cache - The Keyv cache instance for storing exchange data + * @param user - The authenticated user object + * @param token - The JWT access token + * @param refreshToken - Optional refresh token for OpenID users + * @returns The generated exchange code + */ +export async function generateAdminExchangeCode( + cache: Keyv, + user: IUser, + token: string, + refreshToken?: string, +): Promise { + const exchangeCode = crypto.randomBytes(32).toString('hex'); + + const data: AdminExchangeData = { + userId: String(user._id), + user: serializeUserForExchange(user), + token, + refreshToken, + }; + + await cache.set(exchangeCode, data); + + logger.info(`[adminExchange] Generated exchange code for user: ${user.email}`); + + return exchangeCode; +} + +/** + * Exchanges an authorization code for tokens and user data. + * The code is deleted immediately after retrieval (one-time use). + * @param cache - The Keyv cache instance for retrieving exchange data + * @param code - The authorization code to exchange + * @returns The exchange response with token, refreshToken, and user data, or null if invalid/expired + */ +export async function exchangeAdminCode( + cache: Keyv, + code: string, +): Promise { + const data = (await cache.get(code)) as AdminExchangeData | undefined; + + /** Delete immediately - one-time use */ + await cache.delete(code); + + if (!data) { + logger.warn('[adminExchange] Invalid or expired authorization code'); + return null; + } + + logger.info(`[adminExchange] Exchanged code for user: ${data.user?.email}`); + + return { + token: data.token, + refreshToken: data.refreshToken, + user: data.user, + }; +} + +/** + * Checks if the redirect URI is for the admin panel (cross-origin). + * Uses proper URL parsing to compare origins, handling edge cases where + * both URLs might share the same prefix (e.g., localhost:3000 vs localhost:3001). + * + * @param redirectUri - The redirect URI to check. + * @param adminPanelUrl - The admin panel URL (defaults to ADMIN_PANEL_URL env var) + * @param domainClient - The main client domain + * @returns True if redirecting to admin panel (different origin from main client). + */ +export function isAdminPanelRedirect( + redirectUri: string, + adminPanelUrl: string, + domainClient: string, +): boolean { + try { + const redirectOrigin = new URL(redirectUri).origin; + const adminOrigin = new URL(adminPanelUrl).origin; + const clientOrigin = new URL(domainClient).origin; + + /** Redirect is for admin panel if it matches admin origin but not main client origin */ + return redirectOrigin === adminOrigin && redirectOrigin !== clientOrigin; + } catch { + /** If URL parsing fails, fall back to simple string comparison */ + return redirectUri.startsWith(adminPanelUrl) && !redirectUri.startsWith(domainClient); + } +} diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index bee8cf1691..d15d94aad2 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -1,2 +1,3 @@ export * from './domain'; export * from './openid'; +export * from './exchange'; diff --git a/packages/api/src/middleware/admin.spec.ts b/packages/api/src/middleware/admin.spec.ts new file mode 100644 index 0000000000..0074461cb4 --- /dev/null +++ b/packages/api/src/middleware/admin.spec.ts @@ -0,0 +1,140 @@ +import { logger } from '@librechat/data-schemas'; +import { SystemRoles } from 'librechat-data-provider'; +import { requireAdmin } from './admin'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types/http'; + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +describe('requireAdmin middleware', () => { + let mockReq: Partial; + let mockRes: Partial; + let mockNext: jest.Mock; + let jsonMock: jest.Mock; + let statusMock: jest.Mock; + + beforeEach(() => { + jsonMock = jest.fn(); + statusMock = jest.fn().mockReturnValue({ json: jsonMock }); + + mockReq = {}; + mockRes = { + status: statusMock, + }; + mockNext = jest.fn(); + + (logger.warn as jest.Mock).mockClear(); + (logger.debug as jest.Mock).mockClear(); + }); + + describe('when no user is present', () => { + it('should return 401 with AUTHENTICATION_REQUIRED error', () => { + requireAdmin(mockReq as ServerRequest, mockRes as Response, mockNext); + + expect(statusMock).toHaveBeenCalledWith(401); + expect(jsonMock).toHaveBeenCalledWith({ + error: 'Authentication required', + error_code: 'AUTHENTICATION_REQUIRED', + }); + expect(mockNext).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith('[requireAdmin] No user found in request'); + }); + + it('should return 401 when user is undefined', () => { + mockReq.user = undefined; + + requireAdmin(mockReq as ServerRequest, mockRes as Response, mockNext); + + expect(statusMock).toHaveBeenCalledWith(401); + expect(jsonMock).toHaveBeenCalledWith({ + error: 'Authentication required', + error_code: 'AUTHENTICATION_REQUIRED', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('when user does not have admin role', () => { + it('should return 403 when user has no role property', () => { + mockReq.user = { email: 'user@test.com' } as ServerRequest['user']; + + requireAdmin(mockReq as ServerRequest, mockRes as Response, mockNext); + + expect(statusMock).toHaveBeenCalledWith(403); + expect(jsonMock).toHaveBeenCalledWith({ + error: 'Access denied: Admin privileges required', + error_code: 'ADMIN_REQUIRED', + }); + expect(mockNext).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + '[requireAdmin] Access denied for non-admin user: user@test.com', + ); + }); + + it('should return 403 when user has USER role', () => { + mockReq.user = { + email: 'user@test.com', + role: SystemRoles.USER, + } as ServerRequest['user']; + + requireAdmin(mockReq as ServerRequest, mockRes as Response, mockNext); + + expect(statusMock).toHaveBeenCalledWith(403); + expect(jsonMock).toHaveBeenCalledWith({ + error: 'Access denied: Admin privileges required', + error_code: 'ADMIN_REQUIRED', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should return 403 when user has empty string role', () => { + mockReq.user = { + email: 'user@test.com', + role: '', + } as ServerRequest['user']; + + requireAdmin(mockReq as ServerRequest, mockRes as Response, mockNext); + + expect(statusMock).toHaveBeenCalledWith(403); + expect(jsonMock).toHaveBeenCalledWith({ + error: 'Access denied: Admin privileges required', + error_code: 'ADMIN_REQUIRED', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('when user has admin role', () => { + it('should call next() and not send response', () => { + mockReq.user = { + email: 'admin@test.com', + role: SystemRoles.ADMIN, + } as ServerRequest['user']; + + requireAdmin(mockReq as ServerRequest, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith(); + expect(statusMock).not.toHaveBeenCalled(); + expect(jsonMock).not.toHaveBeenCalled(); + }); + + it('should not log any warnings or debug messages for admin users', () => { + mockReq.user = { + email: 'admin@test.com', + role: SystemRoles.ADMIN, + } as ServerRequest['user']; + + requireAdmin(mockReq as ServerRequest, mockRes as Response, mockNext); + + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/api/src/middleware/admin.ts b/packages/api/src/middleware/admin.ts new file mode 100644 index 0000000000..d770adfd85 --- /dev/null +++ b/packages/api/src/middleware/admin.ts @@ -0,0 +1,28 @@ +import { logger } from '@librechat/data-schemas'; +import { SystemRoles } from 'librechat-data-provider'; +import type { NextFunction, Response } from 'express'; +import type { ServerRequest } from '~/types/http'; + +/** + * Middleware to check if authenticated user has admin role. + * Should be used AFTER authentication middleware (requireJwtAuth, requireLocalAuth, etc.) + */ +export const requireAdmin = (req: ServerRequest, res: Response, next: NextFunction) => { + if (!req.user) { + logger.warn('[requireAdmin] No user found in request'); + return res.status(401).json({ + error: 'Authentication required', + error_code: 'AUTHENTICATION_REQUIRED', + }); + } + + if (!req.user.role || req.user.role !== SystemRoles.ADMIN) { + logger.debug(`[requireAdmin] Access denied for non-admin user: ${req.user.email}`); + return res.status(403).json({ + error: 'Access denied: Admin privileges required', + error_code: 'ADMIN_REQUIRED', + }); + } + + next(); +}; diff --git a/packages/api/src/middleware/index.ts b/packages/api/src/middleware/index.ts index 4398b35e14..a208923a49 100644 --- a/packages/api/src/middleware/index.ts +++ b/packages/api/src/middleware/index.ts @@ -1,4 +1,5 @@ export * from './access'; +export * from './admin'; export * from './error'; export * from './balance'; export * from './json'; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 11c0421914..fc5c705472 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1436,6 +1436,10 @@ export enum CacheKeys { * Key for SAML session. */ SAML_SESSION = 'SAML_SESSION', + /** + * Key for admin panel OAuth exchange codes (one-time-use, short TTL). + */ + ADMIN_OAUTH_EXCHANGE = 'ADMIN_OAUTH_EXCHANGE', } /**