Compare commits

...

5 Commits

Author SHA1 Message Date
Cha
5b8b1c78da Remove debug logs 2025-06-17 18:30:04 +08:00
Cha
02b9c9d447 Merge branch 'main' into refactor/package-auth 2025-06-17 18:26:25 +08:00
Cha
49490984d1 Update package.json to includ auth package 2025-06-17 07:20:33 +08:00
Cha
f68be4727c - Move auth strategies to package/auth
- Move email and avatar functions to package/auth
2025-06-16 20:24:26 +08:00
Cha
e77aa92a7b Move AuthService to package-auth 2025-06-03 20:48:50 +08:00
75 changed files with 5991 additions and 9647 deletions

View File

@@ -1,4 +1,6 @@
/** @type {import('jest').Config} */
module.exports = {
displayName: 'default',
testEnvironment: 'node',
clearMocks: true,
roots: ['<rootDir>'],
@@ -11,7 +13,7 @@ module.exports = {
moduleNameMapper: {
'~/(.*)': '<rootDir>/$1',
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js', // Mock for the passport strategy part
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js',
'^openid-client$': '<rootDir>/test/__mocks__/openid-client.js',
},
transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'],

View File

@@ -49,6 +49,7 @@
"@langchain/google-vertexai": "^0.2.9",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.38",
"@librechat/auth": "*",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",
@@ -120,6 +121,7 @@
"jest": "^29.7.0",
"mongodb-memory-server": "^10.1.3",
"nodemon": "^3.0.3",
"supertest": "^7.1.0"
"supertest": "^7.1.0",
"ts-jest": "^29.4.0"
}
}
}

View File

@@ -1,21 +1,24 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const {
registerUser,
requestPasswordReset,
resetPassword,
setAuthTokens,
requestPasswordReset,
setOpenIDAuthTokens,
} = require('~/server/services/AuthService');
} = require('@librechat/auth');
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
const { getOpenIdConfig } = require('~/strategies');
const { getOpenIdConfig } = require('@librechat/auth');
const { isEnabled } = require('~/server/utils');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { getBalanceConfig } = require('~/server/services/Config');
const registrationController = async (req, res) => {
try {
const response = await registerUser(req.body);
const isEmailDomAllowed = await isEmailDomainAllowed(req.body.email);
const balanceConfig = await getBalanceConfig();
const response = await registerUser(req.body, {}, isEmailDomAllowed, balanceConfig);
const { status, message } = response;
res.status(status).send({ message });
} catch (err) {
@@ -65,9 +68,11 @@ const refreshController = async (req, res) => {
if (!refreshToken) {
return res.status(200).send('Refresh token not provided');
}
if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
try {
const openIdConfig = getOpenIdConfig();
const openIdClient = await import('openid-client');
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
const claims = tokenset.claims();
const user = await findUser({ email: claims.email });

View File

@@ -17,7 +17,7 @@ const {
} = require('~/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { verifyEmail, resendVerificationEmail } = require('@librechat/auth');
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { Transaction, Balance, User } = require('~/db/models');

View File

@@ -18,7 +18,7 @@ const {
} = require('~/models/Agent');
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { resizeAvatar } = require('@librechat/auth');
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { updateAction, getActions } = require('~/models/Action');
const { updateAgentProjects } = require('~/models/Agent');

View File

@@ -1,5 +1,5 @@
const { generate2FATempToken } = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const { setAuthTokens } = require('@librechat/auth');
const { logger } = require('~/config');
const loginController = async (req, res) => {

View File

@@ -1,6 +1,6 @@
const cookies = require('cookie');
const { getOpenIdConfig } = require('~/strategies');
const { logoutUser } = require('~/server/services/AuthService');
const { getOpenIdConfig } = require('@librechat/auth');
const { logoutUser } = require('@librechat/auth');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');

View File

@@ -5,7 +5,7 @@ const {
getTOTPSecret,
verifyBackupCode,
} = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const { setAuthTokens } = require('@librechat/auth');
const { getUserById } = require('~/models');
/**

View File

@@ -11,9 +11,8 @@ const fs = require('fs');
const cookieParser = require('cookie-parser');
const { connectDb, indexSync } = require('~/db');
const { jwtLogin, passportLogin } = require('~/strategies');
const { initAuth, passportLogin, ldapLogin, jwtLogin } = require('@librechat/auth');
const { isEnabled } = require('~/server/utils');
const { ldapLogin } = require('~/strategies');
const { logger } = require('~/config');
const validateImageRequest = require('./middleware/validateImageRequest');
const errorController = require('./controllers/ErrorController');
@@ -22,6 +21,9 @@ const AppService = require('./services/AppService');
const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex');
const routes = require('./routes');
const { getBalanceConfig } = require('./services/Config');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { FileSources } = require('librechat-data-provider');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
@@ -36,7 +38,12 @@ const startServer = async () => {
if (typeof Bun !== 'undefined') {
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
}
await connectDb();
const mongooseInstance = await connectDb();
const balanceConfig = await getBalanceConfig();
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER ?? FileSources.local);
// initialize the auth package
initAuth(mongooseInstance, balanceConfig, saveBuffer);
logger.info('Connected to MongoDB');
await indexSync();

View File

@@ -1,7 +1,6 @@
const fs = require('fs').promises;
const express = require('express');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { getAvatarProcessFunction, resizeAvatar } = require('@librechat/auth');
const { filterFile } = require('~/server/services/Files/process');
const { logger } = require('~/config');
@@ -26,7 +25,7 @@ router.post('/', async (req, res) => {
desiredFormat,
});
const { processAvatar } = getStrategyFunctions(fileStrategy);
const processAvatar = getAvatarProcessFunction(fileStrategy);
const url = await processAvatar({ buffer: resizedBuffer, userId, manual });
res.json({ url });

View File

@@ -1,7 +1,6 @@
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
const express = require('express');
const passport = require('passport');
const { randomState } = require('openid-client');
const {
checkBan,
logHeaders,
@@ -9,7 +8,7 @@ const {
setBalanceConfig,
checkDomainAllowed,
} = require('~/server/middleware');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { setAuthTokens, setOpenIDAuthTokens } = require('@librechat/auth');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
@@ -104,7 +103,8 @@ router.get(
/**
* OpenID Routes
*/
router.get('/openid', (req, res, next) => {
router.get('/openid', async (req, res, next) => {
const { randomState } = await import('openid-client');
return passport.authenticate('openid', {
session: false,
state: randomState(),

View File

@@ -1,511 +0,0 @@
const bcrypt = require('bcryptjs');
const { webcrypto } = require('node:crypto');
const { SystemRoles, errorsToString } = require('librechat-data-provider');
const {
findUser,
createUser,
updateUser,
findToken,
countUsers,
getUserById,
findSession,
createToken,
deleteTokens,
deleteSession,
createSession,
generateToken,
deleteUserById,
generateRefreshToken,
} = require('~/models');
const { isEnabled, checkEmailConfig, sendEmail } = require('~/server/utils');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { getBalanceConfig } = require('~/server/services/Config');
const { registerSchema } = require('~/strategies/validators');
const { logger } = require('~/config');
const domains = {
client: process.env.DOMAIN_CLIENT,
server: process.env.DOMAIN_SERVER,
};
const isProduction = process.env.NODE_ENV === 'production';
const genericVerificationMessage = 'Please check your email to verify your email address.';
/**
* Logout user
*
* @param {ServerRequest} req
* @param {string} refreshToken
* @returns
*/
const logoutUser = async (req, refreshToken) => {
try {
const userId = req.user._id;
const session = await findSession({ userId: userId, refreshToken });
if (session) {
try {
await deleteSession({ sessionId: session._id });
} catch (deleteErr) {
logger.error('[logoutUser] Failed to delete session.', deleteErr);
return { status: 500, message: 'Failed to delete session.' };
}
}
try {
req.session.destroy();
} catch (destroyErr) {
logger.debug('[logoutUser] Failed to destroy session.', destroyErr);
}
return { status: 200, message: 'Logout successful' };
} catch (err) {
return { status: 500, message: err.message };
}
};
/**
* Creates Token and corresponding Hash for verification
* @returns {[string, string]}
*/
const createTokenHash = () => {
const token = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString('hex');
const hash = bcrypt.hashSync(token, 10);
return [token, hash];
};
/**
* Send Verification Email
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
* @returns {Promise<void>}
*/
const sendVerificationEmail = async (user) => {
const [verifyToken, hash] = createTokenHash();
const verificationLink = `${
domains.client
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
await sendEmail({
email: user.email,
subject: 'Verify your email',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
template: 'verifyEmail.handlebars',
});
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
};
/**
* Verify Email
* @param {Express.Request} req
*/
const verifyEmail = async (req) => {
const { email, token } = req.body;
const decodedEmail = decodeURIComponent(email);
const user = await findUser({ email: decodedEmail }, 'email _id emailVerified');
if (!user) {
logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`);
return new Error('User not found');
}
if (user.emailVerified) {
logger.info(`[verifyEmail] Email already verified [Email: ${decodedEmail}]`);
return { message: 'Email already verified', status: 'success' };
}
let emailVerificationData = await findToken({ email: decodedEmail });
if (!emailVerificationData) {
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`);
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, emailVerificationData.token);
if (!isValid) {
logger.warn(
`[verifyEmail] [Invalid or expired email verification token] [Email: ${decodedEmail}]`,
);
return new Error('Invalid or expired email verification token');
}
const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true });
if (!updatedUser) {
logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`);
return new Error('Failed to update user verification status');
}
await deleteTokens({ token: emailVerificationData.token });
logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`);
return { message: 'Email verification was successful', status: 'success' };
};
/**
* Register a new user.
* @param {MongoUser} user <email, password, name, username>
* @param {Partial<MongoUser>} [additionalData={}]
* @returns {Promise<{status: number, message: string, user?: MongoUser}>}
*/
const registerUser = async (user, additionalData = {}) => {
const { error } = registerSchema.safeParse(user);
if (error) {
const errorMessage = errorsToString(error.errors);
logger.info(
'Route: register - Validation Error',
{ name: 'Request params:', value: user },
{ name: 'Validation error:', value: errorMessage },
);
return { status: 404, message: errorMessage };
}
const { email, password, name, username } = user;
let newUserId;
try {
const existingUser = await findUser({ email }, 'email _id');
if (existingUser) {
logger.info(
'Register User - Email in use',
{ name: 'Request params:', value: user },
{ name: 'Existing user:', value: existingUser },
);
// Sleep for 1 second
await new Promise((resolve) => setTimeout(resolve, 1000));
return { status: 200, message: genericVerificationMessage };
}
if (!(await isEmailDomainAllowed(email))) {
const errorMessage =
'The email address provided cannot be used. Please use a different email address.';
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
return { status: 403, message: errorMessage };
}
//determine if this is the first registered user (not counting anonymous_user)
const isFirstRegisteredUser = (await countUsers()) === 0;
const salt = bcrypt.genSaltSync(10);
const newUserData = {
provider: 'local',
email,
username,
name,
avatar: null,
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
password: bcrypt.hashSync(password, salt),
...additionalData,
};
const emailEnabled = checkEmailConfig();
const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN);
const balanceConfig = await getBalanceConfig();
const newUser = await createUser(newUserData, balanceConfig, disableTTL, true);
newUserId = newUser._id;
if (emailEnabled && !newUser.emailVerified) {
await sendVerificationEmail({
_id: newUserId,
email,
name,
});
} else {
await updateUser(newUserId, { emailVerified: true });
}
return { status: 200, message: genericVerificationMessage };
} catch (err) {
logger.error('[registerUser] Error in registering user:', err);
if (newUserId) {
const result = await deleteUserById(newUserId);
logger.warn(
`[registerUser] [Email: ${email}] [Temporary User deleted: ${JSON.stringify(result)}]`,
);
}
return { status: 500, message: 'Something went wrong' };
}
};
/**
* Request password reset
* @param {Express.Request} req
*/
const requestPasswordReset = async (req) => {
const { email } = req.body;
const user = await findUser({ email }, 'email _id');
const emailEnabled = checkEmailConfig();
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);
if (!user) {
logger.warn(`[requestPasswordReset] [No user found] [Email: ${email}] [IP: ${req.ip}]`);
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
}
await deleteTokens({ userId: user._id });
const [resetToken, hash] = createTokenHash();
await createToken({
userId: user._id,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`;
if (emailEnabled) {
await sendEmail({
email: user.email,
subject: 'Password Reset Request',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
link: link,
year: new Date().getFullYear(),
},
template: 'requestPasswordReset.handlebars',
});
logger.info(
`[requestPasswordReset] Link emailed. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
);
} else {
logger.info(
`[requestPasswordReset] Link issued. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
);
return { link };
}
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
};
/**
* Reset Password
*
* @param {*} userId
* @param {String} token
* @param {String} password
* @returns
*/
const resetPassword = async (userId, token, password) => {
let passwordResetToken = await findToken({
userId,
});
if (!passwordResetToken) {
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, passwordResetToken.token);
if (!isValid) {
return new Error('Invalid or expired password reset token');
}
const hash = bcrypt.hashSync(password, 10);
const user = await updateUser(userId, { password: hash });
if (checkEmailConfig()) {
await sendEmail({
email: user.email,
subject: 'Password Reset Successfully',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
year: new Date().getFullYear(),
},
template: 'passwordReset.handlebars',
});
}
await deleteTokens({ token: passwordResetToken.token });
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
return { message: 'Password reset was successful' };
};
/**
* Set Auth Tokens
*
* @param {String | ObjectId} userId
* @param {Object} res
* @param {String} sessionId
* @returns
*/
const setAuthTokens = async (userId, res, sessionId = null) => {
try {
const user = await getUserById(userId);
const token = await generateToken(user);
let session;
let refreshToken;
let refreshTokenExpires;
if (sessionId) {
session = await findSession({ sessionId: sessionId }, { lean: false });
refreshTokenExpires = session.expiration.getTime();
refreshToken = await generateRefreshToken(session);
} else {
const result = await createSession(userId);
session = result.session;
refreshToken = result.refreshToken;
refreshTokenExpires = session.expiration.getTime();
}
res.cookie('refreshToken', refreshToken, {
expires: new Date(refreshTokenExpires),
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
res.cookie('token_provider', 'librechat', {
expires: new Date(refreshTokenExpires),
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
return token;
} catch (error) {
logger.error('[setAuthTokens] Error in setting authentication tokens:', error);
throw error;
}
};
/**
* @function setOpenIDAuthTokens
* Set OpenID Authentication Tokens
* //type tokenset from openid-client
* @param {import('openid-client').TokenEndpointResponse & import('openid-client').TokenEndpointResponseHelpers} tokenset
* - The tokenset object containing access and refresh tokens
* @param {Object} res - response object
* @returns {String} - access token
*/
const setOpenIDAuthTokens = (tokenset, res) => {
try {
if (!tokenset) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return;
}
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
const expiryInMilliseconds = REFRESH_TOKEN_EXPIRY
? eval(REFRESH_TOKEN_EXPIRY)
: 1000 * 60 * 60 * 24 * 7; // 7 days default
const expirationDate = new Date(Date.now() + expiryInMilliseconds);
if (tokenset == null) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return;
}
if (!tokenset.access_token || !tokenset.refresh_token) {
logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset');
return;
}
res.cookie('refreshToken', tokenset.refresh_token, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
res.cookie('token_provider', 'openid', {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
return tokenset.access_token;
} catch (error) {
logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error);
throw error;
}
};
/**
* Resend Verification Email
* @param {Object} req
* @param {Object} req.body
* @param {String} req.body.email
* @returns {Promise<{status: number, message: string}>}
*/
const resendVerificationEmail = async (req) => {
try {
const { email } = req.body;
await deleteTokens(email);
const user = await findUser({ email }, 'email _id name');
if (!user) {
logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`);
return { status: 200, message: genericVerificationMessage };
}
const [verifyToken, hash] = createTokenHash();
const verificationLink = `${
domains.client
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
await sendEmail({
email: user.email,
subject: 'Verify your email',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
template: 'verifyEmail.handlebars',
});
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
return {
status: 200,
message: genericVerificationMessage,
};
} catch (error) {
logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`);
return {
status: 500,
message: 'Something went wrong.',
};
}
};
module.exports = {
logoutUser,
verifyEmail,
registerUser,
setAuthTokens,
resetPassword,
requestPasswordReset,
resendVerificationEmail,
setOpenIDAuthTokens,
};

View File

@@ -1,86 +0,0 @@
const sharp = require('sharp');
const fs = require('fs').promises;
const fetch = require('node-fetch');
const { EImageOutputType } = require('librechat-data-provider');
const { resizeAndConvert } = require('./resize');
const { logger } = require('~/config');
/**
* Uploads an avatar image for a user. This function can handle various types of input (URL, Buffer, or File object),
* processes the image to a square format, converts it to target format, and returns the resized buffer.
*
* @param {Object} params - The parameters object.
* @param {string} params.userId - The unique identifier of the user for whom the avatar is being uploaded.
* @param {string} options.desiredFormat - The desired output format of the image.
* @param {(string|Buffer|File)} params.input - The input representing the avatar image. Can be a URL (string),
* a Buffer, or a File object.
*
* @returns {Promise<any>}
* A promise that resolves to a resized buffer.
*
* @throws {Error} Throws an error if the user ID is undefined, the input type is invalid, the image fetching fails,
* or any other error occurs during the processing.
*/
async function resizeAvatar({ userId, input, desiredFormat = EImageOutputType.PNG }) {
try {
if (userId === undefined) {
throw new Error('User ID is undefined');
}
let imageBuffer;
if (typeof input === 'string') {
const response = await fetch(input);
if (!response.ok) {
throw new Error(`Failed to fetch image from URL. Status: ${response.status}`);
}
imageBuffer = await response.buffer();
} else if (input instanceof Buffer) {
imageBuffer = input;
} else if (typeof input === 'object' && input instanceof File) {
const fileContent = await fs.readFile(input.path);
imageBuffer = Buffer.from(fileContent);
} else {
throw new Error('Invalid input type. Expected URL, Buffer, or File.');
}
const metadata = await sharp(imageBuffer).metadata();
const { width, height } = metadata;
const minSize = Math.min(width, height);
if (metadata.format === 'gif') {
const resizedBuffer = await sharp(imageBuffer, { animated: true })
.extract({
left: Math.floor((width - minSize) / 2),
top: Math.floor((height - minSize) / 2),
width: minSize,
height: minSize,
})
.resize(250, 250)
.gif()
.toBuffer();
return resizedBuffer;
}
const squaredBuffer = await sharp(imageBuffer)
.extract({
left: Math.floor((width - minSize) / 2),
top: Math.floor((height - minSize) / 2),
width: minSize,
height: minSize,
})
.toBuffer();
const { buffer } = await resizeAndConvert({
inputBuffer: squaredBuffer,
desiredFormat,
});
return buffer;
} catch (error) {
logger.error('Error uploading the avatar:', error);
throw error;
}
}
module.exports = { resizeAvatar };

View File

@@ -1,4 +1,3 @@
const avatar = require('./avatar');
const convert = require('./convert');
const encode = require('./encode');
const parse = require('./parse');
@@ -9,5 +8,4 @@ module.exports = {
...encode,
...parse,
...resize,
avatar,
};

View File

@@ -89,28 +89,4 @@ async function resizeImageBuffer(inputBuffer, resolution, endpoint) {
};
}
/**
* Resizes an image buffer to a specified format and width.
*
* @param {Object} options - The options for resizing and converting the image.
* @param {Buffer} options.inputBuffer - The buffer of the image to be resized.
* @param {string} options.desiredFormat - The desired output format of the image.
* @param {number} [options.width=150] - The desired width of the image. Defaults to 150 pixels.
* @returns {Promise<{ buffer: Buffer, width: number, height: number, bytes: number }>} An object containing the resized image buffer, its size, and dimensions.
* @throws Will throw an error if the resolution or format parameters are invalid.
*/
async function resizeAndConvert({ inputBuffer, desiredFormat, width = 150 }) {
const resizedBuffer = await sharp(inputBuffer)
.resize({ width })
.toFormat(desiredFormat)
.toBuffer();
const resizedMetadata = await sharp(resizedBuffer).metadata();
return {
buffer: resizedBuffer,
width: resizedMetadata.width,
height: resizedMetadata.height,
bytes: Buffer.byteLength(resizedBuffer),
};
}
module.exports = { resizeImageBuffer, resizeAndConvert };
module.exports = { resizeImageBuffer };

View File

@@ -19,11 +19,8 @@ const {
isAssistantsEndpoint,
} = require('librechat-data-provider');
const { EnvVar } = require('@librechat/agents');
const {
convertImage,
resizeAndConvert,
resizeImageBuffer,
} = require('~/server/services/Files/images');
const { convertImage, resizeImageBuffer } = require('~/server/services/Files/images');
const { resizeAndConvert } = require('@librechat/auth');
const { addResourceFileId, deleteResourceFileId } = require('~/server/controllers/assistants/v2');
const { addAgentResourceFile, removeAgentResourceFiles } = require('~/models/Agent');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');

View File

@@ -59,7 +59,6 @@ const firebaseStrategy = () => ({
deleteFile: deleteFirebaseFile,
saveBuffer: saveBufferToFirebase,
prepareImagePayload: prepareImageURL,
processAvatar: processFirebaseAvatar,
handleImageUpload: uploadImageToFirebase,
getDownloadStream: getFirebaseFileStream,
});
@@ -74,7 +73,6 @@ const localStrategy = () => ({
getFileURL: getLocalFileURL,
saveBuffer: saveLocalBuffer,
deleteFile: deleteLocalFile,
processAvatar: processLocalAvatar,
handleImageUpload: uploadLocalImage,
prepareImagePayload: prepareImagesLocal,
getDownloadStream: getLocalFileStream,
@@ -91,7 +89,6 @@ const s3Strategy = () => ({
deleteFile: deleteFileFromS3,
saveBuffer: saveBufferToS3,
prepareImagePayload: prepareImageURLS3,
processAvatar: processS3Avatar,
handleImageUpload: uploadImageToS3,
getDownloadStream: getS3FileStream,
});
@@ -107,7 +104,6 @@ const azureStrategy = () => ({
deleteFile: deleteFileFromAzure,
saveBuffer: saveBufferToAzure,
prepareImagePayload: prepareAzureImageURL,
processAvatar: processAzureAvatar,
handleImageUpload: uploadImageToAzure,
getDownloadStream: getAzureFileStream,
});
@@ -123,8 +119,6 @@ const vectorStrategy = () => ({
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */
@@ -147,8 +141,6 @@ const openAIStrategy = () => ({
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */
@@ -170,8 +162,6 @@ const codeOutputStrategy = () => ({
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */
@@ -189,8 +179,6 @@ const mistralOCRStrategy = () => ({
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */

View File

@@ -5,7 +5,8 @@ const {
conflictingAzureVariables,
extractVariableName,
} = require('librechat-data-provider');
const { isEnabled, checkEmailConfig } = require('~/server/utils');
const { isEnabled } = require('~/server/utils');
const { checkEmailConfig } = require('@librechat/auth');
const { logger } = require('~/config');
const secretDefaults = {

View File

@@ -5,14 +5,17 @@ const MemoryStore = require('memorystore')(session);
const RedisStore = require('connect-redis').default;
const {
setupOpenId,
getOpenIdConfig,
googleLogin,
githubLogin,
discordLogin,
facebookLogin,
appleLogin,
setupSaml,
samlLogin,
openIdJwtLogin,
} = require('~/strategies');
} = require('@librechat/auth');
const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const { logger } = require('~/config');
@@ -64,10 +67,14 @@ const configureSocialLogins = async (app) => {
}
app.use(session(sessionOptions));
app.use(passport.session());
const config = await setupOpenId();
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const openidLogin = await setupOpenId(tokensCache);
passport.use('openid', openidLogin);
if (isEnabled(process.env.OPENID_REUSE_TOKENS)) {
logger.info('OpenID token reuse is enabled.');
passport.use('openidJwt', openIdJwtLogin(config));
passport.use('openidJwt', openIdJwtLogin(getOpenIdConfig()));
}
logger.info('OpenID Connect configured.');
}
@@ -95,7 +102,8 @@ const configureSocialLogins = async (app) => {
}
app.use(session(sessionOptions));
app.use(passport.session());
setupSaml();
passport.use('saml', samlLogin());
logger.info('SAML Connect configured.');
}

View File

@@ -2,40 +2,17 @@ const streamResponse = require('./streamResponse');
const removePorts = require('./removePorts');
const countTokens = require('./countTokens');
const handleText = require('./handleText');
const sendEmail = require('./sendEmail');
const cryptoUtils = require('./crypto');
const queue = require('./queue');
const files = require('./files');
const math = require('./math');
/**
* Check if email configuration is set
* @returns {Boolean}
*/
function checkEmailConfig() {
// Check if Mailgun is configured
const hasMailgunConfig =
!!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM;
// Check if SMTP is configured
const hasSMTPConfig =
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM;
// Return true if either Mailgun or SMTP is properly configured
return hasMailgunConfig || hasSMTPConfig;
}
module.exports = {
...streamResponse,
checkEmailConfig,
...cryptoUtils,
...handleText,
countTokens,
removePorts,
sendEmail,
...files,
...queue,
math,

View File

@@ -1,173 +0,0 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
const nodemailer = require('nodemailer');
const handlebars = require('handlebars');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { isEnabled } = require('~/server/utils/handleText');
/**
* Sends an email using Mailgun API.
*
* @async
* @function sendEmailViaMailgun
* @param {Object} params - The parameters for sending the email.
* @param {string} params.to - The recipient's email address.
* @param {string} params.from - The sender's email address.
* @param {string} params.subject - The subject of the email.
* @param {string} params.html - The HTML content of the email.
* @returns {Promise<Object>} - A promise that resolves to the response from Mailgun API.
*/
const sendEmailViaMailgun = async ({ to, from, subject, html }) => {
const mailgunApiKey = process.env.MAILGUN_API_KEY;
const mailgunDomain = process.env.MAILGUN_DOMAIN;
const mailgunHost = process.env.MAILGUN_HOST || 'https://api.mailgun.net';
if (!mailgunApiKey || !mailgunDomain) {
throw new Error('Mailgun API key and domain are required');
}
const formData = new FormData();
formData.append('from', from);
formData.append('to', to);
formData.append('subject', subject);
formData.append('html', html);
formData.append('o:tracking-clicks', 'no');
try {
const response = await axios.post(`${mailgunHost}/v3/${mailgunDomain}/messages`, formData, {
headers: {
...formData.getHeaders(),
Authorization: `Basic ${Buffer.from(`api:${mailgunApiKey}`).toString('base64')}`,
},
});
return response.data;
} catch (error) {
throw new Error(logAxiosError({ error, message: 'Failed to send email via Mailgun' }));
}
};
/**
* Sends an email using SMTP via Nodemailer.
*
* @async
* @function sendEmailViaSMTP
* @param {Object} params - The parameters for sending the email.
* @param {Object} params.transporterOptions - The transporter configuration options.
* @param {Object} params.mailOptions - The email options.
* @returns {Promise<Object>} - A promise that resolves to the info object of the sent email.
*/
const sendEmailViaSMTP = async ({ transporterOptions, mailOptions }) => {
const transporter = nodemailer.createTransport(transporterOptions);
return await transporter.sendMail(mailOptions);
};
/**
* Sends an email using the specified template, subject, and payload.
*
* @async
* @function sendEmail
* @param {Object} params - The parameters for sending the email.
* @param {string} params.email - The recipient's email address.
* @param {string} params.subject - The subject of the email.
* @param {Record<string, string>} params.payload - The data to be used in the email template.
* @param {string} params.template - The filename of the email template.
* @param {boolean} [throwError=true] - Whether to throw an error if the email sending process fails.
* @returns {Promise<Object>} - A promise that resolves to the info object of the sent email or the error if sending the email fails.
*
* @example
* const emailData = {
* email: 'recipient@example.com',
* subject: 'Welcome!',
* payload: { name: 'Recipient' },
* template: 'welcome.html'
* };
*
* sendEmail(emailData)
* .then(info => console.log('Email sent:', info))
* .catch(error => console.error('Error sending email:', error));
*
* @throws Will throw an error if the email sending process fails and throwError is `true`.
*/
const sendEmail = async ({ email, subject, payload, template, throwError = true }) => {
try {
// Read and compile the email template
const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8');
const compiledTemplate = handlebars.compile(source);
const html = compiledTemplate(payload);
// Prepare common email data
const fromName = process.env.EMAIL_FROM_NAME || process.env.APP_TITLE;
const fromEmail = process.env.EMAIL_FROM;
const fromAddress = `"${fromName}" <${fromEmail}>`;
const toAddress = `"${payload.name}" <${email}>`;
// Check if Mailgun is configured
if (process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN) {
logger.debug('[sendEmail] Using Mailgun provider');
return await sendEmailViaMailgun({
from: fromAddress,
to: toAddress,
subject: subject,
html: html,
});
}
// Default to SMTP
logger.debug('[sendEmail] Using SMTP provider');
const transporterOptions = {
// Use STARTTLS by default instead of obligatory TLS
secure: process.env.EMAIL_ENCRYPTION === 'tls',
// If explicit STARTTLS is set, require it when connecting
requireTls: process.env.EMAIL_ENCRYPTION === 'starttls',
tls: {
// Whether to accept unsigned certificates
rejectUnauthorized: !isEnabled(process.env.EMAIL_ALLOW_SELFSIGNED),
},
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD,
},
};
if (process.env.EMAIL_ENCRYPTION_HOSTNAME) {
// Check the certificate against this name explicitly
transporterOptions.tls.servername = process.env.EMAIL_ENCRYPTION_HOSTNAME;
}
// Mailer service definition has precedence
if (process.env.EMAIL_SERVICE) {
transporterOptions.service = process.env.EMAIL_SERVICE;
} else {
transporterOptions.host = process.env.EMAIL_HOST;
transporterOptions.port = process.env.EMAIL_PORT ?? 25;
}
const mailOptions = {
// Header address should contain name-addr
from: fromAddress,
to: toAddress,
envelope: {
// Envelope from should contain addr-spec
// Mistake in the Nodemailer documentation?
from: fromEmail,
to: email,
},
subject: subject,
html: html,
};
return await sendEmailViaSMTP({ transporterOptions, mailOptions });
} catch (error) {
if (throwError) {
throw error;
}
logger.error('[sendEmail]', error);
return error;
}
};
module.exports = sendEmail;

View File

@@ -1,26 +0,0 @@
const FacebookStrategy = require('passport-facebook').Strategy;
const socialLogin = require('./socialLogin');
const getProfileDetails = ({ profile }) => ({
email: profile.emails[0]?.value,
id: profile.id,
avatarUrl: profile.photos[0]?.value,
username: profile.displayName,
name: profile.name?.givenName + ' ' + profile.name?.familyName,
emailVerified: true,
});
const facebookLogin = socialLogin('facebook', getProfileDetails);
module.exports = () =>
new FacebookStrategy(
{
clientID: process.env.FACEBOOK_CLIENT_ID,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.FACEBOOK_CALLBACK_URL}`,
proxy: true,
scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
},
facebookLogin,
);

View File

@@ -1,26 +0,0 @@
const appleLogin = require('./appleStrategy');
const passportLogin = require('./localStrategy');
const googleLogin = require('./googleStrategy');
const githubLogin = require('./githubStrategy');
const discordLogin = require('./discordStrategy');
const facebookLogin = require('./facebookStrategy');
const { setupOpenId, getOpenIdConfig } = require('./openidStrategy');
const jwtLogin = require('./jwtStrategy');
const ldapLogin = require('./ldapStrategy');
const { setupSaml } = require('./samlStrategy');
const openIdJwtLogin = require('./openIdJwtStrategy');
module.exports = {
appleLogin,
passportLogin,
googleLogin,
githubLogin,
discordLogin,
jwtLogin,
facebookLogin,
setupOpenId,
getOpenIdConfig,
ldapLogin,
setupSaml,
openIdJwtLogin,
};

View File

@@ -1,148 +0,0 @@
const fs = require('fs');
const LdapStrategy = require('passport-ldapauth');
const { SystemRoles } = require('librechat-data-provider');
const { logger } = require('@librechat/data-schemas');
const { createUser, findUser, updateUser, countUsers } = require('~/models');
const { getBalanceConfig } = require('~/server/services/Config');
const { isEnabled } = require('~/server/utils');
const {
LDAP_URL,
LDAP_BIND_DN,
LDAP_BIND_CREDENTIALS,
LDAP_USER_SEARCH_BASE,
LDAP_SEARCH_FILTER,
LDAP_CA_CERT_PATH,
LDAP_FULL_NAME,
LDAP_ID,
LDAP_USERNAME,
LDAP_EMAIL,
LDAP_TLS_REJECT_UNAUTHORIZED,
LDAP_STARTTLS,
} = process.env;
// Check required environment variables
if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) {
module.exports = null;
}
const searchAttributes = [
'displayName',
'mail',
'uid',
'cn',
'name',
'commonname',
'givenName',
'sn',
'sAMAccountName',
];
if (LDAP_FULL_NAME) {
searchAttributes.push(...LDAP_FULL_NAME.split(','));
}
if (LDAP_ID) {
searchAttributes.push(LDAP_ID);
}
if (LDAP_USERNAME) {
searchAttributes.push(LDAP_USERNAME);
}
if (LDAP_EMAIL) {
searchAttributes.push(LDAP_EMAIL);
}
const rejectUnauthorized = isEnabled(LDAP_TLS_REJECT_UNAUTHORIZED);
const startTLS = isEnabled(LDAP_STARTTLS);
const ldapOptions = {
server: {
url: LDAP_URL,
bindDN: LDAP_BIND_DN,
bindCredentials: LDAP_BIND_CREDENTIALS,
searchBase: LDAP_USER_SEARCH_BASE,
searchFilter: LDAP_SEARCH_FILTER || 'mail={{username}}',
searchAttributes: [...new Set(searchAttributes)],
...(LDAP_CA_CERT_PATH && {
tlsOptions: {
rejectUnauthorized,
ca: (() => {
try {
return [fs.readFileSync(LDAP_CA_CERT_PATH)];
} catch (err) {
logger.error('[ldapStrategy]', 'Failed to read CA certificate', err);
throw err;
}
})(),
},
}),
...(startTLS && { starttls: true }),
},
usernameField: 'email',
passwordField: 'password',
};
const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
if (!userinfo) {
return done(null, false, { message: 'Invalid credentials' });
}
try {
const ldapId =
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
let user = await findUser({ ldapId });
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
const fullName =
fullNameAttributes && fullNameAttributes.length > 0
? fullNameAttributes.map((attr) => userinfo[attr]).join(' ')
: userinfo.cn || userinfo.name || userinfo.commonname || userinfo.displayName;
const username =
(LDAP_USERNAME && userinfo[LDAP_USERNAME]) || userinfo.givenName || userinfo.mail;
const mail = (LDAP_EMAIL && userinfo[LDAP_EMAIL]) || userinfo.mail || username + '@ldap.local';
if (!userinfo.mail && !(LDAP_EMAIL && userinfo[LDAP_EMAIL])) {
logger.warn(
'[ldapStrategy]',
`No valid email attribute found in LDAP userinfo. Using fallback email: ${username}@ldap.local`,
`LDAP_EMAIL env var: ${LDAP_EMAIL || 'not set'}`,
`Available userinfo attributes: ${Object.keys(userinfo).join(', ')}`,
'Full userinfo:',
JSON.stringify(userinfo, null, 2),
);
}
if (!user) {
const isFirstRegisteredUser = (await countUsers()) === 0;
user = {
provider: 'ldap',
ldapId,
username,
email: mail,
emailVerified: true, // The ldap server administrator should verify the email
name: fullName,
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
};
const balanceConfig = await getBalanceConfig();
const userId = await createUser(user, balanceConfig);
user._id = userId;
} else {
// Users registered in LDAP are assumed to have their user information managed in LDAP,
// so update the user information with the values registered in LDAP
user.provider = 'ldap';
user.ldapId = ldapId;
user.email = mail;
user.username = username;
user.name = fullName;
}
user = await updateUser(user._id, user);
done(null, user);
} catch (err) {
logger.error('[ldapStrategy]', err);
done(err);
}
});
module.exports = ldapLogin;

View File

@@ -1,277 +0,0 @@
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const passport = require('passport');
const { hashToken, logger } = require('@librechat/data-schemas');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models');
const { getBalanceConfig } = require('~/server/services/Config');
const paths = require('~/config/paths');
let crypto;
try {
crypto = require('node:crypto');
} catch (err) {
logger.error('[samlStrategy] crypto support is disabled!', err);
}
/**
* Retrieves the certificate content from the given value.
*
* This function determines whether the provided value is a certificate string (RFC7468 format or
* base64-encoded without a header) or a valid file path. If the value matches one of these formats,
* the certificate content is returned. Otherwise, an error is thrown.
*
* @see https://github.com/node-saml/node-saml/tree/master?tab=readme-ov-file#configuration-option-idpcert
* @param {string} value - The certificate string or file path.
* @returns {string} The certificate content if valid.
* @throws {Error} If the value is not a valid certificate string or file path.
*/
function getCertificateContent(value) {
if (typeof value !== 'string') {
throw new Error('Invalid input: SAML_CERT must be a string.');
}
// Check if it's an RFC7468 formatted PEM certificate
const pemRegex = new RegExp(
'-----BEGIN (CERTIFICATE|PUBLIC KEY)-----\n' + // header
'([A-Za-z0-9+/=]{64}\n)+' + // base64 content (64 characters per line)
'[A-Za-z0-9+/=]{1,64}\n' + // base64 content (last line)
'-----END (CERTIFICATE|PUBLIC KEY)-----', // footer
);
if (pemRegex.test(value)) {
logger.info('[samlStrategy] Detected RFC7468-formatted certificate string.');
return value;
}
// Check if it's a Base64-encoded certificate (no header)
if (/^[A-Za-z0-9+/=]+$/.test(value) && value.length % 4 === 0) {
logger.info('[samlStrategy] Detected base64-encoded certificate string (no header).');
return value;
}
// Check if file exists and is readable
const certPath = path.normalize(path.isAbsolute(value) ? value : path.join(paths.root, value));
if (fs.existsSync(certPath) && fs.statSync(certPath).isFile()) {
try {
logger.info(`[samlStrategy] Loading certificate from file: ${certPath}`);
return fs.readFileSync(certPath, 'utf8').trim();
} catch (error) {
throw new Error(`Error reading certificate file: ${error.message}`);
}
}
throw new Error('Invalid cert: SAML_CERT must be a valid file path or certificate string.');
}
/**
* Retrieves a SAML claim from a profile object based on environment configuration.
* @param {object} profile - Saml profile
* @param {string} envVar - Environment variable name (SAML_*)
* @param {string} defaultKey - Default key to use if the environment variable is not set
* @returns {string}
*/
function getSamlClaim(profile, envVar, defaultKey) {
const claimKey = process.env[envVar];
// Avoids accessing `profile[""]` when the environment variable is empty string.
if (claimKey) {
return profile[claimKey] ?? profile[defaultKey];
}
return profile[defaultKey];
}
function getEmail(profile) {
return getSamlClaim(profile, 'SAML_EMAIL_CLAIM', 'email');
}
function getUserName(profile) {
return getSamlClaim(profile, 'SAML_USERNAME_CLAIM', 'username');
}
function getGivenName(profile) {
return getSamlClaim(profile, 'SAML_GIVEN_NAME_CLAIM', 'given_name');
}
function getFamilyName(profile) {
return getSamlClaim(profile, 'SAML_FAMILY_NAME_CLAIM', 'family_name');
}
function getPicture(profile) {
return getSamlClaim(profile, 'SAML_PICTURE_CLAIM', 'picture');
}
/**
* Downloads an image from a URL using an access token.
* @param {string} url
* @returns {Promise<Buffer>}
*/
const downloadImage = async (url) => {
try {
const response = await fetch(url);
if (response.ok) {
return await response.buffer();
} else {
throw new Error(`${response.statusText} (HTTP ${response.status})`);
}
} catch (error) {
logger.error(`[samlStrategy] Error downloading image at URL "${url}": ${error}`);
return null;
}
};
/**
* Determines the full name of a user based on SAML profile and environment configuration.
*
* @param {Object} profile - The user profile object from SAML Connect
* @returns {string} The determined full name of the user
*/
function getFullName(profile) {
if (process.env.SAML_NAME_CLAIM) {
logger.info(
`[samlStrategy] Using SAML_NAME_CLAIM: ${process.env.SAML_NAME_CLAIM}, profile: ${profile[process.env.SAML_NAME_CLAIM]}`,
);
return profile[process.env.SAML_NAME_CLAIM];
}
const givenName = getGivenName(profile);
const familyName = getFamilyName(profile);
if (givenName && familyName) {
return `${givenName} ${familyName}`;
}
if (givenName) {
return givenName;
}
if (familyName) {
return familyName;
}
return getUserName(profile) || getEmail(profile);
}
/**
* Converts an input into a string suitable for a username.
* If the input is a string, it will be returned as is.
* If the input is an array, elements will be joined with underscores.
* In case of undefined or other falsy values, a default value will be returned.
*
* @param {string | string[] | undefined} input - The input value to be converted into a username.
* @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 = '') {
if (typeof input === 'string') {
return input;
} else if (Array.isArray(input)) {
return input.join('_');
}
return defaultValue;
}
async function setupSaml() {
try {
const samlConfig = {
entryPoint: process.env.SAML_ENTRY_POINT,
issuer: process.env.SAML_ISSUER,
callbackUrl: process.env.SAML_CALLBACK_URL,
idpCert: getCertificateContent(process.env.SAML_CERT),
wantAssertionsSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? false : true,
wantAuthnResponseSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? true : false,
};
passport.use(
'saml',
new SamlStrategy(samlConfig, async (profile, done) => {
try {
logger.info(`[samlStrategy] SAML authentication received for NameID: ${profile.nameID}`);
logger.debug('[samlStrategy] SAML profile:', profile);
let user = await findUser({ samlId: profile.nameID });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with SAML ID: ${profile.nameID}`,
);
if (!user) {
const email = getEmail(profile) || '';
user = await findUser({ email });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${profile.email}`,
);
}
const fullName = getFullName(profile);
const username = convertToUsername(
getUserName(profile) || getGivenName(profile) || getEmail(profile),
);
if (!user) {
user = {
provider: 'saml',
samlId: profile.nameID,
username,
email: getEmail(profile) || '',
emailVerified: true,
name: fullName,
};
const balanceConfig = await getBalanceConfig();
user = await createUser(user, balanceConfig, true, true);
} else {
user.provider = 'saml';
user.samlId = profile.nameID;
user.username = username;
user.name = fullName;
}
const picture = getPicture(profile);
if (picture && !user.avatar?.includes('manual=true')) {
const imageBuffer = await downloadImage(profile.picture);
if (imageBuffer) {
let fileName;
if (crypto) {
fileName = (await hashToken(profile.nameID)) + '.png';
} else {
fileName = profile.nameID + '.png';
}
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
buffer: imageBuffer,
});
user.avatar = imagePath ?? '';
}
}
user = await updateUser(user._id, user);
logger.info(
`[samlStrategy] Login success SAML ID: ${user.samlId} | email: ${user.email} | username: ${user.username}`,
{
user: {
samlId: user.samlId,
username: user.username,
email: user.email,
name: user.name,
},
},
);
done(null, user);
} catch (err) {
logger.error('[samlStrategy] Login failed', err);
done(err);
}
}),
);
} catch (err) {
logger.error('[samlStrategy]', err);
}
}
module.exports = { setupSaml, getCertificateContent };

View File

@@ -1,41 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { createSocialUser, handleExistingUser } = require('./process');
const { isEnabled } = require('~/server/utils');
const { findUser } = require('~/models');
const socialLogin =
(provider, getProfileDetails) => async (accessToken, refreshToken, idToken, profile, cb) => {
try {
const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails({
idToken,
profile,
});
const oldUser = await findUser({ email: email.trim() });
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
if (oldUser) {
await handleExistingUser(oldUser, avatarUrl);
return cb(null, oldUser);
}
if (ALLOW_SOCIAL_REGISTRATION) {
const newUser = await createSocialUser({
email,
avatarUrl,
provider,
providerKey: `${provider}Id`,
providerId: id,
username,
name,
emailVerified,
});
return cb(null, newUser);
}
} catch (err) {
logger.error(`[${provider}Login]`, err);
return cb(err);
}
};
module.exports = socialLogin;

View File

@@ -1,4 +1,5 @@
// api/test/__mocks__/openid-client.js
console.log('✅ MOCKED openid-client loaded');
module.exports = {
Issuer: {
discover: jest.fn().mockResolvedValue({

View File

@@ -2,7 +2,7 @@ const path = require('path');
const mongoose = require(path.resolve(__dirname, '..', 'api', 'node_modules', 'mongoose'));
const { User } = require('@librechat/data-schemas').createModels(mongoose);
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const { registerUser } = require('~/server/services/AuthService');
const { registerUser } = require('@librechat/auth');
const { askQuestion, silentExit } = require('./helpers');
const connect = require('./connect');
@@ -102,7 +102,9 @@ or the user will need to attempt logging in to have a verification link sent to
const user = { email, password, name, username, confirm_password: password };
let result;
try {
result = await registerUser(user, { emailVerified });
const isEmailDomAllowed = await isEmailDomAllowed(user.email);
const balanceConfig = await getBalanceConfig();
result = await registerUser(user, { emailVerified }, isEmailDomAllowed, balanceConfig);
} catch (error) {
console.red('Error: ' + error.message);
silentExit(1);

View File

@@ -2,7 +2,7 @@ const path = require('path');
const mongoose = require(path.resolve(__dirname, '..', 'api', 'node_modules', 'mongoose'));
const { User } = require('@librechat/data-schemas').createModels(mongoose);
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const { sendEmail, checkEmailConfig } = require('~/server/utils');
const { checkEmailConfig, sendEmail } = require('@librechat/auth');
const { askQuestion, silentExit } = require('./helpers');
const { createInvite } = require('~/models/inviteUser');
const connect = require('./connect');

View File

@@ -5,6 +5,7 @@ import {
deleteMessages,
deleteAllUserSessions,
} from '@librechat/backend/models';
import { createModels } from '@librechat/data-schemas';
type TUser = { email: string; password: string };
@@ -41,7 +42,7 @@ export default async function cleanupUser(user: TUser) {
await deleteAllUserSessions(userId.toString());
// Get models from the registered models
const { User, Balance, Transaction } = getModels();
const { User, Balance, Transaction } = createModels(db);
// Delete user, balance, and transactions using the registered models
await User.deleteMany({ _id: userId });

10589
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,10 +37,11 @@
"backend": "cross-env NODE_ENV=production node api/server/index.js",
"backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js",
"backend:stop": "node config/stop-backend.js",
"build:auth": "cd packages/auth && npm run build",
"build:data-provider": "cd packages/data-provider && npm run build",
"build:api": "cd packages/api && npm run build",
"build:data-schemas": "cd packages/data-schemas && npm run build",
"frontend": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && cd client && npm run build",
"frontend": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && npm run build:auth && cd client && npm run build",
"frontend:ci": "npm run build:data-provider && cd client && npm run build:ci",
"frontend:dev": "cd client && npm run dev",
"e2e": "playwright test --config=e2e/playwright.config.local.ts",
@@ -142,4 +143,4 @@
"packages/"
]
}
}
}

2
packages/auth/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
test_bundle/

21
packages/auth/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 LibreChat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

114
packages/auth/README.md Normal file
View File

@@ -0,0 +1,114 @@
# `@librechat/data-schemas`
Mongoose schemas and models for LibreChat. This package provides a comprehensive collection of Mongoose schemas used across the LibreChat project, enabling robust data modeling and validation for various entities such as actions, agents, messages, users, and more.
## Features
- **Modular Schemas:** Includes schemas for actions, agents, assistants, balance, banners, categories, conversation tags, conversations, files, keys, messages, plugin authentication, presets, projects, prompts, prompt groups, roles, sessions, shared links, tokens, tool calls, transactions, and users.
- **TypeScript Support:** Provides TypeScript definitions for type-safe development.
- **Ready for Mongoose Integration:** Easily integrate with Mongoose to create models and interact with your MongoDB database.
- **Flexible & Extensible:** Designed to support the evolving needs of LibreChat while being adaptable to other projects.
## Installation
Install the package via npm or yarn:
```bash
npm install @librechat/auth
```
Or with yarn:
```bash
yarn add @librechat/auth
```
## Usage
After installation, you can import and use the schemas in your project. For example, to create a Mongoose model for a user:
```js
import mongoose from 'mongoose';
import { userSchema } from '@librechat/data-schemas';
const UserModel = mongoose.model('User', userSchema);
// Now you can use UserModel to create, read, update, and delete user documents.
```
You can also import other schemas as needed:
```js
import { actionSchema, agentSchema, messageSchema } from '@librechat/data-schemas';
```
Each schema is designed to integrate seamlessly with Mongoose and provides indexes, timestamps, and validations tailored for LibreChats use cases.
## Development
This package uses Rollup and TypeScript for building and bundling.
### Available Scripts
- **Build:**
Cleans the `dist` directory and builds the package.
```bash
npm run build
```
- **Build Watch:**
Rebuilds automatically on file changes.
```bash
npm run build:watch
```
- **Test:**
Runs tests with coverage in watch mode.
```bash
npm run test
```
- **Test (CI):**
Runs tests with coverage for CI environments.
```bash
npm run test:ci
```
- **Verify:**
Runs tests in CI mode to verify code integrity.
```bash
npm run verify
```
- **Clean:**
Removes the `dist` directory.
```bash
npm run clean
```
For those using Bun, equivalent scripts are available:
- **Bun Clean:** `bun run b:clean`
- **Bun Build:** `bun run b:build`
## Repository & Issues
The source code is maintained on GitHub.
- **Repository:** [LibreChat Repository](https://github.com/danny-avila/LibreChat.git)
- **Issues & Bug Reports:** [LibreChat Issues](https://github.com/danny-avila/LibreChat/issues)
## License
This project is licensed under the [MIT License](LICENSE).
## Contributing
Contributions to improve and expand the data schemas are welcome. If you have suggestions, improvements, or bug fixes, please open an issue or submit a pull request on the [GitHub repository](https://github.com/danny-avila/LibreChat/issues).
For more detailed documentation on each schema and model, please refer to the source code or visit the [LibreChat website](https://librechat.ai).

View File

@@ -0,0 +1,4 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
plugins: ['babel-plugin-replace-ts-export-assignment'],
};

View File

@@ -0,0 +1,20 @@
export default {
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!<rootDir>/node_modules/'],
coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
coverageReporters: ['text', 'cobertura'],
testResultsProcessor: 'jest-junit',
moduleNameMapper: {
'^@src/(.*)$': '<rootDir>/src/$1',
'~/(.*)': '<rootDir>/src/$1',
},
// coverageThreshold: {
// global: {
// statements: 58,
// branches: 49,
// functions: 50,
// lines: 57,
// },
// },
restoreMocks: true,
testTimeout: 15000,
};

108
packages/auth/package.json Normal file
View File

@@ -0,0 +1,108 @@
{
"name": "@librechat/auth",
"version": "0.0.1",
"description": "Librechat auth functionality",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.es.js",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"import": "./dist/index.es.js",
"require": "./dist/index.cjs",
"types": "./dist/types/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"copy-templates": "mkdir -p dist/utils && cp -R src/utils/emails/* dist/utils",
"clean": "rimraf dist",
"build": "npm run clean && npm run copy-templates && rollup -c --silent --bundleConfigAsCjs",
"build:watch": "rollup -c -w",
"test": "jest --coverage --watch",
"test:ci": "jest --coverage --ci",
"verify": "npm run test:ci",
"b:clean": "bun run rimraf dist",
"b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs"
},
"repository": {
"type": "git",
"url": "git+https://github.com/danny-avila/LibreChat.git"
},
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/danny-avila/LibreChat/issues"
},
"homepage": "https://librechat.ai",
"devDependencies": {
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^25.0.2",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.1.0",
"@rollup/plugin-replace": "^5.0.5",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.2",
"@types/bcrypt": "^5.0.2",
"@types/diff": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.3.0",
"@types/passport-jwt": "^4.0.1",
"@types/traverse": "^0.6.37",
"jest": "^29.7.0",
"jest-junit": "^16.0.0",
"rimraf": "^5.0.1",
"rollup": "^4.22.4",
"rollup-plugin-generate-package-json": "^3.2.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-typescript2": "^0.35.0",
"ts-node": "^10.9.2",
"typescript": "^5.0.4"
},
"dependencies": {
"@librechat/api": "^1.2.3",
"@librechat/data-schemas": "^0.0.8",
"@node-saml/passport-saml": "^5.0.1",
"@types/nodemailer": "^6.4.17",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"crypto": "^1.0.1",
"form-data": "^4.0.3",
"handlebars": "^4.7.8",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"klona": "^2.0.6",
"mongodb-memory-server": "^10.1.4",
"mongoose": "^8.12.1",
"nodemailer": "^7.0.3",
"openid-client": "^6.5.0",
"passport": "^0.7.0",
"passport-apple": "^2.0.2",
"passport-discord": "^0.1.4",
"passport-facebook": "^3.0.0",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-ldapauth": "^3.0.1",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.8.0",
"sharp": "^0.33.5",
"traverse": "^0.6.11"
},
"peerDependencies": {
"keyv": "^5.3.2"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"keywords": [
"mongoose",
"schema",
"typescript",
"librechat"
]
}

View File

@@ -0,0 +1,40 @@
import json from '@rollup/plugin-json';
import typescript from '@rollup/plugin-typescript';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/index.es.js',
format: 'es',
sourcemap: true,
},
{
file: 'dist/index.cjs',
format: 'cjs',
sourcemap: true,
},
],
plugins: [
// Allow importing JSON files
json(),
// Automatically externalize peer dependencies
peerDepsExternal(),
// Resolve modules from node_modules
nodeResolve(),
// Convert CommonJS modules to ES6
commonjs(),
// Compile TypeScript files and generate type declarations
typescript({
tsconfig: './tsconfig.json',
declaration: true,
declarationDir: 'dist/types',
rootDir: 'src',
}),
],
// Do not bundle these external dependencies
external: ['mongoose', 'sharp'],
};

248
packages/auth/src/index.ts Normal file
View File

@@ -0,0 +1,248 @@
import { Request, Response } from 'express';
import { TokenEndpointResponse } from 'openid-client';
import { errorsToString, SystemRoles } from 'librechat-data-provider';
import bcrypt from 'bcryptjs';
import { IUser, logger } from '@librechat/data-schemas';
import { registerSchema } from './strategies/validators';
import { sendVerificationEmail } from './utils/email';
import { ObjectId } from 'mongoose';
import { initAuth, getMethods } from './initAuth';
import { AuthenticatedRequest, LogoutResponse } from './types';
import { checkEmailConfig, isEnabled } from './utils';
const genericVerificationMessage = 'Please check your email to verify your email address.';
/**
* Logout user
*
* @param req
* @param {string} refreshToken
* @returns
*/
const logoutUser = async (
req: AuthenticatedRequest,
refreshToken: string | null,
): Promise<LogoutResponse> => {
try {
const { findSession, deleteSession } = getMethods();
const userId: string | null = req.user?._id ?? null;
const session = await findSession({ userId: userId, refreshToken });
if (session) {
try {
await deleteSession({ sessionId: session._id });
} catch (deleteErr) {
logger.error('[logoutUser] Failed to delete session.', deleteErr);
return { status: 500, message: 'Failed to delete session.' };
}
}
try {
req.session?.destroy();
} catch (destroyErr) {
logger.debug('[logoutUser] Failed to destroy session.', destroyErr);
}
return { status: 200, message: 'Logout successful' };
} catch (err: any) {
return { status: 500, message: err.message };
}
};
/**
* Register a new user.
* @param {MongoUser} user <email, password, name, username>
* @param {Partial<MongoUser>} [additionalData={}]
* @returns {Promise<{status: number, message: string, user?: MongoUser}>}
*/
const registerUser = async (
user: IUser,
additionalData: Partial<IUser> = {},
isEmailDomainAllowed: boolean = true,
balanceConfig: Record<string, any>,
) => {
const { error } = registerSchema.safeParse(user);
const { findUser, countUsers, createUser, updateUser, deleteUserById } = getMethods();
if (error) {
const errorMessage = errorsToString(error.errors);
logger.info(
'Route: register - Validation Error',
{ name: 'Request params:', value: user },
{ name: 'Validation error:', value: errorMessage },
);
return { status: 404, message: errorMessage };
}
const { email, password, name, username } = user;
let newUserId;
try {
const existingUser = await findUser({ email }, 'email _id');
if (existingUser) {
logger.info(
'Register User - Email in use',
{ name: 'Request params:', value: user },
{ name: 'Existing user:', value: existingUser },
);
// Sleep for 1 second
await new Promise((resolve) => setTimeout(resolve, 1000));
return { status: 200, message: genericVerificationMessage };
}
if (!isEmailDomainAllowed) {
const errorMessage =
'The email address provided cannot be used. Please use a different email address.';
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
return { status: 403, message: errorMessage };
}
//determine if this is the first registered user (not counting anonymous_user)
const isFirstRegisteredUser = (await countUsers()) === 0;
const salt = bcrypt.genSaltSync(10);
const newUserData: Partial<IUser> = {
provider: 'local',
email,
username,
name,
avatar: '',
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
password: bcrypt.hashSync(password ?? '', salt),
...additionalData,
};
const emailEnabled = checkEmailConfig();
const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN ?? '');
const newUser = await createUser(newUserData, balanceConfig, disableTTL, true);
newUserId = newUser._id;
if (emailEnabled && !newUser.emailVerified) {
await sendVerificationEmail({
_id: newUserId,
email,
name,
});
} else {
await updateUser(newUserId, { emailVerified: true });
}
return { status: 200, message: genericVerificationMessage };
} catch (err) {
logger.error('[registerUser] Error in registering user:', err);
if (newUserId) {
const result = await deleteUserById(newUserId);
logger.warn(
`[registerUser] [Email: ${email}] [Temporary User deleted: ${JSON.stringify(result)}]`,
);
}
return { status: 500, message: 'Something went wrong' };
}
};
const isProduction = process.env.NODE_ENV === 'production';
/**
* Set Auth Tokens
*
* @param {String | ObjectId} userId
* @param {Object} res
* @param {String} sessionId
* @returns
*/
const setAuthTokens = async (
userId: string | ObjectId,
res: Response,
sessionId: string | null = null,
) => {
try {
const { getUserById, generateToken, findSession, generateRefreshToken, createSession } =
getMethods();
const user = await getUserById(userId);
const token = await generateToken(user);
let session;
let refreshToken;
let refreshTokenExpires;
if (sessionId) {
session = await findSession({ sessionId: sessionId }, { lean: false });
refreshTokenExpires = session.expiration.getTime();
refreshToken = await generateRefreshToken(session);
} else {
const result = await createSession(userId);
session = result.session;
refreshToken = result.refreshToken;
refreshTokenExpires = session.expiration.getTime();
}
res.cookie('refreshToken', refreshToken, {
expires: new Date(refreshTokenExpires),
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
res.cookie('token_provider', 'librechat', {
expires: new Date(refreshTokenExpires),
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
return token;
} catch (error) {
logger.error('[setAuthTokens] Error in setting authentication tokens:', error);
throw error;
}
};
/**
* @function setOpenIDAuthTokens
* Set OpenID Authentication Tokens
* //type tokenset from openid-client
* @param {import('openid-client').TokenEndpointResponse & import('openid-client').TokenEndpointResponseHelpers} tokenset
* - The tokenset object containing access and refresh tokens
* @param {Object} res - response object
* @returns {String} - access token
*/
const setOpenIDAuthTokens = (tokenset: TokenEndpointResponse, res: Response) => {
try {
if (!tokenset) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return;
}
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
const expiryInMilliseconds = REFRESH_TOKEN_EXPIRY
? eval(REFRESH_TOKEN_EXPIRY)
: 1000 * 60 * 60 * 24 * 7; // 7 days default
const expirationDate = new Date(Date.now() + expiryInMilliseconds);
if (tokenset == null) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return;
}
if (!tokenset.access_token || !tokenset.refresh_token) {
logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset');
return;
}
res.cookie('refreshToken', tokenset.refresh_token, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
res.cookie('token_provider', 'openid', {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
return tokenset.access_token;
} catch (error) {
logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error);
throw error;
}
};
export { setOpenIDAuthTokens, setAuthTokens, logoutUser, registerUser, initAuth };
export * from './strategies';
export * from './utils';

View File

@@ -0,0 +1,51 @@
import type { Mongoose } from 'mongoose';
import { BalanceConfig, createMethods } from '@librechat/data-schemas';
// Flag to prevent re-initialization
let initialized = false;
// Internal references to initialized values
let methods: any = null;
let balanceConfig: BalanceConfig;
let saveBuffer: Function;
/**
* Initializes authentication-related components.
* This should be called once during application setup.
*
* @param mongoose - The Mongoose instance used to create models and methods
* @param config - Balance configuration used in auth flows
* @param saveBufferStrategy - Function used to save buffered data mainly used for user avatar in the auth package
*/
export function initAuth(mongoose: Mongoose, config: BalanceConfig, saveBufferStrategy: Function) {
if (initialized) return;
methods = createMethods(mongoose);
balanceConfig = config;
saveBuffer = saveBufferStrategy;
initialized = true;
}
/**
* Returns the initialized methods for auth-related operations.
* Throws an error if not initialized.
*/
export function getMethods() {
if (!methods) {
throw new Error('Auth methods have not been initialized. Call initAuthModels() first.');
}
return methods;
}
/**
* Returns the balance configuration used for auth logic.
*/
export function getBalanceConfig(): BalanceConfig {
return balanceConfig;
}
/**
* Returns the function used to save buffered data.
*/
export function getSaveBufferStrategy(): Function {
return saveBuffer;
}

View File

@@ -1,47 +1,74 @@
const jwt = require('jsonwebtoken');
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const { Strategy: AppleStrategy } = require('passport-apple');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { createSocialUser, handleExistingUser } = require('./process');
const { isEnabled } = require('~/server/utils');
const socialLogin = require('./socialLogin');
const { findUser } = require('~/models');
const { User } = require('~/db/models');
import mongoose from 'mongoose';
import { Strategy as AppleStrategy, Profile as AppleProfile } from 'passport-apple';
import { MongoMemoryServer } from 'mongodb-memory-server';
import jwt from 'jsonwebtoken';
import { logger, userSchema } from '@librechat/data-schemas';
import { isEnabled } from '@librechat/api';
import { createSocialUser, handleExistingUser } from './helpers';
import { socialLogin } from './socialLogin';
import { IUser } from '@librechat/data-schemas';
const mockFindUser = jest.fn();
jest.mock('jsonwebtoken');
jest.mock('@librechat/data-schemas', () => {
const actualModule = jest.requireActual('@librechat/data-schemas');
return {
...actualModule,
logger: {
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
createMethods: jest.fn(() => {
return { findUser: mockFindUser };
}),
};
});
jest.mock('./process', () => ({
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
}));
jest.mock('~/server/utils', () => ({
jest.mock('../initAuth', () => {
const actualModule = jest.requireActual('../initAuth');
return {
...actualModule,
getMethods: jest.fn(() => {
return { findUser: mockFindUser };
}),
};
});
jest.mock('./helpers', () => {
const actualModule = jest.requireActual('./helpers');
return {
...actualModule,
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
};
});
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
}));
describe('Apple Login Strategy', () => {
let mongoServer;
let appleStrategyInstance;
let mongoServer: MongoMemoryServer;
let appleStrategyInstance: InstanceType<typeof AppleStrategy>;
let User: any;
const OLD_ENV = process.env;
let getProfileDetails;
let getProfileDetails: ({
idToken,
profile,
}: {
idToken: string | null;
profile: AppleProfile;
}) => Partial<IUser> & { avatarUrl: null };
// Start and stop in-memory MongoDB
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
User = mongoose.models.User || mongoose.model('User', userSchema);
});
afterAll(async () => {
@@ -72,7 +99,7 @@ describe('Apple Login Strategy', () => {
throw new Error('idToken is missing');
}
const decoded = jwt.decode(idToken);
const decoded = jwt.decode(idToken) as any;
if (!decoded) {
logger.error('Failed to decode idToken');
throw new Error('idToken is invalid');
@@ -95,18 +122,11 @@ describe('Apple Login Strategy', () => {
};
// Mock isEnabled based on environment variable
isEnabled.mockImplementation((flag) => {
if (flag === 'true') {
return true;
}
if (flag === 'false') {
return false;
}
return false;
});
(isEnabled as jest.Mock).mockImplementation((flag: string) => flag === 'true');
// Initialize the strategy with the mocked getProfileDetails
const appleLogin = socialLogin('apple', getProfileDetails);
appleStrategyInstance = new AppleStrategy(
{
clientID: process.env.APPLE_CLIENT_ID,
@@ -133,7 +153,7 @@ describe('Apple Login Strategy', () => {
});
it('should throw an error if idToken cannot be decoded', () => {
jwt.decode.mockReturnValue(null);
(jwt.decode as jest.Mock).mockReturnValue(null);
expect(() => {
getProfileDetails({ idToken: 'invalid_id_token', profile: mockProfile });
}).toThrow('idToken is invalid');
@@ -150,7 +170,7 @@ describe('Apple Login Strategy', () => {
},
};
jwt.decode.mockReturnValue(fakeDecodedToken);
(jwt.decode as jest.Mock).mockReturnValue(fakeDecodedToken);
const profileDetails = getProfileDetails({
idToken: 'fake_id_token',
@@ -174,7 +194,7 @@ describe('Apple Login Strategy', () => {
sub: 'apple-sub-5678',
};
jwt.decode.mockReturnValue(fakeDecodedToken);
(jwt.decode as jest.Mock).mockReturnValue(fakeDecodedToken);
const profileDetails = getProfileDetails({
idToken: 'fake_id_token',
@@ -209,17 +229,21 @@ describe('Apple Login Strategy', () => {
const fakeAccessToken = 'fake_access_token';
const fakeRefreshToken = 'fake_refresh_token';
beforeEach(() => {
jwt.decode.mockReturnValue(decodedToken);
findUser.mockResolvedValue(null);
beforeEach(async () => {
(jwt.decode as jest.Mock).mockReturnValue(decodedToken);
mockFindUser.mockResolvedValue(null);
const { initAuth } = require('../initAuth');
const saveBufferMock = jest.fn().mockResolvedValue('/fake/path/to/avatar.png');
await initAuth(mongoose, { enabled: false }, saveBufferMock); // mongoose: {}, fake balance config, dummy saveBuffer
});
it('should create a new user if one does not exist and registration is allowed', async () => {
// Mock findUser to return null (user does not exist)
findUser.mockResolvedValue(null);
mockFindUser.mockResolvedValue(null);
// Mock createSocialUser to create a user
createSocialUser.mockImplementation(async (userData) => {
(createSocialUser as jest.Mock).mockImplementation(async (userData: any) => {
const user = new User(userData);
await user.save();
return user;
@@ -234,14 +258,17 @@ describe('Apple Login Strategy', () => {
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err, user) => {
(err: Error | null, user: any) => {
mockVerifyCallback(err, user);
resolve();
},
);
});
expect(mockVerifyCallback).toHaveBeenCalledWith(null, expect.any(User));
expect(mockVerifyCallback).toHaveBeenCalledWith(
null,
expect.objectContaining({ email: 'jane.doe@example.com' }),
);
const user = mockVerifyCallback.mock.calls[0][1];
expect(user.email).toBe('jane.doe@example.com');
expect(user.username).toBe('jane.doe');
@@ -260,15 +287,18 @@ describe('Apple Login Strategy', () => {
avatarUrl: 'old_avatar.png',
});
console.log('aa', existingUser);
// Mock findUser to return the existing user
findUser.mockResolvedValue(existingUser);
mockFindUser.mockResolvedValue(existingUser);
// Mock handleExistingUser to update avatarUrl without saving to database
handleExistingUser.mockImplementation(async (user, avatarUrl) => {
user.avatarUrl = avatarUrl;
// Don't call save() to avoid database operations
return user;
});
(handleExistingUser as jest.Mock).mockImplementation(
async (user: any, avatarUrl: string | null) => {
user.avatarUrl = avatarUrl;
// Don't call save() to avoid database operations
return user;
},
);
const mockVerifyCallback = jest.fn();
@@ -279,16 +309,17 @@ describe('Apple Login Strategy', () => {
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err, user) => {
(err: Error | null, user: any) => {
mockVerifyCallback(err, user);
resolve();
},
);
});
console.log('bb', existingUser);
expect(mockVerifyCallback).toHaveBeenCalledWith(null, existingUser);
expect(existingUser.avatarUrl).toBeNull(); // As per getProfileDetails
expect(handleExistingUser).toHaveBeenCalledWith(existingUser, null);
expect(existingUser.avatarUrl).toBe(''); // As per getProfileDetails
expect(handleExistingUser).toHaveBeenCalledWith(existingUser, '');
});
it('should handle missing idToken gracefully', async () => {
@@ -301,7 +332,7 @@ describe('Apple Login Strategy', () => {
fakeRefreshToken,
null, // idToken is missing
mockProfile,
(err, user) => {
(err: Error | null, user: any) => {
mockVerifyCallback(err, user);
resolve();
},
@@ -317,7 +348,7 @@ describe('Apple Login Strategy', () => {
it('should handle decoding errors gracefully', async () => {
// Simulate decoding failure by returning null
jwt.decode.mockReturnValue(null);
(jwt.decode as jest.Mock).mockReturnValue(null);
const mockVerifyCallback = jest.fn();
@@ -328,7 +359,7 @@ describe('Apple Login Strategy', () => {
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err, user) => {
(err: Error | null, user: any) => {
mockVerifyCallback(err, user);
resolve();
},
@@ -346,10 +377,10 @@ describe('Apple Login Strategy', () => {
it('should handle errors during user creation', async () => {
// Mock findUser to return null (user does not exist)
findUser.mockResolvedValue(null);
mockFindUser.mockResolvedValue(null);
// Mock createSocialUser to throw an error
createSocialUser.mockImplementation(() => {
(createSocialUser as jest.Mock).mockImplementation(() => {
throw new Error('Database error');
});
@@ -362,7 +393,7 @@ describe('Apple Login Strategy', () => {
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err, user) => {
(err: Error | null, user: any) => {
mockVerifyCallback(err, user);
resolve();
},

View File

@@ -1,7 +1,8 @@
const socialLogin = require('./socialLogin');
const { Strategy: AppleStrategy } = require('passport-apple');
const { logger } = require('~/config');
const jwt = require('jsonwebtoken');
import { Strategy as AppleStrategy } from 'passport-apple';
import { logger } from '@librechat/data-schemas';
import jwt from 'jsonwebtoken';
import { GetProfileDetails, GetProfileDetailsParams } from './types';
import socialLogin from './socialLogin';
/**
* Extract profile details from the decoded idToken
@@ -10,13 +11,13 @@ const jwt = require('jsonwebtoken');
* @param {Object} params.profile - The profile object (may contain partial info)
* @returns {Object} - The extracted user profile details
*/
const getProfileDetails = ({ idToken, profile }) => {
const getProfileDetails: GetProfileDetails = ({ profile, idToken }: GetProfileDetailsParams) => {
if (!idToken) {
logger.error('idToken is missing');
throw new Error('idToken is missing');
}
const decoded = jwt.decode(idToken);
const decoded: any = jwt.decode(idToken);
logger.debug(`Decoded Apple JWT: ${JSON.stringify(decoded, null, 2)}`);
@@ -25,7 +26,7 @@ const getProfileDetails = ({ idToken, profile }) => {
id: decoded.sub,
avatarUrl: null, // Apple does not provide an avatar URL
username: decoded.email ? decoded.email.split('@')[0].toLowerCase() : `user_${decoded.sub}`,
name: decoded.name
displayName: decoded.name
? `${decoded.name.firstName} ${decoded.name.lastName}`
: profile.displayName || null,
emailVerified: true, // Apple verifies the email
@@ -33,9 +34,9 @@ const getProfileDetails = ({ idToken, profile }) => {
};
// Initialize the social login handler for Apple
const appleLogin = socialLogin('apple', getProfileDetails);
const appleStrategy = socialLogin('apple', getProfileDetails);
module.exports = () =>
const appleLogin = () =>
new AppleStrategy(
{
clientID: process.env.APPLE_CLIENT_ID,
@@ -45,5 +46,7 @@ module.exports = () =>
privateKeyLocation: process.env.APPLE_PRIVATE_KEY_PATH,
passReqToCallback: false, // Set to true if you need to access the request in the callback
},
appleLogin,
appleStrategy,
);
export default appleLogin;

View File

@@ -1,7 +1,8 @@
const { Strategy: DiscordStrategy } = require('passport-discord');
const socialLogin = require('./socialLogin');
import { Strategy as DiscordStrategy } from 'passport-discord';
import socialLogin from './socialLogin';
import { GetProfileDetails } from './types';
const getProfileDetails = ({ profile }) => {
const getProfileDetails: GetProfileDetails = ({ profile }: any) => {
let avatarUrl;
if (profile.avatar) {
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
@@ -21,9 +22,9 @@ const getProfileDetails = ({ profile }) => {
};
};
const discordLogin = socialLogin('discord', getProfileDetails);
const discordStrategy = socialLogin('discord', getProfileDetails);
module.exports = () =>
const discordLogin = () =>
new DiscordStrategy(
{
clientID: process.env.DISCORD_CLIENT_ID,
@@ -32,5 +33,7 @@ module.exports = () =>
scope: ['identify', 'email'],
authorizationURL: 'https://discord.com/api/oauth2/authorize?prompt=none',
},
discordLogin,
discordStrategy,
);
export default discordLogin;

View File

@@ -0,0 +1,36 @@
import { Strategy as FacebookStrategy } from 'passport-facebook';
import socialLogin from './socialLogin';
import { GetProfileDetails } from './types';
const getProfileDetails: GetProfileDetails = ({ profile }: FacebookStrategy.Profile) => {
// email or photo may not be returned
let email =
profile.emails?.length > 0 ? profile.emails[0]?.value : `${profile.id}@id.facebook.com`;
let photo = profile.photos?.length > 0 ? profile.photos[0]?.value : '';
return {
email: email,
id: profile.id,
avatarUrl: photo,
username: profile.displayName,
name: profile.name?.givenName + ' ' + profile.name?.familyName,
emailVerified: true,
};
};
const facebookStrategy = socialLogin('facebook', getProfileDetails);
const facebookLogin = () =>
new FacebookStrategy(
{
clientID: process.env.FACEBOOK_CLIENT_ID,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.FACEBOOK_CALLBACK_URL}`,
proxy: true,
scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
},
facebookStrategy,
);
export default facebookLogin;

View File

@@ -1,7 +1,8 @@
const { Strategy: GitHubStrategy } = require('passport-github2');
const socialLogin = require('./socialLogin');
import { Strategy as GitHubStrategy } from 'passport-github2';
import socialLogin from './socialLogin';
import { GetProfileDetails } from './types';
const getProfileDetails = ({ profile }) => ({
const getProfileDetails: GetProfileDetails = ({ profile }: any) => ({
email: profile.emails[0].value,
id: profile.id,
avatarUrl: profile.photos[0].value,
@@ -10,9 +11,8 @@ const getProfileDetails = ({ profile }) => ({
emailVerified: profile.emails[0].verified,
});
const githubLogin = socialLogin('github', getProfileDetails);
module.exports = () =>
const githubStrategy = socialLogin('github', getProfileDetails);
const githubLogin = () =>
new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
@@ -30,5 +30,6 @@ module.exports = () =>
}),
}),
},
githubLogin,
githubStrategy,
);
export default githubLogin;

View File

@@ -1,7 +1,8 @@
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const socialLogin = require('./socialLogin');
import { Strategy as GoogleStrategy, Profile } from 'passport-google-oauth20';
import socialLogin from './socialLogin';
import { GetProfileDetails } from './types';
const getProfileDetails = ({ profile }) => ({
const getProfileDetails: GetProfileDetails = ({ profile }: Profile) => ({
email: profile.emails[0].value,
id: profile.id,
avatarUrl: profile.photos[0].value,
@@ -10,9 +11,9 @@ const getProfileDetails = ({ profile }) => ({
emailVerified: profile.emails[0].verified,
});
const googleLogin = socialLogin('google', getProfileDetails);
const googleStrategy = socialLogin('google', getProfileDetails);
module.exports = () =>
const googleLogin = () =>
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
@@ -20,5 +21,7 @@ module.exports = () =>
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.GOOGLE_CALLBACK_URL}`,
proxy: true,
},
googleLogin,
googleStrategy,
);
export default googleLogin;

View File

@@ -1,8 +1,8 @@
const { FileSources } = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { updateUser, createUser, getUserById } = require('~/models');
const { getBalanceConfig } = require('~/server/services/Config');
import { IUser } from '@librechat/data-schemas';
import { FileSources } from 'librechat-data-provider';
import { getBalanceConfig, getMethods } from '../initAuth';
import { getAvatarProcessFunction, resizeAvatar } from '../utils/avatar';
import { CreateSocialUserParams } from './types';
/**
* Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter
@@ -17,24 +17,25 @@ const { getBalanceConfig } = require('~/server/services/Config');
*
* @throws {Error} Throws an error if there's an issue saving the updated user object.
*/
const handleExistingUser = async (oldUser, avatarUrl) => {
const fileStrategy = process.env.CDN_PROVIDER;
const handleExistingUser = async (oldUser: IUser, avatarUrl: string) => {
const fileStrategy = process.env.CDN_PROVIDER ?? FileSources.local;
const isLocal = fileStrategy === FileSources.local;
let updatedAvatar = false;
if (isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
let updatedAvatar = '';
if (isLocal && (oldUser.avatar === null || !oldUser.avatar?.includes('?manual=true'))) {
updatedAvatar = avatarUrl;
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
const userId = oldUser._id;
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar?.includes('?manual=true'))) {
const userId = oldUser.id ?? '';
const resizedBuffer = await resizeAvatar({
userId,
input: avatarUrl,
});
const { processAvatar } = getStrategyFunctions(fileStrategy);
updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId, manual: 'false' });
const processAvatar = getAvatarProcessFunction(fileStrategy);
updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId });
}
if (updatedAvatar) {
if (updatedAvatar != '') {
const { updateUser } = getMethods();
await updateUser(oldUser._id, { avatar: updatedAvatar });
}
};
@@ -68,7 +69,7 @@ const createSocialUser = async ({
username,
name,
emailVerified,
}) => {
}: CreateSocialUserParams): Promise<IUser> => {
const update = {
email,
avatar: avatarUrl,
@@ -78,10 +79,10 @@ const createSocialUser = async ({
name,
emailVerified,
};
const balanceConfig = await getBalanceConfig();
const balanceConfig = getBalanceConfig();
const { createUser, getUserById, updateUser } = getMethods();
const newUserId = await createUser(update, balanceConfig);
const fileStrategy = process.env.CDN_PROVIDER;
const fileStrategy = process.env.CDN_PROVIDER ?? FileSources.local;
const isLocal = fileStrategy === FileSources.local;
if (!isLocal) {
@@ -89,19 +90,11 @@ const createSocialUser = async ({
userId: newUserId,
input: avatarUrl,
});
const { processAvatar } = getStrategyFunctions(fileStrategy);
const avatar = await processAvatar({
buffer: resizedBuffer,
userId: newUserId,
manual: 'false',
});
const processAvatar = getAvatarProcessFunction(fileStrategy);
const avatar = await processAvatar({ buffer: resizedBuffer, userId: newUserId });
await updateUser(newUserId, { avatar });
}
return await getUserById(newUserId);
};
module.exports = {
handleExistingUser,
createSocialUser,
};
export { handleExistingUser, createSocialUser };

View File

@@ -0,0 +1,16 @@
export { setupOpenId, getOpenIdConfig } from './openidStrategy';
export { default as openIdJwtLogin } from './openIdJwtStrategy';
export { default as googleLogin } from './googleStrategy';
export { default as facebookLogin } from './facebookStrategy';
export { default as discordLogin } from './discordStrategy';
export { default as githubLogin } from './githubStrategy';
export { default as socialLogin } from './socialLogin';
export { samlLogin, getCertificateContent } from './samlStrategy';
export { default as ldapLogin } from './ldapStrategy';
export { default as passportLogin } from './localStrategy';
export { default as jwtLogin } from './jwtStrategy';
export { loginSchema, registerSchema } from './validators';
// export this helper so we can mock them
export { createSocialUser, handleExistingUser } from './helpers';

View File

@@ -1,16 +1,24 @@
const { logger } = require('@librechat/data-schemas');
const { SystemRoles } = require('librechat-data-provider');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { getUserById, updateUser } = require('~/models');
import { getMethods } from '../initAuth';
import { logger } from '@librechat/data-schemas';
import { SystemRoles } from 'librechat-data-provider';
import {
Strategy as JwtStrategy,
ExtractJwt,
StrategyOptionsWithoutRequest,
VerifiedCallback,
} from 'passport-jwt';
import { Strategy as PassportStrategy } from 'passport-strategy';
import { JwtPayload } from './types';
// JWT strategy
const jwtLogin = () =>
const jwtLogin = (): PassportStrategy =>
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
},
async (payload, done) => {
} as StrategyOptionsWithoutRequest,
async (payload: JwtPayload, done: VerifiedCallback) => {
const { updateUser, getUserById } = getMethods();
try {
const user = await getUserById(payload?.id, '-password -__v -totpSecret');
if (user) {
@@ -30,4 +38,4 @@ const jwtLogin = () =>
},
);
module.exports = jwtLogin;
export default jwtLogin;

View File

@@ -0,0 +1,150 @@
import fs from 'fs';
import LdapStrategy, { type Options } from 'passport-ldapauth';
import { SystemRoles } from 'librechat-data-provider';
import { logger } from '@librechat/data-schemas';
import { getBalanceConfig, getMethods } from '../initAuth';
import { isEnabled } from '../utils';
const {
LDAP_URL,
LDAP_BIND_DN,
LDAP_BIND_CREDENTIALS,
LDAP_USER_SEARCH_BASE,
LDAP_SEARCH_FILTER,
LDAP_CA_CERT_PATH,
LDAP_FULL_NAME,
LDAP_ID,
LDAP_USERNAME,
LDAP_EMAIL,
LDAP_TLS_REJECT_UNAUTHORIZED,
LDAP_STARTTLS,
} = process.env;
// // Check required environment variables
// if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) {
// module.exports = null;
// }
const searchAttributes = [
'displayName',
'mail',
'uid',
'cn',
'name',
'commonname',
'givenName',
'sn',
'sAMAccountName',
];
if (LDAP_FULL_NAME) {
searchAttributes.push(...LDAP_FULL_NAME.split(','));
}
if (LDAP_ID) {
searchAttributes.push(LDAP_ID);
}
if (LDAP_USERNAME) {
searchAttributes.push(LDAP_USERNAME);
}
if (LDAP_EMAIL) {
searchAttributes.push(LDAP_EMAIL);
}
const rejectUnauthorized = isEnabled(LDAP_TLS_REJECT_UNAUTHORIZED ?? '');
const startTLS = isEnabled(LDAP_STARTTLS ?? '');
const ldapLogin = () => {
const ldapOptions = {
server: {
url: LDAP_URL ?? '',
bindDN: LDAP_BIND_DN,
bindCredentials: LDAP_BIND_CREDENTIALS,
searchBase: LDAP_USER_SEARCH_BASE ?? '',
searchFilter: LDAP_SEARCH_FILTER || 'mail={{username}}',
searchAttributes: [...new Set(searchAttributes)],
...(LDAP_CA_CERT_PATH && {
tlsOptions: {
rejectUnauthorized,
ca: (() => {
try {
return [fs.readFileSync(LDAP_CA_CERT_PATH)];
} catch (err) {
logger.error('[ldapStrategy]', 'Failed to read CA certificate', err);
throw err;
}
})(),
},
}),
...(startTLS && { starttls: true }),
},
usernameField: 'email',
passwordField: 'password',
};
return new LdapStrategy(ldapOptions, async (userinfo: any, done: any) => {
if (!userinfo) {
return done(null, false, { message: 'Invalid credentials' });
}
const { countUsers, createUser, updateUser, findUser } = getMethods();
try {
const ldapId =
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
let user = await findUser({ ldapId });
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
const fullName =
fullNameAttributes && fullNameAttributes.length > 0
? fullNameAttributes.map((attr) => userinfo[attr]).join(' ')
: userinfo.cn || userinfo.name || userinfo.commonname || userinfo.displayName;
const username =
(LDAP_USERNAME && userinfo[LDAP_USERNAME]) || userinfo.givenName || userinfo.mail;
const mail =
(LDAP_EMAIL && userinfo[LDAP_EMAIL]) || userinfo.mail || username + '@ldap.local';
if (!userinfo.mail && !(LDAP_EMAIL && userinfo[LDAP_EMAIL])) {
logger.warn(
'[ldapStrategy]',
`No valid email attribute found in LDAP userinfo. Using fallback email: ${username}@ldap.local`,
`LDAP_EMAIL env var: ${LDAP_EMAIL || 'not set'}`,
`Available userinfo attributes: ${Object.keys(userinfo).join(', ')}`,
'Full userinfo:',
JSON.stringify(userinfo, null, 2),
);
}
if (!user) {
const isFirstRegisteredUser = (await countUsers()) === 0;
user = {
provider: 'ldap',
ldapId,
username,
email: mail,
emailVerified: true, // The ldap server administrator should verify the email
name: fullName,
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
};
const balanceConfig = getBalanceConfig();
const userId = await createUser(user, balanceConfig);
user._id = userId;
} else {
// Users registered in LDAP are assumed to have their user information managed in LDAP,
// so update the user information with the values registered in LDAP
user.provider = 'ldap';
user.ldapId = ldapId;
user.email = mail;
user.username = username;
user.name = fullName;
}
user = await updateUser(user._id, user);
done(null, user);
} catch (err) {
logger.error('[ldapStrategy]', err);
done(err);
}
});
};
export default ldapLogin;

View File

@@ -1,19 +1,48 @@
const { logger } = require('@librechat/data-schemas');
const { errorsToString } = require('librechat-data-provider');
const { Strategy: PassportLocalStrategy } = require('passport-local');
const { isEnabled, checkEmailConfig } = require('~/server/utils');
const { findUser, comparePassword, updateUser } = require('~/models');
const { loginSchema } = require('./validators');
import { IUser, logger } from '@librechat/data-schemas';
import { errorsToString } from 'librechat-data-provider';
import { Strategy as PassportLocalStrategy } from 'passport-local';
import { getMethods } from '../initAuth';
import { checkEmailConfig, isEnabled } from '../utils';
import { loginSchema } from './validators';
import bcrypt from 'bcryptjs';
import { Request } from 'express';
// Unix timestamp for 2024-06-07 15:20:18 Eastern Time
const verificationEnabledTimestamp = 1717788018;
async function validateLoginRequest(req) {
async function validateLoginRequest(req: Request) {
const { error } = loginSchema.safeParse(req.body);
return error ? errorsToString(error.errors) : null;
}
async function passportLogin(req, email, password, done) {
/**
* Compares the provided password with the user's password.
*
* @param {MongoUser} user - The user to compare the password for.
* @param {string} candidatePassword - The password to test against the user's password.
* @returns {Promise<boolean>} A promise that resolves to a boolean indicating if the password matches.
*/
const comparePassword = async (user: IUser, candidatePassword: string) => {
if (!user) {
throw new Error('No user provided');
}
return new Promise((resolve, reject) => {
bcrypt.compare(candidatePassword, user.password ?? '', (err, isMatch) => {
if (err) {
reject(err);
}
resolve(isMatch);
});
});
};
async function passportStrategy(
req: Request,
email: string,
password: string,
done: (error: any, user?: any, options?: { message: string }) => void,
) {
try {
const validationError = await validateLoginRequest(req);
if (validationError) {
@@ -22,6 +51,7 @@ async function passportLogin(req, email, password, done) {
return done(null, false, { message: validationError });
}
const { findUser, updateUser } = getMethods();
const user = await findUser({ email: email.trim() });
if (!user) {
logError('Passport Local Strategy - User Not Found', { email });
@@ -54,7 +84,7 @@ async function passportLogin(req, email, password, done) {
user.emailVerified = true;
}
const unverifiedAllowed = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN);
const unverifiedAllowed = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN ?? '');
if (user.expiresAt && unverifiedAllowed) {
await updateUser(user._id, {});
}
@@ -72,12 +102,12 @@ async function passportLogin(req, email, password, done) {
}
}
function logError(title, parameters) {
function logError(title: string, parameters: any) {
const entries = Object.entries(parameters).map(([name, value]) => ({ name, value }));
logger.error(title, { parameters: entries });
}
module.exports = () =>
const passportLogin = () =>
new PassportLocalStrategy(
{
usernameField: 'email',
@@ -85,5 +115,7 @@ module.exports = () =>
session: false,
passReqToCallback: true,
},
passportLogin,
passportStrategy,
);
export default passportLogin;

View File

@@ -1,9 +1,11 @@
const { SystemRoles } = require('librechat-data-provider');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { updateUser, findUser } = require('~/models');
const { logger } = require('~/config');
const jwksRsa = require('jwks-rsa');
const { isEnabled } = require('~/server/utils');
import { SystemRoles } from 'librechat-data-provider';
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
import jwksRsa from 'jwks-rsa';
import { isEnabled } from 'src/utils';
import { getMethods } from 'src/initAuth';
import { logger } from '@librechat/data-schemas';
import * as client from 'openid-client';
/**
* @function openIdJwtLogin
* @param {import('openid-client').Configuration} openIdConfig - Configuration object for the JWT strategy.
@@ -13,19 +15,20 @@ const { isEnabled } = require('~/server/utils');
* The strategy extracts the JWT from the Authorization header as a Bearer token.
* The JWT is then verified using the signing key, and the user is retrieved from the database.
*/
const openIdJwtLogin = (openIdConfig) =>
const openIdJwtLogin = (openIdConfig: client.Configuration) =>
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret({
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true,
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED || 'true'),
cacheMaxAge: process.env.OPENID_JWKS_URL_CACHE_TIME
? eval(process.env.OPENID_JWKS_URL_CACHE_TIME)
: 60000,
jwksUri: openIdConfig.serverMetadata().jwks_uri,
jwksUri: openIdConfig.serverMetadata().jwks_uri ?? '',
}),
},
async (payload, done) => {
const { findUser, updateUser } = getMethods();
try {
const user = await findUser({ openidId: payload?.sub });
@@ -49,4 +52,4 @@ const openIdJwtLogin = (openIdConfig) =>
},
);
module.exports = openIdJwtLogin;
export default openIdJwtLogin;

View File

@@ -1,350 +1,355 @@
const fetch = require('node-fetch');
const jwtDecode = require('jsonwebtoken/decode');
const { setupOpenId } = require('./openidStrategy');
const { findUser, createUser, updateUser } = require('~/models');
// --- Mocks ---
jest.mock('node-fetch');
jest.mock('jsonwebtoken/decode');
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
})),
}));
jest.mock('~/server/services/Config', () => ({
getBalanceConfig: jest.fn(() => ({
enabled: false,
})),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
}));
jest.mock('~/server/utils/crypto', () => ({
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));
jest.mock('~/server/utils', () => ({
isEnabled: jest.fn(() => false),
}));
jest.mock('~/config', () => ({
logger: {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
}));
jest.mock('~/cache/getLogStores', () =>
jest.fn(() => ({
get: jest.fn(),
set: jest.fn(),
})),
);
// Mock the openid-client module and all its dependencies
jest.mock('openid-client', () => {
return {
discovery: jest.fn().mockResolvedValue({
clientId: 'fake_client_id',
clientSecret: 'fake_client_secret',
issuer: 'https://fake-issuer.com',
// Add any other properties needed by the implementation
}),
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
// Only return additional properties, but don't override any claims
return Promise.resolve({
preferred_username: 'preferred_username',
});
}),
customFetch: Symbol('customFetch'),
};
});
jest.mock('openid-client/passport', () => {
let verifyCallback;
const mockStrategy = jest.fn((options, verify) => {
verifyCallback = verify;
return { name: 'openid', options, verify };
});
return {
Strategy: mockStrategy,
__getVerifyCallback: () => verifyCallback,
};
});
// Mock passport
jest.mock('passport', () => ({
use: jest.fn(),
}));
describe('setupOpenId', () => {
// Store a reference to the verify callback once it's set up
let verifyCallback;
// Helper to wrap the verify callback in a promise
const validate = (tokenset) =>
new Promise((resolve, reject) => {
verifyCallback(tokenset, (err, user, details) => {
if (err) {
reject(err);
} else {
resolve({ user, details });
}
});
});
const tokenset = {
id_token: 'fake_id_token',
access_token: 'fake_access_token',
claims: () => ({
sub: '1234',
email: 'test@example.com',
email_verified: true,
given_name: 'First',
family_name: 'Last',
name: 'My Full',
username: 'flast',
picture: 'https://example.com/avatar.png',
}),
};
beforeEach(async () => {
// Clear previous mock calls and reset implementations
jest.clearAllMocks();
// Reset environment variables needed by the strategy
process.env.OPENID_ISSUER = 'https://fake-issuer.com';
process.env.OPENID_CLIENT_ID = 'fake_client_id';
process.env.OPENID_CLIENT_SECRET = 'fake_client_secret';
process.env.DOMAIN_SERVER = 'https://example.com';
process.env.OPENID_CALLBACK_URL = '/callback';
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
delete process.env.OPENID_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM;
delete process.env.PROXY;
delete process.env.OPENID_USE_PKCE;
// Default jwtDecode mock returns a token that includes the required role.
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
});
// By default, assume that no user is found, so createUser will be called
findUser.mockResolvedValue(null);
createUser.mockImplementation(async (userData) => {
// simulate created user with an _id property
return { _id: 'newUserId', ...userData };
});
updateUser.mockImplementation(async (id, userData) => {
return { _id: id, ...userData };
});
// For image download, simulate a successful response
const fakeBuffer = Buffer.from('fake image');
const fakeResponse = {
ok: true,
buffer: jest.fn().mockResolvedValue(fakeBuffer),
};
fetch.mockResolvedValue(fakeResponse);
// Call the setup function and capture the verify callback
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
});
it('should create a new user with correct username when username claim exists', async () => {
// Arrange our userinfo already has username 'flast'
const userinfo = tokenset.claims();
// Act
const { user } = await validate(tokenset);
// Assert
expect(user.username).toBe(userinfo.username);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.username,
email: userinfo.email,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
{ enabled: false },
true,
true,
);
});
it('should use given_name as username when username claim is missing', async () => {
// Arrange remove username from userinfo
const userinfo = { ...tokenset.claims() };
delete userinfo.username;
// Expect the username to be the given name (unchanged case)
const expectUsername = userinfo.given_name;
// Act
const { user } = await validate({ ...tokenset, claims: () => userinfo });
// Assert
expect(user.username).toBe(expectUsername);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
true,
);
});
it('should use email as username when username and given_name are missing', async () => {
// Arrange remove username and given_name
const userinfo = { ...tokenset.claims() };
delete userinfo.username;
delete userinfo.given_name;
const expectUsername = userinfo.email;
// Act
const { user } = await validate({ ...tokenset, claims: () => userinfo });
// Assert
expect(user.username).toBe(expectUsername);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
true,
);
});
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 = tokenset.claims();
// Act
const { user } = await validate(tokenset);
// Assert username should equal the sub (converted as-is)
expect(user.username).toBe(userinfo.sub);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: userinfo.sub }),
{ enabled: false },
true,
true,
);
});
it('should set the full name correctly when given_name and family_name exist', async () => {
// Arrange
const userinfo = tokenset.claims();
const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`;
// Act
const { user } = await validate(tokenset);
// 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 = { ...tokenset.claims(), name: 'Custom Name' };
// Act
const { user } = await validate({ ...tokenset, claims: () => 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',
email: tokenset.claims().email,
openidId: '',
username: '',
name: '',
};
findUser.mockImplementation(async (query) => {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
return existingUser;
}
return null;
});
const userinfo = tokenset.claims();
// Act
await validate(tokenset);
// Assert updateUser should be called and the user object updated
expect(updateUser).toHaveBeenCalledWith(
existingUser._id,
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.username,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
);
});
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 = tokenset.claims();
// Act
const { user, details } = await validate(tokenset);
// 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 = tokenset.claims();
// Act
const { user } = await validate(tokenset);
// 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 = { ...tokenset.claims() };
delete userinfo.picture;
// Act
await validate({ ...tokenset, claims: () => userinfo });
// Assert fetch should not be called and avatar should remain undefined or empty
expect(fetch).not.toHaveBeenCalled();
// Depending on your implementation, user.avatar may be undefined or an empty string.
});
it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => {
const OpenIDStrategy = require('openid-client/passport').Strategy;
delete process.env.OPENID_USE_PKCE;
await setupOpenId();
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
expect(callOptions.usePKCE).toBe(false);
expect(callOptions.params?.code_challenge_method).toBeUndefined();
});
});
import passport from 'passport';
import mongoose from 'mongoose';
// --- Mocks ---
jest.mock('jsonwebtoken');
jest.mock('undici', () => {
const ActualUndici = jest.requireActual('undici');
return {
...ActualUndici,
fetch: jest.fn(() => {
return new ActualUndici.Response(Buffer.from('fake image'), {
status: 200,
headers: { 'content-type': 'image/png' },
});
}),
};
});
const fetchMock = jest.fn().mockResolvedValue(
new Response(Buffer.from('fake image'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
const mockedMethods = {
findUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
};
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
createMethods: jest.fn(() => mockedMethods),
};
});
// Mock the openid-client module and all its dependencies
jest.mock('openid-client', () => {
// const actual = jest.requireActual('openid-client');
return {
// ...actual,
discovery: jest.fn().mockResolvedValue({
clientId: 'fake_client_id',
clientSecret: 'fake_client_secret',
issuer: 'https://fake-issuer.com',
// Add any other properties needed by the implementation
}),
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
// Only return additional properties, but don't override any claims
return Promise.resolve({
preferred_username: 'preferred_username',
});
}),
customFetch: Symbol('customFetch'),
};
});
jest.mock('openid-client/passport', () => {
let verifyCallback: (...args: any[]) => any;
const mockConstructor = jest.fn((options, verify) => {
verifyCallback = verify;
return {
name: 'openid',
options,
verify,
};
});
return {
Strategy: mockConstructor,
__getVerifyCallback: () => verifyCallback,
};
});
// Mock passport
jest.mock('passport', () => ({
use: jest.fn(),
}));
import undici from 'undici';
import { setupOpenId } from './openidStrategy';
import { initAuth } from '../initAuth';
import jwt from 'jsonwebtoken';
describe('setupOpenId', () => {
// Store a reference to the verify callback once it's set up
let verifyCallback: (...args: any[]) => any;
// Helper to wrap the verify callback in a promise
const validate = (tokenset: any) =>
new Promise((resolve, reject) => {
verifyCallback(tokenset, (err: Error | null, user: any, details: any) => {
if (err) {
reject(err);
} else {
resolve({ user, details });
}
});
});
const tokenset = {
id_token: 'fake_id_token',
access_token: 'fake_access_token',
claims: () => ({
sub: '1234',
email: 'test@example.com',
email_verified: true,
given_name: 'First',
family_name: 'Last',
name: 'My Full',
username: 'flast',
picture: 'https://example.com/avatar.png',
}),
};
beforeEach(async () => {
// Clear previous mock calls and reset implementations
jest.clearAllMocks();
// Reset environment variables needed by the strategy
process.env.OPENID_ISSUER = 'https://fake-issuer.com';
process.env.OPENID_CLIENT_ID = 'fake_client_id';
process.env.OPENID_CLIENT_SECRET = 'fake_client_secret';
process.env.DOMAIN_SERVER = 'https://example.com';
process.env.OPENID_CALLBACK_URL = '/callback';
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
delete process.env.OPENID_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM;
delete process.env.PROXY;
delete process.env.OPENID_USE_PKCE;
// Default jwtDecode mock returns a token that includes the required role.
(jwt.decode as jest.Mock).mockReturnValue({
roles: ['requiredRole'],
});
// By default, assume that no user is found, so createUser will be called
mockedMethods.findUser.mockResolvedValue(null);
mockedMethods.createUser.mockImplementation(async (userData) => {
// simulate created user with an _id property
return { _id: 'newUserId', ...userData };
});
mockedMethods.updateUser.mockImplementation(async (id, userData) => {
return { _id: id, ...userData };
});
try {
// const { setupOpenId } = require('@librechat/auth');
const saveBufferMock = jest.fn().mockResolvedValue('/fake/path/to/avatar.png');
await initAuth(mongoose, { enabled: false }, saveBufferMock); // mongoose: {}, fake balance config, dummy saveBuffer
const openidLogin = await setupOpenId({});
// Simulate the app's `passport.use(...)`
passport.use('openid', openidLogin);
verifyCallback = require('openid-client/passport').__getVerifyCallback();
} catch (e) {
console.log(e);
}
});
it('should create a new user with correct username when username claim exists', async () => {
// Arrange our userinfo already has username 'flast'
const userinfo = tokenset.claims();
// Act
const { user } = (await validate(tokenset)) as any;
// Assert
expect(user.username).toBe(userinfo.username);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.username,
email: userinfo.email,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
{ enabled: false },
true,
true,
);
});
it('should use given_name as username when username claim is missing', async () => {
// Arrange remove username from userinfo
const userinfo: any = { ...tokenset.claims() };
delete userinfo.username;
// Expect the username to be the given name (unchanged case)
const expectUsername = userinfo.given_name;
// Act
const { user } = (await validate({ ...tokenset, claims: () => userinfo })) as any;
// Assert
expect(user.username).toBe(expectUsername);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
true,
);
});
it('should use email as username when username and given_name are missing', async () => {
// Arrange remove username and given_name
const userinfo: any = { ...tokenset.claims() };
delete userinfo.username;
delete userinfo.given_name;
const expectUsername = userinfo.email;
// Act
const { user } = (await validate({ ...tokenset, claims: () => userinfo })) as any;
// Assert
expect(user.username).toBe(expectUsername);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
true,
);
});
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 = tokenset.claims();
// Act
const { user } = (await validate(tokenset)) as any;
// Assert username should equal the sub (converted as-is)
expect(user.username).toBe(userinfo.sub);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: userinfo.sub }),
{ enabled: false },
true,
true,
);
});
it('should set the full name correctly when given_name and family_name exist', async () => {
// Arrange
const userinfo = tokenset.claims();
const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`;
// Act
const { user } = (await validate(tokenset)) as any;
// 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 = { ...tokenset.claims(), name: 'Custom Name' };
// Act
const { user } = (await validate({ ...tokenset, claims: () => userinfo })) as any;
// 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',
email: tokenset.claims().email,
openidId: '',
username: '',
name: '',
};
mockedMethods.findUser.mockImplementation(async (query) => {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
return existingUser;
}
return null;
});
const userinfo = tokenset.claims();
// Act
await validate(tokenset);
// Assert updateUser should be called and the user object updated
expect(mockedMethods.updateUser).toHaveBeenCalledWith(
existingUser._id,
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.username,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
);
});
it('should enforce the required role and reject login if missing', async () => {
// Arrange simulate a token without the required role.
(jwt.decode as jest.Mock).mockReturnValue({
roles: ['SomeOtherRole'],
});
// Act
const { user, details } = (await validate(tokenset)) as any;
// 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.skip('should attempt to download and save the avatar if picture is provided', async () => {
// Act
const { user } = (await validate(tokenset)) as any;
// Assert verify that download was attempted and the avatar field was set via updateUser
expect(undici.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: any = { ...tokenset.claims() };
delete userinfo.picture;
// Act
await validate({ ...tokenset, claims: () => userinfo });
// Assert fetch should not be called and avatar should remain undefined or empty
expect(undici.fetch).not.toHaveBeenCalled();
// Depending on your implementation, user.avatar may be undefined or an empty string.
});
it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => {
const OpenIDStrategy = require('openid-client/passport').Strategy;
delete process.env.OPENID_USE_PKCE;
const { setupOpenId } = require('./openidStrategy');
await setupOpenId({});
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
expect(callOptions.usePKCE).toBe(false);
expect(callOptions.params?.code_challenge_method).toBeUndefined();
});
});

View File

@@ -1,17 +1,16 @@
const undici = require('undici');
const fetch = require('node-fetch');
const passport = require('passport');
const client = require('openid-client');
const jwtDecode = require('jsonwebtoken/decode');
const { CacheKeys } = require('librechat-data-provider');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { hashToken, logger } = require('@librechat/data-schemas');
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
const { isEnabled, safeStringify, logHeaders } = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models');
const { getBalanceConfig } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores');
import * as client from 'openid-client';
// @ts-ignore
import { Strategy as OpenIDStrategy, VerifyCallback } from 'openid-client/passport';
import jwt from 'jsonwebtoken';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { hashToken, logger } from '@librechat/data-schemas';
import { isEnabled } from '../utils';
import { safeStringify, logHeaders } from '@librechat/api';
import * as oauth from 'oauth4webapi';
import { getBalanceConfig, getMethods, getSaveBufferStrategy } from '../initAuth';
import { fetch, Response as UndiciResponse, Headers } from 'undici';
import { Request } from 'express';
let crypto: typeof import('node:crypto') | undefined;
/**
* @typedef {import('openid-client').ClientMetadata} ClientMetadata
@@ -22,10 +21,10 @@ const getLogStores = require('~/cache/getLogStores');
* @param {string} url
* @param {client.CustomFetchOptions} options
*/
async function customFetch(url, options) {
export async function customFetch(url: URL | string, options: any): Promise<UndiciResponse> {
const urlStr = url.toString();
logger.debug(`[openidStrategy] Request to: ${urlStr}`);
const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS);
const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS ?? '');
if (debugOpenId) {
logger.debug(`[openidStrategy] Request method: ${options.method || 'GET'}`);
logger.debug(`[openidStrategy] Request headers: ${logHeaders(options.headers)}`);
@@ -49,15 +48,15 @@ async function customFetch(url, options) {
logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`);
fetchOptions = {
...options,
dispatcher: new HttpsProxyAgent(process.env.PROXY),
dispatcher: new HttpsProxyAgent(process.env.PROXY ?? ''),
};
}
const response = await undici.fetch(url, fetchOptions);
const response: UndiciResponse = await fetch(url, fetchOptions);
if (debugOpenId) {
logger.debug(`[openidStrategy] Response status: ${response.status} ${response.statusText}`);
logger.debug(`[openidStrategy] Response headers: ${logHeaders(response.headers)}`);
// logger.debug(`[openidStrategy] Response headers: ${logHeaders(response.headers)}`);
}
if (response.status === 200 && response.headers.has('www-authenticate')) {
@@ -74,7 +73,7 @@ This violates RFC 7235 and may cause issues with strict OAuth clients. Removing
}
}
return new Response(responseBody, {
return new UndiciResponse(responseBody, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
@@ -82,32 +81,35 @@ This violates RFC 7235 and may cause issues with strict OAuth clients. Removing
}
return response;
} catch (error) {
} catch (error: any) {
logger.error(`[openidStrategy] Fetch error: ${error.message}`);
throw error;
}
}
/** @typedef {Configuration | null} */
let openidConfig = null;
//overload currenturl function because of express version 4 buggy req.host doesn't include port
//More info https://github.com/panva/openid-client/pull/713
let openidConfig: client.Configuration;
class CustomOpenIDStrategy extends OpenIDStrategy {
currentUrl(req) {
const hostAndProtocol = process.env.DOMAIN_SERVER;
constructor(options: any, verify: VerifyCallback) {
super(options, verify);
}
currentUrl(req: Request): URL {
const hostAndProtocol = process.env.DOMAIN_SERVER!;
return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`);
}
authorizationRequestParams(req, options) {
const params = super.authorizationRequestParams(req, options);
if (options?.state && !params.has('state')) {
params.set('state', options.state);
authorizationRequestParams(req: Request, options: any): URLSearchParams {
const params = super.authorizationRequestParams(req, options) as URLSearchParams;
if (options?.state && !params?.has('state')) {
params?.set('state', options.state);
}
return params;
}
}
let tokensCache: any;
/**
* Exchange the access token for a new access token using the on-behalf-of flow if required.
* @param {Configuration} config
@@ -116,12 +118,19 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
* @param {boolean} fromCache - Indicates whether to use cached tokens.
* @returns {Promise<string>} The new access token if exchanged, otherwise the original access token.
*/
const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => {
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED);
const exchangeAccessTokenIfNeeded = async (
config: client.Configuration,
accessToken: string,
sub: string,
fromCache: boolean = false,
) => {
const onBehalfFlowRequired = isEnabled(
process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED ?? '',
);
if (onBehalfFlowRequired) {
if (fromCache) {
const cachedToken = await tokensCache.get(sub);
if (cachedToken) {
return cachedToken.access_token;
}
@@ -140,7 +149,7 @@ const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache =
{
access_token: grantResponse.access_token,
},
grantResponse.expires_in * 1000,
(grantResponse?.expires_in ?? 0) * 1000,
);
return grantResponse.access_token;
}
@@ -154,7 +163,11 @@ const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache =
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @returns {Promise<Object|null>}
*/
const getUserInfo = async (config, accessToken, sub) => {
const getUserInfo = async (
config: client.Configuration,
accessToken: string,
sub: string,
): Promise<oauth.UserInfoResponse | null> => {
try {
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
return await client.fetchUserInfo(config, exchangedAccessToken, sub);
@@ -163,7 +176,6 @@ const getUserInfo = async (config, accessToken, sub) => {
return null;
}
};
/**
* Downloads an image from a URL using an access token.
* @param {string} url
@@ -172,14 +184,19 @@ const getUserInfo = async (config, accessToken, sub) => {
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @returns {Promise<Buffer | string>} The image buffer or an empty string if the download fails.
*/
const downloadImage = async (url, config, accessToken, sub) => {
const downloadImage = async (
url: string,
config: client.Configuration,
accessToken: string,
sub: string,
) => {
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true);
if (!url) {
return '';
}
try {
const options = {
const options: any = {
method: 'GET',
headers: {
Authorization: `Bearer ${exchangedAccessToken}`,
@@ -189,11 +206,10 @@ const downloadImage = async (url, config, accessToken, sub) => {
if (process.env.PROXY) {
options.agent = new HttpsProxyAgent(process.env.PROXY);
}
const response = await fetch(url, options);
const response: UndiciResponse = await fetch(url, options);
if (response.ok) {
const buffer = await response.buffer();
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return buffer;
} else {
throw new Error(`${response.statusText} (HTTP ${response.status})`);
@@ -216,9 +232,10 @@ const downloadImage = async (url, config, accessToken, sub) => {
* @param {string} [userinfo.email] - The user's email address
* @returns {string} The determined full name of the user
*/
function getFullName(userinfo) {
if (process.env.OPENID_NAME_CLAIM) {
return userinfo[process.env.OPENID_NAME_CLAIM];
function getFullName(userinfo: client.UserInfoResponse & { username?: string }): string {
const nameClaim = process.env.OPENID_NAME_CLAIM;
if (nameClaim && typeof userinfo[nameClaim] === 'string') {
return userinfo[nameClaim] as string;
}
if (userinfo.given_name && userinfo.family_name) {
@@ -233,7 +250,7 @@ function getFullName(userinfo) {
return userinfo.family_name;
}
return userinfo.username || userinfo.email;
return (userinfo?.username || userinfo?.email) ?? '';
}
/**
@@ -246,7 +263,7 @@ 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 = '') {
function convertToUsername(input: string | string[], defaultValue: string = '') {
if (typeof input === 'string') {
return input;
} else if (Array.isArray(input)) {
@@ -266,74 +283,78 @@ function convertToUsername(input, defaultValue = '') {
* @returns {Promise<Configuration | null>} A promise that resolves when the OpenID strategy is set up and returns the openid client config object.
* @throws {Error} If an error occurs during the setup process.
*/
async function setupOpenId() {
async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
try {
/** @type {ClientMetadata} */
tokensCache = tokensCacheKv;
const clientMetadata = {
client_id: process.env.OPENID_CLIENT_ID,
client_secret: process.env.OPENID_CLIENT_SECRET,
};
/** @type {Configuration} */
openidConfig = await client.discovery(
new URL(process.env.OPENID_ISSUER),
process.env.OPENID_CLIENT_ID,
new URL(process.env.OPENID_ISSUER ?? ''),
process.env.OPENID_CLIENT_ID ?? '',
clientMetadata,
undefined,
{
//@ts-ignore
[client.customFetch]: customFetch,
},
);
const { findUser, createUser, updateUser } = getMethods();
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 usePKCE = isEnabled(process.env.OPENID_USE_PKCE);
const usePKCE: boolean = isEnabled(process.env.OPENID_USE_PKCE ?? '');
const openidLogin = new CustomOpenIDStrategy(
{
config: openidConfig,
scope: process.env.OPENID_SCOPE,
callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.OPENID_CALLBACK_URL}`,
usePKCE,
},
async (tokenset, done) => {
async (tokenset: any, done) => {
try {
const claims = tokenset.claims();
let user = await findUser({ openidId: claims.sub });
const claims: oauth.IDToken | undefined = tokenset.claims();
let user = await findUser({ openidId: claims?.sub });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${claims.sub}`,
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${claims?.sub}`,
);
if (!user) {
user = await findUser({ email: claims.email });
user = await findUser({ email: claims?.email });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
claims.email
} for openidId: ${claims.sub}`,
claims?.email
} for openidId: ${claims?.sub}`,
);
}
const userinfo = {
const userinfo: any = {
...claims,
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
...(await getUserInfo(openidConfig, tokenset.access_token, claims?.sub ?? '')),
};
const fullName = getFullName(userinfo);
if (requiredRole) {
let decodedToken = '';
let decodedToken = null;
if (requiredRoleTokenKind === 'access') {
decodedToken = jwtDecode(tokenset.access_token);
decodedToken = jwt.decode(tokenset.access_token);
} else if (requiredRoleTokenKind === 'id') {
decodedToken = jwtDecode(tokenset.id_token);
decodedToken = jwt.decode(tokenset.id_token ?? '');
}
const pathParts = requiredRoleParameterPath.split('.');
const pathParts = requiredRoleParameterPath?.split('.');
let found = true;
let roles = pathParts.reduce((o, key) => {
if (o === null || o === undefined || !(key in o)) {
found = false;
return [];
let roles: any = decodedToken;
if (pathParts) {
for (const key of pathParts) {
if (roles && typeof roles === 'object' && key in roles) {
roles = (roles as Record<string, unknown>)[key];
} else {
found = false;
break;
}
}
return o[key];
}, decodedToken);
}
if (!found) {
logger.error(
@@ -341,7 +362,7 @@ async function setupOpenId() {
);
}
if (!roles.includes(requiredRole)) {
if (!roles?.includes(requiredRole)) {
return done(null, false, {
message: `You must have the "${requiredRole}" role to log in.`,
});
@@ -349,11 +370,11 @@ async function setupOpenId() {
}
let username = '';
if (process.env.OPENID_USERNAME_CLAIM) {
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
if (process.env.OPENID_USERNAME_CLAIM && userinfo[process.env.OPENID_USERNAME_CLAIM]) {
username = userinfo[process.env.OPENID_USERNAME_CLAIM] as string;
} else {
username = convertToUsername(
userinfo.username || userinfo.given_name || userinfo.email,
userinfo?.username ?? userinfo?.given_name ?? userinfo?.email,
);
}
@@ -367,8 +388,7 @@ async function setupOpenId() {
name: fullName,
};
const balanceConfig = await getBalanceConfig();
const balanceConfig = getBalanceConfig();
user = await createUser(user, balanceConfig, true, true);
} else {
user.provider = 'openid';
@@ -377,17 +397,21 @@ async function setupOpenId() {
user.name = fullName;
}
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
if (!!userinfo && userinfo.picture && !user?.avatar?.includes('manual=true')) {
/** @type {string | undefined} */
const imageUrl = userinfo.picture;
let fileName;
try {
crypto = await import('node:crypto');
} catch (err) {
logger.error('[openidStrategy] crypto support is disabled!', err);
}
if (crypto) {
fileName = (await hashToken(userinfo.sub)) + '.png';
} else {
fileName = userinfo.sub + '.png';
}
const imageBuffer = await downloadImage(
imageUrl,
openidConfig,
@@ -395,7 +419,7 @@ async function setupOpenId() {
userinfo.sub,
);
if (imageBuffer) {
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
const saveBuffer = getSaveBufferStrategy();
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
@@ -404,9 +428,7 @@ async function setupOpenId() {
user.avatar = imagePath ?? '';
}
}
user = await updateUser(user._id, user);
user = await updateUser(user?._id, user);
logger.info(
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
{
@@ -426,27 +448,23 @@ async function setupOpenId() {
}
},
);
passport.use('openid', openidLogin);
return openidConfig;
return openidLogin;
} catch (err) {
logger.error('[openidStrategy]', err);
return null;
}
}
/**
* @function getOpenIdConfig
* @description Returns the OpenID client instance.
* @throws {Error} If the OpenID client is not initialized.
* @returns {Configuration}
*/
function getOpenIdConfig() {
function getOpenIdConfig(): client.Configuration {
if (!openidConfig) {
throw new Error('OpenID client is not initialized. Please call setupOpenId first.');
}
return openidConfig;
}
module.exports = {
setupOpenId,
getOpenIdConfig,
};
export { setupOpenId, getOpenIdConfig };

View File

@@ -1,62 +1,53 @@
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { findUser, createUser, updateUser } = require('~/models');
const { setupSaml, getCertificateContent } = require('./samlStrategy');
import passport from 'passport';
import mongoose from 'mongoose';
// --- Mocks ---
jest.mock('fs');
jest.mock('path');
jest.mock('node-fetch');
jest.mock('@node-saml/passport-saml');
jest.mock('~/models', () => ({
jest.mock('fs', () => ({
existsSync: jest.fn(),
statSync: jest.fn(),
readFileSync: jest.fn(),
}));
jest.mock('path', () => ({
isAbsolute: jest.fn(),
basename: jest.fn(),
dirname: jest.fn(),
join: jest.fn(),
normalize: jest.fn(),
}));
const mockedMethods = {
findUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
config: {
registration: {
socialLogins: ['saml'],
};
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
createMethods: jest.fn(() => mockedMethods),
logger: {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
},
getBalanceConfig: jest.fn().mockResolvedValue({
tokenCredits: 1000,
startingBalance: 1000,
}),
}));
jest.mock('~/server/services/Config/EndpointService', () => ({
config: {},
}));
jest.mock('~/server/utils', () => ({
};
});
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(() => false),
isUserProvided: jest.fn(() => false),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
})),
}));
jest.mock('~/server/utils/crypto', () => ({
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));
jest.mock('~/config', () => ({
logger: {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
}));
import path from 'path';
import fs from 'fs';
import { samlLogin, getCertificateContent } from './samlStrategy';
import { initAuth } from '../initAuth';
import { Profile } from '@node-saml/passport-saml/lib';
// To capture the verify callback from the strategy, we grab it from the mock constructor
let verifyCallback;
SamlStrategy.mockImplementation((options, verify) => {
verifyCallback = verify;
return { name: 'saml', options, verify };
});
describe('getCertificateContent', () => {
// const { getCertificateContent } = require('@librechat/auth');
const certWithHeader = `-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUKhXaFJGJJPx466rlwYORIsqCq7MwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
@@ -123,13 +114,13 @@ u7wlOSk+oFzDIO/UILIA
process.env.SAML_CERT = 'test.pem';
const resolvedPath = '/absolute/path/to/test.pem';
path.isAbsolute.mockReturnValue(false);
path.join.mockReturnValue(resolvedPath);
path.normalize.mockReturnValue(resolvedPath);
(path.isAbsolute as jest.Mock).mockReturnValue(false);
(path.join as jest.Mock).mockReturnValue(resolvedPath);
(path.normalize as jest.Mock).mockReturnValue(resolvedPath);
fs.existsSync.mockReturnValue(true);
fs.statSync.mockReturnValue({ isFile: () => true });
fs.readFileSync.mockReturnValue(certWithHeader);
(fs.existsSync as jest.Mock).mockReturnValue(true);
(fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true });
(fs.readFileSync as jest.Mock).mockReturnValue(certWithHeader);
const actual = getCertificateContent(process.env.SAML_CERT);
expect(actual).toBe(certWithHeader);
@@ -138,12 +129,12 @@ u7wlOSk+oFzDIO/UILIA
it('should load cert from an absolute file path if SAML_CERT is valid', () => {
process.env.SAML_CERT = '/absolute/path/to/test.pem';
path.isAbsolute.mockReturnValue(true);
path.normalize.mockReturnValue(process.env.SAML_CERT);
(path.isAbsolute as jest.Mock).mockReturnValue(true);
(path.normalize as jest.Mock).mockReturnValue(process.env.SAML_CERT);
fs.existsSync.mockReturnValue(true);
fs.statSync.mockReturnValue({ isFile: () => true });
fs.readFileSync.mockReturnValue(certWithHeader);
(fs.existsSync as jest.Mock).mockReturnValue(true);
(fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true });
(fs.readFileSync as jest.Mock).mockReturnValue(certWithHeader);
const actual = getCertificateContent(process.env.SAML_CERT);
expect(actual).toBe(certWithHeader);
@@ -153,11 +144,11 @@ u7wlOSk+oFzDIO/UILIA
process.env.SAML_CERT = 'missing.pem';
const resolvedPath = '/absolute/path/to/missing.pem';
path.isAbsolute.mockReturnValue(false);
path.join.mockReturnValue(resolvedPath);
path.normalize.mockReturnValue(resolvedPath);
(path.isAbsolute as jest.Mock).mockReturnValue(false);
(path.join as jest.Mock).mockReturnValue(resolvedPath);
(path.normalize as jest.Mock).mockReturnValue(resolvedPath);
fs.existsSync.mockReturnValue(false);
(fs.existsSync as jest.Mock).mockReturnValue(false);
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
@@ -168,13 +159,13 @@ u7wlOSk+oFzDIO/UILIA
process.env.SAML_CERT = 'unreadable.pem';
const resolvedPath = '/absolute/path/to/unreadable.pem';
path.isAbsolute.mockReturnValue(false);
path.join.mockReturnValue(resolvedPath);
path.normalize.mockReturnValue(resolvedPath);
(path.isAbsolute as jest.Mock).mockReturnValue(false);
(path.join as jest.Mock).mockReturnValue(resolvedPath);
(path.normalize as jest.Mock).mockReturnValue(resolvedPath);
fs.existsSync.mockReturnValue(true);
fs.statSync.mockReturnValue({ isFile: () => true });
fs.readFileSync.mockImplementation(() => {
(fs.existsSync as jest.Mock).mockReturnValue(true);
(fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true });
(fs.readFileSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});
@@ -185,10 +176,12 @@ u7wlOSk+oFzDIO/UILIA
});
describe('setupSaml', () => {
let verifyCallback: (...args: any[]) => any;
// Helper to wrap the verify callback in a promise
const validate = (profile) =>
const validate = (profile: any) =>
new Promise((resolve, reject) => {
verifyCallback(profile, (err, user, details) => {
verifyCallback(profile, (err: Error | null, user: any, details: any) => {
if (err) {
reject(err);
} else {
@@ -212,13 +205,12 @@ describe('setupSaml', () => {
jest.clearAllMocks();
// Configure mocks
const { findUser, createUser, updateUser } = require('~/models');
findUser.mockResolvedValue(null);
createUser.mockImplementation(async (userData) => ({
mockedMethods.findUser.mockResolvedValue(null);
mockedMethods.createUser.mockImplementation(async (userData) => ({
_id: 'mock-user-id',
...userData,
}));
updateUser.mockImplementation(async (id, userData) => ({
mockedMethods.updateUser.mockImplementation(async (id, userData) => ({
_id: id,
...userData,
}));
@@ -259,19 +251,26 @@ u7wlOSk+oFzDIO/UILIA
delete process.env.SAML_PICTURE_CLAIM;
delete process.env.SAML_NAME_CLAIM;
// Simulate image download
const fakeBuffer = Buffer.from('fake image');
fetch.mockResolvedValue({
// For image download, simulate a successful response
global.fetch = jest.fn().mockResolvedValue({
ok: true,
buffer: jest.fn().mockResolvedValue(fakeBuffer),
arrayBuffer: jest.fn().mockResolvedValue(Buffer.from('fake image')),
});
await setupSaml();
const saveBufferMock = jest.fn().mockResolvedValue('/fake/path/to/avatar.png');
await initAuth(mongoose, { enabled: false }, saveBufferMock);
// Simulate the app's `passport.use(...)`
const SamlStrategy: any = samlLogin();
passport.use('saml', SamlStrategy);
console.log('---SamlStrategy', SamlStrategy);
verifyCallback = SamlStrategy._signonVerify;
});
it('should create a new user with correct username when username claim exists', async () => {
const profile = { ...baseProfile };
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(user.username).toBe(profile.username);
expect(user.provider).toBe('saml');
@@ -281,23 +280,23 @@ u7wlOSk+oFzDIO/UILIA
});
it('should use given_name as username when username claim is missing', async () => {
const profile = { ...baseProfile };
const profile: any = { ...baseProfile };
delete profile.username;
const expectUsername = profile.given_name;
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(user.username).toBe(expectUsername);
expect(user.provider).toBe('saml');
});
it('should use email as username when username and given_name are missing', async () => {
const profile = { ...baseProfile };
const profile: Partial<Profile> = { ...baseProfile };
delete profile.username;
delete profile.given_name;
const expectUsername = profile.email;
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(user.username).toBe(expectUsername);
expect(user.provider).toBe('saml');
@@ -307,7 +306,7 @@ u7wlOSk+oFzDIO/UILIA
process.env.SAML_USERNAME_CLAIM = 'nameID';
const profile = { ...baseProfile };
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(user.username).toBe(profile.nameID);
expect(user.provider).toBe('saml');
@@ -317,50 +316,50 @@ u7wlOSk+oFzDIO/UILIA
const profile = { ...baseProfile };
const expectedFullName = `${profile.given_name} ${profile.family_name}`;
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when given_name exist', async () => {
const profile = { ...baseProfile };
const profile: Partial<Profile> = { ...baseProfile };
delete profile.family_name;
const expectedFullName = profile.given_name;
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when family_name exist', async () => {
const profile = { ...baseProfile };
const profile: Partial<Profile> = { ...baseProfile };
delete profile.given_name;
const expectedFullName = profile.family_name;
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when username exist', async () => {
const profile = { ...baseProfile };
const profile: Partial<Profile> = { ...baseProfile };
delete profile.family_name;
delete profile.given_name;
const expectedFullName = profile.username;
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when email only exist', async () => {
const profile = { ...baseProfile };
const profile: Partial<Profile> = { ...baseProfile };
delete profile.family_name;
delete profile.given_name;
delete profile.username;
const expectedFullName = profile.email;
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
@@ -370,14 +369,13 @@ u7wlOSk+oFzDIO/UILIA
const profile = { ...baseProfile };
const expectedFullName = profile.custom_name;
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
it('should update an existing user on login', async () => {
// Set up findUser to return an existing user
const { findUser } = require('~/models');
const existingUser = {
_id: 'existing-user-id',
provider: 'local',
@@ -386,10 +384,10 @@ u7wlOSk+oFzDIO/UILIA
username: 'oldusername',
name: 'Old Name',
};
findUser.mockResolvedValue(existingUser);
mockedMethods.findUser.mockResolvedValue(existingUser);
const profile = { ...baseProfile };
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(user.provider).toBe('saml');
expect(user.samlId).toBe(baseProfile.nameID);
@@ -399,20 +397,20 @@ u7wlOSk+oFzDIO/UILIA
});
it('should attempt to download and save the avatar if picture is provided', async () => {
const profile = { ...baseProfile };
const profile: Partial<Profile> = { ...baseProfile };
const { user } = await validate(profile);
const { user } = (await validate(profile)) as any;
expect(fetch).toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalled();
expect(user.avatar).toBe('/fake/path/to/avatar.png');
});
it('should not attempt to download avatar if picture is not provided', async () => {
const profile = { ...baseProfile };
const profile: Partial<Profile> = { ...baseProfile };
delete profile.picture;
await validate(profile);
expect(fetch).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,284 @@
import fs from 'fs';
import path from 'path';
import { hashToken, logger } from '@librechat/data-schemas';
import { Strategy as SamlStrategy, Profile, PassportSamlConfig } from '@node-saml/passport-saml';
import { getBalanceConfig, getMethods, getSaveBufferStrategy } from '../initAuth';
let crypto: typeof import('node:crypto') | undefined;
/**
* Retrieves the certificate content from the given value.
*
* This function determines whether the provided value is a certificate string (RFC7468 format or
* base64-encoded without a header) or a valid file path. If the value matches one of these formats,
* the certificate content is returned. Otherwise, an error is thrown.
*
* @see https://github.com/node-saml/node-saml/tree/master?tab=readme-ov-file#configuration-option-idpcert
* @param {string} value - The certificate string or file path.
* @returns {string} The certificate content if valid.
* @throws {Error} If the value is not a valid certificate string or file path.
*/
function getCertificateContent(value: any): string {
if (typeof value !== 'string') {
throw new Error('Invalid input: SAML_CERT must be a string.');
}
// Check if it's an RFC7468 formatted PEM certificate
const pemRegex = new RegExp(
'-----BEGIN (CERTIFICATE|PUBLIC KEY)-----\n' + // header
'([A-Za-z0-9+/=]{64}\n)+' + // base64 content (64 characters per line)
'[A-Za-z0-9+/=]{1,64}\n' + // base64 content (last line)
'-----END (CERTIFICATE|PUBLIC KEY)-----', // footer
);
if (pemRegex.test(value)) {
logger.info('[samlStrategy] Detected RFC7468-formatted certificate string.');
return value;
}
// Check if it's a Base64-encoded certificate (no header)
if (/^[A-Za-z0-9+/=]+$/.test(value) && value.length % 4 === 0) {
logger.info('[samlStrategy] Detected base64-encoded certificate string (no header).');
return value;
}
// Check if file exists and is readable
// const root = path.resolve(__dirname, '..', '..');
const certPath = path.normalize(path.isAbsolute(value) ? value : '/');
// const certPath = path.normalize(path.isAbsolute(value) ? value : path.join(root, value));
if (fs.existsSync(certPath) && fs.statSync(certPath).isFile()) {
try {
logger.info(`[samlStrategy] Loading certificate from file: ${certPath}`);
return fs.readFileSync(certPath, 'utf8').trim();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Error reading certificate file: ${errorMessage}`);
}
}
throw new Error('Invalid cert: SAML_CERT must be a valid file path or certificate string.');
}
/**
* Retrieves a SAML claim from a profile object based on environment configuration.
* @param {object} profile - Saml profile
* @param {string} envVar - Environment variable name (SAML_*)
* @param {string} defaultKey - Default key to use if the environment variable is not set
* @returns {string}
*/
function getSamlClaim(profile: Profile | null, envVar: string, defaultKey: string): string {
if (profile) {
const claimKey = process.env[envVar] as keyof Profile;
let returnVal = profile[defaultKey as keyof Profile];
// Avoids accessing `profile[""]` when the environment variable is empty string.
if (claimKey) {
returnVal = profile[claimKey] ?? profile[defaultKey as keyof Profile];
}
if (typeof returnVal == 'string') {
return returnVal;
}
}
return '';
}
function getEmail(profile: Profile | null) {
return getSamlClaim(profile, 'SAML_EMAIL_CLAIM', 'email');
}
function getUserName(profile: Profile | null): string {
return getSamlClaim(profile, 'SAML_USERNAME_CLAIM', 'username');
}
function getGivenName(profile: Profile | null) {
return getSamlClaim(profile, 'SAML_GIVEN_NAME_CLAIM', 'given_name');
}
function getFamilyName(profile: Profile | null) {
return getSamlClaim(profile, 'SAML_FAMILY_NAME_CLAIM', 'family_name');
}
function getPicture(profile: Profile | null) {
return getSamlClaim(profile, 'SAML_PICTURE_CLAIM', 'picture');
}
/**
* Downloads an image from a URL using an access token.
* @param {string} url
* @returns {Promise<Buffer>}
*/
const downloadImage = async (url: string) => {
try {
const response = await fetch(url);
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} else {
throw new Error(`${response.statusText} (HTTP ${response.status})`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error(`[samlStrategy] Error downloading image at URL "${url}": ${errorMessage}`);
return null;
}
};
/**
* Determines the full name of a user based on SAML profile and environment configuration.
*
* @param {Object} profile - The user profile object from SAML Connect
* @returns {string} The determined full name of the user
*/
function getFullName(profile: Profile | null): string {
const nameClaim = process.env.SAML_NAME_CLAIM;
if (profile && nameClaim && nameClaim in profile) {
const key = nameClaim as keyof Profile;
logger.info(
`[samlStrategy] Using SAML_NAME_CLAIM: ${process.env.SAML_NAME_CLAIM}, profile: ${profile[key]}`,
);
return profile[key] + '';
}
const givenName = getGivenName(profile);
const familyName = getFamilyName(profile);
if (givenName && familyName) {
return `${givenName} ${familyName}`;
}
if (givenName) {
return givenName + '';
}
if (familyName) {
return familyName + '';
}
return getUserName(profile) || getEmail(profile);
}
/**
* Converts an input into a string suitable for a username.
* If the input is a string, it will be returned as is.
* If the input is an array, elements will be joined with underscores.
* In case of undefined or other falsy values, a default value will be returned.
*
* @param {string | string[] | undefined} input - The input value to be converted into a username.
* @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: string | string[], defaultValue: string = '') {
if (typeof input === 'string') {
return input;
} else if (Array.isArray(input)) {
return input.join('_');
}
return defaultValue;
}
const signOnVerify = async (profile: Profile | null, done: (err: any, user?: any) => void) => {
const { findUser, createUser, updateUser } = getMethods();
try {
logger.info(`[samlStrategy] SAML authentication received for NameID: ${profile?.nameID}`);
logger.debug('[samlStrategy] SAML profile:', profile);
let user = await findUser({ samlId: profile?.nameID });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with SAML ID: ${profile?.nameID}`,
);
if (!user) {
const email = getEmail(profile) || '';
user = await findUser({ email });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${profile?.email}`,
);
}
const fullName = getFullName(profile);
const username = convertToUsername(
getUserName(profile) || getGivenName(profile) || getEmail(profile),
);
if (!user) {
user = {
provider: 'saml',
samlId: profile?.nameID,
username,
email: getEmail(profile) || '',
emailVerified: true,
name: fullName,
};
const balanceConfig = await getBalanceConfig();
user = await createUser(user, balanceConfig, true, true);
} else {
user.provider = 'saml';
user.samlId = profile?.nameID;
user.username = username;
user.name = fullName;
}
const picture = getPicture(profile);
if (picture && !user.avatar?.includes('manual=true')) {
const imageBuffer = await downloadImage(profile?.picture?.toString() ?? '');
if (imageBuffer) {
let fileName;
try {
crypto = await import('node:crypto');
} catch (err) {
logger.error('[samlStrategy] crypto support is disabled!', err);
}
if (crypto) {
fileName = (await hashToken(profile?.nameID.toString() ?? '')) + '.png';
} else {
fileName = profile?.nameID + '.png';
}
const saveBuffer = getSaveBufferStrategy();
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
buffer: imageBuffer,
});
user.avatar = imagePath ?? '';
}
}
user = await updateUser(user._id, user);
logger.info(
`[samlStrategy] Login success SAML ID: ${user.samlId} | email: ${user.email} | username: ${user.username}`,
{
user: {
samlId: user.samlId,
username: user.username,
email: user.email,
name: user.name,
},
},
);
done(null, user);
} catch (err) {
logger.error('[samlStrategy] Login failed', err);
done(err);
}
};
const samlLogin = () => {
const samlConfig: PassportSamlConfig = {
entryPoint: process.env.SAML_ENTRY_POINT,
issuer: process.env.SAML_ISSUER + '',
callbackUrl: process.env.SAML_CALLBACK_URL + '',
idpCert: getCertificateContent(process.env.SAML_CERT) ?? '',
wantAssertionsSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? false : true,
wantAuthnResponseSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? true : false,
};
return new SamlStrategy(samlConfig, signOnVerify, () => {
logger.info('saml logout!');
});
};
export { samlLogin, getCertificateContent };

View File

@@ -0,0 +1,57 @@
import { logger } from '@librechat/data-schemas';
import { Profile } from 'passport';
import { getMethods } from '../initAuth';
import { isEnabled } from '../utils';
import { createSocialUser, handleExistingUser } from './helpers';
import { GetProfileDetails, SocialLoginStrategy } from './types';
export function socialLogin(
provider: string,
getProfileDetails: GetProfileDetails,
): SocialLoginStrategy {
return async (
accessToken: string,
refreshToken: string,
idToken: string,
profile: Profile,
cb: any,
): Promise<void> => {
try {
const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails({
idToken,
profile,
});
const { findUser } = getMethods();
const oldUser = await findUser({ email: email?.trim() });
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION ?? '');
if (oldUser) {
await handleExistingUser(oldUser, avatarUrl ?? '');
return cb(null, oldUser);
}
if (ALLOW_SOCIAL_REGISTRATION) {
const newUser = await createSocialUser({
email: email ?? '',
avatarUrl: avatarUrl ?? '',
provider,
providerKey: `${provider}Id`,
providerId: id,
username,
name,
emailVerified,
});
return cb(null, newUser);
}
return cb(new Error('Social registration is disabled'));
} catch (err) {
logger.error(`[${provider}Login]`, err);
return cb(err as Error);
}
};
}
export default socialLogin;

View File

@@ -0,0 +1,34 @@
import { Profile } from 'passport';
import { IUser } from '@librechat/data-schemas';
export interface GetProfileDetailsParams {
idToken: string;
profile: Profile;
}
export type GetProfileDetails = (
params: GetProfileDetailsParams,
) => Partial<IUser> & { avatarUrl: string | null };
export type SocialLoginStrategy = (
accessToken: string,
refreshToken: string,
idToken: string,
profile: Profile,
cb: any,
) => Promise<void>;
export interface CreateSocialUserParams {
email: string;
avatarUrl: string;
provider: string;
providerKey: string;
providerId: string;
username?: string;
name?: string;
emailVerified?: boolean;
}
export interface JwtPayload {
id: string;
[key: string]: any;
}

View File

@@ -1,6 +1,16 @@
jest.mock('@librechat/data-schemas', () => {
return {
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
};
});
// file deepcode ignore NoHardcodedPasswords: No hard-coded passwords in tests
const { errorsToString } = require('librechat-data-provider');
const { loginSchema, registerSchema } = require('./validators');
import { errorsToString } from 'librechat-data-provider';
import { loginSchema, registerSchema } from '@librechat/auth';
describe('Zod Schemas', () => {
describe('loginSchema', () => {
@@ -258,7 +268,7 @@ describe('Zod Schemas', () => {
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
extraField: 'I shouldn\'t be here',
extraField: "I shouldn't be here",
});
expect(result.success).toBe(true);
});
@@ -407,7 +417,7 @@ describe('Zod Schemas', () => {
'john{doe}', // Contains `{` and `}`
'j', // Only one character
'a'.repeat(81), // More than 80 characters
'\' OR \'1\'=\'1\'; --', // SQL Injection
"' OR '1'='1'; --", // SQL Injection
'{$ne: null}', // MongoDB Injection
'<script>alert("XSS")</script>', // Basic XSS
'"><script>alert("XSS")</script>', // XSS breaking out of an attribute

View File

@@ -1,20 +1,21 @@
const { z } = require('zod');
import { z } from 'zod';
const allowedCharactersRegex = new RegExp(
'^[' +
'a-zA-Z0-9_.@#$%&*()' + // Basic Latin characters and symbols
'\\p{Script=Latin}' + // Latin script characters
'\\p{Script=Common}' + // Characters common across scripts
'\\p{Script=Cyrillic}' + // Cyrillic script for Russian, etc.
'\\p{Script=Devanagari}' + // Devanagari script for Hindi, etc.
'\\p{Script=Han}' + // Han script for Chinese characters, etc.
'\\p{Script=Cyrillic}' + // Cyrillic script
'\\p{Script=Devanagari}' + // Devanagari script
'\\p{Script=Han}' + // Han script
'\\p{Script=Arabic}' + // Arabic script
'\\p{Script=Hiragana}' + // Hiragana script for Japanese
'\\p{Script=Katakana}' + // Katakana script for Japanese
'\\p{Script=Hangul}' + // Hangul script for Korean
']+$', // End of string
'u', // Use Unicode mode
'\\p{Script=Hiragana}' + // Hiragana
'\\p{Script=Katakana}' + // Katakana
'\\p{Script=Hangul}' + // Hangul
']+$', // End
'u', // Unicode mode
);
const injectionPatternsRegex = /('|--|\$ne|\$gt|\$lt|\$or|\{|\}|\*|;|<|>|\/|=)/i;
const usernameSchema = z
@@ -72,7 +73,4 @@ const registerSchema = z
}
});
module.exports = {
loginSchema,
registerSchema,
};
export { usernameSchema, loginSchema, registerSchema };

View File

@@ -0,0 +1,22 @@
import { EImageOutputType } from 'librechat-data-provider';
import sharp from 'sharp';
export interface ResizeAvatarParams {
userId: string;
input: string | Buffer | File;
desiredFormat?: typeof EImageOutputType;
}
export interface ResizeAndConvertOptions {
inputBuffer: Buffer;
desiredFormat: keyof sharp.FormatEnum | typeof EImageOutputType;
width?: number;
}
export interface ProcessAvatarParams {
buffer: Buffer;
userId: string;
manual?: string | boolean;
basePath?: string;
containerName?: string;
}

View File

@@ -0,0 +1,33 @@
import { TransportOptions, SendMailOptions } from 'nodemailer';
export interface SendEmailParams {
email: string;
subject: string;
payload: Record<string, string | number>;
template: string;
throwError?: boolean;
}
export interface SendEmailResponse {
accepted: string[];
rejected: string[];
response: string;
envelope: { from: string; to: string[] };
messageId: string;
}
export interface MailgunEmailParams {
to: string;
from: string;
subject: string;
html: string;
}
export interface MailgunResponse {
id: string;
message: string;
}
export interface SMTPParams {
transporterOptions: any;
mailOptions: SendMailOptions;
}

View File

@@ -0,0 +1,10 @@
export interface LogoutResponse {
status: number;
message: string;
}
export interface AuthenticatedRequest extends Request {
user?: { _id: string };
session?: {
destroy: (callback?: (err?: any) => void) => void;
};
}

View File

@@ -0,0 +1,283 @@
import sharp from 'sharp';
import { FileSources } from 'librechat-data-provider';
import fs from 'fs';
import path from 'path';
import { getMethods, getSaveBufferStrategy } from '../initAuth';
import { logger } from '@librechat/data-schemas';
import { ProcessAvatarParams, ResizeAndConvertOptions, ResizeAvatarParams } from '../types/avatar';
const { EImageOutputType } = require('librechat-data-provider');
const defaultBasePath = 'images';
const getAvatarProcessFunction = (fileSource: string): Function => {
if (fileSource === FileSources.firebase) {
return processFirebaseAvatar;
} else if (fileSource === FileSources.local) {
return processLocalAvatar;
} else if (fileSource === FileSources.azure_blob) {
return processAzureAvatar;
} else if (fileSource === FileSources.s3) {
return processS3Avatar;
} else {
throw new Error('Invalid file source for saving avata');
}
};
/**
* Uploads a user's avatar to Firebase Storage and returns the URL.
* If the 'manual' flag is set to 'true', it also updates the user's avatar URL in the database.
*
* @param {object} params - The parameters object.
* @param {Buffer} params.buffer - The Buffer containing the avatar image.
* @param {string} params.userId - The user ID.
* @param {string} params.manual - A string flag indicating whether the update is manual ('true' or 'false').
* @returns {Promise<string>} - A promise that resolves with the URL of the uploaded avatar.
* @throws {Error} - Throws an error if Firebase is not initialized or if there is an error in uploading.
*/
async function processFirebaseAvatar({
buffer,
userId,
manual,
}: ProcessAvatarParams): Promise<string> {
try {
const saveBufferToFirebase = getSaveBufferStrategy();
const downloadURL = await saveBufferToFirebase({
userId,
buffer,
fileName: 'avatar.png',
});
const isManual = manual === 'true';
const url = `${downloadURL}?manual=${isManual}`;
if (isManual) {
const { updateUser } = getMethods();
await updateUser(userId, { avatar: url });
}
return url;
} catch (error) {
logger.error('Error uploading profile picture:', error);
throw error;
}
}
/**
* Uploads a user's avatar to local server storage and returns the URL.
* If the 'manual' flag is set to 'true', it also updates the user's avatar URL in the database.
*
* @param {object} params - The parameters object.
* @param {Buffer} params.buffer - The Buffer containing the avatar image.
* @param {string} params.userId - The user ID.
* @param {string} params.manual - A string flag indicating whether the update is manual ('true' or 'false').
* @returns {Promise<string>} - A promise that resolves with the URL of the uploaded avatar.
* @throws {Error} - Throws an error if Firebase is not initialized or if there is an error in uploading.
*/
async function processLocalAvatar({ buffer, userId, manual }: ProcessAvatarParams) {
const userDir = path.resolve(
__dirname,
'..',
'..',
'..',
'..',
'..',
'client',
'public',
'images',
userId,
);
const fileName = `avatar-${new Date().getTime()}.png`;
const urlRoute = `/images/${userId}/${fileName}`;
const avatarPath = path.join(userDir, fileName);
await fs.promises.mkdir(userDir, { recursive: true });
await fs.promises.writeFile(avatarPath, buffer);
const isManual = manual === 'true';
let url = `${urlRoute}?manual=${isManual}`;
if (isManual) {
const { updateUser } = getMethods();
await updateUser(userId, { avatar: url });
}
return url;
}
/**
* Processes a user's avatar image by uploading it to S3 and updating the user's avatar URL if required.
*
* @param {Object} params
* @param {Buffer} params.buffer - Avatar image buffer.
* @param {string} params.userId - User's unique identifier.
* @param {string} params.manual - 'true' or 'false' flag for manual update.
* @param {string} [params.basePath='images'] - Base path in the bucket.
* @returns {Promise<string>} Signed URL of the uploaded avatar.
*/
async function processS3Avatar({
buffer,
userId,
manual,
basePath = defaultBasePath,
}: ProcessAvatarParams): Promise<string> {
try {
const saveBufferToS3 = getSaveBufferStrategy();
const downloadURL = await saveBufferToS3({ userId, buffer, fileName: 'avatar.png', basePath });
if (manual === 'true') {
const { updateUser } = getMethods();
await updateUser(userId, { avatar: downloadURL });
}
return downloadURL;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error('Error processing S3 avatar: ' + errorMessage);
}
}
/**
* Uploads and processes a user's avatar to Azure Blob Storage.
*
* @param {Object} params
* @param {Buffer} params.buffer - The avatar image buffer.
* @param {string} params.userId - The user's id.
* @param {string} params.manual - Flag to indicate manual update.
* @param {string} [params.basePath='images'] - The base folder within the container.
* @param {string} [params.containerName] - The Azure Blob container name.
* @returns {Promise<string>} The URL of the avatar.
*/
async function processAzureAvatar({
buffer,
userId,
manual,
basePath = 'images',
containerName,
}: ProcessAvatarParams) {
try {
const saveBufferToAzure = getSaveBufferStrategy();
const downloadURL = await saveBufferToAzure({
userId,
buffer,
fileName: 'avatar.png',
basePath,
containerName,
});
const isManual = manual === 'true';
const url = `${downloadURL}?manual=${isManual}`;
if (isManual) {
const { updateUser } = getMethods();
await updateUser(userId, { avatar: url });
}
return url;
} catch (error) {
logger.error('[processAzureAvatar] Error uploading profile picture to Azure:', error);
throw error;
}
}
/**
* Uploads an avatar image for a user. This function can handle various types of input (URL, Buffer, or File object),
* processes the image to a square format, converts it to target format, and returns the resized buffer.
*
* @param {Object} params - The parameters object.
* @param {string} params.userId - The unique identifier of the user for whom the avatar is being uploaded.
* @param {string} options.desiredFormat - The desired output format of the image.
* @param {(string|Buffer|File)} params.input - The input representing the avatar image. Can be a URL (string),
* a Buffer, or a File object.
*
* @returns {Promise<any>}
* A promise that resolves to a resized buffer.
*
* @throws {Error} Throws an error if the user ID is undefined, the input type is invalid, the image fetching fails,
* or any other error occurs during the processing.
*/
async function resizeAvatar({
userId,
input,
desiredFormat = EImageOutputType.PNG,
}: ResizeAvatarParams) {
try {
if (userId === undefined) {
throw new Error('User ID is undefined');
}
let imageBuffer: Buffer;
if (typeof input === 'string') {
const response = await fetch(input);
if (!response.ok) {
throw new Error(`Failed to fetch image from URL. Status: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
imageBuffer = Buffer.from(arrayBuffer);
} else if (input instanceof Buffer) {
imageBuffer = input;
} else if (typeof input === 'object' && input instanceof File) {
const fileContent = await fs.promises.readFile(input?.path);
imageBuffer = Buffer.from(fileContent);
} else {
throw new Error('Invalid input type. Expected URL, Buffer, or File.');
}
const metadata = await sharp(imageBuffer).metadata();
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
const minSize = Math.min(width, height);
if (metadata.format === 'gif') {
const resizedBuffer = await sharp(imageBuffer, { animated: true })
.extract({
left: Math.floor((width - minSize) / 2),
top: Math.floor((height - minSize) / 2),
width: minSize,
height: minSize,
})
.resize(250, 250)
.gif()
.toBuffer();
return resizedBuffer;
}
const squaredBuffer = await sharp(imageBuffer)
.extract({
left: Math.floor((width - minSize) / 2),
top: Math.floor((height - minSize) / 2),
width: minSize,
height: minSize,
})
.toBuffer();
const buffer = await resizeAndConvert({
inputBuffer: squaredBuffer,
desiredFormat,
});
return buffer;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error('Error uploading the avatar: ' + errorMessage);
}
}
/**
* Resizes an image buffer to a specified format and width.
*
* @param {ResizeAndConvertOptions} options - The options for resizing and converting the image.
* @returns {Buffer} An object containing the resized image buffer, its size, and dimensions.
* @throws Will throw an error if the resolution or format parameters are invalid.
*/
async function resizeAndConvert({
inputBuffer,
desiredFormat,
width = 150,
}: ResizeAndConvertOptions) {
const resizedBuffer: Buffer = await sharp(inputBuffer)
.resize({ width })
.toFormat(desiredFormat as keyof sharp.FormatEnum)
.toBuffer();
return resizedBuffer;
}
export { resizeAvatar, resizeAndConvert, getAvatarProcessFunction };

View File

@@ -0,0 +1,313 @@
import fs from 'fs';
import path from 'path';
import nodemailer, { SentMessageInfo } from 'nodemailer';
import handlebars from 'handlebars';
import { createTokenHash } from '.';
import { logAxiosError } from '@librechat/api';
import { isEnabled } from '.';
import { IUser, logger } from '@librechat/data-schemas';
import { getMethods } from '../initAuth';
import { ObjectId } from 'mongoose';
import bcrypt from 'bcryptjs';
import { Request } from 'express';
import FormData from 'form-data';
import axios, { AxiosResponse } from 'axios';
import { MailgunEmailParams, SendEmailParams, SMTPParams } from '../types/email';
const genericVerificationMessage = 'Please check your email to verify your email address.';
const domains = {
client: process.env.DOMAIN_CLIENT,
server: process.env.DOMAIN_SERVER,
};
/**
* Sends an email using Mailgun API.
*
* @async
* @function sendEmailViaMailgun
* @param {Object} params - The parameters for sending the email.
* @param {string} params.to - The recipient's email address.
* @param {string} params.from - The sender's email address.
* @param {string} params.subject - The subject of the email.
* @param {string} params.html - The HTML content of the email.
* @returns {Promise<Object>} - A promise that resolves to the response from Mailgun API.
*/
const sendEmailViaMailgun = async ({
to,
from,
subject,
html,
}: MailgunEmailParams): Promise<SentMessageInfo> => {
const mailgunApiKey: string | undefined = process.env.MAILGUN_API_KEY;
const mailgunDomain: string | undefined = process.env.MAILGUN_DOMAIN;
const mailgunHost: string = process.env.MAILGUN_HOST || 'smtp.mailgun.org';
if (!mailgunApiKey || !mailgunDomain) {
throw new Error('Mailgun API key and domain are required');
}
const formData = new FormData();
formData.append('from', from);
formData.append('to', to);
formData.append('subject', subject);
formData.append('html', html);
formData.append('o:tracking-clicks', 'no');
try {
const response = await axios.post(`${mailgunHost}/v3/${mailgunDomain}/messages`, formData, {
headers: {
...formData.getHeaders(),
Authorization: `Basic ${Buffer.from(`api:${mailgunApiKey}`).toString('base64')}`,
},
});
return response.data;
} catch (error: any) {
throw new Error(logAxiosError({ error, message: 'Failed to send email via Mailgun' }));
}
};
/**
* Sends an email using SMTP via Nodemailer.
*
* @async
* @function sendEmailViaSMTP
* @param {Object} params - The parameters for sending the email.
* @param {Object} params.transporterOptions - The transporter configuration options.
* @param {Object} params.mailOptions - The email options.
* @returns {Promise<Object>} - A promise that resolves to the info object of the sent email.
*/
const sendEmailViaSMTP = async ({
transporterOptions,
mailOptions,
}: SMTPParams): Promise<SentMessageInfo> => {
const transporter = nodemailer.createTransport(transporterOptions);
return await transporter.sendMail(mailOptions);
};
export const sendEmail = async ({
email,
subject,
payload,
template,
throwError = true,
}: SendEmailParams): Promise<SentMessageInfo | Error> => {
try {
// Read and compile the email template
const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8');
const compiledTemplate = handlebars.compile(source);
const html = compiledTemplate(payload);
// Prepare common email data
const fromName = process.env.EMAIL_FROM_NAME || process.env.APP_TITLE;
const fromEmail = process.env.EMAIL_FROM;
const fromAddress = `"${fromName}" <${fromEmail}>`;
const toAddress = `"${payload.name}" <${email}>`;
// Check if Mailgun is configured
if (process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN) {
logger.debug('[sendEmail] Using Mailgun provider');
return await sendEmailViaMailgun({
from: fromAddress,
to: toAddress,
subject: subject,
html: html,
});
}
// Default to SMTP
logger.debug('[sendEmail] Using SMTP provider');
const transporterOptions: any = {
secure: process.env.EMAIL_ENCRYPTION === 'tls',
requireTLS: process.env.EMAIL_ENCRYPTION === 'starttls',
tls: {
rejectUnauthorized: !isEnabled(process.env.EMAIL_ALLOW_SELFSIGNED ?? ''),
},
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD,
},
};
if (process.env.EMAIL_ENCRYPTION_HOSTNAME) {
transporterOptions.tls = {
...transporterOptions.tls,
servername: process.env.EMAIL_ENCRYPTION_HOSTNAME,
};
}
if (process.env.EMAIL_SERVICE) {
transporterOptions.service = process.env.EMAIL_SERVICE;
} else {
transporterOptions.host = process.env.EMAIL_HOST;
transporterOptions.port = Number(process.env.EMAIL_PORT ?? 25);
}
const mailOptions = {
// Header address should contain name-addr
from: fromAddress,
to: toAddress,
envelope: {
// Envelope from should contain addr-spec
// Mistake in the Nodemailer documentation?
from: fromEmail,
to: email,
},
subject: subject,
html: html,
};
return await sendEmailViaSMTP({ transporterOptions, mailOptions });
} catch (error: any) {
if (throwError) {
throw error;
}
logger.error('[sendEmail]', error);
return error;
}
};
/**
* Send Verification Email
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
* @returns {Promise<void>}
*/
export const sendVerificationEmail = async (
user: Partial<IUser> & { _id: ObjectId; email: string },
) => {
const [verifyToken, hash] = createTokenHash();
const { createToken } = getMethods();
const verificationLink = `${
domains.client
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
await sendEmail({
email: user.email,
subject: 'Verify your email',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
template: 'verifyEmail.handlebars',
});
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
};
/**
* Verify Email
* @param {Express.Request} req
*/
export const verifyEmail = async (req: Request) => {
const { email, token } = req.body;
const decodedEmail = decodeURIComponent(email);
const { findUser, findToken, updateUser, deleteTokens } = getMethods();
const user = await findUser({ email: decodedEmail }, 'email _id emailVerified');
if (!user) {
logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`);
return new Error('User not found');
}
if (user.emailVerified) {
logger.info(`[verifyEmail] Email already verified [Email: ${decodedEmail}]`);
return { message: 'Email already verified', status: 'success' };
}
let emailVerificationData = await findToken({ email: decodedEmail });
if (!emailVerificationData) {
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`);
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, emailVerificationData.token);
if (!isValid) {
logger.warn(
`[verifyEmail] [Invalid or expired email verification token] [Email: ${decodedEmail}]`,
);
return new Error('Invalid or expired email verification token');
}
const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true });
if (!updatedUser) {
logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`);
return new Error('Failed to update user verification status');
}
await deleteTokens({ token: emailVerificationData.token });
logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`);
return { message: 'Email verification was successful', status: 'success' };
};
/**
* Resend Verification Email
* @param {Object} req
* @param {Object} req.body
* @param {String} req.body.email
* @returns {Promise<{status: number, message: string}>}
*/
export const resendVerificationEmail = async (req: Request) => {
try {
const { deleteTokens, findUser, createToken } = getMethods();
const { email } = req.body as { email: string };
await deleteTokens(email);
const user = await findUser({ email }, 'email _id name');
if (!user) {
logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`);
return { status: 200, message: genericVerificationMessage };
}
const [verifyToken, hash] = createTokenHash();
const verificationLink = `${
domains.client
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
await sendEmail({
email: user.email,
subject: 'Verify your email',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
template: 'verifyEmail.handlebars',
});
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
return {
status: 200,
message: genericVerificationMessage,
};
} catch (error: any) {
logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`);
return {
status: 500,
message: 'Something went wrong.',
};
}
};

View File

@@ -0,0 +1,66 @@
export * from './avatar';
import { webcrypto } from 'node:crypto';
import bcrypt from 'bcryptjs';
/**
* Creates Token and corresponding Hash for verification
* @returns {[string, string]}
*/
const createTokenHash = (): [string, string] => {
const token: string = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString('hex');
const hash: string = bcrypt.hashSync(token, 10);
return [token, hash];
};
/**
* Checks if the given value is truthy by being either the boolean `true` or a string
* that case-insensitively matches 'true'.
*
* @function
* @param {string|boolean|null|undefined} value - The value to check.
* @returns {boolean} Returns `true` if the value is the boolean `true` or a case-insensitive
* match for the string 'true', otherwise returns `false`.
* @example
*
* isEnabled("True"); // returns true
* isEnabled("TRUE"); // returns true
* isEnabled(true); // returns true
* isEnabled("false"); // returns false
* isEnabled(false); // returns false
* isEnabled(null); // returns false
* isEnabled(); // returns false
*/
function isEnabled(value: boolean | string) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
return value.toLowerCase().trim() === 'true';
}
return false;
}
/**
* Check if email configuration is set
* @returns {Boolean}
*/
function checkEmailConfig() {
// Check if Mailgun is configured
const hasMailgunConfig =
!!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM;
// Check if SMTP is configured
const hasSMTPConfig =
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM;
// Return true if either Mailgun or SMTP is properly configured
return hasMailgunConfig || hasSMTPConfig;
}
export { checkEmailConfig, isEnabled, createTokenHash };
// export this helper so we can mock them
export { sendEmail, sendVerificationEmail, verifyEmail, resendVerificationEmail } from './email';
export { resizeAvatar, resizeAndConvert, getAvatarProcessFunction } from './avatar';
export { requestPasswordReset, resetPassword } from './password';

View File

@@ -0,0 +1,113 @@
import { ObjectId } from 'mongoose';
import { getMethods } from '../initAuth';
import bcrypt from 'bcryptjs';
import { sendEmail } from './email';
import { logger } from '@librechat/data-schemas';
import { checkEmailConfig, createTokenHash } from '.';
import { Request } from 'express';
/**
* Reset Password
*
* @param {*} userId
* @param {String} token
* @param {String} password
* @returns
*/
const resetPassword = async (userId: string | ObjectId, token: string, password: string) => {
const { findToken, updateUser, deleteTokens } = getMethods();
let passwordResetToken = await findToken({
userId,
});
if (!passwordResetToken) {
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, passwordResetToken.token);
if (!isValid) {
return new Error('Invalid or expired password reset token');
}
const hash = bcrypt.hashSync(password, 10);
const user = await updateUser(userId, { password: hash });
if (checkEmailConfig()) {
await sendEmail({
email: user.email,
subject: 'Password Reset Successfully',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
year: new Date().getFullYear(),
},
template: 'passwordReset.handlebars',
});
}
await deleteTokens({ token: passwordResetToken.token });
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
return { message: 'Password reset was successful' };
};
/**
* Request password reset
* @param {Express.Request} req
*/
const requestPasswordReset = async (req: Request) => {
const { email } = req.body;
const { findUser, createToken, deleteTokens } = getMethods();
const user = await findUser({ email }, 'email _id');
const emailEnabled = checkEmailConfig();
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);
if (!user) {
logger.warn(`[requestPasswordReset] [No user found] [Email: ${email}] [IP: ${req.ip}]`);
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
}
await deleteTokens({ userId: user._id });
const [resetToken, hash] = createTokenHash();
await createToken({
userId: user._id,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
const link = `${process.env.DOMAIN_CLIENT}/reset-password?token=${resetToken}&userId=${user._id}`;
if (emailEnabled) {
await sendEmail({
email: user.email,
subject: 'Password Reset Request',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
link: link,
year: new Date().getFullYear(),
},
template: 'requestPasswordReset.handlebars',
});
logger.info(
`[requestPasswordReset] Link emailed. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
);
} else {
logger.info(
`[requestPasswordReset] Link issued. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
);
return { link };
}
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
};
export { requestPasswordReset, resetPassword };

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2019",
"module": "ESNext",
"moduleResolution": "node",
"declaration": true,
"declarationDir": "dist/types",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@librechat/data-schemas/*": ["./packages/data-schemas/*"]
},
"typeRoots": ["./src/types", "./node_modules/@types"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"outDir": "./dist/tests",
"baseUrl": "."
},
"include": ["specs/**/*", "src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -58,7 +58,7 @@ function redactMessage(str: string, trimLength?: number): string {
* @returns The modified log information object.
*/
const redactFormat = winston.format((info: winston.Logform.TransformableInfo) => {
if (info.level === 'error') {
if (info && info.level === 'error') {
// Type guard to ensure message is a string
if (typeof info.message === 'string') {
info.message = redactMessage(info.message);