🔐 feat: Admin Auth. Routes with Secure Cross-Origin Token Exchange (#11297)

* 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.
This commit is contained in:
Danny Avila 2026-01-11 14:46:23 -05:00 committed by GitHub
parent 9cb9f42f52
commit 0e9d42a60b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 878 additions and 298 deletions

View file

@ -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,
),
};
/**

View file

@ -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,
};

View file

@ -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);

View file

@ -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;

View file

@ -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,

View file

@ -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. */

View file

@ -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,

View file

@ -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<Object>} 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.

View file

@ -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);
});

View file

@ -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<string> {
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<AdminExchangeResponse | null> {
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);
}
}

View file

@ -1,2 +1,3 @@
export * from './domain';
export * from './openid';
export * from './exchange';

View file

@ -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<ServerRequest>;
let mockRes: Partial<Response>;
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();
});
});
});

View file

@ -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();
};

View file

@ -1,4 +1,5 @@
export * from './access';
export * from './admin';
export * from './error';
export * from './balance';
export * from './json';

View file

@ -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',
}
/**