LibreChat/api/server/controllers/AuthController.js
Claude e0f56b17c8
🔒 fix: Refresh MCP OBO Tokens From the Live OpenID Session
Merge jcbartle's #13901 up to date with main (307 commits since its last
merge at 5ff282f90) and reconcile it with what landed in the meantime.

OBO exchange read `user.federatedTokens`, the snapshot captured at strategy
time, so a user who idled past their access-token lifetime got "No valid
OpenID access token is available for OBO exchange" while a still-refreshable
token sat unread in `req.session.openidTokens` (#13899). Exchange now reads
the live server-side session at MCP call time and refreshes the upstream
token inline, single-flighted across workers through a Mongo refresh-flight
record, with an encrypted bridge so a token rotated mid-SSE stream (headers
already sent, cookie unwritable) stays recoverable on the next /refresh.

Reconciled against main:

- Exchanged-token cache TTLs now carry the in-transit expiry buffer (#15320).
  The PR's `mcp/oauth/expiry.ts` and main's `oauth/expiry.ts` (#15321) were
  two homes for the same arithmetic; they are now one. `getTokenCacheTtlMs`
  subtracts `OPENID_EXPIRY_BUFFER_SECONDS`, which also covers the Graph and
  openidStrategy caches, and the skew helpers build on main's
  `normalizeExpiresIn` so an elapsed lifetime still stays elapsed.
  `OPENID_EXPIRY_BUFFER_SECONDS` moves to `oauth/expiry.ts`, where the rest
  of the lifetime math lives: consumers that stub `~/utils/oidc` were
  leaving the arithmetic reading `undefined`.
- The `openid_user_id` marker keeps the refresh-token binding main added in
  #15252, now inside the shared `setOpenIDMarkerCookies` helper so the
  inline-refresh path produces the same bound cookie as login.
- Every OpenID token issuance in `refreshController` — including bridge
  recovery, which previously issued tokens without one — persists the
  durable Session record through `sendOpenIDAuthResponse`.
- An inline refresh that rotates the token and writes the cookie moves the
  durable Session record with it. Local image access for OpenID users is
  authorized by that record keyed on the browser's cookie, so leaving it
  behind 401s every image until the next /refresh. The bridge path
  deliberately leaves it alone: there the browser keeps the old cookie.

Co-authored-by: jcbartle <7274202+jcbartle@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxKWxwqxAGckYpRsYTqx3F
2026-08-29 13:40:21 +00:00

561 lines
19 KiB
JavaScript

const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const {
math,
isEnabled,
findOpenIDUser,
getOpenIdIssuer,
createAuthIdentityContext,
isOpenIDSessionIdentityMatch,
buildOpenIDRefreshParams,
OPENID_EXPIRY_BUFFER_SECONDS,
} = require('@librechat/api');
const {
requestPasswordReset,
setOpenIDAuthTokens,
storeOpenIDSession,
setCloudFrontAuthCookies,
resetPassword,
setAuthTokens,
registerUser,
} = require('~/server/services/AuthService');
const {
deleteAllUserSessions,
getUserById,
findSession,
updateUser,
findUser,
} = require('~/models');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const {
OPENID_REFRESH_BRIDGE_GRACE_MS,
getRefreshTokenBridge,
storeRefreshTokenBridge,
} = require('~/server/services/RefreshTokenBridge');
const AUTH_REFRESH_USER_PROJECTION = '-password -__v -totpSecret -backupCodes -federatedTokens';
/**
* Max age (ms) LibreChat reuses a cached OpenID session token before forcing an IdP refresh.
* Env-overridable (accepts an arithmetic expression, e.g. `60 * 60 * 24 * 1000`, like
* `SESSION_EXPIRY`): deployments whose IdP revokes the previous access token on refresh can
* widen this to the access-token lifetime so a still-valid token is not rotated/revoked out
* from under downstream consumers (e.g. MCP servers that introspect the bearer). Defaults to
* 15 minutes.
*/
const OPENID_REUSE_MAX_SESSION_AGE_MS = math(
process.env.OPENID_REUSE_MAX_SESSION_AGE_MS,
15 * 60 * 1000,
);
const registrationController = async (req, res) => {
try {
const response = await registerUser(req.body);
const { status, message } = response;
res.status(status).send({ message });
} catch (err) {
logger.error('[registrationController]', err);
return res.status(500).json({ message: err.message });
}
};
const sanitizeUserForAuthResponse = (user) => {
const source = (typeof user?.toObject === 'function' ? user.toObject() : user) || {};
const {
password: _pw,
__v: _v,
totpSecret: _ts,
backupCodes: _bc,
federatedTokens: _ft,
...safeUser
} = source;
return safeUser;
};
const getValidOpenIDReuseUserId = (parsedCookies) => {
const openidUserId = parsedCookies.openid_user_id;
if (!openidUserId || !process.env.JWT_REFRESH_SECRET) {
return null;
}
try {
const payload = jwt.verify(openidUserId, process.env.JWT_REFRESH_SECRET);
return typeof payload === 'object' && payload != null && typeof payload.id === 'string'
? payload.id
: null;
} catch {
return null;
}
};
const selectOpenIDRefreshToken = (openidTokens, parsedCookies) => {
const sessionRefreshToken = openidTokens?.refreshToken;
const browserRefreshToken = parsedCookies.refreshToken;
const lastSyncedBrowserRefreshToken = openidTokens?.browserRefreshToken;
const hasKnownBrowserRefreshTokenMarker =
typeof lastSyncedBrowserRefreshToken === 'string' && lastSyncedBrowserRefreshToken.length > 0;
const driftReference = hasKnownBrowserRefreshTokenMarker
? lastSyncedBrowserRefreshToken
: sessionRefreshToken;
if (browserRefreshToken && driftReference && browserRefreshToken !== driftReference) {
logger.info('[refreshController] OpenID refresh token cookie differs from session state');
return { refreshToken: browserRefreshToken, cookieDiffersFromSession: true };
}
return {
refreshToken: sessionRefreshToken || browserRefreshToken,
cookieDiffersFromSession: false,
};
};
const isRecentOpenIDSessionRefresh = (openidTokens) => {
const lastRefreshedAt = Number(openidTokens?.lastRefreshedAt);
const elapsed = Date.now() - lastRefreshedAt;
return (
Number.isFinite(lastRefreshedAt) && elapsed >= 0 && elapsed <= OPENID_REUSE_MAX_SESSION_AGE_MS
);
};
const isInvalidGrantError = (error) => {
const values = [
error?.message,
error?.error,
error?.code,
error?.response?.data?.error,
error?.response?.data?.error_description,
error?.body?.error,
error?.body?.error_description,
];
return values.some(
(value) => typeof value === 'string' && value.toLowerCase().includes('invalid_grant'),
);
};
const refreshOpenIDUser = async ({ refreshToken, strategyName }) => {
const openIdConfig = getOpenIdConfig();
const refreshParams = buildOpenIDRefreshParams();
logger.debug('[refreshController] OpenID refresh params', {
has_scope: Boolean(process.env.OPENID_SCOPE),
has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE),
});
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken, refreshParams);
logger.debug('[refreshController] OpenID refresh succeeded', {
has_access_token: Boolean(tokenset.access_token),
has_id_token: Boolean(tokenset.id_token),
has_refresh_token: Boolean(tokenset.refresh_token),
expires_in: tokenset.expires_in,
});
const claims = tokenset.claims();
const openidIssuer = getOpenIdIssuer(claims, openIdConfig);
const { user, error, migration } = await findOpenIDUser({
findUser,
email: getOpenIdEmail(claims),
openidId: claims.sub,
openidIssuer,
idOnTheSource: claims.oid,
strategyName,
});
logger.debug(
`[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`,
);
return { tokenset, claims, openidIssuer, user, error, migration };
};
const getAuthIdentitySource = (user) =>
typeof user?.toObject === 'function' ? user.toObject() : user;
const sendOpenIDAuthResponse = async ({
tokenset,
user,
existingRefreshToken,
openidSubject,
openidIssuer,
req,
res,
}) => {
const userId = user._id.toString();
/**
* Persist the durable refresh-token session before the cookies are written: the
* express session expires well before the IdP refresh token, and a rotated token
* that never reached the Session collection is replayed — and rejected — on the
* next refresh after that session is gone.
*/
await storeOpenIDSession(
userId,
tokenset.refresh_token || existingRefreshToken,
user.tenantId,
existingRefreshToken,
);
const token = setOpenIDAuthTokens(tokenset, req, res, {
userId,
existingRefreshToken,
tenantId: user.tenantId,
openidSubject: openidSubject ?? user.openidId,
openidIssuer: openidIssuer ?? user.openidIssuer,
});
return res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
};
const isReusableOpenIDSessionIdentity = (openidTokens, user) => {
const identitySource = getAuthIdentitySource(user);
const expectedIdentity = createAuthIdentityContext({ user: identitySource });
const matches = isOpenIDSessionIdentityMatch(openidTokens, expectedIdentity);
if (!matches) {
logger.warn('[refreshController] OpenID session token identity mismatch; forcing refresh', {
userId: expectedIdentity.appUserId,
has_session_user_id: Boolean(openidTokens?.appUserId),
has_session_subject: Boolean(openidTokens?.openidSubject),
has_session_issuer: Boolean(openidTokens?.openidIssuer),
});
}
return matches;
};
const getReusableOpenIDSessionToken = (openidTokens) => {
if (!isRecentOpenIDSessionRefresh(openidTokens)) {
return null;
}
const candidates = [
{ token: openidTokens?.idToken, type: 'id_token' },
{ token: openidTokens?.accessToken, type: 'access_token' },
];
const now = Math.floor(Date.now() / 1000);
for (const candidate of candidates) {
if (!candidate.token) {
continue;
}
/** Decode only: tokens are from the trusted server-side session; expiry gates reuse. */
const decoded = jwt.decode(candidate.token);
if (
decoded &&
typeof decoded === 'object' &&
decoded.exp > now + OPENID_EXPIRY_BUFFER_SECONDS
) {
return candidate;
}
}
return null;
};
const resetPasswordRequestController = async (req, res) => {
try {
const resetService = await requestPasswordReset(req);
if (resetService instanceof Error) {
return res.status(400).json(resetService);
} else {
return res.status(200).json(resetService);
}
} catch (e) {
logger.error('[resetPasswordRequestController]', e);
return res.status(400).json({ message: e.message });
}
};
const resetPasswordController = async (req, res) => {
try {
const resetPasswordService = await resetPassword(
req.body.userId,
req.body.token,
req.body.password,
);
if (resetPasswordService instanceof Error) {
return res.status(400).json(resetPasswordService);
} else {
await deleteAllUserSessions({ userId: req.body.userId });
return res.status(200).json(resetPasswordService);
}
} catch (e) {
logger.error('[resetPasswordController]', e);
return res.status(400).json({ message: e.message });
}
};
const refreshController = async (req, res) => {
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
const token_provider = parsedCookies.token_provider;
if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
/** Prefer session refresh tokens unless the browser cookie proves the session is stale. */
const { refreshToken, cookieDiffersFromSession } = selectOpenIDRefreshToken(
req.session?.openidTokens,
parsedCookies,
);
if (!refreshToken) {
return res.status(200).send('Refresh token not provided');
}
try {
/**
* Reuse skips an IdP refresh only for recently-refreshed server-side tokens.
* Stale, missing, or near-expiry tokens fall through to refreshTokenGrant so
* upstream revocations and cookie/session extension are checked regularly.
*/
const reusableSessionToken = cookieDiffersFromSession
? null
: getReusableOpenIDSessionToken(req.session?.openidTokens);
const reuseUserId = reusableSessionToken ? getValidOpenIDReuseUserId(parsedCookies) : null;
if (reuseUserId) {
const user = await getUserById(reuseUserId, AUTH_REFRESH_USER_PROJECTION);
if (user && isReusableOpenIDSessionIdentity(req.session?.openidTokens, user)) {
const cloudFrontCookiesSet = setCloudFrontAuthCookies(req, res, user);
logger.debug('[refreshController] OpenID session token reused', {
token_type: reusableSessionToken.type,
has_id_token: Boolean(req.session?.openidTokens?.idToken),
has_access_token: Boolean(req.session?.openidTokens?.accessToken),
cloudfront_cookies_set: cloudFrontCookiesSet,
});
return res.status(200).send({
token: reusableSessionToken.token,
user: sanitizeUserForAuthResponse(user),
});
}
}
const { tokenset, claims, openidIssuer, user, error, migration } = await refreshOpenIDUser({
refreshToken,
strategyName: 'refreshController',
});
if (error || !user) {
logger.warn(
`[refreshController] Redirecting to /login: error=${error ?? 'null'}, user=${user ? 'exists' : 'null'}`,
);
return res.status(401).redirect('/login');
}
// Handle migration: update user with openidId if found by email without openidId
// Also handle case where user has mismatched openidId (e.g., after database switch)
if (migration || user.openidId !== claims.sub) {
const reason = migration ? 'migration' : 'openidId mismatch';
await updateUser(user._id.toString(), {
provider: 'openid',
openidId: claims.sub,
...(openidIssuer ? { openidIssuer } : {}),
});
logger.info(
`[refreshController] Updated user ${user.email} openidId (${reason}): ${user.openidId ?? 'null'} -> ${claims.sub}`,
);
}
return await sendOpenIDAuthResponse({
tokenset,
user,
existingRefreshToken: refreshToken,
openidSubject: claims?.sub,
openidIssuer,
req,
res,
});
} catch (error) {
logger.error('[refreshController] OpenID token refresh error', error);
/**
* Detect and recover from stale refresh-token cookie after SSE-triggered rotation.
* If the initial refresh with the cookie fails with invalid_grant, check if a
* recovery bridge exists. Bridges are stored when an OBO refresh rotates the token
* but cannot set the browser cookie (headers already sent during SSE streaming).
*/
if (isInvalidGrantError(error) && refreshToken) {
// Bridge lookup uses the signed user-id cookie because /refresh is unauthenticated.
const userId = getValidOpenIDReuseUserId(parsedCookies);
if (userId) {
try {
const bridgeUser = await getUserById(userId, AUTH_REFRESH_USER_PROJECTION);
if (!bridgeUser) {
return res.status(403).send('Invalid OpenID refresh token');
}
const bridgedRefreshToken = await getRefreshTokenBridge({
oldRefreshToken: refreshToken,
userId,
tenantId: bridgeUser.tenantId,
openidIssuer: bridgeUser.openidIssuer,
});
if (bridgedRefreshToken) {
logger.info(
'[refreshController] Recovered via refresh-token bridge after invalid_grant',
{
userId,
},
);
// Retry with the recovered (rotated) refresh token
try {
const {
tokenset: retryTokenset,
claims: retryClaims,
openidIssuer: retryOpenidIssuer,
user: retryUser,
error: retryError,
} = await refreshOpenIDUser({
refreshToken: bridgedRefreshToken,
strategyName: 'refreshController (bridge recovery)',
});
if (retryUser && !retryError) {
if (retryUser._id.toString() !== userId) {
logger.warn(
'[refreshController] Bridge recovery resolved a different user; refusing token issuance',
{
cookieUserId: userId,
resolvedUserId: retryUser._id.toString(),
},
);
return res.status(403).send('Invalid OpenID refresh token');
}
try {
/**
* Keep the stale-cookie bridge briefly so parallel /refresh requests that
* already sent the old cookie can recover too. Re-storing also shrinks the
* remaining replay window from REFRESH_TOKEN_EXPIRY (potentially days) to
* this short grace TTL while Mongo/expiresAt cleanup removes it.
*/
await storeRefreshTokenBridge({
oldRefreshToken: refreshToken,
newRefreshToken: retryTokenset.refresh_token || bridgedRefreshToken,
userId,
tenantId: bridgeUser.tenantId,
openidIssuer: bridgeUser.openidIssuer,
ttl: OPENID_REFRESH_BRIDGE_GRACE_MS,
});
} catch (graceError) {
logger.warn(
'[refreshController] Bridge grace-period storage failed after successful recovery',
graceError,
);
}
return await sendOpenIDAuthResponse({
tokenset: retryTokenset,
user: retryUser,
existingRefreshToken: bridgedRefreshToken,
openidSubject: retryClaims?.sub,
openidIssuer: retryOpenidIssuer,
req,
res,
});
}
} catch (retryError) {
logger.error('[refreshController] Bridge recovery retry failed', retryError);
// Fall through to generic error response
}
}
} catch (bridgeError) {
logger.warn('[refreshController] Refresh-token bridge lookup failed', {
error: bridgeError instanceof Error ? bridgeError.message : bridgeError,
});
}
}
}
return res.status(403).send('Invalid OpenID refresh token');
}
}
/** For non-OpenID users, read refresh token from cookies */
const refreshToken = parsedCookies.refreshToken;
if (!refreshToken) {
return res.status(200).send('Refresh token not provided');
}
try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const user = await getUserById(payload.id, AUTH_REFRESH_USER_PROJECTION);
if (!user) {
return res.status(401).redirect('/login');
}
const userId = payload.id;
if (process.env.NODE_ENV === 'CI') {
const token = await setAuthTokens(userId, res, null, req);
return res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
}
/** Session with the hashed refresh token */
const session = await findSession(
{
userId: userId,
refreshToken: refreshToken,
},
{ lean: false },
);
if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, session, req);
res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
} else if (req?.query?.retry) {
// Retrying from a refresh token request that failed (401)
res.status(403).send('No session found');
} else if (payload.exp < Date.now() / 1000) {
res.status(403).redirect('/login');
} else {
res.status(401).send('Refresh token expired or not found for this user');
}
} catch (err) {
logger.error(`[refreshController] Invalid refresh token:`, err);
res.status(403).send('Invalid refresh token');
}
};
const graphTokenController = async (req, res) => {
try {
// Validate user is authenticated via Entra ID
if (!req.user.openidId || req.user.provider !== 'openid') {
return res.status(403).json({
message: 'Microsoft Graph access requires Entra ID authentication',
});
}
// Check if OpenID token reuse is active (required for on-behalf-of flow)
if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
return res.status(403).json({
message: 'SharePoint integration requires OpenID token reuse to be enabled',
});
}
const scopes = req.query.scopes;
if (!scopes) {
return res.status(400).json({
message: 'Graph API scopes are required as query parameter',
});
}
const accessToken = req.user.federatedTokens?.access_token;
if (!accessToken) {
return res.status(401).json({
message: 'No federated access token available for token exchange',
});
}
const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes);
res.json(tokenResponse);
} catch (error) {
logger.error('[graphTokenController] Failed to obtain Graph API token:', error);
res.status(500).json({
message: 'Failed to obtain Microsoft Graph token',
});
}
};
module.exports = {
refreshController,
registrationController,
resetPasswordController,
resetPasswordRequestController,
graphTokenController,
};