mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 06:17:21 +02:00
🛡️ fix: Add Origin Binding to Admin OAuth Exchange Codes (#12469)
* fix(auth): add origin binding to admin OAuth exchange codes Bind admin OAuth exchange codes to the admin panel's origin at generation time and validate the origin on redemption. This prevents an intercepted code (via referrer leakage, logs, or network capture) from being redeemed by a different origin within the 30-second TTL. - Store the admin panel origin alongside the exchange code in cache - Extract the request origin (from Origin/Referer headers) on the exchange endpoint and pass it for validation - Reject code redemption when the request origin does not match the stored origin (code is still consumed to prevent replay) - Backward compatible: codes without a stored origin are accepted * fix(auth): add PKCE proof-of-possession to admin OAuth exchange codes Add a PKCE-like code_challenge/code_verifier flow to the admin OAuth exchange so that intercepting the exchange code alone is insufficient to redeem it. The admin panel generates a code_verifier (stored in its HttpOnly session cookie) and sends sha256(verifier) as code_challenge through the OAuth initiation URL. LibreChat stores the challenge keyed by OAuth state and attaches it to the exchange code. On redemption, the admin panel sends the verifier and LibreChat verifies the hash match. - Add verifyCodeChallenge() helper using SHA-256 - Store code_challenge in ADMIN_OAUTH_EXCHANGE cache (pkce: prefix, 5min TTL) - Capture OAuth state in callback middleware before passport processes it - Accept code_verifier in exchange endpoint body - Backward compatible: no challenge stored → PKCE check skipped * fix(auth): harden PKCE and origin binding in admin OAuth exchange - Await cache.set for PKCE challenge storage with error handling - Use crypto.timingSafeEqual for PKCE hash comparison - Drop case-insensitive flag from hex validation regexes - Add code_verifier length validation (max 512 chars) - Normalize Origin header via URL parsing in resolveRequestOrigin - Add test for undefined requestOrigin rejection - Clarify JSDoc: hex-encoded SHA-256, not RFC 7636 S256 * fix(auth): fail closed on PKCE callback cache errors, clean up origin/buffer handling - Callback middleware now redirects to error URL on cache.get failure instead of silently continuing without PKCE challenge - resolveRequestOrigin returns undefined (not raw header) on parse failure - Remove dead try/catch around Buffer.from which never throws for string input * chore(auth): remove narration comments, scope eslint-disable to lines * chore(auth): narrow query.state to string, remove narration comments in exchange.ts * fix(auth): address review findings — warn on missing PKCE challenge, validate verifier length, deduplicate URL parse - Log warning when OAuth state is present but no PKCE challenge found - Add minimum length check (>= 1) on code_verifier input validation - Update POST /oauth/exchange JSDoc to document code_verifier param - Deduplicate new URL(redirectUri) parse in createOAuthHandler - Restore intent comment on pre-delete pattern in exchangeAdminCode * test(auth): replace mock cache with real Keyv, remove all as-any casts - Use real Keyv in-memory store instead of hand-rolled Map mock - Replace jest.fn mocks with jest.spyOn on real Keyv instance - Remove redundant store.has() assertion, use cache.get() instead - Eliminate all eslint-disable and as-any suppressions - User fixture no longer needs any cast (Keyv accepts plain objects) * fix(auth): add IUser type cast for test fixture to satisfy tsc
This commit is contained in:
parent
1455f15b7b
commit
2bf0f892d6
4 changed files with 353 additions and 9 deletions
|
|
@ -47,9 +47,15 @@ function createOAuthHandler(redirectUri = domains.client) {
|
|||
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);
|
||||
const exchangeCode = await generateAdminExchangeCode(
|
||||
cache,
|
||||
req.user,
|
||||
token,
|
||||
refreshToken,
|
||||
callbackUrl.origin,
|
||||
req.pkceChallenge,
|
||||
);
|
||||
callbackUrl.searchParams.set('code', exchangeCode);
|
||||
logger.info(`[OAuth] Admin panel redirect with exchange code for user: ${req.user.email}`);
|
||||
return res.redirect(callbackUrl.toString());
|
||||
|
|
|
|||
|
|
@ -24,6 +24,28 @@ const setBalanceConfig = createSetBalanceConfig({
|
|||
|
||||
const router = express.Router();
|
||||
|
||||
function resolveRequestOrigin(req) {
|
||||
const originHeader = req.get('origin');
|
||||
if (originHeader) {
|
||||
try {
|
||||
return new URL(originHeader).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const refererHeader = req.get('referer');
|
||||
if (!refererHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(refererHeader).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/login/local',
|
||||
middleware.logHeaders,
|
||||
|
|
@ -52,20 +74,67 @@ router.get('/oauth/openid/check', (req, res) => {
|
|||
res.status(200).json({ message: 'OpenID check successful' });
|
||||
});
|
||||
|
||||
router.get('/oauth/openid', (req, res, next) => {
|
||||
/** PKCE challenge cache TTL: 5 minutes (enough for user to authenticate with IdP) */
|
||||
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;
|
||||
|
||||
if (typeof codeChallenge === 'string' && PKCE_CHALLENGE_PATTERN.test(codeChallenge)) {
|
||||
try {
|
||||
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
|
||||
await cache.set(`pkce:${state}`, codeChallenge, PKCE_CHALLENGE_TTL);
|
||||
} catch (err) {
|
||||
logger.error('[admin/oauth/openid] Failed to store PKCE challenge:', err);
|
||||
return res.redirect(
|
||||
`${getAdminPanelUrl()}/auth/openid/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return passport.authenticate('openidAdmin', {
|
||||
session: false,
|
||||
state: randomState(),
|
||||
state,
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/oauth/openid/callback',
|
||||
(req, res, next) => {
|
||||
req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined;
|
||||
next();
|
||||
},
|
||||
passport.authenticate('openidAdmin', {
|
||||
failureRedirect: `${getAdminPanelUrl()}/auth/openid/callback?error=auth_failed&error_description=Authentication+failed`,
|
||||
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();
|
||||
},
|
||||
requireAdminAccess,
|
||||
setBalanceConfig,
|
||||
middleware.checkDomainAllowed,
|
||||
|
|
@ -73,7 +142,7 @@ router.get(
|
|||
);
|
||||
|
||||
/** Regex pattern for valid exchange codes: 64 hex characters */
|
||||
const EXCHANGE_CODE_PATTERN = /^[a-f0-9]{64}$/i;
|
||||
const EXCHANGE_CODE_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
/**
|
||||
* Exchange OAuth authorization code for tokens.
|
||||
|
|
@ -81,12 +150,12 @@ const EXCHANGE_CODE_PATTERN = /^[a-f0-9]{64}$/i;
|
|||
* The code is one-time-use and expires in 30 seconds.
|
||||
*
|
||||
* POST /api/admin/oauth/exchange
|
||||
* Body: { code: string }
|
||||
* Body: { code: string, code_verifier?: string }
|
||||
* Response: { token: string, refreshToken: string, user: object }
|
||||
*/
|
||||
router.post('/oauth/exchange', middleware.loginLimiter, async (req, res) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
const { code, code_verifier: codeVerifier } = req.body;
|
||||
|
||||
if (!code) {
|
||||
logger.warn('[admin/oauth/exchange] Missing authorization code');
|
||||
|
|
@ -104,8 +173,20 @@ router.post('/oauth/exchange', middleware.loginLimiter, async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
if (
|
||||
codeVerifier !== undefined &&
|
||||
(typeof codeVerifier !== 'string' || codeVerifier.length < 1 || codeVerifier.length > 512)
|
||||
) {
|
||||
logger.warn('[admin/oauth/exchange] Invalid code_verifier format');
|
||||
return res.status(400).json({
|
||||
error: 'Invalid code_verifier',
|
||||
error_code: 'INVALID_VERIFIER',
|
||||
});
|
||||
}
|
||||
|
||||
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
|
||||
const result = await exchangeAdminCode(cache, code);
|
||||
const requestOrigin = resolveRequestOrigin(req);
|
||||
const result = await exchangeAdminCode(cache, code, requestOrigin, codeVerifier);
|
||||
|
||||
if (!result) {
|
||||
return res.status(401).json({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue