LibreChat/api/server/services/OpenIDSessionRefresh.js
J.C. Bartle 07c4a906d2 🛡️ fix: Harden OBO inline-refresh against token type and session edge cases
- Token-preference asymmetry: live-token reuse and expires_at derivation
  now strictly gate on the access_token, not the id_token. Added a
  required `tokenPreference` parameter on isLiveSessionTokenStillValid,
  buildOIDCTokensFromSession, and createOpenIDSessionTokenProvider
  so every call site is explicit. Dropped the bogus id_token-exp
  fallback in performIdpRefresh — id_token TTL is governed by IdP
  session policy and would mark a short-lived access_token reusable
  past its real lifetime.
- Missing req in /reinitialize route: the manual reconnect
  endpoint now forwards req into reinitMCPServer, so OBO servers can
  build a session-aware upstream-token closure instead of failing with
  missing_upstream_token.
- Single-flight key collisions: composed key as
  tenantId:openidIssuer:openidId:sessionId via getSingleFlightKey.
  Concurrent calls in the same session still coalesce; separate sessions
  never share an in-flight refresh, preventing refresh-token rotation
  from breaking sibling sessions and preventing cross-tenant token
  crossover when distinct users share an IdP sub.
- Opaque access token reuse): persist accessTokenExpiresAt
  (unix seconds, from tokenset.expires_in) on each refresh AND on initial
  login / SPA refresh in setOpenIDAuthTokens. New getAccessTokenExp
  helper falls back to it when the access token isn't a JWT, avoiding
  redundant inline refreshes for Microsoft Graph and Auth0 default
  audiences.
- Log hygiene: the single-flight key (containing sessionId,
  openidId, openidIssuer, tenantId) is now SHA-256-hashed in the
  "Joining in-flight refresh" debug log. Preserves cross-line correlation
  via a 12-char prefix without leaking credential or PII material.

Documented req.session.openidTokens shape contract via JSDoc typedef so
the new accessTokenExpiresAt field has a discoverable home alongside the
existing accessToken/idToken/refreshToken/expiresAt/lastRefreshedAt.

Tests: OpenIDSessionRefresh.spec.js up to 30 passing (added coverage for
opaque-token reuse, JWT-access-token-exp fallback, no-id_token-fallback
regression, cross-session no-coalesce, persistence on refresh, and a
guard against stale accessTokenExpiresAt carryover). AuthService.spec.js
adds two cases covering accessTokenExpiresAt persistence on login.
mcp.spec.js (route) gains a regression test asserting req flows into
reinitMCPServer.
2026-06-13 17:52:01 -04:00

424 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const jwt = require('jsonwebtoken');
const crypto = require('node:crypto');
const openIdClient = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, buildOpenIDRefreshParams } = require('@librechat/api');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
/**
* Shape of `req.session.openidTokens`. Established by `setOpenIDAuthTokens`
* (`api/server/services/AuthService.js`) on login/refresh, mutated in place by
* this module on inline refresh, and consumed by `refreshController` and
* `LogoutController`. Distinct from the snake_case `OIDCTokens` type in
* `@librechat/data-schemas` (which describes `IUser.federatedTokens` /
* `IUser.openidTokens` — model fields, not the express-session field).
*
* Express-session's SessionData is open by design, so this contract lives in
* comments rather than a TS interface; keep this and AuthService.js in sync
* when the shape changes.
*
* @typedef {Object} SessionOpenIDTokens
* @property {string} [accessToken] — IdP access token (may be opaque).
* @property {string} [idToken] — IdP ID token (always JWT).
* @property {string} [refreshToken] — IdP refresh token.
* @property {number} [expiresAt] — SESSION cookie expiry (ms).
* @property {number} [lastRefreshedAt] — wall-clock ms of the last server-side rotation.
* @property {number} [accessTokenExpiresAt] — access token expiry (unix seconds), captured
* from the IdP `tokenset.expires_in` so opaque
* access tokens can still be reused without
* redundant refreshes.
*/
/**
* Skew buffer for the upstream access-token expiry check. Mirrors
* `OPENID_REUSE_EXPIRY_BUFFER_SECONDS` in `AuthController.js` so that a token
* which the controller is about to rotate also triggers an inline refresh here.
*/
const UPSTREAM_TOKEN_EXPIRY_BUFFER_SECONDS = 30;
/**
* In-flight upstream refreshes keyed by `getSingleFlightKey(req, user)` —
* a composite of `tenantId:openidIssuer:openidId:sessionId`. See that helper
* for the rationale on why each component is needed; in short, per-session
* keying prevents refresh-token rotation from breaking sibling sessions, and
* tenant+issuer keying prevents cross-tenant token crossover when distinct
* users share an IdP `sub`.
*
* A fan-out of tool calls landing on an expired session within the SAME
* session coalesces into one IdP refresh-token grant. Mirrors the
* single-flight pattern in `OboTokenService.js`.
*
* Process-local: multi-worker deployments may double-refresh on the very
* first concurrent miss across workers — acceptable because the IdP accepts
* both and the session store uses last-write-wins.
*/
const inFlightRefreshes = new Map();
/**
* Returns the single-flight key for a refresh attempt, composed from the
* Express session id, the user's tenant (if any), and the IdP issuer + sub.
* Tightening past `openidId` alone serves two purposes:
*
* 1. Same human, multiple sessions: refresh-token rotation by the IdP would
* otherwise let session A's refresh invalidate session B's stored
* refresh_token, leaving B silently broken. Per-session keying ensures
* each session refreshes its own credentials.
* 2. Multi-tenant deployments where two distinct users share an IdP `sub`
* (different issuers, same sub): tenant + issuer disambiguates them so
* tokens never cross tenant boundaries via shared in-flight Promises.
*
* Concurrent tool calls inside the SAME session still coalesce — the common
* case the single-flight is designed for (a fan-out of MCP tool calls in one
* agent run) is unaffected.
*
* Returns null when there's no usable identity at all; callers fall through
* to a non-coalesced refresh, which is safe but missing the optimization.
*/
function getSingleFlightKey(req, user) {
const sub = user?.openidId || user?.id || user?._id?.toString?.();
if (!sub) {
return null;
}
const sessionId = req?.sessionID || 'no-session';
const tenantId = user?.tenantId || 'no-tenant';
const issuer = user?.openidIssuer || 'no-issuer';
return `${tenantId}:${issuer}:${sub}:${sessionId}`;
}
/**
* Returns a short SHA-256 prefix of the single-flight key for use in logs.
* Preserves correlation across "started" / "joined" / "completed" log events
* for the same refresh attempt without leaking the underlying values:
*
* - sessionId is effectively a credential (cookie material) and must never
* reach log sinks in clear text.
* - openidId (the IdP `sub`) and openidIssuer are tenant/user fingerprints.
*
* 12 hex chars = 48 bits of entropy: ~7×10^14 distinct keys before a 50%
* collision chance — more than enough for correlating concurrent refreshes.
*/
function hashKeyForLogs(key) {
return crypto.createHash('sha256').update(key).digest('hex').slice(0, 12);
}
function decodeJwtExp(token) {
if (typeof token !== 'string' || token.length === 0) {
return null;
}
try {
const decoded = jwt.decode(token);
if (!decoded || typeof decoded !== 'object') {
return null;
}
return typeof decoded.exp === 'number' ? decoded.exp : null;
} catch (error) {
logger.debug('[OpenIDSessionRefresh] JWT decode failed (non-fatal)', error?.message);
return null;
}
}
/**
* Returns the access token's expiry in unix seconds, preferring the JWT `exp`
* claim and falling back to the persisted `accessTokenExpiresAt` written from
* the IdP's `tokenset.expires_in` on the previous refresh.
*
* The fallback exists because some IdPs (Microsoft Entra for Graph audiences,
* Auth0 without a custom audience) issue OPAQUE access tokens whose expiry
* cannot be decoded locally. Without this lookup, every OBO call would treat
* the session as expired and burn an IdP refresh, risking refresh-token
* rotation thrash under concurrent tool calls.
*
* @param {{ accessToken?: string, accessTokenExpiresAt?: number }} sessionTokens
* @returns {number | null} unix seconds, or null when no source proves an expiry
*/
function getAccessTokenExp(sessionTokens) {
const fromJwt = decodeJwtExp(sessionTokens?.accessToken);
if (fromJwt != null) {
return fromJwt;
}
const persisted = sessionTokens?.accessTokenExpiresAt;
return typeof persisted === 'number' ? persisted : null;
}
/**
* Returns true when the session token nominated by `tokenPreference` is still
* valid for at least the skew buffer. Required argument (no default) so every
* caller is explicit about which token's freshness gates this check.
*
* Use 'access_token' for OBO and any flow whose downstream sends the access
* token to the IdP as an assertion (jwt-bearer / on-behalf-of) — those flows
* fail when the access token is expired even if the id_token is still fresh.
* Access-token expiry is read via `getAccessTokenExp`, which handles opaque
* (non-JWT) tokens by falling back to the persisted `accessTokenExpiresAt`.
*
* Use 'id_token' for flows whose downstream is the LibreChat backend itself
* (e.g. session-token reuse in `refreshController`); the id_token is the
* standard JWT signed for the client_id audience and is the bearer the SPA
* sends back to LibreChat.
*
* @param {{ accessToken?: string, idToken?: string, accessTokenExpiresAt?: number }} sessionTokens
* @param {'access_token' | 'id_token'} tokenPreference
*/
function isLiveSessionTokenStillValid(sessionTokens, tokenPreference) {
if (tokenPreference !== 'access_token' && tokenPreference !== 'id_token') {
throw new Error(
`[OpenIDSessionRefresh] tokenPreference must be 'access_token' or 'id_token', got: ${tokenPreference}`,
);
}
const now = Math.floor(Date.now() / 1000);
const exp =
tokenPreference === 'access_token'
? getAccessTokenExp(sessionTokens)
: decodeJwtExp(sessionTokens?.idToken);
return exp != null && exp > now + UPSTREAM_TOKEN_EXPIRY_BUFFER_SECONDS;
}
/**
* Builds the OIDCTokens shape consumed by `resolveOboToken`. Required
* `tokenPreference` selects which token's expiry becomes `expires_at` —
* caller intent must match what the downstream consumer actually validates.
* `expiresAtOverride` (unix seconds) wins when the caller has an authoritative
* value such as the IdP's `tokenset.expires_in` from a fresh refresh response;
* use it after refresh so we never attribute a prior token's `exp` to a freshly
* rotated counterpart. For 'access_token', the fallback uses `getAccessTokenExp`
* so opaque tokens are handled correctly via the persisted `accessTokenExpiresAt`.
*
* @param {{ accessToken?: string, idToken?: string, refreshToken?: string, accessTokenExpiresAt?: number }} sessionTokens
* @param {'access_token' | 'id_token'} tokenPreference
* @param {number} [expiresAtOverride] — unix seconds (preferred when present)
*/
function buildOIDCTokensFromSession(sessionTokens, tokenPreference, expiresAtOverride) {
if (tokenPreference !== 'access_token' && tokenPreference !== 'id_token') {
throw new Error(
`[OpenIDSessionRefresh] tokenPreference must be 'access_token' or 'id_token', got: ${tokenPreference}`,
);
}
let expiresAt = expiresAtOverride;
if (expiresAt == null) {
expiresAt =
tokenPreference === 'access_token'
? (getAccessTokenExp(sessionTokens) ?? undefined)
: (decodeJwtExp(sessionTokens?.idToken) ?? undefined);
}
return {
access_token: sessionTokens?.accessToken,
id_token: sessionTokens?.idToken,
refresh_token: sessionTokens?.refreshToken,
expires_at: expiresAt ?? undefined,
};
}
async function persistSession(req) {
if (typeof req?.session?.save !== 'function') {
return;
}
await new Promise((resolve, reject) => {
req.session.save((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
async function performIdpRefresh(req, tokenPreference) {
const sessionTokens = req?.session?.openidTokens;
const refreshToken = sessionTokens?.refreshToken;
if (!refreshToken) {
logger.debug(
'[OpenIDSessionRefresh] Session lacks refresh_token; cannot refresh upstream token',
);
return null;
}
const config = getOpenIdConfig();
const refreshParams = buildOpenIDRefreshParams();
logger.debug('[OpenIDSessionRefresh] Performing inline IdP refresh-token grant');
const tokenset = await openIdClient.refreshTokenGrant(config, refreshToken, refreshParams);
if (!tokenset?.access_token) {
throw new Error('IdP refresh returned no access_token');
}
/**
* Preserve previous values when the IdP omits `id_token` / `refresh_token`
* on rotation (Auth0 with rotation off, MS personal accounts in some flows).
* Same fallback behavior as setOpenIDAuthTokens.
*/
const nextIdToken = tokenset.id_token || sessionTokens.idToken;
const nextRefreshToken = tokenset.refresh_token || refreshToken;
/**
* Capture the freshly-issued access-token's expiry (unix seconds) so the
* next OBO call can reuse it without a redundant refresh — critical for
* opaque (non-JWT) access tokens whose expiry isn't readable from the
* token itself. Source order:
* 1. tokenset.expires_in — IdP's authoritative value for the new access
* token. Always preferred when present.
* 2. decodeJwtExp(tokenset.access_token) — only when access_token is
* itself a JWT. Decoding is a fact about THIS token, not a guess.
*
* Deliberately do NOT fall back to id_token's exp: id_token TTL is governed
* by IdP session policy and is often longer than access-token TTL. Trusting
* it would mark an opaque access token reusable past its real lifetime, so
* a stale token would be sent to the OBO IdP and rejected. When neither
* source proves an expiry, leave `accessTokenExpiresAt` unset; the next
* freshness check will correctly fall through to refresh.
*/
let nextAccessTokenExp = null;
if (typeof tokenset.expires_in === 'number') {
nextAccessTokenExp = Math.floor(Date.now() / 1000) + tokenset.expires_in;
} else {
nextAccessTokenExp = decodeJwtExp(tokenset.access_token);
}
const updatedSessionTokens = {
...sessionTokens,
accessToken: tokenset.access_token,
idToken: nextIdToken,
refreshToken: nextRefreshToken,
lastRefreshedAt: Date.now(),
};
if (nextAccessTokenExp != null) {
updatedSessionTokens.accessTokenExpiresAt = nextAccessTokenExp;
} else {
/** Drop a stale value rather than carry it across an unknown-expiry rotation. */
delete updatedSessionTokens.accessTokenExpiresAt;
}
req.session.openidTokens = updatedSessionTokens;
await persistSession(req);
logger.info('[OpenIDSessionRefresh] Inline refresh succeeded');
/**
* Pass the same expiry as the explicit `expiresAtOverride` so the returned
* OIDCTokens carries it directly, regardless of token preference. After
* refresh the IdP's value is authoritative and supersedes any decode.
*/
return buildOIDCTokensFromSession(
updatedSessionTokens,
tokenPreference,
nextAccessTokenExp ?? undefined,
);
}
async function refreshOrReuseSession(req, tokenPreference) {
const sessionTokens = req?.session?.openidTokens;
if (!sessionTokens) {
logger.debug('[OpenIDSessionRefresh] No session tokens to refresh from');
return null;
}
if (isLiveSessionTokenStillValid(sessionTokens, tokenPreference)) {
logger.debug('[OpenIDSessionRefresh] Live session token reused');
return buildOIDCTokensFromSession(sessionTokens, tokenPreference);
}
return performIdpRefresh(req, tokenPreference);
}
/**
* Single-flighted entry point. Concurrent callers for the same user share one
* in-flight refresh. The map is cleared in finally so a failed refresh does
* not pin subsequent retries.
*
* @param {import('express').Request} req
* @param {import('@librechat/data-schemas').IUser} user
* @param {'access_token' | 'id_token'} tokenPreference — required; selects
* which token's `exp` gates the live-vs-refresh decision and populates the
* returned `expires_at`. OBO callers pass 'access_token'.
*/
async function refreshOpenIDSession(req, user, tokenPreference) {
const key = getSingleFlightKey(req, user);
if (!key) {
return refreshOrReuseSession(req, tokenPreference);
}
const inFlight = inFlightRefreshes.get(key);
if (inFlight) {
logger.debug(`[OpenIDSessionRefresh] Joining in-flight refresh (key=${hashKeyForLogs(key)})`);
return inFlight;
}
const promise = refreshOrReuseSession(req, tokenPreference).finally(() => {
if (inFlightRefreshes.get(key) === promise) {
inFlightRefreshes.delete(key);
}
});
inFlightRefreshes.set(key, promise);
/** Swallow rejection on the cleanup chain; the original is delivered to the awaiter. */
promise.catch(() => {});
return promise;
}
/**
* Returns true when this user is in scope for OIDC session refresh. Non-OIDC
* users and deployments without `OPENID_REUSE_TOKENS` never had a populated
* `req.session.openidTokens` to begin with — the closure is a no-op there.
*/
function isOIDCRefreshApplicable(user) {
if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
return false;
}
if (!user) {
return false;
}
return user.provider === 'openid' || Boolean(user.openidId);
}
/**
* Builds the UpstreamTokenProvider closure forwarded into the MCP layer.
* The closure closes over `req` so it reads `req.session.openidTokens` at OBO
* call time (not at request validation), which is what makes the walk-away
* failure mode recover without a user-visible re-authentication.
*
* `tokenPreference` is required and identifies which upstream token's freshness
* gates the closure. OBO needs 'access_token' because the OBO exchange uses
* the access token as the jwt-bearer assertion; using id_token preference here
* would let an expired access token reach the IdP under a still-fresh id_token.
*
* Closure contract (matches `UpstreamTokenProvider` in obo.ts):
* - resolves to non-null OIDCTokens when fresh tokens are available.
* - resolves to null when refresh is not applicable / no session.
* - rejects when refresh was attempted and rejected by the IdP. The MCP
* layer wraps the rejection as `session_refresh_failed`.
*
* @param {object} args
* @param {import('express').Request} [args.req]
* @param {import('@librechat/data-schemas').IUser} [args.user]
* @param {'access_token' | 'id_token'} args.tokenPreference
* @returns {() => Promise<import('@librechat/data-schemas').OIDCTokens | null>}
*/
function createOpenIDSessionTokenProvider({ req, user, tokenPreference }) {
if (tokenPreference !== 'access_token' && tokenPreference !== 'id_token') {
throw new Error(
`[OpenIDSessionRefresh] createOpenIDSessionTokenProvider requires tokenPreference 'access_token' or 'id_token', got: ${tokenPreference}`,
);
}
return async function upstreamTokenProvider() {
if (!isOIDCRefreshApplicable(user)) {
return null;
}
if (!req?.session?.openidTokens) {
logger.debug(
'[OpenIDSessionRefresh] No session.openidTokens available on req; closure returning null',
);
return null;
}
return refreshOpenIDSession(req, user, tokenPreference);
};
}
module.exports = {
createOpenIDSessionTokenProvider,
refreshOpenIDSession,
/** Exposed for tests; not a public API. */
__internals: {
UPSTREAM_TOKEN_EXPIRY_BUFFER_SECONDS,
inFlightRefreshes,
isLiveSessionTokenStillValid,
getAccessTokenExp,
},
};