From 3551c1ba8ee13bc7a796583c378647abee55eb49 Mon Sep 17 00:00:00 2001 From: Rayan Salhab Date: Sat, 1 Aug 2026 21:46:17 +0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=A7=20fix:=20Guard=20Admin=20OAuth=20R?= =?UTF-8?q?outes=20When=20Providers=20Are=20Not=20Configured=20(#14507)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: guard admin OpenID routes without config * fix: guard remaining admin SSO routes without registered strategy --------- Co-authored-by: cyphercodes Co-authored-by: Danny Avila --- api/server/routes/admin/auth.js | 184 ++++++++++++------- api/server/routes/admin/auth.refresh.test.js | 149 ++++++++++++++- 2 files changed, 268 insertions(+), 65 deletions(-) diff --git a/api/server/routes/admin/auth.js b/api/server/routes/admin/auth.js index 72fdf7eb7f..29e9c8102f 100644 --- a/api/server/routes/admin/auth.js +++ b/api/server/routes/admin/auth.js @@ -46,6 +46,39 @@ const setBalanceConfig = createSetBalanceConfig({ const router = express.Router(); +function getOptionalOpenIdConfig() { + try { + return getOpenIdConfig(); + } catch { + return null; + } +} + +function requireOpenIdConfig(req, res, next) { + const openidConfig = getOptionalOpenIdConfig(); + if (!openidConfig) { + return res.status(404).json({ + error: 'OpenID configuration not found', + error_code: 'OPENID_NOT_CONFIGURED', + }); + } + + next(); +} + +/** Returns middleware that responds 404 when the given admin passport strategy is not registered. */ +function requireAdminStrategy(strategyName, provider) { + return (req, res, next) => { + if (passport._strategy(strategyName)) { + return next(); + } + return res.status(404).json({ + error: `${provider} configuration not found`, + error_code: `${provider.toUpperCase()}_NOT_CONFIGURED`, + }); + }; +} + function resolveRequestOrigin(req) { const originHeader = req.get('origin'); if (originHeader) { @@ -88,7 +121,7 @@ router.get('/verify', middleware.requireJwtAuth, requireAdminAccess, (req, res) }); router.get('/oauth/openid/check', (req, res) => { - const openidConfig = getOpenIdConfig(); + const openidConfig = getOptionalOpenIdConfig(); if (!openidConfig) { return res.status(404).json({ error: 'OpenID configuration not found', @@ -145,7 +178,7 @@ function retrievePkceChallenge(provider) { * OpenID Admin Routes * ────────────────────────────────────────────── */ -router.get('/oauth/openid', async (req, res, next) => { +router.get('/oauth/openid', requireOpenIdConfig, async (req, res, next) => { const state = generateState(); const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); const stored = await storeAndStripChallenge(cache, req, state, 'openid'); @@ -167,6 +200,7 @@ router.get( req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined; next(); }, + requireOpenIdConfig, passport.authenticate('openidAdmin', { failureRedirect: `${getAdminPanelUrl()}/auth/openid/callback?error=auth_failed&error_description=Authentication+failed`, failureMessage: true, @@ -184,7 +218,7 @@ router.get( * SAML Admin Routes * ────────────────────────────────────────────── */ -router.get('/oauth/saml', async (req, res, next) => { +router.get('/oauth/saml', requireAdminStrategy('samlAdmin', 'SAML'), async (req, res, next) => { const state = generateState(); const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); const stored = await storeAndStripChallenge(cache, req, state, 'saml'); @@ -206,6 +240,7 @@ router.post( req.oauthState = typeof req.body.RelayState === 'string' ? req.body.RelayState : undefined; next(); }, + requireAdminStrategy('samlAdmin', 'SAML'), passport.authenticate('samlAdmin', { failureRedirect: `${getAdminPanelUrl()}/auth/saml/callback?error=auth_failed&error_description=Authentication+failed`, failureMessage: true, @@ -223,22 +258,26 @@ router.post( * Google Admin Routes * ────────────────────────────────────────────── */ -router.get('/oauth/google', async (req, res, next) => { - const state = generateState(); - const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); - const stored = await storeAndStripChallenge(cache, req, state, 'google'); - if (!stored) { - return res.redirect( - `${getAdminPanelUrl()}/auth/google/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, - ); - } +router.get( + '/oauth/google', + requireAdminStrategy('googleAdmin', 'Google'), + async (req, res, next) => { + const state = generateState(); + const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); + const stored = await storeAndStripChallenge(cache, req, state, 'google'); + if (!stored) { + return res.redirect( + `${getAdminPanelUrl()}/auth/google/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + ); + } - return passport.authenticate('googleAdmin', { - scope: ['openid', 'profile', 'email'], - session: false, - state, - })(req, res, next); -}); + return passport.authenticate('googleAdmin', { + scope: ['openid', 'profile', 'email'], + session: false, + state, + })(req, res, next); + }, +); router.get( '/oauth/google/callback', @@ -246,6 +285,7 @@ router.get( req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined; next(); }, + requireAdminStrategy('googleAdmin', 'Google'), passport.authenticate('googleAdmin', { failureRedirect: `${getAdminPanelUrl()}/auth/google/callback?error=auth_failed&error_description=Authentication+failed`, failureMessage: true, @@ -263,22 +303,26 @@ router.get( * GitHub Admin Routes * ────────────────────────────────────────────── */ -router.get('/oauth/github', async (req, res, next) => { - const state = generateState(); - const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); - const stored = await storeAndStripChallenge(cache, req, state, 'github'); - if (!stored) { - return res.redirect( - `${getAdminPanelUrl()}/auth/github/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, - ); - } +router.get( + '/oauth/github', + requireAdminStrategy('githubAdmin', 'GitHub'), + async (req, res, next) => { + const state = generateState(); + const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); + const stored = await storeAndStripChallenge(cache, req, state, 'github'); + if (!stored) { + return res.redirect( + `${getAdminPanelUrl()}/auth/github/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + ); + } - return passport.authenticate('githubAdmin', { - scope: ['user:email', 'read:user'], - session: false, - state, - })(req, res, next); -}); + return passport.authenticate('githubAdmin', { + scope: ['user:email', 'read:user'], + session: false, + state, + })(req, res, next); + }, +); router.get( '/oauth/github/callback', @@ -286,6 +330,7 @@ router.get( req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined; next(); }, + requireAdminStrategy('githubAdmin', 'GitHub'), passport.authenticate('githubAdmin', { failureRedirect: `${getAdminPanelUrl()}/auth/github/callback?error=auth_failed&error_description=Authentication+failed`, failureMessage: true, @@ -303,22 +348,26 @@ router.get( * Discord Admin Routes * ────────────────────────────────────────────── */ -router.get('/oauth/discord', async (req, res, next) => { - const state = generateState(); - const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); - const stored = await storeAndStripChallenge(cache, req, state, 'discord'); - if (!stored) { - return res.redirect( - `${getAdminPanelUrl()}/auth/discord/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, - ); - } +router.get( + '/oauth/discord', + requireAdminStrategy('discordAdmin', 'Discord'), + async (req, res, next) => { + const state = generateState(); + const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); + const stored = await storeAndStripChallenge(cache, req, state, 'discord'); + if (!stored) { + return res.redirect( + `${getAdminPanelUrl()}/auth/discord/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + ); + } - return passport.authenticate('discordAdmin', { - scope: ['identify', 'email'], - session: false, - state, - })(req, res, next); -}); + return passport.authenticate('discordAdmin', { + scope: ['identify', 'email'], + session: false, + state, + })(req, res, next); + }, +); router.get( '/oauth/discord/callback', @@ -326,6 +375,7 @@ router.get( req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined; next(); }, + requireAdminStrategy('discordAdmin', 'Discord'), passport.authenticate('discordAdmin', { failureRedirect: `${getAdminPanelUrl()}/auth/discord/callback?error=auth_failed&error_description=Authentication+failed`, failureMessage: true, @@ -343,22 +393,26 @@ router.get( * Facebook Admin Routes * ────────────────────────────────────────────── */ -router.get('/oauth/facebook', async (req, res, next) => { - const state = generateState(); - const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); - const stored = await storeAndStripChallenge(cache, req, state, 'facebook'); - if (!stored) { - return res.redirect( - `${getAdminPanelUrl()}/auth/facebook/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, - ); - } +router.get( + '/oauth/facebook', + requireAdminStrategy('facebookAdmin', 'Facebook'), + async (req, res, next) => { + const state = generateState(); + const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); + const stored = await storeAndStripChallenge(cache, req, state, 'facebook'); + if (!stored) { + return res.redirect( + `${getAdminPanelUrl()}/auth/facebook/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + ); + } - return passport.authenticate('facebookAdmin', { - scope: ['public_profile'], - session: false, - state, - })(req, res, next); -}); + return passport.authenticate('facebookAdmin', { + scope: ['public_profile'], + session: false, + state, + })(req, res, next); + }, +); router.get( '/oauth/facebook/callback', @@ -366,6 +420,7 @@ router.get( req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined; next(); }, + requireAdminStrategy('facebookAdmin', 'Facebook'), passport.authenticate('facebookAdmin', { failureRedirect: `${getAdminPanelUrl()}/auth/facebook/callback?error=auth_failed&error_description=Authentication+failed`, failureMessage: true, @@ -383,7 +438,7 @@ router.get( * Apple Admin Routes (POST callback) * ────────────────────────────────────────────── */ -router.get('/oauth/apple', async (req, res, next) => { +router.get('/oauth/apple', requireAdminStrategy('appleAdmin', 'Apple'), async (req, res, next) => { const state = generateState(); const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); const stored = await storeAndStripChallenge(cache, req, state, 'apple'); @@ -405,6 +460,7 @@ router.post( req.oauthState = typeof req.body.state === 'string' ? req.body.state : undefined; next(); }, + requireAdminStrategy('appleAdmin', 'Apple'), passport.authenticate('appleAdmin', { failureRedirect: `${getAdminPanelUrl()}/auth/apple/callback?error=auth_failed&error_description=Authentication+failed`, failureMessage: true, diff --git a/api/server/routes/admin/auth.refresh.test.js b/api/server/routes/admin/auth.refresh.test.js index fafce2d2d1..3e2ce1cff7 100644 --- a/api/server/routes/admin/auth.refresh.test.js +++ b/api/server/routes/admin/auth.refresh.test.js @@ -3,6 +3,7 @@ const request = require('supertest'); jest.mock('passport', () => ({ authenticate: jest.fn(() => (req, res, next) => next()), + _strategy: jest.fn(), })); jest.mock('openid-client', () => ({ @@ -103,9 +104,15 @@ jest.mock('~/server/middleware', () => ({ checkDomainAllowed: jest.fn((req, res, next) => next()), })); +const passport = require('passport'); const openIdClient = require('openid-client'); const { logger } = require('@librechat/data-schemas'); -const { isEnabled, applyAdminRefresh, buildOpenIDRefreshParams } = require('@librechat/api'); +const { + isEnabled, + applyAdminRefresh, + storeAndStripChallenge, + buildOpenIDRefreshParams, +} = require('@librechat/api'); const { getOpenIdConfig } = require('~/strategies'); const middleware = require('~/server/middleware'); const adminAuthRouter = require('./auth'); @@ -114,6 +121,146 @@ const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE; const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE; const ORIGINAL_SESSION_EXPIRY = process.env.SESSION_EXPIRY; +describe('admin auth OpenID route availability', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + + app = express(); + app.use(express.json()); + app.use('/api/admin', adminAuthRouter); + }); + + it('returns not configured for the OpenID availability check when config lookup throws', async () => { + getOpenIdConfig.mockImplementation(() => { + throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); + }); + + const response = await request(app).get('/api/admin/oauth/openid/check'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: 'OpenID configuration not found', + error_code: 'OPENID_NOT_CONFIGURED', + }); + }); + + it('does not start OpenID admin login when config lookup throws', async () => { + getOpenIdConfig.mockImplementation(() => { + throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); + }); + + const response = await request(app).get('/api/admin/oauth/openid'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: 'OpenID configuration not found', + error_code: 'OPENID_NOT_CONFIGURED', + }); + expect(storeAndStripChallenge).not.toHaveBeenCalled(); + expect(passport.authenticate).not.toHaveBeenCalled(); + }); + + it('does not run OpenID admin callback auth when config lookup throws', async () => { + getOpenIdConfig.mockImplementation(() => { + throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); + }); + + const response = await request(app).get('/api/admin/oauth/openid/callback?state=state'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: 'OpenID configuration not found', + error_code: 'OPENID_NOT_CONFIGURED', + }); + }); +}); + +describe('admin auth social route availability', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + passport._strategy.mockReturnValue(undefined); + + app = express(); + app.use(express.json()); + app.use('/api/admin', adminAuthRouter); + }); + + const startRoutes = [ + ['saml', 'SAML'], + ['google', 'Google'], + ['github', 'GitHub'], + ['discord', 'Discord'], + ['facebook', 'Facebook'], + ['apple', 'Apple'], + ]; + + it.each(startRoutes)( + 'does not start %s admin login when the strategy is not registered', + async (path, provider) => { + const response = await request(app).get(`/api/admin/oauth/${path}`); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: `${provider} configuration not found`, + error_code: `${provider.toUpperCase()}_NOT_CONFIGURED`, + }); + expect(storeAndStripChallenge).not.toHaveBeenCalled(); + expect(passport.authenticate).not.toHaveBeenCalled(); + }, + ); + + const callbackRoutes = [ + [ + 'saml', + 'SAML', + (agent) => agent.post('/api/admin/oauth/saml/callback').send({ RelayState: 'state' }), + ], + ['google', 'Google', (agent) => agent.get('/api/admin/oauth/google/callback?state=state')], + ['github', 'GitHub', (agent) => agent.get('/api/admin/oauth/github/callback?state=state')], + ['discord', 'Discord', (agent) => agent.get('/api/admin/oauth/discord/callback?state=state')], + [ + 'facebook', + 'Facebook', + (agent) => agent.get('/api/admin/oauth/facebook/callback?state=state'), + ], + [ + 'apple', + 'Apple', + (agent) => agent.post('/api/admin/oauth/apple/callback').send({ state: 'state' }), + ], + ]; + + it.each(callbackRoutes)( + 'does not run %s admin callback auth when the strategy is not registered', + async (path, provider, makeRequest) => { + const response = await makeRequest(request(app)); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: `${provider} configuration not found`, + error_code: `${provider.toUpperCase()}_NOT_CONFIGURED`, + }); + }, + ); + + it('starts admin login when the strategy is registered', async () => { + passport._strategy.mockReturnValue({ name: 'googleAdmin' }); + storeAndStripChallenge.mockResolvedValue(true); + + await request(app).get('/api/admin/oauth/google'); + + expect(storeAndStripChallenge).toHaveBeenCalledTimes(1); + expect(passport.authenticate).toHaveBeenCalledWith( + 'googleAdmin', + expect.objectContaining({ session: false }), + ); + }); +}); + describe('admin auth OpenID refresh route', () => { const openIdConfig = { serverMetadata: jest.fn(() => ({ issuer: 'https://issuer.example.com' })),