- Move auth strategies to package/auth

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

View file

@ -17,8 +17,9 @@
"dist"
],
"scripts": {
"copy-templates": "mkdir -p dist/utils && cp -R src/utils/emails/* dist/utils",
"clean": "rimraf dist",
"build": "npm run clean && rollup -c --silent --bundleConfigAsCjs",
"build": "npm run clean && npm run copy-templates && rollup -c --silent --bundleConfigAsCjs",
"build:watch": "rollup -c -w",
"test": "jest --coverage --watch",
"test:ci": "jest --coverage --ci",
@ -49,6 +50,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.0",
"@types/passport-jwt": "^4.0.1",
"@types/traverse": "^0.6.37",
"jest": "^29.5.0",
"jest-junit": "^16.0.0",
@ -62,13 +64,27 @@
},
"dependencies": {
"@librechat/data-schemas": "^0.0.7",
"@node-saml/passport-saml": "^5.0.1",
"bcryptjs": "^3.0.2",
"crypto": "^1.0.1",
"handlebars": "^4.7.8",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"klona": "^2.0.6",
"mongoose": "^8.12.1",
"nodemailer": "^7.0.3",
"openid-client": "^6.5.0",
"passport": "^0.7.0",
"passport-apple": "^2.0.2",
"passport-discord": "^0.1.4",
"passport-facebook": "^3.0.0",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-ldapauth": "^3.0.1",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.8.0",
"sharp": "^0.33.5",
"traverse": "^0.6.11",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
@ -86,4 +102,4 @@
"typescript",
"librechat"
]
}
}

View file

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

View file

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

View file

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

View file

@ -2,32 +2,16 @@ import { Request, Response } from 'express';
import { TokenEndpointResponse } from 'openid-client';
import { errorsToString, SystemRoles } from 'librechat-data-provider';
import bcrypt from 'bcryptjs';
import { IUser } from '@librechat/data-schemas';
import { IUser, logger } from '@librechat/data-schemas';
import { registerSchema } from './strategies/validators';
import { webcrypto } from 'node:crypto';
import { sendEmail } from './utils/sendEmail';
import logger from './config/winston';
import { sendVerificationEmail } from './utils/email';
import { ObjectId } from 'mongoose';
import { initAuth, getMethods } from './initAuth';
import { AuthenticatedRequest, LogoutResponse } from './types';
import { checkEmailConfig, isEnabled } from './utils';
import { initAuthModels, getMethods } from './init';
const genericVerificationMessage = 'Please check your email to verify your email address.';
const domains = {
client: process.env.DOMAIN_CLIENT,
server: process.env.DOMAIN_SERVER,
};
interface LogoutResponse {
status: number;
message: string;
}
interface AuthenticatedRequest extends Request {
user?: { _id: string };
session?: {
destroy: (callback?: (err?: any) => void) => void;
};
}
/**
* Logout user
*
@ -157,263 +141,6 @@ const registerUser = async (
}
};
/**
* Creates Token and corresponding Hash for verification
* @returns {[string, string]}
*/
const createTokenHash = (): [string, string] => {
const token: string = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString('hex');
const hash: string = bcrypt.hashSync(token, 10);
return [token, hash];
};
/**
* Send Verification Email
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
* @returns {Promise<void>}
*/
const sendVerificationEmail = async (user: Partial<IUser> & { _id: ObjectId; email: string }) => {
const [verifyToken, hash] = createTokenHash();
const { createToken } = getMethods();
const verificationLink = `${
domains.client
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
await sendEmail({
email: user.email,
subject: 'Verify your email',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
template: 'verifyEmail.handlebars',
});
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
};
/**
* Verify Email
* @param {Express.Request} req
*/
const verifyEmail = async (req: Request) => {
const { email, token } = req.body;
const decodedEmail = decodeURIComponent(email);
const { findUser, findToken, updateUser, deleteTokens } = getMethods();
const user = await findUser({ email: decodedEmail }, 'email _id emailVerified');
if (!user) {
logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`);
return new Error('User not found');
}
if (user.emailVerified) {
logger.info(`[verifyEmail] Email already verified [Email: ${decodedEmail}]`);
return { message: 'Email already verified', status: 'success' };
}
let emailVerificationData = await findToken({ email: decodedEmail });
if (!emailVerificationData) {
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`);
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, emailVerificationData.token);
if (!isValid) {
logger.warn(
`[verifyEmail] [Invalid or expired email verification token] [Email: ${decodedEmail}]`,
);
return new Error('Invalid or expired email verification token');
}
const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true });
if (!updatedUser) {
logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`);
return new Error('Failed to update user verification status');
}
await deleteTokens({ token: emailVerificationData.token });
logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`);
return { message: 'Email verification was successful', status: 'success' };
};
/**
* Resend Verification Email
* @param {Object} req
* @param {Object} req.body
* @param {String} req.body.email
* @returns {Promise<{status: number, message: string}>}
*/
const resendVerificationEmail = async (req: Request) => {
try {
const { deleteTokens, findUser, createToken } = getMethods();
const { email } = req.body;
await deleteTokens(email);
const user = await findUser({ email }, 'email _id name');
if (!user) {
logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`);
return { status: 200, message: genericVerificationMessage };
}
const [verifyToken, hash] = createTokenHash();
const verificationLink = `${
domains.client
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
await sendEmail({
email: user.email,
subject: 'Verify your email',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
template: 'verifyEmail.handlebars',
});
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
return {
status: 200,
message: genericVerificationMessage,
};
} catch (error: any) {
logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`);
return {
status: 500,
message: 'Something went wrong.',
};
}
};
/**
* Reset Password
*
* @param {*} userId
* @param {String} token
* @param {String} password
* @returns
*/
const resetPassword = async (userId: string | ObjectId, token: string, password: string) => {
const { findToken, updateUser, deleteTokens } = getMethods();
let passwordResetToken = await findToken({
userId,
});
if (!passwordResetToken) {
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, passwordResetToken.token);
if (!isValid) {
return new Error('Invalid or expired password reset token');
}
const hash = bcrypt.hashSync(password, 10);
const user = await updateUser(userId, { password: hash });
if (checkEmailConfig()) {
await sendEmail({
email: user.email,
subject: 'Password Reset Successfully',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
year: new Date().getFullYear(),
},
template: 'passwordReset.handlebars',
});
}
await deleteTokens({ token: passwordResetToken.token });
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
return { message: 'Password reset was successful' };
};
/**
* Request password reset
* @param {Express.Request} req
*/
const requestPasswordReset = async (req: Request) => {
const { email } = req.body;
const { findUser, createToken, deleteTokens } = getMethods();
const user = await findUser({ email }, 'email _id');
const emailEnabled = checkEmailConfig();
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);
if (!user) {
logger.warn(`[requestPasswordReset] [No user found] [Email: ${email}] [IP: ${req.ip}]`);
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
}
await deleteTokens({ userId: user._id });
const [resetToken, hash] = createTokenHash();
await createToken({
userId: user._id,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`;
if (emailEnabled) {
await sendEmail({
email: user.email,
subject: 'Password Reset Request',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
link: link,
year: new Date().getFullYear(),
},
template: 'requestPasswordReset.handlebars',
});
logger.info(
`[requestPasswordReset] Link emailed. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
);
} else {
logger.info(
`[requestPasswordReset] Link issued. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`,
);
return { link };
}
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
};
const isProduction = process.env.NODE_ENV === 'production';
/**
* Set Auth Tokens
@ -512,15 +239,7 @@ const setOpenIDAuthTokens = (tokenset: TokenEndpointResponse, res: Response) =>
throw error;
}
};
export {
setOpenIDAuthTokens,
setAuthTokens,
logoutUser,
registerUser,
verifyEmail,
resendVerificationEmail,
resetPassword,
requestPasswordReset,
checkEmailConfig,
initAuthModels,
};
export { setOpenIDAuthTokens, setAuthTokens, logoutUser, registerUser, initAuth };
export * from './strategies';
export * from './utils';

View file

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

View file

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

View file

@ -0,0 +1,53 @@
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
* @param {Object} params - Parameters from the verify callback
* @param {string} params.idToken - The ID token received from Apple
* @param {Object} params.profile - The profile object (may contain partial info)
* @returns {Object} - The extracted user profile details
*/
const getProfileDetails: GetProfileDetails = ({ profile, idToken }: GetProfileDetailsParams) => {
if (!idToken) {
logger.error('idToken is missing');
throw new Error('idToken is missing');
}
const decoded: any = jwt.decode(idToken);
logger.debug(`Decoded Apple JWT: ${JSON.stringify(decoded, null, 2)}`);
return {
email: decoded.email,
id: decoded.sub,
avatarUrl: null, // Apple does not provide an avatar URL
username: decoded.email ? decoded.email.split('@')[0].toLowerCase() : `user_${decoded.sub}`,
name: decoded.name
? `${decoded.name.firstName} ${decoded.name.lastName}`
: profile.displayName || null,
emailVerified: true, // Apple verifies the email
};
};
// Initialize the social login handler for Apple
const appleStrategy = socialLogin('apple', getProfileDetails);
const appleLogin = () =>
new AppleStrategy(
{
clientID: process.env.APPLE_CLIENT_ID,
teamID: process.env.APPLE_TEAM_ID,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.APPLE_CALLBACK_URL}`,
keyID: process.env.APPLE_KEY_ID,
privateKeyLocation: process.env.APPLE_PRIVATE_KEY_PATH,
passReqToCallback: false, // Set to true if you need to access the request in the callback
},
appleStrategy,
);
export default appleLogin;

View file

@ -0,0 +1,40 @@
import { Profile } from 'passport';
import { Strategy as DiscordStrategy } from 'passport-discord';
import socialLogin from './socialLogin';
import { GetProfileDetails } from './types';
const getProfileDetails: GetProfileDetails = ({ profile }: any) => {
let avatarUrl;
if (profile.avatar) {
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
avatarUrl = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`;
} else {
const defaultAvatarNum = Number(profile.discriminator) % 5;
avatarUrl = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`;
}
return {
email: profile.email,
id: profile.id,
avatarUrl,
username: profile.username,
name: profile.global_name,
emailVerified: true,
};
};
const discordStrategy = socialLogin('discord', getProfileDetails);
const discordLogin = () =>
new DiscordStrategy(
{
clientID: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.DISCORD_CALLBACK_URL}`,
scope: ['identify', 'email'],
authorizationURL: 'https://discord.com/api/oauth2/authorize?prompt=none',
},
discordStrategy,
);
export default discordLogin;

View file

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

View file

@ -0,0 +1,35 @@
import { Strategy as GitHubStrategy } from 'passport-github2';
import socialLogin from './socialLogin';
import { GetProfileDetails } from './types';
const getProfileDetails: GetProfileDetails = ({ profile }: any) => ({
email: profile.emails[0].value,
id: profile.id,
avatarUrl: profile.photos[0].value,
username: profile.username,
name: profile.displayName,
emailVerified: profile.emails[0].verified,
});
const githubStrategy = socialLogin('github', getProfileDetails);
const githubLogin = () =>
new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.GITHUB_CALLBACK_URL}`,
proxy: false,
scope: ['user:email'],
...(process.env.GITHUB_ENTERPRISE_BASE_URL && {
authorizationURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/authorize`,
tokenURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/access_token`,
userProfileURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user`,
userEmailURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user/emails`,
...(process.env.GITHUB_ENTERPRISE_USER_AGENT && {
userAgent: process.env.GITHUB_ENTERPRISE_USER_AGENT,
}),
}),
},
githubStrategy,
);
export default githubLogin;

View file

@ -0,0 +1,27 @@
import { Strategy as GoogleStrategy, Profile } from 'passport-google-oauth20';
import socialLogin from './socialLogin';
import { GetProfileDetails } from './types';
const getProfileDetails: GetProfileDetails = ({ profile }: Profile) => ({
email: profile.emails[0].value,
id: profile.id,
avatarUrl: profile.photos[0].value,
username: profile.name.givenName,
name: `${profile.name.givenName}${profile.name.familyName ? ` ${profile.name.familyName}` : ''}`,
emailVerified: profile.emails[0].verified,
});
const googleStrategy = socialLogin('google', getProfileDetails);
const googleLogin = () =>
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.GOOGLE_CALLBACK_URL}`,
proxy: true,
},
googleStrategy,
);
export default googleLogin;

View file

@ -0,0 +1,100 @@
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
* '?manual=true', it updates the user's avatar with the provided URL. For local file storage, it directly updates
* the avatar URL, while for other storage types, it processes the avatar URL using the specified file strategy.
*
* @param {MongoUser} oldUser - The existing user object that needs to be updated.
* @param {string} avatarUrl - The new avatar URL to be set for the user.
*
* @returns {Promise<void>}
* The function updates the user's avatar and saves the user object. It does not return any value.
*
* @throws {Error} Throws an error if there's an issue saving the updated user object.
*/
const handleExistingUser = async (oldUser: IUser, avatarUrl: string) => {
const fileStrategy = process.env.CDN_PROVIDER ?? FileSources.local;
const isLocal = fileStrategy === FileSources.local;
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 ?? '';
const resizedBuffer = await resizeAvatar({
userId,
input: avatarUrl,
});
const processAvatar = getAvatarProcessFunction(fileStrategy);
updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId });
}
if (updatedAvatar != '') {
const { updateUser } = getMethods();
await updateUser(oldUser._id, { avatar: updatedAvatar });
}
};
/**
* Creates a new user with the provided user details. If the file strategy is not local, the avatar URL is
* processed using the specified file strategy. The new user is saved to the database with the processed or
* original avatar URL.
*
* @param {Object} params - The parameters object for user creation.
* @param {string} params.email - The email of the new user.
* @param {string} params.avatarUrl - The avatar URL of the new user.
* @param {string} params.provider - The provider of the user's account.
* @param {string} params.providerKey - The key to identify the provider in the user model.
* @param {string} params.providerId - The provider-specific ID of the user.
* @param {string} params.username - The username of the new user.
* @param {string} params.name - The name of the new user.
* @param {boolean} [params.emailVerified=false] - Optional. Indicates whether the user's email is verified. Defaults to false.
*
* @returns {Promise<User>}
* A promise that resolves to the newly created user object.
*
* @throws {Error} Throws an error if there's an issue creating or saving the new user object.
*/
const createSocialUser = async ({
email,
avatarUrl,
provider,
providerKey,
providerId,
username,
name,
emailVerified,
}: CreateSocialUserParams): Promise<IUser> => {
const update = {
email,
avatar: avatarUrl,
provider,
[providerKey]: providerId,
username,
name,
emailVerified,
};
const balanceConfig = getBalanceConfig();
const { createUser, getUserById, updateUser } = getMethods();
const newUserId = await createUser(update, balanceConfig);
const fileStrategy = process.env.CDN_PROVIDER ?? FileSources.local;
const isLocal = fileStrategy === FileSources.local;
if (!isLocal) {
const resizedBuffer = await resizeAvatar({
userId: newUserId,
input: avatarUrl,
});
const processAvatar = getAvatarProcessFunction(fileStrategy);
const avatar = await processAvatar({ buffer: resizedBuffer, userId: newUserId });
await updateUser(newUserId, { avatar });
}
return await getUserById(newUserId);
};
export { handleExistingUser, createSocialUser };

