diff --git a/api/jest.config.js b/api/jest.config.js index 7169e82259..eb59b57a69 100644 --- a/api/jest.config.js +++ b/api/jest.config.js @@ -1,18 +1,67 @@ +/** @type {import('jest').Config} */ module.exports = { - testEnvironment: 'node', - clearMocks: true, - roots: [''], - 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: [''], + coverageDirectory: 'coverage', + setupFiles: [ + './test/jestSetup.js', + './test/__mocks__/logger.js', + './test/__mocks__/fetchEventSource.js', + ], + moduleNameMapper: { + '~/(.*)': '/$1', + '~/data/auth.json': '/__mocks__/auth.mock.json', + '^openid-client/passport$': '/test/__mocks__/openid-client-passport.js', + '^openid-client$': '/test/__mocks__/openid-client.js', + }, + transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'], + // testMatch: ['/**/*.spec.js', '/**/*.spec.ts'], + testPathIgnorePatterns: [ + '/strategies/openidStrategy.spec.js', + '/strategies/samlStrategy.spec.js', + '/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: { + '~/(.*)': '/$1', + '~/data/auth.json': '/__mocks__/auth.mock.json', + '^openid-client/passport$': '/test/__mocks__/openid-client-passport.js', + '^openid-client$': '/test/__mocks__/openid-client.js', + }, + transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + testMatch: [ + '/strategies/openidStrategy.spec.js', + '/strategies/samlStrategy.spec.js', + '/strategies/appleStrategy.test.js', + ], + }, ], - moduleNameMapper: { - '~/(.*)': '/$1', - '~/data/auth.json': '/__mocks__/auth.mock.json', - '^openid-client/passport$': '/test/__mocks__/openid-client-passport.js', // Mock for the passport strategy part - '^openid-client$': '/test/__mocks__/openid-client.js', - }, - transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'], }; diff --git a/api/package.json b/api/package.json index 3d3766bde8..a562dd9a14 100644 --- a/api/package.json +++ b/api/package.json @@ -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" } } diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index a052979f7f..543e98af1d 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -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 }); diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index 0391b40f5f..b2f488bce3 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -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'); diff --git a/api/server/index.js b/api/server/index.js index db0492f43c..a6231afbed 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -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(); diff --git a/api/server/routes/files/avatar.js b/api/server/routes/files/avatar.js index eab1a6435f..7ae2d6412e 100644 --- a/api/server/routes/files/avatar.js +++ b/api/server/routes/files/avatar.js @@ -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 }); diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 29622e60fb..d95d196ba0 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -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(), diff --git a/api/server/services/Files/images/avatar.js b/api/server/services/Files/images/avatar.js deleted file mode 100644 index 3c1068a453..0000000000 --- a/api/server/services/Files/images/avatar.js +++ /dev/null @@ -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} - * 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 }; diff --git a/api/server/services/Files/images/index.js b/api/server/services/Files/images/index.js index 889b19f206..4ea3107af9 100644 --- a/api/server/services/Files/images/index.js +++ b/api/server/services/Files/images/index.js @@ -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, }; diff --git a/api/server/services/Files/images/resize.js b/api/server/services/Files/images/resize.js index c2cdaacb63..d54decad41 100644 --- a/api/server/services/Files/images/resize.js +++ b/api/server/services/Files/images/resize.js @@ -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 }; diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 94b1bc4dad..b1127d155d 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -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'); diff --git a/api/server/services/Files/strategies.js b/api/server/services/Files/strategies.js index c6cfe77069..685c22c08e 100644 --- a/api/server/services/Files/strategies.js +++ b/api/server/services/Files/strategies.js @@ -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} */ diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index 9b9541cdcd..f9c9c6f44a 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -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.'); } diff --git a/api/server/utils/index.js b/api/server/utils/index.js index 069f552725..16faf0a7a2 100644 --- a/api/server/utils/index.js +++ b/api/server/utils/index.js @@ -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, diff --git a/api/server/utils/sendEmail.js b/api/server/utils/sendEmail.js deleted file mode 100644 index 59d75830f4..0000000000 --- a/api/server/utils/sendEmail.js +++ /dev/null @@ -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} 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} - 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; diff --git a/api/strategies/appleStrategy.test.js b/api/strategies/appleStrategy.test.js index 65a961bd4d..a1a6aed046 100644 --- a/api/strategies/appleStrategy.test.js +++ b/api/strategies/appleStrategy.test.js @@ -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'); diff --git a/api/strategies/facebookStrategy.js b/api/strategies/facebookStrategy.js deleted file mode 100644 index e5d1b054db..0000000000 --- a/api/strategies/facebookStrategy.js +++ /dev/null @@ -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, - ); diff --git a/api/strategies/index.js b/api/strategies/index.js deleted file mode 100644 index 725e04224a..0000000000 --- a/api/strategies/index.js +++ /dev/null @@ -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, -}; diff --git a/api/strategies/ldapStrategy.js b/api/strategies/ldapStrategy.js deleted file mode 100644 index 434534c264..0000000000 --- a/api/strategies/ldapStrategy.js +++ /dev/null @@ -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; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 3e52ad01f1..32a74472d4 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -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); diff --git a/api/strategies/samlStrategy.js b/api/strategies/samlStrategy.js deleted file mode 100644 index 376434f733..0000000000 --- a/api/strategies/samlStrategy.js +++ /dev/null @@ -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} - */ -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 }; diff --git a/api/strategies/samlStrategy.spec.js b/api/strategies/samlStrategy.spec.js index 675bdc998b..c20a27667c 100644 --- a/api/strategies/samlStrategy.spec.js +++ b/api/strategies/samlStrategy.spec.js @@ -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(); }); }); diff --git a/api/strategies/socialLogin.js b/api/strategies/socialLogin.js deleted file mode 100644 index 4f9462316a..0000000000 --- a/api/strategies/socialLogin.js +++ /dev/null @@ -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; diff --git a/api/strategies/validators.js b/api/strategies/validators.js deleted file mode 100644 index e8ae300f03..0000000000 --- a/api/strategies/validators.js +++ /dev/null @@ -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, -}; diff --git a/api/strategies/validators.spec.js b/api/strategies/validators.spec.js index 312f06923d..e070a9830e 100644 --- a/api/strategies/validators.spec.js +++ b/api/strategies/validators.spec.js @@ -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 '', // Basic XSS '">', // XSS breaking out of an attribute diff --git a/api/test/__mocks__/openid-client.js b/api/test/__mocks__/openid-client.js index 4848a4799f..5ef081f7b6 100644 --- a/api/test/__mocks__/openid-client.js +++ b/api/test/__mocks__/openid-client.js @@ -1,4 +1,5 @@ // api/test/__mocks__/openid-client.js +console.log('✅ MOCKED openid-client loaded'); module.exports = { Issuer: { discover: jest.fn().mockResolvedValue({ diff --git a/config/invite-user.js b/config/invite-user.js index 830d989c9e..c4c067efec 100644 --- a/config/invite-user.js +++ b/config/invite-user.js @@ -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'); diff --git a/e2e/setup/cleanupUser.ts b/e2e/setup/cleanupUser.ts index 01f59142e8..b4a0b416a8 100644 --- a/e2e/setup/cleanupUser.ts +++ b/e2e/setup/cleanupUser.ts @@ -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 }); diff --git a/package-lock.json b/package-lock.json index 6a0e46c9b4..5b32419d87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/auth/package.json b/packages/auth/package.json index a56e6f7645..74f0e5bcf5 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -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" ] -} +} \ No newline at end of file diff --git a/packages/auth/rollup.config.js b/packages/auth/rollup.config.js index c9f8838e77..be5005bf20 100644 --- a/packages/auth/rollup.config.js +++ b/packages/auth/rollup.config.js @@ -36,5 +36,5 @@ export default { }), ], // Do not bundle these external dependencies - external: ['mongoose'], + external: ['mongoose', 'sharp'], }; diff --git a/packages/auth/src/config/parsers.ts b/packages/auth/src/config/parsers.ts deleted file mode 100644 index 3064844815..0000000000 --- a/packages/auth/src/config/parsers.ts +++ /dev/null @@ -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)[MESSAGE_SYMBOL]; - if (typeof symbolValue === 'string') { - (info as Record)[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) => { - 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; - 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(); - - 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; - const newObj: Record = {}; - 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 }; diff --git a/packages/auth/src/config/winston.ts b/packages/auth/src/config/winston.ts deleted file mode 100644 index 598d967394..0000000000 --- a/packages/auth/src/config/winston.ts +++ /dev/null @@ -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; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 96478838c4..5cc0e638ac 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -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 & { _id: ObjectId, email: string, name: string}} user - * @returns {Promise} - */ -const sendVerificationEmail = async (user: Partial & { _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'; diff --git a/packages/auth/src/init.ts b/packages/auth/src/init.ts deleted file mode 100644 index 5b99675db4..0000000000 --- a/packages/auth/src/init.ts +++ /dev/null @@ -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; -} diff --git a/packages/auth/src/initAuth.ts b/packages/auth/src/initAuth.ts new file mode 100644 index 0000000000..1eda9c50f0 --- /dev/null +++ b/packages/auth/src/initAuth.ts @@ -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; +} diff --git a/api/strategies/appleStrategy.js b/packages/auth/src/strategies/appleStrategy.ts similarity index 70% rename from api/strategies/appleStrategy.js rename to packages/auth/src/strategies/appleStrategy.ts index 4dbac2e364..dd947cde9e 100644 --- a/api/strategies/appleStrategy.js +++ b/packages/auth/src/strategies/appleStrategy.ts @@ -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; diff --git a/api/strategies/discordStrategy.js b/packages/auth/src/strategies/discordStrategy.ts similarity index 69% rename from api/strategies/discordStrategy.js rename to packages/auth/src/strategies/discordStrategy.ts index dc7cb05ac6..f12ec2debe 100644 --- a/api/strategies/discordStrategy.js +++ b/packages/auth/src/strategies/discordStrategy.ts @@ -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; diff --git a/packages/auth/src/strategies/facebookStrategy.ts b/packages/auth/src/strategies/facebookStrategy.ts new file mode 100644 index 0000000000..88069edcd5 --- /dev/null +++ b/packages/auth/src/strategies/facebookStrategy.ts @@ -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; diff --git a/api/strategies/githubStrategy.js b/packages/auth/src/strategies/githubStrategy.ts similarity index 74% rename from api/strategies/githubStrategy.js rename to packages/auth/src/strategies/githubStrategy.ts index 1c3937381e..fcd0b8e386 100644 --- a/api/strategies/githubStrategy.js +++ b/packages/auth/src/strategies/githubStrategy.ts @@ -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; diff --git a/api/strategies/googleStrategy.js b/packages/auth/src/strategies/googleStrategy.ts similarity index 57% rename from api/strategies/googleStrategy.js rename to packages/auth/src/strategies/googleStrategy.ts index fd65823327..2afa7e2575 100644 --- a/api/strategies/googleStrategy.js +++ b/packages/auth/src/strategies/googleStrategy.ts @@ -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; diff --git a/api/strategies/process.js b/packages/auth/src/strategies/helpers.ts similarity index 73% rename from api/strategies/process.js rename to packages/auth/src/strategies/helpers.ts index 774d8d015e..edc3f07274 100644 --- a/api/strategies/process.js +++ b/packages/auth/src/strategies/helpers.ts @@ -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 => { 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 }; diff --git a/packages/auth/src/strategies/index.ts b/packages/auth/src/strategies/index.ts new file mode 100644 index 0000000000..ba4836cc2f --- /dev/null +++ b/packages/auth/src/strategies/index.ts @@ -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'; diff --git a/api/strategies/jwtStrategy.js b/packages/auth/src/strategies/jwtStrategy.ts similarity index 53% rename from api/strategies/jwtStrategy.js rename to packages/auth/src/strategies/jwtStrategy.ts index 6793873ee8..ace0dc1d1c 100644 --- a/api/strategies/jwtStrategy.js +++ b/packages/auth/src/strategies/jwtStrategy.ts @@ -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; diff --git a/packages/auth/src/strategies/ldapStrategy.ts b/packages/auth/src/strategies/ldapStrategy.ts new file mode 100644 index 0000000000..b013e0beae --- /dev/null +++ b/packages/auth/src/strategies/ldapStrategy.ts @@ -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; diff --git a/api/strategies/localStrategy.js b/packages/auth/src/strategies/localStrategy.ts similarity index 63% rename from api/strategies/localStrategy.js rename to packages/auth/src/strategies/localStrategy.ts index 5dfe6fbc49..4b1f012469 100644 --- a/api/strategies/localStrategy.js +++ b/packages/auth/src/strategies/localStrategy.ts @@ -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} 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; diff --git a/api/strategies/openIdJwtStrategy.js b/packages/auth/src/strategies/openIdJwtStrategy.ts similarity index 72% rename from api/strategies/openIdJwtStrategy.js rename to packages/auth/src/strategies/openIdJwtStrategy.ts index dae8d17bc6..871c516f2b 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/packages/auth/src/strategies/openIdJwtStrategy.ts @@ -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; diff --git a/api/strategies/openidStrategy.js b/packages/auth/src/strategies/openidStrategy.ts similarity index 67% rename from api/strategies/openidStrategy.js rename to packages/auth/src/strategies/openidStrategy.ts index 1d0a6bc5e6..f27300b45e 100644 --- a/api/strategies/openidStrategy.js +++ b/packages/auth/src/strategies/openidStrategy.ts @@ -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} 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} */ -const getUserInfo = async (config, accessToken, sub) => { +const getUserInfo = async ( + config: client.Configuration, + accessToken: string, + sub: string, +): Promise => { 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} 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} 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 { 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)[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 }; diff --git a/packages/auth/src/strategies/samlStrategy.ts b/packages/auth/src/strategies/samlStrategy.ts new file mode 100644 index 0000000000..2322c5613c --- /dev/null +++ b/packages/auth/src/strategies/samlStrategy.ts @@ -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} + */ +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 }; diff --git a/packages/auth/src/strategies/socialLogin.ts b/packages/auth/src/strategies/socialLogin.ts new file mode 100644 index 0000000000..f605249aaf --- /dev/null +++ b/packages/auth/src/strategies/socialLogin.ts @@ -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 => { + 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; diff --git a/packages/auth/src/strategies/types.ts b/packages/auth/src/strategies/types.ts new file mode 100644 index 0000000000..287c7bb5fc --- /dev/null +++ b/packages/auth/src/strategies/types.ts @@ -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 & { avatarUrl: string }; + +export type SocialLoginStrategy = ( + accessToken: string, + refreshToken: string, + idToken: string, + profile: Profile, + cb: VerifyCallback, +) => Promise; + +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; +} diff --git a/packages/auth/src/types/avatar.ts b/packages/auth/src/types/avatar.ts new file mode 100644 index 0000000000..cdb822ca25 --- /dev/null +++ b/packages/auth/src/types/avatar.ts @@ -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; +} diff --git a/packages/auth/src/types/email.ts b/packages/auth/src/types/email.ts new file mode 100644 index 0000000000..49a9551cf1 --- /dev/null +++ b/packages/auth/src/types/email.ts @@ -0,0 +1,15 @@ +export interface SendEmailParams { + email: string; + subject: string; + payload: Record; + template: string; + throwError?: boolean; +} + +export interface SendEmailResponse { + accepted: string[]; + rejected: string[]; + response: string; + envelope: { from: string; to: string[] }; + messageId: string; +} diff --git a/packages/auth/src/types/index.ts b/packages/auth/src/types/index.ts new file mode 100644 index 0000000000..ab3623859f --- /dev/null +++ b/packages/auth/src/types/index.ts @@ -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; + }; +} diff --git a/packages/auth/src/utils/avatar.ts b/packages/auth/src/utils/avatar.ts new file mode 100644 index 0000000000..43dba97035 --- /dev/null +++ b/packages/auth/src/utils/avatar.ts @@ -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} - 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 { + 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} - 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} Signed URL of the uploaded avatar. + */ +async function processS3Avatar({ + buffer, + userId, + manual, + basePath = defaultBasePath, +}: ProcessAvatarParams): Promise { + 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} 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} + * 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 }; diff --git a/packages/auth/src/utils/email.ts b/packages/auth/src/utils/email.ts new file mode 100644 index 0000000000..de77a23f75 --- /dev/null +++ b/packages/auth/src/utils/email.ts @@ -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 => { + 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 & { _id: ObjectId, email: string, name: string}} user + * @returns {Promise} + */ +export const sendVerificationEmail = async ( + user: Partial & { _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.', + }; + } +}; diff --git a/api/server/utils/emails/inviteUser.handlebars b/packages/auth/src/utils/emails/inviteUser.handlebars similarity index 100% rename from api/server/utils/emails/inviteUser.handlebars rename to packages/auth/src/utils/emails/inviteUser.handlebars diff --git a/api/server/utils/emails/passwordReset.handlebars b/packages/auth/src/utils/emails/passwordReset.handlebars similarity index 100% rename from api/server/utils/emails/passwordReset.handlebars rename to packages/auth/src/utils/emails/passwordReset.handlebars diff --git a/api/server/utils/emails/requestPasswordReset.handlebars b/packages/auth/src/utils/emails/requestPasswordReset.handlebars similarity index 100% rename from api/server/utils/emails/requestPasswordReset.handlebars rename to packages/auth/src/utils/emails/requestPasswordReset.handlebars diff --git a/api/server/utils/emails/verifyEmail.handlebars b/packages/auth/src/utils/emails/verifyEmail.handlebars similarity index 100% rename from api/server/utils/emails/verifyEmail.handlebars rename to packages/auth/src/utils/emails/verifyEmail.handlebars diff --git a/packages/auth/src/utils/index.ts b/packages/auth/src/utils/index.ts index eb6e23c037..443459c7fe 100644 --- a/packages/auth/src/utils/index.ts +++ b/packages/auth/src/utils/index.ts @@ -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'; diff --git a/packages/auth/src/utils/password.ts b/packages/auth/src/utils/password.ts new file mode 100644 index 0000000000..55a16cd461 --- /dev/null +++ b/packages/auth/src/utils/password.ts @@ -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 }; diff --git a/packages/auth/src/utils/schemaMethods.ts b/packages/auth/src/utils/schemaMethods.ts deleted file mode 100644 index 39bfc8797c..0000000000 --- a/packages/auth/src/utils/schemaMethods.ts +++ /dev/null @@ -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, -}; diff --git a/packages/auth/src/utils/sendEmail.ts b/packages/auth/src/utils/sendEmail.ts deleted file mode 100644 index 9e92c29683..0000000000 --- a/packages/auth/src/utils/sendEmail.ts +++ /dev/null @@ -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; - 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 => { - 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; - } -}; diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index b7dbf09580..f561a60eff 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -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"]