🔐 fix: Strip code_challenge from Admin OAuth requests before Passport (#12534)

* 🔐 fix: Strip code_challenge from admin OAuth requests before Passport

openid-client v6's Passport Strategy uses `currentUrl.searchParams.size === 0`
to distinguish initial authorization requests from OAuth callbacks. The
admin-panel-specific `code_challenge` query parameter caused the strategy to
misclassify the request as a callback and return 401 Unauthorized.

* 🔐 fix: Strip code_challenge from admin OAuth requests before Passport

openid-client v6's Passport Strategy uses `currentUrl.searchParams.size === 0`
to distinguish initial authorization requests from OAuth callbacks. The
admin-panel-specific `code_challenge` query parameter caused the strategy to
misclassify the request as a callback and return 401 Unauthorized.

- Fix regex to handle `code_challenge` in any query position without producing
  malformed URLs, and handle empty `code_challenge=` values (`[^&]*` vs `[^&]+`)
- Combine `storePkceChallenge` + `stripCodeChallenge` into a single
  `storeAndStripChallenge` helper to enforce read-store-strip ordering
- Apply defensively to all 7 admin OAuth providers
- Add 12 unit tests covering stripCodeChallenge and storeAndStripChallenge

* refactor: Extract PKCE helpers to utility file, harden tests

- Move stripCodeChallenge and storeAndStripChallenge to
  api/server/utils/adminPkce.js — eliminates _test production export
  and avoids loading the full auth.js module tree in tests
- Add missing req.originalUrl/req.url assertions to invalid-challenge
  and no-challenge test branches (regression blind spots)
- Hoist cache reference to module scope in tests (was redundantly
  re-acquired from mock factory on every beforeEach)

* chore: Address review NITs — imports, exports, naming, assertions

- Fix import order in auth.js (longest-to-shortest per CLAUDE.md)
- Remove unused PKCE_CHALLENGE_TTL/PKCE_CHALLENGE_PATTERN exports
- Hoist strip arrow to module-scope stripChallengeFromUrl
- Rename auth.test.js → auth.spec.js (project convention)
- Tighten cache-failure test: toBe instead of toContain, add req.url

* refactor: Move PKCE helpers to packages/api with dependency injection

Move stripCodeChallenge and storeAndStripChallenge from api/server/utils
into packages/api/src/auth/exchange.ts alongside the existing PKCE
verification logic. Cache is now injected as a Keyv parameter, matching
the dependency-injection pattern used throughout packages/api/.

- Add PkceStrippableRequest interface for minimal req typing
- auth.js imports storeAndStripChallenge from @librechat/api
- Delete api/server/utils/adminPkce.js
- Move tests to packages/api/src/auth/adminPkce.spec.ts (TypeScript,
  real Keyv instances, no getLogStores mock needed)
This commit is contained in:
Danny Avila 2026-04-02 21:03:44 -04:00 committed by GitHub
parent ed02fe40e0
commit fa4a43da21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 298 additions and 34 deletions

View file

@ -3,7 +3,12 @@ const passport = require('passport');
const crypto = require('node:crypto');
const { CacheKeys } = require('librechat-data-provider');
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
const { getAdminPanelUrl, exchangeAdminCode, createSetBalanceConfig } = require('@librechat/api');
const {
getAdminPanelUrl,
exchangeAdminCode,
createSetBalanceConfig,
storeAndStripChallenge,
} = require('@librechat/api');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { requireCapability } = require('~/server/middleware/roles/capabilities');
const { createOAuthHandler } = require('~/server/controllers/auth/oauth');
@ -73,11 +78,6 @@ router.get('/oauth/openid/check', (req, res) => {
res.status(200).json({ message: 'OpenID check successful' });
});
/** 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}$/;
/**
* Generates a random hex state string for OAuth flows.
* @returns {string} A 32-byte random hex string.
@ -86,27 +86,6 @@ function generateState() {
return crypto.randomBytes(32).toString('hex');
}
/**
* 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).
@ -148,7 +127,8 @@ function retrievePkceChallenge(provider) {
router.get('/oauth/openid', async (req, res, next) => {
const state = generateState();
const stored = await storePkceChallenge(state, req.query.code_challenge, 'openid');
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
const stored = await storeAndStripChallenge(cache, req, state, 'openid');
if (!stored) {
return res.redirect(
`${getAdminPanelUrl()}/auth/openid/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
@ -185,7 +165,8 @@ router.get(
router.get('/oauth/saml', async (req, res, next) => {
const state = generateState();
const stored = await storePkceChallenge(state, req.query.code_challenge, 'saml');
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
const stored = await storeAndStripChallenge(cache, req, state, 'saml');
if (!stored) {
return res.redirect(
`${getAdminPanelUrl()}/auth/saml/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,
@ -222,7 +203,8 @@ router.post(
router.get('/oauth/google', async (req, res, next) => {
const state = generateState();
const stored = await storePkceChallenge(state, req.query.code_challenge, 'google');
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`,
@ -260,7 +242,8 @@ router.get(
router.get('/oauth/github', async (req, res, next) => {
const state = generateState();
const stored = await storePkceChallenge(state, req.query.code_challenge, 'github');
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`,
@ -298,7 +281,8 @@ router.get(
router.get('/oauth/discord', async (req, res, next) => {
const state = generateState();
const stored = await storePkceChallenge(state, req.query.code_challenge, 'discord');
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`,
@ -336,7 +320,8 @@ router.get(
router.get('/oauth/facebook', async (req, res, next) => {
const state = generateState();
const stored = await storePkceChallenge(state, req.query.code_challenge, 'facebook');
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`,
@ -374,7 +359,8 @@ router.get(
router.get('/oauth/apple', async (req, res, next) => {
const state = generateState();
const stored = await storePkceChallenge(state, req.query.code_challenge, 'apple');
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
const stored = await storeAndStripChallenge(cache, req, state, 'apple');
if (!stored) {
return res.redirect(
`${getAdminPanelUrl()}/auth/apple/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`,