🆔 fix: Prioritize Immutable Sub Claim for OIDC User ID (#9788)

* add use of immutable claims to identify user object

* fix semicolons

* update email attribute on change

* replace ternary expressions

* fix semicolon

* chore: add typing

* chore: reorder fields in `findOpenIDUser`

* refactor: optimize user lookup logic in `findOpenIDUser` function to minimize database roundtrips

* refactor: integrate findOpenIDUser for improved user retrieval in refreshController

* refactor: improve error logging for invalid refresh tokens in refreshController

* ci: mock findUser correctly in openidStrategy tests

* test: add unit tests for findOpenIDUser function to enhance user retrieval logic

---------

Co-authored-by: Joachim Keltsch <joachim.keltsch@daimlertruck.com>
This commit is contained in:
Danny Avila 2025-09-23 14:46:53 -04:00 committed by GitHub
parent e4f323e71a
commit bcec5bfceb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 458 additions and 17 deletions

View file

@ -1,8 +1,8 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, findOpenIDUser } = require('@librechat/api');
const {
requestPasswordReset,
setOpenIDAuthTokens,
@ -72,8 +72,14 @@ const refreshController = async (req, res) => {
const openIdConfig = getOpenIdConfig();
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
const claims = tokenset.claims();
const user = await findUser({ email: claims.email });
if (!user) {
const { user, error } = await findOpenIDUser({
findUser,
email: claims.email,
openidId: claims.sub,
idOnTheSource: claims.oid,
strategyName: 'refreshController',
});
if (error || !user) {
return res.status(401).redirect('/login');
}
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString());
@ -126,7 +132,7 @@ const refreshController = async (req, res) => {
res.status(401).send('Refresh token expired or not found for this user');
}
} catch (err) {
logger.error(`[refreshController] Refresh token: ${refreshToken}`, err);
logger.error(`[refreshController] Invalid refresh token:`, err);
res.status(403).send('Invalid refresh token');
}
};

View file

@ -41,13 +41,18 @@ const openIdJwtLogin = (openIdConfig) => {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
},
/**
* @param {import('openid-client').IDToken} payload
* @param {import('passport-jwt').VerifyCallback} done
*/
async (payload, done) => {
try {
const { user, error, migration } = await findOpenIDUser({
openidId: payload?.sub,
email: payload?.email,
strategyName: 'openIdJwtLogin',
findUser,
email: payload?.email,
openidId: payload?.sub,
idOnTheSource: payload?.oid,
strategyName: 'openIdJwtLogin',
});
if (error) {

View file

@ -337,6 +337,10 @@ async function setupOpenId() {
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
usePKCE,
},
/**
* @param {import('openid-client').TokenEndpointResponseHelpers} tokenset
* @param {import('passport-jwt').VerifyCallback} done
*/
async (tokenset, done) => {
try {
const claims = tokenset.claims();
@ -354,10 +358,11 @@ async function setupOpenId() {
}
const result = await findOpenIDUser({
openidId: claims.sub,
email: claims.email,
strategyName: 'openidStrategy',
findUser,
email: claims.email,
openidId: claims.sub,
idOnTheSource: claims.oid,
strategyName: 'openidStrategy',
});
let user = result.user;
const error = result.error;
@ -436,6 +441,10 @@ async function setupOpenId() {
user.username = username;
user.name = fullName;
user.idOnTheSource = userinfo.oid;
if (userinfo.email && userinfo.email !== user.email) {
user.email = userinfo.email;
user.emailVerified = userinfo.email_verified || false;
}
}
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {

View file

@ -274,10 +274,7 @@ describe('setupOpenId', () => {
name: '',
};
findUser.mockImplementation(async (query) => {
if (
query.openidId === tokenset.claims().sub ||
(query.email === tokenset.claims().email && query.provider === 'openid')
) {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
return existingUser;
}
return null;