Skip to content

🔐 fix: Keep the Admin OpenID State So PKCE Holds When the IdP Omits S256 - #17478

Open
danny-avila wants to merge 1 commit into
devfrom
fix/admin-openid-pkce-state
Open

danny-avila wants to merge 1 commit into
devfrom
fix/admin-openid-pkce-state

Conversation

@danny-avila

Copy link
Copy Markdown
Collaborator

Pull Request

Before submitting, please review the Contributing Guide.

Summary

The admin panel's OpenID sign-in silently drops its PKCE check when the IdP's discovery document does not list S256 in code_challenge_methods_supported. Azure Entra ID is the common case: its discovery document omits the field. GET /api/admin/oauth/openid stores the panel's code_challenge under pkce:<state> and passes that state to the openidAdmin strategy, but openid-client v6's Passport Strategy replaces the state with a random one whenever serverMetadata().supportsPKCE() is false and the request carries no nonce. The IdP returns the replaced state, so the callback finds no challenge, logs State present but no PKCE challenge found; PKCE will not be enforced for this request (the warning in LibreChat-AI/admin-panel#79's logs), and issues an exchange code that POST /api/admin/oauth/exchange redeems with any code_verifier or with none. Sign-in still succeeds, so the only sign of the problem is the warning.

This PR gives admin OpenID sign-in its own strategy class. When the IdP does not advertise S256 and the request has no nonce yet, it adds a fresh nonce to the authorization request. openid-client then keeps the admin route's state, stores the nonce with the session's PKCE verifier and checks it against the ID token on the callback. The callback finds the panel's challenge, and the exchange code only redeems with the panel's code_verifier. Nothing changes for an IdP that advertises S256, for a request that already has a nonce (OPENID_GENERATE_NONCE=true, the existing workaround), or for the chat OpenID sign-in (/oauth/openid).

Related to LibreChat-AI/admin-panel#79

How it works

openid-client's authorizationRequest (openid-client/build/passport.js) only replaces the state when there is no nonce:

if (!this._config.serverMetadata().supportsPKCE() &&
    !redirectTo.searchParams.has('nonce')) {
    redirectTo.searchParams.set('state', client.randomState());
}

setupOpenIdAdmin now builds an AdminOpenIDStrategy, a subclass of CustomOpenIDStrategy. It adds the nonce through preserveAdminOAuthState in packages/api/src/auth/exchange.ts, next to the helper that stores the challenge:

GET /api/admin/oauth/openid?code_challenge=<hex>
  storeAndStripChallenge                              # pkce:<state> in ADMIN_OAUTH_EXCHANGE
  passport.authenticate('openidAdmin', { state })
    AdminOpenIDStrategy.authorizationRequestParams
      CustomOpenIDStrategy.authorizationRequestParams # state, audience, OPENID_GENERATE_NONCE
      preserveAdminOAuthState                         # nonce when !supportsPKCE() and none is set
    authorizationRequest (openid-client)              # keeps the state: a nonce is present
GET /api/admin/oauth/openid/callback?code&state
  passport.authenticate('openidAdmin')                # checks state, PKCE with the IdP, ID token nonce
  retrievePkceChallenge                               # finds and consumes pkce:<state>
  createOAuthHandler                                  # exchange code bound to the challenge

Type of change

  • Bug fix

Testing

Tested environments/configuration:

  • A stub OpenID provider over HTTPS (discovery, RS256 ID tokens, a token endpoint that checks the IdP-level PKCE verifier), with code_challenge_methods_supported left out (like Entra ID) and with ['S256'] (like Keycloak)
  • This branch's backend on a local MongoDB replica set, with OPENID_GENERATE_NONCE and OPENID_USE_PKCE unset
  • The admin panel's current main, served against that backend

Each row is three admin sign-ins driven the way the panel drives them: start with a code_challenge, follow the IdP, take the exchange code from the panel callback URL, then exchange it with a wrong verifier, without a verifier, and with the right one:

IdP metadata Build State the IdP received Nonce sent "no PKCE challenge found" warnings Wrong verifier No verifier Right verifier
No code_challenge_methods_supported dev openid-client's random 43-character state none 3 of 3 200, admin token 200, admin token 200
No code_challenge_methods_supported This branch the admin route's 64-hex state 43 characters 0 401 INVALID_OR_EXPIRED_CODE 401 INVALID_OR_EXPIRED_CODE 200
['S256'] This branch the admin route's 64-hex state none 0 401 INVALID_OR_EXPIRED_CODE 401 INVALID_OR_EXPIRED_CODE 200

In the admin panel, "Sign in with SSO" against the Entra-like provider on this branch goes from the backend to the IdP, back to the backend callback and on to the panel, which lands on its home page signed in as the OpenID user. The backend logs no warning. With either metadata, this branch's chat sign-in (GET /oauth/openid) still sends the IdP no nonce.

Automated tests:

  • api/server/routes/admin/auth.openid.test.js (new): runs the real admin router, Passport and the installed openid-client against a stub IdP behind undici.fetch, with metadata that has and lacks code_challenge_methods_supported. It checks that the state the admin challenge is stored under reaches the IdP, that the callback finds and consumes the challenge without the warning, that the exchange refuses a missing or wrong code_verifier and issues the token for the right one, that a nonce is added only without S256, and that the chat sign-in request is unchanged. An Entra callback whose ID token leaves the nonce out, or carries another request's nonce, hands the admin panel no exchange code. With setupOpenIdAdmin back on CustomOpenIDStrategy, 4 of its 10 tests fail.
  • packages/api/src/auth/adminPkce.spec.ts: preserveAdminOAuthState adds a nonce when the metadata lacks S256 or lists only plain, and a fresh one for each request. It adds nothing when the metadata advertises S256 or the request has no state, and it keeps a nonce the request already has.
  • npm run build for the packages, tsc for data-schemas and api, the admin and app suites in packages/api, the app and config suites in packages/data-schemas, the legacy admin routes, openidStrategy.spec.js and the Config service suites, and the static checks pass.
  • npm run lighthouse, the gate AGENTS.md asks for on auth changes, passes on this branch: median LCP 3,422 ms (budget 4,500), CLS 0.018 (0.1), TBT 129 ms (500). The change adds no database read, and its code only runs when an admin starts OpenID sign-in, which the Lighthouse scenario does not load.

Screenshots / recordings

No user-facing change.

Risk / compatibility

Security-sensitive: this restores the PKCE binding between the admin panel's session and the exchange code for IdPs that do not advertise S256. On those IdPs, admin sign-in now also needs the ID token to carry the nonce it was sent. OpenID Connect requires the IdP to return it, and Entra ID does. An IdP that omits S256 and also drops the nonce signed in on dev, without the PKCE check. It now fails the admin callback, as it already does with OPENID_GENERATE_NONCE=true. The admin sees the backend's generic error page (HTTP 500, "An unknown error occurred.", the existing handling for any openid-client callback error) rather than the admin panel's auth_failed page, and no setting turns this nonce off. Admin sign-in with IdPs that advertise S256, and all chat OpenID sign-ins, behave as before.

Known limit, kept for a follow-up: the callback still lets the exchange code through without a challenge when no challenge is cached for some other reason, such as an admin panel that sends no code_challenge or a stay at the IdP longer than PKCE_CHALLENGE_TTL (5 minutes). It logs the same warning, as on dev.

Checklist

  • I reviewed my own changes
  • Relevant tests have been added or updated
  • Existing relevant tests pass
  • The change does not introduce new warnings or errors
  • User-facing or complex behavior is documented where necessary
  • Required dependency changes have been merged/published
  • Required documentation PR: N/A

openid-client's Passport Strategy replaces the authorization request's state with a random one
when the IdP's discovery metadata does not list S256 in code_challenge_methods_supported and the
request has no nonce. Azure Entra ID omits that field, so the admin callback could not find the
admin panel's PKCE challenge under pkce:<state> and issued an exchange code that any
code_verifier, or none, could redeem.

The admin strategy now adds a nonce in that case, so openid-client keeps the admin route's state
and checks the nonce against the ID token on the callback. The chat OpenID flow is unchanged.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Pass 1. Head: 76948e7

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T08:11:17.474625Z 76948e7 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 76948e759e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@codegraph-librechat codegraph-librechat Bot added 🗺️ Auth codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) 🛡️ security review labels Oct 27, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🗺️ Auth codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) 🛡️ security review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant