LibreChat/api/server/services/OpenIDSessionRefresh.js
2026-08-29 12:51:04 -04:00

858 lines
33 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, DEFAULT_REFRESH_TOKEN_EXPIRY } = require('@librechat/data-schemas');
const {
isEnabled,
math,
createAuthIdentityContext,
isOpenIDSessionIdentityMatch,
createOpenIDRefreshIdentityTuple,
createRefreshTokenBridgeIdentity,
serializeAuthIdentityTuple,
buildOpenIDRefreshParams,
setRefreshTokenCookie,
setOpenIDMarkerCookies,
storeOpenIdSession,
normalizeExpiresIn,
} = require('@librechat/api');
const { upsertSession, deleteSession } = require('~/models');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const { OPENID_REFRESH_BRIDGE_GRACE_MS, storeRefreshTokenBridge } = require('./RefreshTokenBridge');
const {
acquireOpenIDRefreshFlight,
completeOpenIDRefreshFlight,
createOpenIDRefreshFlightKey,
failOpenIDRefreshFlight,
waitForOpenIDRefreshFlight,
} = require('./OpenIDRefreshFlight');
/**
* 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 {string} [browserRefreshToken] — refresh token last known to be written to
* the browser cookie.
* @property {number} [expiresAt] — SESSION cookie expiry (ms).
* @property {number} [lastRefreshedAt] — wall-clock ms of the last server-side rotation.
* @property {string} [appUserId] — LibreChat user id bound to these session tokens.
* @property {string} [openidSubject] — OpenID `sub` bound to these session tokens.
* @property {string} [tenantId] — tenant bound to these session tokens.
* @property {string} [openidIssuer] — normalized issuer bound to these session tokens.
* @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;
const INTERNAL_BROWSER_REFRESH_TOKEN_FIELD = '__browserRefreshToken';
const IDENTITY_PART_SEPARATOR = '\x1f';
/**
* In-flight upstream refreshes keyed by `getSingleFlightKey(req, user, identityContext)` —
* a composite of `tenantId:openidIssuer:subject:sessionId:refreshTokenHash`.
* 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, tenant+issuer keying prevents cross-tenant token crossover when
* distinct users share an IdP `sub`, and refresh-token keying prevents a request
* carrying a newly-rotated session token from joining an older pending refresh.
*
* 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), the IdP issuer + sub, and
* the current session refresh token.
* 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 with the SAME refresh token
* 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, identityContext) {
const identitySource = identityContext
? {
id: identityContext.appUserId,
openidId: identityContext.openidSubject,
tenantId: identityContext.tenantId,
openidIssuer: identityContext.openidIssuer,
}
: user;
const tuple = createOpenIDRefreshIdentityTuple({
user: identitySource,
requestUser: req?.user,
});
const refreshToken = req?.session?.openidTokens?.refreshToken;
if (!tuple || !refreshToken) {
return null;
}
const sessionId = req?.sessionID || 'no-session';
const refreshTokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
return [serializeAuthIdentityTuple(tuple), sessionId, refreshTokenHash].join(
IDENTITY_PART_SEPARATOR,
);
}
/**
* 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 resolveExpectedOpenIDSessionIdentity(req, user, identityContext) {
if (!identityContext) {
return createAuthIdentityContext({
user,
requestUser: req?.user,
});
}
return createAuthIdentityContext({
user: {
id: identityContext.appUserId,
openidId: identityContext.openidSubject,
tenantId: identityContext.tenantId,
openidIssuer: identityContext.openidIssuer,
},
requestUser: user ?? req?.user,
tenantId: identityContext.tenantId,
openidIssuer: identityContext.openidIssuer,
});
}
function assertOpenIDSessionIdentityMatch(req, user, identityContext) {
const sessionTokens = req?.session?.openidTokens;
if (!sessionTokens) {
return;
}
const expectedIdentity = resolveExpectedOpenIDSessionIdentity(req, user, identityContext);
if (isOpenIDSessionIdentityMatch(sessionTokens, expectedIdentity)) {
return;
}
logger.warn('[OpenIDSessionRefresh] OpenID session token identity mismatch; refusing reuse', {
userId: expectedIdentity.appUserId,
has_session_user_id: Boolean(sessionTokens.appUserId),
has_session_subject: Boolean(sessionTokens.openidSubject),
has_session_issuer: Boolean(sessionTokens.openidIssuer),
});
throw new Error('OpenID session token identity mismatch');
}
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;
}
function canWriteRefreshTokenCookie(res) {
return !!res && typeof res.cookie === 'function' && !res.headersSent;
}
/**
* 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,
};
}
function attachBrowserRefreshTokenMarker(tokens, browserRefreshToken) {
if (!tokens || !browserRefreshToken) {
return tokens;
}
Object.defineProperty(tokens, INTERNAL_BROWSER_REFRESH_TOKEN_FIELD, {
value: browserRefreshToken,
enumerable: false,
configurable: true,
});
return tokens;
}
function getBrowserRefreshTokenMarker(tokens) {
const browserRefreshToken = tokens?.[INTERNAL_BROWSER_REFRESH_TOKEN_FIELD];
return typeof browserRefreshToken === 'string' && browserRefreshToken
? browserRefreshToken
: null;
}
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();
}
});
});
}
/**
* Writes the rotated refresh token and OpenID marker cookies to the browser so
* they stay in sync with the session copy. These cookies outlive the shorter
* express-session cookie and are the fallback `refreshController` reads when
* the session is gone; without this sync an OBO-triggered rotation would leave
* stale or mismatched cookies and sign the user out on the next refresh.
*
* When no cookie-capable response is available, or `res.headersSent` is true
* (streaming SSE path), the cookie cannot be set. In this case, store a
* server-side recovery bridge so that if the session is later lost,
* `refreshController` can look up the rotated token by hash of the stale cookie
* token.
*
* @param {object} args
* @param {import('express').Response} [args.res]
* @param {string} args.newRefreshToken — the rotated token to sync
* @param {string} [args.oldRefreshToken] — the browser-cookie token to bridge from
* @param {string} [args.userId] — user._id (required for bridge verification)
* @param {string} [args.tenantId] — user.tenantId (optional, verified on bridge lookup)
* @param {string} [args.openidIssuer] — user.openidIssuer (optional, verified on bridge lookup)
*/
async function syncRefreshTokenCookie({
res,
newRefreshToken,
oldRefreshToken,
userId,
tenantId,
openidIssuer,
}) {
if (canWriteRefreshTokenCookie(res)) {
const expiryInMilliseconds = math(
process.env.REFRESH_TOKEN_EXPIRY,
DEFAULT_REFRESH_TOKEN_EXPIRY,
);
const expirationDate = new Date(Date.now() + expiryInMilliseconds);
setRefreshTokenCookie(res, newRefreshToken, expirationDate);
setOpenIDMarkerCookies(res, {
userId,
expires: expirationDate,
refreshExpiryMs: expiryInMilliseconds,
refreshToken: newRefreshToken,
});
/**
* The durable Session record is what authorizes local image access for OpenID users
* (`authenticateRequest` in `packages/api/src/images/authorization.ts` looks it up by the
* browser's refresh-token cookie). The cookie just moved to the rotated token, so the record
* has to move with it — otherwise every image request 401s until the next `/refresh`.
* The bridge branch below deliberately leaves the record alone: there the browser keeps the
* old cookie, and `refreshController` rewrites both once it recovers through the bridge.
*/
if (userId) {
try {
await storeOpenIdSession(
{
userId,
refreshToken: newRefreshToken,
tenantId,
previousRefreshToken: oldRefreshToken,
},
{ upsertSession, deleteSession },
);
} catch (error) {
logger.error(
'[OpenIDSessionRefresh] Failed to persist rotated refresh token to the durable session',
error,
);
}
}
return;
}
if (oldRefreshToken && userId) {
try {
await storeRefreshTokenBridge({
oldRefreshToken,
newRefreshToken,
userId,
tenantId,
openidIssuer,
});
logger.debug('[OpenIDSessionRefresh] Stored refresh-token recovery bridge', {
userId,
responseAvailable: !!res,
headersSent: !!res?.headersSent,
hasCookieWriter: typeof res?.cookie === 'function',
});
} catch (error) {
logger.error('[OpenIDSessionRefresh] Failed to store refresh-token recovery bridge', error);
}
} else {
logger.warn(
'[OpenIDSessionRefresh] Cannot set refresh-token cookie and insufficient context to store bridge',
{
responseAvailable: !!res,
headersSent: !!res?.headersSent,
hasCookieWriter: typeof res?.cookie === 'function',
hasOldToken: !!oldRefreshToken,
hasUserId: !!userId,
},
);
}
}
async function storeSessionSaveFailureBridge({ oldRefreshToken, newRefreshToken, bridgeIdentity }) {
if (!oldRefreshToken || !newRefreshToken || !bridgeIdentity?.userId) {
return;
}
try {
await storeRefreshTokenBridge({
oldRefreshToken,
newRefreshToken,
userId: bridgeIdentity.userId,
tenantId: bridgeIdentity.tenantId,
openidIssuer: bridgeIdentity.openidIssuer,
ttl: OPENID_REFRESH_BRIDGE_GRACE_MS,
});
logger.warn(
'[OpenIDSessionRefresh] Stored short refresh-token bridge after session save failure',
{
userId: bridgeIdentity.userId,
ttl: OPENID_REFRESH_BRIDGE_GRACE_MS,
},
);
} catch (bridgeError) {
logger.warn(
'[OpenIDSessionRefresh] Failed to store refresh-token bridge after session save failure',
bridgeError,
);
}
}
async function performIdpRefreshGrant(req, res, user, tokenPreference, identityContext) {
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;
const browserRefreshToken = sessionTokens.browserRefreshToken || refreshToken;
const needsRefreshTokenSync = nextRefreshToken !== browserRefreshToken;
const willWriteRefreshTokenCookie = needsRefreshTokenSync && canWriteRefreshTokenCookie(res);
/**
* 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;
const accessTokenExpiresIn = normalizeExpiresIn(tokenset.expires_in);
if (accessTokenExpiresIn != null) {
nextAccessTokenExp = Math.floor(Date.now() / 1000) + accessTokenExpiresIn;
} else {
nextAccessTokenExp = decodeJwtExp(tokenset.access_token);
}
const updatedSessionTokens = {
...sessionTokens,
accessToken: tokenset.access_token,
idToken: nextIdToken,
refreshToken: nextRefreshToken,
browserRefreshToken: willWriteRefreshTokenCookie ? nextRefreshToken : browserRefreshToken,
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;
/**
* Keep the browser refresh-token cookie in sync with the session token. If headers are
* already sent (SSE streaming), store a recovery bridge instead. Do this before the
* session save so a transient session-store failure cannot lose an IdP-rotated token.
*/
let bridgeIdentity = null;
if (needsRefreshTokenSync) {
bridgeIdentity = createRefreshTokenBridgeIdentity({
user,
requestUser: req?.user,
userId: identityContext?.appUserId,
tenantId: identityContext?.tenantId,
openidIssuer: identityContext?.openidIssuer,
});
await syncRefreshTokenCookie({
res,
newRefreshToken: nextRefreshToken,
oldRefreshToken: browserRefreshToken,
userId: bridgeIdentity?.userId,
tenantId: bridgeIdentity?.tenantId,
openidIssuer: bridgeIdentity?.openidIssuer,
});
}
try {
await persistSession(req);
} catch (error) {
if (needsRefreshTokenSync && willWriteRefreshTokenCookie) {
await storeSessionSaveFailureBridge({
oldRefreshToken: browserRefreshToken,
newRefreshToken: nextRefreshToken,
bridgeIdentity,
});
}
throw error;
}
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 attachBrowserRefreshTokenMarker(
buildOIDCTokensFromSession(
updatedSessionTokens,
tokenPreference,
nextAccessTokenExp ?? undefined,
),
updatedSessionTokens.browserRefreshToken,
);
}
async function performIdpRefresh(req, res, user, tokenPreference, identityContext) {
const refreshToken = req?.session?.openidTokens?.refreshToken;
const key = createOpenIDRefreshFlightKey({ req, user, refreshToken, identityContext });
if (!key) {
return performIdpRefreshGrant(req, res, user, tokenPreference, identityContext);
}
let flight;
try {
flight = await acquireOpenIDRefreshFlight({ key });
} catch (error) {
logger.warn(
'[OpenIDSessionRefresh] Failed to acquire shared refresh flight; refreshing directly',
error,
);
return performIdpRefreshGrant(req, res, user, tokenPreference, identityContext);
}
if (!flight.acquired) {
logger.debug('[OpenIDSessionRefresh] Joining shared refresh flight', {
key: hashKeyForLogs(key),
});
const resolvedTokens = await waitForOpenIDRefreshFlight({ key });
if (resolvedTokens) {
await hydrateSessionFromResolvedTokens(req, resolvedTokens);
return resolvedTokens;
}
logger.warn('[OpenIDSessionRefresh] Shared refresh flight unavailable; refreshing directly', {
key: hashKeyForLogs(key),
});
return performIdpRefreshGrant(req, res, user, tokenPreference, identityContext);
}
try {
const resolvedTokens = await performIdpRefreshGrant(
req,
res,
user,
tokenPreference,
identityContext,
);
try {
await completeOpenIDRefreshFlight({
key,
ownerId: flight.ownerId,
tokens: resolvedTokens,
});
} catch (flightError) {
logger.warn('[OpenIDSessionRefresh] Failed to complete shared refresh flight', {
key: hashKeyForLogs(key),
error: flightError?.message,
});
}
return resolvedTokens;
} catch (error) {
try {
await failOpenIDRefreshFlight({ key, ownerId: flight.ownerId, error });
} catch (flightError) {
logger.warn('[OpenIDSessionRefresh] Failed to mark shared refresh flight failed', {
key: hashKeyForLogs(key),
error: flightError?.message,
});
}
throw error;
}
}
/**
* Hydrates `req.session.openidTokens` from a resolved OIDCTokens result and
* persists it. Used by joining requests in the single-flight path: the leader
* mutates only its own `req.session`, so a joiner that shares the session id but
* carries a distinct `req` (concurrent HTTP requests) would otherwise re-read
* stale tokens on its next OBO call. This includes stable-refresh-token IdPs,
* where the refresh token remains unchanged but the access token and expiry
* were refreshed by the leader.
* Idempotent when the joiner shares the leader's `req` object.
*/
async function hydrateSessionFromResolvedTokens(req, resolvedTokens) {
if (!req?.session || !resolvedTokens?.access_token) {
return;
}
const existing = req.session.openidTokens ?? {};
const accessTokenChanged = existing.accessToken !== resolvedTokens.access_token;
const idTokenChanged =
resolvedTokens.id_token != null && existing.idToken !== resolvedTokens.id_token;
const refreshTokenChanged =
resolvedTokens.refresh_token != null && existing.refreshToken !== resolvedTokens.refresh_token;
const resolvedBrowserRefreshToken = getBrowserRefreshTokenMarker(resolvedTokens);
const browserRefreshTokenChanged =
resolvedBrowserRefreshToken != null &&
existing.browserRefreshToken !== resolvedBrowserRefreshToken;
const hasResolvedExpiry = typeof resolvedTokens.expires_at === 'number';
const expiresAtChanged = hasResolvedExpiry
? existing.accessTokenExpiresAt !== resolvedTokens.expires_at
: accessTokenChanged && existing.accessTokenExpiresAt !== undefined;
if (
!accessTokenChanged &&
!idTokenChanged &&
!refreshTokenChanged &&
!browserRefreshTokenChanged &&
!expiresAtChanged
) {
return;
}
const nextSessionTokens = {
...existing,
accessToken: resolvedTokens.access_token,
idToken: resolvedTokens.id_token ?? existing.idToken,
refreshToken: resolvedTokens.refresh_token ?? existing.refreshToken,
browserRefreshToken: resolvedBrowserRefreshToken ?? existing.browserRefreshToken,
lastRefreshedAt: Date.now(),
};
if (hasResolvedExpiry) {
nextSessionTokens.accessTokenExpiresAt = resolvedTokens.expires_at;
} else if (accessTokenChanged) {
delete nextSessionTokens.accessTokenExpiresAt;
}
req.session.openidTokens = nextSessionTokens;
await persistSession(req);
}
async function refreshOrReuseSession(req, res, user, tokenPreference, identityContext) {
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, res, user, tokenPreference, identityContext);
}
/**
* 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('express').Response} [res] — when present and writable, the
* rotated refresh token is mirrored to the `refreshToken` cookie.
* @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, res, user, tokenPreference, identityContext) {
assertOpenIDSessionIdentityMatch(req, user, identityContext);
const key = getSingleFlightKey(req, user, identityContext);
if (!key) {
return refreshOrReuseSession(req, res, user, tokenPreference, identityContext);
}
const inFlight = inFlightRefreshes.get(key);
if (inFlight) {
logger.debug(`[OpenIDSessionRefresh] Joining in-flight refresh (key=${hashKeyForLogs(key)})`);
const resolvedTokens = await inFlight;
/**
* The leader mutated only its own request's session. Copy the resolved
* tokens into THIS request's session so a later OBO call on the joiner
* reads the rotated refresh token instead of replaying the stale one.
*/
await hydrateSessionFromResolvedTokens(req, resolvedTokens);
return resolvedTokens;
}
const promise = refreshOrReuseSession(req, res, user, tokenPreference, identityContext).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 session identity metadata does not match the current user.
* - 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('express').Response} [args.res] — forwarded so a rotated
* refresh token can be mirrored to the `refreshToken` cookie when the
* response is still writable (no-op on the streaming tool-call path).
* @param {import('@librechat/data-schemas').IUser} [args.user]
* @param {import('@librechat/api').AuthIdentityContext} [args.identityContext]
* @param {'access_token' | 'id_token'} args.tokenPreference
* @returns {() => Promise<import('@librechat/data-schemas').OIDCTokens | null>}
*/
function createOpenIDSessionTokenProvider({ req, res, user, tokenPreference, identityContext }) {
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;
}
const resolvedIdentityContext =
identityContext ??
createAuthIdentityContext({
user,
requestUser: req?.user,
});
return refreshOpenIDSession(req, res, user, tokenPreference, resolvedIdentityContext);
};
}
module.exports = {
createOpenIDSessionTokenProvider,
refreshOpenIDSession,
/** Exposed for tests; not a public API. */
__internals: {
UPSTREAM_TOKEN_EXPIRY_BUFFER_SECONDS,
inFlightRefreshes,
getSingleFlightKey,
isLiveSessionTokenStillValid,
getAccessTokenExp,
},
};