mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 14:27:20 +02:00
🔐 feat: Admin Auth Support for SAML and Social OAuth Providers (#12472)
* refactor: Add existingUsersOnly support to social and SAML auth callbacks
- Add `existingUsersOnly` option to the `socialLogin` handler factory to
reject unknown users instead of creating new accounts
- Refactor SAML strategy callback into `createSamlCallback(existingUsersOnly)`
factory function, mirroring the OpenID `createOpenIDCallback` pattern
- Extract shared SAML config into `getBaseSamlConfig()` helper
- Register `samlAdmin` passport strategy with `existingUsersOnly: true` and
admin-specific callback URL, called automatically from `setupSaml()`
* feat: Register admin OAuth strategy variants for all social providers
- Add admin strategy exports to Google, GitHub, Discord, Facebook, and
Apple strategy files with admin callback URLs and existingUsersOnly
- Extract provider configs into reusable helpers to avoid duplication
between regular and admin strategy constructors
- Re-export all admin strategy factories from strategies/index.js
- Register admin passport strategies (googleAdmin, githubAdmin, etc.)
alongside regular ones in socialLogins.js when env vars are present
* feat: Add admin auth routes for SAML and social OAuth providers
- Add initiation and callback routes for SAML, Google, GitHub, Discord,
Facebook, and Apple to the admin auth router
- Each provider follows the exchange code + PKCE pattern established by
OpenID admin auth: store PKCE challenge on initiation, retrieve on
callback, generate exchange code for the admin panel
- SAML and Apple use POST callbacks with state extracted from
req.body.RelayState and req.body.state respectively
- Extract storePkceChallenge(), retrievePkceChallenge(), and
generateState() helpers; refactor existing OpenID routes to use them
- All callback chains enforce requireAdminAccess, setBalanceConfig,
checkDomainAllowed, and the shared createOAuthHandler
- No changes needed to the generic POST /oauth/exchange endpoint
* fix: Update SAML strategy test to handle dual strategy registration
setupSaml() now registers both 'saml' and 'samlAdmin' strategies,
causing the SamlStrategy mock to be called twice. The verifyCallback
variable was getting overwritten with the admin callback (which has
existingUsersOnly: true), making all new-user tests fail.
Fix: capture only the first callback per setupSaml() call and reset
between tests.
* fix: Address review findings for admin OAuth strategy changes
- Fix existingUsersOnly rejection in socialLogin.js to use
cb(null, false, { message }) instead of cb(error), ensuring
passport's failureRedirect fires correctly for admin flows
- Consolidate duplicate require() calls in strategies/index.js by
destructuring admin exports from the already-imported default export
- Pass pre-parsed baseConfig to setupSamlAdmin() to avoid redundant
certificate file I/O at startup
- Extract getGoogleConfig() helper in googleStrategy.js for consistency
with all other provider strategy files
- Replace randomState() (openid-client) with generateState() (crypto)
in the OpenID admin route for consistency with all other providers,
and remove the now-unused openid-client import
* Reorder import statements in auth.js
This commit is contained in:
parent
2bf0f892d6
commit
4f37e8adb9
11 changed files with 608 additions and 208 deletions
|
|
@ -1,9 +1,8 @@
|
|||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const { randomState } = require('openid-client');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const crypto = require('node:crypto');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const { getAdminPanelUrl, exchangeAdminCode, createSetBalanceConfig } = require('@librechat/api');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { requireCapability } = require('~/server/middleware/roles/capabilities');
|
||||
|
|
@ -79,20 +78,81 @@ const PKCE_CHALLENGE_TTL = 5 * 60 * 1000;
|
|||
/** Regex pattern for valid PKCE challenges: 64 hex characters (SHA-256 hex digest) */
|
||||
const PKCE_CHALLENGE_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
router.get('/oauth/openid', async (req, res, next) => {
|
||||
const state = randomState();
|
||||
const codeChallenge = req.query.code_challenge;
|
||||
/**
|
||||
* Generates a random hex state string for OAuth flows.
|
||||
* @returns {string} A 32-byte random hex string.
|
||||
*/
|
||||
function generateState() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
if (typeof codeChallenge === 'string' && PKCE_CHALLENGE_PATTERN.test(codeChallenge)) {
|
||||
/**
|
||||
* Stores a PKCE challenge in cache keyed by state.
|
||||
* @param {string} state - The OAuth state value.
|
||||
* @param {string | undefined} codeChallenge - The PKCE code_challenge from query params.
|
||||
* @param {string} provider - Provider name for logging.
|
||||
* @returns {Promise<boolean>} True if stored successfully or no challenge provided.
|
||||
*/
|
||||
async function storePkceChallenge(state, codeChallenge, provider) {
|
||||
if (typeof codeChallenge !== 'string' || !PKCE_CHALLENGE_PATTERN.test(codeChallenge)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
|
||||
await cache.set(`pkce:${state}`, codeChallenge, PKCE_CHALLENGE_TTL);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`[admin/oauth/${provider}] Failed to store PKCE challenge:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to retrieve PKCE challenge from cache using the OAuth state.
|
||||
* Reads state from req.oauthState (set by a preceding middleware).
|
||||
* @param {string} provider - Provider name for logging.
|
||||
* @returns {Function} Express middleware.
|
||||
*/
|
||||
function retrievePkceChallenge(provider) {
|
||||
return async (req, res, next) => {
|
||||
if (!req.oauthState) {
|
||||
return next();
|
||||
}
|
||||
try {
|
||||
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
|
||||
await cache.set(`pkce:${state}`, codeChallenge, PKCE_CHALLENGE_TTL);
|
||||
const challenge = await cache.get(`pkce:${req.oauthState}`);
|
||||
if (challenge) {
|
||||
req.pkceChallenge = challenge;
|
||||
await cache.delete(`pkce:${req.oauthState}`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[admin/oauth/${provider}/callback] State present but no PKCE challenge found; PKCE will not be enforced for this request`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[admin/oauth/openid] Failed to store PKCE challenge:', err);
|
||||
logger.error(
|
||||
`[admin/oauth/${provider}/callback] Failed to retrieve PKCE challenge, aborting:`,
|
||||
err,
|
||||
);
|
||||
return res.redirect(
|
||||
`${getAdminPanelUrl()}/auth/openid/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
|
||||
`${getAdminPanelUrl()}/auth/${provider}/callback?error=pkce_retrieval_failed&error_description=Failed+to+retrieve+PKCE+challenge`,
|
||||
);
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────
|
||||
* OpenID Admin Routes
|
||||
* ────────────────────────────────────────────── */
|
||||
|
||||
router.get('/oauth/openid', async (req, res, next) => {
|
||||
const state = generateState();
|
||||
const stored = await storePkceChallenge(state, req.query.code_challenge, 'openid');
|
||||
if (!stored) {
|
||||
return res.redirect(
|
||||
`${getAdminPanelUrl()}/auth/openid/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
|
||||
);
|
||||
}
|
||||
|
||||
return passport.authenticate('openidAdmin', {
|
||||
|
|
@ -112,35 +172,239 @@ router.get(
|
|||
failureMessage: true,
|
||||
session: false,
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
if (!req.oauthState) {
|
||||
return next();
|
||||
}
|
||||
try {
|
||||
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
|
||||
const challenge = await cache.get(`pkce:${req.oauthState}`);
|
||||
if (challenge) {
|
||||
req.pkceChallenge = challenge;
|
||||
await cache.delete(`pkce:${req.oauthState}`);
|
||||
} else {
|
||||
logger.warn(
|
||||
'[admin/oauth/callback] State present but no PKCE challenge found; PKCE will not be enforced for this request',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[admin/oauth/callback] Failed to retrieve PKCE challenge, aborting:', err);
|
||||
return res.redirect(
|
||||
`${getAdminPanelUrl()}/auth/openid/callback?error=pkce_retrieval_failed&error_description=Failed+to+retrieve+PKCE+challenge`,
|
||||
);
|
||||
}
|
||||
next();
|
||||
},
|
||||
retrievePkceChallenge('openid'),
|
||||
requireAdminAccess,
|
||||
setBalanceConfig,
|
||||
middleware.checkDomainAllowed,
|
||||
createOAuthHandler(`${getAdminPanelUrl()}/auth/openid/callback`),
|
||||
);
|
||||
|
||||
/* ──────────────────────────────────────────────
|
||||
* SAML Admin Routes
|
||||
* ────────────────────────────────────────────── */
|
||||
|
||||
router.get('/oauth/saml', async (req, res, next) => {
|
||||
const state = generateState();
|
||||
const stored = await storePkceChallenge(state, req.query.code_challenge, 'saml');
|
||||
if (!stored) {
|
||||
return res.redirect(
|
||||
`${getAdminPanelUrl()}/auth/saml/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
|
||||
);
|
||||
}
|
||||
|
||||
return passport.authenticate('samlAdmin', {
|
||||
session: false,
|
||||
additionalParams: { RelayState: state },
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/oauth/saml/callback',
|
||||
(req, res, next) => {
|
||||
req.oauthState = typeof req.body.RelayState === 'string' ? req.body.RelayState : undefined;
|
||||
next();
|
||||
},
|
||||
passport.authenticate('samlAdmin', {
|
||||
failureRedirect: `${getAdminPanelUrl()}/auth/saml/callback?error=auth_failed&error_description=Authentication+failed`,
|
||||
failureMessage: true,
|
||||
session: false,
|
||||
}),
|
||||
retrievePkceChallenge('saml'),
|
||||
requireAdminAccess,
|
||||
setBalanceConfig,
|
||||
middleware.checkDomainAllowed,
|
||||
createOAuthHandler(`${getAdminPanelUrl()}/auth/saml/callback`),
|
||||
);
|
||||
|
||||
/* ──────────────────────────────────────────────
|
||||
* Google Admin Routes
|
||||
* ────────────────────────────────────────────── */
|
||||
|
||||
router.get('/oauth/google', async (req, res, next) => {
|
||||
const state = generateState();
|
||||
const stored = await storePkceChallenge(state, req.query.code_challenge, '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);
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/oauth/google/callback',
|
||||
(req, res, next) => {
|
||||
req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined;
|
||||
next();
|
||||
},
|
||||
passport.authenticate('googleAdmin', {
|
||||
failureRedirect: `${getAdminPanelUrl()}/auth/google/callback?error=auth_failed&error_description=Authentication+failed`,
|
||||
failureMessage: true,
|
||||
session: false,
|
||||
}),
|
||||
retrievePkceChallenge('google'),
|
||||
requireAdminAccess,
|
||||
setBalanceConfig,
|
||||
middleware.checkDomainAllowed,
|
||||
createOAuthHandler(`${getAdminPanelUrl()}/auth/google/callback`),
|
||||
);
|
||||
|
||||
/* ──────────────────────────────────────────────
|
||||
* GitHub Admin Routes
|
||||
* ────────────────────────────────────────────── */
|
||||
|
||||
router.get('/oauth/github', async (req, res, next) => {
|
||||
const state = generateState();
|
||||
const stored = await storePkceChallenge(state, req.query.code_challenge, '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);
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/oauth/github/callback',
|
||||
(req, res, next) => {
|
||||
req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined;
|
||||
next();
|
||||
},
|
||||
passport.authenticate('githubAdmin', {
|
||||
failureRedirect: `${getAdminPanelUrl()}/auth/github/callback?error=auth_failed&error_description=Authentication+failed`,
|
||||
failureMessage: true,
|
||||
session: false,
|
||||
}),
|
||||
retrievePkceChallenge('github'),
|
||||
requireAdminAccess,
|
||||
setBalanceConfig,
|
||||
middleware.checkDomainAllowed,
|
||||
createOAuthHandler(`${getAdminPanelUrl()}/auth/github/callback`),
|
||||
);
|
||||
|
||||
/* ──────────────────────────────────────────────
|
||||
* Discord Admin Routes
|
||||
* ────────────────────────────────────────────── */
|
||||
|
||||
router.get('/oauth/discord', async (req, res, next) => {
|
||||
const state = generateState();
|
||||
const stored = await storePkceChallenge(state, req.query.code_challenge, '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);
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/oauth/discord/callback',
|
||||
(req, res, next) => {
|
||||
req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined;
|
||||
next();
|
||||
},
|
||||
passport.authenticate('discordAdmin', {
|
||||
failureRedirect: `${getAdminPanelUrl()}/auth/discord/callback?error=auth_failed&error_description=Authentication+failed`,
|
||||
failureMessage: true,
|
||||
session: false,
|
||||
}),
|
||||
retrievePkceChallenge('discord'),
|
||||
requireAdminAccess,
|
||||
setBalanceConfig,
|
||||
middleware.checkDomainAllowed,
|
||||
createOAuthHandler(`${getAdminPanelUrl()}/auth/discord/callback`),
|
||||
);
|
||||
|
||||
/* ──────────────────────────────────────────────
|
||||
* Facebook Admin Routes
|
||||
* ────────────────────────────────────────────── */
|
||||
|
||||
router.get('/oauth/facebook', async (req, res, next) => {
|
||||
const state = generateState();
|
||||
const stored = await storePkceChallenge(state, req.query.code_challenge, '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);
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/oauth/facebook/callback',
|
||||
(req, res, next) => {
|
||||
req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined;
|
||||
next();
|
||||
},
|
||||
passport.authenticate('facebookAdmin', {
|
||||
failureRedirect: `${getAdminPanelUrl()}/auth/facebook/callback?error=auth_failed&error_description=Authentication+failed`,
|
||||
failureMessage: true,
|
||||
session: false,
|
||||
}),
|
||||
retrievePkceChallenge('facebook'),
|
||||
requireAdminAccess,
|
||||
setBalanceConfig,
|
||||
middleware.checkDomainAllowed,
|
||||
createOAuthHandler(`${getAdminPanelUrl()}/auth/facebook/callback`),
|
||||
);
|
||||
|
||||
/* ──────────────────────────────────────────────
|
||||
* Apple Admin Routes (POST callback)
|
||||
* ────────────────────────────────────────────── */
|
||||
|
||||
router.get('/oauth/apple', async (req, res, next) => {
|
||||
const state = generateState();
|
||||
const stored = await storePkceChallenge(state, req.query.code_challenge, 'apple');
|
||||
if (!stored) {
|
||||
return res.redirect(
|
||||
`${getAdminPanelUrl()}/auth/apple/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
|
||||
);
|
||||
}
|
||||
|
||||
return passport.authenticate('appleAdmin', {
|
||||
session: false,
|
||||
state,
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/oauth/apple/callback',
|
||||
(req, res, next) => {
|
||||
req.oauthState = typeof req.body.state === 'string' ? req.body.state : undefined;
|
||||
next();
|
||||
},
|
||||
passport.authenticate('appleAdmin', {
|
||||
failureRedirect: `${getAdminPanelUrl()}/auth/apple/callback?error=auth_failed&error_description=Authentication+failed`,
|
||||
failureMessage: true,
|
||||
session: false,
|
||||
}),
|
||||
retrievePkceChallenge('apple'),
|
||||
requireAdminAccess,
|
||||
setBalanceConfig,
|
||||
middleware.checkDomainAllowed,
|
||||
createOAuthHandler(`${getAdminPanelUrl()}/auth/apple/callback`),
|
||||
);
|
||||
|
||||
/** Regex pattern for valid exchange codes: 64 hex characters */
|
||||
const EXCHANGE_CODE_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue