mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🪧 fix: Guard Admin OAuth Routes When Providers Are Not Configured (#14507)
* fix: guard admin OpenID routes without config * fix: guard remaining admin SSO routes without registered strategy --------- Co-authored-by: cyphercodes <cyphercodes@users.noreply.github.com> Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
ad0f72dede
commit
3551c1ba8e
2 changed files with 268 additions and 65 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' })),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue