Compare commits

...

32 Commits

Author SHA1 Message Date
Ruben Talstra
32a0998e4d Merge branch 'dev' into feat/openid-custom-data 2025-05-16 07:55:32 +02:00
Ruben Talstra
8f460b9f75 Merge branch 'dev' into feat/openid-custom-data 2025-05-15 17:22:15 +02:00
Ruben Talstra
c925f9f39c 🚀 feat: Add Cloudflare Turnstile support (#5987)
* 🚀 feat: Add @marsidev/react-turnstile dependency to package.json and package-lock.json

* 🚀 feat: Integrate Cloudflare Turnstile configuration support in AppService and add schema validation

* 🚀 feat: Implemented Cloudflare Turnstile integration in Login and Registration forms

* 🚀 feat: Enhance AppService tests with additional mocks and configuration setups

* 🚀 feat: Comment out outdated config version warning tests in AppService.spec.js

* 🚀 feat: Remove outdated warning tests and add new checks for environment variables and API health

* 🔧 test: Update AppService.spec.js to use expect.anything() for paths validation

* 🔧 test: Refactor AppService.spec.js to streamline mocks and enhance clarity

* 🔧 chore: removed not needed test

* Potential fix for code scanning alert no. 5638: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5629: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5642: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Update turnstile.js

* Potential fix for code scanning alert no. 5634: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5646: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 5647: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-15 09:38:58 -04:00
matt burnett
71effb1a66 🔃 refactor: AgentFooter to conditionally render buttons based on activePanel (#7306) 2025-05-15 09:37:14 -04:00
andresgit
e3acd18c07 🎨 feat: add copy-tex to improve copying KaTeX (#7308)
When selecting equations and using copy paste, uses the correct latex code.

Co-authored-by: Ruben Talstra <RubenTalstra1211@outlook.com>
2025-05-15 09:35:48 -04:00
Ruben Talstra
b661057b97 fix conflict 2025-05-14 21:12:56 +02:00
Ruben Talstra
0d0f408d85 Merge branch 'main' into feat/openid-custom-data 2025-05-14 21:11:58 +02:00
Ruben Talstra
eae6a969f4 fix conflict 2025-05-14 21:11:41 +02:00
Ruben Talstra
4a72821d55 Merge branch 'main' into feat/openid-custom-data 2025-04-11 15:14:01 +02:00
Ruben Talstra
284fc82d8e 🔧 chore: Bump version to 0.0.7 in package.json and package-lock.json 2025-04-10 19:15:10 +02:00
Ruben Talstra
20ad59c6f5 🔧 chore: Bump version to 0.7.791 and update dependencies 2025-04-10 19:14:13 +02:00
Ruben Talstra
f0a42d20a2 Merge branch 'main' into feat/openid-custom-data 2025-04-10 19:13:03 +02:00
Ruben Talstra
1cfb9f1b3a 🚀 feat: Bump package version to 0.7.790 2025-04-10 19:12:48 +02:00
Ruben Talstra
232cdaa5f7 🚀 feat: Bump package version to 0.7.790 2025-04-10 19:11:36 +02:00
Ruben Talstra
b4f57a18a7 Merge branch 'main' into feat/openid-custom-data 2025-03-25 23:32:59 +01:00
Ruben Talstra
291f76207f 🚀 feat: Refactor customOpenIdData handling to use an object instead of Map and update return types 2025-03-24 10:00:11 +01:00
Ruben Talstra
51cfd9a520 🚀 feat: Bump package version to 0.7.75 2025-03-24 09:37:22 +01:00
Ruben Talstra
2267a251fa 🚀 feat: Change customOpenIdData to be optional and use Record<string, unknown> type 2025-03-24 09:36:12 +01:00
Ruben Talstra
7a5be00f71 🚀 feat: Update openid-client dependency to version 5.7.1 2025-03-24 09:32:23 +01:00
Ruben Talstra
3628572aea 🚀 feat: Update package version to 0.0.6 and add Microsoft Graph client dependency 2025-03-24 09:31:41 +01:00
Ruben Talstra
b8215c314f Merge branch 'main' into feat/openid-custom-data 2025-03-24 09:23:30 +01:00
Ruben Talstra
ec1a31e852 refactor: remove customOpenIdData property from user schema 2025-03-24 09:23:08 +01:00
Ruben Talstra
5c01eaa36c chore: update .env.example to include OPENID_BUTTON_LABEL and OPENID_IMAGE_URL 2025-03-24 09:21:19 +01:00
Ruben Talstra
8f783180a6 chore: update dependencies for openid-client and passport 2025-03-24 09:19:37 +01:00
Ruben Talstra
cd922131a9 fix: package issue update. 2025-02-12 13:38:15 +01:00
Ruben Talstra
102e79b185 refactor: refactored the openidStrategy.js so it's more readable and understandable. 2025-02-12 13:22:39 +01:00
Ruben Talstra
65a0e1db54 fixed: package issue 2025-02-12 13:12:56 +01:00
Ruben Talstra
f1e031a9f5 refactor: updated the code basted on suggestion. 2025-02-12 13:07:13 +01:00
Ruben Talstra
244b9f94dc Merge branch 'main' into feat/openid-custom-data 2025-02-12 12:58:58 +01:00
Ruben Talstra
17afeb5c36 chore: resolving conflicts 2025-02-12 12:58:41 +01:00
Ruben Talstra
ce407626fd feat: add missing package: @microsoft/microsoft-graph-client 2025-02-11 16:48:16 +01:00
Ruben Talstra
2ef6e4462d feat: Add custom fields & role assignment to OpenID strategy (#5612)
* started with Support for Customizable OpenID Profile Fields via Environment Variable

* kept as much of the original code as possible but still added the custom data mapper

* kept as much of the original code as possible but still added the custom data mapper

* resolved merge conflicts

* resolved merge conflicts

* resolved merge conflicts

* resolved merge conflicts

* removed some unneeded comments

* fix: conflicted issue

---------

Co-authored-by: Talstra Ruben SRSNL <ruben.talstra@stadlerrail.com>
2025-02-11 16:42:05 +01:00
8 changed files with 429 additions and 217 deletions

View File

@@ -438,6 +438,10 @@ OPENID_USERNAME_CLAIM=
# Set to determine which user info property returned from OpenID Provider to store as the User's name
OPENID_NAME_CLAIM=
OPENID_CUSTOM_DATA=
OPENID_PROVIDER=
OPENID_ADMIN_ROLE=
OPENID_BUTTON_LABEL=
OPENID_IMAGE_URL=
# Set to true to automatically redirect to the OpenID provider when a user visits the login page

View File

@@ -51,6 +51,7 @@
"@librechat/agents": "^2.4.317",
"@librechat/data-schemas": "*",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"@microsoft/microsoft-graph-client": "^3.0.7",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",
"cohere-ai": "^7.9.1",
@@ -92,7 +93,7 @@
"ollama": "^0.5.0",
"openai": "^4.96.2",
"openai-chat-tokens": "^0.2.8",
"openid-client": "^5.4.2",
"openid-client": "^5.7.1",
"passport": "^0.6.0",
"passport-apple": "^2.0.2",
"passport-discord": "^0.1.4",

View File

@@ -0,0 +1,165 @@
const fetch = require('node-fetch');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { logger } = require('~/config');
// Microsoft SDK
const { Client: MicrosoftGraphClient } = require('@microsoft/microsoft-graph-client');
/**
* Base class for provider-specific data mappers.
*/
class BaseDataMapper {
/**
* Map custom OpenID data.
* @param {string} accessToken - The access token to authenticate the request.
* @param {string|Array<string>} customQuery - Either a full query string (if it contains operators)
* or an array of fields to select.
* @returns {Promise<Record<string, unknown>>} A promise that resolves to an object of custom fields.
* @throws {Error} Throws an error if not implemented in the subclass.
*/
async mapCustomData(accessToken, customQuery) {
throw new Error('mapCustomData() must be implemented by subclasses');
}
/**
* Optionally handle proxy settings for HTTP requests.
* @returns {Object} Configuration object with proxy settings if PROXY is set.
*/
getProxyOptions() {
if (process.env.PROXY) {
const agent = new HttpsProxyAgent(process.env.PROXY);
return { agent };
}
return {};
}
}
/**
* Microsoft-specific data mapper using the Microsoft Graph SDK.
*/
class MicrosoftDataMapper extends BaseDataMapper {
/**
* Initializes the MicrosoftGraphClient once for reuse.
*/
constructor() {
super();
this.accessToken = null;
this.client = MicrosoftGraphClient.init({
defaultVersion: 'beta',
authProvider: (done) => {
// The authProvider will be called for each request to get the token
if (this.accessToken) {
done(null, this.accessToken);
} else {
done(new Error('Access token is not set.'), null);
}
},
fetch: fetch,
...this.getProxyOptions(),
});
// Bind methods to maintain context
this.mapCustomData = this.mapCustomData.bind(this);
this.cleanData = this.cleanData.bind(this);
}
/**
* Set the access token for the client.
* This method should be called before making any requests.
*
* @param {string} accessToken - The access token.
*/
setAccessToken(accessToken) {
if (!accessToken || typeof accessToken !== 'string') {
throw new Error('[MicrosoftDataMapper] Invalid access token provided.');
}
this.accessToken = accessToken;
}
/**
* Map custom OpenID data using the Microsoft Graph SDK.
*
* @param {string} accessToken - The access token to authenticate the request.
* @param {string|Array<string>} customQuery - Fields to select from the Microsoft Graph API.
* @returns {Promise<Record<string, unknown>>} A promise that resolves to an object of custom fields.
*/
async mapCustomData(accessToken, customQuery) {
try {
this.setAccessToken(accessToken);
if (!customQuery) {
logger.warn('[MicrosoftDataMapper] No customQuery provided.');
return {};
}
// Convert customQuery to a comma-separated string if it's an array
const fields = Array.isArray(customQuery) ? customQuery.join(',') : customQuery;
if (!fields) {
logger.warn('[MicrosoftDataMapper] No fields specified in customQuery.');
return {};
}
const result = await this.client.api('/me').select(fields).get();
// Clean and return the data as a plain object
return this.cleanData(result);
} catch (error) {
// Handle specific Microsoft Graph errors if needed
logger.error(`[MicrosoftDataMapper] Error fetching user data: ${error.message}`, {
stack: error.stack,
});
return {};
}
}
/**
* Recursively remove all keys starting with @odata. from an object or array.
*
* @param {object|Array} obj - The object or array to clean.
* @returns {object|Array} - The cleaned object or array.
*/
cleanData(obj) {
if (Array.isArray(obj)) {
return obj.map(this.cleanData);
} else if (obj && typeof obj === 'object') {
return Object.entries(obj).reduce((acc, [key, value]) => {
if (!key.startsWith('@odata.')) {
acc[key] = this.cleanData(value);
}
return acc;
}, {});
}
return obj;
}
}
/**
* Map provider names to their specific data mappers.
*/
const PROVIDER_MAPPERS = {
microsoft: MicrosoftDataMapper,
};
/**
* Abstraction layer that returns a provider-specific mapper instance.
*/
class OpenIdDataMapper {
/**
* Retrieve an instance of the mapper for the specified provider.
*
* @param {string} provider - The name of the provider (e.g., 'microsoft').
* @returns {BaseDataMapper} An instance of the specific data mapper for the provider.
* @throws {Error} Throws an error if no mapper is found for the specified provider.
*/
static getMapper(provider) {
const MapperClass = PROVIDER_MAPPERS[provider.toLowerCase()];
if (!MapperClass) {
throw new Error(`No mapper found for provider: ${provider}`);
}
return new MapperClass();
}
}
module.exports = OpenIdDataMapper;

View File

@@ -8,6 +8,8 @@ const { findUser, createUser, updateUser } = require('~/models/userMethods');
const { hashToken } = require('~/server/utils/crypto');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const { SystemRoles } = require('librechat-data-provider');
const OpenIdDataMapper = require('./OpenId/openidDataMapper');
let crypto;
try {
@@ -20,37 +22,27 @@ try {
* Downloads an image from a URL using an access token.
* @param {string} url
* @param {string} accessToken
* @returns {Promise<Buffer>}
* @returns {Promise<Buffer|string>}
*/
const downloadImage = async (url, accessToken) => {
if (!url) {
return '';
}
const options = {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
...(process.env.PROXY && { agent: new HttpsProxyAgent(process.env.PROXY) }),
};
try {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
};
if (process.env.PROXY) {
options.agent = new HttpsProxyAgent(process.env.PROXY);
}
const response = await fetch(url, options);
if (response.ok) {
const buffer = await response.buffer();
return buffer;
} else {
if (!response.ok) {
throw new Error(`${response.statusText} (HTTP ${response.status})`);
}
return await response.buffer();
} catch (error) {
logger.error(
`[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`,
);
logger.error(`[openidStrategy] Error downloading image at URL "${url}": ${error}`);
return '';
}
};
@@ -65,25 +57,21 @@ const downloadImage = async (url, accessToken) => {
* @param {string} [userinfo.email] - The user's email address
* @returns {string} The determined full name of the user
*/
function getFullName(userinfo) {
const getFullName = (userinfo) => {
if (process.env.OPENID_NAME_CLAIM) {
return userinfo[process.env.OPENID_NAME_CLAIM];
}
if (userinfo.given_name && userinfo.family_name) {
return `${userinfo.given_name} ${userinfo.family_name}`;
}
if (userinfo.given_name) {
return userinfo.given_name;
}
if (userinfo.family_name) {
return userinfo.family_name;
}
return userinfo.username || userinfo.email;
}
};
/**
* Converts an input into a string suitable for a username.
@@ -95,26 +83,93 @@ function getFullName(userinfo) {
* @param {string} [defaultValue=''] - The default value to return if the input is falsy.
* @returns {string} The processed input as a string suitable for a username.
*/
function convertToUsername(input, defaultValue = '') {
const convertToUsername = (input, defaultValue = '') => {
if (typeof input === 'string') {
return input;
} else if (Array.isArray(input)) {
}
if (Array.isArray(input)) {
return input.join('_');
}
return defaultValue;
}
};
/**
* Safely decodes a JWT token.
* @param {string} token
* @returns {Object|null}
*/
const safeDecode = (token) => {
try {
const decoded = jwtDecode(token);
if (decoded && typeof decoded === 'object') {
return decoded;
}
logger.error('[openidStrategy] Decoded token is not an object.');
} catch (error) {
logger.error('[openidStrategy] Error decoding token:', error);
}
return null;
};
/**
* Extracts roles from a decoded token based on the provided path.
* @param {Object} decodedToken
* @param {string} parameterPath
* @returns {string[]}
*/
const extractRolesFromToken = (decodedToken, parameterPath) => {
if (!decodedToken) {
return [];
}
if (!parameterPath) {
return [];
}
const roles = parameterPath.split('.').reduce((obj, key) => obj?.[key] ?? null, decodedToken);
if (!Array.isArray(roles)) {
logger.error('[openidStrategy] Roles extracted from token are not in array format.');
return [];
}
return roles;
};
/**
* Updates the user's avatar if a valid picture URL is provided.
* @param {Object} user
* @param {string | undefined} pictureUrl - The URL of the user's avatar.
* @param {string} accessToken
* @returns {Promise<Object>} The updated user object.
*/
const updateUserAvatar = async (user, pictureUrl, accessToken) => {
if (!pictureUrl || (user.avatar && user.avatar.includes('manual=true'))) {
return user;
}
const fileName = crypto ? (await hashToken(user.openidId)) + '.png' : `${user.openidId}.png`;
const imageBuffer = await downloadImage(pictureUrl, accessToken);
if (imageBuffer) {
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
buffer: imageBuffer,
});
user.avatar = imagePath ?? '';
}
return user;
};
async function setupOpenId() {
try {
// Configure proxy if defined.
if (process.env.PROXY) {
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
custom.setHttpOptionsDefaults({
agent: proxyAgent,
});
logger.info(`[openidStrategy] proxy agent added: ${process.env.PROXY}`);
custom.setHttpOptionsDefaults({ agent: proxyAgent });
logger.info(`[openidStrategy] Proxy agent added: ${process.env.PROXY}`);
}
const issuer = await Issuer.discover(process.env.OPENID_ISSUER);
/* Supported Algorithms, openid-client v5 doesn't set it automatically as discovered from server.
- id_token_signed_response_alg // defaults to 'RS256'
- request_object_signing_alg // defaults to 'RS256'
@@ -128,125 +183,113 @@ async function setupOpenId() {
client_secret: process.env.OPENID_CLIENT_SECRET,
redirect_uris: [process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL],
};
if (isEnabled(process.env.OPENID_SET_FIRST_SUPPORTED_ALGORITHM)) {
clientMetadata.id_token_signed_response_alg =
issuer.id_token_signing_alg_values_supported?.[0] || 'RS256';
}
const client = new issuer.Client(clientMetadata);
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
const adminRolesEnv = process.env.OPENID_ADMIN_ROLE;
const adminRoles = adminRolesEnv ? adminRolesEnv.split(',').map((role) => role.trim()) : [];
const openidLogin = new OpenIDStrategy(
{
client,
params: {
scope: process.env.OPENID_SCOPE,
},
params: { scope: process.env.OPENID_SCOPE },
},
async (tokenset, userinfo, done) => {
try {
logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`);
logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo });
logger.info(`[openidStrategy] Verifying login for openidId: ${userinfo.sub}`);
logger.debug('[openidStrategy] Tokenset and userinfo:', { tokenset, userinfo });
let user = await findUser({ openidId: userinfo.sub });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${userinfo.sub}`,
);
if (!user) {
user = await findUser({ email: userinfo.email });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
userinfo.email
} for openidId: ${userinfo.sub}`,
);
}
// Find an existing user by openidId or email.
let user =
(await findUser({ openidId: userinfo.sub })) ||
(await findUser({ email: userinfo.email }));
const fullName = getFullName(userinfo);
const username = process.env.OPENID_USERNAME_CLAIM
? userinfo[process.env.OPENID_USERNAME_CLAIM]
: convertToUsername(userinfo.username || userinfo.given_name || userinfo.email);
if (requiredRole) {
let decodedToken = '';
if (requiredRoleTokenKind === 'access') {
decodedToken = jwtDecode(tokenset.access_token);
} else if (requiredRoleTokenKind === 'id') {
decodedToken = jwtDecode(tokenset.id_token);
}
const pathParts = requiredRoleParameterPath.split('.');
let found = true;
let roles = pathParts.reduce((o, key) => {
if (o === null || o === undefined || !(key in o)) {
found = false;
return [];
}
return o[key];
}, decodedToken);
// Use the token specified by configuration to extract roles.
const token =
requiredRoleTokenKind === 'access' ? tokenset.access_token : tokenset.id_token;
const decodedToken = safeDecode(token);
const tokenBasedRoles = extractRolesFromToken(decodedToken, requiredRoleParameterPath);
if (!found) {
logger.error(
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
);
}
if (!roles.includes(requiredRole)) {
return done(null, false, {
message: `You must have the "${requiredRole}" role to log in.`,
});
}
// Ensure the required role exists.
if (requiredRole && !tokenBasedRoles.includes(requiredRole)) {
return done(null, false, {
message: `You must have the "${requiredRole}" role to log in.`,
});
}
let username = '';
if (process.env.OPENID_USERNAME_CLAIM) {
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
} else {
username = convertToUsername(
userinfo.username || userinfo.given_name || userinfo.email,
// Determine system role.
const isAdmin = tokenBasedRoles.some((role) => adminRoles.includes(role));
const assignedRole = isAdmin ? SystemRoles.ADMIN : SystemRoles.USER;
logger.debug(
`[openidStrategy] Assigned system role: ${assignedRole} (isAdmin: ${isAdmin})`,
);
// Map custom OpenID data if configured.
let customOpenIdData = {};
if (process.env.OPENID_CUSTOM_DATA) {
const dataMapper = OpenIdDataMapper.getMapper(
process.env.OPENID_PROVIDER.toLowerCase(),
);
customOpenIdData = await dataMapper.mapCustomData(
tokenset.access_token,
process.env.OPENID_CUSTOM_DATA,
);
if (tokenBasedRoles.length) {
customOpenIdData.roles = tokenBasedRoles;
} else {
logger.warn('[openidStrategy] tokenBasedRoles is missing or invalid.');
}
}
// Create or update the user.
if (!user) {
user = await createUser(
{
provider: 'openid',
openidId: userinfo.sub,
username,
email: userinfo.email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
role: assignedRole,
customOpenIdData,
},
true,
true,
);
} else {
user = {
...user,
provider: 'openid',
openidId: userinfo.sub,
username,
email: userinfo.email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
role: assignedRole,
customOpenIdData,
};
user = await createUser(user, true, true);
} else {
user.provider = 'openid';
user.openidId = userinfo.sub;
user.username = username;
user.name = fullName;
}
if (userinfo.picture && !user.avatar?.includes('manual=true')) {
/** @type {string | undefined} */
const imageUrl = userinfo.picture;
let fileName;
if (crypto) {
fileName = (await hashToken(userinfo.sub)) + '.png';
} else {
fileName = userinfo.sub + '.png';
}
const imageBuffer = await downloadImage(imageUrl, tokenset.access_token);
if (imageBuffer) {
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
buffer: imageBuffer,
});
user.avatar = imagePath ?? '';
}
}
// Update the user's avatar if available.
user = await updateUserAvatar(user, userinfo.picture, tokenset.access_token);
// Persist updated user data.
user = await updateUser(user._id, user);
logger.info(
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
`[openidStrategy] Login success for openidId: ${user.openidId} | email: ${user.email} | username: ${user.username}`,
{
user: {
openidId: user.openidId,
@@ -256,10 +299,9 @@ async function setupOpenId() {
},
},
);
done(null, user);
} catch (err) {
logger.error('[openidStrategy] login failed', err);
logger.error('[openidStrategy] Login failed', err);
done(err);
}
},

View File

@@ -3,6 +3,7 @@ const jwtDecode = require('jsonwebtoken/decode');
const { Issuer, Strategy: OpenIDStrategy } = require('openid-client');
const { findUser, createUser, updateUser } = require('~/models/userMethods');
const setupOpenId = require('./openidStrategy');
const OpenIdDataMapper = require('./OpenId/openidDataMapper');
// --- Mocks ---
jest.mock('node-fetch');
@@ -10,7 +11,6 @@ jest.mock('openid-client');
jest.mock('jsonwebtoken/decode');
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
// You can modify this mock as needed (here returning a dummy function)
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
})),
}));
@@ -23,7 +23,7 @@ jest.mock('~/server/utils/crypto', () => ({
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));
jest.mock('~/server/utils', () => ({
isEnabled: jest.fn(() => false), // default to false, override per test if needed
isEnabled: jest.fn(() => false),
}));
jest.mock('~/config', () => ({
logger: {
@@ -43,7 +43,7 @@ Issuer.discover = jest.fn().mockResolvedValue({
}),
});
// To capture the verify callback from the strategy, we grab it from the mock constructor
// Capture the verify callback from the strategy via the mock constructor
let verifyCallback;
OpenIDStrategy.mockImplementation((options, verify) => {
verifyCallback = verify;
@@ -80,7 +80,6 @@ describe('setupOpenId', () => {
};
beforeEach(async () => {
// Clear previous mock calls and reset implementations
jest.clearAllMocks();
// Reset environment variables needed by the strategy
@@ -96,6 +95,8 @@ describe('setupOpenId', () => {
delete process.env.OPENID_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM;
delete process.env.PROXY;
delete process.env.OPENID_CUSTOM_DATA;
delete process.env.OPENID_PROVIDER;
// Default jwtDecode mock returns a token that includes the required role.
jwtDecode.mockReturnValue({
@@ -125,13 +126,8 @@ describe('setupOpenId', () => {
});
it('should create a new user with correct username when username claim exists', async () => {
// Arrange our userinfo already has username 'flast'
const userinfo = { ...baseUserinfo };
// Act
const { user } = await validate(tokenset, userinfo);
// Assert
expect(user.username).toBe(userinfo.username);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
@@ -147,16 +143,10 @@ describe('setupOpenId', () => {
});
it('should use given_name as username when username claim is missing', async () => {
// Arrange remove username from userinfo
const userinfo = { ...baseUserinfo };
delete userinfo.username;
// Expect the username to be the given name (unchanged case)
const expectUsername = userinfo.given_name;
// Act
const { user } = await validate(tokenset, userinfo);
// Assert
expect(user.username).toBe(expectUsername);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
@@ -166,16 +156,11 @@ describe('setupOpenId', () => {
});
it('should use email as username when username and given_name are missing', async () => {
// Arrange remove username and given_name
const userinfo = { ...baseUserinfo };
delete userinfo.username;
delete userinfo.given_name;
const expectUsername = userinfo.email;
// Act
const { user } = await validate(tokenset, userinfo);
// Assert
expect(user.username).toBe(expectUsername);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
@@ -185,14 +170,9 @@ describe('setupOpenId', () => {
});
it('should override username with OPENID_USERNAME_CLAIM when set', async () => {
// Arrange set OPENID_USERNAME_CLAIM so that the sub claim is used
process.env.OPENID_USERNAME_CLAIM = 'sub';
const userinfo = { ...baseUserinfo };
// Act
const { user } = await validate(tokenset, userinfo);
// Assert username should equal the sub (converted as-is)
expect(user.username).toBe(userinfo.sub);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: userinfo.sub }),
@@ -202,31 +182,20 @@ describe('setupOpenId', () => {
});
it('should set the full name correctly when given_name and family_name exist', async () => {
// Arrange
const userinfo = { ...baseUserinfo };
const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`;
// Act
const { user } = await validate(tokenset, userinfo);
// Assert
expect(user.name).toBe(expectedFullName);
});
it('should override full name with OPENID_NAME_CLAIM when set', async () => {
// Arrange use the name claim as the full name
process.env.OPENID_NAME_CLAIM = 'name';
const userinfo = { ...baseUserinfo, name: 'Custom Name' };
// Act
const { user } = await validate(tokenset, userinfo);
// Assert
expect(user.name).toBe('Custom Name');
});
it('should update an existing user on login', async () => {
// Arrange simulate that a user already exists
const existingUser = {
_id: 'existingUserId',
provider: 'local',
@@ -241,13 +210,8 @@ describe('setupOpenId', () => {
}
return null;
});
const userinfo = { ...baseUserinfo };
// Act
await validate(tokenset, userinfo);
// Assert updateUser should be called and the user object updated
expect(updateUser).toHaveBeenCalledWith(
existingUser._id,
expect.objectContaining({
@@ -260,43 +224,41 @@ describe('setupOpenId', () => {
});
it('should enforce the required role and reject login if missing', async () => {
// Arrange simulate a token without the required role.
jwtDecode.mockReturnValue({
roles: ['SomeOtherRole'],
});
const userinfo = { ...baseUserinfo };
// Act
const { user, details } = await validate(tokenset, userinfo);
// Assert verify that the strategy rejects login
expect(user).toBe(false);
expect(details.message).toBe('You must have the "requiredRole" role to log in.');
});
it('should attempt to download and save the avatar if picture is provided', async () => {
// Arrange ensure userinfo contains a picture URL
const userinfo = { ...baseUserinfo };
// Act
const { user } = await validate(tokenset, userinfo);
// Assert verify that download was attempted and the avatar field was set via updateUser
expect(fetch).toHaveBeenCalled();
// Our mock getStrategyFunctions.saveBuffer returns '/fake/path/to/avatar.png'
expect(user.avatar).toBe('/fake/path/to/avatar.png');
});
it('should not attempt to download avatar if picture is not provided', async () => {
// Arrange remove picture
const userinfo = { ...baseUserinfo };
delete userinfo.picture;
// Act
await validate(tokenset, userinfo);
// Assert fetch should not be called and avatar should remain undefined or empty
const { user } = await validate(tokenset, userinfo);
expect(fetch).not.toHaveBeenCalled();
// Depending on your implementation, user.avatar may be undefined or an empty string.
expect(user.avatar).toBeFalsy();
});
it('should map customOpenIdData as an object when OPENID_CUSTOM_DATA is set', async () => {
process.env.OPENID_CUSTOM_DATA = 'some,fields';
process.env.OPENID_PROVIDER = 'microsoft';
const fakeCustomData = { foo: 'bar' };
const fakeDataMapper = { mapCustomData: jest.fn().mockResolvedValue(fakeCustomData) };
OpenIdDataMapper.getMapper = jest.fn(() => fakeDataMapper);
const userinfo = { ...baseUserinfo };
const { user } = await validate(tokenset, userinfo);
expect(OpenIdDataMapper.getMapper).toHaveBeenCalledWith('microsoft');
expect(fakeDataMapper.mapCustomData).toHaveBeenCalledWith(tokenset.access_token, 'some,fields');
expect(user.customOpenIdData).toEqual({ ...fakeCustomData, roles: ['requiredRole'] });
});
});

110
package-lock.json generated
View File

@@ -66,6 +66,7 @@
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.317",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",
@@ -108,7 +109,7 @@
"ollama": "^0.5.0",
"openai": "^4.96.2",
"openai-chat-tokens": "^0.2.8",
"openid-client": "^5.4.2",
"openid-client": "^5.7.1",
"passport": "^0.6.0",
"passport-apple": "^2.0.2",
"passport-discord": "^0.1.4",
@@ -814,6 +815,15 @@
"node": ">= 14"
}
},
"api/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"api/node_modules/keyv-file": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.1.2.tgz",
@@ -829,6 +839,18 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
},
"api/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"api/node_modules/mongodb": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.14.2.tgz",
@@ -993,6 +1015,21 @@
}
}
},
"api/node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"api/node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
@@ -1062,6 +1099,12 @@
"webidl-conversions": "^3.0.0"
}
},
"api/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"client": {
"name": "@librechat/frontend",
"version": "v0.7.8",
@@ -19959,6 +20002,33 @@
"json-buffer": "3.0.1"
}
},
"node_modules/@microsoft/microsoft-graph-client": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.7.tgz",
"integrity": "sha512-/AazAV/F+HK4LIywF9C+NYHcJo038zEnWkteilcxC1FM/uK/4NVGDKGrxx7nNq1ybspAroRKT4I1FHfxQzxkUw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"tslib": "^2.2.0"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependenciesMeta": {
"@azure/identity": {
"optional": true
},
"@azure/msal-browser": {
"optional": true
},
"buffer": {
"optional": true
},
"stream-browserify": {
"optional": true
}
}
},
"node_modules/@mistralai/mistralai": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.5.2.tgz",
@@ -33542,14 +33612,6 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jose": {
"version": "4.15.5",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz",
"integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-base64": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz",
@@ -37053,36 +37115,6 @@
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="
},
"node_modules/openid-client": {
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.4.tgz",
"integrity": "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA==",
"dependencies": {
"jose": "^4.15.4",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/optionator": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",

View File

@@ -128,6 +128,7 @@ export type TUser = {
backupCodes?: TBackupCode[];
createdAt: string;
updatedAt: string;
customOpenIdData?: Record<string, unknown>;
};
export type TGetConversationsResponse = {

View File

@@ -13,6 +13,7 @@ export interface IUser extends Document {
googleId?: string;
facebookId?: string;
openidId?: string;
customOpenIdData?: Record<string, unknown>;
ldapId?: string;
githubId?: string;
discordId?: string;
@@ -112,6 +113,10 @@ const User = new Schema<IUser>(
unique: true,
sparse: true,
},
customOpenIdData: {
type: Schema.Types.Mixed,
default: undefined,
},
ldapId: {
type: String,
unique: true,