Merge branch 'main' into refactor/package-auth

This commit is contained in:
Cha 2025-06-17 18:26:25 +08:00
commit 02b9c9d447
340 changed files with 18559 additions and 14872 deletions

View file

@ -5,6 +5,7 @@ export default {
testResultsProcessor: 'jest-junit',
moduleNameMapper: {
'^@src/(.*)$': '<rootDir>/src/$1',
'~/(.*)': '<rootDir>/src/$1',
},
// coverageThreshold: {
// global: {
@ -16,4 +17,4 @@ export default {
// },
restoreMocks: true,
testTimeout: 15000,
};
};

View file

@ -48,11 +48,11 @@
"@types/bcrypt": "^5.0.2",
"@types/diff": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/jest": "^29.5.14",
"@types/node": "^20.3.0",
"@types/passport-jwt": "^4.0.1",
"@types/traverse": "^0.6.37",
"jest": "^29.5.0",
"jest": "^29.7.0",
"jest-junit": "^16.0.0",
"rimraf": "^5.0.1",
"rollup": "^4.22.4",
@ -63,14 +63,19 @@
"typescript": "^5.0.4"
},
"dependencies": {
"@librechat/data-schemas": "^0.0.7",
"@librechat/api": "^1.2.3",
"@librechat/data-schemas": "^0.0.8",
"@node-saml/passport-saml": "^5.0.1",
"@types/nodemailer": "^6.4.17",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"crypto": "^1.0.1",
"form-data": "^4.0.3",
"handlebars": "^4.7.8",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"klona": "^2.0.6",
"mongodb-memory-server": "^10.1.4",
"mongoose": "^8.12.1",
"nodemailer": "^7.0.3",
"openid-client": "^6.5.0",
@ -85,9 +90,7 @@
"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"
"traverse": "^0.6.11"
},
"peerDependencies": {
"keyv": "^5.3.2"
@ -102,4 +105,4 @@
"typescript",
"librechat"
]
}
}

View file

@ -211,7 +211,10 @@ const setOpenIDAuthTokens = (tokenset: TokenEndpointResponse, res: Response) =>
return;
}
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
const expiryInMilliseconds = eval(REFRESH_TOKEN_EXPIRY ?? '') ?? 1000 * 60 * 60 * 24 * 7; // 7 days default
const expiryInMilliseconds = REFRESH_TOKEN_EXPIRY
? eval(REFRESH_TOKEN_EXPIRY)
: 1000 * 60 * 60 * 24 * 7; // 7 days default
const expirationDate = new Date(Date.now() + expiryInMilliseconds);
if (tokenset == null) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');

View file

@ -1,5 +1,5 @@
import { BalanceConfig, createMethods } from '@librechat/data-schemas';
import type { Mongoose } from 'mongoose';
import { BalanceConfig, createMethods } from '@librechat/data-schemas';
// Flag to prevent re-initialization
let initialized = false;

View file

@ -0,0 +1,409 @@
import mongoose from 'mongoose';
import { Strategy as AppleStrategy, Profile as AppleProfile } from 'passport-apple';
import { MongoMemoryServer } from 'mongodb-memory-server';
import jwt from 'jsonwebtoken';
import { logger, userSchema } from '@librechat/data-schemas';
import { isEnabled } from '@librechat/api';
import { createSocialUser, handleExistingUser } from './helpers';
import { socialLogin } from './socialLogin';
import { IUser } from '@librechat/data-schemas';
const mockFindUser = jest.fn();
jest.mock('jsonwebtoken');
jest.mock('@librechat/data-schemas', () => {
const actualModule = jest.requireActual('@librechat/data-schemas');
return {
...actualModule,
logger: {
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
createMethods: jest.fn(() => {
return { findUser: mockFindUser };
}),
};
});
jest.mock('../initAuth', () => {
const actualModule = jest.requireActual('../initAuth');
return {
...actualModule,
getMethods: jest.fn(() => {
return { findUser: mockFindUser };
}),
};
});
jest.mock('./helpers', () => {
const actualModule = jest.requireActual('./helpers');
return {
...actualModule,
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
};
});
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(),
}));
describe('Apple Login Strategy', () => {
let mongoServer: MongoMemoryServer;
let appleStrategyInstance: InstanceType<typeof AppleStrategy>;
let User: any;
const OLD_ENV = process.env;
let getProfileDetails: ({
idToken,
profile,
}: {
idToken: string | null;
profile: AppleProfile;
}) => Partial<IUser> & { avatarUrl: null };
// Start and stop in-memory MongoDB
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
User = mongoose.models.User || mongoose.model('User', userSchema);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
process.env = OLD_ENV;
});
beforeEach(async () => {
// Reset environment variables
process.env = { ...OLD_ENV };
process.env.APPLE_CLIENT_ID = 'fake_client_id';
process.env.APPLE_TEAM_ID = 'fake_team_id';
process.env.APPLE_CALLBACK_URL = '/auth/apple/callback';
process.env.DOMAIN_SERVER = 'https://example.com';
process.env.APPLE_KEY_ID = 'fake_key_id';
process.env.APPLE_PRIVATE_KEY_PATH = '/path/to/fake/private/key';
process.env.ALLOW_SOCIAL_REGISTRATION = 'true';
// Clear mocks and database
jest.clearAllMocks();
await User.deleteMany({});
// Define getProfileDetails within the test scope
getProfileDetails = ({ idToken, profile }) => {
if (!idToken) {
logger.error('idToken is missing');
throw new Error('idToken is missing');
}
const decoded = jwt.decode(idToken) as any;
if (!decoded) {
logger.error('Failed to decode idToken');
throw new Error('idToken is invalid');
}
console.log('Decoded token:', decoded);
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
};
};
// Mock isEnabled based on environment variable
(isEnabled as jest.Mock).mockImplementation((flag: string) => flag === 'true');
// Initialize the strategy with the mocked getProfileDetails
const appleLogin = socialLogin('apple', getProfileDetails);
appleStrategyInstance = 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,
},
appleLogin,
);
});
const mockProfile = {
displayName: 'John Doe',
};
describe('getProfileDetails', () => {
it('should throw an error if idToken is missing', () => {
expect(() => {
getProfileDetails({ idToken: null, profile: mockProfile });
}).toThrow('idToken is missing');
expect(logger.error).toHaveBeenCalledWith('idToken is missing');
});
it('should throw an error if idToken cannot be decoded', () => {
(jwt.decode as jest.Mock).mockReturnValue(null);
expect(() => {
getProfileDetails({ idToken: 'invalid_id_token', profile: mockProfile });
}).toThrow('idToken is invalid');
expect(logger.error).toHaveBeenCalledWith('Failed to decode idToken');
});
it('should extract user details correctly from idToken', () => {
const fakeDecodedToken = {
email: 'john.doe@example.com',
sub: 'apple-sub-1234',
name: {
firstName: 'John',
lastName: 'Doe',
},
};
(jwt.decode as jest.Mock).mockReturnValue(fakeDecodedToken);
const profileDetails = getProfileDetails({
idToken: 'fake_id_token',
profile: mockProfile,
});
expect(jwt.decode).toHaveBeenCalledWith('fake_id_token');
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Decoded Apple JWT'));
expect(profileDetails).toEqual({
email: 'john.doe@example.com',
id: 'apple-sub-1234',
avatarUrl: null,
username: 'john.doe',
name: 'John Doe',
emailVerified: true,
});
});
it('should handle missing email and use sub for username', () => {
const fakeDecodedToken = {
sub: 'apple-sub-5678',
};
(jwt.decode as jest.Mock).mockReturnValue(fakeDecodedToken);
const profileDetails = getProfileDetails({
idToken: 'fake_id_token',
profile: mockProfile,
});
expect(profileDetails).toEqual({
email: undefined,
id: 'apple-sub-5678',
avatarUrl: null,
username: 'user_apple-sub-5678',
name: 'John Doe',
emailVerified: true,
});
});
});
describe('Strategy verify callback', () => {
const tokenset = {
id_token: 'fake_id_token',
};
const decodedToken = {
email: 'jane.doe@example.com',
sub: 'apple-sub-9012',
name: {
firstName: 'Jane',
lastName: 'Doe',
},
};
const fakeAccessToken = 'fake_access_token';
const fakeRefreshToken = 'fake_refresh_token';
beforeEach(async () => {
(jwt.decode as jest.Mock).mockReturnValue(decodedToken);
mockFindUser.mockResolvedValue(null);
const { initAuth } = require('../initAuth');
const saveBufferMock = jest.fn().mockResolvedValue('/fake/path/to/avatar.png');
await initAuth(mongoose, { enabled: false }, saveBufferMock); // mongoose: {}, fake balance config, dummy saveBuffer
});
it('should create a new user if one does not exist and registration is allowed', async () => {
// Mock findUser to return null (user does not exist)
mockFindUser.mockResolvedValue(null);
// Mock createSocialUser to create a user
(createSocialUser as jest.Mock).mockImplementation(async (userData: any) => {
const user = new User(userData);
await user.save();
return user;
});
const mockVerifyCallback = jest.fn();
// Invoke the verify callback with correct arguments
await new Promise((resolve) => {
appleStrategyInstance._verify(
fakeAccessToken,
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err: Error | null, user: any) => {
mockVerifyCallback(err, user);
resolve();
},
);
});
expect(mockVerifyCallback).toHaveBeenCalledWith(
null,
expect.objectContaining({ email: 'jane.doe@example.com' }),
);
const user = mockVerifyCallback.mock.calls[0][1];
expect(user.email).toBe('jane.doe@example.com');
expect(user.username).toBe('jane.doe');
expect(user.name).toBe('Jane Doe');
expect(user.provider).toBe('apple');
});
it('should handle existing user and update avatarUrl', async () => {
// Create an existing user without saving to database
const existingUser = new User({
email: 'jane.doe@example.com',
username: 'jane.doe',
name: 'Jane Doe',
provider: 'apple',
providerId: 'apple-sub-9012',
avatarUrl: 'old_avatar.png',
});
console.log('aa', existingUser);
// Mock findUser to return the existing user
mockFindUser.mockResolvedValue(existingUser);
// Mock handleExistingUser to update avatarUrl without saving to database
(handleExistingUser as jest.Mock).mockImplementation(
async (user: any, avatarUrl: string | null) => {
user.avatarUrl = avatarUrl;
// Don't call save() to avoid database operations
return user;
},
);
const mockVerifyCallback = jest.fn();
// Invoke the verify callback with correct arguments
await new Promise((resolve) => {
appleStrategyInstance._verify(
fakeAccessToken,
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err: Error | null, user: any) => {
mockVerifyCallback(err, user);
resolve();
},
);
});
console.log('bb', existingUser);
expect(mockVerifyCallback).toHaveBeenCalledWith(null, existingUser);
expect(existingUser.avatarUrl).toBe(''); // As per getProfileDetails
expect(handleExistingUser).toHaveBeenCalledWith(existingUser, '');
});
it('should handle missing idToken gracefully', async () => {
const mockVerifyCallback = jest.fn();
// Invoke the verify callback with missing id_token
await new Promise((resolve) => {
appleStrategyInstance._verify(
fakeAccessToken,
fakeRefreshToken,
null, // idToken is missing
mockProfile,
(err: Error | null, user: any) => {
mockVerifyCallback(err, user);
resolve();
},
);
});
expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined);
expect(mockVerifyCallback.mock.calls[0][0].message).toBe('idToken is missing');
// Ensure createSocialUser and handleExistingUser were not called
expect(createSocialUser).not.toHaveBeenCalled();
expect(handleExistingUser).not.toHaveBeenCalled();
});
it('should handle decoding errors gracefully', async () => {
// Simulate decoding failure by returning null
(jwt.decode as jest.Mock).mockReturnValue(null);
const mockVerifyCallback = jest.fn();
// Invoke the verify callback with correct arguments
await new Promise((resolve) => {
appleStrategyInstance._verify(
fakeAccessToken,
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err: Error | null, user: any) => {
mockVerifyCallback(err, user);
resolve();
},
);
});
expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined);
expect(mockVerifyCallback.mock.calls[0][0].message).toBe('idToken is invalid');
// Ensure createSocialUser and handleExistingUser were not called
expect(createSocialUser).not.toHaveBeenCalled();
expect(handleExistingUser).not.toHaveBeenCalled();
// Ensure logger.error was called
expect(logger.error).toHaveBeenCalledWith('Failed to decode idToken');
});
it('should handle errors during user creation', async () => {
// Mock findUser to return null (user does not exist)
mockFindUser.mockResolvedValue(null);
// Mock createSocialUser to throw an error
(createSocialUser as jest.Mock).mockImplementation(() => {
throw new Error('Database error');
});
const mockVerifyCallback = jest.fn();
// Invoke the verify callback with correct arguments
await new Promise((resolve) => {
appleStrategyInstance._verify(
fakeAccessToken,
fakeRefreshToken,
tokenset.id_token,
mockProfile,
(err: Error | null, user: any) => {
mockVerifyCallback(err, user);
resolve();
},
);
});
expect(mockVerifyCallback).toHaveBeenCalledWith(expect.any(Error), undefined);
expect(mockVerifyCallback.mock.calls[0][0].message).toBe('Database error');
// Ensure logger.error was called
expect(logger.error).toHaveBeenCalledWith('[appleLogin]', expect.any(Error));
});
});
});

View file

@ -3,7 +3,6 @@ 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
@ -27,7 +26,7 @@ const getProfileDetails: GetProfileDetails = ({ profile, idToken }: GetProfileDe
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
displayName: decoded.name
? `${decoded.name.firstName} ${decoded.name.lastName}`
: profile.displayName || null,
emailVerified: true, // Apple verifies the email

View file

@ -1,4 +1,3 @@
import { Profile } from 'passport';
import { Strategy as DiscordStrategy } from 'passport-discord';
import socialLogin from './socialLogin';
import { GetProfileDetails } from './types';

View file

@ -18,6 +18,7 @@ import { CreateSocialUserParams } from './types';
* @throws {Error} Throws an error if there's an issue saving the updated user object.
*/
const handleExistingUser = async (oldUser: IUser, avatarUrl: string) => {
console.log(1111);
const fileStrategy = process.env.CDN_PROVIDER ?? FileSources.local;
const isLocal = fileStrategy === FileSources.local;

View file

@ -2,8 +2,8 @@ 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';
import { isEnabled } from '../utils';
const {
LDAP_URL,
@ -79,7 +79,7 @@ const ldapLogin = () => {
usernameField: 'email',
passwordField: 'password',
};
return new LdapStrategy(ldapOptions, async (userinfo: any, done) => {
return new LdapStrategy(ldapOptions, async (userinfo: any, done: any) => {
if (!userinfo) {
return done(null, false, { message: 'Invalid credentials' });
}

View file

@ -10,7 +10,7 @@ import { Request } from 'express';
// Unix timestamp for 2024-06-07 15:20:18 Eastern Time
const verificationEnabledTimestamp = 1717788018;
async function validateLoginRequest(req) {
async function validateLoginRequest(req: Request) {
const { error } = loginSchema.safeParse(req.body);
return error ? errorsToString(error.errors) : null;
}
@ -59,6 +59,12 @@ async function passportStrategy(
return done(null, false, { message: 'Email does not exist.' });
}
if (!user.password) {
logError('Passport Local Strategy - User has no password', { 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 });

View file

@ -0,0 +1,355 @@
import passport from 'passport';
import mongoose from 'mongoose';
// --- Mocks ---
jest.mock('jsonwebtoken');
jest.mock('undici', () => {
const ActualUndici = jest.requireActual('undici');
return {
...ActualUndici,
fetch: jest.fn(() => {
return new ActualUndici.Response(Buffer.from('fake image'), {
status: 200,
headers: { 'content-type': 'image/png' },
});
}),
};
});
const fetchMock = jest.fn().mockResolvedValue(
new Response(Buffer.from('fake image'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
const mockedMethods = {
findUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
};
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
createMethods: jest.fn(() => mockedMethods),
};
});
// Mock the openid-client module and all its dependencies
jest.mock('openid-client', () => {
// const actual = jest.requireActual('openid-client');
return {
// ...actual,
discovery: jest.fn().mockResolvedValue({
clientId: 'fake_client_id',
clientSecret: 'fake_client_secret',
issuer: 'https://fake-issuer.com',
// Add any other properties needed by the implementation
}),
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
// Only return additional properties, but don't override any claims
return Promise.resolve({
preferred_username: 'preferred_username',
});
}),
customFetch: Symbol('customFetch'),
};
});
jest.mock('openid-client/passport', () => {
let verifyCallback: (...args: any[]) => any;
const mockConstructor = jest.fn((options, verify) => {
verifyCallback = verify;
return {
name: 'openid',
options,
verify,
};
});
return {
Strategy: mockConstructor,
__getVerifyCallback: () => verifyCallback,
};
});
// Mock passport
jest.mock('passport', () => ({
use: jest.fn(),
}));
import undici from 'undici';
import { setupOpenId } from './openidStrategy';
import { initAuth } from '../initAuth';
import jwt from 'jsonwebtoken';
describe('setupOpenId', () => {
// Store a reference to the verify callback once it's set up
let verifyCallback: (...args: any[]) => any;
// Helper to wrap the verify callback in a promise
const validate = (tokenset: any) =>
new Promise((resolve, reject) => {
verifyCallback(tokenset, (err: Error | null, user: any, details: any) => {
if (err) {
reject(err);
} else {
resolve({ user, details });
}
});
});
const tokenset = {
id_token: 'fake_id_token',
access_token: 'fake_access_token',
claims: () => ({
sub: '1234',
email: 'test@example.com',
email_verified: true,
given_name: 'First',
family_name: 'Last',
name: 'My Full',
username: 'flast',
picture: 'https://example.com/avatar.png',
}),
};
beforeEach(async () => {
// Clear previous mock calls and reset implementations
jest.clearAllMocks();
// Reset environment variables needed by the strategy
process.env.OPENID_ISSUER = 'https://fake-issuer.com';
process.env.OPENID_CLIENT_ID = 'fake_client_id';
process.env.OPENID_CLIENT_SECRET = 'fake_client_secret';
process.env.DOMAIN_SERVER = 'https://example.com';
process.env.OPENID_CALLBACK_URL = '/callback';
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
delete process.env.OPENID_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM;
delete process.env.PROXY;
delete process.env.OPENID_USE_PKCE;
// Default jwtDecode mock returns a token that includes the required role.
(jwt.decode as jest.Mock).mockReturnValue({
roles: ['requiredRole'],
});
// By default, assume that no user is found, so createUser will be called
mockedMethods.findUser.mockResolvedValue(null);
mockedMethods.createUser.mockImplementation(async (userData) => {
// simulate created user with an _id property
return { _id: 'newUserId', ...userData };
});
mockedMethods.updateUser.mockImplementation(async (id, userData) => {
return { _id: id, ...userData };
});
try {
// const { setupOpenId } = require('@librechat/auth');
const saveBufferMock = jest.fn().mockResolvedValue('/fake/path/to/avatar.png');
await initAuth(mongoose, { enabled: false }, saveBufferMock); // mongoose: {}, fake balance config, dummy saveBuffer
const openidLogin = await setupOpenId({});
// Simulate the app's `passport.use(...)`
passport.use('openid', openidLogin);
verifyCallback = require('openid-client/passport').__getVerifyCallback();
} catch (e) {
console.log(e);
}
});
it('should create a new user with correct username when username claim exists', async () => {
// Arrange our userinfo already has username 'flast'
const userinfo = tokenset.claims();
// Act
const { user } = (await validate(tokenset)) as any;
// Assert
expect(user.username).toBe(userinfo.username);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.username,
email: userinfo.email,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
{ enabled: false },
true,
true,
);
});
it('should use given_name as username when username claim is missing', async () => {
// Arrange remove username from userinfo
const userinfo: any = { ...tokenset.claims() };
delete userinfo.username;
// Expect the username to be the given name (unchanged case)
const expectUsername = userinfo.given_name;
// Act
const { user } = (await validate({ ...tokenset, claims: () => userinfo })) as any;
// Assert
expect(user.username).toBe(expectUsername);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
true,
);
});
it('should use email as username when username and given_name are missing', async () => {
// Arrange remove username and given_name
const userinfo: any = { ...tokenset.claims() };
delete userinfo.username;
delete userinfo.given_name;
const expectUsername = userinfo.email;
// Act
const { user } = (await validate({ ...tokenset, claims: () => userinfo })) as any;
// Assert
expect(user.username).toBe(expectUsername);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: expectUsername }),
{ enabled: false },
true,
true,
);
});
it('should override username with OPENID_USERNAME_CLAIM when set', async () => {
// Arrange set OPENID_USERNAME_CLAIM so that the sub claim is used
process.env.OPENID_USERNAME_CLAIM = 'sub';
const userinfo = tokenset.claims();
// Act
const { user } = (await validate(tokenset)) as any;
// Assert username should equal the sub (converted as-is)
expect(user.username).toBe(userinfo.sub);
expect(mockedMethods.createUser).toHaveBeenCalledWith(
expect.objectContaining({ username: userinfo.sub }),
{ enabled: false },
true,
true,
);
});
it('should set the full name correctly when given_name and family_name exist', async () => {
// Arrange
const userinfo = tokenset.claims();
const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`;
// Act
const { user } = (await validate(tokenset)) as any;
// Assert
expect(user.name).toBe(expectedFullName);
});
it('should override full name with OPENID_NAME_CLAIM when set', async () => {
// Arrange use the name claim as the full name
process.env.OPENID_NAME_CLAIM = 'name';
const userinfo = { ...tokenset.claims(), name: 'Custom Name' };
// Act
const { user } = (await validate({ ...tokenset, claims: () => userinfo })) as any;
// Assert
expect(user.name).toBe('Custom Name');
});
it('should update an existing user on login', async () => {
// Arrange simulate that a user already exists
const existingUser = {
_id: 'existingUserId',
provider: 'local',
email: tokenset.claims().email,
openidId: '',
username: '',
name: '',
};
mockedMethods.findUser.mockImplementation(async (query) => {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
return existingUser;
}
return null;
});
const userinfo = tokenset.claims();
// Act
await validate(tokenset);
// Assert updateUser should be called and the user object updated
expect(mockedMethods.updateUser).toHaveBeenCalledWith(
existingUser._id,
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.username,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
);
});
it('should enforce the required role and reject login if missing', async () => {
// Arrange simulate a token without the required role.
(jwt.decode as jest.Mock).mockReturnValue({
roles: ['SomeOtherRole'],
});
// Act
const { user, details } = (await validate(tokenset)) as any;
// Assert verify that the strategy rejects login
expect(user).toBe(false);
expect(details.message).toBe('You must have the "requiredRole" role to log in.');
});
it.skip('should attempt to download and save the avatar if picture is provided', async () => {
// Act
const { user } = (await validate(tokenset)) as any;
// Assert verify that download was attempted and the avatar field was set via updateUser
expect(undici.fetch).toHaveBeenCalled();
// Our mock getStrategyFunctions.saveBuffer returns '/fake/path/to/avatar.png'
expect(user.avatar).toBe('/fake/path/to/avatar.png');
});
it('should not attempt to download avatar if picture is not provided', async () => {
// Arrange remove picture
const userinfo: any = { ...tokenset.claims() };
delete userinfo.picture;
// Act
await validate({ ...tokenset, claims: () => userinfo });
// Assert fetch should not be called and avatar should remain undefined or empty
expect(undici.fetch).not.toHaveBeenCalled();
// Depending on your implementation, user.avatar may be undefined or an empty string.
});
it('should default to usePKCE false when OPENID_USE_PKCE is not defined', async () => {
const OpenIDStrategy = require('openid-client/passport').Strategy;
delete process.env.OPENID_USE_PKCE;
const { setupOpenId } = require('./openidStrategy');
await setupOpenId({});
const callOptions = OpenIDStrategy.mock.calls[OpenIDStrategy.mock.calls.length - 1][0];
expect(callOptions.usePKCE).toBe(false);
expect(callOptions.params?.code_challenge_method).toBeUndefined();
});
});

View file

@ -1,27 +1,106 @@
import passport from 'passport';
import * as client from 'openid-client';
// @ts-ignore
import { Strategy as OpenIDStrategy } from 'openid-client/passport';
import { Strategy as OpenIDStrategy, VerifyCallback } 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 { safeStringify, logHeaders } from '@librechat/api';
import * as oauth from 'oauth4webapi';
import { getBalanceConfig, getMethods, getSaveBufferStrategy } from '../initAuth';
import { fetch, Response as UndiciResponse, Headers } from 'undici';
import { Request } from 'express';
let crypto: typeof import('node:crypto') | undefined;
/**
* @typedef {import('openid-client').ClientMetadata} ClientMetadata
* @typedef {import('openid-client').Configuration} Configuration
**/
/**
* @param {string} url
* @param {client.CustomFetchOptions} options
*/
export async function customFetch(url: URL | string, options: any): Promise<UndiciResponse> {
const urlStr = url.toString();
logger.debug(`[openidStrategy] Request to: ${urlStr}`);
const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS ?? '');
if (debugOpenId) {
logger.debug(`[openidStrategy] Request method: ${options.method || 'GET'}`);
logger.debug(`[openidStrategy] Request headers: ${logHeaders(options.headers)}`);
if (options.body) {
let bodyForLogging = '';
if (options.body instanceof URLSearchParams) {
bodyForLogging = options.body.toString();
} else if (typeof options.body === 'string') {
bodyForLogging = options.body;
} else {
bodyForLogging = safeStringify(options.body);
}
logger.debug(`[openidStrategy] Request body: ${bodyForLogging}`);
}
}
try {
/** @type {undici.RequestInit} */
let fetchOptions = options;
if (process.env.PROXY) {
logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`);
fetchOptions = {
...options,
dispatcher: new HttpsProxyAgent(process.env.PROXY ?? ''),
};
}
const response: UndiciResponse = await fetch(url, fetchOptions);
if (debugOpenId) {
logger.debug(`[openidStrategy] Response status: ${response.status} ${response.statusText}`);
// logger.debug(`[openidStrategy] Response headers: ${logHeaders(response.headers)}`);
}
if (response.status === 200 && response.headers.has('www-authenticate')) {
const wwwAuth = response.headers.get('www-authenticate');
logger.warn(`[openidStrategy] Non-standard WWW-Authenticate header found in successful response (200 OK): ${wwwAuth}.
This violates RFC 7235 and may cause issues with strict OAuth clients. Removing header for compatibility.`);
/** Cloned response without the WWW-Authenticate header */
const responseBody = await response.arrayBuffer();
const newHeaders = new Headers();
for (const [key, value] of response.headers.entries()) {
if (key.toLowerCase() !== 'www-authenticate') {
newHeaders.append(key, value);
}
}
return new UndiciResponse(responseBody, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}
return response;
} catch (error: any) {
logger.error(`[openidStrategy] Fetch error: ${error.message}`);
throw error;
}
}
//overload currenturl function because of express version 4 buggy req.host doesn't include port
//More info https://github.com/panva/openid-client/pull/713
let openidConfig: client.Configuration;
class CustomOpenIDStrategy extends OpenIDStrategy {
constructor(options: any, verify: Function) {
constructor(options: any, verify: VerifyCallback) {
super(options, verify);
}
currentUrl(req: any): URL {
currentUrl(req: Request): 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);
authorizationRequestParams(req: Request, options: any): URLSearchParams {
const params = super.authorizationRequestParams(req, options) as URLSearchParams;
if (options?.state && !params?.has('state')) {
params?.set('state', options.state);
}
@ -29,7 +108,6 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
}
}
let openidConfig: client.Configuration;
let tokensCache: any;
/**
@ -128,7 +206,7 @@ const downloadImage = async (
if (process.env.PROXY) {
options.agent = new HttpsProxyAgent(process.env.PROXY);
}
const response: Response = await fetch(url, options);
const response: UndiciResponse = await fetch(url, options);
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
@ -208,7 +286,7 @@ function convertToUsername(input: string | string[], defaultValue: string = '')
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,
@ -218,19 +296,13 @@ async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
new URL(process.env.OPENID_ISSUER ?? ''),
process.env.OPENID_CLIENT_ID ?? '',
clientMetadata,
undefined,
{
//@ts-ignore
[client.customFetch]: customFetch,
},
);
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;
@ -243,17 +315,13 @@ async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.OPENID_CALLBACK_URL}`,
usePKCE,
},
async (
tokenset: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers,
done: passport.AuthenticateCallback,
) => {
async (tokenset: any, done) => {
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(
@ -267,7 +335,6 @@ async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
...(await getUserInfo(openidConfig, tokenset.access_token, claims?.sub ?? '')),
};
const fullName = getFullName(userinfo);
if (requiredRole) {
let decodedToken = null;
if (requiredRoleTokenKind === 'access') {
@ -333,7 +400,6 @@ async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
if (!!userinfo && userinfo.picture && !user?.avatar?.includes('manual=true')) {
/** @type {string | undefined} */
const imageUrl = userinfo.picture;
let fileName;
try {
crypto = await import('node:crypto');
@ -346,7 +412,6 @@ async function setupOpenId(tokensCacheKv: any): Promise<any | null> {
} else {
fileName = userinfo.sub + '.png';
}
const imageBuffer = await downloadImage(
imageUrl,
openidConfig,

View file

@ -0,0 +1,416 @@
import passport from 'passport';
import mongoose from 'mongoose';
// --- Mocks ---
jest.mock('fs', () => ({
existsSync: jest.fn(),
statSync: jest.fn(),
readFileSync: jest.fn(),
}));
jest.mock('path', () => ({
isAbsolute: jest.fn(),
basename: jest.fn(),
dirname: jest.fn(),
join: jest.fn(),
normalize: jest.fn(),
}));
const mockedMethods = {
findUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
};
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
createMethods: jest.fn(() => mockedMethods),
logger: {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
};
});
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(() => false),
isUserProvided: jest.fn(() => false),
}));
import path from 'path';
import fs from 'fs';
import { samlLogin, getCertificateContent } from './samlStrategy';
import { initAuth } from '../initAuth';
import { Profile } from '@node-saml/passport-saml/lib';
// To capture the verify callback from the strategy, we grab it from the mock constructor
describe('getCertificateContent', () => {
// const { getCertificateContent } = require('@librechat/auth');
const certWithHeader = `-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUKhXaFJGJJPx466rlwYORIsqCq7MwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTAzMDQwODUxNTJaFw0yNjAz
MDQwODUxNTJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCWP09NZg0xaRiLpNygCVgV3M+4RFW2S0c5X/fg/uFT
O5MfaVYzG5GxzhXzWRB8RtNPsxX/nlbPsoUroeHbz+SABkOsNEv6JuKRH4VXRH34
VzjazVkPAwj+N4WqsC/Wo4EGGpKIGeGi8Zed4yvMqoTyE3mrS19fY0nMHT62wUwS
GMm2pAQdAQePZ9WY7A5XOA1IoxW2Zh2Oxaf1p59epBkZDhoxSMu8GoSkvK27Km4A
4UXftzdg/wHNPrNirmcYouioHdmrOtYxPjrhUBQ74AmE1/QK45B6wEgirKH1A1AW
6C+ApLwpBMvy9+8Gbyvc8G18W3CjdEVKmAeWb9JUedSXAgMBAAGjUzBRMB0GA1Ud
DgQWBBRxpaqBx8VDLLc8IkHATujj8IOs6jAfBgNVHSMEGDAWgBRxpaqBx8VDLLc8
IkHATujj8IOs6jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc
Puk6i+yowwGccB3LhfxZ+Fz6s6/Lfx6bP/Hy4NYOxmx2/awGBgyfp1tmotjaS9Cf
FWd67LuEru4TYtz12RNMDBF5ypcEfibvb3I8O6igOSQX/Jl5D2pMChesZxhmCift
Qp09T41MA8PmHf1G9oMG0A3ZnjKDG5ebaJNRFImJhMHsgh/TP7V3uZy7YHTgopKX
Hv63V3Uo3Oihav29Q7urwmf7Ly7X7J2WE86/w3vRHi5dhaWWqEqxmnAXl+H+sG4V
meeVRI332bg1Nuy8KnnX8v3ZeJzMBkAhzvSr6Ri96R0/Un/oEFwVC5jDTq8sXVn6
u7wlOSk+oFzDIO/UILIA
-----END CERTIFICATE-----`;
const certWithoutHeader = certWithHeader
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s+/g, '');
it('should throw an error if SAML_CERT is not set', () => {
process.env.SAML_CERT;
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Invalid input: SAML_CERT must be a string.',
);
});
it('should throw an error if SAML_CERT is empty', () => {
process.env.SAML_CERT = '';
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
);
});
it('should load cert from an environment variable if it is a single-line string(with header)', () => {
process.env.SAML_CERT = certWithHeader;
const actual = getCertificateContent(process.env.SAML_CERT);
expect(actual).toBe(certWithHeader);
});
it('should load cert from an environment variable if it is a single-line string(with no header)', () => {
process.env.SAML_CERT = certWithoutHeader;
const actual = getCertificateContent(process.env.SAML_CERT);
expect(actual).toBe(certWithoutHeader);
});
it('should throw an error if SAML_CERT is a single-line string (with header, no newline characters)', () => {
process.env.SAML_CERT = certWithHeader.replace(/\n/g, '');
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
);
});
it('should load cert from a relative file path if SAML_CERT is valid', () => {
process.env.SAML_CERT = 'test.pem';
const resolvedPath = '/absolute/path/to/test.pem';
(path.isAbsolute as jest.Mock).mockReturnValue(false);
(path.join as jest.Mock).mockReturnValue(resolvedPath);
(path.normalize as jest.Mock).mockReturnValue(resolvedPath);
(fs.existsSync as jest.Mock).mockReturnValue(true);
(fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true });
(fs.readFileSync as jest.Mock).mockReturnValue(certWithHeader);
const actual = getCertificateContent(process.env.SAML_CERT);
expect(actual).toBe(certWithHeader);
});
it('should load cert from an absolute file path if SAML_CERT is valid', () => {
process.env.SAML_CERT = '/absolute/path/to/test.pem';
(path.isAbsolute as jest.Mock).mockReturnValue(true);
(path.normalize as jest.Mock).mockReturnValue(process.env.SAML_CERT);
(fs.existsSync as jest.Mock).mockReturnValue(true);
(fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true });
(fs.readFileSync as jest.Mock).mockReturnValue(certWithHeader);
const actual = getCertificateContent(process.env.SAML_CERT);
expect(actual).toBe(certWithHeader);
});
it('should throw an error if the file does not exist', () => {
process.env.SAML_CERT = 'missing.pem';
const resolvedPath = '/absolute/path/to/missing.pem';
(path.isAbsolute as jest.Mock).mockReturnValue(false);
(path.join as jest.Mock).mockReturnValue(resolvedPath);
(path.normalize as jest.Mock).mockReturnValue(resolvedPath);
(fs.existsSync as jest.Mock).mockReturnValue(false);
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Invalid cert: SAML_CERT must be a valid file path or certificate string.',
);
});
it('should throw an error if the file is not readable', () => {
process.env.SAML_CERT = 'unreadable.pem';
const resolvedPath = '/absolute/path/to/unreadable.pem';
(path.isAbsolute as jest.Mock).mockReturnValue(false);
(path.join as jest.Mock).mockReturnValue(resolvedPath);
(path.normalize as jest.Mock).mockReturnValue(resolvedPath);
(fs.existsSync as jest.Mock).mockReturnValue(true);
(fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true });
(fs.readFileSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Error reading certificate file: Permission denied',
);
});
});
describe('setupSaml', () => {
let verifyCallback: (...args: any[]) => any;
// Helper to wrap the verify callback in a promise
const validate = (profile: any) =>
new Promise((resolve, reject) => {
verifyCallback(profile, (err: Error | null, user: any, details: any) => {
if (err) {
reject(err);
} else {
resolve({ user, details });
}
});
});
const baseProfile = {
nameID: 'saml-1234',
email: 'test@example.com',
given_name: 'First',
family_name: 'Last',
name: 'My Full Name',
username: 'flast',
picture: 'https://example.com/avatar.png',
custom_name: 'custom',
};
beforeEach(async () => {
jest.clearAllMocks();
// Configure mocks
mockedMethods.findUser.mockResolvedValue(null);
mockedMethods.createUser.mockImplementation(async (userData) => ({
_id: 'mock-user-id',
...userData,
}));
mockedMethods.updateUser.mockImplementation(async (id, userData) => ({
_id: id,
...userData,
}));
const cert = `
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUKhXaFJGJJPx466rlwYORIsqCq7MwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTAzMDQwODUxNTJaFw0yNjAz
MDQwODUxNTJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCWP09NZg0xaRiLpNygCVgV3M+4RFW2S0c5X/fg/uFT
O5MfaVYzG5GxzhXzWRB8RtNPsxX/nlbPsoUroeHbz+SABkOsNEv6JuKRH4VXRH34
VzjazVkPAwj+N4WqsC/Wo4EGGpKIGeGi8Zed4yvMqoTyE3mrS19fY0nMHT62wUwS
GMm2pAQdAQePZ9WY7A5XOA1IoxW2Zh2Oxaf1p59epBkZDhoxSMu8GoSkvK27Km4A
4UXftzdg/wHNPrNirmcYouioHdmrOtYxPjrhUBQ74AmE1/QK45B6wEgirKH1A1AW
6C+ApLwpBMvy9+8Gbyvc8G18W3CjdEVKmAeWb9JUedSXAgMBAAGjUzBRMB0GA1Ud
DgQWBBRxpaqBx8VDLLc8IkHATujj8IOs6jAfBgNVHSMEGDAWgBRxpaqBx8VDLLc8
IkHATujj8IOs6jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc
Puk6i+yowwGccB3LhfxZ+Fz6s6/Lfx6bP/Hy4NYOxmx2/awGBgyfp1tmotjaS9Cf
FWd67LuEru4TYtz12RNMDBF5ypcEfibvb3I8O6igOSQX/Jl5D2pMChesZxhmCift
Qp09T41MA8PmHf1G9oMG0A3ZnjKDG5ebaJNRFImJhMHsgh/TP7V3uZy7YHTgopKX
Hv63V3Uo3Oihav29Q7urwmf7Ly7X7J2WE86/w3vRHi5dhaWWqEqxmnAXl+H+sG4V
meeVRI332bg1Nuy8KnnX8v3ZeJzMBkAhzvSr6Ri96R0/Un/oEFwVC5jDTq8sXVn6
u7wlOSk+oFzDIO/UILIA
-----END CERTIFICATE-----
`;
// Reset environment variables
process.env.SAML_ENTRY_POINT = 'https://example.com/saml';
process.env.SAML_ISSUER = 'saml-issuer';
process.env.SAML_CERT = cert;
process.env.SAML_CALLBACK_URL = '/oauth/saml/callback';
delete process.env.SAML_EMAIL_CLAIM;
delete process.env.SAML_USERNAME_CLAIM;
delete process.env.SAML_GIVEN_NAME_CLAIM;
delete process.env.SAML_FAMILY_NAME_CLAIM;
delete process.env.SAML_PICTURE_CLAIM;
delete process.env.SAML_NAME_CLAIM;
// For image download, simulate a successful response
global.fetch = jest.fn().mockResolvedValue({
ok: true,
arrayBuffer: jest.fn().mockResolvedValue(Buffer.from('fake image')),
});
const saveBufferMock = jest.fn().mockResolvedValue('/fake/path/to/avatar.png');
await initAuth(mongoose, { enabled: false }, saveBufferMock);
// Simulate the app's `passport.use(...)`
const SamlStrategy: any = samlLogin();
passport.use('saml', SamlStrategy);
console.log('---SamlStrategy', SamlStrategy);
verifyCallback = SamlStrategy._signonVerify;
});
it('should create a new user with correct username when username claim exists', async () => {
const profile = { ...baseProfile };
const { user } = (await validate(profile)) as any;
expect(user.username).toBe(profile.username);
expect(user.provider).toBe('saml');
expect(user.samlId).toBe(profile.nameID);
expect(user.email).toBe(profile.email);
expect(user.name).toBe(`${profile.given_name} ${profile.family_name}`);
});
it('should use given_name as username when username claim is missing', async () => {
const profile: any = { ...baseProfile };
delete profile.username;
const expectUsername = profile.given_name;
const { user } = (await validate(profile)) as any;
expect(user.username).toBe(expectUsername);
expect(user.provider).toBe('saml');
});
it('should use email as username when username and given_name are missing', async () => {
const profile: Partial<Profile> = { ...baseProfile };
delete profile.username;
delete profile.given_name;
const expectUsername = profile.email;
const { user } = (await validate(profile)) as any;
expect(user.username).toBe(expectUsername);
expect(user.provider).toBe('saml');
});
it('should override username with SAML_USERNAME_CLAIM when set', async () => {
process.env.SAML_USERNAME_CLAIM = 'nameID';
const profile = { ...baseProfile };
const { user } = (await validate(profile)) as any;
expect(user.username).toBe(profile.nameID);
expect(user.provider).toBe('saml');
});
it('should set the full name correctly when given_name and family_name exist', async () => {
const profile = { ...baseProfile };
const expectedFullName = `${profile.given_name} ${profile.family_name}`;
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when given_name exist', async () => {
const profile: Partial<Profile> = { ...baseProfile };
delete profile.family_name;
const expectedFullName = profile.given_name;
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when family_name exist', async () => {
const profile: Partial<Profile> = { ...baseProfile };
delete profile.given_name;
const expectedFullName = profile.family_name;
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when username exist', async () => {
const profile: Partial<Profile> = { ...baseProfile };
delete profile.family_name;
delete profile.given_name;
const expectedFullName = profile.username;
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when email only exist', async () => {
const profile: Partial<Profile> = { ...baseProfile };
delete profile.family_name;
delete profile.given_name;
delete profile.username;
const expectedFullName = profile.email;
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly with SAML_NAME_CLAIM when set', async () => {
process.env.SAML_NAME_CLAIM = 'custom_name';
const profile = { ...baseProfile };
const expectedFullName = profile.custom_name;
const { user } = (await validate(profile)) as any;
expect(user.name).toBe(expectedFullName);
});
it('should update an existing user on login', async () => {
// Set up findUser to return an existing user
const existingUser = {
_id: 'existing-user-id',
provider: 'local',
email: baseProfile.email,
samlId: '',
username: 'oldusername',
name: 'Old Name',
};
mockedMethods.findUser.mockResolvedValue(existingUser);
const profile = { ...baseProfile };
const { user } = (await validate(profile)) as any;
expect(user.provider).toBe('saml');
expect(user.samlId).toBe(baseProfile.nameID);
expect(user.username).toBe(baseProfile.username);
expect(user.name).toBe(`${baseProfile.given_name} ${baseProfile.family_name}`);
expect(user.email).toBe(baseProfile.email);
});
it('should attempt to download and save the avatar if picture is provided', async () => {
const profile: Partial<Profile> = { ...baseProfile };
const { user } = (await validate(profile)) as any;
expect(global.fetch).toHaveBeenCalled();
expect(user.avatar).toBe('/fake/path/to/avatar.png');
});
it('should not attempt to download avatar if picture is not provided', async () => {
const profile: Partial<Profile> = { ...baseProfile };
delete profile.picture;
await validate(profile);
expect(global.fetch).not.toHaveBeenCalled();
});
});

View file

@ -276,7 +276,6 @@ const samlLogin = () => {
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!');
});

View file

@ -1,6 +1,5 @@
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';
@ -15,26 +14,29 @@ export function socialLogin(
refreshToken: string,
idToken: string,
profile: Profile,
cb: VerifyCallback,
cb: any,
): 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);
console.log('1', oldUser);
await handleExistingUser(oldUser, avatarUrl ?? '');
return cb(null, oldUser);
}
if (ALLOW_SOCIAL_REGISTRATION) {
const newUser = await createSocialUser({
email,
avatarUrl,
email: email ?? '',
avatarUrl: avatarUrl ?? '',
provider,
providerKey: `${provider}Id`,
providerId: id,

View file

@ -1,4 +1,3 @@
import { VerifyCallback } from 'passport-oauth2';
import { Profile } from 'passport';
import { IUser } from '@librechat/data-schemas';
@ -8,14 +7,14 @@ export interface GetProfileDetailsParams {
}
export type GetProfileDetails = (
params: GetProfileDetailsParams,
) => Partial<IUser> & { avatarUrl: string };
) => Partial<IUser> & { avatarUrl: string | null };
export type SocialLoginStrategy = (
accessToken: string,
refreshToken: string,
idToken: string,
profile: Profile,
cb: VerifyCallback,
cb: any,
) => Promise<void>;
export interface CreateSocialUserParams {

View file

@ -0,0 +1,466 @@
jest.mock('@librechat/data-schemas', () => {
return {
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
};
});
// file deepcode ignore NoHardcodedPasswords: No hard-coded passwords in tests
import { errorsToString } from 'librechat-data-provider';
import { loginSchema, registerSchema } from '@librechat/auth';
describe('Zod Schemas', () => {
describe('loginSchema', () => {
it('should validate a correct login object', () => {
const result = loginSchema.safeParse({
email: 'test@example.com',
password: 'password123',
});
expect(result.success).toBe(true);
});
it('should invalidate an incorrect email', () => {
const result = loginSchema.safeParse({
email: 'testexample.com',
password: 'password123',
});
expect(result.success).toBe(false);
});
it('should invalidate a short password', () => {
const result = loginSchema.safeParse({
email: 'test@example.com',
password: 'pass',
});
expect(result.success).toBe(false);
});
it('should handle email with unusual characters', () => {
const emails = ['test+alias@example.com', 'test@subdomain.example.co.uk'];
emails.forEach((email) => {
const result = loginSchema.safeParse({
email,
password: 'password123',
});
expect(result.success).toBe(true);
});
});
it('should invalidate email without a domain', () => {
const result = loginSchema.safeParse({
email: 'test@.com',
password: 'password123',
});
expect(result.success).toBe(false);
});
it('should invalidate password with only spaces', () => {
const result = loginSchema.safeParse({
email: 'test@example.com',
password: ' ',
});
expect(result.success).toBe(false);
});
it('should invalidate password that is too long', () => {
const result = loginSchema.safeParse({
email: 'test@example.com',
password: 'a'.repeat(129),
});
expect(result.success).toBe(false);
});
it('should invalidate empty email or password', () => {
const result = loginSchema.safeParse({
email: '',
password: '',
});
expect(result.success).toBe(false);
});
});
describe('registerSchema', () => {
it('should validate a correct register object', () => {
const result = registerSchema.safeParse({
name: 'John Doe',
username: 'john_doe',
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
});
expect(result.success).toBe(true);
});
it('should allow the username to be omitted', () => {
const result = registerSchema.safeParse({
name: 'John Doe',
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
});
expect(result.success).toBe(true);
});
it('should invalidate a short name', () => {
const result = registerSchema.safeParse({
name: 'Jo',
username: 'john_doe',
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
});
expect(result.success).toBe(false);
});
it('should handle empty username by transforming to null', () => {
const result = registerSchema.safeParse({
name: 'John Doe',
username: '',
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
});
expect(result.success).toBe(true);
expect(result.data.username).toBe(null);
});
it('should handle name with special characters', () => {
const names = ['Jöhn Dœ', 'John <Doe>'];
names.forEach((name) => {
const result = registerSchema.safeParse({
name,
username: 'john_doe',
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
});
expect(result.success).toBe(true);
});
});
it('should handle username with special characters', () => {
const usernames = ['john.doe@', 'john..doe'];
usernames.forEach((username) => {
const result = registerSchema.safeParse({
name: 'John Doe',
username,
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
});
expect(result.success).toBe(true);
});
});
it('should invalidate mismatched password and confirm_password', () => {
const result = registerSchema.safeParse({
name: 'John Doe',
username: 'john_doe',
email: 'john@example.com',
password: 'password123',
confirm_password: 'password124',
});
expect(result.success).toBe(false);
});
it('should handle email without a TLD', () => {
const result = registerSchema.safeParse({
name: 'John Doe',
username: 'john_doe',
email: 'john@domain',
password: 'password123',
confirm_password: 'password123',
});
expect(result.success).toBe(false);
});
it('should handle email with multiple @ symbols', () => {
const result = registerSchema.safeParse({
name: 'John Doe',
username: 'john_doe',
email: 'john@domain@com',
password: 'password123',
confirm_password: 'password123',
});
expect(result.success).toBe(false);
});
it('should handle name that is too long', () => {
const result = registerSchema.safeParse({
name: 'a'.repeat(81),
username: 'john_doe',
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
});
expect(result.success).toBe(false);
});
it('should handle username that is too long', () => {
const result = registerSchema.safeParse({
name: 'John Doe',
username: 'a'.repeat(81),
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
});
expect(result.success).toBe(false);
});
it('should handle password or confirm_password that is too long', () => {
const result = registerSchema.safeParse({
name: 'John Doe',
username: 'john_doe',
email: 'john@example.com',
password: 'a'.repeat(129),
confirm_password: 'a'.repeat(129),
});
expect(result.success).toBe(false);
});
it('should handle password or confirm_password that is just spaces', () => {
const result = registerSchema.safeParse({
name: 'John Doe',
username: 'john_doe',
email: 'john@example.com',
password: ' ',
confirm_password: ' ',
});
expect(result.success).toBe(false);
});
it('should handle null values for fields', () => {
const result = registerSchema.safeParse({
name: null,
username: null,
email: null,
password: null,
confirm_password: null,
});
expect(result.success).toBe(false);
});
it('should handle undefined values for fields', () => {
const result = registerSchema.safeParse({
name: undefined,
username: undefined,
email: undefined,
password: undefined,
confirm_password: undefined,
});
expect(result.success).toBe(false);
});
it('should handle extra fields not defined in the schema', () => {
const result = registerSchema.safeParse({
name: 'John Doe',
username: 'john_doe',
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
extraField: "I shouldn't be here",
});
expect(result.success).toBe(true);
});
it('should handle username with special characters from various languages', () => {
const usernames = [
// General
'éèäöü',
// German
'Jöhn.Döe@',
'Jöhn_Ü',
'Jöhnß',
// French
'Jéan-Piérre',
'Élève',
'Fiançée',
'Mère',
// Spanish
'Niño',
'Señor',
'Muñoz',
// Portuguese
'João',
'Coração',
'Pão',
// Italian
'Pietro',
'Bambino',
'Forlì',
// Romanian
'Mâncare',
'Școală',
'Țară',
// Catalan
'Niç',
'Màquina',
'Çap',
// Swedish
'Fjärran',
'Skål',
'Öland',
// Norwegian
'Blåbær',
'Fjord',
'Årstid',
// Danish
'Flød',
'Søster',
'Århus',
// Icelandic
'Þór',
'Ætt',
'Öx',
// Turkish
'Şehir',
'Çocuk',
'Gözlük',
// Polish
'Łódź',
'Część',
'Świat',
// Czech
'Čaj',
'Řeka',
'Život',
// Slovak
'Kočka',
'Ľudia',
'Žaba',
// Croatian
'Čovjek',
'Šuma',
'Žaba',
// Hungarian
'Tűz',
'Ősz',
'Ünnep',
// Finnish
'Mäki',
'Yö',
'Äiti',
// Estonian
'Tänav',
'Öö',
'Ülikool',
// Latvian
'Ēka',
'Ūdens',
'Čempions',
// Lithuanian
'Ūsas',
'Ąžuolas',
'Čia',
// Dutch
'Maïs',
'Geërfd',
'Coördinatie',
];
const failingUsernames = usernames.reduce((acc, username) => {
const result = registerSchema.safeParse({
name: 'John Doe',
username,
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
});
if (!result.success) {
acc.push({ username, error: result.error });
}
return acc;
}, []);
if (failingUsernames.length > 0) {
console.log('Failing Usernames:', failingUsernames);
}
expect(failingUsernames).toEqual([]);
});
it('should reject invalid usernames', () => {
const invalidUsernames = [
'john{doe}', // Contains `{` and `}`
'j', // Only one character
'a'.repeat(81), // More than 80 characters
"' OR '1'='1'; --", // SQL Injection
'{$ne: null}', // MongoDB Injection
'<script>alert("XSS")</script>', // Basic XSS
'"><script>alert("XSS")</script>', // XSS breaking out of an attribute
'"><img src=x onerror=alert("XSS")>', // XSS using an image tag
];
const passingUsernames = [];
const failingUsernames = invalidUsernames.reduce((acc, username) => {
const result = registerSchema.safeParse({
name: 'John Doe',
username,
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
});
if (!result.success) {
acc.push({ username, error: result.error });
}
if (result.success) {
passingUsernames.push({ username });
}
return acc;
}, []);
expect(failingUsernames.length).toEqual(invalidUsernames.length); // They should match since all invalidUsernames should fail.
});
});
describe('errorsToString', () => {
it('should convert errors to string', () => {
const { error } = registerSchema.safeParse({
name: 'Jo',
username: 'john_doe',
email: 'john@example.com',
password: 'password123',
confirm_password: 'password123',
});
const result = errorsToString(error.errors);
expect(result).toBe('name: String must contain at least 3 character(s)');
});
});
});

View file

@ -1,3 +1,4 @@
import { TransportOptions, SendMailOptions } from 'nodemailer';
export interface SendEmailParams {
email: string;
subject: string;
@ -13,3 +14,20 @@ export interface SendEmailResponse {
envelope: { from: string; to: string[] };
messageId: string;
}
export interface MailgunEmailParams {
to: string;
from: string;
subject: string;
html: string;
}
export interface MailgunResponse {
id: string;
message: string;
}
export interface SMTPParams {
transporterOptions: any;
mailOptions: SendMailOptions;
}

View file

@ -215,9 +215,6 @@ async function resizeAvatar({
} 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 {
@ -229,6 +226,21 @@ async function resizeAvatar({
const height = metadata.height ?? 0;
const minSize = Math.min(width, height);
if (metadata.format === 'gif') {
const resizedBuffer = await sharp(imageBuffer, { animated: true })
.extract({
left: Math.floor((width - minSize) / 2),
top: Math.floor((height - minSize) / 2),
width: minSize,
height: minSize,
})
.resize(250, 250)
.gif()
.toBuffer();
return resizedBuffer;
}
const squaredBuffer = await sharp(imageBuffer)
.extract({
left: Math.floor((width - minSize) / 2),

View file

@ -1,29 +1,123 @@
import fs from 'fs';
import path from 'path';
import nodemailer, { TransportOptions } from 'nodemailer';
import nodemailer, { SentMessageInfo } from 'nodemailer';
import handlebars from 'handlebars';
import { createTokenHash, isEnabled } from '.';
import { createTokenHash } from '.';
import { logAxiosError } from '@librechat/api';
import { 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';
import FormData from 'form-data';
import axios, { AxiosResponse } from 'axios';
import { MailgunEmailParams, SendEmailParams, SMTPParams } 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,
};
/**
* Sends an email using Mailgun API.
*
* @async
* @function sendEmailViaMailgun
* @param {Object} params - The parameters for sending the email.
* @param {string} params.to - The recipient's email address.
* @param {string} params.from - The sender's email address.
* @param {string} params.subject - The subject of the email.
* @param {string} params.html - The HTML content of the email.
* @returns {Promise<Object>} - A promise that resolves to the response from Mailgun API.
*/
const sendEmailViaMailgun = async ({
to,
from,
subject,
html,
}: MailgunEmailParams): Promise<SentMessageInfo> => {
const mailgunApiKey: string | undefined = process.env.MAILGUN_API_KEY;
const mailgunDomain: string | undefined = process.env.MAILGUN_DOMAIN;
const mailgunHost: string = process.env.MAILGUN_HOST || 'smtp.mailgun.org';
if (!mailgunApiKey || !mailgunDomain) {
throw new Error('Mailgun API key and domain are required');
}
const formData = new FormData();
formData.append('from', from);
formData.append('to', to);
formData.append('subject', subject);
formData.append('html', html);
formData.append('o:tracking-clicks', 'no');
try {
const response = await axios.post(`${mailgunHost}/v3/${mailgunDomain}/messages`, formData, {
headers: {
...formData.getHeaders(),
Authorization: `Basic ${Buffer.from(`api:${mailgunApiKey}`).toString('base64')}`,
},
});
return response.data;
} catch (error: any) {
throw new Error(logAxiosError({ error, message: 'Failed to send email via Mailgun' }));
}
};
/**
* Sends an email using SMTP via Nodemailer.
*
* @async
* @function sendEmailViaSMTP
* @param {Object} params - The parameters for sending the email.
* @param {Object} params.transporterOptions - The transporter configuration options.
* @param {Object} params.mailOptions - The email options.
* @returns {Promise<Object>} - A promise that resolves to the info object of the sent email.
*/
const sendEmailViaSMTP = async ({
transporterOptions,
mailOptions,
}: SMTPParams): Promise<SentMessageInfo> => {
const transporter = nodemailer.createTransport(transporterOptions);
return await transporter.sendMail(mailOptions);
};
export const sendEmail = async ({
email,
subject,
payload,
template,
throwError = true,
}: SendEmailParams): Promise<SendEmailResponse | Error> => {
}: SendEmailParams): Promise<SentMessageInfo | Error> => {
try {
const transporterOptions: TransportOptions = {
// Read and compile the email template
const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8');
const compiledTemplate = handlebars.compile(source);
const html = compiledTemplate(payload);
// Prepare common email data
const fromName = process.env.EMAIL_FROM_NAME || process.env.APP_TITLE;
const fromEmail = process.env.EMAIL_FROM;
const fromAddress = `"${fromName}" <${fromEmail}>`;
const toAddress = `"${payload.name}" <${email}>`;
// Check if Mailgun is configured
if (process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN) {
logger.debug('[sendEmail] Using Mailgun provider');
return await sendEmailViaMailgun({
from: fromAddress,
to: toAddress,
subject: subject,
html: html,
});
}
// Default to SMTP
logger.debug('[sendEmail] Using SMTP provider');
const transporterOptions: any = {
secure: process.env.EMAIL_ENCRYPTION === 'tls',
requireTLS: process.env.EMAIL_ENCRYPTION === 'starttls',
tls: {
@ -49,24 +143,21 @@ export const sendEmail = async ({
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}>`,
// Header address should contain name-addr
from: fromAddress,
to: toAddress,
envelope: {
from: process.env.EMAIL_FROM!,
// Envelope from should contain addr-spec
// Mistake in the Nodemailer documentation?
from: fromEmail,
to: email,
},
subject,
html: compiledTemplate(payload),
subject: subject,
html: html,
};
return await transporter.sendMail(mailOptions);
return await sendEmailViaSMTP({ transporterOptions, mailOptions });
} catch (error: any) {
if (throwError) {
throw error;

View file

@ -22,17 +22,71 @@
<!--<![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; }
@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>

View file

@ -22,18 +22,78 @@
<!--<![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; }
@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>

View file

@ -22,18 +22,75 @@
<!--<![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; }
@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>

View file

@ -39,13 +39,24 @@ function isEnabled(value: boolean | string) {
return false;
}
/**
* Check if email configuration is set
* @returns {Boolean}
*/
function checkEmailConfig() {
return (
// Check if Mailgun is configured
const hasMailgunConfig =
!!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM;
// Check if SMTP is configured
const hasSMTPConfig =
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM
);
!!process.env.EMAIL_FROM;
// Return true if either Mailgun or SMTP is properly configured
return hasMailgunConfig || hasSMTPConfig;
}
export { checkEmailConfig, isEnabled, createTokenHash };