View file

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

View file

@ -0,0 +1,41 @@
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 = (): PassportStrategy =>
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
} as StrategyOptionsWithoutRequest,
async (payload: JwtPayload, done: VerifiedCallback) => {
const { updateUser, getUserById } = getMethods();
try {
const user = await getUserById(payload?.id, '-password -__v -totpSecret');
if (user) {
user.id = user._id.toString();
if (!user.role) {
user.role = SystemRoles.USER;
await updateUser(user.id, { role: user.role });
}
done(null, user);
} else {
logger.warn('[jwtLogin] JwtStrategy => no user found: ' + payload?.id);
done(null, false);
}
} catch (err) {
done(err, false);
}
},
);
export default jwtLogin;

View file

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

View file

@ -0,0 +1,115 @@
import { IUser, logger } from '@librechat/data-schemas';
import { errorsToString } from 'librechat-data-provider';
import { Strategy as PassportLocalStrategy } from 'passport-local';
import { getMethods } from '../initAuth';
import { checkEmailConfig, isEnabled } from '../utils';
import { loginSchema } from './validators';
import bcrypt from 'bcryptjs';
import { Request } from 'express';
// Unix timestamp for 2024-06-07 15:20:18 Eastern Time
const verificationEnabledTimestamp = 1717788018;
async function validateLoginRequest(req) {
const { error } = loginSchema.safeParse(req.body);
return error ? errorsToString(error.errors) : null;
}
/**
* Compares the provided password with the user's password.
*
* @param {MongoUser} user - The user to compare the password for.
* @param {string} candidatePassword - The password to test against the user's password.
* @returns {Promise<boolean>} A promise that resolves to a boolean indicating if the password matches.
*/
const comparePassword = async (user: IUser, candidatePassword: string) => {
if (!user) {
throw new Error('No user provided');
}
return new Promise((resolve, reject) => {
bcrypt.compare(candidatePassword, user.password ?? '', (err, isMatch) => {
if (err) {
reject(err);
}
resolve(isMatch);
});
});
};
async function passportStrategy(
req: Request,
email: string,
password: string,
done: (error: any, user?: any, options?: { message: string }) => void,
) {
try {
const validationError = await validateLoginRequest(req);
if (validationError) {
logError('Passport Local Strategy - Validation Error', { reqBody: req.body });
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
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 });
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
return done(null, false, { message: 'Email does not exist.' });
}
const isMatch = await comparePassword(user, password);
if (!isMatch) {
logError('Passport Local Strategy - Password does not match', { isMatch });
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
return done(null, false, { message: 'Incorrect password.' });
}
const emailEnabled = checkEmailConfig();
const userCreatedAtTimestamp = Math.floor(new Date(user.createdAt).getTime() / 1000);
if (
!emailEnabled &&
!user.emailVerified &&
userCreatedAtTimestamp < verificationEnabledTimestamp
) {
await updateUser(user._id, { emailVerified: true });
user.emailVerified = true;
}
const unverifiedAllowed = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN ?? '');
if (user.expiresAt && unverifiedAllowed) {
await updateUser(user._id, {});
}
if (!user.emailVerified && !unverifiedAllowed) {
logError('Passport Local Strategy - Email not verified', { email });
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
return done(null, user, { message: 'Email not verified.' });
}
logger.info(`[Login] [Login successful] [Username: ${email}] [Request-IP: ${req.ip}]`);
return done(null, user);
} catch (err) {
return done(err);
}
}
function logError(title: string, parameters: any) {
const entries = Object.entries(parameters).map(([name, value]) => ({ name, value }));
logger.error(title, { parameters: entries });
}
const passportLogin = () =>
new PassportLocalStrategy(
{
usernameField: 'email',
passwordField: 'password',
session: false,
passReqToCallback: true,
},
passportStrategy,
);
export default passportLogin;

View file

