🛡️ fix: Add Origin Binding to Admin OAuth Exchange Codes (#12469)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

* 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:
Danny Avila 2026-03-30 16:54:00 -04:00 committed by GitHub
parent 1455f15b7b
commit 2bf0f892d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 353 additions and 9 deletions

View file

@ -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({