🛠️ fix: Bind OpenID session tokens to authenticated identity

Stamp OpenID session token state with the LibreChat user id, OpenID subject,
tenant id, and normalized issuer when tokens are stored.

Fail closed before OBO inline token reuse/refresh when the session token
identity does not match the current authenticated identity, preventing a stale
or mixed Express session from supplying another user's upstream assertion.

Also validate the normal /api/auth/refresh session-token reuse shortcut against
the signed marker-cookie user before returning cached session tokens.

Note: sessions created before this change carry no identity stamp and are
treated as a mismatch. This is self-healing — the reuse path forces a full IdP
refresh (which re-stamps the session) and the OBO path throws, surfacing as a
one-time re-authentication for active OBO users at deploy time. The session
re-stamps within one session lifetime (SESSION_EXPIRY, default 15 min).
This commit is contained in:
J.C. Bartle 2026-06-28 17:53:46 -04:00
parent cc74590864
commit 8b5cea29bc
9 changed files with 490 additions and 22 deletions

View file

@ -7,6 +7,8 @@ const {
isEnabled,
findOpenIDUser,
getOpenIdIssuer,
createAuthIdentityContext,
isOpenIDSessionIdentityMatch,
buildOpenIDRefreshParams,
} = require('@librechat/api');
const {
@ -144,16 +146,44 @@ const refreshOpenIDUser = async ({ refreshToken, strategyName }) => {
return { tokenset, claims, openidIssuer, user, error, migration };
};
const sendOpenIDAuthResponse = ({ tokenset, user, existingRefreshToken, req, res }) => {
const getAuthIdentitySource = (user) =>
typeof user?.toObject === 'function' ? user.toObject() : user;
const sendOpenIDAuthResponse = ({
tokenset,
user,
existingRefreshToken,
openidSubject,
openidIssuer,
req,
res,
}) => {
const token = setOpenIDAuthTokens(tokenset, req, res, {
userId: user._id.toString(),
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;
@ -238,7 +268,7 @@ const refreshController = async (req, res) => {
const reuseUserId = reusableSessionToken ? getValidOpenIDReuseUserId(parsedCookies) : null;
if (reuseUserId) {
const user = await getUserById(reuseUserId, AUTH_REFRESH_USER_PROJECTION);
if (user) {
if (user && isReusableOpenIDSessionIdentity(req.session?.openidTokens, user)) {
const cloudFrontCookiesSet = setCloudFrontAuthCookies(req, res, user);
logger.debug('[refreshController] OpenID session token reused', {
token_type: reusableSessionToken.type,
@ -283,6 +313,8 @@ const refreshController = async (req, res) => {
tokenset,
user,
existingRefreshToken: refreshToken,
openidSubject: claims?.sub,
openidIssuer,
req,
res,
});
@ -328,6 +360,8 @@ const refreshController = async (req, res) => {
try {
const {
tokenset: retryTokenset,
claims: retryClaims,
openidIssuer: retryOpenidIssuer,
user: retryUser,
error: retryError,
} = await refreshOpenIDUser({
@ -372,6 +406,8 @@ const refreshController = async (req, res) => {
tokenset: retryTokenset,
user: retryUser,
existingRefreshToken: bridgedRefreshToken,
openidSubject: retryClaims?.sub,
openidIssuer: retryOpenidIssuer,
req,
res,
});

View file

@ -30,6 +30,31 @@ jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(),
findOpenIDUser: jest.fn(),
getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
createAuthIdentityContext: jest.fn(({ user }) => ({
appUserId: user?._id?.toString?.() ?? user?.id,
openidSubject: user?.openidId,
tenantId: user?.tenantId,
openidIssuer: user?.openidIssuer,
})),
isOpenIDSessionIdentityMatch: jest.fn((sessionIdentity, expectedIdentity) => {
const normalize = (value) => {
if (value == null) {
return undefined;
}
const normalized = typeof value === 'string' ? value.trim() : value.toString().trim();
return normalized || undefined;
};
const normalizeIssuer = (value) => normalize(value)?.replace(/\/+$/, '');
return (
Boolean(normalize(sessionIdentity?.appUserId)) &&
Boolean(normalize(sessionIdentity?.openidSubject)) &&
normalize(sessionIdentity?.appUserId) === normalize(expectedIdentity?.appUserId) &&
normalize(sessionIdentity?.openidSubject) === normalize(expectedIdentity?.openidSubject) &&
normalize(sessionIdentity?.tenantId) === normalize(expectedIdentity?.tenantId) &&
normalizeIssuer(sessionIdentity?.openidIssuer) ===
normalizeIssuer(expectedIdentity?.openidIssuer)
);
}),
buildOpenIDRefreshParams: jest.fn(() => {
const params = {};
if (process.env.OPENID_SCOPE) {
@ -201,6 +226,8 @@ describe('refreshController OpenID path', () => {
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
password: '$2b$10$hashedpassword',
__v: 0,
totpSecret: 'encrypted-totp-secret',
@ -251,6 +278,8 @@ describe('refreshController OpenID path', () => {
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
});
updateUser.mockResolvedValue({});
@ -296,7 +325,9 @@ describe('refreshController OpenID path', () => {
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
userId: 'user-db-id',
existingRefreshToken: 'stored-refresh',
tenantId: undefined,
tenantId: 'tenant-1',
openidSubject: baseClaims.sub,
openidIssuer: baseClaims.iss,
});
};
@ -324,6 +355,10 @@ describe('refreshController OpenID path', () => {
idToken: reusableIdToken,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
appUserId: 'user-db-id',
openidSubject: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
},
};
const user = {
@ -370,6 +405,37 @@ describe('refreshController OpenID path', () => {
expect(debugOutput).not.toContain('session-access-token');
});
it('falls through to full OpenID refresh when reusable session token identity mismatches', async () => {
setOpenIDReuseCookies();
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: makeSessionToken(),
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
appUserId: 'other-user-id',
openidSubject: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
},
};
await refreshController(req, res);
expect(getUserById).toHaveBeenCalledWith(
'user-db-id',
'-password -__v -totpSecret -backupCodes -federatedTokens',
);
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
expectOpenIDRefreshGrant();
expect(logger.warn).toHaveBeenCalledWith(
'[refreshController] OpenID session token identity mismatch; forcing refresh',
expect.objectContaining({
userId: 'user-db-id',
}),
);
});
it('falls through to full OpenID refresh when session tokens are expired', async () => {
const expiredToken = makeSessionToken({ exp: Math.floor(Date.now() / 1000) - 60 });
setOpenIDReuseCookies();
@ -379,6 +445,10 @@ describe('refreshController OpenID path', () => {
idToken: expiredToken,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
appUserId: 'user-db-id',
openidSubject: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
},
};
@ -523,6 +593,10 @@ describe('refreshController OpenID path', () => {
idToken: reusableIdToken,
refreshToken: 'stored-refresh',
lastRefreshedAt: Date.now(),
appUserId: 'user-db-id',
openidSubject: baseClaims.sub,
tenantId: 'tenant-1',
openidIssuer: baseClaims.iss,
},
};
const userDocument = {
@ -775,7 +849,9 @@ describe('refreshController OpenID path', () => {
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
userId: 'user-db-id',
existingRefreshToken: 'bridged-refresh',
tenantId: undefined,
tenantId: 'tenant-1',
openidSubject: baseClaims.sub,
openidIssuer: baseClaims.iss,
});
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'stored-refresh',
@ -873,7 +949,9 @@ describe('refreshController OpenID path', () => {
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
userId: 'user-db-id',
existingRefreshToken: 'bridged-refresh',
tenantId: undefined,
tenantId: 'tenant-1',
openidSubject: baseClaims.sub,
openidIssuer: baseClaims.iss,
});
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'stored-refresh',

View file

@ -75,6 +75,8 @@ function createOAuthHandler(redirectUri = domains.client) {
setOpenIDAuthTokens(req.user.tokenset, req, res, {
userId: req.user._id.toString(),
tenantId: req.user.tenantId,
openidSubject: req.user.openidId,
openidIssuer: req.user.openidIssuer,
});
} else {
await setAuthTokens(req.user._id, res, null, req);