@ -0,0 +1,55 @@
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.
* @returns {JwtStrategy}
* @description This function creates a JWT strategy for OpenID authentication.
* It uses the jwks-rsa library to retrieve the signing key from a JWKS endpoint.
* 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: client.Configuration) =>
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret({
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 ?? '',
}),
},
async (payload, done) => {
const { findUser, updateUser } = getMethods();
try {
const user = await findUser({ openidId: payload?.sub });
if (user) {
user.id = user._id.toString();
if (!user.role) {
user.role = SystemRoles.USER;
await updateUser(user.id, { role: user.role });
}
done(null, user);
} else {
logger.warn(
'[openIdJwtLogin] openId JwtStrategy => no user found with the sub claims: ' +
payload?.sub,
);
done(null, false);
}
} catch (err) {
done(err, false);
}
},
);
export default openIdJwtLogin;

View file

@ -0,0 +1,405 @@
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';
let crypto: typeof import('node:crypto') | undefined;
class CustomOpenIDStrategy extends OpenIDStrategy {
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: any, options: any) {
const params = super.authorizationRequestParams(req, options);
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
* @param {string} accessToken access token to be exchanged if necessary
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @param {boolean} fromCache - Indicates whether to use cached tokens.
* @returns {Promise<string>} The new access token if exchanged, otherwise the original access token.
*/
const exchangeAccessTokenIfNeeded = async (
config: 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;
}
}
const grantResponse = await client.genericGrantRequest(
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE || 'user.read',
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},
);
await tokensCache.set(
sub,
{
access_token: grantResponse.access_token,
},
(grantResponse?.expires_in ?? 0) * 1000,
);
return grantResponse.access_token;
}
return accessToken;
};
/**
* get user info from openid provider
* @param {Configuration} config
* @param {string} accessToken access token
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @returns {Promise<Object|null>}
*/
const getUserInfo = async (
config: client.Configuration,
accessToken: string,
sub: string,
): Promise<oauth.UserInfoResponse | null> => {
try {
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
return await client.fetchUserInfo(config, exchangedAccessToken, sub);
} catch (error) {
logger.warn(`[openidStrategy] getUserInfo: Error fetching user info: ${error}`);
return null;
}
};
/**
* Downloads an image from a URL using an access token.
* @param {string} url
* @param {Configuration} config
* @param {string} accessToken access token
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
* @returns {Promise<Buffer | string>} The image buffer or an empty string if the download fails.
*/
const downloadImage = async (
url: string,
config: client.Configuration,
accessToken: string,
sub: string,
) => {
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true);
if (!url) {
return '';
}
try {
const options: any = {
method: 'GET',
headers: {
Authorization: `Bearer ${exchangedAccessToken}`,
},
};
if (process.env.PROXY) {
options.agent = new HttpsProxyAgent(process.env.PROXY);
}
const response: Response = await fetch(url, options);
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return buffer;
} else {
throw new Error(`${response.statusText} (HTTP ${response.status})`);
}
} catch (error) {
logger.error(
`[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`,
);
return '';
}
};
/**
* Determines the full name of a user based on OpenID userinfo and environment configuration.
*
* @param {Object} userinfo - The user information object from OpenID Connect
* @param {string} [userinfo.given_name] - The user's first name
* @param {string} [userinfo.family_name] - The user's last name
* @param {string} [userinfo.username] - The user's username
* @param {string} [userinfo.email] - The user's email address
* @returns {string} The determined full name of the user
*/
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) {
return `${userinfo.given_name} ${userinfo.family_name}`;
}
if (userinfo.given_name) {
return userinfo.given_name;
}
if (userinfo.family_name) {
return userinfo.family_name;
}
return (userinfo?.username || userinfo?.email) ?? '';
}
/**
* 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;
}
/**
* Sets up the OpenID strategy for authentication.
* This function configures the OpenID client, handles proxy settings,
* and defines the OpenID strategy for Passport.js.
*
* @async
* @function setupOpenId
* @returns {Promise<Configuration | null>} A promise that resolves when the OpenID strategy is set up and returns the openid client config object.
* @throws {Error} If an error occurs during the setup process.
*/
async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
try {
tokensCache = tokensCacheKv;
/** @type {ClientMetadata} */
const clientMetadata = {
client_id: process.env.OPENID_CLIENT_ID,
client_secret: process.env.OPENID_CLIENT_SECRET,
};
/** @type {Configuration} */
openidConfig = await client.discovery(
new URL(process.env.OPENID_ISSUER ?? ''),
process.env.OPENID_CLIENT_ID ?? '',
clientMetadata,
);
const { findUser, createUser, updateUser } = getMethods();
if (process.env.PROXY) {
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
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: 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}`,
usePKCE,
},
async (
tokenset: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers,
done: passport.AuthenticateCallback,
) => {
try {
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}`,
);
if (!user) {
user = await findUser({ email: claims?.email });
logger.info(
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
claims?.email
} for openidId: ${claims?.sub}`,
);
}
const userinfo: any = {
...claims,
...(await getUserInfo(openidConfig, tokenset.access_token, claims?.sub ?? '')),
};
const fullName = getFullName(userinfo);
if (requiredRole) {
let decodedToken = null;
if (requiredRoleTokenKind === 'access') {
decodedToken = jwt.decode(tokenset.access_token);
} else if (requiredRoleTokenKind === 'id') {
decodedToken = jwt.decode(tokenset.id_token ?? '');
}
const pathParts = requiredRoleParameterPath?.split('.');
let found = true;
let roles: any = decodedToken;
if (pathParts) {
for (const key of pathParts) {
if (roles && typeof roles === 'object' && key in roles) {
roles = (roles as Record<string, unknown>)[key];
} else {
found = false;
break;
}
}
}
if (!found) {
logger.error(
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
);
}
if (!roles?.includes(requiredRole)) {
return done(null, false, {
message: `You must have the "${requiredRole}" role to log in.`,
});
}
}
let username = '';
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,
);
}
if (!user) {
user = {
provider: 'openid',
openidId: userinfo.sub,
username,
email: userinfo.email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
};
const balanceConfig = getBalanceConfig();
user = await createUser(user, balanceConfig, true, true);
} else {
user.provider = 'openid';
user.openidId = userinfo.sub;
user.username = username;
user.name = fullName;
}
if (!!userinfo && userinfo.picture && !user?.avatar?.includes('manual=true')) {
/** @type {string | undefined} */
const imageUrl = userinfo.picture;
let fileName;
try {
crypto = await import('node:crypto');
} catch (err) {
logger.error('[openidStrategy] crypto support is disabled!', err);
}
if (crypto) {
fileName = (await hashToken(userinfo.sub)) + '.png';
} else {
fileName = userinfo.sub + '.png';
}
const imageBuffer = await downloadImage(
imageUrl,
openidConfig,
tokenset.access_token,
userinfo.sub,
);
if (imageBuffer) {
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(
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
{
user: {
openidId: user.openidId,
username: user.username,
email: user.email,
name: user.name,
},
},
);
done(null, { ...user, tokenset });
} catch (err) {
logger.error('[openidStrategy] login failed', err);
done(err);
}
},
);
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(): client.Configuration {
if (!openidConfig) {
throw new Error('OpenID client is not initialized. Please call setupOpenId first.');
}
return openidConfig;
}
export { setupOpenId, getOpenIdConfig };

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
export interface SendEmailParams {
email: string;
subject: string;
payload: Record<string, string | number>;
template: string;
throwError?: boolean;
}
export interface SendEmailResponse {
accepted: string[];
rejected: string[];
response: string;
envelope: { from: string; to: string[] };
messageId: string;
}

View file

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

View file

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

View file

@ -0,0 +1,222 @@
import fs from 'fs';
import path from 'path';
import nodemailer, { TransportOptions } from 'nodemailer';
import handlebars from 'handlebars';
import { createTokenHash, isEnabled } from '.';
import { IUser, logger } from '@librechat/data-schemas';
import { getMethods } from '../initAuth';
import { ObjectId } from 'mongoose';
import bcrypt from 'bcryptjs';
import { Request } from 'express';
import { SendEmailParams, SendEmailResponse } from '../types/email';
const genericVerificationMessage = 'Please check your email to verify your email address.';
const domains = {
client: process.env.DOMAIN_CLIENT,
server: process.env.DOMAIN_SERVER,
};
export const sendEmail = async ({
email,
subject,
payload,
template,
throwError = true,
}: SendEmailParams): Promise<SendEmailResponse | Error> => {
try {
const transporterOptions: TransportOptions = {
secure: process.env.EMAIL_ENCRYPTION === 'tls',
requireTLS: process.env.EMAIL_ENCRYPTION === 'starttls',
tls: {
rejectUnauthorized: !isEnabled(process.env.EMAIL_ALLOW_SELFSIGNED ?? ''),
},
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD,
},
};
if (process.env.EMAIL_ENCRYPTION_HOSTNAME) {
transporterOptions.tls = {
...transporterOptions.tls,
servername: process.env.EMAIL_ENCRYPTION_HOSTNAME,
};
}
if (process.env.EMAIL_SERVICE) {
transporterOptions.service = process.env.EMAIL_SERVICE;
} else {
transporterOptions.host = process.env.EMAIL_HOST;
transporterOptions.port = Number(process.env.EMAIL_PORT ?? 25);
}
const transporter = nodemailer.createTransport(transporterOptions);
const templatePath = path.join(__dirname, 'utils/', template);
const source = fs.readFileSync(templatePath, 'utf8');
const compiledTemplate = handlebars.compile(source);
const mailOptions = {
from: `"${process.env.EMAIL_FROM_NAME || process.env.APP_TITLE}" <${process.env.EMAIL_FROM}>`,
to: `"${payload.name}" <${email}>`,
envelope: {
from: process.env.EMAIL_FROM!,
to: email,
},
subject,
html: compiledTemplate(payload),
};
return await transporter.sendMail(mailOptions);
} catch (error: any) {
if (throwError) {
throw error;
}
logger.error('[sendEmail]', error);
return error;
}
};
/**
* Send Verification Email
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
* @returns {Promise<void>}
*/
export const sendVerificationEmail = async (
user: Partial<IUser> & { _id: ObjectId; email: string },
) => {
const [verifyToken, hash] = createTokenHash();
const { createToken } = getMethods();
const verificationLink = `${
domains.client
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
await sendEmail({
email: user.email,
subject: 'Verify your email',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
template: 'verifyEmail.handlebars',
});
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
};
/**
* Verify Email
* @param {Express.Request} req
*/
export const verifyEmail = async (req: Request) => {
const { email, token } = req.body;
const decodedEmail = decodeURIComponent(email);
const { findUser, findToken, updateUser, deleteTokens } = getMethods();
const user = await findUser({ email: decodedEmail }, 'email _id emailVerified');
if (!user) {
logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`);
return new Error('User not found');
}
if (user.emailVerified) {
logger.info(`[verifyEmail] Email already verified [Email: ${decodedEmail}]`);
return { message: 'Email already verified', status: 'success' };
}
let emailVerificationData = await findToken({ email: decodedEmail });
if (!emailVerificationData) {
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`);
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, emailVerificationData.token);
if (!isValid) {
logger.warn(
`[verifyEmail] [Invalid or expired email verification token] [Email: ${decodedEmail}]`,
);
return new Error('Invalid or expired email verification token');
}
const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true });
if (!updatedUser) {
logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`);
return new Error('Failed to update user verification status');
}
await deleteTokens({ token: emailVerificationData.token });
logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`);
return { message: 'Email verification was successful', status: 'success' };
};
/**
* Resend Verification Email
* @param {Object} req
* @param {Object} req.body
* @param {String} req.body.email
* @returns {Promise<{status: number, message: string}>}
*/
export const resendVerificationEmail = async (req: Request) => {
try {
const { deleteTokens, findUser, createToken } = getMethods();
const { email } = req.body as { email: string };
await deleteTokens(email);
const user = await findUser({ email }, 'email _id name');
if (!user) {
logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`);
return { status: 200, message: genericVerificationMessage };
}
const [verifyToken, hash] = createTokenHash();
const verificationLink = `${
domains.client
}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`;
await sendEmail({
email: user.email,
subject: 'Verify your email',
payload: {
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name || user.username || user.email,
verificationLink: verificationLink,
year: new Date().getFullYear(),
},
template: 'verifyEmail.handlebars',
});
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
});
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
return {
status: 200,
message: genericVerificationMessage,
};
} catch (error: any) {
logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`);
return {
status: 500,
message: 'Something went wrong.',
};
}
};

