mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-25 04:40:15 +01:00
- Move auth strategies to package/auth
- Move email and avatar functions to package/auth
This commit is contained in:
parent
e77aa92a7b
commit
f68be4727c
65 changed files with 2089 additions and 1967 deletions
|
|
@ -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)/).*/'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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} */
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// api/test/__mocks__/openid-client.js
|
||||
console.log('✅ MOCKED openid-client loaded');
|
||||
module.exports = {
|
||||
Issuer: {
|
||||
discover: jest.fn().mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
317
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -36,5 +36,5 @@ export default {
|
|||
}),
|
||||
],
|
||||
// Do not bundle these external dependencies
|
||||
external: ['mongoose'],
|
||||
external: ['mongoose', 'sharp'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
51
packages/auth/src/initAuth.ts
Normal file
51
packages/auth/src/initAuth.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
36
packages/auth/src/strategies/facebookStrategy.ts
Normal file
36
packages/auth/src/strategies/facebookStrategy.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
16
packages/auth/src/strategies/index.ts
Normal file
16
packages/auth/src/strategies/index.ts
Normal 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';
|
||||
|
|
@ -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;
|
||||
150
packages/auth/src/strategies/ldapStrategy.ts
Normal file
150
packages/auth/src/strategies/ldapStrategy.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
285
packages/auth/src/strategies/samlStrategy.ts
Normal file
285
packages/auth/src/strategies/samlStrategy.ts
Normal 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 };
|
||||
56
packages/auth/src/strategies/socialLogin.ts
Normal file
56
packages/auth/src/strategies/socialLogin.ts
Normal 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;
|
||||
35
packages/auth/src/strategies/types.ts
Normal file
35
packages/auth/src/strategies/types.ts
Normal 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;
|
||||
}
|
||||
22
packages/auth/src/types/avatar.ts
Normal file
22
packages/auth/src/types/avatar.ts
Normal 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;
|
||||
}
|
||||
15
packages/auth/src/types/email.ts
Normal file
15
packages/auth/src/types/email.ts
Normal 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;
|
||||
}
|
||||
10
packages/auth/src/types/index.ts
Normal file
10
packages/auth/src/types/index.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
271
packages/auth/src/utils/avatar.ts
Normal file
271
packages/auth/src/utils/avatar.ts
Normal 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 };
|
||||
222
packages/auth/src/utils/email.ts
Normal file
222
packages/auth/src/utils/email.ts
Normal 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.',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
113
packages/auth/src/utils/password.ts
Normal file
113
packages/auth/src/utils/password.ts
Normal 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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue