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);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const {
|
|||
shouldUseSecureCookie,
|
||||
setRefreshTokenCookie,
|
||||
setOpenIDMarkerCookies,
|
||||
createOpenIDSessionIdentity,
|
||||
resolveAppConfigForUser,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
|
|
@ -699,7 +700,9 @@ const resolveOpenIDAuthTokenOptions = (optionsOrUserId, existingRefreshToken, te
|
|||
if (
|
||||
'userId' in optionsOrUserId ||
|
||||
'existingRefreshToken' in optionsOrUserId ||
|
||||
'tenantId' in optionsOrUserId
|
||||
'tenantId' in optionsOrUserId ||
|
||||
'openidSubject' in optionsOrUserId ||
|
||||
'openidIssuer' in optionsOrUserId
|
||||
) {
|
||||
return optionsOrUserId;
|
||||
}
|
||||
|
|
@ -709,6 +712,44 @@ const resolveOpenIDAuthTokenOptions = (optionsOrUserId, existingRefreshToken, te
|
|||
return { userId: optionsOrUserId, existingRefreshToken, tenantId };
|
||||
};
|
||||
|
||||
const getOpenIDTokenClaims = (tokenset) => {
|
||||
if (typeof tokenset?.claims === 'function') {
|
||||
try {
|
||||
const claims = tokenset.claims();
|
||||
return claims && typeof claims === 'object' ? claims : {};
|
||||
} catch (error) {
|
||||
logger.debug('[setOpenIDAuthTokens] Unable to read tokenset claims', error?.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof tokenset?.id_token !== 'string') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const decoded = jwt.decode(tokenset.id_token);
|
||||
return decoded && typeof decoded === 'object' ? decoded : {};
|
||||
};
|
||||
|
||||
const getStringClaim = (claims, claim) => {
|
||||
const value = claims?.[claim];
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
};
|
||||
|
||||
const applyOpenIDSessionIdentity = (sessionOpenidTokens, identity) => {
|
||||
if (identity.appUserId) {
|
||||
sessionOpenidTokens.appUserId = identity.appUserId;
|
||||
}
|
||||
if (identity.openidSubject) {
|
||||
sessionOpenidTokens.openidSubject = identity.openidSubject;
|
||||
}
|
||||
if (identity.tenantId) {
|
||||
sessionOpenidTokens.tenantId = identity.tenantId;
|
||||
}
|
||||
if (identity.openidIssuer) {
|
||||
sessionOpenidTokens.openidIssuer = identity.openidIssuer;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @function setOpenIDAuthTokens
|
||||
* Set OpenID Authentication Tokens
|
||||
|
|
@ -723,6 +764,8 @@ const resolveOpenIDAuthTokenOptions = (optionsOrUserId, existingRefreshToken, te
|
|||
* @param {string} [options.userId] - Optional MongoDB user ID for image path validation
|
||||
* @param {string} [options.existingRefreshToken] - Optional existing refresh token to preserve
|
||||
* @param {string} [options.tenantId] - Optional tenant identifier for CloudFront cookie scoping
|
||||
* @param {string} [options.openidSubject] - Optional OpenID subject bound to the session tokens
|
||||
* @param {string} [options.openidIssuer] - Optional OpenID issuer bound to the session tokens
|
||||
* @returns {String} - id_token (preferred) or access_token as the app auth token
|
||||
*/
|
||||
const setOpenIDAuthTokens = (
|
||||
|
|
@ -734,11 +777,8 @@ const setOpenIDAuthTokens = (
|
|||
tenantIdArg,
|
||||
) => {
|
||||
try {
|
||||
const { userId, existingRefreshToken, tenantId } = resolveOpenIDAuthTokenOptions(
|
||||
optionsOrUserId,
|
||||
existingRefreshTokenArg,
|
||||
tenantIdArg,
|
||||
);
|
||||
const { userId, existingRefreshToken, tenantId, openidSubject, openidIssuer } =
|
||||
resolveOpenIDAuthTokenOptions(optionsOrUserId, existingRefreshTokenArg, tenantIdArg);
|
||||
|
||||
if (!tokenset) {
|
||||
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
||||
|
|
@ -774,6 +814,14 @@ const setOpenIDAuthTokens = (
|
|||
getUnexpiredOpenIDSessionIdToken(sessionIdToken) ||
|
||||
tokenset.access_token;
|
||||
const logoutIdToken = tokenset.id_token || sessionIdToken;
|
||||
const claims = getOpenIDTokenClaims(tokenset);
|
||||
const sessionIdentity = createOpenIDSessionIdentity({
|
||||
user: req?.user,
|
||||
userId,
|
||||
openidSubject: openidSubject ?? getStringClaim(claims, 'sub'),
|
||||
tenantId,
|
||||
openidIssuer: openidIssuer ?? getStringClaim(claims, 'iss'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Always set refresh token cookie so it survives express session expiry.
|
||||
|
|
@ -796,6 +844,7 @@ const setOpenIDAuthTokens = (
|
|||
expiresAt: expirationDate.getTime(),
|
||||
lastRefreshedAt: Date.now(),
|
||||
};
|
||||
applyOpenIDSessionIdentity(sessionOpenidTokens, sessionIdentity);
|
||||
/**
|
||||
* Capture the access-token's own expiry (unix seconds) when the IdP
|
||||
* advertises one. Lets downstream consumers — notably the OBO inline-
|
||||
|
|
|
|||
|
|
@ -42,6 +42,27 @@ jest.mock('@librechat/api', () => {
|
|||
}
|
||||
}),
|
||||
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
|
||||
createOpenIDSessionIdentity: jest.fn(
|
||||
({ user, userId, openidSubject, tenantId, openidIssuer }) => {
|
||||
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(/\/\.well-known\/openid-configuration$/, '')
|
||||
.replace(/\/+$/, '');
|
||||
return {
|
||||
appUserId: normalize(userId) ?? normalize(user?._id) ?? normalize(user?.id),
|
||||
openidSubject: normalize(openidSubject) ?? normalize(user?.openidId),
|
||||
tenantId: normalize(tenantId) ?? normalize(user?.tenantId),
|
||||
openidIssuer: normalizeIssuer(openidIssuer) ?? normalizeIssuer(user?.openidIssuer),
|
||||
};
|
||||
},
|
||||
),
|
||||
setCloudFrontCookies: jest.fn(() => true),
|
||||
getCloudFrontConfig: jest.fn(() => ({
|
||||
domain: 'https://cdn.example.com',
|
||||
|
|
@ -284,6 +305,32 @@ describe('setOpenIDAuthTokens', () => {
|
|||
expect(req.session.openidTokens.lastRefreshedAt).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it('should bind session tokens to the OpenID user identity', () => {
|
||||
const tokenset = {
|
||||
id_token: 'the-id-token',
|
||||
access_token: 'the-access-token',
|
||||
refresh_token: 'the-refresh-token',
|
||||
};
|
||||
const req = mockRequest();
|
||||
const res = mockResponse();
|
||||
|
||||
setOpenIDAuthTokens(tokenset, req, res, {
|
||||
userId: 'user-123',
|
||||
openidSubject: 'oidc-sub-123',
|
||||
tenantId: 'tenantA',
|
||||
openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration',
|
||||
});
|
||||
|
||||
expect(req.session.openidTokens).toEqual(
|
||||
expect.objectContaining({
|
||||
appUserId: 'user-123',
|
||||
openidSubject: 'oidc-sub-123',
|
||||
tenantId: 'tenantA',
|
||||
openidIssuer: 'https://issuer.example.com',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Codex Finding 5: persist the access-token's expiry (unix seconds) so the
|
||||
* first OBO call after login or SPA refresh can reuse a still-valid OPAQUE
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const {
|
|||
isEnabled,
|
||||
math,
|
||||
createAuthIdentityContext,
|
||||
isOpenIDSessionIdentityMatch,
|
||||
createOpenIDRefreshIdentityTuple,
|
||||
createRefreshTokenBridgeIdentity,
|
||||
serializeAuthIdentityTuple,
|
||||
|
|
@ -43,6 +44,10 @@ const {
|
|||
* 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
|
||||
|
|
@ -138,6 +143,47 @@ 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;
|
||||
|
|
@ -625,6 +671,7 @@ async function refreshOrReuseSession(req, res, user, tokenPreference, identityCo
|
|||
* 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);
|
||||
|
|
@ -685,6 +732,7 @@ function isOIDCRefreshApplicable(user) {
|
|||
* 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`.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -23,6 +23,36 @@ jest.mock('@librechat/api', () => ({
|
|||
tenantId: user?.tenantId ?? requestUser?.tenantId,
|
||||
openidIssuer: user?.openidIssuer ?? requestUser?.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(/\/+$/, '');
|
||||
const session = {
|
||||
appUserId: normalize(sessionIdentity?.appUserId),
|
||||
openidSubject: normalize(sessionIdentity?.openidSubject),
|
||||
tenantId: normalize(sessionIdentity?.tenantId),
|
||||
openidIssuer: normalizeIssuer(sessionIdentity?.openidIssuer),
|
||||
};
|
||||
const expected = {
|
||||
appUserId: normalize(expectedIdentity?.appUserId),
|
||||
openidSubject: normalize(expectedIdentity?.openidSubject),
|
||||
tenantId: normalize(expectedIdentity?.tenantId),
|
||||
openidIssuer: normalizeIssuer(expectedIdentity?.openidIssuer),
|
||||
};
|
||||
return (
|
||||
Boolean(session.appUserId) &&
|
||||
Boolean(session.openidSubject) &&
|
||||
session.appUserId === expected.appUserId &&
|
||||
session.openidSubject === expected.openidSubject &&
|
||||
session.tenantId === expected.tenantId &&
|
||||
session.openidIssuer === expected.openidIssuer
|
||||
);
|
||||
}),
|
||||
createOpenIDRefreshIdentityTuple: jest.fn(({ user, requestUser }) => {
|
||||
const subject =
|
||||
user?.openidId ??
|
||||
|
|
@ -117,13 +147,25 @@ const SECRET = 'test-secret';
|
|||
|
||||
const makeJwt = (exp) => jwt.sign({ sub: 'user-123', exp }, SECRET);
|
||||
|
||||
const buildReq = (sessionTokens, sessionId = 'session-A') => ({
|
||||
const DEFAULT_SESSION_IDENTITY = {
|
||||
appUserId: 'local-id-1',
|
||||
openidSubject: 'oidc-sub-123',
|
||||
tenantId: 'tenant-1',
|
||||
openidIssuer: 'https://issuer.example.com',
|
||||
};
|
||||
|
||||
const withSessionIdentity = (sessionTokens) =>
|
||||
sessionTokens == null ? sessionTokens : { ...DEFAULT_SESSION_IDENTITY, ...sessionTokens };
|
||||
|
||||
const buildReq = (sessionTokens, sessionId = 'session-A', { bindIdentity = true } = {}) => ({
|
||||
sessionID: sessionId,
|
||||
session: Object.assign(
|
||||
{
|
||||
save: jest.fn((cb) => cb(null)),
|
||||
},
|
||||
sessionTokens === undefined ? {} : { openidTokens: sessionTokens },
|
||||
sessionTokens === undefined
|
||||
? {}
|
||||
: { openidTokens: bindIdentity ? withSessionIdentity(sessionTokens) : sessionTokens },
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -136,6 +178,8 @@ const buildRes = ({ headersSent = false } = {}) => ({
|
|||
const makeOpenIdUser = (overrides = {}) => ({
|
||||
id: 'local-id-1',
|
||||
openidId: 'oidc-sub-123',
|
||||
tenantId: 'tenant-1',
|
||||
openidIssuer: 'https://issuer.example.com',
|
||||
provider: 'openid',
|
||||
...overrides,
|
||||
});
|
||||
|
|
@ -247,6 +291,37 @@ describe('OpenIDSessionRefresh', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('rejects session tokens that are missing identity metadata', async () => {
|
||||
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
|
||||
const sessionTokens = {
|
||||
accessToken: makeJwt(farFutureExp),
|
||||
idToken: makeJwt(farFutureExp),
|
||||
refreshToken: 'rt-unbound',
|
||||
};
|
||||
const req = buildReq(sessionTokens, 'session-unbound', { bindIdentity: false });
|
||||
|
||||
await expect(
|
||||
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
|
||||
).rejects.toThrow('OpenID session token identity mismatch');
|
||||
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects session tokens bound to a different OpenID identity', async () => {
|
||||
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
|
||||
const sessionTokens = {
|
||||
accessToken: makeJwt(farFutureExp),
|
||||
idToken: makeJwt(farFutureExp),
|
||||
refreshToken: 'rt-other-user',
|
||||
appUserId: 'other-user',
|
||||
};
|
||||
const req = buildReq(sessionTokens);
|
||||
|
||||
await expect(
|
||||
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
|
||||
).rejects.toThrow('OpenID session token identity mismatch');
|
||||
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* The bug fixed by Codex Finding 1a: id_token can outlive access_token.
|
||||
* Old behavior would declare "live" because id_token is fresh, sending an
|
||||
|
|
@ -530,8 +605,8 @@ describe('OpenIDSessionRefresh', () => {
|
|||
oldRefreshToken: 'rt-browser-stale',
|
||||
newRefreshToken: 'rt-session-current',
|
||||
userId: 'local-id-1',
|
||||
tenantId: undefined,
|
||||
openidIssuer: undefined,
|
||||
tenantId: 'tenant-1',
|
||||
openidIssuer: 'https://issuer.example.com',
|
||||
});
|
||||
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-browser-stale');
|
||||
});
|
||||
|
|
@ -544,7 +619,10 @@ describe('OpenIDSessionRefresh', () => {
|
|||
refresh_token: 'rt-rotated',
|
||||
expires_in: 3600,
|
||||
});
|
||||
const req = buildReq(buildExpiredSession('rt-old'));
|
||||
const req = buildReq({
|
||||
...buildExpiredSession('rt-old'),
|
||||
appUserId: 'mongo-id',
|
||||
});
|
||||
const res = buildRes({ headersSent: true });
|
||||
|
||||
await refreshOpenIDSession(
|
||||
|
|
@ -639,8 +717,8 @@ describe('OpenIDSessionRefresh', () => {
|
|||
oldRefreshToken: 'rt-browser-cookie',
|
||||
newRefreshToken: 'rt-second-rotation',
|
||||
userId: 'local-id-1',
|
||||
tenantId: undefined,
|
||||
openidIssuer: undefined,
|
||||
tenantId: 'tenant-1',
|
||||
openidIssuer: 'https://issuer.example.com',
|
||||
});
|
||||
expect(req.session.openidTokens.refreshToken).toBe('rt-second-rotation');
|
||||
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-browser-cookie');
|
||||
|
|
@ -665,8 +743,8 @@ describe('OpenIDSessionRefresh', () => {
|
|||
oldRefreshToken: 'rt-old',
|
||||
newRefreshToken: 'rt-rotated',
|
||||
userId: 'local-id-1',
|
||||
tenantId: undefined,
|
||||
openidIssuer: undefined,
|
||||
tenantId: 'tenant-1',
|
||||
openidIssuer: 'https://issuer.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -688,8 +766,8 @@ describe('OpenIDSessionRefresh', () => {
|
|||
oldRefreshToken: 'rt-old',
|
||||
newRefreshToken: 'rt-rotated',
|
||||
userId: 'local-id-1',
|
||||
tenantId: undefined,
|
||||
openidIssuer: undefined,
|
||||
tenantId: 'tenant-1',
|
||||
openidIssuer: 'https://issuer.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import {
|
||||
createAuthIdentityContext,
|
||||
createOpenIDOboIdentityTuple,
|
||||
createOpenIDSessionIdentity,
|
||||
createOpenIDRefreshIdentityTuple,
|
||||
createRefreshTokenBridgeIdentity,
|
||||
isOpenIDSessionIdentityMatch,
|
||||
resolveAppUserId,
|
||||
serializeAuthIdentityTuple,
|
||||
} from './identity';
|
||||
|
|
@ -33,6 +35,65 @@ describe('auth identity helpers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('creates session identity from explicit token metadata before request user fallback', () => {
|
||||
expect(
|
||||
createOpenIDSessionIdentity({
|
||||
user: {
|
||||
id: 'request-user',
|
||||
openidId: 'request-sub',
|
||||
tenantId: 'request-tenant',
|
||||
openidIssuer: 'https://request.example.com',
|
||||
},
|
||||
userId: 'session-user',
|
||||
openidSubject: 'session-sub',
|
||||
tenantId: 'session-tenant',
|
||||
openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration',
|
||||
}),
|
||||
).toEqual({
|
||||
appUserId: 'session-user',
|
||||
openidSubject: 'session-sub',
|
||||
tenantId: 'session-tenant',
|
||||
openidIssuer: 'https://issuer.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires stamped OpenID session identity metadata to match exactly', () => {
|
||||
const expected = {
|
||||
appUserId: 'user-123',
|
||||
openidSubject: 'oidc-sub',
|
||||
tenantId: 'tenant-a',
|
||||
openidIssuer: 'https://issuer.example.com',
|
||||
};
|
||||
|
||||
expect(
|
||||
isOpenIDSessionIdentityMatch(
|
||||
{
|
||||
...expected,
|
||||
openidIssuer: 'https://issuer.example.com/',
|
||||
},
|
||||
expected,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isOpenIDSessionIdentityMatch(
|
||||
{
|
||||
...expected,
|
||||
openidSubject: 'different-sub',
|
||||
},
|
||||
expected,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isOpenIDSessionIdentityMatch(
|
||||
{
|
||||
...expected,
|
||||
openidIssuer: undefined,
|
||||
},
|
||||
expected,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows refresh tuple to fall back to app id when openidId is absent', () => {
|
||||
expect(
|
||||
createOpenIDRefreshIdentityTuple({
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ export type RefreshTokenBridgeIdentity = {
|
|||
openidIssuer?: string;
|
||||
};
|
||||
|
||||
export type OpenIDSessionIdentitySource = {
|
||||
appUserId?: string | null;
|
||||
openidSubject?: string | null;
|
||||
tenantId?: string | null;
|
||||
openidIssuer?: string | null;
|
||||
};
|
||||
|
||||
const NO_TENANT = 'no-tenant';
|
||||
const NO_ISSUER = 'no-issuer';
|
||||
const IDENTITY_PART_SEPARATOR = '\x1f';
|
||||
|
|
@ -136,6 +143,68 @@ export function createAuthIdentityContext({
|
|||
};
|
||||
}
|
||||
|
||||
export function createOpenIDSessionIdentity({
|
||||
user,
|
||||
requestUser,
|
||||
userId,
|
||||
openidSubject,
|
||||
tenantId,
|
||||
openidIssuer,
|
||||
}: {
|
||||
user?: AuthIdentitySource | null;
|
||||
requestUser?: AuthIdentitySource | null;
|
||||
userId?: string | null;
|
||||
openidSubject?: string | null;
|
||||
tenantId?: string | null;
|
||||
openidIssuer?: string | null;
|
||||
}): AuthIdentityContext {
|
||||
return createAuthIdentityContext({
|
||||
user: {
|
||||
id: userId,
|
||||
openidId: openidSubject,
|
||||
tenantId,
|
||||
openidIssuer,
|
||||
},
|
||||
requestUser: user ?? requestUser,
|
||||
tenantId,
|
||||
openidIssuer,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOpenIDSessionIdentity(
|
||||
identity: OpenIDSessionIdentitySource | null | undefined,
|
||||
): AuthIdentityContext | null {
|
||||
const appUserId = normalizeIdentityValue(identity?.appUserId);
|
||||
const openidSubject = normalizeIdentityValue(identity?.openidSubject);
|
||||
if (!appUserId || !openidSubject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
appUserId,
|
||||
openidSubject,
|
||||
tenantId: normalizeIdentityValue(identity?.tenantId),
|
||||
openidIssuer: normalizeOpenIdIssuer(identity?.openidIssuer ?? undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export function isOpenIDSessionIdentityMatch(
|
||||
sessionIdentity: OpenIDSessionIdentitySource | null | undefined,
|
||||
expectedIdentity: OpenIDSessionIdentitySource | null | undefined,
|
||||
): boolean {
|
||||
const session = normalizeOpenIDSessionIdentity(sessionIdentity);
|
||||
const expected = normalizeOpenIDSessionIdentity(expectedIdentity);
|
||||
|
||||
return (
|
||||
session != null &&
|
||||
expected != null &&
|
||||
session.appUserId === expected.appUserId &&
|
||||
session.openidSubject === expected.openidSubject &&
|
||||
session.tenantId === expected.tenantId &&
|
||||
session.openidIssuer === expected.openidIssuer
|
||||
);
|
||||
}
|
||||
|
||||
export function createRefreshTokenBridgeIdentity({
|
||||
user,
|
||||
requestUser,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue