Skip to content

[Bug]: OPENID_REUSE_TOKENS=true with Entra ID causes 401 on all API calls — two bugs in AuthService.js #11753

@marianfoo

Description

@marianfoo

What happened?

When using OPENID_REUSE_TOKENS=true with Microsoft Entra ID on http://localhost:3080 (Docker Compose, NODE_ENV=production), login succeeds but every subsequent API call returns 401 Unauthorized. The UI shows a loading spinner indefinitely.

Two independent bugs in api/server/services/AuthService.js cause this:

  1. secure: isProduction prevents auth cookies from being set over HTTP on localhost — PR 🍪 refactor: Secure Cookie Setting for Localhost OAuth Sessions #11518 already fixed this for session cookies in socialLogins.js but missed the auth token cookies in AuthService.js.
  2. setOpenIDAuthTokens() returns access_token instead of id_token — With Entra ID (and Auth0, per [Bug]: OpenID Connect Token Reuse Bug with Auth0 #8796), the access_token is a Graph API token that cannot be validated via JWKS. The openIdJwtStrategy expects an id_token.

Note: This issue, all debugging and root-cause analysis for this issue was performed with AI assistance (Claude / Cursor IDE).


Version Information

  • LibreChat: main branch, commit cc7f61096 (50 commits ahead of v0.8.2)
  • Deployment: Docker Compose, local build from Dockerfile
  • OS: macOS (Darwin 24.6.0)
  • Browser: Chrome (tested in incognito)

Environment Configuration

# Server
HOST=localhost
PORT=3080
DOMAIN_SERVER=http://localhost:3080
DOMAIN_CLIENT=http://localhost:3080
NODE_ENV=production  # (set by Docker image default)

# OpenID — Microsoft Entra ID
OPENID_CLIENT_ID=<entra-app-client-id>
OPENID_CLIENT_SECRET=<entra-app-secret-value>
OPENID_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
OPENID_SESSION_SECRET=<random-hex>
OPENID_SCOPE="openid profile email offline_access"
OPENID_CALLBACK_URL=/oauth/openid/callback
OPENID_REUSE_TOKENS=true
# OPENID_AUDIENCE is NOT set (default Entra ID setup)
# librechat.yaml
version: 1.3.3
cache: true

interface:
  agents: true
  mcpServers:
    use: true

registration:
  socialLogins: ['openid']

Steps to Reproduce

  1. Configure LibreChat with Entra ID as OpenID provider (standard v2.0 endpoint)
  2. Set OPENID_REUSE_TOKENS=true (required for SharePoint integration / federated token pass-through)
  3. Do NOT set OPENID_AUDIENCE (this is the default, undocumented setup)
  4. Run via Docker Compose on http://localhost:3080
  5. Log in via Entra ID — OAuth callback succeeds, redirect back to app
  6. Observe: all API calls (/api/convos, /api/balance, /api/config, etc.) return 401 Unauthorized
  7. The /api/auth/refresh call itself returns 200 OK, but the token it provides fails JWKS validation on subsequent requests

Root Cause Analysis

Bug 1: secure: isProduction in AuthService.js prevents cookies on localhost

PR #11518 ("Secure Cookie Setting for Localhost OAuth Sessions") correctly introduced shouldUseSecureCookie() in api/server/socialLogins.js to handle the case where NODE_ENV=production but the server runs on http://localhost. This function returns false for localhost, allowing cookies to be set without the Secure flag (which requires HTTPS).

However, api/server/services/AuthService.js was not updated in that PR. It still uses secure: isProduction in 6 locations across setAuthTokens() and setOpenIDAuthTokens():

  • refreshToken cookie
  • token_provider cookie
  • openid_access_token cookie (session fallback)
  • openid_user_id cookie

This means session cookies (from socialLogins.js) work correctly on localhost, but auth token cookies (from AuthService.js) are silently dropped by the browser because Secure cookies cannot be set/sent over http://.

The token_provider cookie being dropped is critical — without it, requireJwtAuth middleware defaults to the standard jwt strategy instead of openidJwt, causing all authenticated requests to fail.

Bug 2: setOpenIDAuthTokens() returns access_token instead of id_token

Even after fixing Bug 1, all API calls still returned 401. The openIdJwtStrategy validates the Bearer token via JWKS (the IdP's signing keys). The token flow is:

  1. setOpenIDAuthTokens() returns a token → sent to client
  2. Client sends it as Authorization: Bearer <token> on every request
  3. openIdJwtStrategy validates the Bearer token via JWKS

The upstream code returns tokenset.access_token. For Entra ID v2.0 without OPENID_AUDIENCE, this token is a Microsoft Graph API token (audience: https://graph.microsoft.com). It is either:

  • An opaque token, or
  • A JWT signed for the Graph API audience, not the app's audience

In either case, JWKS validation against the app's issuer endpoint fails silently, and openIdJwtStrategy returns false (unauthenticated).

The id_token, on the other hand:

  • Is always a standard JWT signed by Entra's JWKS keys
  • Has the audience set to the app's client_id
  • Contains all required claims (sub, email, oid)

The strategy itself confirms this design intent — the callback payload is typed as IDToken:

// api/strategies/openIdJwtStrategy.js, line 46-48
/**
 * @param {import('openid-client').IDToken} payload  // <-- expects ID token
 */

And the strategy assigns the Bearer token to id_token in federatedTokens:

// api/strategies/openIdJwtStrategy.js, line 95-100
user.federatedTokens = {
    access_token: accessToken || rawToken,  // real access_token from session
    id_token: rawToken,                      // the Bearer token IS the id_token
    refresh_token: refreshToken,
    expires_at: payload.exp,
};

This is the same root cause as issue #8796 ("OpenID Connect Token Reuse Bug with Auth0"), where Auth0 returned encrypted access tokens (JWE format) that couldn't be validated as JWTs. That issue proposed using id_token as "Option 1" but was converted to a discussion without implementing the fix. Instead, OPENID_AUDIENCE support was added (PR #9931) as a workaround — but OPENID_AUDIENCE requires additional IdP configuration (registering custom API scopes) and is not part of the standard Entra ID setup.


Related Issues and PRs

Reference Relevance
PR #11518Secure Cookie Setting for Localhost OAuth Sessions Fixed shouldUseSecureCookie() in socialLogins.js but missed AuthService.js — this is Bug 1
PR #11236Move OpenID Tokens from Cookies to Server-Side Sessions Refactored setOpenIDAuthTokens to add req parameter and session storage, but kept secure: isProduction and return tokenset.access_token
PR #9931Add OpenID Connect Federated Provider Token Support Added OPENID_AUDIENCE as a workaround and existingRefreshToken parameter, but did not fix the default access_token return
Issue #8796OpenID Connect Token Reuse Bug with Auth0 Same root cause as Bug 2. Auth0 encrypted access tokens fail JWKS validation. Proposed fix: tokenset.id_token || tokenset.access_token. Was closed/converted to discussion without implementing Option 1
Issue #11078OPENID_REUSE_TOKENS causes HTTP/2 connection failures due to large cookie size Related context — the bug that prompted PR #11236's session-based token storage

Proposed Fix

The fix is minimal and contained entirely in api/server/services/AuthService.js:

1. Add shouldUseSecureCookie() (same function already in socialLogins.js)

function shouldUseSecureCookie() {
  const domainServer = process.env.DOMAIN_SERVER || '';
  let hostname = '';
  if (domainServer) {
    try {
      const normalized = /^https?:\/\//i.test(domainServer)
        ? domainServer
        : `http://${domainServer}`;
      const url = new URL(normalized);
      hostname = (url.hostname || '').toLowerCase();
    } catch {
      hostname = domainServer.toLowerCase();
    }
  }
  const isLocalhost =
    hostname === 'localhost' ||
    hostname === '127.0.0.1' ||
    hostname === '::1' ||
    hostname.endsWith('.localhost');
  return isProduction && !isLocalhost;
}

2. Replace all secure: isProduction with secure: shouldUseSecureCookie()

In both setAuthTokens() (2 cookies) and setOpenIDAuthTokens() (4 cookies).

3. Return id_token as the app authentication token

- return tokenset.access_token;
+ const appAuthToken = tokenset.id_token || tokenset.access_token;
+ return appAuthToken;

And in the cookie fallback path:

- res.cookie('openid_access_token', tokenset.access_token, { ... });
+ res.cookie('openid_access_token', appAuthToken, { ... });

The fallback to access_token ensures backward compatibility for providers where the access_token is a standard JWKS-validatable JWT (e.g., when OPENID_AUDIENCE is set).

Full diff

diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js
--- a/api/server/services/AuthService.js
+++ b/api/server/services/AuthService.js
@@ -36,6 +36,37 @@ const domains = {
 const isProduction = process.env.NODE_ENV === 'production';
 const genericVerificationMessage = 'Please check your email to verify your email address.';
 
+/**
+ * Determines if secure cookies should be used.
+ * Only use secure cookies in production when not on localhost.
+ * @returns {boolean}
+ */
+function shouldUseSecureCookie() {
+  const domainServer = process.env.DOMAIN_SERVER || '';
+
+  let hostname = '';
+  if (domainServer) {
+    try {
+      const normalized = /^https?:\/\//i.test(domainServer)
+        ? domainServer
+        : `http://${domainServer}`;
+      const url = new URL(normalized);
+      hostname = (url.hostname || '').toLowerCase();
+    } catch {
+      // Fallback: treat DOMAIN_SERVER directly as a hostname-like string
+      hostname = domainServer.toLowerCase();
+    }
+  }
+
+  const isLocalhost =
+    hostname === 'localhost' ||
+    hostname === '127.0.0.1' ||
+    hostname === '::1' ||
+    hostname.endsWith('.localhost');
+
+  return isProduction && !isLocalhost;
+}
+
 // --- setAuthTokens() ---
 
     res.cookie('refreshToken', refreshToken, {
       expires: new Date(refreshTokenExpires),
       httpOnly: true,
-      secure: isProduction,
+      secure: shouldUseSecureCookie(),
       sameSite: 'strict',
     });
     res.cookie('token_provider', 'librechat', {
       expires: new Date(refreshTokenExpires),
       httpOnly: true,
-      secure: isProduction,
+      secure: shouldUseSecureCookie(),
       sameSite: 'strict',
     });

 // --- setOpenIDAuthTokens() ---
 
+    const appAuthToken = tokenset.id_token || tokenset.access_token;
 
       res.cookie('refreshToken', refreshToken, {
-        secure: isProduction,
+        secure: shouldUseSecureCookie(),
       });
-      res.cookie('openid_access_token', tokenset.access_token, {
-        secure: isProduction,
+      res.cookie('openid_access_token', appAuthToken, {
+        secure: shouldUseSecureCookie(),
       });
 
     res.cookie('token_provider', 'openid', {
-      secure: isProduction,
+      secure: shouldUseSecureCookie(),
     });
 
       res.cookie('openid_user_id', signedUserId, {
-        secure: isProduction,
+        secure: shouldUseSecureCookie(),
       });
 
-    return tokenset.access_token;
+    return appAuthToken;

Why OPENID_AUDIENCE is a workaround, not a fix

Setting OPENID_AUDIENCE makes the IdP issue a JWT access token with the app's audience, which JWKS can validate. However:

  1. It requires additional IdP configuration — registering custom API scopes in Azure / creating an API in Auth0
  2. It's not documented as a requirement for basic Entra ID + OPENID_REUSE_TOKENS setup
  3. It doesn't address the fundamental mismatch — the openIdJwtStrategy expects an id_token (payload typed as IDToken), but receives an access_token
  4. The id_token is always available in any OIDC flow and is the correct token for identity assertion

The proposed fix makes the default behavior correct while preserving access_token as a fallback for backward compatibility.


Impact

  • Affected: Any deployment using OPENID_REUSE_TOKENS=true with Entra ID (or Auth0, Cognito) on http://localhost without OPENID_AUDIENCE
  • Severity: Authentication is completely broken — users can log in but cannot use the application
  • Files changed: Only api/server/services/AuthService.js (one file)

Code of Conduct

  • I agree to follow this project's Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions