mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-24 03:14:08 +01:00
Merge branch 'main' into refactor/package-auth
This commit is contained in:
commit
02b9c9d447
340 changed files with 18559 additions and 14872 deletions
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
409
packages/auth/src/strategies/appleStrategy.test.ts
Normal file
409
packages/auth/src/strategies/appleStrategy.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Profile } from 'passport';
|
||||
import { Strategy as DiscordStrategy } from 'passport-discord';
|
||||
import socialLogin from './socialLogin';
|
||||
import { GetProfileDetails } from './types';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
355
packages/auth/src/strategies/openidStrategy.spec.ts
Normal file
355
packages/auth/src/strategies/openidStrategy.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
416
packages/auth/src/strategies/samlStrategy.spec.ts
Normal file
416
packages/auth/src/strategies/samlStrategy.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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!');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
466
packages/auth/src/strategies/validators.spec.ts
Normal file
466
packages/auth/src/strategies/validators.spec.ts
Normal 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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue