- Move auth strategies to package/auth

- Move email and avatar functions to package/auth
This commit is contained in:
Cha 2025-06-16 20:24:26 +08:00
parent e77aa92a7b
commit f68be4727c
65 changed files with 2089 additions and 1967 deletions

View file

@ -1,18 +1,67 @@
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'node',
clearMocks: true,
roots: ['<rootDir>'],
coverageDirectory: 'coverage',
setupFiles: [
'./test/jestSetup.js',
'./test/__mocks__/logger.js',
'./test/__mocks__/fetchEventSource.js',
// Define separate Jest projects
projects: [
// Default config for most tests
{
displayName: 'default',
testEnvironment: 'node',
clearMocks: true,
roots: ['<rootDir>'],
coverageDirectory: 'coverage',
setupFiles: [
'./test/jestSetup.js',
'./test/__mocks__/logger.js',
'./test/__mocks__/fetchEventSource.js',
],
moduleNameMapper: {
'~/(.*)': '<rootDir>/$1',
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
'^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)/).*/'],
// testMatch: ['<rootDir>/**/*.spec.js', '<rootDir>/**/*.spec.ts'],
testPathIgnorePatterns: [
'<rootDir>/strategies/openidStrategy.spec.js',
'<rootDir>/strategies/samlStrategy.spec.js',
'<rootDir>/strategies/appleStrategy.test.js',
],
},
// Special config just for openidStrategy.spec.js
{
displayName: 'openid-strategy',
testEnvironment: 'node',
clearMocks: true,
setupFiles: [
'./test/jestSetup.js',
'./test/__mocks__/logger.js',
'./test/__mocks__/fetchEventSource.js',
],
moduleNameMapper: {
'~/(.*)': '<rootDir>/$1',
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
'^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)/).*/'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: {
esModuleInterop: true,
allowSyntheticDefaultImports: true,
},
},
],
},
testMatch: [
'<rootDir>/strategies/openidStrategy.spec.js',
'<rootDir>/strategies/samlStrategy.spec.js',
'<rootDir>/strategies/appleStrategy.test.js',
],
},
],
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$': '<rootDir>/test/__mocks__/openid-client.js',
},
transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'],
};

View file

@ -118,6 +118,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,6 +1,5 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const {
registerUser,
@ -10,7 +9,7 @@ const {
setOpenIDAuthTokens,
} = 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');
@ -69,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

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

View file

@ -11,10 +11,8 @@ const fs = require('fs');
const cookieParser = require('cookie-parser');
const { connectDb, indexSync } = require('~/db');
const { jwtLogin, passportLogin } = require('~/strategies');
const { initAuthModels } = require('@librechat/auth');
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');
@ -23,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 ?? {};
@ -38,7 +39,11 @@ const startServer = async () => {
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
}
const mongooseInstance = await connectDb();
initAuthModels(mongooseInstance);
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,
@ -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,69 +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 { width, height } = await sharp(imageBuffer).metadata();
const minSize = Math.min(width, height);
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,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,7 +2,6 @@ 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');
@ -14,7 +13,6 @@ module.exports = {
...handleText,
countTokens,
removePorts,
sendEmail,
...files,
...queue,
math,

View file

@ -1,98 +0,0 @@
const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
const handlebars = require('handlebars');
const { isEnabled } = require('~/server/utils/handleText');
const logger = require('~/config/winston');
/**
* 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 {
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 transporter = nodemailer.createTransport(transporterOptions);
const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8');
const compiledTemplate = handlebars.compile(source);
const options = () => {
return {
// Header address should contain name-addr
from:
`"${process.env.EMAIL_FROM_NAME || process.env.APP_TITLE}"` +
`<${process.env.EMAIL_FROM}>`,
to: `"${payload.name}" <${email}>`,
envelope: {
// Envelope from should contain addr-spec
// Mistake in the Nodemailer documentation?
from: process.env.EMAIL_FROM,
to: email,
},
subject: subject,
html: compiledTemplate(payload),
};
};
// Send email
return await transporter.sendMail(options());
} catch (error) {
if (throwError) {
throw error;
}
logger.error('[sendEmail]', error);
return error;
}
};
module.exports = sendEmail;

View file

@ -1,14 +1,10 @@
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');
const findUser = jest.fn();
jest.mock('jsonwebtoken');
jest.mock('@librechat/data-schemas', () => {
const actualModule = jest.requireActual('@librechat/data-schemas');
@ -18,19 +14,32 @@ jest.mock('@librechat/data-schemas', () => {
error: jest.fn(),
debug: jest.fn(),
},
createMethods: jest.fn(() => {
return { findUser };
}),
};
});
jest.mock('../../packages/auth/src/strategies/helpers', () => {
const actualModule = jest.requireActual('../../packages/auth/src/strategies/helpers');
return {
...actualModule,
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
};
});
jest.mock('./process', () => ({
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
}));
jest.mock('~/server/utils', () => ({
isEnabled: jest.fn(),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
}));
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const { isEnabled } = require('~/server/utils');
const {
createSocialUser,
handleExistingUser,
} = require('../../packages/auth/src/strategies/helpers');
const { socialLogin } = require('../../packages/auth/src/strategies/socialLogin');
describe('Apple Login Strategy', () => {
let mongoServer;
let appleStrategyInstance;
@ -107,6 +116,7 @@ describe('Apple Login Strategy', () => {
// Initialize the strategy with the mocked getProfileDetails
const appleLogin = socialLogin('apple', getProfileDetails);
appleStrategyInstance = new AppleStrategy(
{
clientID: process.env.APPLE_CLIENT_ID,
@ -209,9 +219,13 @@ describe('Apple Login Strategy', () => {
const fakeAccessToken = 'fake_access_token';
const fakeRefreshToken = 'fake_refresh_token';
beforeEach(() => {
beforeEach(async () => {
jwt.decode.mockReturnValue(decodedToken);
findUser.mockResolvedValue(null);
const { initAuth } = require('../../packages/auth/src/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 () => {
@ -241,7 +255,10 @@ describe('Apple Login Strategy', () => {
);
});
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');

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,26 +1,28 @@
const fetch = require('node-fetch');
const jwtDecode = require('jsonwebtoken/decode');
const { setupOpenId } = require('./openidStrategy');
const { findUser, createUser, updateUser } = require('~/models');
const passport = require('passport');
const mongoose = require('mongoose');
// --- 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', () => ({
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),
};
});
jest.mock('~/server/utils/crypto', () => ({
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));
@ -44,7 +46,9 @@ jest.mock('~/cache/getLogStores', () =>
// 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',
@ -63,13 +67,17 @@ jest.mock('openid-client', () => {
jest.mock('openid-client/passport', () => {
let verifyCallback;
const mockStrategy = jest.fn((options, verify) => {
const mockConstructor = jest.fn((options, verify) => {
verifyCallback = verify;
return { name: 'openid', options, verify };
return {
name: 'openid',
options,
verify,
};
});
return {
Strategy: mockStrategy,
Strategy: mockConstructor,
__getVerifyCallback: () => verifyCallback,
};
});
@ -79,6 +87,8 @@ jest.mock('passport', () => ({
use: jest.fn(),
}));
const jwtDecode = require('jsonwebtoken/decode');
describe('setupOpenId', () => {
// Store a reference to the verify callback once it's set up
let verifyCallback;
@ -135,25 +145,31 @@ describe('setupOpenId', () => {
});
// By default, assume that no user is found, so createUser will be called
findUser.mockResolvedValue(null);
createUser.mockImplementation(async (userData) => {
mockedMethods.findUser.mockResolvedValue(null);
mockedMethods.createUser.mockImplementation(async (userData) => {
// simulate created user with an _id property
return { _id: 'newUserId', ...userData };
});
updateUser.mockImplementation(async (id, userData) => {
mockedMethods.updateUser.mockImplementation(async (id, userData) => {
return { _id: id, ...userData };
});
// For image download, simulate a successful response
const fakeBuffer = Buffer.from('fake image');
const fakeResponse = {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
buffer: jest.fn().mockResolvedValue(fakeBuffer),
};
fetch.mockResolvedValue(fakeResponse);
arrayBuffer: jest.fn().mockResolvedValue(Buffer.from('fake image')),
});
// const { initAuth, setupOpenId } = require('@librechat/auth');
const { setupOpenId } = require('../../packages/auth/src/strategies/openidStrategy');
const { initAuth } = require('../../packages/auth/src/initAuth');
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);
// Call the setup function and capture the verify callback
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
});
@ -166,7 +182,7 @@ describe('setupOpenId', () => {
// Assert
expect(user.username).toBe(userinfo.username);
expect(createUser).toHaveBeenCalledWith(
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
@ -192,7 +208,7 @@ describe('setupOpenId', () => {
// Assert
expect(user.username).toBe(expectUsername);
expect(createUser).toHaveBeenCalledWith(
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
@ -212,7 +228,7 @@ describe('setupOpenId', () => {
// Assert
expect(user.username).toBe(expectUsername);
expect(createUser).toHaveBeenCalledWith(
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
@ -230,7 +246,7 @@ describe('setupOpenId', () => {
// Assert username should equal the sub (converted as-is)
expect(user.username).toBe(userinfo.sub);
expect(createUser).toHaveBeenCalledWith(
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: userinfo.sub }),
{ enabled: false },
true,
@ -272,7 +288,7 @@ describe('setupOpenId', () => {
username: '',
name: '',
};
findUser.mockImplementation(async (query) => {
mockedMethods.findUser.mockImplementation(async (query) => {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
return existingUser;
}
@ -285,7 +301,7 @@ describe('setupOpenId', () => {
await validate(tokenset);
// Assert updateUser should be called and the user object updated
expect(updateUser).toHaveBeenCalledWith(
expect(mockedMethods.updateUser).toHaveBeenCalledWith(
existingUser._id,
expect.objectContaining({
provider: 'openid',
@ -301,7 +317,6 @@ describe('setupOpenId', () => {
jwtDecode.mockReturnValue({
roles: ['SomeOtherRole'],
});
const userinfo = tokenset.claims();
// Act
const { user, details } = await validate(tokenset);
@ -312,14 +327,12 @@ describe('setupOpenId', () => {
});
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();
expect(global.fetch).toHaveBeenCalled();
// Our mock getStrategyFunctions.saveBuffer returns '/fake/path/to/avatar.png'
expect(user.avatar).toBe('/fake/path/to/avatar.png');
});
@ -333,7 +346,7 @@ describe('setupOpenId', () => {
await validate({ ...tokenset, claims: () => userinfo });
// Assert fetch should not be called and avatar should remain undefined or empty
expect(fetch).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
// Depending on your implementation, user.avatar may be undefined or an empty string.
});
@ -341,7 +354,8 @@ describe('setupOpenId', () => {
const OpenIDStrategy = require('openid-client/passport').Strategy;
delete process.env.OPENID_USE_PKCE;
await setupOpenId();
const { setupOpenId } = require('../../packages/auth/src/strategies/openidStrategy');
await setupOpenId({});
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
expect(callOptions.usePKCE).toBe(false);

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,20 +1,34 @@
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');
const passport = require('passport');
const mongoose = require('mongoose');
// --- Mocks ---
jest.mock('fs');
jest.mock('path');
jest.mock('node-fetch');
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(),
}));
jest.mock('@node-saml/passport-saml');
jest.mock('~/models', () => ({
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),
};
});
jest.mock('~/server/services/Config', () => ({
config: {
registration: {
@ -33,11 +47,7 @@ jest.mock('~/server/utils', () => ({
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'),
}));
@ -49,14 +59,12 @@ jest.mock('~/config', () => ({
},
}));
const path = require('path');
const fs = require('fs');
// 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('../../packages/auth/src/strategies/samlStrategy');
// const { getCertificateContent } = require('@librechat/auth');
const certWithHeader = `-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUKhXaFJGJJPx466rlwYORIsqCq7MwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
@ -132,6 +140,7 @@ u7wlOSk+oFzDIO/UILIA
fs.readFileSync.mockReturnValue(certWithHeader);
const actual = getCertificateContent(process.env.SAML_CERT);
console.log(actual);
expect(actual).toBe(certWithHeader);
});
@ -185,6 +194,8 @@ u7wlOSk+oFzDIO/UILIA
});
describe('setupSaml', () => {
let verifyCallback;
// Helper to wrap the verify callback in a promise
const validate = (profile) =>
new Promise((resolve, reject) => {
@ -212,13 +223,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,14 +269,24 @@ 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 { samlLogin } = require('../../packages/auth/src/strategies/samlStrategy');
const { initAuth } = require('../../packages/auth/src/initAuth');
const saveBufferMock = jest.fn().mockResolvedValue('/fake/path/to/avatar.png');
await initAuth(mongoose, { enabled: false }, saveBufferMock);
// Simulate the app's `passport.use(...)`
const SamlStrategy = samlLogin();
passport.use('saml', SamlStrategy);
console.log('SamlStrategy', SamlStrategy);
verifyCallback = SamlStrategy._signonVerify;
console.log('----', verifyCallback);
});
it('should create a new user with correct username when username claim exists', async () => {
@ -403,7 +423,7 @@ u7wlOSk+oFzDIO/UILIA
const { user } = await validate(profile);
expect(fetch).toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalled();
expect(user.avatar).toBe('/fake/path/to/avatar.png');
});
@ -413,6 +433,6 @@ u7wlOSk+oFzDIO/UILIA
await validate(profile);
expect(fetch).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
});
});

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,78 +0,0 @@
const { z } = require('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=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
);
const injectionPatternsRegex = /('|--|\$ne|\$gt|\$lt|\$or|\{|\}|\*|;|<|>|\/|=)/i;
const usernameSchema = z
.string()
.min(2)
.max(80)
.refine((value) => allowedCharactersRegex.test(value), {
message: 'Invalid characters in username',
})
.refine((value) => !injectionPatternsRegex.test(value), {
message: 'Potential injection attack detected',
});
const loginSchema = z.object({
email: z.string().email(),
password: z
.string()
.min(8)
.max(128)
.refine((value) => value.trim().length > 0, {
message: 'Password cannot be only spaces',
}),
});
const registerSchema = z
.object({
name: z.string().min(3).max(80),
username: z
.union([z.literal(''), usernameSchema])
.transform((value) => (value === '' ? null : value))
.optional()
.nullable(),
email: z.string().email(),
password: z
.string()
.min(8)
.max(128)
.refine((value) => value.trim().length > 0, {
message: 'Password cannot be only spaces',
}),
confirm_password: z
.string()
.min(8)
.max(128)
.refine((value) => value.trim().length > 0, {
message: 'Password cannot be only spaces',
}),
})
.superRefine(({ confirm_password, password }, ctx) => {
if (confirm_password !== password) {
ctx.addIssue({
code: 'custom',
message: 'The passwords did not match',
});
}
});
module.exports = {
loginSchema,
registerSchema,
};

View file

@ -1,6 +1,6 @@
// file deepcode ignore NoHardcodedPasswords: No hard-coded passwords in tests
const { errorsToString } = require('librechat-data-provider');
const { loginSchema, registerSchema } = require('./validators');
const { loginSchema, registerSchema } = require('@librechat/auth');
describe('Zod Schemas', () => {
describe('loginSchema', () => {
@ -258,7 +258,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 +407,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,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,8 +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 } = require('~/server/utils');
const { checkEmailConfig } = require('@librechat/auth');
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 });

317
package-lock.json generated
View file

@ -134,7 +134,8 @@
"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"
}
},
"api/node_modules/@anthropic-ai/sdk": {
@ -15576,9 +15577,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.0.tgz",
"integrity": "sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw==",
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
@ -19835,44 +19836,6 @@
"node": ">= 18"
}
},
"node_modules/@node-saml/node-saml/node_modules/xml-encryption": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz",
"integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==",
"dependencies": {
"@xmldom/xmldom": "^0.8.5",
"escape-html": "^1.0.3",
"xpath": "0.0.32"
}
},
"node_modules/@node-saml/node-saml/node_modules/xml-encryption/node_modules/xpath": {
"version": "0.0.32",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
"integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/@node-saml/node-saml/node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/@node-saml/node-saml/node_modules/xml2js/node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"engines": {
"node": ">=4.0"
}
},
"node_modules/@node-saml/node-saml/node_modules/xpath": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
@ -24888,6 +24851,16 @@
"@types/express": "*"
}
},
"node_modules/@types/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
"integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
"dev": true,
"dependencies": {
"@types/jsonwebtoken": "*",
"@types/passport-strategy": "*"
}
},
"node_modules/@types/passport-strategy": {
"version": "0.2.38",
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
@ -27701,6 +27674,12 @@
"node": ">= 8"
}
},
"node_modules/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in."
},
"node_modules/crypto-browserify": {
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz",
@ -28240,9 +28219,9 @@
}
},
"node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"engines": {
"node": ">=8"
}
@ -33723,7 +33702,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
"license": "MIT",
"dependencies": {
"@types/express": "^4.17.20",
"@types/jsonwebtoken": "^9.0.4",
@ -37497,7 +37475,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/passport-apple/-/passport-apple-2.0.2.tgz",
"integrity": "sha512-JRXomYvirWeIq11pa/SwhXXxekFWoukMcQu45BDl3Kw5WobtWF0iw99vpkBwPEpdaou0DDSq4udxR34T6eZkdw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
"passport-oauth2": "^1.6.1"
@ -41397,9 +41374,9 @@
}
},
"node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"bin": {
"semver": "bin/semver.js"
},
@ -43070,6 +43047,70 @@
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
},
"node_modules/ts-jest": {
"version": "29.4.0",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz",
"integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==",
"dev": true,
"dependencies": {
"bs-logger": "^0.2.6",
"ejs": "^3.1.10",
"fast-json-stable-stringify": "^2.1.0",
"json5": "^2.2.3",
"lodash.memoize": "^4.1.2",
"make-error": "^1.3.6",
"semver": "^7.7.2",
"type-fest": "^4.41.0",
"yargs-parser": "^21.1.1"
},
"bin": {
"ts-jest": "cli.js"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"@babel/core": ">=7.0.0-beta.0 <8",
"@jest/transform": "^29.0.0 || ^30.0.0",
"@jest/types": "^29.0.0 || ^30.0.0",
"babel-jest": "^29.0.0 || ^30.0.0",
"jest": "^29.0.0 || ^30.0.0",
"jest-util": "^29.0.0 || ^30.0.0",
"typescript": ">=4.3 <6"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"@jest/transform": {
"optional": true
},
"@jest/types": {
"optional": true
},
"babel-jest": {
"optional": true
},
"esbuild": {
"optional": true
},
"jest-util": {
"optional": true
}
}
},
"node_modules/ts-jest/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@ -45314,6 +45355,24 @@
"node": ">=16"
}
},
"node_modules/xml-encryption": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz",
"integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==",
"dependencies": {
"@xmldom/xmldom": "^0.8.5",
"escape-html": "^1.0.3",
"xpath": "0.0.32"
}
},
"node_modules/xml-encryption/node_modules/xpath": {
"version": "0.0.32",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
"integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
@ -45323,6 +45382,26 @@
"node": ">=12"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xml2js/node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
@ -45504,13 +45583,27 @@
"license": "MIT",
"dependencies": {
"@librechat/data-schemas": "^0.0.7",
"@node-saml/passport-saml": "^5.0.1",
"bcryptjs": "^3.0.2",
"crypto": "^1.0.1",
"handlebars": "^4.7.8",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"klona": "^2.0.6",
"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",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
@ -45528,6 +45621,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.0",
"@types/passport-jwt": "^4.0.1",
"@types/traverse": "^0.6.37",
"jest": "^29.5.0",
"jest-junit": "^16.0.0",
@ -45543,6 +45637,66 @@
"keyv": "^5.3.2"
}
},
"packages/auth/node_modules/@node-saml/node-saml": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.0.1.tgz",
"integrity": "sha512-YQzFPEC+CnsfO9AFYnwfYZKIzOLx3kITaC1HrjHVLTo6hxcQhc+LgHODOMvW4VCV95Gwrz1MshRUWCPzkDqmnA==",
"dependencies": {
"@types/debug": "^4.1.12",
"@types/qs": "^6.9.11",
"@types/xml-encryption": "^1.2.4",
"@types/xml2js": "^0.4.14",
"@xmldom/is-dom-node": "^1.0.1",
"@xmldom/xmldom": "^0.8.10",
"debug": "^4.3.4",
"xml-crypto": "^6.0.1",
"xml-encryption": "^3.0.2",
"xml2js": "^0.6.2",
"xmlbuilder": "^15.1.1",
"xpath": "^0.0.34"
},
"engines": {
"node": ">= 18"
}
},
"packages/auth/node_modules/@node-saml/passport-saml": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@node-saml/passport-saml/-/passport-saml-5.0.1.tgz",
"integrity": "sha512-fMztg3zfSnjLEgxvpl6HaDMNeh0xeQX4QHiF9e2Lsie2dc4qFE37XYbQZhVmn8XJ2awPpSWLQ736UskYgGU8lQ==",
"dependencies": {
"@node-saml/node-saml": "^5.0.1",
"@types/express": "^4.17.21",
"@types/passport": "^1.0.16",
"@types/passport-strategy": "^0.2.38",
"passport": "^0.7.0",
"passport-strategy": "^1.0.0"
},
"engines": {
"node": ">= 18"
}
},
"packages/auth/node_modules/@node-saml/passport-saml/node_modules/@types/express": {
"version": "4.17.22",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.22.tgz",
"integrity": "sha512-eZUmSnhRX9YRSkplpz0N+k6NljUUn5l3EWZIKZvYzhvMphEuNiyyy1viH/ejgt66JWgALwC/gtSUAeQKtSwW/w==",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"packages/auth/node_modules/@types/express-serve-static-core": {
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"packages/auth/node_modules/bcryptjs": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
@ -45642,6 +45796,23 @@
"node": ">= 6"
}
},
"packages/auth/node_modules/passport": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"packages/auth/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@ -45670,6 +45841,44 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/auth/node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"hasInstallScript": true,
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"packages/auth/node_modules/traverse": {
"version": "0.6.11",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.11.tgz",
@ -45737,6 +45946,14 @@
"node": ">= 12.0.0"
}
},
"packages/auth/node_modules/xpath": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
"integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==",
"engines": {
"node": ">=0.6.0"
}
},
"packages/data-provider": {
"name": "librechat-data-provider",
"version": "0.7.86",

View file

@ -17,8 +17,9 @@
"dist"
],
"scripts": {
"copy-templates": "mkdir -p dist/utils && cp -R src/utils/emails/* dist/utils",
"clean": "rimraf dist",
"build": "npm run clean && rollup -c --silent --bundleConfigAsCjs",
"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",
@ -49,6 +50,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.0",
"@types/passport-jwt": "^4.0.1",
"@types/traverse": "^0.6.37",
"jest": "^29.5.0",
"jest-junit": "^16.0.0",
@ -62,13 +64,27 @@
},
"dependencies": {
"@librechat/data-schemas": "^0.0.7",
"@node-saml/passport-saml": "^5.0.1",
"bcryptjs": "^3.0.2",
"crypto": "^1.0.1",
"handlebars": "^4.7.8",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"klona": "^2.0.6",
"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",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
@ -86,4 +102,4 @@
"typescript",
"librechat"
]
}
}

View file

@ -36,5 +36,5 @@ export default {
}),
],
// Do not bundle these external dependencies
external: ['mongoose'],
external: ['mongoose', 'sharp'],
};

View file

@ -1,241 +0,0 @@
import { klona } from 'klona';
import winston from 'winston';
import traverse from 'traverse';
const SPLAT_SYMBOL = Symbol.for('splat');
const MESSAGE_SYMBOL = Symbol.for('message');
const CONSOLE_JSON_STRING_LENGTH: number =
parseInt(process.env.CONSOLE_JSON_STRING_LENGTH || '', 10) || 255;
const sensitiveKeys: RegExp[] = [
/^(sk-)[^\s]+/, // OpenAI API key pattern
/(Bearer )[^\s]+/, // Header: Bearer token pattern
/(api-key:? )[^\s]+/, // Header: API key pattern
/(key=)[^\s]+/, // URL query param: sensitive key pattern (Google)
];
/**
* Determines if a given value string is sensitive and returns matching regex patterns.
*
* @param valueStr - The value string to check.
* @returns An array of regex patterns that match the value string.
*/
function getMatchingSensitivePatterns(valueStr: string): RegExp[] {
if (valueStr) {
// Filter and return all regex patterns that match the value string
return sensitiveKeys.filter((regex) => regex.test(valueStr));
}
return [];
}
/**
* Redacts sensitive information from a console message and trims it to a specified length if provided.
* @param str - The console message to be redacted.
* @param trimLength - The optional length at which to trim the redacted message.
* @returns The redacted and optionally trimmed console message.
*/
function redactMessage(str: string, trimLength?: number): string {
if (!str) {
return '';
}
const patterns = getMatchingSensitivePatterns(str);
patterns.forEach((pattern) => {
str = str.replace(pattern, '$1[REDACTED]');
});
if (trimLength !== undefined && str.length > trimLength) {
return `${str.substring(0, trimLength)}...`;
}
return str;
}
/**
* Redacts sensitive information from log messages if the log level is 'error'.
* Note: Intentionally mutates the object.
* @param info - The log information object.
* @returns The modified log information object.
*/
const redactFormat = winston.format((info: winston.Logform.TransformableInfo) => {
if (info.level === 'error') {
// Type guard to ensure message is a string
if (typeof info.message === 'string') {
info.message = redactMessage(info.message);
}
// Handle MESSAGE_SYMBOL with type safety
const symbolValue = (info as Record<string | symbol, unknown>)[MESSAGE_SYMBOL];
if (typeof symbolValue === 'string') {
(info as Record<string | symbol, unknown>)[MESSAGE_SYMBOL] = redactMessage(symbolValue);
}
}
return info;
});
/**
* Truncates long strings, especially base64 image data, within log messages.
*
* @param value - The value to be inspected and potentially truncated.
* @param length - The length at which to truncate the value. Default: 100.
* @returns The truncated or original value.
*/
const truncateLongStrings = (value: unknown, length = 100): unknown => {
if (typeof value === 'string') {
return value.length > length ? value.substring(0, length) + '... [truncated]' : value;
}
return value;
};
/**
* An array mapping function that truncates long strings (objects converted to JSON strings).
* @param item - The item to be condensed.
* @returns The condensed item.
*/
const condenseArray = (item: unknown): string | unknown => {
if (typeof item === 'string') {
return truncateLongStrings(JSON.stringify(item));
} else if (typeof item === 'object') {
return truncateLongStrings(JSON.stringify(item));
}
return item;
};
/**
* Formats log messages for debugging purposes.
* - Truncates long strings within log messages.
* - Condenses arrays by truncating long strings and objects as strings within array items.
* - Redacts sensitive information from log messages if the log level is 'error'.
* - Converts log information object to a formatted string.
*
* @param options - The options for formatting log messages.
* @returns The formatted log message.
*/
const debugTraverse = winston.format.printf(
({ level, message, timestamp, ...metadata }: Record<string, unknown>) => {
if (!message) {
return `${timestamp} ${level}`;
}
// Type-safe version of the CJS logic: !message?.trim || typeof message !== 'string'
if (typeof message !== 'string' || !message.trim) {
return `${timestamp} ${level}: ${JSON.stringify(message)}`;
}
let msg = `${timestamp} ${level}: ${truncateLongStrings(message.trim(), 150)}`;
try {
if (level !== 'debug') {
return msg;
}
if (!metadata) {
return msg;
}
// Type-safe access to SPLAT_SYMBOL using bracket notation
const metadataRecord = metadata as Record<string | symbol, unknown>;
const splatArray = metadataRecord[SPLAT_SYMBOL];
const debugValue = Array.isArray(splatArray) ? splatArray[0] : undefined;
if (!debugValue) {
return msg;
}
if (debugValue && Array.isArray(debugValue)) {
msg += `\n${JSON.stringify(debugValue.map(condenseArray))}`;
return msg;
}
if (typeof debugValue !== 'object') {
return (msg += ` ${debugValue}`);
}
msg += '\n{';
const copy = klona(metadata);
traverse(copy).forEach(function (this: traverse.TraverseContext, value: unknown) {
if (typeof this?.key === 'symbol') {
return;
}
let _parentKey = '';
const parent = this.parent;
if (typeof parent?.key !== 'symbol' && parent?.key) {
_parentKey = parent.key;
}
const parentKey = `${parent && parent.notRoot ? _parentKey + '.' : ''}`;
const tabs = `${parent && parent.notRoot ? ' ' : ' '}`;
const currentKey = this?.key ?? 'unknown';
if (this.isLeaf && typeof value === 'string') {
const truncatedText = truncateLongStrings(value);
msg += `\n${tabs}${parentKey}${currentKey}: ${JSON.stringify(truncatedText)},`;
} else if (this.notLeaf && Array.isArray(value) && value.length > 0) {
const currentMessage = `\n${tabs}// ${value.length} ${currentKey.replace(/s$/, '')}(s)`;
this.update(currentMessage, true);
msg += currentMessage;
const stringifiedArray = value.map(condenseArray);
msg += `\n${tabs}${parentKey}${currentKey}: [${stringifiedArray}],`;
} else if (this.isLeaf && typeof value === 'function') {
msg += `\n${tabs}${parentKey}${currentKey}: function,`;
} else if (this.isLeaf) {
msg += `\n${tabs}${parentKey}${currentKey}: ${value},`;
}
});
msg += '\n}';
return msg;
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
return (msg += `\n[LOGGER PARSING ERROR] ${errorMessage}`);
}
},
);
/**
* Truncates long string values in JSON log objects.
* Prevents outputting extremely long values (e.g., base64, blobs).
*/
const jsonTruncateFormat = winston.format((info: winston.Logform.TransformableInfo) => {
const truncateLongStrings = (str: string, maxLength: number): string =>
str.length > maxLength ? str.substring(0, maxLength) + '...' : str;
const seen = new WeakSet<object>();
const truncateObject = (obj: unknown): unknown => {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// Handle circular references - now with proper object type
if (seen.has(obj)) {
return '[Circular]';
}
seen.add(obj);
if (Array.isArray(obj)) {
return obj.map((item) => truncateObject(item));
}
// We know this is an object at this point
const objectRecord = obj as Record<string, unknown>;
const newObj: Record<string, unknown> = {};
Object.entries(objectRecord).forEach(([key, value]) => {
if (typeof value === 'string') {
newObj[key] = truncateLongStrings(value, CONSOLE_JSON_STRING_LENGTH);
} else {
newObj[key] = truncateObject(value);
}
});
return newObj;
};
return truncateObject(info) as winston.Logform.TransformableInfo;
});
export { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat };

View file

@ -1,123 +0,0 @@
import path from 'path';
import winston from 'winston';
import 'winston-daily-rotate-file';
import { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat } from './parsers';
// Define log directory
const logDir = path.join(__dirname, '..', 'logs');
// Type-safe environment variables
const { NODE_ENV, DEBUG_LOGGING, CONSOLE_JSON, DEBUG_CONSOLE } = process.env;
const useConsoleJson = typeof CONSOLE_JSON === 'string' && CONSOLE_JSON.toLowerCase() === 'true';
const useDebugConsole = typeof DEBUG_CONSOLE === 'string' && DEBUG_CONSOLE.toLowerCase() === 'true';
const useDebugLogging = typeof DEBUG_LOGGING === 'string' && DEBUG_LOGGING.toLowerCase() === 'true';
// Define custom log levels
const levels: winston.config.AbstractConfigSetLevels = {
error: 0,
warn: 1,
info: 2,
http: 3,
verbose: 4,
debug: 5,
activity: 6,
silly: 7,
};
winston.addColors({
info: 'green',
warn: 'italic yellow',
error: 'red',
debug: 'blue',
});
const level = (): string => {
const env = NODE_ENV || 'development';
return env === 'development' ? 'debug' : 'warn';
};
const fileFormat = winston.format.combine(
redactFormat(),
winston.format.timestamp({ format: () => new Date().toISOString() }),
winston.format.errors({ stack: true }),
winston.format.splat(),
);
const transports: winston.transport[] = [
new winston.transports.DailyRotateFile({
level: 'error',
filename: `${logDir}/error-%DATE%.log`,
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
format: fileFormat,
}),
];
if (useDebugLogging) {
transports.push(
new winston.transports.DailyRotateFile({
level: 'debug',
filename: `${logDir}/debug-%DATE%.log`,
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
format: winston.format.combine(fileFormat, debugTraverse),
}),
);
}
const consoleFormat = winston.format.combine(
redactFormat(),
winston.format.colorize({ all: true }),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf((info) => {
const message = `${info.timestamp} ${info.level}: ${info.message}`;
return info.level.includes('error') ? redactMessage(message) : message;
}),
);
let consoleLogLevel: string = 'info';
if (useDebugConsole) {
consoleLogLevel = 'debug';
}
// Add console transport
if (useDebugConsole) {
transports.push(
new winston.transports.Console({
level: consoleLogLevel,
format: useConsoleJson
? winston.format.combine(fileFormat, jsonTruncateFormat(), winston.format.json())
: winston.format.combine(fileFormat, debugTraverse),
}),
);
} else if (useConsoleJson) {
transports.push(
new winston.transports.Console({
level: consoleLogLevel,
format: winston.format.combine(fileFormat, jsonTruncateFormat(), winston.format.json()),
}),
);
} else {
transports.push(
new winston.transports.Console({
level: consoleLogLevel,
format: consoleFormat,
}),
);
}
// Create logger
const logger = winston.createLogger({
level: level(),
levels,
transports,
});
export default logger;

View file

@ -2,32 +2,16 @@ import { Request, Response } from 'express';
import { TokenEndpointResponse } from 'openid-client';
import { errorsToString, SystemRoles } from 'librechat-data-provider';
import bcrypt from 'bcryptjs';
import { IUser } from '@librechat/data-schemas';
import { IUser, logger } from '@librechat/data-schemas';
import { registerSchema } from './strategies/validators';
import { webcrypto } from 'node:crypto';
import { sendEmail } from './utils/sendEmail';
import logger from './config/winston';
import { sendVerificationEmail } from './utils/email';
import { ObjectId } from 'mongoose';
import { initAuth, getMethods } from './initAuth';
import { AuthenticatedRequest, LogoutResponse } from './types';
import { checkEmailConfig, isEnabled } from './utils';
import { initAuthModels, getMethods } from './init';
const genericVerificationMessage = 'Please check your email to verify your email address.';
const domains = {
client: process.env.DOMAIN_CLIENT,
server: process.env.DOMAIN_SERVER,
};
interface LogoutResponse {
status: number;
message: string;
}
interface AuthenticatedRequest extends Request {
user?: { _id: string };
session?: {
destroy: (callback?: (err?: any) => void) => void;
};
}
/**
* Logout user
*
@ -157,263 +141,6 @@ const registerUser = async (
}
};
/**
* 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];
};
/**
* Send Verification Email
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
* @returns {Promise<void>}
*/
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
*/
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}>}
*/
const resendVerificationEmail = async (req: Request) => {
try {
const { deleteTokens, findUser, createToken } = getMethods();
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: any) {
logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`);
return {
status: 500,
message: 'Something went wrong.',
};
}
};
/**
* 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 = `${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.',
};
};
const isProduction = process.env.NODE_ENV === 'production';
/**
* Set Auth Tokens
@ -512,15 +239,7 @@ const setOpenIDAuthTokens = (tokenset: TokenEndpointResponse, res: Response) =>
throw error;
}
};
export {
setOpenIDAuthTokens,
setAuthTokens,
logoutUser,
registerUser,
verifyEmail,
resendVerificationEmail,
resetPassword,
requestPasswordReset,
checkEmailConfig,
initAuthModels,
};
export { setOpenIDAuthTokens, setAuthTokens, logoutUser, registerUser, initAuth };
export * from './strategies';
export * from './utils';

View file

@ -1,28 +0,0 @@
import { createMethods, createModels } from '@librechat/data-schemas';
import type { Mongoose } from 'mongoose';
let initialized = false;
let models: any = null;
let methods: any = {};
export function initAuthModels(mongoose: Mongoose) {
if (initialized) return;
models = createModels(mongoose);
methods = createMethods(mongoose);
initialized = true;
}
export function getModels() {
if (!models) {
throw new Error('Auth models have not been initialized. Call initAuthModels() first.');
}
return models;
}
export function getMethods() {
if (!methods) {
throw new Error('Auth methods have not been initialized. Call initAuthModels() first.');
}
return methods;
}

View file

@ -0,0 +1,51 @@
import { BalanceConfig, createMethods } from '@librechat/data-schemas';
import type { Mongoose } from 'mongoose';
// 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,7 +1,9 @@
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';
import { Profile } from 'passport';
/**
* Extract profile details from the decoded idToken
@ -10,13 +12,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)}`);
@ -33,9 +35,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 +47,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,9 @@
const { Strategy: DiscordStrategy } = require('passport-discord');
const socialLogin = require('./socialLogin');
import { Profile } from 'passport';
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 +23,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 +34,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);
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,15 +90,11 @@ const createSocialUser = async ({
userId: newUserId,
input: avatarUrl,
});
const { processAvatar } = getStrategyFunctions(fileStrategy);
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 { isEnabled } from '../utils';
import { getBalanceConfig, getMethods } from '../initAuth';
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) => {
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,10 +1,11 @@
const { logger } = require('@librechat/data-schemas');
const { errorsToString } = require('librechat-data-provider');
const { Strategy: PassportLocalStrategy } = require('passport-local');
const { isEnabled } = require('~/server/utils');
const { checkEmailConfig } = require('@librechat/auth');
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;
@ -14,7 +15,34 @@ async function validateLoginRequest(req) {
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) {
@ -23,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 });
@ -49,7 +78,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, {});
}
@ -67,12 +96,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',
@ -80,5 +109,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,42 +1,37 @@
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 { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models');
const { getBalanceConfig } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores');
const { isEnabled } = require('~/server/utils');
import passport from 'passport';
import * as client from 'openid-client';
// @ts-ignore
import { Strategy as OpenIDStrategy } 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 * as oauth from 'oauth4webapi';
import { getBalanceConfig, getMethods, getSaveBufferStrategy } from '../initAuth';
/**
* @typedef {import('openid-client').ClientMetadata} ClientMetadata
* @typedef {import('openid-client').Configuration} Configuration
**/
/** @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 crypto: typeof import('node:crypto') | undefined;
class CustomOpenIDStrategy extends OpenIDStrategy {
currentUrl(req) {
const hostAndProtocol = process.env.DOMAIN_SERVER;
constructor(options: any, verify: Function) {
super(options, verify);
}
currentUrl(req: any): URL {
const hostAndProtocol = process.env.DOMAIN_SERVER!;
return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`);
}
authorizationRequestParams(req, options) {
authorizationRequestParams(req: any, options: any) {
const params = super.authorizationRequestParams(req, options);
if (options?.state && !params.has('state')) {
params.set('state', options.state);
if (options?.state && !params?.has('state')) {
params?.set('state', options.state);
}
return params;
}
}
let openidConfig: client.Configuration;
let tokensCache: any;
/**
* Exchange the access token for a new access token using the on-behalf-of flow if required.
* @param {Configuration} config
@ -45,12 +40,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;
}
@ -69,7 +71,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;
}
@ -83,7 +85,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);
@ -92,7 +98,6 @@ const getUserInfo = async (config, accessToken, sub) => {
return null;
}
};
/**
* Downloads an image from a URL using an access token.
* @param {string} url
@ -101,14 +106,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}`,
@ -118,11 +128,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: Response = 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})`);
@ -145,9 +154,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) {
@ -162,7 +172,7 @@ function getFullName(userinfo) {
return userinfo.family_name;
}
return userinfo.username || userinfo.email;
return (userinfo?.username || userinfo?.email) ?? '';
}
/**
@ -175,7 +185,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)) {
@ -195,76 +205,89 @@ 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 {
tokensCache = tokensCacheKv;
/** @type {ClientMetadata} */
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,
);
const { findUser, createUser, updateUser } = getMethods();
if (process.env.PROXY) {
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
openidConfig[client.customFetch] = (...args) => {
const customFetch: client.CustomFetch = (...args: any[]) => {
return fetch(args[0], { ...args[1], agent: proxyAgent });
};
openidConfig[client.customFetch] = customFetch;
logger.info(`[openidStrategy] proxy agent added: ${process.env.PROXY}`);
}
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: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers,
done: passport.AuthenticateCallback,
) => {
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(
@ -272,7 +295,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.`,
});
@ -280,11 +303,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,
);
}
@ -298,8 +321,7 @@ async function setupOpenId() {
name: fullName,
};
const balanceConfig = await getBalanceConfig();
const balanceConfig = getBalanceConfig();
user = await createUser(user, balanceConfig, true, true);
} else {
user.provider = 'openid';
@ -308,11 +330,17 @@ 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 {
@ -326,7 +354,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(),
@ -335,9 +363,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} `,
{
@ -357,27 +383,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

@ -0,0 +1,285 @@
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,56 @@
import { logger } from '@librechat/data-schemas';
import { Profile } from 'passport';
import { VerifyCallback } from 'passport-oauth2';
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: VerifyCallback,
): 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,
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,35 @@
import { VerifyCallback } from 'passport-oauth2';
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 };
export type SocialLoginStrategy = (
accessToken: string,
refreshToken: string,
idToken: string,
profile: Profile,
cb: VerifyCallback,
) => 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

@ -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,15 @@
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;
}

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,271 @@
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) {
console.log(input);
console.log('----');
// @ts-ignore
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);
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,222 @@
import fs from 'fs';
import path from 'path';
import nodemailer, { TransportOptions } from 'nodemailer';
import handlebars from 'handlebars';
import { createTokenHash, 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 { SendEmailParams, SendEmailResponse } 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,
};
export const sendEmail = async ({
email,
subject,
payload,
template,
throwError = true,
}: SendEmailParams): Promise<SendEmailResponse | Error> => {
try {
const transporterOptions: TransportOptions = {
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 transporter = nodemailer.createTransport(transporterOptions);
const templatePath = path.join(__dirname, 'utils/', template);
const source = fs.readFileSync(templatePath, 'utf8');
const compiledTemplate = handlebars.compile(source);
const mailOptions = {
from: `"${process.env.EMAIL_FROM_NAME || process.env.APP_TITLE}" <${process.env.EMAIL_FROM}>`,
to: `"${payload.name}" <${email}>`,
envelope: {
from: process.env.EMAIL_FROM!,
to: email,
},
subject,
html: compiledTemplate(payload),
};
return await transporter.sendMail(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

@ -1,4 +1,15 @@
export * from './schemaMethods';
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
@ -18,7 +29,7 @@ export * from './schemaMethods';
* isEnabled(null); // returns false
* isEnabled(); // returns false
*/
export function isEnabled(value: boolean | string) {
function isEnabled(value: boolean | string) {
if (typeof value === 'boolean') {
return value;
}
@ -28,7 +39,7 @@ export function isEnabled(value: boolean | string) {
return false;
}
export function checkEmailConfig() {
function checkEmailConfig() {
return (
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
@ -36,3 +47,9 @@ export function checkEmailConfig() {
!!process.env.EMAIL_FROM
);
}
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

@ -1,39 +0,0 @@
import { createModels } from '@librechat/data-schemas';
const mongoose = require('mongoose');
const { createMethods } = require('@librechat/data-schemas');
const methods = createMethods(mongoose);
const {
findSession,
deleteSession,
createSession,
findUser,
countUsers,
deleteUserById,
createUser,
updateUser,
createToken,
findToken,
deleteTokens,
generateToken,
generateRefreshToken,
getUserById,
} = methods;
export {
findSession,
deleteSession,
createSession,
findUser,
countUsers,
deleteUserById,
createUser,
updateUser,
createToken,
findToken,
deleteTokens,
generateToken,
generateRefreshToken,
getUserById,
};

View file

@ -1,83 +0,0 @@
import fs from 'fs';
import path from 'path';
import nodemailer, { TransportOptions } from 'nodemailer';
import handlebars from 'handlebars';
import logger from '../config/winston';
import { isEnabled } from '.';
interface SendEmailParams {
email: string;
subject: string;
payload: Record<string, string | number>;
template: string;
throwError?: boolean;
}
interface SendEmailResponse {
accepted: string[];
rejected: string[];
response: string;
envelope: { from: string; to: string[] };
messageId: string;
}
export const sendEmail = async ({
email,
subject,
payload,
template,
throwError = true,
}: SendEmailParams): Promise<SendEmailResponse | Error> => {
try {
const transporterOptions: TransportOptions = {
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 transporter = nodemailer.createTransport(transporterOptions);
const templatePath = path.join(__dirname, 'emails', template);
const source = fs.readFileSync(templatePath, 'utf8');
const compiledTemplate = handlebars.compile(source);
const mailOptions = {
from: `"${process.env.EMAIL_FROM_NAME || process.env.APP_TITLE}" <${process.env.EMAIL_FROM}>`,
to: `"${payload.name}" <${email}>`,
envelope: {
from: process.env.EMAIL_FROM!,
to: email,
},
subject,
html: compiledTemplate(payload),
};
return await transporter.sendMail(mailOptions);
} catch (error: any) {
if (throwError) {
throw error;
}
logger.error('[sendEmail]', error);
return error;
}
};

View file

@ -13,9 +13,12 @@
"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"]