-
-
Notifications
You must be signed in to change notification settings - Fork 6.9k
Description
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:
secure: isProductionprevents 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 insocialLogins.jsbut missed the auth token cookies inAuthService.js.setOpenIDAuthTokens()returnsaccess_tokeninstead ofid_token— With Entra ID (and Auth0, per [Bug]: OpenID Connect Token Reuse Bug with Auth0 #8796), theaccess_tokenis a Graph API token that cannot be validated via JWKS. TheopenIdJwtStrategyexpects anid_token.
Note: This issue, all debugging and root-cause analysis for this issue was performed with AI assistance (Claude / Cursor IDE).
Version Information
- LibreChat:
mainbranch, commitcc7f61096(50 commits ahead ofv0.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
- Configure LibreChat with Entra ID as OpenID provider (standard v2.0 endpoint)
- Set
OPENID_REUSE_TOKENS=true(required for SharePoint integration / federated token pass-through) - Do NOT set
OPENID_AUDIENCE(this is the default, undocumented setup) - Run via Docker Compose on
http://localhost:3080 - Log in via Entra ID — OAuth callback succeeds, redirect back to app
- Observe: all API calls (
/api/convos,/api/balance,/api/config, etc.) return 401 Unauthorized - The
/api/auth/refreshcall 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():
refreshTokencookietoken_providercookieopenid_access_tokencookie (session fallback)openid_user_idcookie
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:
setOpenIDAuthTokens()returns a token → sent to client- Client sends it as
Authorization: Bearer <token>on every request openIdJwtStrategyvalidates 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 #11518 — Secure Cookie Setting for Localhost OAuth Sessions | Fixed shouldUseSecureCookie() in socialLogins.js but missed AuthService.js — this is Bug 1 |
| PR #11236 — Move 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 #9931 — Add 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 #8796 — OpenID 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 #11078 — OPENID_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:
- It requires additional IdP configuration — registering custom API scopes in Azure / creating an API in Auth0
- It's not documented as a requirement for basic Entra ID +
OPENID_REUSE_TOKENSsetup - It doesn't address the fundamental mismatch — the
openIdJwtStrategyexpects anid_token(payload typed asIDToken), but receives anaccess_token - The
id_tokenis 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=truewith Entra ID (or Auth0, Cognito) onhttp://localhostwithoutOPENID_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