auth: make @hanzo/iam the single login path

Strip every non-IAM credential path and route the client through the
@hanzo/iam redirect-PKCE flow. IAM owns every credential step.

Client
- iam.ts: one IAM SDK singleton, always configured (env + prod defaults)
- Login.tsx: single "Log in with Hanzo" -> signinRedirect() (PKCE);
  /auth/callback completes the exchange (unchanged OAuthCallback)
- AuthLayout: drop the social/OpenID button block
- GuestLimitDialog / LandingPage: login = IAM signinRedirect
- remove LoginForm, Registration, ResetPassword, RequestPasswordReset,
  SocialButton, SocialLoginRender (+ their specs); drop the
  register / forgot-password / reset-password routes

Server
- delete local + third-party passport strategies (local, apple, discord,
  facebook, github, google, ldap, saml) and their specs
- keep jwt / openid / openidJwt strategies for IAM JWT validation
- socialLogins: Hanzo IAM (OpenID Connect) only
- oauth routes: OpenID only
- auth routes: drop /login, /register, /requestPasswordReset,
  /resetPassword; admin: drop /login/local
- remove dead requireLocalAuth / requireLdapAuth middleware + LoginController
This commit is contained in:
Hanzo Dev
2026-07-20 14:54:56 -07:00
parent d9c11e9cb4
commit c0f452c853
40 changed files with 65 additions and 3783 deletions
@@ -1,30 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { generate2FATempToken } = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const loginController = async (req, res) => {
try {
if (!req.user) {
return res.status(400).json({ message: 'Invalid credentials' });
}
if (req.user.twoFactorEnabled) {
const tempToken = generate2FATempToken(req.user._id);
return res.status(200).json({ twoFAPending: true, tempToken });
}
const { password: _p, totpSecret: _t, __v, ...user } = req.user;
user.id = user._id.toString();
const token = await setAuthTokens(req.user._id, res);
return res.status(200).send({ token, user });
} catch (err) {
logger.error('[loginController]', err);
return res.status(500).json({ message: 'Something went wrong' });
}
};
module.exports = {
loginController,
};
+1 -7
View File
@@ -22,7 +22,7 @@ const {
const { connectDb, indexSync } = require('~/db');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
const createValidateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const { jwtLogin } = require('~/strategies');
const { updateInterfacePermissions } = require('~/models/interface');
const { checkMigrations } = require('./services/start/migration');
const initializeMCPs = require('./services/initializeMCPs');
@@ -283,12 +283,6 @@ if (cluster.isMaster) {
/** OAUTH */
app.use(passport.initialize());
passport.use(jwtLogin());
passport.use(passportLogin());
/** LDAP Auth */
if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
passport.use(ldapLogin);
}
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
await configureSocialLogins(app);
+1 -7
View File
@@ -22,7 +22,7 @@ const {
const { connectDb, indexSync } = require('~/db');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
const createValidateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const { jwtLogin } = require('~/strategies');
const { updateInterfacePermissions } = require('~/models/interface');
const { checkMigrations } = require('./services/start/migration');
const initializeMCPs = require('./services/initializeMCPs');
@@ -161,12 +161,6 @@ const startServer = async () => {
/* OAUTH */
app.use(passport.initialize());
passport.use(jwtLogin());
passport.use(passportLogin());
/* LDAP Auth */
if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
passport.use(ldapLogin);
}
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
await configureSocialLogins(app);
-4
View File
@@ -3,10 +3,8 @@ const validateRegistration = require('./validateRegistration');
const buildEndpointOption = require('./buildEndpointOption');
const validateMessageReq = require('./validateMessageReq');
const checkDomainAllowed = require('./checkDomainAllowed');
const requireLocalAuth = require('./requireLocalAuth');
const canDeleteAccount = require('./canDeleteAccount');
const accessResources = require('./accessResources');
const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
const checkInviteUser = require('./checkInviteUser');
const requireJwtAuth = require('./requireJwtAuth');
@@ -41,8 +39,6 @@ module.exports = {
requireGuestOrJwtAuth,
enforceGuestScope,
checkInviteUser,
requireLdapAuth,
requireLocalAuth,
canDeleteAccount,
configMiddleware,
checkDomainAllowed,
-22
View File
@@ -1,22 +0,0 @@
const passport = require('passport');
const requireLdapAuth = (req, res, next) => {
passport.authenticate('ldapauth', (err, user, info) => {
if (err) {
console.log({
title: '(requireLdapAuth) Error at passport.authenticate',
parameters: [{ name: 'error', value: err }],
});
return next(err);
}
if (!user) {
console.log({
title: '(requireLdapAuth) Error: No user',
});
return res.status(404).send(info);
}
req.user = user;
next();
})(req, res, next);
};
module.exports = requireLdapAuth;
-23
View File
@@ -1,23 +0,0 @@
const passport = require('passport');
const { logger } = require('@librechat/data-schemas');
const requireLocalAuth = (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) {
logger.error('[requireLocalAuth] Error at passport.authenticate:', err);
return next(err);
}
if (!user) {
logger.debug('[requireLocalAuth] Error: No user');
return res.status(404).send(info);
}
if (info && info.message) {
logger.debug('[requireLocalAuth] Error: ' + info.message);
return res.status(422).send({ message: info.message });
}
req.user = user;
next();
})(req, res, next);
};
module.exports = requireLocalAuth;
-12
View File
@@ -9,7 +9,6 @@ const {
exchangeAdminCode,
createSetBalanceConfig,
} = require('@hanzochat/api');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { createOAuthHandler } = require('~/server/controllers/auth/oauth');
const { getAppConfig } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores');
@@ -24,17 +23,6 @@ const setBalanceConfig = createSetBalanceConfig({
const router = express.Router();
router.post(
'/login/local',
middleware.logHeaders,
middleware.loginLimiter,
middleware.checkBan,
middleware.requireLocalAuth,
requireAdmin,
setBalanceConfig,
loginController,
);
router.get('/verify', middleware.requireJwtAuth, requireAdmin, (req, res) => {
const { password: _p, totpSecret: _t, __v, ...user } = req.user;
user.id = user._id.toString();
@@ -57,10 +57,6 @@ jest.mock('@hanzochat/api', () => {
};
});
jest.mock('~/server/controllers/auth/LoginController', () => ({
loginController: jest.fn((req, res) => res.status(200).end()),
}));
jest.mock('~/server/middleware/roles/capabilities', () => ({
hasCapability: jest.fn(() => Promise.resolve(true)),
requireCapability: jest.fn(() => (req, res, next) => next()),
@@ -32,10 +32,6 @@ jest.mock('~/server/controllers/auth/LogoutController', () => ({
logoutController: jest.fn((req, res) => res.status(200).end()),
}));
jest.mock('~/server/controllers/auth/LoginController', () => ({
loginController: jest.fn((req, res) => res.status(200).end()),
}));
jest.mock('~/models', () => ({
findBalanceByUser: jest.fn(),
upsertBalanceFields: jest.fn(),
-45
View File
@@ -1,9 +1,5 @@
const express = require('express');
const { createSetBalanceConfig } = require('@hanzochat/api');
const {
resetPasswordRequestController,
resetPasswordController,
registrationController,
graphTokenController,
refreshController,
} = require('~/server/controllers/AuthController');
@@ -16,55 +12,14 @@ const {
} = require('~/server/controllers/TwoFactorController');
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
const { logoutController } = require('~/server/controllers/auth/LogoutController');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { guestTokenController } = require('~/server/controllers/auth/GuestController');
const { getAppConfig } = require('~/server/services/Config');
const middleware = require('~/server/middleware');
const { Balance } = require('~/db/models');
const setBalanceConfig = createSetBalanceConfig({
getAppConfig,
Balance,
});
const router = express.Router();
const ldapAuth = !!process.env.LDAP_URL && !!process.env.LDAP_USER_SEARCH_BASE;
//Local
router.post('/logout', middleware.requireJwtAuth, logoutController);
router.post(
'/login',
middleware.logHeaders,
middleware.loginLimiter,
middleware.checkBan,
ldapAuth ? middleware.requireLdapAuth : middleware.requireLocalAuth,
setBalanceConfig,
loginController,
);
router.post('/refresh', refreshController);
router.post('/guest', middleware.guestTokenLimiter, middleware.checkBan, guestTokenController);
router.post(
'/register',
middleware.registerLimiter,
middleware.checkBan,
middleware.checkInviteUser,
middleware.validateRegistration,
registrationController,
);
router.post(
'/requestPasswordReset',
middleware.resetPasswordLimiter,
middleware.checkBan,
middleware.validatePasswordReset,
resetPasswordRequestController,
);
router.post(
'/resetPassword',
middleware.checkBan,
middleware.validatePasswordReset,
resetPasswordController,
);
router.get('/2fa/enable', middleware.requireJwtAuth, enable2FA);
router.post('/2fa/verify', middleware.requireJwtAuth, verify2FA);
router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken);
+1 -147
View File
@@ -37,59 +37,7 @@ router.get('/error', (req, res) => {
});
/**
* Google Routes
*/
router.get(
'/google',
loginLimiter,
passport.authenticate('google', {
scope: ['openid', 'profile', 'email'],
session: false,
}),
);
router.get(
'/google/callback',
passport.authenticate('google', {
failureRedirect: `${domains.client}/oauth/error`,
failureMessage: true,
session: false,
scope: ['openid', 'profile', 'email'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
/**
* Facebook Routes
*/
router.get(
'/facebook',
loginLimiter,
passport.authenticate('facebook', {
scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
session: false,
}),
);
router.get(
'/facebook/callback',
passport.authenticate('facebook', {
failureRedirect: `${domains.client}/oauth/error`,
failureMessage: true,
session: false,
scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
/**
* OpenID Routes
* Hanzo IAM (OpenID Connect) Routes
*/
router.get('/openid', loginLimiter, (req, res, next) => {
return passport.authenticate('openid', {
@@ -110,98 +58,4 @@ router.get(
oauthHandler,
);
/**
* GitHub Routes
*/
router.get(
'/github',
loginLimiter,
passport.authenticate('github', {
scope: ['user:email', 'read:user'],
session: false,
}),
);
router.get(
'/github/callback',
passport.authenticate('github', {
failureRedirect: `${domains.client}/oauth/error`,
failureMessage: true,
session: false,
scope: ['user:email', 'read:user'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
/**
* Discord Routes
*/
router.get(
'/discord',
loginLimiter,
passport.authenticate('discord', {
scope: ['identify', 'email'],
session: false,
}),
);
router.get(
'/discord/callback',
passport.authenticate('discord', {
failureRedirect: `${domains.client}/oauth/error`,
failureMessage: true,
session: false,
scope: ['identify', 'email'],
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
/**
* Apple Routes
*/
router.get(
'/apple',
loginLimiter,
passport.authenticate('apple', {
session: false,
}),
);
router.post(
'/apple/callback',
passport.authenticate('apple', {
failureRedirect: `${domains.client}/oauth/error`,
failureMessage: true,
session: false,
}),
setBalanceConfig,
checkDomainAllowed,
oauthHandler,
);
/**
* SAML Routes
*/
router.get(
'/saml',
loginLimiter,
passport.authenticate('saml', {
session: false,
}),
);
router.post(
'/saml/callback',
passport.authenticate('saml', {
failureRedirect: `${domains.client}/oauth/error`,
failureMessage: true,
session: false,
}),
oauthHandler,
);
module.exports = router;
+5 -52
View File
@@ -3,20 +3,11 @@ const session = require('express-session');
const { CacheKeys } = require('librechat-data-provider');
const { isEnabled, shouldUseSecureCookie } = require('@hanzochat/api');
const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas');
const {
openIdJwtLogin,
facebookLogin,
discordLogin,
setupOpenId,
googleLogin,
githubLogin,
appleLogin,
setupSaml,
} = require('~/strategies');
const { openIdJwtLogin, setupOpenId } = require('~/strategies');
const { getLogStores } = require('~/cache');
/**
* Configures OpenID Connect for the application.
* Configures OpenID Connect (Hanzo IAM) for the application.
* @param {Express.Application} app - The Express application instance.
* @returns {Promise<void>}
*/
@@ -50,27 +41,13 @@ async function configureOpenId(app) {
}
/**
*
* Configures Hanzo IAM (OpenID Connect) login. This is the single social
* login path — every credential step is owned by Hanzo IAM.
* @param {Express.Application} app
*/
const configureSocialLogins = async (app) => {
logger.info('Configuring social logins...');
logger.info('Configuring Hanzo IAM login...');
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
passport.use(googleLogin());
}
if (process.env.FACEBOOK_CLIENT_ID && process.env.FACEBOOK_CLIENT_SECRET) {
passport.use(facebookLogin());
}
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
passport.use(githubLogin());
}
if (process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET) {
passport.use(discordLogin());
}
if (process.env.APPLE_CLIENT_ID && process.env.APPLE_PRIVATE_KEY_PATH) {
passport.use(appleLogin());
}
if (
process.env.OPENID_CLIENT_ID &&
process.env.OPENID_CLIENT_SECRET &&
@@ -80,30 +57,6 @@ const configureSocialLogins = async (app) => {
) {
await configureOpenId(app);
}
if (
process.env.SAML_ENTRY_POINT &&
process.env.SAML_ISSUER &&
process.env.SAML_CERT &&
process.env.SAML_SESSION_SECRET
) {
logger.info('Configuring SAML Connect...');
const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
const sessionOptions = {
secret: process.env.SAML_SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: getLogStores(CacheKeys.SAML_SESSION),
cookie: {
maxAge: sessionExpiry,
secure: shouldUseSecureCookie(),
},
};
app.use(session(sessionOptions));
app.use(passport.session());
setupSaml();
logger.info('SAML Connect configured.');
}
};
module.exports = configureSocialLogins;
-49
View File
@@ -1,49 +0,0 @@
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const { Strategy: AppleStrategy } = require('passport-apple');
const socialLogin = require('./socialLogin');
/**
* Extract profile details from the decoded idToken
* @param {Object} params - Parameters from the verify callback
* @param {string} params.idToken - The ID token received from Apple
* @param {Object} params.profile - The profile object (may contain partial info)
* @returns {Object} - The extracted user profile details
*/
const getProfileDetails = ({ idToken, profile }) => {
if (!idToken) {
logger.error('idToken is missing');
throw new Error('idToken is missing');
}
const decoded = jwt.decode(idToken);
logger.debug(`Decoded Apple JWT: ${JSON.stringify(decoded, null, 2)}`);
return {
email: decoded.email,
id: decoded.sub,
avatarUrl: null, // Apple does not provide an avatar URL
username: decoded.email ? decoded.email.split('@')[0].toLowerCase() : `user_${decoded.sub}`,
name: decoded.name
? `${decoded.name.firstName} ${decoded.name.lastName}`
: profile.displayName || null,
emailVerified: true, // Apple verifies the email
};
};
// Initialize the social login handler for Apple
const appleLogin = socialLogin('apple', getProfileDetails);
module.exports = () =>
new AppleStrategy(
{
clientID: process.env.APPLE_CLIENT_ID,
teamID: process.env.APPLE_TEAM_ID,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.APPLE_CALLBACK_URL}`,
keyID: process.env.APPLE_KEY_ID,
privateKeyLocation: process.env.APPLE_PRIVATE_KEY_PATH,
passReqToCallback: false, // Set to true if you need to access the request in the callback
},
appleLogin,
);
-395
View File
@@ -1,395 +0,0 @@
const jwt = require('jsonwebtoken');
const mongoose = require('mongoose');
const { isEnabled } = require('@hanzochat/api');
const { logger } = require('@librechat/data-schemas');
const { Strategy: AppleStrategy } = require('passport-apple');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { createSocialUser, handleExistingUser } = require('./process');
const socialLogin = require('./socialLogin');
const { findUser } = require('~/models');
const { User } = require('~/db/models');
jest.mock('jsonwebtoken');
jest.mock('@librechat/data-schemas', () => {
const actualModule = jest.requireActual('@librechat/data-schemas');
return {
...actualModule,
logger: {
error: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
},
};
});
jest.mock('./process', () => ({
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
}));
jest.mock('@hanzochat/api', () => ({
...jest.requireActual('@hanzochat/api'),
isEnabled: jest.fn(),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({
fileStrategy: 'local',
balance: { enabled: false },
}),
}));
describe('Apple Login Strategy', () => {
let mongoServer;
let appleStrategyInstance;
const OLD_ENV = process.env;
let getProfileDetails;
// Start and stop in-memory MongoDB
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
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);
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.mockImplementation((flag) => {
if (flag === 'true') {
return true;
}
if (flag === 'false') {
return false;
}
return false;
});
// 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.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.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.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(() => {
jwt.decode.mockReturnValue(decodedToken);
findUser.mockResolvedValue(null);
});
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)
findUser.mockResolvedValue(null);
// Mock createSocialUser to create a user
createSocialUser.mockImplementation(async (userData) => {
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, user) => {
mockVerifyCallback(err, user);
resolve();
},
);
});
expect(mockVerifyCallback).toHaveBeenCalledWith(null, expect.any(User));
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',
});
// Mock findUser to return the existing user
findUser.mockResolvedValue(existingUser);
// Mock handleExistingUser to update avatarUrl without saving to database
handleExistingUser.mockImplementation(async (user, avatarUrl) => {
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, user) => {
mockVerifyCallback(err, user);
resolve();
},
);
});
expect(mockVerifyCallback).toHaveBeenCalledWith(null, existingUser);
expect(existingUser.avatarUrl).toBeNull(); // As per getProfileDetails
expect(handleExistingUser).toHaveBeenCalledWith(
existingUser,
null,
expect.objectContaining({
fileStrategy: 'local',
balance: { enabled: false },
}),
'jane.doe@example.com',
);
});
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, user) => {
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.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, user) => {
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)
findUser.mockResolvedValue(null);
// Mock createSocialUser to throw an error
createSocialUser.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, user) => {
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));
});
});
});
-36
View File
@@ -1,36 +0,0 @@
const { Strategy: DiscordStrategy } = require('passport-discord');
const socialLogin = require('./socialLogin');
const getProfileDetails = ({ profile }) => {
let avatarUrl;
if (profile.avatar) {
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
avatarUrl = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`;
} else {
const defaultAvatarNum = Number(profile.discriminator) % 5;
avatarUrl = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`;
}
return {
email: profile.email,
id: profile.id,
avatarUrl,
username: profile.username,
name: profile.global_name,
emailVerified: true,
};
};
const discordLogin = socialLogin('discord', getProfileDetails);
module.exports = () =>
new DiscordStrategy(
{
clientID: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.DISCORD_CALLBACK_URL}`,
scope: ['identify', 'email'],
authorizationURL: 'https://discord.com/api/oauth2/authorize?prompt=none',
},
discordLogin,
);
-26
View File
@@ -1,26 +0,0 @@
const FacebookStrategy = require('passport-facebook').Strategy;
const socialLogin = require('./socialLogin');
const getProfileDetails = ({ profile }) => ({
email: profile.emails[0]?.value,
id: profile.id,
avatarUrl: profile.photos[0]?.value,
username: profile.displayName,
name: profile.name?.givenName + ' ' + profile.name?.familyName,
emailVerified: true,
});
const facebookLogin = socialLogin('facebook', getProfileDetails);
module.exports = () =>
new FacebookStrategy(
{
clientID: process.env.FACEBOOK_CLIENT_ID,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.FACEBOOK_CALLBACK_URL}`,
proxy: true,
scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
},
facebookLogin,
);
-34
View File
@@ -1,34 +0,0 @@
const { Strategy: GitHubStrategy } = require('passport-github2');
const socialLogin = require('./socialLogin');
const getProfileDetails = ({ profile }) => ({
email: profile.emails[0].value,
id: profile.id,
avatarUrl: profile.photos[0].value,
username: profile.username,
name: profile.displayName,
emailVerified: profile.emails[0].verified,
});
const githubLogin = socialLogin('github', getProfileDetails);
module.exports = () =>
new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.GITHUB_CALLBACK_URL}`,
proxy: false,
scope: ['user:email'],
...(process.env.GITHUB_ENTERPRISE_BASE_URL && {
authorizationURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/authorize`,
tokenURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/access_token`,
userProfileURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user`,
userEmailURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user/emails`,
...(process.env.GITHUB_ENTERPRISE_USER_AGENT && {
userAgent: process.env.GITHUB_ENTERPRISE_USER_AGENT,
}),
}),
},
githubLogin,
);
-24
View File
@@ -1,24 +0,0 @@
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const socialLogin = require('./socialLogin');
const getProfileDetails = ({ profile }) => ({
email: profile.emails[0].value,
id: profile.id,
avatarUrl: profile.photos[0].value,
username: profile.name.givenName,
name: `${profile.name.givenName}${profile.name.familyName ? ` ${profile.name.familyName}` : ''}`,
emailVerified: profile.emails[0].verified,
});
const googleLogin = socialLogin('google', getProfileDetails);
module.exports = () =>
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: `${process.env.DOMAIN_SERVER}${process.env.GOOGLE_CALLBACK_URL}`,
proxy: true,
},
googleLogin,
);
-16
View File
@@ -1,26 +1,10 @@
const { setupOpenId, getOpenIdConfig } = require('./openidStrategy');
const openIdJwtLogin = require('./openIdJwtStrategy');
const facebookLogin = require('./facebookStrategy');
const discordLogin = require('./discordStrategy');
const passportLogin = require('./localStrategy');
const googleLogin = require('./googleStrategy');
const githubLogin = require('./githubStrategy');
const { setupSaml } = require('./samlStrategy');
const appleLogin = require('./appleStrategy');
const ldapLogin = require('./ldapStrategy');
const jwtLogin = require('./jwtStrategy');
module.exports = {
appleLogin,
passportLogin,
googleLogin,
githubLogin,
discordLogin,
jwtLogin,
facebookLogin,
setupOpenId,
getOpenIdConfig,
ldapLogin,
setupSaml,
openIdJwtLogin,
};
-167
View File
@@ -1,167 +0,0 @@
const fs = require('fs');
const LdapStrategy = require('passport-ldapauth');
const { logger } = require('@librechat/data-schemas');
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
const { isEnabled, getBalanceConfig, isEmailDomainAllowed } = require('@hanzochat/api');
const { createUser, findUser, updateUser, countUsers } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const {
LDAP_URL,
LDAP_BIND_DN,
LDAP_BIND_CREDENTIALS,
LDAP_USER_SEARCH_BASE,
LDAP_SEARCH_FILTER,
LDAP_CA_CERT_PATH,
LDAP_FULL_NAME,
LDAP_ID,
LDAP_USERNAME,
LDAP_EMAIL,
LDAP_TLS_REJECT_UNAUTHORIZED,
LDAP_STARTTLS,
} = process.env;
// Check required environment variables
if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) {
module.exports = null;
}
const searchAttributes = [
'displayName',
'mail',
'uid',
'cn',
'name',
'commonname',
'givenName',
'sn',
'sAMAccountName',
];
if (LDAP_FULL_NAME) {
searchAttributes.push(...LDAP_FULL_NAME.split(','));
}
if (LDAP_ID) {
searchAttributes.push(LDAP_ID);
}
if (LDAP_USERNAME) {
searchAttributes.push(LDAP_USERNAME);
}
if (LDAP_EMAIL) {
searchAttributes.push(LDAP_EMAIL);
}
const rejectUnauthorized = isEnabled(LDAP_TLS_REJECT_UNAUTHORIZED);
const startTLS = isEnabled(LDAP_STARTTLS);
const ldapOptions = {
server: {
url: LDAP_URL,
bindDN: LDAP_BIND_DN,
bindCredentials: LDAP_BIND_CREDENTIALS,
searchBase: LDAP_USER_SEARCH_BASE,
searchFilter: LDAP_SEARCH_FILTER || 'mail={{username}}',
searchAttributes: [...new Set(searchAttributes)],
...(LDAP_CA_CERT_PATH && {
tlsOptions: {
rejectUnauthorized,
ca: (() => {
try {
return [fs.readFileSync(LDAP_CA_CERT_PATH)];
} catch (err) {
logger.error('[ldapStrategy]', 'Failed to read CA certificate', err);
throw err;
}
})(),
},
}),
...(startTLS && { starttls: true }),
},
usernameField: 'email',
passwordField: 'password',
};
const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
if (!userinfo) {
return done(null, false, { message: 'Invalid credentials' });
}
try {
const ldapId =
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
let user = await findUser({ ldapId });
if (user && user.provider !== 'ldap') {
logger.info(
`[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`,
);
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});
}
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
const fullName =
fullNameAttributes && fullNameAttributes.length > 0
? fullNameAttributes.map((attr) => userinfo[attr]).join(' ')
: userinfo.cn || userinfo.name || userinfo.commonname || userinfo.displayName;
const username =
(LDAP_USERNAME && userinfo[LDAP_USERNAME]) || userinfo.givenName || userinfo.mail;
let mail = (LDAP_EMAIL && userinfo[LDAP_EMAIL]) || userinfo.mail || username + '@ldap.local';
mail = Array.isArray(mail) ? mail[0] : mail;
if (!userinfo.mail && !(LDAP_EMAIL && userinfo[LDAP_EMAIL])) {
logger.warn(
'[ldapStrategy]',
`No valid email attribute found in LDAP userinfo. Using fallback email: ${username}@ldap.local`,
`LDAP_EMAIL env var: ${LDAP_EMAIL || 'not set'}`,
`Available userinfo attributes: ${Object.keys(userinfo).join(', ')}`,
'Full userinfo:',
JSON.stringify(userinfo, null, 2),
);
}
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(mail, appConfig?.registration?.allowedDomains)) {
logger.error(
`[LDAP Strategy] Authentication blocked - email domain not allowed [Email: ${mail}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
if (!user) {
const isFirstRegisteredUser = (await countUsers()) === 0;
const role = isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER;
user = {
provider: 'ldap',
ldapId,
username,
email: mail,
emailVerified: true, // The ldap server administrator should verify the email
name: fullName,
role,
};
const balanceConfig = getBalanceConfig(appConfig);
const userId = await createUser(user, balanceConfig);
user._id = userId;
} else {
// Users registered in LDAP are assumed to have their user information managed in LDAP,
// so update the user information with the values registered in LDAP
user.provider = 'ldap';
user.ldapId = ldapId;
user.email = mail;
user.username = username;
user.name = fullName;
}
user = await updateUser(user._id, user);
done(null, user);
} catch (err) {
logger.error('[ldapStrategy]', err);
done(err);
}
});
module.exports = ldapLogin;
-183
View File
@@ -1,183 +0,0 @@
// --- Mocks ---
jest.mock('@librechat/data-schemas', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('@hanzochat/api', () => ({
// isEnabled used for TLS flags
isEnabled: jest.fn(() => false),
isEmailDomainAllowed: jest.fn(() => true),
getBalanceConfig: jest.fn(() => ({ enabled: false })),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
countUsers: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({}),
}));
// Mock passport-ldapauth to capture verify callback
let verifyCallback;
jest.mock('passport-ldapauth', () => {
return jest.fn().mockImplementation((options, verify) => {
verifyCallback = verify; // capture the strategy verify function
return { name: 'ldap', options, verify };
});
});
const { ErrorTypes } = require('librechat-data-provider');
const { isEmailDomainAllowed } = require('@hanzochat/api');
const { findUser, createUser, updateUser, countUsers } = require('~/models');
// Helper to call the verify callback and wrap in a Promise for convenience
const callVerify = (userinfo) =>
new Promise((resolve, reject) => {
verifyCallback(userinfo, (err, user, info) => {
if (err) return reject(err);
resolve({ user, info });
});
});
describe('ldapStrategy', () => {
beforeEach(() => {
jest.clearAllMocks();
// minimal required env for ldapStrategy module to export
process.env.LDAP_URL = 'ldap://example.com';
process.env.LDAP_USER_SEARCH_BASE = 'ou=users,dc=example,dc=com';
// Unset optional envs to exercise defaults
delete process.env.LDAP_CA_CERT_PATH;
delete process.env.LDAP_FULL_NAME;
delete process.env.LDAP_ID;
delete process.env.LDAP_USERNAME;
delete process.env.LDAP_EMAIL;
delete process.env.LDAP_TLS_REJECT_UNAUTHORIZED;
delete process.env.LDAP_STARTTLS;
// Default model/domain mocks
findUser.mockReset().mockResolvedValue(null);
createUser.mockReset().mockResolvedValue('newUserId');
updateUser.mockReset().mockImplementation(async (id, user) => ({ _id: id, ...user }));
countUsers.mockReset().mockResolvedValue(0);
isEmailDomainAllowed.mockReset().mockReturnValue(true);
// Ensure requiring the strategy sets up the verify callback
jest.isolateModules(() => {
require('./ldapStrategy');
});
});
it('uses the first email when LDAP returns multiple emails (array)', async () => {
const userinfo = {
uid: 'uid123',
givenName: 'Alice',
cn: 'Alice Doe',
mail: ['first@example.com', 'second@example.com'],
};
const { user } = await callVerify(userinfo);
expect(user.email).toBe('first@example.com');
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'ldap',
ldapId: 'uid123',
username: 'Alice',
email: 'first@example.com',
emailVerified: true,
name: 'Alice Doe',
}),
expect.any(Object),
);
});
it('blocks login if an existing user has a different provider', async () => {
findUser.mockResolvedValue({ _id: 'u1', email: 'first@example.com', provider: 'google' });
const userinfo = {
uid: 'uid123',
mail: 'first@example.com',
givenName: 'Alice',
cn: 'Alice Doe',
};
const { user, info } = await callVerify(userinfo);
expect(user).toBe(false);
expect(info).toEqual({ message: ErrorTypes.AUTH_FAILED });
expect(createUser).not.toHaveBeenCalled();
});
it('updates an existing ldap user with current LDAP info', async () => {
const existing = {
_id: 'u2',
provider: 'ldap',
email: 'old@example.com',
ldapId: 'uid123',
username: 'olduser',
name: 'Old Name',
};
findUser.mockResolvedValue(existing);
const userinfo = {
uid: 'uid123',
mail: 'new@example.com',
givenName: 'NewFirst',
cn: 'NewFirst NewLast',
};
const { user } = await callVerify(userinfo);
expect(createUser).not.toHaveBeenCalled();
expect(updateUser).toHaveBeenCalledWith(
'u2',
expect.objectContaining({
provider: 'ldap',
ldapId: 'uid123',
email: 'new@example.com',
username: 'NewFirst',
name: 'NewFirst NewLast',
}),
);
expect(user.email).toBe('new@example.com');
});
it('falls back to username@ldap.local when no email attributes are present', async () => {
const userinfo = {
uid: 'uid999',
givenName: 'John',
cn: 'John Doe',
// no mail and no custom LDAP_EMAIL
};
const { user } = await callVerify(userinfo);
expect(user.email).toBe('John@ldap.local');
});
it('denies login if email domain is not allowed', async () => {
isEmailDomainAllowed.mockReturnValue(false);
const userinfo = {
uid: 'uid123',
mail: 'notallowed@blocked.com',
givenName: 'Alice',
cn: 'Alice Doe',
};
const { user, info } = await callVerify(userinfo);
expect(user).toBe(false);
expect(info).toEqual({ message: 'Email domain not allowed' });
});
});
-89
View File
@@ -1,89 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { errorsToString } = require('librechat-data-provider');
const { isEnabled, checkEmailConfig } = require('@hanzochat/api');
const { Strategy: PassportLocalStrategy } = require('passport-local');
const { findUser, comparePassword, updateUser } = require('~/models');
const { loginSchema } = require('./validators');
// Unix timestamp for 2024-06-07 15:20:18 Eastern Time
const verificationEnabledTimestamp = 1717788018;
async function validateLoginRequest(req) {
const { error } = loginSchema.safeParse(req.body);
return error ? errorsToString(error.errors) : null;
}
async function passportLogin(req, email, password, done) {
try {
const validationError = await validateLoginRequest(req);
if (validationError) {
logError('Passport Local Strategy - Validation Error', { reqBody: req.body });
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
return done(null, false, { message: validationError });
}
const user = await findUser({ email: email.trim() }, '+password');
if (!user) {
logError('Passport Local Strategy - User Not Found', { email });
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
return done(null, false, { message: 'Email does not exist.' });
}
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 });
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
return done(null, false, { message: 'Incorrect password.' });
}
const emailEnabled = checkEmailConfig();
const userCreatedAtTimestamp = Math.floor(new Date(user.createdAt).getTime() / 1000);
if (
!emailEnabled &&
!user.emailVerified &&
userCreatedAtTimestamp < verificationEnabledTimestamp
) {
await updateUser(user._id, { emailVerified: true });
user.emailVerified = true;
}
const unverifiedAllowed = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN);
if (user.expiresAt && unverifiedAllowed) {
await updateUser(user._id, {});
}
if (!user.emailVerified && !unverifiedAllowed) {
logError('Passport Local Strategy - Email not verified', { email });
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
return done(null, user, { message: 'Email not verified.' });
}
logger.info(`[Login] [Login successful] [Username: ${email}] [Request-IP: ${req.ip}]`);
return done(null, user);
} catch (err) {
return done(err);
}
}
function logError(title, parameters) {
const entries = Object.entries(parameters).map(([name, value]) => ({ name, value }));
logger.error(title, { parameters: entries });
}
module.exports = () =>
new PassportLocalStrategy(
{
usernameField: 'email',
passwordField: 'password',
session: false,
passReqToCallback: true,
},
passportLogin,
);
-299
View File
@@ -1,299 +0,0 @@
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const passport = require('passport');
const { ErrorTypes } = require('librechat-data-provider');
const { hashToken, logger } = require('@librechat/data-schemas');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { getBalanceConfig, isEmailDomainAllowed } = require('@hanzochat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const paths = require('~/config/paths');
let crypto;
try {
crypto = require('node:crypto');
} catch (err) {
logger.error('[samlStrategy] crypto support is disabled!', err);
}
/**
* Retrieves the certificate content from the given value.
*
* This function determines whether the provided value is a certificate string (RFC7468 format or
* base64-encoded without a header) or a valid file path. If the value matches one of these formats,
* the certificate content is returned. Otherwise, an error is thrown.
*
* @see https://github.com/node-saml/node-saml/tree/master?tab=readme-ov-file#configuration-option-idpcert
* @param {string} value - The certificate string or file path.
* @returns {string} The certificate content if valid.
* @throws {Error} If the value is not a valid certificate string or file path.
*/
function getCertificateContent(value) {
if (typeof value !== 'string') {
throw new Error('Invalid input: SAML_CERT must be a string.');
}
// Check if it's an RFC7468 formatted PEM certificate
const pemRegex = new RegExp(
'-----BEGIN (CERTIFICATE|PUBLIC KEY)-----\n' + // header
'([A-Za-z0-9+/=]{64}\n)+' + // base64 content (64 characters per line)
'[A-Za-z0-9+/=]{1,64}\n' + // base64 content (last line)
'-----END (CERTIFICATE|PUBLIC KEY)-----', // footer
);
if (pemRegex.test(value)) {
logger.info('[samlStrategy] Detected RFC7468-formatted certificate string.');
return value;
}
// Check if it's a Base64-encoded certificate (no header)
if (/^[A-Za-z0-9+/=]+$/.test(value) && value.length % 4 === 0) {
logger.info('[samlStrategy] Detected base64-encoded certificate string (no header).');
return value;
}
// Check if file exists and is readable
const certPath = path.normalize(path.isAbsolute(value) ? value : path.join(paths.root, value));
if (fs.existsSync(certPath) && fs.statSync(certPath).isFile()) {
try {
logger.info(`[samlStrategy] Loading certificate from file: ${certPath}`);
return fs.readFileSync(certPath, 'utf8').trim();
} catch (error) {
throw new Error(`Error reading certificate file: ${error.message}`);
}
}
throw new Error('Invalid cert: SAML_CERT must be a valid file path or certificate string.');
}
/**
* Retrieves a SAML claim from a profile object based on environment configuration.
* @param {object} profile - Saml profile
* @param {string} envVar - Environment variable name (SAML_*)
* @param {string} defaultKey - Default key to use if the environment variable is not set
* @returns {string}
*/
function getSamlClaim(profile, envVar, defaultKey) {
const claimKey = process.env[envVar];
// Avoids accessing `profile[""]` when the environment variable is empty string.
if (claimKey) {
return profile[claimKey] ?? profile[defaultKey];
}
return profile[defaultKey];
}
function getEmail(profile) {
return getSamlClaim(profile, 'SAML_EMAIL_CLAIM', 'email');
}
function getUserName(profile) {
return getSamlClaim(profile, 'SAML_USERNAME_CLAIM', 'username');
}
function getGivenName(profile) {
return getSamlClaim(profile, 'SAML_GIVEN_NAME_CLAIM', 'given_name');
}
function getFamilyName(profile) {
return getSamlClaim(profile, 'SAML_FAMILY_NAME_CLAIM', 'family_name');
}
function getPicture(profile) {
return getSamlClaim(profile, 'SAML_PICTURE_CLAIM', 'picture');
}
/**
* Downloads an image from a URL using an access token.
* @param {string} url
* @returns {Promise<Buffer>}
*/
const downloadImage = async (url) => {
try {
const response = await fetch(url);
if (response.ok) {
return await response.buffer();
} else {
throw new Error(`${response.statusText} (HTTP ${response.status})`);
}
} catch (error) {
logger.error(`[samlStrategy] Error downloading image at URL "${url}": ${error}`);
return null;
}
};
/**
* Determines the full name of a user based on SAML profile and environment configuration.
*
* @param {Object} profile - The user profile object from SAML Connect
* @returns {string} The determined full name of the user
*/
function getFullName(profile) {
if (process.env.SAML_NAME_CLAIM) {
logger.info(
`[samlStrategy] Using SAML_NAME_CLAIM: ${process.env.SAML_NAME_CLAIM}, profile: ${profile[process.env.SAML_NAME_CLAIM]}`,
);
return profile[process.env.SAML_NAME_CLAIM];
}
const givenName = getGivenName(profile);
const familyName = getFamilyName(profile);
if (givenName && familyName) {
return `${givenName} ${familyName}`;
}
if (givenName) {
return givenName;
}
if (familyName) {
return familyName;
}
return getUserName(profile) || getEmail(profile);
}
/**
* Converts an input into a string suitable for a username.
* If the input is a string, it will be returned as is.
* If the input is an array, elements will be joined with underscores.
* In case of undefined or other falsy values, a default value will be returned.
*
* @param {string | string[] | undefined} input - The input value to be converted into a username.
* @param {string} [defaultValue=''] - The default value to return if the input is falsy.
* @returns {string} The processed input as a string suitable for a username.
*/
function convertToUsername(input, defaultValue = '') {
if (typeof input === 'string') {
return input;
} else if (Array.isArray(input)) {
return input.join('_');
}
return defaultValue;
}
async function setupSaml() {
try {
const samlConfig = {
entryPoint: process.env.SAML_ENTRY_POINT,
issuer: process.env.SAML_ISSUER,
callbackUrl: process.env.SAML_CALLBACK_URL,
idpCert: getCertificateContent(process.env.SAML_CERT),
wantAssertionsSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? false : true,
wantAuthnResponseSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? true : false,
};
passport.use(
'saml',
new SamlStrategy(samlConfig, async (profile, done) => {
try {
logger.info(`[samlStrategy] SAML authentication received for NameID: ${profile.nameID}`);
logger.debug('[samlStrategy] SAML profile:', profile);
const userEmail = getEmail(profile) || '';
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(userEmail, appConfig?.registration?.allowedDomains)) {
logger.error(
`[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
let user = await findUser({ samlId: profile.nameID });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with SAML ID: ${profile.nameID}`,
);
if (!user) {
user = await findUser({ email: userEmail });
logger.info(
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${userEmail}`,
);
}
if (user && user.provider !== 'saml') {
logger.info(
`[samlStrategy] User ${user.email} already exists with provider ${user.provider}`,
);
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});
}
const fullName = getFullName(profile);
const username = convertToUsername(
getUserName(profile) || getGivenName(profile) || getEmail(profile),
);
if (!user) {
user = {
provider: 'saml',
samlId: profile.nameID,
username,
email: userEmail,
emailVerified: true,
name: fullName,
};
const balanceConfig = getBalanceConfig(appConfig);
user = await createUser(user, balanceConfig, true, true);
} else {
user.provider = 'saml';
user.samlId = profile.nameID;
user.username = username;
user.name = fullName;
}
const picture = getPicture(profile);
if (picture && !user.avatar?.includes('manual=true')) {
const imageBuffer = await downloadImage(profile.picture);
if (imageBuffer) {
let fileName;
if (crypto) {
fileName = (await hashToken(profile.nameID)) + '.png';
} else {
fileName = profile.nameID + '.png';
}
const { saveBuffer } = getStrategyFunctions(
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
);
const imagePath = await saveBuffer({
fileName,
userId: user._id.toString(),
buffer: imageBuffer,
});
user.avatar = imagePath ?? '';
}
}
user = await updateUser(user._id, user);
logger.info(
`[samlStrategy] Login success SAML ID: ${user.samlId} | email: ${user.email} | username: ${user.username}`,
{
user: {
samlId: user.samlId,
username: user.username,
email: user.email,
name: user.name,
},
},
);
done(null, user);
} catch (err) {
logger.error('[samlStrategy] Login failed', err);
done(err);
}
}),
);
} catch (err) {
logger.error('[samlStrategy]', err);
}
}
module.exports = { setupSaml, getCertificateContent };
-444
View File
@@ -1,444 +0,0 @@
// --- Mocks ---
jest.mock('tiktoken');
jest.mock('fs');
jest.mock('path');
jest.mock('node-fetch');
jest.mock('@node-saml/passport-saml');
jest.mock('@librechat/data-schemas', () => ({
logger: {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
config: {
registration: {
socialLogins: ['saml'],
},
},
getAppConfig: jest.fn().mockResolvedValue({}),
}));
jest.mock('@hanzochat/api', () => ({
isEmailDomainAllowed: jest.fn(() => true),
getBalanceConfig: jest.fn(() => ({
tokenCredits: 1000,
startBalance: 1000,
})),
}));
jest.mock('~/server/services/Config/EndpointService', () => ({
config: {},
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
})),
}));
jest.mock('~/config/paths', () => ({
root: '/fake/root/path',
}));
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { setupSaml, getCertificateContent } = require('./samlStrategy');
// Configure fs mock
jest.mocked(fs).existsSync = jest.fn();
jest.mocked(fs).statSync = jest.fn();
jest.mocked(fs).readFileSync = jest.fn();
// To capture the verify callback from the strategy, we grab it from the mock constructor
let verifyCallback;
SamlStrategy.mockImplementation((options, verify) => {
verifyCallback = verify;
return { name: 'saml', options, verify };
});
describe('getCertificateContent', () => {
const 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.mockReturnValue(false);
path.join.mockReturnValue(resolvedPath);
path.normalize.mockReturnValue(resolvedPath);
fs.existsSync.mockReturnValue(true);
fs.statSync.mockReturnValue({ isFile: () => true });
fs.readFileSync.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.mockReturnValue(true);
path.normalize.mockReturnValue(process.env.SAML_CERT);
fs.existsSync.mockReturnValue(true);
fs.statSync.mockReturnValue({ isFile: () => true });
fs.readFileSync.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.mockReturnValue(false);
path.join.mockReturnValue(resolvedPath);
path.normalize.mockReturnValue(resolvedPath);
fs.existsSync.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.mockReturnValue(false);
path.join.mockReturnValue(resolvedPath);
path.normalize.mockReturnValue(resolvedPath);
fs.existsSync.mockReturnValue(true);
fs.statSync.mockReturnValue({ isFile: () => true });
fs.readFileSync.mockImplementation(() => {
throw new Error('Permission denied');
});
expect(() => getCertificateContent(process.env.SAML_CERT)).toThrow(
'Error reading certificate file: Permission denied',
);
});
});
describe('setupSaml', () => {
// Helper to wrap the verify callback in a promise
const validate = (profile) =>
new Promise((resolve, reject) => {
verifyCallback(profile, (err, user, details) => {
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
const { findUser, createUser, updateUser } = require('~/models');
findUser.mockResolvedValue(null);
createUser.mockImplementation(async (userData) => ({
_id: 'mock-user-id',
...userData,
}));
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;
// Simulate image download
const fakeBuffer = Buffer.from('fake image');
fetch.mockResolvedValue({
ok: true,
buffer: jest.fn().mockResolvedValue(fakeBuffer),
});
await setupSaml();
});
it('should create a new user with correct username when username claim exists', async () => {
const profile = { ...baseProfile };
const { user } = await validate(profile);
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 = { ...baseProfile };
delete profile.username;
const expectUsername = profile.given_name;
const { user } = await validate(profile);
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 = { ...baseProfile };
delete profile.username;
delete profile.given_name;
const expectUsername = profile.email;
const { user } = await validate(profile);
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);
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);
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when given_name exist', async () => {
const profile = { ...baseProfile };
delete profile.family_name;
const expectedFullName = profile.given_name;
const { user } = await validate(profile);
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when family_name exist', async () => {
const profile = { ...baseProfile };
delete profile.given_name;
const expectedFullName = profile.family_name;
const { user } = await validate(profile);
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when username exist', async () => {
const profile = { ...baseProfile };
delete profile.family_name;
delete profile.given_name;
const expectedFullName = profile.username;
const { user } = await validate(profile);
expect(user.name).toBe(expectedFullName);
});
it('should set the full name correctly when email only exist', async () => {
const profile = { ...baseProfile };
delete profile.family_name;
delete profile.given_name;
delete profile.username;
const expectedFullName = profile.email;
const { user } = await validate(profile);
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);
expect(user.name).toBe(expectedFullName);
});
it('should update an existing user on login', async () => {
// Set up findUser to return an existing user with saml provider
const { findUser } = require('~/models');
const existingUser = {
_id: 'existing-user-id',
provider: 'saml',
email: baseProfile.email,
samlId: '',
username: 'oldusername',
name: 'Old Name',
};
findUser.mockResolvedValue(existingUser);
const profile = { ...baseProfile };
const { user } = await validate(profile);
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 block login when email exists with different provider', async () => {
// Set up findUser to return a user with different provider
const { findUser } = require('~/models');
const existingUser = {
_id: 'existing-user-id',
provider: 'google',
email: baseProfile.email,
googleId: 'some-google-id',
username: 'existinguser',
name: 'Existing User',
};
findUser.mockResolvedValue(existingUser);
const profile = { ...baseProfile };
const result = await validate(profile);
expect(result.user).toBe(false);
expect(result.details.message).toBe(require('librechat-data-provider').ErrorTypes.AUTH_FAILED);
});
it('should attempt to download and save the avatar if picture is provided', async () => {
const profile = { ...baseProfile };
const { user } = await validate(profile);
expect(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 = { ...baseProfile };
delete profile.picture;
await validate(profile);
expect(fetch).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,6 @@ import { ThemeSelector } from '@librechat/client';
import { TStartupConfig } from 'librechat-data-provider';
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
import { TranslationKeys, useLocalize } from '~/hooks';
import SocialLoginRender from './SocialLoginRender';
import { BlinkAnimation } from './BlinkAnimation';
import { Banner } from '../Banners';
import Footer from './Footer';
@@ -84,10 +83,6 @@ function AuthLayout({
</h1>
)}
{children}
{!pathname.includes('2fa') &&
(pathname.includes('login') || pathname.includes('register')) && (
<SocialLoginRender startupConfig={startupConfig} />
)}
</div>
</main>
<Footer startupConfig={startupConfig} />
@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { OGDialog, OGDialogTemplate, Button } from '@librechat/client';
import { getHanzoIamSdk, isStaticIamMode } from '~/utils/iam';
import { useGetStartupConfig } from '~/data-provider';
import { getHanzoIamSdk } from '~/utils/iam';
import { useLocalize } from '~/hooks';
/**
@@ -13,7 +12,6 @@ import { useLocalize } from '~/hooks';
export default function GuestLimitDialog() {
const [open, setOpen] = useState(false);
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig();
useEffect(() => {
const handler = () => setOpen(true);
@@ -22,17 +20,8 @@ export default function GuestLimitDialog() {
}, []);
const handleLogin = useCallback(() => {
const iamSdk = getHanzoIamSdk();
if (isStaticIamMode() && iamSdk) {
iamSdk.signinRedirect();
return;
}
if (startupConfig?.openidLoginEnabled && startupConfig?.serverDomain) {
window.location.href = `${startupConfig.serverDomain}/oauth/openid`;
return;
}
window.location.href = '/login';
}, [startupConfig]);
getHanzoIamSdk().signinRedirect();
}, []);
return (
<OGDialog open={open} onOpenChange={setOpen}>
+33 -107
View File
@@ -1,39 +1,29 @@
import { useEffect, useState, useCallback } from 'react';
import { ErrorTypes, registerPage } from 'librechat-data-provider';
import { OpenIDIcon, useToastContext } from '@librechat/client';
import { useOutletContext, useSearchParams } from 'react-router-dom';
import type { TLoginLayoutContext } from '~/common';
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
import SocialButton from '~/components/Auth/SocialButton';
import { useAuthContext } from '~/hooks/AuthContext';
import { getLoginError } from '~/utils';
import { getHanzoIamSdk, isStaticIamMode } from '~/utils/iam';
import { ErrorTypes } from 'librechat-data-provider';
import { useToastContext } from '@librechat/client';
import { useSearchParams } from 'react-router-dom';
import { getHanzoIamSdk } from '~/utils/iam';
import { useLocalize } from '~/hooks';
import LoginForm from './LoginForm';
/**
* Login — the single Hanzo IAM path.
*
* Renders one "Log in with Hanzo" action that starts the @hanzo/iam
* redirect-PKCE authorize flow. IAM owns every credential step; the
* `/auth/callback` route completes the token exchange. Auto-redirects
* to IAM on mount unless `?redirect=false` is present.
*/
function Login() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { error, setError, login } = useAuthContext();
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
const [searchParams, setSearchParams] = useSearchParams();
// Determine if auto-redirect should be disabled based on the URL parameter
const disableAutoRedirect = searchParams.get('redirect') === 'false';
// Persist the disable flag locally so that once detected, auto-redirect stays disabled.
const disableAutoRedirect = searchParams.get('redirect') === 'false';
const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect);
// Check if we're in static/IAM mode
const iamSdk = getHanzoIamSdk();
const isStaticMode = isStaticIamMode();
/** Trigger IAM PKCE login redirect via the SDK. */
const handleIamLogin = useCallback(() => {
if (iamSdk) {
iamSdk.signinRedirect();
}
}, [iamSdk]);
getHanzoIamSdk().signinRedirect();
}, []);
useEffect(() => {
const oauthError = searchParams?.get('error');
@@ -48,7 +38,6 @@ function Login() {
}
}, [searchParams, setSearchParams, showToast, localize]);
// Once the disable flag is detected, update local state and remove the parameter from the URL.
useEffect(() => {
if (disableAutoRedirect) {
setIsAutoRedirectDisabled(true);
@@ -58,100 +47,37 @@ function Login() {
}
}, [disableAutoRedirect, searchParams, setSearchParams]);
// Determine whether we should auto-redirect to OpenID or Hanzo IAM
const shouldAutoRedirect =
!isAutoRedirectDisabled &&
((startupConfig?.openidLoginEnabled &&
startupConfig?.openidAutoRedirect &&
startupConfig?.serverDomain) ||
(isStaticMode && iamSdk));
const hasError = !!searchParams?.get('error');
const shouldAutoRedirect = !isAutoRedirectDisabled && !hasError;
useEffect(() => {
if (shouldAutoRedirect) {
if (isStaticMode && iamSdk) {
console.log('Auto-redirecting to Hanzo IAM...');
iamSdk.signinRedirect();
} else if (startupConfig?.serverDomain) {
console.log('Auto-redirecting to OpenID provider...');
window.location.href = `${startupConfig.serverDomain}/oauth/openid`;
}
getHanzoIamSdk().signinRedirect();
}
}, [shouldAutoRedirect, startupConfig, isStaticMode, iamSdk]);
}, [shouldAutoRedirect]);
// Render fallback UI if auto-redirect is active.
if (shouldAutoRedirect) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<p className="text-lg font-semibold">
{isStaticMode
? 'Redirecting to Hanzo...'
: localize('com_ui_redirecting_to_provider', { 0: startupConfig?.openidLabel })}
</p>
{!isStaticMode && startupConfig && (
<div className="mt-4">
<SocialButton
key="openid"
enabled={startupConfig.openidLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="openid"
Icon={() =>
startupConfig.openidImageUrl ? (
<img src={startupConfig.openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
) : (
<OpenIDIcon />
)
}
label={startupConfig.openidLabel}
id="openid"
/>
</div>
)}
<p className="text-lg font-semibold">Redirecting to Hanzo...</p>
</div>
);
}
return (
<>
{error != null && <ErrorMessage>{localize(getLoginError(error))}</ErrorMessage>}
{/* Hanzo IAM button (static mode) */}
{isStaticMode && iamSdk && (
<div className="mt-4">
<button
type="button"
onClick={handleIamLogin}
className="flex w-full items-center justify-center space-x-3 rounded-2xl border border-border-light bg-surface-primary px-5 py-3 text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
<p>Sign in with Hanzo</p>
</button>
</div>
)}
{/* Email/password form (non-static mode or as fallback) */}
{startupConfig?.emailLoginEnabled === true && !isStaticMode && (
<LoginForm
onSubmit={login}
startupConfig={startupConfig}
error={error}
setError={setError}
/>
)}
{startupConfig?.registrationEnabled === true && !isStaticMode && (
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
{' '}
{localize('com_auth_no_account')}{' '}
<a
href={registerPage()}
className="inline-flex p-1 text-sm font-medium text-green-600 underline decoration-transparent transition-all duration-200 hover:text-green-700 hover:decoration-green-700 focus:text-green-700 focus:decoration-green-700 dark:text-green-500 dark:hover:text-green-400 dark:hover:decoration-green-400 dark:focus:text-green-400 dark:focus:decoration-green-400"
>
{localize('com_auth_sign_up')}
</a>
</p>
)}
</>
<div className="mt-4">
<button
type="button"
aria-label="Log in with Hanzo"
onClick={handleIamLogin}
className="flex w-full items-center justify-center space-x-3 rounded-2xl border border-border-light bg-surface-primary px-5 py-3 text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
<p>Log in with Hanzo</p>
</button>
</div>
);
}
-188
View File
@@ -1,188 +0,0 @@
import React, { useState, useEffect, useContext } from 'react';
import { useForm } from 'react-hook-form';
import { Turnstile } from '@marsidev/react-turnstile';
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
import type { TAuthContext } from '~/common';
import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider';
import { validateEmail } from '~/utils';
import { useLocalize } from '~/hooks';
type TLoginFormProps = {
onSubmit: (data: TLoginUser) => void;
startupConfig: TStartupConfig;
error: Pick<TAuthContext, 'error'>['error'];
setError: Pick<TAuthContext, 'setError'>['setError'];
};
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => {
const localize = useLocalize();
const { theme } = useContext(ThemeContext);
const {
register,
getValues,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TLoginUser>();
const [showResendLink, setShowResendLink] = useState<boolean>(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const { data: config } = useGetStartupConfig();
const useUsernameLogin = config?.ldap?.username;
const validTheme = isDark(theme) ? 'dark' : 'light';
const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey);
useEffect(() => {
if (error && error.includes('422') && !showResendLink) {
setShowResendLink(true);
}
}, [error, showResendLink]);
const resendLinkMutation = useResendVerificationEmail({
onMutate: () => {
setError(undefined);
setShowResendLink(false);
},
});
if (!startupConfig) {
return null;
}
const renderError = (fieldName: string) => {
const errorMessage = errors[fieldName]?.message;
return errorMessage ? (
<span role="alert" className="mt-1 text-sm text-red-600 dark:text-red-500">
{String(errorMessage)}
</span>
) : null;
};
const handleResendEmail = () => {
const email = getValues('email');
if (!email) {
return setShowResendLink(false);
}
resendLinkMutation.mutate({ email });
};
return (
<>
{showResendLink && (
<div className="mt-2 rounded-md border border-green-500 bg-green-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200">
{localize('com_auth_email_verification_resend_prompt')}
<button
type="button"
className="ml-2 text-blue-600 hover:underline"
onClick={handleResendEmail}
disabled={resendLinkMutation.isLoading}
>
{localize('com_auth_email_resend_link')}
</button>
</div>
)}
<form
className="mt-6"
aria-label="Login form"
method="POST"
onSubmit={handleSubmit((data) => onSubmit(data))}
>
<div className="mb-4">
<div className="relative">
<input
type="text"
id="email"
autoComplete={useUsernameLogin ? 'username' : 'email'}
aria-label={localize('com_auth_email')}
{...register('email', {
required: localize('com_auth_email_required'),
maxLength: { value: 120, message: localize('com_auth_email_max_length') },
validate: useUsernameLogin
? undefined
: (value) => validateEmail(value, localize('com_auth_email_pattern')),
})}
aria-invalid={!!errors.email}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="email"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{useUsernameLogin
? localize('com_auth_username').replace(/ \(.*$/, '')
: localize('com_auth_email_address')}
</label>
</div>
{renderError('email')}
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
id="password"
autoComplete="current-password"
aria-label={localize('com_auth_password')}
{...register('password', {
required: localize('com_auth_password_required'),
minLength: {
value: startupConfig?.minPasswordLength || 8,
message: localize('com_auth_password_min_length'),
},
maxLength: { value: 128, message: localize('com_auth_password_max_length') },
})}
aria-invalid={!!errors.password}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="password"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password')}
</label>
</div>
{renderError('password')}
</div>
{startupConfig.passwordResetEnabled && (
<a
href="/forgot-password"
className="inline-flex p-1 text-sm font-medium text-green-600 underline decoration-transparent transition-all duration-200 hover:text-green-700 hover:decoration-green-700 focus:text-green-700 focus:decoration-green-700 dark:text-green-500 dark:hover:text-green-400 dark:hover:decoration-green-400 dark:focus:text-green-400 dark:focus:decoration-green-400"
>
{localize('com_auth_password_forgot')}
</a>
)}
{requireCaptcha && (
<div className="my-4 flex justify-center">
<Turnstile
siteKey={startupConfig.turnstile!.siteKey}
options={{
...startupConfig.turnstile!.options,
theme: validTheme,
}}
onSuccess={setTurnstileToken}
onError={() => setTurnstileToken(null)}
onExpire={() => setTurnstileToken(null)}
/>
</div>
)}
<div className="mt-6">
<Button
aria-label={localize('com_auth_continue')}
data-testid="login-button"
type="submit"
disabled={(requireCaptcha && !turnstileToken) || isSubmitting}
variant="submit"
className="h-12 w-full rounded-2xl"
>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</Button>
</div>
</form>
</>
);
};
export default LoginForm;
-230
View File
@@ -1,230 +0,0 @@
import { useForm } from 'react-hook-form';
import React, { useContext, useState } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
import type { TRegisterUser, TError } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { useLocalize, TranslationKeys } from '~/hooks';
import { ErrorMessage } from './ErrorMessage';
const Registration: React.FC = () => {
const navigate = useNavigate();
const localize = useLocalize();
const { theme } = useContext(ThemeContext);
const { startupConfig, startupConfigError, isFetching } = useOutletContext<TLoginLayoutContext>();
const {
watch,
register,
handleSubmit,
formState: { errors },
} = useForm<TRegisterUser>({ mode: 'onChange' });
const password = watch('password');
const [errorMessage, setErrorMessage] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [countdown, setCountdown] = useState<number>(3);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const token = queryParams.get('token');
const validTheme = isDark(theme) ? 'dark' : 'light';
// only require captcha if we have a siteKey
const requireCaptcha = Boolean(startupConfig?.turnstile?.siteKey);
const registerUser = useRegisterUserMutation({
onMutate: () => {
setIsSubmitting(true);
},
onSuccess: () => {
setIsSubmitting(false);
setCountdown(3);
const timer = setInterval(() => {
setCountdown((prevCountdown) => {
if (prevCountdown <= 1) {
clearInterval(timer);
navigate('/c/new', { replace: true });
return 0;
} else {
return prevCountdown - 1;
}
});
}, 1000);
},
onError: (error: unknown) => {
setIsSubmitting(false);
if ((error as TError).response?.data?.message) {
setErrorMessage((error as TError).response?.data?.message ?? '');
}
},
});
const renderInput = (id: string, label: TranslationKeys, type: string, validation: object) => (
<div className="mb-4">
<div className="relative">
<input
id={id}
type={type}
autoComplete={id}
aria-label={localize(label)}
{...register(
id as 'name' | 'email' | 'username' | 'password' | 'confirm_password',
validation,
)}
aria-invalid={!!errors[id]}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
data-testid={id}
/>
<label
htmlFor={id}
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize(label)}
</label>
</div>
{errors[id] && (
<span role="alert" className="mt-1 text-sm text-red-500">
{String(errors[id]?.message) ?? ''}
</span>
)}
</div>
);
return (
<>
{errorMessage && (
<ErrorMessage>
{localize('com_auth_error_create')} {errorMessage}
</ErrorMessage>
)}
{registerUser.isSuccess && countdown > 0 && (
<div
className="rounded-md border border-green-500 bg-green-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200"
role="alert"
>
{localize(
startupConfig?.emailEnabled
? 'com_auth_registration_success_generic'
: 'com_auth_registration_success_insecure',
) +
' ' +
localize('com_auth_email_verification_redirecting', { 0: countdown.toString() })}
</div>
)}
{!startupConfigError && !isFetching && (
<>
<form
className="mt-6"
aria-label="Registration form"
method="POST"
onSubmit={handleSubmit((data: TRegisterUser) =>
registerUser.mutate({ ...data, token: token ?? undefined }),
)}
>
{renderInput('name', 'com_auth_full_name', 'text', {
required: localize('com_auth_name_required'),
minLength: {
value: 3,
message: localize('com_auth_name_min_length'),
},
maxLength: {
value: 80,
message: localize('com_auth_name_max_length'),
},
})}
{renderInput('username', 'com_auth_username', 'text', {
minLength: {
value: 2,
message: localize('com_auth_username_min_length'),
},
maxLength: {
value: 80,
message: localize('com_auth_username_max_length'),
},
})}
{renderInput('email', 'com_auth_email', 'email', {
required: localize('com_auth_email_required'),
minLength: {
value: 1,
message: localize('com_auth_email_min_length'),
},
maxLength: {
value: 120,
message: localize('com_auth_email_max_length'),
},
pattern: {
value: /\S+@\S+\.\S+/,
message: localize('com_auth_email_pattern'),
},
})}
{renderInput('password', 'com_auth_password', 'password', {
required: localize('com_auth_password_required'),
minLength: {
value: startupConfig?.minPasswordLength || 8,
message: localize('com_auth_password_min_length'),
},
maxLength: {
value: 128,
message: localize('com_auth_password_max_length'),
},
})}
{renderInput('confirm_password', 'com_auth_password_confirm', 'password', {
validate: (value: string) =>
value === password || localize('com_auth_password_not_match'),
})}
{startupConfig?.turnstile?.siteKey && (
<div className="my-4 flex justify-center">
<Turnstile
siteKey={startupConfig.turnstile.siteKey}
options={{
...startupConfig.turnstile.options,
theme: validTheme,
}}
onSuccess={(token) => setTurnstileToken(token)}
onError={() => setTurnstileToken(null)}
onExpire={() => setTurnstileToken(null)}
/>
</div>
)}
<div className="mt-6">
<Button
disabled={
Object.keys(errors).length > 0 ||
isSubmitting ||
(requireCaptcha && !turnstileToken)
}
type="submit"
aria-label="Submit registration"
variant="submit"
className="h-12 w-full rounded-2xl"
>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</Button>
</div>
</form>
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
{localize('com_auth_already_have_account')}{' '}
<a
href={loginPage()}
aria-label="Login"
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_login')}
</a>
</p>
</>
)}
</>
);
};
export default Registration;
@@ -1,148 +0,0 @@
import { useForm } from 'react-hook-form';
import { useState, ReactNode } from 'react';
import { Spinner, Button } from '@librechat/client';
import { useOutletContext } from 'react-router-dom';
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import type { FC } from 'react';
import { useLocalize } from '~/hooks';
const BodyTextWrapper: FC<{ children: ReactNode }> = ({ children }) => {
return (
<div
className="relative mt-6 rounded-xl border border-green-500/20 bg-green-50/50 px-6 py-4 text-green-700 shadow-sm transition-all dark:bg-green-950/30 dark:text-green-100"
role="alert"
>
{children}
</div>
);
};
const ResetPasswordBodyText = () => {
const localize = useLocalize();
return (
<div className="flex flex-col space-y-4">
<p>{localize('com_auth_reset_password_if_email_exists')}</p>
<a
className="inline-flex text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
href={loginPage()}
>
{localize('com_auth_back_to_login')}
</a>
</div>
);
};
function RequestPasswordReset() {
const localize = useLocalize();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<TRequestPasswordReset>();
const [bodyText, setBodyText] = useState<ReactNode | undefined>(undefined);
const { startupConfig, setHeaderText } = useOutletContext<TLoginLayoutContext>();
const requestPasswordReset = useRequestPasswordResetMutation();
const { isLoading } = requestPasswordReset;
const onSubmit = (data: TRequestPasswordReset) => {
requestPasswordReset.mutate(data, {
onSuccess: (data: TRequestPasswordResetResponse) => {
if (data.link && !startupConfig?.emailEnabled) {
setHeaderText('com_auth_reset_password');
setBodyText(
<span>
{localize('com_auth_click')}{' '}
<a className="text-green-500 hover:underline" href={data.link}>
{localize('com_auth_here')}
</a>{' '}
{localize('com_auth_to_reset_your_password')}
</span>,
);
} else {
setHeaderText('com_auth_reset_password_link_sent');
setBodyText(<ResetPasswordBodyText />);
}
},
onError: () => {
setHeaderText('com_auth_reset_password_link_sent');
setBodyText(<ResetPasswordBodyText />);
},
});
};
if (bodyText) {
return <BodyTextWrapper>{bodyText}</BodyTextWrapper>;
}
return (
<form
className="mt-8 space-y-6"
aria-label="Password reset form"
method="POST"
onSubmit={handleSubmit(onSubmit)}
>
<div className="space-y-2">
<div className="relative">
<input
type="email"
id="email"
autoComplete="off"
aria-label={localize('com_auth_email')}
{...register('email', {
required: localize('com_auth_email_required'),
minLength: {
value: 3,
message: localize('com_auth_email_min_length'),
},
maxLength: {
value: 120,
message: localize('com_auth_email_max_length'),
},
pattern: {
value: /\S+@\S+\.\S+/,
message: localize('com_auth_email_pattern'),
},
})}
aria-invalid={!!errors.email}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="email"
className="absolute -top-2 left-2 z-10 bg-white px-2 text-sm text-gray-600 transition-all peer-placeholder-shown:top-3 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-focus:-top-2 peer-focus:text-sm peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400 dark:peer-focus:text-green-500"
>
{localize('com_auth_email_address')}
</label>
</div>
{errors.email && (
<p role="alert" className="text-sm font-medium text-red-600 dark:text-red-400">
{errors.email.message}
</p>
)}
</div>
<div className="space-y-4">
<Button
aria-label="Continue with password reset"
type="submit"
disabled={!!errors.email || isLoading}
variant="submit"
className="h-12 w-full rounded-2xl"
>
{isLoading ? <Spinner /> : localize('com_auth_continue')}
</Button>
<a
href={loginPage()}
className="block text-center text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_back_to_login')}
</a>
</div>
</form>
);
}
export default RequestPasswordReset;
@@ -1,163 +0,0 @@
import { useForm } from 'react-hook-form';
import { Spinner, Button } from '@librechat/client';
import { useOutletContext } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useResetPasswordMutation } from 'librechat-data-provider/react-query';
import type { TResetPassword } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { useLocalize } from '~/hooks';
function ResetPassword() {
const localize = useLocalize();
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<TResetPassword>();
const navigate = useNavigate();
const [params] = useSearchParams();
const password = watch('password');
const resetPassword = useResetPasswordMutation();
const { setError, setHeaderText, startupConfig } = useOutletContext<TLoginLayoutContext>();
const onSubmit = (data: TResetPassword) => {
resetPassword.mutate(data, {
onError: () => {
setError('com_auth_error_invalid_reset_token');
},
onSuccess: () => {
setHeaderText('com_auth_reset_password_success');
},
});
};
if (resetPassword.isSuccess) {
return (
<>
<div
className="relative mt-6 rounded-xl border border-green-500/20 bg-green-50/50 px-6 py-4 text-green-700 shadow-sm transition-all dark:bg-green-950/30 dark:text-green-100"
role="alert"
>
<div className="flex flex-col space-y-4">
<p>{localize('com_auth_login_with_new_password')}</p>
<Button
onClick={() => navigate('/login')}
aria-label={localize('com_auth_sign_in')}
variant="submit"
>
{localize('com_auth_continue')}
</Button>
</div>
</div>
</>
);
}
return (
<form
className="mt-6"
aria-label="Password reset form"
method="POST"
onSubmit={handleSubmit(onSubmit)}
>
<div className="mb-2">
<div className="relative">
<input
type="hidden"
id="token"
value={params.get('token') ?? ''}
{...register('token', { required: 'Unable to process: No valid reset token' })}
/>
<input
type="hidden"
id="userId"
value={params.get('userId') ?? ''}
{...register('userId', { required: 'Unable to process: No valid user id' })}
/>
<input
type="password"
id="password"
autoComplete="current-password"
aria-label={localize('com_auth_password')}
{...register('password', {
required: localize('com_auth_password_required'),
minLength: {
value: startupConfig?.minPasswordLength || 8,
message: localize('com_auth_password_min_length'),
},
maxLength: {
value: 128,
message: localize('com_auth_password_max_length'),
},
})}
aria-invalid={!!errors.password}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="password"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password')}
</label>
</div>
{errors.password && (
<span role="alert" className="mt-1 text-sm text-red-500 dark:text-red-900">
{errors.password.message}
</span>
)}
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
id="confirm_password"
aria-label={localize('com_auth_password_confirm')}
{...register('confirm_password', {
validate: (value) => value === password || localize('com_auth_password_not_match'),
})}
aria-invalid={!!errors.confirm_password}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="confirm_password"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password_confirm')}
</label>
</div>
{errors.confirm_password && (
<span role="alert" className="mt-1 text-sm text-red-500 dark:text-red-900">
{errors.confirm_password.message}
</span>
)}
{errors.token && (
<span role="alert" className="mt-1 text-sm text-red-500 dark:text-red-900">
{errors.token.message}
</span>
)}
{errors.userId && (
<span role="alert" className="mt-1 text-sm text-red-500 dark:text-red-900">
{errors.userId.message}
</span>
)}
</div>
<div className="mt-6">
<Button
type="submit"
aria-label={localize('com_auth_submit_registration')}
disabled={!!errors.password || !!errors.confirm_password || isSubmitting}
variant="submit"
className="h-12 w-full rounded-2xl"
>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</Button>
</div>
</form>
);
}
export default ResetPassword;
@@ -1,23 +0,0 @@
import React from 'react';
const SocialButton = ({ id, enabled, serverDomain, oauthPath, Icon, label }) => {
if (!enabled) {
return null;
}
return (
<div className="mt-2 flex gap-x-2">
<a
aria-label={`${label}`}
className="flex w-full items-center space-x-3 rounded-2xl border border-border-light bg-surface-primary px-5 py-3 text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
href={`${serverDomain}/oauth/${oauthPath}`}
data-testid={id}
>
<Icon />
<p>{label}</p>
</a>
</div>
);
};
export default SocialButton;
@@ -1,141 +0,0 @@
import {
GoogleIcon,
FacebookIcon,
OpenIDIcon,
GithubIcon,
DiscordIcon,
AppleIcon,
SamlIcon,
} from '@librechat/client';
import SocialButton from './SocialButton';
import { useLocalize } from '~/hooks';
import { TStartupConfig } from 'librechat-data-provider';
function SocialLoginRender({
startupConfig,
}: {
startupConfig: TStartupConfig | null | undefined;
}) {
const localize = useLocalize();
if (!startupConfig) {
return null;
}
const providerComponents = {
discord: startupConfig.discordLoginEnabled && (
<SocialButton
key="discord"
enabled={startupConfig.discordLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="discord"
Icon={DiscordIcon}
label={localize('com_auth_discord_login')}
id="discord"
/>
),
facebook: startupConfig.facebookLoginEnabled && (
<SocialButton
key="facebook"
enabled={startupConfig.facebookLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="facebook"
Icon={FacebookIcon}
label={localize('com_auth_facebook_login')}
id="facebook"
/>
),
github: startupConfig.githubLoginEnabled && (
<SocialButton
key="github"
enabled={startupConfig.githubLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="github"
Icon={GithubIcon}
label={localize('com_auth_github_login')}
id="github"
/>
),
google: startupConfig.googleLoginEnabled && (
<SocialButton
key="google"
enabled={startupConfig.googleLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="google"
Icon={GoogleIcon}
label={localize('com_auth_google_login')}
id="google"
/>
),
apple: startupConfig.appleLoginEnabled && (
<SocialButton
key="apple"
enabled={startupConfig.appleLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="apple"
Icon={AppleIcon}
label={localize('com_auth_apple_login')}
id="apple"
/>
),
openid: startupConfig.openidLoginEnabled && (
<SocialButton
key="openid"
enabled={startupConfig.openidLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="openid"
Icon={() =>
startupConfig.openidImageUrl ? (
<img src={startupConfig.openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
) : (
<OpenIDIcon />
)
}
label={startupConfig.openidLabel}
id="openid"
/>
),
saml: startupConfig.samlLoginEnabled && (
<SocialButton
key="saml"
enabled={startupConfig.samlLoginEnabled}
serverDomain={startupConfig.serverDomain}
oauthPath="saml"
Icon={() =>
startupConfig.samlImageUrl ? (
<img src={startupConfig.samlImageUrl} alt="SAML Logo" className="h-5 w-5" />
) : (
<SamlIcon />
)
}
label={startupConfig.samlLabel ? startupConfig.samlLabel : localize('com_auth_saml_login')}
id="saml"
/>
),
};
return (
startupConfig.socialLoginEnabled && (
<>
{startupConfig.emailLoginEnabled && (
<>
<div className="relative mt-6 flex w-full items-center justify-center border border-t border-gray-300 uppercase dark:border-gray-600">
<div className="absolute bg-white px-3 text-xs text-black dark:bg-gray-900 dark:text-white">
Or
</div>
</div>
<div className="mt-8" />
</>
)}
<div className="mt-2">
{startupConfig.socialLogins?.map((provider) => providerComponents[provider] || null)}
</div>
</>
)
);
}
export default SocialLoginRender;
@@ -1,206 +0,0 @@
import reactRouter from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { getByTestId, render, waitFor } from 'test/layout-test-utils';
import type { TStartupConfig } from 'librechat-data-provider';
import * as endpointQueries from '~/data-provider/Endpoints/queries';
import * as miscDataProvider from '~/data-provider/Misc/queries';
import * as authMutations from '~/data-provider/Auth/mutations';
import * as authQueries from '~/data-provider/Auth/queries';
import AuthLayout from '~/components/Auth/AuthLayout';
import Login from '~/components/Auth/Login';
jest.mock('librechat-data-provider/react-query');
const mockStartupConfig = {
isFetching: false,
isLoading: false,
isError: false,
data: {
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord', 'saml'],
discordLoginEnabled: true,
facebookLoginEnabled: true,
githubLoginEnabled: true,
googleLoginEnabled: true,
openidLoginEnabled: true,
openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com',
samlLoginEnabled: true,
samlLabel: 'Test SAML',
samlImageUrl: 'http://test-server.com',
ldap: {
enabled: false,
},
registrationEnabled: true,
emailLoginEnabled: true,
socialLoginEnabled: true,
serverDomain: 'mock-server',
},
};
const setup = ({
useGetUserQueryReturnValue = {
isLoading: false,
isError: false,
data: {},
},
useLoginUserReturnValue = {
isLoading: false,
isError: false,
mutate: jest.fn(),
data: {},
isSuccess: false,
},
useRefreshTokenMutationReturnValue = {
isLoading: false,
isError: false,
mutate: jest.fn(),
data: {
token: 'mock-token',
user: {},
},
},
useGetStartupConfigReturnValue = mockStartupConfig,
useGetBannerQueryReturnValue = {
isLoading: false,
isError: false,
data: {},
},
} = {}) => {
const mockUseLoginUser = jest
.spyOn(authMutations, 'useLoginUserMutation')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useLoginUserReturnValue);
const mockUseGetUserQuery = jest
.spyOn(authQueries, 'useGetUserQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetUserQueryReturnValue);
const mockUseGetStartupConfig = jest
.spyOn(endpointQueries, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupConfigReturnValue);
const mockUseRefreshTokenMutation = jest
.spyOn(authMutations, 'useRefreshTokenMutation')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useRefreshTokenMutationReturnValue);
const mockUseGetBannerQuery = jest
.spyOn(miscDataProvider, 'useGetBannerQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetBannerQueryReturnValue);
const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({
startupConfig: useGetStartupConfigReturnValue.data,
});
const renderResult = render(
<AuthLayout
startupConfig={useGetStartupConfigReturnValue.data as TStartupConfig}
isFetching={useGetStartupConfigReturnValue.isFetching}
error={null}
startupConfigError={null}
header={'Welcome back'}
pathname="login"
>
<Login />
</AuthLayout>,
);
return {
...renderResult,
mockUseLoginUser,
mockUseGetUserQuery,
mockUseOutletContext,
mockUseGetStartupConfig,
mockUseRefreshTokenMutation,
mockUseGetBannerQuery,
};
};
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useOutletContext: () => ({
startupConfig: mockStartupConfig,
}),
}));
test('renders login form', () => {
const { getByLabelText, getByRole } = setup();
expect(getByLabelText(/email/i)).toBeInTheDocument();
expect(getByLabelText(/password/i)).toBeInTheDocument();
expect(getByTestId(document.body, 'login-button')).toBeInTheDocument();
expect(getByRole('link', { name: /Sign up/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Sign up/i })).toHaveAttribute('href', '/register');
expect(getByRole('link', { name: /Continue with Google/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Continue with Google/i })).toHaveAttribute(
'href',
'mock-server/oauth/google',
);
expect(getByRole('link', { name: /Continue with Facebook/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Continue with Facebook/i })).toHaveAttribute(
'href',
'mock-server/oauth/facebook',
);
expect(getByRole('link', { name: /Continue with Github/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Continue with Github/i })).toHaveAttribute(
'href',
'mock-server/oauth/github',
);
expect(getByRole('link', { name: /Continue with Discord/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Continue with Discord/i })).toHaveAttribute(
'href',
'mock-server/oauth/discord',
);
expect(getByRole('link', { name: /Test SAML/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Test SAML/i })).toHaveAttribute(
'href',
'mock-server/oauth/saml',
);
});
test('calls loginUser.mutate on login', async () => {
const mutate = jest.fn();
const { getByLabelText } = setup({
// @ts-ignore - we don't need all parameters of the QueryObserverResult
useLoginUserReturnValue: {
isLoading: false,
mutate: mutate,
isError: false,
},
});
const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i);
const submitButton = getByTestId(document.body, 'login-button');
await userEvent.type(emailInput, 'test@test.com');
await userEvent.type(passwordInput, 'password');
await userEvent.click(submitButton);
waitFor(() => expect(mutate).toHaveBeenCalled());
});
test('Navigates to / on successful login', async () => {
const { getByLabelText, history } = setup({
// @ts-ignore - we don't need all parameters of the QueryObserverResult
useLoginUserReturnValue: {
isLoading: false,
mutate: jest.fn(),
isError: false,
isSuccess: true,
},
useGetStartupConfigReturnValue: {
...mockStartupConfig,
data: {
...mockStartupConfig.data,
emailLoginEnabled: true,
registrationEnabled: true,
},
},
});
const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i);
const submitButton = getByTestId(document.body, 'login-button');
await userEvent.type(emailInput, 'test@test.com');
await userEvent.type(passwordInput, 'password');
await userEvent.click(submitButton);
waitFor(() => expect(history.location.pathname).toBe('/'));
});
@@ -1,144 +0,0 @@
import { render, getByTestId } from 'test/layout-test-utils';
import userEvent from '@testing-library/user-event';
import type { TStartupConfig } from 'librechat-data-provider';
import * as endpointQueries from '~/data-provider/Endpoints/queries';
import * as miscDataProvider from '~/data-provider/Misc/queries';
import * as authMutations from '~/data-provider/Auth/mutations';
import * as authQueries from '~/data-provider/Auth/queries';
import Login from '../LoginForm';
jest.mock('librechat-data-provider/react-query');
const mockLogin = jest.fn();
const mockStartupConfig: TStartupConfig = {
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord', 'saml'],
discordLoginEnabled: true,
facebookLoginEnabled: true,
githubLoginEnabled: true,
googleLoginEnabled: true,
openidLoginEnabled: true,
openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com',
samlLoginEnabled: true,
samlLabel: 'Test SAML',
samlImageUrl: 'http://test-server.com',
registrationEnabled: true,
emailLoginEnabled: true,
socialLoginEnabled: true,
passwordResetEnabled: true,
serverDomain: 'mock-server',
appTitle: '',
ldap: {
enabled: false,
},
emailEnabled: false,
checkBalance: false,
showBirthdayIcon: false,
helpAndFaqURL: '',
};
const setup = ({
useGetUserQueryReturnValue = {
isLoading: false,
isError: false,
data: {},
},
useLoginUserReturnValue = {
isLoading: false,
isError: false,
mutate: jest.fn(),
data: {},
isSuccess: false,
},
useRefreshTokenMutationReturnValue = {
isLoading: false,
isError: false,
mutate: jest.fn(),
data: {
token: 'mock-token',
user: {},
},
},
useGetStartupConfigReturnValue = {
isLoading: false,
isError: false,
data: mockStartupConfig,
},
useGetBannerQueryReturnValue = {
isLoading: false,
isError: false,
data: {},
},
} = {}) => {
const mockUseLoginUser = jest
.spyOn(authMutations, 'useLoginUserMutation')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useLoginUserReturnValue);
const mockUseGetUserQuery = jest
.spyOn(authQueries, 'useGetUserQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetUserQueryReturnValue);
const mockUseGetStartupConfig = jest
.spyOn(endpointQueries, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupConfigReturnValue);
const mockUseRefreshTokenMutation = jest
.spyOn(authMutations, 'useRefreshTokenMutation')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useRefreshTokenMutationReturnValue);
const mockUseGetBannerQuery = jest
.spyOn(miscDataProvider, 'useGetBannerQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetBannerQueryReturnValue);
return {
mockUseLoginUser,
mockUseGetUserQuery,
mockUseGetStartupConfig,
mockUseRefreshTokenMutation,
mockUseGetBannerQuery,
};
};
beforeEach(() => {
setup();
});
test('renders login form', () => {
const { getByLabelText } = render(
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
);
expect(getByLabelText(/email/i)).toBeInTheDocument();
expect(getByLabelText(/password/i)).toBeInTheDocument();
});
test('submits login form', async () => {
const { getByLabelText, getByRole } = render(
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
);
const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i);
const submitButton = getByTestId(document.body, 'login-button');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(passwordInput, 'password');
await userEvent.click(submitButton);
expect(mockLogin).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password' });
});
test('displays validation error messages', async () => {
const { getByLabelText, getByRole, getByText } = render(
<Login onSubmit={mockLogin} startupConfig={mockStartupConfig} />,
);
const emailInput = getByLabelText(/email/i);
const passwordInput = getByLabelText(/password/i);
const submitButton = getByTestId(document.body, 'login-button');
await userEvent.type(emailInput, 'test');
await userEvent.type(passwordInput, 'pass');
await userEvent.click(submitButton);
expect(getByText(/You must enter a valid email address/i)).toBeInTheDocument();
expect(getByText(/Password must be at least 8 characters/i)).toBeInTheDocument();
});
@@ -1,231 +0,0 @@
import reactRouter from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { render, waitFor, screen } from 'test/layout-test-utils';
import * as mockDataProvider from 'librechat-data-provider/react-query';
import type { TStartupConfig } from 'librechat-data-provider';
import * as miscDataProvider from '~/data-provider/Misc/queries';
import * as endpointQueries from '~/data-provider/Endpoints/queries';
import * as authMutations from '~/data-provider/Auth/mutations';
import * as authQueries from '~/data-provider/Auth/queries';
import Registration from '~/components/Auth/Registration';
import AuthLayout from '~/components/Auth/AuthLayout';
jest.mock('librechat-data-provider/react-query');
const mockStartupConfig = {
isFetching: false,
isLoading: false,
isError: false,
data: {
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord', 'saml'],
discordLoginEnabled: true,
facebookLoginEnabled: true,
githubLoginEnabled: true,
googleLoginEnabled: true,
openidLoginEnabled: true,
openidLabel: 'Test OpenID',
openidImageUrl: 'http://test-server.com',
samlLoginEnabled: true,
samlLabel: 'Test SAML',
samlImageUrl: 'http://test-server.com',
registrationEnabled: true,
socialLoginEnabled: true,
serverDomain: 'mock-server',
},
};
const setup = ({
useGetUserQueryReturnValue = {
isLoading: false,
isError: false,
data: {},
},
useRegisterUserMutationReturnValue = {
isLoading: false,
isError: false,
mutate: jest.fn(),
data: {},
isSuccess: false,
error: null as Error | null,
},
useRefreshTokenMutationReturnValue = {
isLoading: false,
isError: false,
mutate: jest.fn(),
data: {
token: 'mock-token',
user: {},
},
},
useGetBannerQueryReturnValue = {
isLoading: false,
isError: false,
data: {},
},
useGetStartupConfigReturnValue = mockStartupConfig,
} = {}) => {
const mockUseRegisterUserMutation = jest
.spyOn(mockDataProvider, 'useRegisterUserMutation')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useRegisterUserMutationReturnValue);
const mockUseGetUserQuery = jest
.spyOn(authQueries, 'useGetUserQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetUserQueryReturnValue);
const mockUseGetStartupConfig = jest
.spyOn(endpointQueries, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupConfigReturnValue);
const mockUseRefreshTokenMutation = jest
.spyOn(authMutations, 'useRefreshTokenMutation')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useRefreshTokenMutationReturnValue);
const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({
startupConfig: useGetStartupConfigReturnValue.data,
});
const mockUseGetBannerQuery = jest
.spyOn(miscDataProvider, 'useGetBannerQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetBannerQueryReturnValue);
const renderResult = render(
<AuthLayout
startupConfig={useGetStartupConfigReturnValue.data as TStartupConfig}
isFetching={useGetStartupConfigReturnValue.isFetching}
error={null}
startupConfigError={null}
header={'Create your account'}
pathname="register"
>
<Registration />
</AuthLayout>,
);
return {
...renderResult,
mockUseGetUserQuery,
mockUseOutletContext,
mockUseGetStartupConfig,
mockUseRegisterUserMutation,
mockUseRefreshTokenMutation,
};
};
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useOutletContext: () => ({
startupConfig: mockStartupConfig,
}),
}));
test('renders registration form', () => {
const { getByText, getByTestId, getByRole } = setup();
expect(getByText(/Create your account/i)).toBeInTheDocument();
expect(getByRole('textbox', { name: /Full name/i })).toBeInTheDocument();
expect(getByRole('form', { name: /Registration form/i })).toBeVisible();
expect(getByRole('textbox', { name: /Username/i })).toBeInTheDocument();
expect(getByRole('textbox', { name: /Email/i })).toBeInTheDocument();
expect(getByTestId('password')).toBeInTheDocument();
expect(getByTestId('confirm_password')).toBeInTheDocument();
expect(getByRole('button', { name: /Submit registration/i })).toBeInTheDocument();
expect(getByRole('link', { name: 'Login' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Login' })).toHaveAttribute('href', '/login');
expect(getByRole('link', { name: /Continue with Google/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Continue with Google/i })).toHaveAttribute(
'href',
'mock-server/oauth/google',
);
expect(getByRole('link', { name: /Continue with Facebook/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Continue with Facebook/i })).toHaveAttribute(
'href',
'mock-server/oauth/facebook',
);
expect(getByRole('link', { name: /Continue with Github/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Continue with Github/i })).toHaveAttribute(
'href',
'mock-server/oauth/github',
);
expect(getByRole('link', { name: /Continue with Discord/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Continue with Discord/i })).toHaveAttribute(
'href',
'mock-server/oauth/discord',
);
expect(getByRole('link', { name: /Test SAML/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Test SAML/i })).toHaveAttribute(
'href',
'mock-server/oauth/saml',
);
});
// test('calls registerUser.mutate on registration', async () => {
// const mutate = jest.fn();
// const { getByTestId, getByRole, history } = setup({
// // @ts-ignore - we don't need all parameters of the QueryObserverResult
// useLoginUserReturnValue: {
// isLoading: false,
// mutate: mutate,
// isError: false,
// isSuccess: true,
// },
// });
// await userEvent.type(getByRole('textbox', { name: /Full name/i }), 'John Doe');
// await userEvent.type(getByRole('textbox', { name: /Username/i }), 'johndoe');
// await userEvent.type(getByRole('textbox', { name: /Email/i }), 'test@test.com');
// await userEvent.type(getByTestId('password'), 'password');
// await userEvent.type(getByTestId('confirm_password'), 'password');
// await userEvent.click(getByRole('button', { name: /Submit registration/i }));
// console.log(history);
// waitFor(() => {
// // expect(mutate).toHaveBeenCalled();
// expect(history.location.pathname).toBe('/c/new');
// });
// });
test('shows validation error messages', async () => {
const { getByTestId, getAllByRole, getByRole } = setup();
await userEvent.type(getByRole('textbox', { name: /Full name/i }), 'J');
await userEvent.type(getByRole('textbox', { name: /Username/i }), 'j');
await userEvent.type(getByRole('textbox', { name: /Email/i }), 'test');
await userEvent.type(getByTestId('password'), 'pass');
await userEvent.type(getByTestId('confirm_password'), 'password1');
const alerts = getAllByRole('alert');
expect(alerts).toHaveLength(6);
// This first alert is for the theme toggle, which is empty within this test but still picked up by getAllByRole as an alert
expect(alerts[0]).toHaveTextContent('');
expect(alerts[1]).toHaveTextContent(/Name must be at least 3 characters/i);
expect(alerts[2]).toHaveTextContent(/Username must be at least 2 characters/i);
expect(alerts[3]).toHaveTextContent(/You must enter a valid email address/i);
expect(alerts[4]).toHaveTextContent(/Password must be at least 8 characters/i);
expect(alerts[5]).toHaveTextContent(/Passwords do not match/i);
});
test('shows error message when registration fails', async () => {
const mutate = jest.fn();
const { getByTestId, getByRole } = setup({
useRegisterUserMutationReturnValue: {
isLoading: false,
isError: true,
mutate,
error: new Error('Registration failed'),
data: {},
isSuccess: false,
},
});
await userEvent.type(getByRole('textbox', { name: /Full name/i }), 'John Doe');
await userEvent.type(getByRole('textbox', { name: /Username/i }), 'johndoe');
await userEvent.type(getByRole('textbox', { name: /Email/i }), 'test@test.com');
await userEvent.type(getByTestId('password'), 'password');
await userEvent.type(getByTestId('confirm_password'), 'password');
await userEvent.click(getByRole('button', { name: /Submit registration/i }));
waitFor(() => {
expect(screen.getByTestId('registration-error')).toBeInTheDocument();
expect(screen.getByTestId('registration-error')).toHaveTextContent(
/There was an error attempting to register your account. Please try again. Registration failed/i,
);
});
});
-3
View File
@@ -1,7 +1,4 @@
export { default as Login } from './Login';
export { default as Registration } from './Registration';
export { default as ResetPassword } from './ResetPassword';
export { default as VerifyEmail } from './VerifyEmail';
export { default as ApiErrorWatcher } from './ApiErrorWatcher';
export { default as RequestPasswordReset } from './RequestPasswordReset';
export { default as TwoFactorScreen } from './TwoFactorScreen';
+6 -14
View File
@@ -136,21 +136,13 @@ const thirdPartyModels = [
export default function LandingPage() {
const { data: config } = useGetStartupConfig();
const serverDomain = config?.serverDomain || '';
const iamSdk = getHanzoIamSdk();
// Login is the @hanzo/iam redirect-PKCE flow.
const loginHref = '#';
// When IAM SDK is available, login is an async redirect (PKCE).
// Otherwise fall back to the backend OAuth endpoint.
const loginHref = iamSdk ? '#' : `${serverDomain}/oauth/openid`;
const handleLoginClick = useCallback(
(e: React.MouseEvent) => {
if (iamSdk) {
e.preventDefault();
iamSdk.signinRedirect();
}
},
[iamSdk],
);
const handleLoginClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
getHanzoIamSdk().signinRedirect();
}, []);
return (
<div
-23
View File
@@ -2,11 +2,8 @@ import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom';
import {
Login,
VerifyEmail,
Registration,
ResetPassword,
ApiErrorWatcher,
TwoFactorScreen,
RequestPasswordReset,
} from '~/components/Auth';
import OAuthCallback from '~/components/Auth/OAuthCallback';
import { MarketplaceProvider } from '~/components/Agents/MarketplaceContext';
@@ -15,7 +12,6 @@ import { OAuthSuccess, OAuthError } from '~/components/OAuth';
import { AuthContextProvider } from '~/hooks/AuthContext';
import RouteErrorBoundary from './RouteErrorBoundary';
import BuildRoute from './BuildRoute';
import StartupLayout from './Layouts/Startup';
import LoginLayout from './Layouts/Login';
import dashboardRoutes from './Dashboard';
import ShareRoute from './ShareRoute';
@@ -68,25 +64,6 @@ export const router = createBrowserRouter(
},
],
},
{
path: '/',
element: <StartupLayout />,
errorElement: <RouteErrorBoundary />,
children: [
{
path: 'register',
element: <Registration />,
},
{
path: 'forgot-password',
element: <RequestPasswordReset />,
},
{
path: 'reset-password',
element: <ResetPassword />,
},
],
},
{
path: 'verify',
element: <VerifyEmail />,
+15 -24
View File
@@ -1,31 +1,29 @@
/**
* Shared BrowserIamSdk singleton for Hanzo IAM OIDC flows.
* Shared browser IAM SDK singleton for Hanzo IAM PKCE login.
*
* Reads VITE_HANZO_IAM_URL, VITE_HANZO_IAM_APP, and VITE_HANZO_API_URL
* from the Vite environment. Returns null when IAM is not configured
* (i.e. the app is running in backend-proxied mode).
* @hanzo/iam is the single login path. The SDK drives the redirect-PKCE
* authorize + callback exchange; IAM owns every credential step. Config is
* read from the Vite environment with production defaults so login works
* out of the box.
*/
import { IAM } from '@hanzo/iam';
let instance: IAM | null = null;
let checked = false;
export function getHanzoIamSdk(): IAM | null {
if (checked) {
const SERVER_URL = import.meta.env.VITE_HANZO_IAM_URL || 'https://iam.hanzo.ai';
const CLIENT_ID = import.meta.env.VITE_HANZO_IAM_APP || 'chat';
const ORGANIZATION = import.meta.env.VITE_HANZO_IAM_ORG || 'hanzo';
/** The single IAM SDK instance driving PKCE login and callback exchange. */
export function getHanzoIamSdk(): IAM {
if (instance) {
return instance;
}
checked = true;
const serverUrl = import.meta.env.VITE_HANZO_IAM_URL;
const clientId = import.meta.env.VITE_HANZO_IAM_APP;
if (!serverUrl || !clientId) {
return null;
}
instance = new IAM({
serverUrl,
clientId,
serverUrl: SERVER_URL,
clientId: CLIENT_ID,
organization: ORGANIZATION,
redirectUri: `${window.location.origin}/auth/callback`,
scope: 'openid profile email',
proxyBaseUrl: import.meta.env.VITE_HANZO_API_URL || undefined,
@@ -33,10 +31,3 @@ export function getHanzoIamSdk(): IAM | null {
return instance;
}
/**
* Whether the app is running in static/IAM mode (VITE_HANZO_API_URL is set).
*/
export function isStaticIamMode(): boolean {
return !!import.meta.env.VITE_HANZO_API_URL;
}