From f6925f906bb9474c89b30b97e5920a12d851fe95 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 13 Sep 2025 13:53:06 -0400 Subject: [PATCH] WIP: first pass, OpenID Proxy Auth --- api/server/controllers/auth/oauth.js | 50 ++++ api/server/routes/admin/auth.js | 62 +++-- api/server/routes/oauth.js | 34 +-- api/strategies/index.js | 14 +- api/strategies/openidStrategy.js | 361 ++++++++++++++++----------- client/vite.config.ts | 2 +- 6 files changed, 317 insertions(+), 206 deletions(-) create mode 100644 api/server/controllers/auth/oauth.js diff --git a/api/server/controllers/auth/oauth.js b/api/server/controllers/auth/oauth.js new file mode 100644 index 0000000000..9f0144ba80 --- /dev/null +++ b/api/server/controllers/auth/oauth.js @@ -0,0 +1,50 @@ +const { isEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); +const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService'); +const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService'); +const { checkBan } = require('~/server/middleware'); + +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; + } + 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, 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/routes/admin/auth.js b/api/server/routes/admin/auth.js index ac3cf0f0e6..937461ef4c 100644 --- a/api/server/routes/admin/auth.js +++ b/api/server/routes/admin/auth.js @@ -1,7 +1,11 @@ const express = require('express'); -const { loginController } = require('~/server/controllers/auth/LoginController'); -const { getAppConfig } = require('~/server/services/Config'); +const passport = require('passport'); +const { randomState } = require('openid-client'); const { 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 { getOpenIdConfig } = require('~/strategies'); const middleware = require('~/server/middleware'); const { Balance } = require('~/db/models'); @@ -12,33 +16,51 @@ const setBalanceConfig = createSetBalanceConfig({ const router = express.Router(); -// Admin local authentication route - reuses main login controller router.post( '/login/local', middleware.logHeaders, middleware.loginLimiter, middleware.checkBan, - middleware.requireLocalAuth, // Standard local auth - middleware.requireAdmin, // Then check if user is admin + middleware.requireLocalAuth, + middleware.requireAdmin, setBalanceConfig, - loginController, // Reuse existing login controller + loginController, ); -// Admin token verification endpoint - simple JWT verify + admin check +router.get('/verify', middleware.requireJwtAuth, middleware.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({ message: 'OpenID configuration not found' }); + } + 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( - '/verify', - middleware.requireJwtAuth, // Standard JWT auth - middleware.requireAdmin, // Then check if user is admin - (req, res) => { - // Simple response - user is already verified by middleware - const { password: _p, totpSecret: _t, __v, ...user } = req.user; - user.id = user._id.toString(); - res.status(200).json({ user }); - }, + '/oauth/openid/callback', + passport.authenticate('openidAdmin', { + failureRedirect: `${process.env.DOMAIN_CLIENT}/oauth/error`, + failureMessage: true, + session: false, + }), + middleware.requireAdmin, + setBalanceConfig, + middleware.checkDomainAllowed, + createOAuthHandler( + (process.env.ADMIN_PANEL_URL || 'http://localhost:3000') + '/auth/openid/callback', + ), ); -// TODO: Future OAuth/OpenID routes will be added here -// router.get('/auth/openid', ...); -// router.get('/auth/openid/callback', ...); - module.exports = router; diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 0b1252f636..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, 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 011676ecad..687736db9f 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -281,6 +281,215 @@ 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 {Object} additionalUserinfo - Additional userinfo to merge with token claims + * @returns {Promise} The authenticated user object with tokenset + */ +async function processOpenIDAuth(tokenset, additionalUserinfo = {}) { + const claims = tokenset.claims ? tokenset.claims() : tokenset; + const userinfo = { + ...claims, + ...additionalUserinfo, + }; + + // Get userinfo from provider if we have access_token and haven't already + if (tokenset.access_token && !additionalUserinfo.sub) { + const providerUserinfo = await getUserInfo(openidConfig, tokenset.access_token, claims.sub); + Object.assign(userinfo, providerUserinfo); + } + + const appConfig = await getAppConfig(); + if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) { + logger.error( + `[OpenID Auth] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`, + ); + throw new Error('Email domain not allowed'); + } + + const result = await findOpenIDUser({ + openidId: claims.sub || userinfo.sub, + email: claims.email || userinfo.email, + strategyName: 'openidStrategy', + findUser, + }); + let user = result.user; + const error = result.error; + + if (error) { + throw new Error(ErrorTypes.AUTH_FAILED); + } + + const fullName = getFullName(userinfo); + + // Check required role if configured + const requiredRole = process.env.OPENID_REQUIRED_ROLE; + if (requiredRole) { + 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); + } else if (userinfo.roles) { + // If roles are already in userinfo, use them directly + const roles = Array.isArray(userinfo.roles) ? userinfo.roles : [userinfo.roles]; + if (!roles.includes(requiredRole)) { + throw new Error(`You must have the "${requiredRole}" role to log in.`); + } + } else if (requiredRoleParameterPath) { + const pathParts = requiredRoleParameterPath.split('.'); + let found = true; + let roles = pathParts.reduce((o, key) => { + if (o === null || o === undefined || !(key in o)) { + found = false; + return []; + } + return o[key]; + }, decodedToken); + + if (!found) { + logger.error( + `[OpenID Auth] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`, + ); + } + + if (!roles.includes(requiredRole)) { + throw new Error(`You must have the "${requiredRole}" 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: userinfo.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; + } + + // Handle avatar + if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { + 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( + `[OpenID Auth] 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 }; +} + +function createOpenIDCallback() { + return async (tokenset, done) => { + try { + const user = await processOpenIDAuth(tokenset); + 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(), + ); + + 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, @@ -318,10 +527,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,159 +540,19 @@ async function setupOpenId() { scope: process.env.OPENID_SCOPE, callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL, clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300, - usePKCE, - }, - async (tokenset, done) => { - try { - const claims = tokenset.claims(); - const userinfo = { - ...claims, - ...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)), - }; - - const appConfig = await getAppConfig(); - if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) { - logger.error( - `[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`, - ); - return done(null, false, { message: 'Email domain not allowed' }); - } - - const result = await findOpenIDUser({ - openidId: claims.sub, - email: claims.email, - strategyName: 'openidStrategy', - findUser, - }); - let user = result.user; - const error = result.error; - - if (error) { - return done(null, false, { - message: ErrorTypes.AUTH_FAILED, - }); - } - - const fullName = getFullName(userinfo); - - if (requiredRole) { - let decodedToken = ''; - if (requiredRoleTokenKind === 'access') { - decodedToken = jwtDecode(tokenset.access_token); - } else if (requiredRoleTokenKind === 'id') { - decodedToken = jwtDecode(tokenset.id_token); - } - const pathParts = requiredRoleParameterPath.split('.'); - let found = true; - let roles = pathParts.reduce((o, key) => { - if (o === null || o === undefined || !(key in o)) { - found = false; - return []; - } - return o[key]; - }, decodedToken); - - if (!found) { - logger.error( - `[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`, - ); - } - - if (!roles.includes(requiredRole)) { - return done(null, false, { - message: `You must have the "${requiredRole}" 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: userinfo.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 (!!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 }); - } 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/client/vite.config.ts b/client/vite.config.ts index a356e246a1..c9d0fef4ce 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -49,7 +49,7 @@ export default defineConfig(({ command }) => ({ ], globIgnores: ['images/**/*', '**/*.map', 'index.html'], maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, - navigateFallbackDenylist: [/^\/oauth/, /^\/api/], + navigateFallbackDenylist: [/^\/oauth/, /^\/api/, /^\/admin\/openid/], }, includeAssets: [], manifest: {