View file

@ -0,0 +1,287 @@
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } p { margin: 0; } .ie-container table, .mso-container table { table-layout: fixed;
} * { line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; } #u_body a { color: #0000ee;
text-decoration: underline; }
</style>
</head>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
id='u_body'
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><table width="100%"><tr><td><![endif]-->
<h1
style='margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;'
>
<div>
<div>You have been invited to join {{appName}}!</div>
</div>
</div>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Hi,</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<p style='line-height: 140%;'>You have been invited to join {{appName}}. Click the
button below to create your account and get started.</p>
</p>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align='left'>
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{inviteLink}}" style="height:37px; v-text-anchor:middle; width:142px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a
href='{{inviteLink}}'
target='_blank'
class='v-button'
style='box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;'
>
<span
style='display:block;padding:10px 20px;line-height:120%;'
><span style='line-height: 16.8px;'>Create Account</span></span>
</span></span>
</a>
<!--[if mso]></center></v:roundrect><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>
Hurry up, the invite will expiry in 7 days
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>

View file

@ -0,0 +1,196 @@
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } .ie-container table, .mso-container table { table-layout: fixed; } * {
line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; }
</style>
</head>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Hi {{name}},</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>Your password has been updated successfully! </div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>

View file

@ -0,0 +1,284 @@
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } p { margin: 0; } .ie-container table, .mso-container table { table-layout: fixed;
} * { line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; } #u_body a { color: #0000ee;
text-decoration: underline; }
</style>
</head>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
id='u_body'
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><table width="100%"><tr><td><![endif]-->
<h1
style='margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;'
>
<div>
<div>You have requested to reset your password.
</div>
</div>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Hi {{name}},</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<p style='line-height: 140%;'>Please click the button below to
reset your password.</p>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align='left'>
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{link}}" style="height:37px; v-text-anchor:middle; width:142px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a
href='{{link}}'
target='_blank'
class='v-button'
style='box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;'
>
<span
style='display:block;padding:10px 20px;line-height:120%;'
><span style='line-height: 16.8px;'>Reset Password</span></span>
</a>
<!--[if mso]></center></v:roundrect><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>If you did not request a password reset, please ignore this
email.</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>

View file

@ -0,0 +1,290 @@
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } .ie-container table, .mso-container table { table-layout: fixed; } * {
line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; } #u_body a { color: #0000ee;
text-decoration: underline; }
</style>
</head>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
id='u_body'
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><table width="100%"><tr><td><![endif]-->
<h1
style='margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;'
>
<div>
<div>Welcome to {{appName}}!</div>
</div>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>Dear {{name}},</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>Thank you for registering with
{{appName}}. To complete your registration and verify your
email address, please click the button below:</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align='left'>
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="href=&quot;{{verificationLink}}&quot;" style="height:37px; v-text-anchor:middle; width:114px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a
href='{{verificationLink}}'
target='_blank'
class='v-button'
style='box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;'
>
<span style='display:block;padding:10px 20px;line-height:120%;'>
<div>
<div>Verify Email</div>
</div>
</span>
</a>
<!--[if mso]></center></v:roundrect><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>If you did not create an account with
{{appName}}, please ignore this email.</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>

View file

@ -1,4 +1,15 @@
export * from './schemaMethods';
export * from './avatar';
import { webcrypto } from 'node:crypto';
import bcrypt from 'bcryptjs';
/**
* Creates Token and corresponding Hash for verification
* @returns {[string, string]}
*/
const createTokenHash = (): [string, string] => {
const token: string = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString('hex');
const hash: string = bcrypt.hashSync(token, 10);
return [token, hash];
};
/**
* Checks if the given value is truthy by being either the boolean `true` or a string
@ -18,7 +29,7 @@ export * from './schemaMethods';
* isEnabled(null); // returns false
* isEnabled(); // returns false
*/
export function isEnabled(value: boolean | string) {
function isEnabled(value: boolean | string) {
if (typeof value === 'boolean') {
return value;
}
@ -28,7 +39,7 @@ export function isEnabled(value: boolean | string) {
return false;
}
export function checkEmailConfig() {
function checkEmailConfig() {
return (
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
@ -36,3 +47,9 @@ export function checkEmailConfig() {
!!process.env.EMAIL_FROM
);
}
export { checkEmailConfig, isEnabled, createTokenHash };
// export this helper so we can mock them
export { sendEmail, sendVerificationEmail, verifyEmail, resendVerificationEmail } from './email';
export { resizeAvatar, resizeAndConvert, getAvatarProcessFunction } from './avatar';
export { requestPasswordReset, resetPassword } from './password';

View file

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

View file

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

View file

@ -1,83 +0,0 @@
import fs from 'fs';
import path from 'path';
import nodemailer, { TransportOptions } from 'nodemailer';
import handlebars from 'handlebars';
import logger from '../config/winston';
import { isEnabled } from '.';
interface SendEmailParams {
email: string;
subject: string;
payload: Record<string, string | number>;
template: string;
throwError?: boolean;
}
interface SendEmailResponse {
accepted: string[];
rejected: string[];
response: string;
envelope: { from: string; to: string[] };
messageId: string;
}
export const sendEmail = async ({
email,
subject,
payload,
template,
throwError = true,
}: SendEmailParams): Promise<SendEmailResponse | Error> => {
try {
const transporterOptions: TransportOptions = {
secure: process.env.EMAIL_ENCRYPTION === 'tls',
requireTLS: process.env.EMAIL_ENCRYPTION === 'starttls',
tls: {
rejectUnauthorized: !isEnabled(process.env.EMAIL_ALLOW_SELFSIGNED ?? ''),
},
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD,
},
};
if (process.env.EMAIL_ENCRYPTION_HOSTNAME) {
transporterOptions.tls = {
...transporterOptions.tls,
servername: process.env.EMAIL_ENCRYPTION_HOSTNAME,
};
}
if (process.env.EMAIL_SERVICE) {
transporterOptions.service = process.env.EMAIL_SERVICE;
} else {
transporterOptions.host = process.env.EMAIL_HOST;
transporterOptions.port = Number(process.env.EMAIL_PORT ?? 25);
}
const transporter = nodemailer.createTransport(transporterOptions);
const templatePath = path.join(__dirname, 'emails', template);
const source = fs.readFileSync(templatePath, 'utf8');
const compiledTemplate = handlebars.compile(source);
const mailOptions = {
from: `"${process.env.EMAIL_FROM_NAME || process.env.APP_TITLE}" <${process.env.EMAIL_FROM}>`,
to: `"${payload.name}" <${email}>`,
envelope: {
from: process.env.EMAIL_FROM!,
to: email,
},
subject,
html: compiledTemplate(payload),
};
return await transporter.sendMail(mailOptions);
} catch (error: any) {
if (throwError) {
throw error;
}
logger.error('[sendEmail]', error);
return error;
}
};

View file

@ -13,9 +13,12 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@librechat/data-schemas/*": ["./packages/data-schemas/*"]
}
},
"typeRoots": ["./src/types", "./node_modules/@types"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]