mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-09 16:10:04 +00:00
🛠️ 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:
parent
cc74590864
commit
8b5cea29bc
9 changed files with 490 additions and 22 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue