feat: Refresh Token for improved Session Security (#927)

* feat(api): refresh token logic

* feat(client): refresh token logic

* feat(data-provider): refresh token logic

* fix: SSE uses esm

* chore: add default refresh token expiry to AuthService, add message about env var not set when generating a token

* chore: update scripts to more compatible bun methods, ran bun install again

* chore: update env.example and playwright workflow with JWT_REFRESH_SECRET

* chore: update breaking changes docs

* chore: add timeout to url visit

* chore: add default SESSION_EXPIRY in generateToken logic, add act script for testing github actions

* fix(e2e): refresh automatically in development environment to pass e2e tests
This commit is contained in:
Danny Avila 2023-09-11 13:10:46 -04:00 committed by GitHub
parent 75be9a3279
commit 33f087d38f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 420 additions and 232 deletions

View file

@ -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

View file

@ -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 }}

59
api/models/Session.js Normal file
View file

@ -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;

View file

@ -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) {

View file

@ -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,

View file

@ -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) {

View file

@ -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) {

View file

@ -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);

View file

@ -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,
});
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,
});
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,
});
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,
});
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,
});
async (req, res) => {
try {
await setAuthTokens(req.user._id, res);
res.redirect(domains.client);
} catch (err) {
console.error('Error in setting authentication tokens:', err);
}
},
);

View file

@ -1,31 +1,35 @@
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) {
@ -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,
};

BIN
bun.lockb

Binary file not shown.

View file

@ -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",

View file

@ -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(<Login />);
return {
...renderResult,
mockUseLoginUser,
mockUseGetUserQuery,
mockUseGetStartupConfig,
mockUseRefreshTokenMutation,
};
};

View file

@ -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(<Registration />);
return {
@ -56,6 +68,7 @@ const setup = ({
mockUseRegisterUserMutation,
mockUseGetUserQuery,
mockUseGetStartupConfig,
mockUseRefreshTokenMutation,
};
};

View file

@ -9,7 +9,6 @@ const Logout = forwardRef(() => {
const handleLogout = () => {
logout();
window.location.reload();
};
return (

View file

@ -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(<PluginStoreDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
@ -145,6 +158,7 @@ const setup = ({
mockUseGetUserQuery,
mockUseAvailablePluginsQuery,
mockUseUpdateUserPluginsMutation,
mockUseRefreshTokenMutation,
mockSetIsOpen,
};
};

View file

@ -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(

View file

@ -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.

View file

@ -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

View file

@ -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)

View file

@ -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',
},
},
});

View file

@ -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

View file

@ -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');

View file

@ -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');

View file

@ -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() ?? '';

View file

@ -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();

View file

@ -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

View file

@ -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);

View file

@ -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",

View file

@ -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<T>(url: string, options?: AxiosRequestConfig): Promise<T> {
const response = await axios.get(url, { ...options });

View file

@ -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;
}