diff --git a/.env.example b/.env.example
index 419004125..d4788e71a 100644
--- a/.env.example
+++ b/.env.example
@@ -226,8 +226,10 @@ ALLOW_SOCIAL_LOGIN=false
ALLOW_SOCIAL_REGISTRATION=false
# JWT Secrets
-JWT_SECRET=secret
-JWT_REFRESH_SECRET=secret
+# You should use secure values. The examples given are 32-byte keys (64 characters in hex)
+# Use this replit to generate some quickly: https://replit.com/@daavila/crypto#index.js
+JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef
+JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418
# Google:
# Add your Google Client ID and Secret here, you must register an app with Google Cloud to get these values
@@ -260,8 +262,10 @@ OPENID_BUTTON_LABEL=
OPENID_IMAGE_URL=
# Set the expiration delay for the secure cookie with the JWT token
+# Recommend session expiry to be 15 minutes
# Delay is in millisecond e.g. 7 days is 1000*60*60*24*7
-SESSION_EXPIRY=(1000 * 60 * 60 * 24) * 7
+SESSION_EXPIRY=1000 * 60 * 15
+REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7
# Github:
# Get the Client ID and Secret from your Discord Application
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 6af0ed46d..f3dbecbd4 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -27,6 +27,7 @@ jobs:
E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
+ JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }}
CREDS_KEY: ${{ secrets.CREDS_KEY }}
CREDS_IV: ${{ secrets.CREDS_IV }}
DOMAIN_CLIENT: ${{ secrets.DOMAIN_CLIENT }}
diff --git a/api/models/Session.js b/api/models/Session.js
new file mode 100644
index 000000000..e1b9898bb
--- /dev/null
+++ b/api/models/Session.js
@@ -0,0 +1,59 @@
+const mongoose = require('mongoose');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
+const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7;
+
+const sessionSchema = mongoose.Schema({
+ refreshTokenHash: {
+ type: String,
+ required: true,
+ },
+ expiration: {
+ type: Date,
+ required: true,
+ expires: 0,
+ },
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true,
+ },
+});
+
+sessionSchema.methods.generateRefreshToken = async function () {
+ try {
+ let expiresIn;
+ if (this.expiration) {
+ expiresIn = this.expiration.getTime();
+ } else {
+ expiresIn = Date.now() + expires;
+ this.expiration = new Date(expiresIn);
+ }
+
+ const refreshToken = jwt.sign(
+ {
+ id: this.user,
+ },
+ process.env.JWT_REFRESH_SECRET,
+ { expiresIn: Math.floor((expiresIn - Date.now()) / 1000) },
+ );
+
+ const hash = crypto.createHash('sha256');
+ this.refreshTokenHash = hash.update(refreshToken).digest('hex');
+
+ await this.save();
+
+ return refreshToken;
+ } catch (error) {
+ console.error(
+ 'Error generating refresh token. Have you set a JWT_REFRESH_SECRET in the .env file?\n\n',
+ error,
+ );
+ throw error;
+ }
+};
+
+const Session = mongoose.model('Session', sessionSchema);
+
+module.exports = Session;
diff --git a/api/models/User.js b/api/models/User.js
index 74cba3730..5e84d1545 100644
--- a/api/models/User.js
+++ b/api/models/User.js
@@ -4,20 +4,14 @@ const jwt = require('jsonwebtoken');
const Joi = require('joi');
const DebugControl = require('../utils/debug.js');
const userSchema = require('./schema/userSchema.js');
+const { SESSION_EXPIRY } = process.env ?? {};
+const expires = eval(SESSION_EXPIRY) ?? 1000 * 60 * 15;
function log({ title, parameters }) {
DebugControl.log.functionName(title);
DebugControl.log.parameters(parameters);
}
-//Remove refreshToken from the response
-userSchema.set('toJSON', {
- transform: function (_doc, ret) {
- delete ret.refreshToken;
- return ret;
- },
-});
-
userSchema.methods.toJSON = function () {
return {
id: this._id,
@@ -43,25 +37,11 @@ userSchema.methods.generateToken = function () {
email: this.email,
},
process.env.JWT_SECRET,
- { expiresIn: eval(process.env.SESSION_EXPIRY) },
+ { expiresIn: expires / 1000 },
);
return token;
};
-userSchema.methods.generateRefreshToken = function () {
- const refreshToken = jwt.sign(
- {
- id: this._id,
- username: this.username,
- provider: this.provider,
- email: this.email,
- },
- process.env.JWT_REFRESH_SECRET,
- { expiresIn: eval(process.env.REFRESH_TOKEN_EXPIRY) },
- );
- return refreshToken;
-};
-
userSchema.methods.comparePassword = function (candidatePassword, callback) {
bcrypt.compare(candidatePassword, this.password, (err, isMatch) => {
if (err) {
diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js
index bedf827ba..fbf85bec2 100644
--- a/api/server/controllers/AuthController.js
+++ b/api/server/controllers/AuthController.js
@@ -1,19 +1,27 @@
-const { registerUser, requestPasswordReset, resetPassword } = require('../services/AuthService');
-
-const isProduction = process.env.NODE_ENV === 'production';
+const {
+ registerUser,
+ requestPasswordReset,
+ resetPassword,
+ setAuthTokens,
+} = require('../services/AuthService');
+const jwt = require('jsonwebtoken');
+const Session = require('../../models/Session');
+const User = require('../../models/User');
+const crypto = require('crypto');
+const cookies = require('cookie');
const registrationController = async (req, res) => {
try {
const response = await registerUser(req.body);
if (response.status === 200) {
const { status, user } = response;
- const token = user.generateToken();
- //send token for automatic login
- res.cookie('token', token, {
- expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)),
- httpOnly: false,
- secure: isProduction,
- });
+ let newUser = await User.findOne({ _id: user._id });
+ if (!newUser) {
+ newUser = new User(user);
+ await newUser.save();
+ }
+ const token = await setAuthTokens(user._id, res);
+ res.setHeader('Authorization', `Bearer ${token}`);
res.status(status).send({ user });
} else {
const { status, message } = response;
@@ -61,59 +69,47 @@ const resetPasswordController = async (req, res) => {
}
};
-// const refreshController = async (req, res, next) => {
-// const { signedCookies = {} } = req;
-// const { refreshToken } = signedCookies;
-// TODO
-// if (refreshToken) {
-// try {
-// const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
-// const userId = payload._id;
-// User.findOne({ _id: userId }).then(
-// (user) => {
-// if (user) {
-// // Find the refresh token against the user record in database
-// const tokenIndex = user.refreshToken.findIndex(item => item.refreshToken === refreshToken);
+const refreshController = async (req, res) => {
+ const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
+ if (!refreshToken) {
+ return res.status(200).send('Refresh token not provided');
+ }
-// if (tokenIndex === -1) {
-// res.statusCode = 401;
-// res.send('Unauthorized');
-// } else {
-// const token = req.user.generateToken();
-// // If the refresh token exists, then create new one and replace it.
-// const newRefreshToken = req.user.generateRefreshToken();
-// user.refreshToken[tokenIndex] = { refreshToken: newRefreshToken };
-// user.save((err) => {
-// if (err) {
-// res.statusCode = 500;
-// res.send(err);
-// } else {
-// // setTokenCookie(res, newRefreshToken);
-// const user = req.user.toJSON();
-// res.status(200).send({ token, user });
-// }
-// });
-// }
-// } else {
-// res.statusCode = 401;
-// res.send('Unauthorized');
-// }
-// },
-// err => next(err)
-// );
-// } catch (err) {
-// res.statusCode = 401;
-// res.send('Unauthorized');
-// }
-// } else {
-// res.statusCode = 401;
-// res.send('Unauthorized');
-// }
-// };
+ try {
+ const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
+ const userId = payload.id;
+ const user = await User.findOne({ _id: userId });
+ if (!user) {
+ return res.status(401).send('User not found');
+ }
+
+ if (process.env.NODE_ENV === 'development') {
+ const token = await setAuthTokens(userId, res);
+ const userObj = user.toJSON();
+ return res.status(200).send({ token, user: userObj });
+ }
+
+ // Hash the refresh token
+ const hash = crypto.createHash('sha256');
+ const hashedToken = hash.update(refreshToken).digest('hex');
+
+ // Find the session with the hashed refresh token
+ const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken });
+ if (session && session.expiration > new Date()) {
+ const token = await setAuthTokens(userId, res, session._id);
+ const userObj = user.toJSON();
+ res.status(200).send({ token, user: userObj });
+ } else {
+ res.status(401).send('Refresh token expired or not found for this user');
+ }
+ } catch (err) {
+ res.status(401).send('Invalid refresh token');
+ }
+};
module.exports = {
getUserController,
- // refreshController,
+ refreshController,
registrationController,
resetPasswordRequestController,
resetPasswordController,
diff --git a/api/server/controllers/auth/LoginController.js b/api/server/controllers/auth/LoginController.js
index 0c7cf271f..9c3b556f6 100644
--- a/api/server/controllers/auth/LoginController.js
+++ b/api/server/controllers/auth/LoginController.js
@@ -1,4 +1,5 @@
const User = require('../../../models/User');
+const { setAuthTokens } = require('../../services/AuthService');
const loginController = async (req, res) => {
try {
@@ -10,15 +11,7 @@ const loginController = async (req, res) => {
return res.status(400).json({ message: 'Invalid credentials' });
}
- const token = req.user.generateToken();
- const expires = eval(process.env.SESSION_EXPIRY);
-
- // Add token to cookie
- res.cookie('token', token, {
- expires: new Date(Date.now() + expires),
- httpOnly: false,
- secure: process.env.NODE_ENV === 'production',
- });
+ const token = await setAuthTokens(user._id, res);
return res.status(200).send({ token, user });
} catch (err) {
diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js
index 227ff6ad2..714a6466d 100644
--- a/api/server/controllers/auth/LogoutController.js
+++ b/api/server/controllers/auth/LogoutController.js
@@ -1,12 +1,11 @@
const { logoutUser } = require('../../services/AuthService');
+const cookies = require('cookie');
const logoutController = async (req, res) => {
- const { signedCookies = {} } = req;
- const { refreshToken } = signedCookies;
+ const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
try {
- const logout = await logoutUser(req.user, refreshToken);
+ const logout = await logoutUser(req.user._id, refreshToken);
const { status, message } = logout;
- res.clearCookie('token');
res.clearCookie('refreshToken');
return res.status(status).send({ message });
} catch (err) {
diff --git a/api/server/routes/auth.js b/api/server/routes/auth.js
index ed7d1a68c..1ccbcb34b 100644
--- a/api/server/routes/auth.js
+++ b/api/server/routes/auth.js
@@ -2,7 +2,7 @@ const express = require('express');
const {
resetPasswordRequestController,
resetPasswordController,
- // refreshController,
+ refreshController,
registrationController,
} = require('../controllers/AuthController');
const { loginController } = require('../controllers/auth/LoginController');
@@ -20,7 +20,7 @@ const router = express.Router();
//Local
router.post('/logout', requireJwtAuth, logoutController);
router.post('/login', loginLimiter, requireLocalAuth, loginController);
-// router.post('/refresh', requireJwtAuth, refreshController);
+router.post('/refresh', refreshController);
router.post('/register', registerLimiter, validateRegistration, registrationController);
router.post('/requestPasswordReset', resetPasswordRequestController);
router.post('/resetPassword', resetPasswordController);
diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js
index 069ece7cb..556603e9e 100644
--- a/api/server/routes/oauth.js
+++ b/api/server/routes/oauth.js
@@ -1,17 +1,15 @@
const passport = require('passport');
const express = require('express');
const router = express.Router();
-const { loginLimiter } = require('../middleware');
const config = require('../../../config/loader');
+const { setAuthTokens } = require('../services/AuthService');
const domains = config.domains;
-const isProduction = config.isProduction;
/**
* Google Routes
*/
router.get(
'/google',
- loginLimiter,
passport.authenticate('google', {
scope: ['openid', 'profile', 'email'],
session: false,
@@ -26,20 +24,18 @@ router.get(
session: false,
scope: ['openid', 'profile', 'email'],
}),
- (req, res) => {
- const token = req.user.generateToken();
- res.cookie('token', token, {
- expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)),
- httpOnly: false,
- secure: isProduction,
- });
- res.redirect(domains.client);
+ async (req, res) => {
+ try {
+ await setAuthTokens(req.user._id, res);
+ res.redirect(domains.client);
+ } catch (err) {
+ console.error('Error in setting authentication tokens:', err);
+ }
},
);
router.get(
'/facebook',
- loginLimiter,
passport.authenticate('facebook', {
scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
@@ -56,20 +52,18 @@ router.get(
scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
}),
- (req, res) => {
- const token = req.user.generateToken();
- res.cookie('token', token, {
- expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)),
- httpOnly: false,
- secure: isProduction,
- });
- res.redirect(domains.client);
+ async (req, res) => {
+ try {
+ await setAuthTokens(req.user._id, res);
+ res.redirect(domains.client);
+ } catch (err) {
+ console.error('Error in setting authentication tokens:', err);
+ }
},
);
router.get(
'/openid',
- loginLimiter,
passport.authenticate('openid', {
session: false,
}),
@@ -82,20 +76,18 @@ router.get(
failureMessage: true,
session: false,
}),
- (req, res) => {
- const token = req.user.generateToken();
- res.cookie('token', token, {
- expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)),
- httpOnly: false,
- secure: isProduction,
- });
- res.redirect(domains.client);
+ async (req, res) => {
+ try {
+ await setAuthTokens(req.user._id, res);
+ res.redirect(domains.client);
+ } catch (err) {
+ console.error('Error in setting authentication tokens:', err);
+ }
},
);
router.get(
'/github',
- loginLimiter,
passport.authenticate('github', {
scope: ['user:email', 'read:user'],
session: false,
@@ -110,20 +102,17 @@ router.get(
session: false,
scope: ['user:email', 'read:user'],
}),
- (req, res) => {
- const token = req.user.generateToken();
- res.cookie('token', token, {
- expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)),
- httpOnly: false,
- secure: isProduction,
- });
- res.redirect(domains.client);
+ async (req, res) => {
+ try {
+ await setAuthTokens(req.user._id, res);
+ res.redirect(domains.client);
+ } catch (err) {
+ console.error('Error in setting authentication tokens:', err);
+ }
},
);
-
router.get(
'/discord',
- loginLimiter,
passport.authenticate('discord', {
scope: ['identify', 'email'],
session: false,
@@ -138,14 +127,13 @@ router.get(
session: false,
scope: ['identify', 'email'],
}),
- (req, res) => {
- const token = req.user.generateToken();
- res.cookie('token', token, {
- expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)),
- httpOnly: false,
- secure: isProduction,
- });
- res.redirect(domains.client);
+ async (req, res) => {
+ try {
+ await setAuthTokens(req.user._id, res);
+ res.redirect(domains.client);
+ } catch (err) {
+ console.error('Error in setting authentication tokens:', err);
+ }
},
);
diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js
index 52380bc8a..a54053f05 100644
--- a/api/server/services/AuthService.js
+++ b/api/server/services/AuthService.js
@@ -1,32 +1,36 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const User = require('../../models/User');
+const Session = require('../../models/Session');
const Token = require('../../models/schema/tokenSchema');
const { registerSchema } = require('../../strategies/validators');
const config = require('../../../config/loader');
const { sendEmail } = require('../utils');
const domains = config.domains;
+const isProduction = config.isProduction;
/**
* Logout user
*
- * @param {Object} user
+ * @param {String} userId
* @param {*} refreshToken
* @returns
*/
-const logoutUser = async (user, refreshToken) => {
+const logoutUser = async (userId, refreshToken) => {
try {
- const userFound = await User.findById(user._id);
- const tokenIndex = userFound.refreshToken.findIndex(
- (item) => item.refreshToken === refreshToken,
- );
+ const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
- if (tokenIndex !== -1) {
- userFound.refreshToken.id(userFound.refreshToken[tokenIndex]._id).remove();
+ // Find the session with the matching user and refreshTokenHash
+ const session = await Session.findOne({ user: userId, refreshTokenHash: hash });
+ if (session) {
+ try {
+ await Session.deleteOne({ _id: session._id });
+ } catch (deleteErr) {
+ console.error(deleteErr);
+ return { status: 500, message: 'Failed to delete session.' };
+ }
}
- await userFound.save();
-
return { status: 200, message: 'Logout successful' };
} catch (err) {
return { status: 500, message: err.message };
@@ -83,9 +87,6 @@ const registerUser = async (user) => {
role: isFirstRegisteredUser ? 'ADMIN' : 'USER',
});
- // todo: implement refresh token
- // const refreshToken = newUser.generateRefreshToken();
- // newUser.refreshToken.push({ refreshToken });
const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync(newUser.password, salt);
newUser.password = hash;
@@ -188,9 +189,51 @@ const resetPassword = async (userId, token, password) => {
return { message: 'Password reset was successful' };
};
+/**
+ * Set Auth Tokens
+ *
+ * @param {String} userId
+ * @param {Object} res
+ * @param {String} sessionId
+ * @returns
+ */
+const setAuthTokens = async (userId, res, sessionId = null) => {
+ try {
+ const user = await User.findOne({ _id: userId });
+ const token = await user.generateToken();
+
+ let session;
+ let refreshTokenExpires;
+ if (sessionId) {
+ session = await Session.findById(sessionId);
+ refreshTokenExpires = session.expiration.getTime();
+ } else {
+ session = new Session({ user: userId });
+ const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
+ const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7;
+ refreshTokenExpires = Date.now() + expires;
+ }
+
+ const refreshToken = await session.generateRefreshToken();
+
+ res.cookie('refreshToken', refreshToken, {
+ expires: new Date(refreshTokenExpires),
+ httpOnly: true,
+ secure: isProduction,
+ sameSite: 'strict',
+ });
+
+ return token;
+ } catch (error) {
+ console.log('Error in setting authentication tokens:', error);
+ throw error;
+ }
+};
+
module.exports = {
registerUser,
logoutUser,
requestPasswordReset,
resetPassword,
+ setAuthTokens,
};
diff --git a/bun.lockb b/bun.lockb
index 25f9ccd96..a83866beb 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/client/package.json b/client/package.json
index d3c5d012d..df34c5a26 100644
--- a/client/package.json
+++ b/client/package.json
@@ -10,8 +10,8 @@
"preview-prod": "cross-env NODE_ENV=development dotenv -e ../.env -- vite preview",
"test": "cross-env NODE_ENV=test jest --watch",
"test:ci": "cross-env NODE_ENV=test jest --ci",
- "b:build": "NODE_ENV=production bunx --bun vite build",
- "b:dev": "NODE_ENV=development bunx --bun vite"
+ "b:build": "NODE_ENV=production bun vite build",
+ "b:dev": "NODE_ENV=development bun vite"
},
"repository": {
"type": "git",
diff --git a/client/src/components/Auth/__tests__/Login.spec.tsx b/client/src/components/Auth/__tests__/Login.spec.tsx
index 6b8b66f1e..7025779f8 100644
--- a/client/src/components/Auth/__tests__/Login.spec.tsx
+++ b/client/src/components/Auth/__tests__/Login.spec.tsx
@@ -18,6 +18,15 @@ const setup = ({
data: {},
isSuccess: false,
},
+ useRefreshTokenMutationReturnValue = {
+ isLoading: false,
+ isError: false,
+ mutate: jest.fn(),
+ data: {
+ token: 'mock-token',
+ user: {},
+ },
+ },
useGetStartupCongfigReturnValue = {
isLoading: false,
isError: false,
@@ -47,12 +56,17 @@ const setup = ({
.spyOn(mockDataProvider, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupCongfigReturnValue);
+ const mockUseRefreshTokenMutation = jest
+ .spyOn(mockDataProvider, 'useRefreshTokenMutation')
+ //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
+ .mockReturnValue(useRefreshTokenMutationReturnValue);
const renderResult = render();
return {
...renderResult,
mockUseLoginUser,
mockUseGetUserQuery,
mockUseGetStartupConfig,
+ mockUseRefreshTokenMutation,
};
};
diff --git a/client/src/components/Auth/__tests__/Registration.spec.tsx b/client/src/components/Auth/__tests__/Registration.spec.tsx
index 0c40b29c0..9c55548d1 100644
--- a/client/src/components/Auth/__tests__/Registration.spec.tsx
+++ b/client/src/components/Auth/__tests__/Registration.spec.tsx
@@ -19,6 +19,15 @@ const setup = ({
isSuccess: false,
error: null as Error | null,
},
+ useRefreshTokenMutationReturnValue = {
+ isLoading: false,
+ isError: false,
+ mutate: jest.fn(),
+ data: {
+ token: 'mock-token',
+ user: {},
+ },
+ },
useGetStartupCongfigReturnValue = {
isLoading: false,
isError: false,
@@ -48,7 +57,10 @@ const setup = ({
.spyOn(mockDataProvider, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupCongfigReturnValue);
-
+ const mockUseRefreshTokenMutation = jest
+ .spyOn(mockDataProvider, 'useRefreshTokenMutation')
+ //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
+ .mockReturnValue(useRefreshTokenMutationReturnValue);
const renderResult = render();
return {
@@ -56,6 +68,7 @@ const setup = ({
mockUseRegisterUserMutation,
mockUseGetUserQuery,
mockUseGetStartupConfig,
+ mockUseRefreshTokenMutation,
};
};
diff --git a/client/src/components/Nav/Logout.tsx b/client/src/components/Nav/Logout.tsx
index 7d97e5a00..455a4ba14 100644
--- a/client/src/components/Nav/Logout.tsx
+++ b/client/src/components/Nav/Logout.tsx
@@ -9,7 +9,6 @@ const Logout = forwardRef(() => {
const handleLogout = () => {
logout();
- window.location.reload();
};
return (
diff --git a/client/src/components/Plugins/Store/__tests__/PluginStoreDialog.spec.tsx b/client/src/components/Plugins/Store/__tests__/PluginStoreDialog.spec.tsx
index fd8b87043..fb49590f3 100644
--- a/client/src/components/Plugins/Store/__tests__/PluginStoreDialog.spec.tsx
+++ b/client/src/components/Plugins/Store/__tests__/PluginStoreDialog.spec.tsx
@@ -113,6 +113,15 @@ const setup = ({
plugins: ['wolfram'],
},
},
+ useRefreshTokenMutationReturnValue = {
+ isLoading: false,
+ isError: false,
+ mutate: jest.fn(),
+ data: {
+ token: 'mock-token',
+ user: {},
+ },
+ },
useAvailablePluginsQueryReturnValue = {
isLoading: false,
isError: false,
@@ -137,6 +146,10 @@ const setup = ({
.spyOn(mockDataProvider, 'useGetUserQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetUserQueryReturnValue);
+ const mockUseRefreshTokenMutation = jest
+ .spyOn(mockDataProvider, 'useRefreshTokenMutation')
+ //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
+ .mockReturnValue(useRefreshTokenMutationReturnValue);
const mockSetIsOpen = jest.fn();
const renderResult = render();
@@ -145,6 +158,7 @@ const setup = ({
mockUseGetUserQuery,
mockUseAvailablePluginsQuery,
mockUseUpdateUserPluginsMutation,
+ mockUseRefreshTokenMutation,
mockSetIsOpen,
};
};
diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx
index a120cfd6b..3c6c8aff8 100644
--- a/client/src/hooks/AuthContext.tsx
+++ b/client/src/hooks/AuthContext.tsx
@@ -107,12 +107,7 @@ const AuthContextProvider = ({
});
};
- const logout = () => {
- document.cookie.split(';').forEach((c) => {
- document.cookie = c
- .replace(/^ +/, '')
- .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/');
- });
+ const logout = useCallback(() => {
logoutUser.mutate(undefined, {
onSuccess: () => {
setUserContext({
@@ -126,7 +121,25 @@ const AuthContextProvider = ({
doSetError((error as Error).message);
},
});
- };
+ }, [setUserContext, logoutUser]);
+
+ const silentRefresh = useCallback(() => {
+ refreshToken.mutate(undefined, {
+ onSuccess: (data: TLoginResponse) => {
+ const { user, token } = data;
+ if (token) {
+ setUserContext({ token, isAuthenticated: true, user });
+ } else {
+ console.log('Token is not present. User is not authenticated.');
+ navigate('/login');
+ }
+ },
+ onError: (error) => {
+ console.log('refreshToken mutation error:', error);
+ navigate('/login');
+ },
+ });
+ }, []);
useEffect(() => {
if (userQuery.data) {
@@ -139,12 +152,7 @@ const AuthContextProvider = ({
doSetError(undefined);
}
if (!token || !isAuthenticated) {
- const tokenFromCookie = getCookieValue('token');
- if (tokenFromCookie) {
- setUserContext({ token: tokenFromCookie, isAuthenticated: true, user: userQuery.data });
- } else {
- navigate('/login', { replace: true });
- }
+ silentRefresh();
}
}, [
token,
@@ -157,23 +165,23 @@ const AuthContextProvider = ({
setUserContext,
]);
- // const silentRefresh = useCallback(() => {
- // refreshToken.mutate(undefined, {
- // onSuccess: (data: TLoginResponse) => {
- // const { user, token } = data;
- // setUserContext({ token, isAuthenticated: true, user });
- // },
- // onError: error => {
- // setError(error.message);
- // }
- // });
- //
- // }, [setUserContext]);
+ useEffect(() => {
+ const handleTokenUpdate = (event) => {
+ console.log('tokenUpdated event received event');
+ const newToken = event.detail;
+ setUserContext({
+ token: newToken,
+ isAuthenticated: true,
+ user: user,
+ });
+ };
- // useEffect(() => {
- // if (token)
- // silentRefresh();
- // }, [token, silentRefresh]);
+ window.addEventListener('tokenUpdated', handleTokenUpdate);
+
+ return () => {
+ window.removeEventListener('tokenUpdated', handleTokenUpdate);
+ };
+ }, [setUserContext, user]);
// Make the provider update only when it should
const memoedValue = useMemo(
diff --git a/docs/general_info/breaking_changes.md b/docs/general_info/breaking_changes.md
index dbb0b900c..76b3ee05c 100644
--- a/docs/general_info/breaking_changes.md
+++ b/docs/general_info/breaking_changes.md
@@ -5,7 +5,11 @@
Certain changes in the updates may impact cookies, leading to unexpected behaviors if not cleared properly.
## v0.5.8
-**If you have issues after updating, please try to clear your browser cache and cookies!**
+
+- It's now required to set a JWT_REFRESH_SECRET in your .env file as of [#927](https://github.com/danny-avila/LibreChat/pull/927)
+ - It's also recommended you set REFRESH_TOKEN_EXPIRY or the default value will be used.
+
+## v0.5.8
- It's now required to name manifest JSON files (for [ChatGPT Plugins](..\features\plugins\chatgpt_plugins_openapi.md)) in the `api\app\clients\tools\.well-known` directory after their `name_for_model` property should you add one yourself.
- This was a recommended convention before, but is now required.
diff --git a/e2e/.env.test.example b/e2e/.env.test.example
deleted file mode 100644
index e7a3fc48e..000000000
--- a/e2e/.env.test.example
+++ /dev/null
@@ -1,9 +0,0 @@
-# Test database. You can use your actual MONGO_URI if you don't mind it potentially including test data.
-MONGO_URI=mongodb://127.0.0.1:27017/chatgpt-jest
-
-# Credential encryption/decryption for testing
-CREDS_KEY=c3301ad2f69681295e022fb135e92787afb6ecfeaa012a10f8bb4ddf6b669e6d
-CREDS_IV=cd02538f4be2fa37aba9420b5924389f
-
-# For testing the ChatAgent
-OPENAI_API_KEY=your-api-key
diff --git a/e2e/playwright.config.local.ts b/e2e/playwright.config.local.ts
index 4f27241f6..b6ceee5e1 100644
--- a/e2e/playwright.config.local.ts
+++ b/e2e/playwright.config.local.ts
@@ -13,8 +13,10 @@ const config: PlaywrightTestConfig = {
...mainConfig.webServer,
command: `node ${absolutePath}`,
env: {
- NODE_ENV: 'production',
...process.env,
+ NODE_ENV: 'development',
+ SESSION_EXPIRY: '60000',
+ REFRESH_TOKEN_EXPIRY: '300000',
},
},
fullyParallel: false, // if you are on Windows, keep this as `false`. On a Mac, `true` could make tests faster (maybe on some Windows too, just try)
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
index dc7124b62..7c6fe7107 100644
--- a/e2e/playwright.config.ts
+++ b/e2e/playwright.config.ts
@@ -11,7 +11,7 @@ export default defineConfig({
/* Run tests in files in parallel.
NOTE: This sometimes causes issues on Windows.
Set to false if you experience issues running on a Windows machine. */
- fullyParallel: true,
+ fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
@@ -62,7 +62,8 @@ export default defineConfig({
env: {
...process.env,
NODE_ENV: 'development',
- SESSION_EXPIRY: '86400000',
+ SESSION_EXPIRY: '60000',
+ REFRESH_TOKEN_EXPIRY: '300000',
},
},
});
diff --git a/e2e/setup/authenticate.ts b/e2e/setup/authenticate.ts
index 3aba926b4..d0bb2f2ea 100644
--- a/e2e/setup/authenticate.ts
+++ b/e2e/setup/authenticate.ts
@@ -22,13 +22,8 @@ async function authenticate(config: FullConfig, user: User) {
if (!baseURL) {
throw new Error('🤖: baseURL is not defined');
}
- await page.goto(baseURL);
+ await page.goto(baseURL, { timeout: 5000 });
await login(page, user);
- // const loginPromise = page.getByTestId('landing-title').waitFor({ timeout: 25000 }); // due to GH Actions load time
- // if (process.env.NODE_ENV === 'ci') {
- // await page.screenshot({ path: 'login-screenshot.png' });
- // }
- // await loginPromise;
await page.waitForURL(`${baseURL}/chat/new`);
console.log('🤖: ✔️ user successfully authenticated');
// Set localStorage before navigating to the page
diff --git a/e2e/specs/keys.spec.ts b/e2e/specs/keys.spec.ts
index 8e30ef5fa..021f260d6 100644
--- a/e2e/specs/keys.spec.ts
+++ b/e2e/specs/keys.spec.ts
@@ -13,7 +13,7 @@ const enterTestKey = async (page: Page, endpoint: string) => {
test.describe('Key suite', () => {
// npx playwright test --config=e2e/playwright.config.local.ts --headed e2e/specs/keys.spec.ts
test('Test Setting and Revoking Keys', async ({ page }) => {
- await page.goto('http://localhost:3080/');
+ await page.goto('http://localhost:3080/', { timeout: 5000 });
const endpoint = 'chatGPTBrowser';
const newTopicButton = page.getByTestId('new-conversation-menu');
@@ -50,7 +50,7 @@ test.describe('Key suite', () => {
});
test('Test Setting and Revoking Keys from Settings', async ({ page }) => {
- await page.goto('http://localhost:3080/');
+ await page.goto('http://localhost:3080/', { timeout: 5000 });
const endpoint = 'bingAI';
const newTopicButton = page.getByTestId('new-conversation-menu');
diff --git a/e2e/specs/landing.spec.ts b/e2e/specs/landing.spec.ts
index 6eee1f997..86421cb6f 100644
--- a/e2e/specs/landing.spec.ts
+++ b/e2e/specs/landing.spec.ts
@@ -1,15 +1,14 @@
-/* eslint-disable no-undef */
import { expect, test } from '@playwright/test';
test.describe('Landing suite', () => {
test('Landing title', async ({ page }) => {
- await page.goto('http://localhost:3080/');
+ await page.goto('http://localhost:3080/', { timeout: 5000 });
const pageTitle = await page.textContent('#landing-title');
expect(pageTitle?.length).toBeGreaterThan(0);
});
test('Create Conversation', async ({ page }) => {
- await page.goto('http://localhost:3080/');
+ await page.goto('http://localhost:3080/', { timeout: 5000 });
async function getItems() {
const navDiv = await page.waitForSelector('nav > div');
diff --git a/e2e/specs/messages.spec.ts b/e2e/specs/messages.spec.ts
index a81ff9cd3..27f0087ec 100644
--- a/e2e/specs/messages.spec.ts
+++ b/e2e/specs/messages.spec.ts
@@ -19,7 +19,7 @@ const waitForServerStream = async (response: Response) => {
};
async function clearConvos(page: Page) {
- await page.goto(initialUrl);
+ await page.goto(initialUrl, { timeout: 5000 });
await page.getByRole('button', { name: 'test' }).click();
await page.getByText('Settings').click();
await page.getByTestId('clear-convos-initial').click();
@@ -47,7 +47,7 @@ test.afterAll(async () => {
});
test.beforeEach(async ({ page }) => {
- await page.goto(initialUrl);
+ await page.goto(initialUrl, { timeout: 5000 });
});
test.afterEach(async ({ page }) => {
@@ -60,7 +60,7 @@ test.describe('Messaging suite', () => {
}) => {
test.setTimeout(120000);
const message = 'hi';
- await page.goto(initialUrl);
+ await page.goto(initialUrl, { timeout: 5000 });
await page.locator('#new-conversation-menu').click();
await page.locator(`#${endpoint}`).click();
await page.locator('form').getByRole('textbox').click();
@@ -125,7 +125,7 @@ test.describe('Messaging suite', () => {
test('message should stop and continue', async ({ page }) => {
const message = 'write me a 10 stanza poem about space';
- await page.goto(initialUrl);
+ await page.goto(initialUrl, { timeout: 5000 });
await page.locator('#new-conversation-menu').click();
await page.locator(`#${endpoint}`).click();
@@ -161,7 +161,7 @@ test.describe('Messaging suite', () => {
// in this spec as we are testing post-message navigation, we are not testing the message response
test('Page navigations', async ({ page }) => {
- await page.goto(initialUrl);
+ await page.goto(initialUrl, { timeout: 5000 });
await page.getByTestId('convo-icon').first().click({ timeout: 5000 });
const currentUrl = page.url();
const conversationId = currentUrl.split(basePath).pop() ?? '';
diff --git a/e2e/specs/nav.spec.ts b/e2e/specs/nav.spec.ts
index ecd9269d1..d8f997058 100644
--- a/e2e/specs/nav.spec.ts
+++ b/e2e/specs/nav.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
test.describe('Navigation suite', () => {
test('Navigation bar', async ({ page }) => {
- await page.goto('http://localhost:3080/');
+ await page.goto('http://localhost:3080/', { timeout: 5000 });
await page.locator('[id="headlessui-menu-button-\\:r0\\:"]').click();
const navBar = await page.locator('[id="headlessui-menu-button-\\:r0\\:"]').isVisible();
@@ -10,7 +10,7 @@ test.describe('Navigation suite', () => {
});
test('Settings modal', async ({ page }) => {
- await page.goto('http://localhost:3080/');
+ await page.goto('http://localhost:3080/', { timeout: 5000 });
await page.locator('[id="headlessui-menu-button-\\:r0\\:"]').click();
await page.getByText('Settings').click();
diff --git a/e2e/specs/popup.spec.ts b/e2e/specs/popup.spec.ts
index edf87ad28..7055507ed 100644
--- a/e2e/specs/popup.spec.ts
+++ b/e2e/specs/popup.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
test.describe('Endpoints Presets suite', () => {
test('Endpoints Suite', async ({ page }) => {
- await page.goto('http://localhost:3080/');
+ await page.goto('http://localhost:3080/', { timeout: 5000 });
await page.getByTestId('new-conversation-menu').click();
// includes the icon + endpoint names in obj property
diff --git a/e2e/specs/settings.spec.ts b/e2e/specs/settings.spec.ts
index cc7cf72a6..41bf774b0 100644
--- a/e2e/specs/settings.spec.ts
+++ b/e2e/specs/settings.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
test.describe('Settings suite', () => {
test('Last Bing settings', async ({ page }) => {
- await page.goto('http://localhost:3080/');
+ await page.goto('http://localhost:3080/', { timeout: 5000 });
await page.evaluate(() =>
window.localStorage.setItem(
'lastConversationSetup',
@@ -23,7 +23,7 @@ test.describe('Settings suite', () => {
}),
),
);
- await page.goto('http://localhost:3080/');
+ await page.goto('http://localhost:3080/', { timeout: 5000 });
const initialLocalStorage = await page.evaluate(() => window.localStorage);
const lastConvoSetup = JSON.parse(initialLocalStorage.lastConversationSetup);
diff --git a/package.json b/package.json
index 14304f10a..bff7ba80e 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
"upgrade": "node config/upgrade.js",
"create-user": "node config/create-user.js",
"backend": "cross-env NODE_ENV=production node api/server/index.js",
- "backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js",
+ "backend:dev": "cross-env NODE_ENV=production npx nodemon api/server/index.js",
"backend:stop": "node config/stop-backend.js",
"build:data-provider": "cd packages/data-provider && npm run build",
"frontend": "npm run build:data-provider && cd client && npm run build",
@@ -34,6 +34,8 @@
"e2e:ci": "playwright test --config=e2e/playwright.config.ts",
"e2e:debug": "cross-env PWDEBUG=1 playwright test --config=e2e/playwright.config.local.ts",
"e2e:codegen": "npx playwright codegen --load-storage=e2e/storageState.json http://localhost:3080/chat/new",
+ "e2e:login": "npx playwright codegen --save-storage=e2e/auth.json http://localhost:3080/login",
+ "e2e:github": "act -W .github/workflows/playwright.yml --secret-file my.secrets",
"test:client": "cd client && npm run test",
"test:api": "cd api && npm run test",
"e2e:update": "playwright test --config=e2e/playwright.config.js --update-snapshots",
diff --git a/packages/data-provider/src/request.ts b/packages/data-provider/src/request.ts
index 07e93e76a..6d7c82ac4 100644
--- a/packages/data-provider/src/request.ts
+++ b/packages/data-provider/src/request.ts
@@ -1,4 +1,69 @@
-import axios, { AxiosRequestConfig } from 'axios';
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import axios, { AxiosRequestConfig, AxiosError } from 'axios';
+// eslint-disable-next-line import/no-cycle
+import { refreshToken } from './data-service';
+import { setTokenHeader } from './headers-helpers';
+
+let isRefreshing = false;
+let failedQueue: { resolve: (value?: any) => void; reject: (reason?: any) => void }[] = [];
+
+const processQueue = (error: AxiosError | null, token: string | null = null) => {
+ failedQueue.forEach((prom) => {
+ if (error) {
+ prom.reject(error);
+ } else {
+ prom.resolve(token);
+ }
+ });
+ failedQueue = [];
+};
+
+axios.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ const originalRequest = error.config;
+ if (error.response.status === 401 && !originalRequest._retry) {
+ if (isRefreshing) {
+ return new Promise(function (resolve, reject) {
+ failedQueue.push({ resolve, reject });
+ })
+ .then((token) => {
+ originalRequest.headers['Authorization'] = 'Bearer ' + token;
+ return axios(originalRequest);
+ })
+ .catch((err) => {
+ return Promise.reject(err);
+ });
+ }
+
+ originalRequest._retry = true;
+ isRefreshing = true;
+
+ return new Promise(function (resolve, reject) {
+ refreshToken()
+ .then(({ token }) => {
+ if (token) {
+ originalRequest.headers['Authorization'] = 'Bearer ' + token;
+ setTokenHeader(token);
+ window.dispatchEvent(new CustomEvent('tokenUpdated', { detail: token }));
+ processQueue(null, token);
+ resolve(axios(originalRequest));
+ } else {
+ window.location.href = '/login';
+ }
+ })
+ .catch((err) => {
+ processQueue(err, null);
+ reject(err);
+ })
+ .then(() => {
+ isRefreshing = false;
+ });
+ });
+ }
+ return Promise.reject(error);
+ },
+);
async function _get(url: string, options?: AxiosRequestConfig): Promise {
const response = await axios.get(url, { ...options });
diff --git a/packages/data-provider/src/sse.js b/packages/data-provider/src/sse.js
index 7f350f1ff..58c204f57 100644
--- a/packages/data-provider/src/sse.js
+++ b/packages/data-provider/src/sse.js
@@ -4,6 +4,9 @@
* All rights reserved.
*/
+import { refreshToken } from './data-service';
+import { setTokenHeader } from './headers-helpers';
+
var SSE = function (url, options) {
if (!(this instanceof SSE)) {
return new SSE(url, options);
@@ -102,12 +105,27 @@ var SSE = function (url, options) {
this.close();
};
- this._onStreamProgress = function (e) {
+ this._onStreamProgress = async function (e) {
if (!this.xhr) {
return;
}
- if (this.xhr.status !== 200) {
+ if (this.xhr.status === 401 && !this._retry) {
+ this._retry = true;
+ try {
+ const refreshResponse = await refreshToken();
+ this.headers = {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${refreshResponse.token}`,
+ };
+ setTokenHeader(refreshResponse.token);
+ window.dispatchEvent(new CustomEvent('tokenUpdated', { detail: refreshResponse.token }));
+ this.stream();
+ } catch (err) {
+ this._onStreamFailure(e);
+ return;
+ }
+ } else if (this.xhr.status !== 200) {
this._onStreamFailure(e);
return;
}