From e77aa92a7b57c339999da506ca323a9353188fba Mon Sep 17 00:00:00 2001 From: Cha Date: Tue, 3 Jun 2025 20:48:50 +0800 Subject: [PATCH] Move AuthService to package-auth --- api/server/controllers/AuthController.js | 10 +- api/server/controllers/UserController.js | 2 +- .../controllers/auth/LoginController.js | 2 +- .../controllers/auth/LogoutController.js | 2 +- .../auth/TwoFactorAuthController.js | 2 +- api/server/index.js | 4 +- api/server/routes/oauth.js | 2 +- api/server/services/start/checks.js | 3 +- api/server/utils/index.js | 14 - api/strategies/localStrategy.js | 3 +- config/create-user.js | 6 +- config/invite-user.js | 3 +- package-lock.json | 334 +++++++++++-- packages/auth/.gitignore | 2 + packages/auth/LICENSE | 21 + packages/auth/README.md | 114 +++++ packages/auth/babel.config.cjs | 4 + packages/auth/jest.config.mjs | 19 + packages/auth/package.json | 89 ++++ packages/auth/rollup.config.js | 40 ++ packages/auth/src/config/parsers.ts | 241 +++++++++ packages/auth/src/config/winston.ts | 123 +++++ .../auth/src/index.ts | 469 +++++++++--------- packages/auth/src/init.ts | 28 ++ packages/auth/src/strategies/validators.ts | 76 +++ packages/auth/src/utils/index.ts | 38 ++ packages/auth/src/utils/schemaMethods.ts | 39 ++ packages/auth/src/utils/sendEmail.ts | 83 ++++ packages/auth/tsconfig.json | 22 + packages/auth/tsconfig.spec.json | 10 + 30 files changed, 1520 insertions(+), 285 deletions(-) create mode 100644 packages/auth/.gitignore create mode 100644 packages/auth/LICENSE create mode 100644 packages/auth/README.md create mode 100644 packages/auth/babel.config.cjs create mode 100644 packages/auth/jest.config.mjs create mode 100644 packages/auth/package.json create mode 100644 packages/auth/rollup.config.js create mode 100644 packages/auth/src/config/parsers.ts create mode 100644 packages/auth/src/config/winston.ts rename api/server/services/AuthService.js => packages/auth/src/index.ts (82%) create mode 100644 packages/auth/src/init.ts create mode 100644 packages/auth/src/strategies/validators.ts create mode 100644 packages/auth/src/utils/index.ts create mode 100644 packages/auth/src/utils/schemaMethods.ts create mode 100644 packages/auth/src/utils/sendEmail.ts create mode 100644 packages/auth/tsconfig.json create mode 100644 packages/auth/tsconfig.spec.json diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 0f8152de3e..a052979f7f 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -4,18 +4,22 @@ const openIdClient = require('openid-client'); const { logger } = require('@librechat/data-schemas'); const { registerUser, + requestPasswordReset, resetPassword, setAuthTokens, - requestPasswordReset, setOpenIDAuthTokens, -} = require('~/server/services/AuthService'); +} = require('@librechat/auth'); const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models'); const { getOpenIdConfig } = require('~/strategies'); const { isEnabled } = require('~/server/utils'); +const { isEmailDomainAllowed } = require('~/server/services/domains'); +const { getBalanceConfig } = require('~/server/services/Config'); const registrationController = async (req, res) => { try { - const response = await registerUser(req.body); + const isEmailDomAllowed = await isEmailDomainAllowed(req.body.email); + const balanceConfig = await getBalanceConfig(); + const response = await registerUser(req.body, {}, isEmailDomAllowed, balanceConfig); const { status, message } = response; res.status(status).send({ message }); } catch (err) { diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index a2fbc3c485..d53f32cdce 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -17,7 +17,7 @@ const { } = require('~/models'); const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService'); const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService'); -const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService'); +const { verifyEmail, resendVerificationEmail } = require('@librechat/auth'); const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud'); const { processDeleteRequest } = require('~/server/services/Files/process'); const { Transaction, Balance, User } = require('~/db/models'); diff --git a/api/server/controllers/auth/LoginController.js b/api/server/controllers/auth/LoginController.js index 226b5605cc..b12258d168 100644 --- a/api/server/controllers/auth/LoginController.js +++ b/api/server/controllers/auth/LoginController.js @@ -1,5 +1,5 @@ const { generate2FATempToken } = require('~/server/services/twoFactorService'); -const { setAuthTokens } = require('~/server/services/AuthService'); +const { setAuthTokens } = require('@librechat/auth'); const { logger } = require('~/config'); const loginController = async (req, res) => { diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index 1d18e4a94d..0391b40f5f 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -1,6 +1,6 @@ const cookies = require('cookie'); const { getOpenIdConfig } = require('~/strategies'); -const { logoutUser } = require('~/server/services/AuthService'); +const { logoutUser } = require('@librechat/auth'); const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); diff --git a/api/server/controllers/auth/TwoFactorAuthController.js b/api/server/controllers/auth/TwoFactorAuthController.js index b37c89a998..37fdb9feb5 100644 --- a/api/server/controllers/auth/TwoFactorAuthController.js +++ b/api/server/controllers/auth/TwoFactorAuthController.js @@ -5,7 +5,7 @@ const { getTOTPSecret, verifyBackupCode, } = require('~/server/services/twoFactorService'); -const { setAuthTokens } = require('~/server/services/AuthService'); +const { setAuthTokens } = require('@librechat/auth'); const { getUserById } = require('~/models'); /** diff --git a/api/server/index.js b/api/server/index.js index ed770f7703..db0492f43c 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -12,6 +12,7 @@ const cookieParser = require('cookie-parser'); const { connectDb, indexSync } = require('~/db'); const { jwtLogin, passportLogin } = require('~/strategies'); +const { initAuthModels } = require('@librechat/auth'); const { isEnabled } = require('~/server/utils'); const { ldapLogin } = require('~/strategies'); const { logger } = require('~/config'); @@ -36,7 +37,8 @@ const startServer = async () => { if (typeof Bun !== 'undefined') { axios.defaults.headers.common['Accept-Encoding'] = 'gzip'; } - await connectDb(); + const mongooseInstance = await connectDb(); + initAuthModels(mongooseInstance); logger.info('Connected to MongoDB'); await indexSync(); diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index bc8d120ef5..29622e60fb 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -9,7 +9,7 @@ const { setBalanceConfig, checkDomainAllowed, } = require('~/server/middleware'); -const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService'); +const { setAuthTokens, setOpenIDAuthTokens } = require('@librechat/auth'); const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); diff --git a/api/server/services/start/checks.js b/api/server/services/start/checks.js index 09ffb85935..b1bb003b11 100644 --- a/api/server/services/start/checks.js +++ b/api/server/services/start/checks.js @@ -5,7 +5,8 @@ const { conflictingAzureVariables, extractVariableName, } = require('librechat-data-provider'); -const { isEnabled, checkEmailConfig } = require('~/server/utils'); +const { isEnabled } = require('~/server/utils'); +const { checkEmailConfig } = require('@librechat/auth'); const { logger } = require('~/config'); const secretDefaults = { diff --git a/api/server/utils/index.js b/api/server/utils/index.js index b79b42f00d..069f552725 100644 --- a/api/server/utils/index.js +++ b/api/server/utils/index.js @@ -8,22 +8,8 @@ const queue = require('./queue'); const files = require('./files'); const math = require('./math'); -/** - * Check if email configuration is set - * @returns {Boolean} - */ -function checkEmailConfig() { - return ( - (!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) && - !!process.env.EMAIL_USERNAME && - !!process.env.EMAIL_PASSWORD && - !!process.env.EMAIL_FROM - ); -} - module.exports = { ...streamResponse, - checkEmailConfig, ...cryptoUtils, ...handleText, countTokens, diff --git a/api/strategies/localStrategy.js b/api/strategies/localStrategy.js index edc749ee9e..5dfe6fbc49 100644 --- a/api/strategies/localStrategy.js +++ b/api/strategies/localStrategy.js @@ -1,7 +1,8 @@ const { logger } = require('@librechat/data-schemas'); const { errorsToString } = require('librechat-data-provider'); const { Strategy: PassportLocalStrategy } = require('passport-local'); -const { isEnabled, checkEmailConfig } = require('~/server/utils'); +const { isEnabled } = require('~/server/utils'); +const { checkEmailConfig } = require('@librechat/auth'); const { findUser, comparePassword, updateUser } = require('~/models'); const { loginSchema } = require('./validators'); diff --git a/config/create-user.js b/config/create-user.js index 5f8a39c6b1..62f10ca965 100644 --- a/config/create-user.js +++ b/config/create-user.js @@ -2,7 +2,7 @@ const path = require('path'); const mongoose = require(path.resolve(__dirname, '..', 'api', 'node_modules', 'mongoose')); const { User } = require('@librechat/data-schemas').createModels(mongoose); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); -const { registerUser } = require('~/server/services/AuthService'); +const { registerUser } = require('@librechat/auth'); const { askQuestion, silentExit } = require('./helpers'); const connect = require('./connect'); @@ -102,7 +102,9 @@ or the user will need to attempt logging in to have a verification link sent to const user = { email, password, name, username, confirm_password: password }; let result; try { - result = await registerUser(user, { emailVerified }); + const isEmailDomAllowed = await isEmailDomAllowed(user.email); + const balanceConfig = await getBalanceConfig(); + result = await registerUser(user, { emailVerified }, isEmailDomAllowed, balanceConfig); } catch (error) { console.red('Error: ' + error.message); silentExit(1); diff --git a/config/invite-user.js b/config/invite-user.js index fde53eef9a..830d989c9e 100644 --- a/config/invite-user.js +++ b/config/invite-user.js @@ -2,7 +2,8 @@ const path = require('path'); const mongoose = require(path.resolve(__dirname, '..', 'api', 'node_modules', 'mongoose')); const { User } = require('@librechat/data-schemas').createModels(mongoose); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); -const { sendEmail, checkEmailConfig } = require('~/server/utils'); +const { sendEmail } = require('~/server/utils'); +const { checkEmailConfig } = require('@librechat/auth'); const { askQuestion, silentExit } = require('./helpers'); const { createInvite } = require('~/models/inviteUser'); const connect = require('./connect'); diff --git a/package-lock.json b/package-lock.json index 2988897407..6a0e46c9b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2200,15 +2200,6 @@ "node": ">= 14" } }, - "api/node_modules/jose": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz", - "integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "api/node_modules/keyv-file": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.1.2.tgz", @@ -2418,19 +2409,6 @@ } } }, - "api/node_modules/openid-client": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.5.0.tgz", - "integrity": "sha512-fAfYaTnOYE2kQCqEJGX9KDObW2aw7IQy4jWpU/+3D3WoCFLbix5Hg6qIPQ6Js9r7f8jDUmsnnguRNCSw4wU/IQ==", - "license": "MIT", - "dependencies": { - "jose": "^6.0.10", - "oauth4webapi": "^3.5.1" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "api/node_modules/sharp": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", @@ -19271,6 +19249,10 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@librechat/auth": { + "resolved": "packages/auth", + "link": true + }, "node_modules/@librechat/backend": { "resolved": "api", "link": true @@ -24648,6 +24630,15 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -36359,11 +36350,55 @@ "node": "*" } }, + "node_modules/mongodb": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.16.0.tgz", + "integrity": "sha512-D1PNcdT0y4Grhou5Zi/qgipZOYeWrhLEpk33n3nm6LGtz61jvO88WlrWCK/bigMjpnOdAUKKQwsGIl0NtWMyYw==", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.3", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, "node_modules/mongodb-connection-string-url": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", - "peer": true, "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" @@ -36373,7 +36408,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -36385,7 +36419,6 @@ "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -36394,6 +36427,27 @@ "node": ">=18" } }, + "node_modules/mongoose": { + "version": "8.15.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.15.1.tgz", + "integrity": "sha512-RhQ4DzmBi5BNGcS0w4u1vdMRIKcteXTCNzDt1j7XRcdWYBz1MjMjulBhPaeC5jBCHOD1yinuOFTTSOWLLGexWw==", + "dependencies": { + "bson": "^6.10.3", + "kareem": "2.6.3", + "mongodb": "~6.16.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, "node_modules/moo-color": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", @@ -37132,6 +37186,26 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" }, + "node_modules/openid-client": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.5.0.tgz", + "integrity": "sha512-fAfYaTnOYE2kQCqEJGX9KDObW2aw7IQy4jWpU/+3D3WoCFLbix5Hg6qIPQ6Js9r7f8jDUmsnnguRNCSw4wU/IQ==", + "dependencies": { + "jose": "^6.0.10", + "oauth4webapi": "^3.5.1" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/jose": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz", + "integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -43233,7 +43307,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.5.tgz", "integrity": "sha512-q7QNVDGTdl702bVFiI5eY4l/HkgCM6at9KhcFbgUAzezHFbOVy4+0O/lCjsABEQwbZPravVfBIiBVGo89yzHFg==", - "peer": true, "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -45428,18 +45501,21 @@ "packages/auth": { "name": "@librechat/auth", "version": "0.0.1", - "extraneous": true, "license": "MIT", "dependencies": { - "https-proxy-agent": "^7.0.6", + "@librechat/data-schemas": "^0.0.7", + "bcryptjs": "^3.0.2", + "handlebars": "^4.7.8", "jsonwebtoken": "^9.0.2", + "klona": "^2.0.6", "mongoose": "^8.12.1", + "nodemailer": "^7.0.3", "openid-client": "^6.5.0", - "passport": "^0.7.0", - "passport-facebook": "^3.0.0" + "traverse": "^0.6.11", + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { - "@librechat/data-schemas": "^0.0.7", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^25.0.2", "@rollup/plugin-json": "^6.1.0", @@ -45447,10 +45523,12 @@ "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", + "@types/bcrypt": "^5.0.2", "@types/diff": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.0", + "@types/traverse": "^0.6.37", "jest": "^29.5.0", "jest-junit": "^16.0.0", "rimraf": "^5.0.1", @@ -45465,6 +45543,200 @@ "keyv": "^5.3.2" } }, + "packages/auth/node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "packages/auth/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "packages/auth/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/auth/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "packages/auth/node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "packages/auth/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/auth/node_modules/nodemailer": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz", + "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==", + "engines": { + "node": ">=6.0.0" + } + }, + "packages/auth/node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "packages/auth/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "packages/auth/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/auth/node_modules/traverse": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.11.tgz", + "integrity": "sha512-vxXDZg8/+p3gblxB6BhhG5yWVn1kGRlaL8O78UDXc3wRnPizB5g83dcvWV1jpDMIPnjZjOFuxlMmE82XJ4407w==", + "dependencies": { + "gopd": "^1.2.0", + "typedarray.prototype.slice": "^1.0.5", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/auth/node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "packages/auth/node_modules/winston-daily-rotate-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", + "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^3.0.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, + "packages/auth/node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "packages/data-provider": { "name": "librechat-data-provider", "version": "0.7.86", diff --git a/packages/auth/.gitignore b/packages/auth/.gitignore new file mode 100644 index 0000000000..7b961825b3 --- /dev/null +++ b/packages/auth/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +test_bundle/ diff --git a/packages/auth/LICENSE b/packages/auth/LICENSE new file mode 100644 index 0000000000..535850a920 --- /dev/null +++ b/packages/auth/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 LibreChat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/auth/README.md b/packages/auth/README.md new file mode 100644 index 0000000000..e961626281 --- /dev/null +++ b/packages/auth/README.md @@ -0,0 +1,114 @@ +# `@librechat/data-schemas` + +Mongoose schemas and models for LibreChat. This package provides a comprehensive collection of Mongoose schemas used across the LibreChat project, enabling robust data modeling and validation for various entities such as actions, agents, messages, users, and more. + +## Features + +- **Modular Schemas:** Includes schemas for actions, agents, assistants, balance, banners, categories, conversation tags, conversations, files, keys, messages, plugin authentication, presets, projects, prompts, prompt groups, roles, sessions, shared links, tokens, tool calls, transactions, and users. +- **TypeScript Support:** Provides TypeScript definitions for type-safe development. +- **Ready for Mongoose Integration:** Easily integrate with Mongoose to create models and interact with your MongoDB database. +- **Flexible & Extensible:** Designed to support the evolving needs of LibreChat while being adaptable to other projects. + +## Installation + +Install the package via npm or yarn: + +```bash +npm install @librechat/auth +``` + +Or with yarn: + +```bash +yarn add @librechat/auth +``` + +## Usage + +After installation, you can import and use the schemas in your project. For example, to create a Mongoose model for a user: + +```js +import mongoose from 'mongoose'; +import { userSchema } from '@librechat/data-schemas'; + +const UserModel = mongoose.model('User', userSchema); + +// Now you can use UserModel to create, read, update, and delete user documents. +``` + +You can also import other schemas as needed: + +```js +import { actionSchema, agentSchema, messageSchema } from '@librechat/data-schemas'; +``` + +Each schema is designed to integrate seamlessly with Mongoose and provides indexes, timestamps, and validations tailored for LibreChat’s use cases. + +## Development + +This package uses Rollup and TypeScript for building and bundling. + +### Available Scripts + +- **Build:** + Cleans the `dist` directory and builds the package. + + ```bash + npm run build + ``` + +- **Build Watch:** + Rebuilds automatically on file changes. + + ```bash + npm run build:watch + ``` + +- **Test:** + Runs tests with coverage in watch mode. + + ```bash + npm run test + ``` + +- **Test (CI):** + Runs tests with coverage for CI environments. + + ```bash + npm run test:ci + ``` + +- **Verify:** + Runs tests in CI mode to verify code integrity. + + ```bash + npm run verify + ``` + +- **Clean:** + Removes the `dist` directory. + ```bash + npm run clean + ``` + +For those using Bun, equivalent scripts are available: + +- **Bun Clean:** `bun run b:clean` +- **Bun Build:** `bun run b:build` + +## Repository & Issues + +The source code is maintained on GitHub. + +- **Repository:** [LibreChat Repository](https://github.com/danny-avila/LibreChat.git) +- **Issues & Bug Reports:** [LibreChat Issues](https://github.com/danny-avila/LibreChat/issues) + +## License + +This project is licensed under the [MIT License](LICENSE). + +## Contributing + +Contributions to improve and expand the data schemas are welcome. If you have suggestions, improvements, or bug fixes, please open an issue or submit a pull request on the [GitHub repository](https://github.com/danny-avila/LibreChat/issues). + +For more detailed documentation on each schema and model, please refer to the source code or visit the [LibreChat website](https://librechat.ai). diff --git a/packages/auth/babel.config.cjs b/packages/auth/babel.config.cjs new file mode 100644 index 0000000000..7d5344d252 --- /dev/null +++ b/packages/auth/babel.config.cjs @@ -0,0 +1,4 @@ +module.exports = { + presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'], + plugins: ['babel-plugin-replace-ts-export-assignment'], +}; diff --git a/packages/auth/jest.config.mjs b/packages/auth/jest.config.mjs new file mode 100644 index 0000000000..f5fb1f20d7 --- /dev/null +++ b/packages/auth/jest.config.mjs @@ -0,0 +1,19 @@ +export default { + collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!/node_modules/'], + coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], + coverageReporters: ['text', 'cobertura'], + testResultsProcessor: 'jest-junit', + moduleNameMapper: { + '^@src/(.*)$': '/src/$1', + }, + // coverageThreshold: { + // global: { + // statements: 58, + // branches: 49, + // functions: 50, + // lines: 57, + // }, + // }, + restoreMocks: true, + testTimeout: 15000, +}; \ No newline at end of file diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000000..a56e6f7645 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,89 @@ +{ + "name": "@librechat/auth", + "version": "0.0.1", + "description": "Librechat auth functionality", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.es.js", + "types": "./dist/types/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.es.js", + "require": "./dist/index.cjs", + "types": "./dist/types/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && rollup -c --silent --bundleConfigAsCjs", + "build:watch": "rollup -c -w", + "test": "jest --coverage --watch", + "test:ci": "jest --coverage --ci", + "verify": "npm run test:ci", + "b:clean": "bun run rimraf dist", + "b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/danny-avila/LibreChat.git" + }, + "author": "", + "license": "MIT", + "bugs": { + "url": "https://github.com/danny-avila/LibreChat/issues" + }, + "homepage": "https://librechat.ai", + "devDependencies": { + "@rollup/plugin-alias": "^5.1.0", + "@rollup/plugin-commonjs": "^25.0.2", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.1.0", + "@rollup/plugin-replace": "^5.0.5", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^12.1.2", + "@types/bcrypt": "^5.0.2", + "@types/diff": "^6.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.2", + "@types/node": "^20.3.0", + "@types/traverse": "^0.6.37", + "jest": "^29.5.0", + "jest-junit": "^16.0.0", + "rimraf": "^5.0.1", + "rollup": "^4.22.4", + "rollup-plugin-generate-package-json": "^3.2.0", + "rollup-plugin-peer-deps-external": "^2.2.4", + "rollup-plugin-typescript2": "^0.35.0", + "ts-node": "^10.9.2", + "typescript": "^5.0.4" + }, + "dependencies": { + "@librechat/data-schemas": "^0.0.7", + "bcryptjs": "^3.0.2", + "handlebars": "^4.7.8", + "jsonwebtoken": "^9.0.2", + "klona": "^2.0.6", + "mongoose": "^8.12.1", + "nodemailer": "^7.0.3", + "openid-client": "^6.5.0", + "traverse": "^0.6.11", + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0" + }, + "peerDependencies": { + "keyv": "^5.3.2" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "keywords": [ + "mongoose", + "schema", + "typescript", + "librechat" + ] +} diff --git a/packages/auth/rollup.config.js b/packages/auth/rollup.config.js new file mode 100644 index 0000000000..c9f8838e77 --- /dev/null +++ b/packages/auth/rollup.config.js @@ -0,0 +1,40 @@ +import json from '@rollup/plugin-json'; +import typescript from '@rollup/plugin-typescript'; +import commonjs from '@rollup/plugin-commonjs'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import peerDepsExternal from 'rollup-plugin-peer-deps-external'; + +export default { + input: 'src/index.ts', + output: [ + { + file: 'dist/index.es.js', + format: 'es', + sourcemap: true, + }, + { + file: 'dist/index.cjs', + format: 'cjs', + sourcemap: true, + }, + ], + plugins: [ + // Allow importing JSON files + json(), + // Automatically externalize peer dependencies + peerDepsExternal(), + // Resolve modules from node_modules + nodeResolve(), + // Convert CommonJS modules to ES6 + commonjs(), + // Compile TypeScript files and generate type declarations + typescript({ + tsconfig: './tsconfig.json', + declaration: true, + declarationDir: 'dist/types', + rootDir: 'src', + }), + ], + // Do not bundle these external dependencies + external: ['mongoose'], +}; diff --git a/packages/auth/src/config/parsers.ts b/packages/auth/src/config/parsers.ts new file mode 100644 index 0000000000..3064844815 --- /dev/null +++ b/packages/auth/src/config/parsers.ts @@ -0,0 +1,241 @@ +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 new file mode 100644 index 0000000000..598d967394 --- /dev/null +++ b/packages/auth/src/config/winston.ts @@ -0,0 +1,123 @@ +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/api/server/services/AuthService.js b/packages/auth/src/index.ts similarity index 82% rename from api/server/services/AuthService.js rename to packages/auth/src/index.ts index 11b37ac886..96478838c4 100644 --- a/api/server/services/AuthService.js +++ b/packages/auth/src/index.ts @@ -1,46 +1,47 @@ -const bcrypt = require('bcryptjs'); -const { webcrypto } = require('node:crypto'); -const { SystemRoles, errorsToString } = require('librechat-data-provider'); -const { - findUser, - createUser, - updateUser, - findToken, - countUsers, - getUserById, - findSession, - createToken, - deleteTokens, - deleteSession, - createSession, - generateToken, - deleteUserById, - generateRefreshToken, -} = require('~/models'); -const { isEnabled, checkEmailConfig, sendEmail } = require('~/server/utils'); -const { isEmailDomainAllowed } = require('~/server/services/domains'); -const { getBalanceConfig } = require('~/server/services/Config'); -const { registerSchema } = require('~/strategies/validators'); -const { logger } = require('~/config'); +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 { registerSchema } from './strategies/validators'; +import { webcrypto } from 'node:crypto'; +import { sendEmail } from './utils/sendEmail'; +import logger from './config/winston'; +import { ObjectId } from 'mongoose'; +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, }; -const isProduction = process.env.NODE_ENV === 'production'; -const genericVerificationMessage = 'Please check your email to verify your email address.'; +interface LogoutResponse { + status: number; + message: string; +} +interface AuthenticatedRequest extends Request { + user?: { _id: string }; + session?: { + destroy: (callback?: (err?: any) => void) => void; + }; +} /** * Logout user * - * @param {ServerRequest} req + * @param req * @param {string} refreshToken * @returns */ -const logoutUser = async (req, refreshToken) => { +const logoutUser = async ( + req: AuthenticatedRequest, + refreshToken: string | null, +): Promise => { try { - const userId = req.user._id; + const { findSession, deleteSession } = getMethods(); + const userId: string | null = req.user?._id ?? null; const session = await findSession({ userId: userId, refreshToken }); if (session) { @@ -53,24 +54,116 @@ const logoutUser = async (req, refreshToken) => { } try { - req.session.destroy(); + req.session?.destroy(); } catch (destroyErr) { logger.debug('[logoutUser] Failed to destroy session.', destroyErr); } return { status: 200, message: 'Logout successful' }; - } catch (err) { + } catch (err: any) { return { status: 500, message: err.message }; } }; +/** + * Register a new user. + * @param {MongoUser} user + * @param {Partial} [additionalData={}] + * @returns {Promise<{status: number, message: string, user?: MongoUser}>} + */ +const registerUser = async ( + user: IUser, + additionalData: Partial = {}, + isEmailDomainAllowed: boolean = true, + balanceConfig: Record, +) => { + const { error } = registerSchema.safeParse(user); + const { findUser, countUsers, createUser, updateUser, deleteUserById } = getMethods(); + if (error) { + const errorMessage = errorsToString(error.errors); + logger.info( + 'Route: register - Validation Error', + { name: 'Request params:', value: user }, + { name: 'Validation error:', value: errorMessage }, + ); + + return { status: 404, message: errorMessage }; + } + + const { email, password, name, username } = user; + + let newUserId; + try { + const existingUser = await findUser({ email }, 'email _id'); + if (existingUser) { + logger.info( + 'Register User - Email in use', + { name: 'Request params:', value: user }, + { name: 'Existing user:', value: existingUser }, + ); + + // Sleep for 1 second + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { status: 200, message: genericVerificationMessage }; + } + + if (!isEmailDomainAllowed) { + const errorMessage = + 'The email address provided cannot be used. Please use a different email address.'; + logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`); + return { status: 403, message: errorMessage }; + } + + //determine if this is the first registered user (not counting anonymous_user) + const isFirstRegisteredUser = (await countUsers()) === 0; + + const salt = bcrypt.genSaltSync(10); + const newUserData: Partial = { + provider: 'local', + email, + username, + name, + avatar: '', + role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER, + password: bcrypt.hashSync(password ?? '', salt), + ...additionalData, + }; + + const emailEnabled = checkEmailConfig(); + const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN ?? ''); + + const newUser = await createUser(newUserData, balanceConfig, disableTTL, true); + newUserId = newUser._id; + if (emailEnabled && !newUser.emailVerified) { + await sendVerificationEmail({ + _id: newUserId, + email, + name, + }); + } else { + await updateUser(newUserId, { emailVerified: true }); + } + + return { status: 200, message: genericVerificationMessage }; + } catch (err) { + logger.error('[registerUser] Error in registering user:', err); + if (newUserId) { + const result = await deleteUserById(newUserId); + logger.warn( + `[registerUser] [Email: ${email}] [Temporary User deleted: ${JSON.stringify(result)}]`, + ); + } + return { status: 500, message: 'Something went wrong' }; + } +}; + /** * Creates Token and corresponding Hash for verification * @returns {[string, string]} */ -const createTokenHash = () => { - const token = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString('hex'); - const hash = bcrypt.hashSync(token, 10); +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]; }; @@ -79,9 +172,9 @@ const createTokenHash = () => { * @param {Partial & { _id: ObjectId, email: string, name: string}} user * @returns {Promise} */ -const sendVerificationEmail = async (user) => { +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)}`; @@ -112,9 +205,10 @@ const sendVerificationEmail = async (user) => { * Verify Email * @param {Express.Request} req */ -const verifyEmail = async (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'); @@ -157,99 +251,117 @@ const verifyEmail = async (req) => { }; /** - * Register a new user. - * @param {MongoUser} user - * @param {Partial} [additionalData={}] - * @returns {Promise<{status: number, message: string, user?: MongoUser}>} + * Resend Verification Email + * @param {Object} req + * @param {Object} req.body + * @param {String} req.body.email + * @returns {Promise<{status: number, message: string}>} */ -const registerUser = async (user, additionalData = {}) => { - const { error } = registerSchema.safeParse(user); - if (error) { - const errorMessage = errorsToString(error.errors); - logger.info( - 'Route: register - Validation Error', - { name: 'Request params:', value: user }, - { name: 'Validation error:', value: errorMessage }, - ); - - return { status: 404, message: errorMessage }; - } - - const { email, password, name, username } = user; - - let newUserId; +const resendVerificationEmail = async (req: Request) => { try { - const existingUser = await findUser({ email }, 'email _id'); + const { deleteTokens, findUser, createToken } = getMethods(); + const { email } = req.body; + await deleteTokens(email); + const user = await findUser({ email }, 'email _id name'); - if (existingUser) { - logger.info( - 'Register User - Email in use', - { name: 'Request params:', value: user }, - { name: 'Existing user:', value: existingUser }, - ); - - // Sleep for 1 second - await new Promise((resolve) => setTimeout(resolve, 1000)); + if (!user) { + logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`); return { status: 200, message: genericVerificationMessage }; } - if (!(await isEmailDomainAllowed(email))) { - const errorMessage = - 'The email address provided cannot be used. Please use a different email address.'; - logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`); - return { status: 403, message: errorMessage }; - } + const [verifyToken, hash] = createTokenHash(); - //determine if this is the first registered user (not counting anonymous_user) - const isFirstRegisteredUser = (await countUsers()) === 0; + const verificationLink = `${ + domains.client + }/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`; - const salt = bcrypt.genSaltSync(10); - const newUserData = { - provider: 'local', - email, - username, - name, - avatar: null, - role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER, - password: bcrypt.hashSync(password, salt), - ...additionalData, + 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.', }; - - const emailEnabled = checkEmailConfig(); - const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN); - const balanceConfig = await getBalanceConfig(); - - const newUser = await createUser(newUserData, balanceConfig, disableTTL, true); - newUserId = newUser._id; - if (emailEnabled && !newUser.emailVerified) { - await sendVerificationEmail({ - _id: newUserId, - email, - name, - }); - } else { - await updateUser(newUserId, { emailVerified: true }); - } - - return { status: 200, message: genericVerificationMessage }; - } catch (err) { - logger.error('[registerUser] Error in registering user:', err); - if (newUserId) { - const result = await deleteUserById(newUserId); - logger.warn( - `[registerUser] [Email: ${email}] [Temporary User deleted: ${JSON.stringify(result)}]`, - ); - } - return { status: 500, message: 'Something went wrong' }; } }; +/** + * 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) => { +const requestPasswordReset = async (req: Request) => { const { email } = req.body; + const { findUser, createToken, deleteTokens } = getMethods(); const user = await findUser({ email }, 'email _id'); const emailEnabled = checkEmailConfig(); @@ -302,50 +414,7 @@ const requestPasswordReset = async (req) => { }; }; -/** - * Reset Password - * - * @param {*} userId - * @param {String} token - * @param {String} password - * @returns - */ -const resetPassword = async (userId, token, password) => { - let passwordResetToken = await findToken({ - userId, - }); - - if (!passwordResetToken) { - return new Error('Invalid or expired password reset token'); - } - - const isValid = bcrypt.compareSync(token, passwordResetToken.token); - - if (!isValid) { - return new Error('Invalid or expired password reset token'); - } - - const hash = bcrypt.hashSync(password, 10); - const user = await updateUser(userId, { password: hash }); - - if (checkEmailConfig()) { - await sendEmail({ - email: user.email, - subject: 'Password Reset Successfully', - payload: { - appName: process.env.APP_TITLE || 'LibreChat', - name: user.name || user.username || user.email, - year: new Date().getFullYear(), - }, - template: 'passwordReset.handlebars', - }); - } - - await deleteTokens({ token: passwordResetToken.token }); - logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`); - return { message: 'Password reset was successful' }; -}; - +const isProduction = process.env.NODE_ENV === 'production'; /** * Set Auth Tokens * @@ -354,8 +423,14 @@ const resetPassword = async (userId, token, password) => { * @param {String} sessionId * @returns */ -const setAuthTokens = async (userId, res, sessionId = null) => { +const setAuthTokens = async ( + userId: string | ObjectId, + res: Response, + sessionId: string | null = null, +) => { try { + const { getUserById, generateToken, findSession, generateRefreshToken, createSession } = + getMethods(); const user = await getUserById(userId); const token = await generateToken(user); @@ -402,14 +477,14 @@ const setAuthTokens = async (userId, res, sessionId = null) => { * @param {Object} res - response object * @returns {String} - access token */ -const setOpenIDAuthTokens = (tokenset, res) => { +const setOpenIDAuthTokens = (tokenset: TokenEndpointResponse, res: Response) => { try { if (!tokenset) { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); return; } const { REFRESH_TOKEN_EXPIRY } = process.env ?? {}; - const expiryInMilliseconds = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7; // 7 days default + const expiryInMilliseconds = eval(REFRESH_TOKEN_EXPIRY ?? '') ?? 1000 * 60 * 60 * 24 * 7; // 7 days default const expirationDate = new Date(Date.now() + expiryInMilliseconds); if (tokenset == null) { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); @@ -437,73 +512,15 @@ const setOpenIDAuthTokens = (tokenset, res) => { throw error; } }; - -/** - * Resend Verification Email - * @param {Object} req - * @param {Object} req.body - * @param {String} req.body.email - * @returns {Promise<{status: number, message: string}>} - */ -const resendVerificationEmail = async (req) => { - try { - const { email } = req.body; - await deleteTokens(email); - const user = await findUser({ email }, 'email _id name'); - - if (!user) { - logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`); - return { status: 200, message: genericVerificationMessage }; - } - - const [verifyToken, hash] = createTokenHash(); - - const verificationLink = `${ - domains.client - }/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`; - - await sendEmail({ - email: user.email, - subject: 'Verify your email', - payload: { - appName: process.env.APP_TITLE || 'LibreChat', - name: user.name || user.username || user.email, - verificationLink: verificationLink, - year: new Date().getFullYear(), - }, - template: 'verifyEmail.handlebars', - }); - - await createToken({ - userId: user._id, - email: user.email, - token: hash, - createdAt: Date.now(), - expiresIn: 900, - }); - - logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`); - - return { - status: 200, - message: genericVerificationMessage, - }; - } catch (error) { - logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`); - return { - status: 500, - message: 'Something went wrong.', - }; - } -}; - -module.exports = { - logoutUser, - verifyEmail, - registerUser, +export { + setOpenIDAuthTokens, setAuthTokens, + logoutUser, + registerUser, + verifyEmail, + resendVerificationEmail, resetPassword, requestPasswordReset, - resendVerificationEmail, - setOpenIDAuthTokens, + checkEmailConfig, + initAuthModels, }; diff --git a/packages/auth/src/init.ts b/packages/auth/src/init.ts new file mode 100644 index 0000000000..5b99675db4 --- /dev/null +++ b/packages/auth/src/init.ts @@ -0,0 +1,28 @@ +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/strategies/validators.ts b/packages/auth/src/strategies/validators.ts new file mode 100644 index 0000000000..89b343d91f --- /dev/null +++ b/packages/auth/src/strategies/validators.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; + +const allowedCharactersRegex = new RegExp( + '^[' + + 'a-zA-Z0-9_.@#$%&*()' + // Basic Latin characters and symbols + '\\p{Script=Latin}' + // Latin script characters + '\\p{Script=Common}' + // Characters common across scripts + '\\p{Script=Cyrillic}' + // Cyrillic script + '\\p{Script=Devanagari}' + // Devanagari script + '\\p{Script=Han}' + // Han script + '\\p{Script=Arabic}' + // Arabic script + '\\p{Script=Hiragana}' + // Hiragana + '\\p{Script=Katakana}' + // Katakana + '\\p{Script=Hangul}' + // Hangul + ']+$', // End + 'u', // Unicode mode +); + +const injectionPatternsRegex = /('|--|\$ne|\$gt|\$lt|\$or|\{|\}|\*|;|<|>|\/|=)/i; + +const usernameSchema = z + .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', + }); + } + }); + +export { usernameSchema, loginSchema, registerSchema }; diff --git a/packages/auth/src/utils/index.ts b/packages/auth/src/utils/index.ts new file mode 100644 index 0000000000..eb6e23c037 --- /dev/null +++ b/packages/auth/src/utils/index.ts @@ -0,0 +1,38 @@ +export * from './schemaMethods'; + +/** + * Checks if the given value is truthy by being either the boolean `true` or a string + * that case-insensitively matches 'true'. + * + * @function + * @param {string|boolean|null|undefined} value - The value to check. + * @returns {boolean} Returns `true` if the value is the boolean `true` or a case-insensitive + * match for the string 'true', otherwise returns `false`. + * @example + * + * isEnabled("True"); // returns true + * isEnabled("TRUE"); // returns true + * isEnabled(true); // returns true + * isEnabled("false"); // returns false + * isEnabled(false); // returns false + * isEnabled(null); // returns false + * isEnabled(); // returns false + */ +export function isEnabled(value: boolean | string) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + return value.toLowerCase().trim() === 'true'; + } + return false; +} + +export function checkEmailConfig() { + return ( + (!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) && + !!process.env.EMAIL_USERNAME && + !!process.env.EMAIL_PASSWORD && + !!process.env.EMAIL_FROM + ); +} diff --git a/packages/auth/src/utils/schemaMethods.ts b/packages/auth/src/utils/schemaMethods.ts new file mode 100644 index 0000000000..39bfc8797c --- /dev/null +++ b/packages/auth/src/utils/schemaMethods.ts @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000000..9e92c29683 --- /dev/null +++ b/packages/auth/src/utils/sendEmail.ts @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000000..b7dbf09580 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2019", + "module": "ESNext", + "moduleResolution": "node", + "declaration": true, + "declarationDir": "dist/types", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "sourceMap": true, + "paths": { + "@librechat/data-schemas/*": ["./packages/data-schemas/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/auth/tsconfig.spec.json b/packages/auth/tsconfig.spec.json new file mode 100644 index 0000000000..f766b118e4 --- /dev/null +++ b/packages/auth/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "outDir": "./dist/tests", + "baseUrl": "." + }, + "include": ["specs/**/*", "src/**/*"], + "exclude": ["node_modules", "dist"] +}