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:
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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 };
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user