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 ALLOW_SOCIAL_REGISTRATION=false
# JWT Secrets # JWT Secrets
JWT_SECRET=secret # You should use secure values. The examples given are 32-byte keys (64 characters in hex)
JWT_REFRESH_SECRET=secret # Use this replit to generate some quickly: https://replit.com/@daavila/crypto#index.js
JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef
JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418
# Google: # Google:
# Add your Google Client ID and Secret here, you must register an app with Google Cloud to get these values # 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= OPENID_IMAGE_URL=
# Set the expiration delay for the secure cookie with the JWT token # 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 # 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: # Github:
# Get the Client ID and Secret from your Discord Application # 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_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }} E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
JWT_SECRET: ${{ secrets.JWT_SECRET }} JWT_SECRET: ${{ secrets.JWT_SECRET }}
JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }}
CREDS_KEY: ${{ secrets.CREDS_KEY }} CREDS_KEY: ${{ secrets.CREDS_KEY }}
CREDS_IV: ${{ secrets.CREDS_IV }} CREDS_IV: ${{ secrets.CREDS_IV }}
DOMAIN_CLIENT: ${{ secrets.DOMAIN_CLIENT }} 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 Joi = require('joi');
const DebugControl = require('../utils/debug.js'); const DebugControl = require('../utils/debug.js');
const userSchema = require('./schema/userSchema.js'); const userSchema = require('./schema/userSchema.js');
const { SESSION_EXPIRY } = process.env ?? {};
const expires = eval(SESSION_EXPIRY) ?? 1000 * 60 * 15;
function log({ title, parameters }) { function log({ title, parameters }) {
DebugControl.log.functionName(title); DebugControl.log.functionName(title);
DebugControl.log.parameters(parameters); 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 () { userSchema.methods.toJSON = function () {
return { return {
id: this._id, id: this._id,
@ -43,25 +37,11 @@ userSchema.methods.generateToken = function () {
email: this.email, email: this.email,
}, },
process.env.JWT_SECRET, process.env.JWT_SECRET,
{ expiresIn: eval(process.env.SESSION_EXPIRY) }, { expiresIn: expires / 1000 },
); );
return token; 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) { userSchema.methods.comparePassword = function (candidatePassword, callback) {
bcrypt.compare(candidatePassword, this.password, (err, isMatch) => { bcrypt.compare(candidatePassword, this.password, (err, isMatch) => {
if (err) { if (err) {

View file

@ -1,19 +1,27 @@
const { registerUser, requestPasswordReset, resetPassword } = require('../services/AuthService'); const {
registerUser,
const isProduction = process.env.NODE_ENV === 'production'; 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) => { const registrationController = async (req, res) => {
try { try {
const response = await registerUser(req.body); const response = await registerUser(req.body);
if (response.status === 200) { if (response.status === 200) {
const { status, user } = response; const { status, user } = response;
const token = user.generateToken(); let newUser = await User.findOne({ _id: user._id });
//send token for automatic login if (!newUser) {
res.cookie('token', token, { newUser = new User(user);
expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)), await newUser.save();
httpOnly: false, }
secure: isProduction, const token = await setAuthTokens(user._id, res);
}); res.setHeader('Authorization', `Bearer ${token}`);
res.status(status).send({ user }); res.status(status).send({ user });
} else { } else {
const { status, message } = response; const { status, message } = response;
@ -61,59 +69,47 @@ const resetPasswordController = async (req, res) => {
} }
}; };
// const refreshController = async (req, res, next) => { const refreshController = async (req, res) => {
// const { signedCookies = {} } = req; const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
// const { refreshToken } = signedCookies; if (!refreshToken) {
// TODO return res.status(200).send('Refresh token not provided');
// 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);
// if (tokenIndex === -1) { try {
// res.statusCode = 401; const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// res.send('Unauthorized'); const userId = payload.id;
// } else { const user = await User.findOne({ _id: userId });
// const token = req.user.generateToken(); if (!user) {
// // If the refresh token exists, then create new one and replace it. return res.status(401).send('User not found');
// const newRefreshToken = req.user.generateRefreshToken(); }
// user.refreshToken[tokenIndex] = { refreshToken: newRefreshToken };
// user.save((err) => { if (process.env.NODE_ENV === 'development') {
// if (err) { const token = await setAuthTokens(userId, res);
// res.statusCode = 500; const userObj = user.toJSON();
// res.send(err); return res.status(200).send({ token, user: userObj });
// } else { }
// // setTokenCookie(res, newRefreshToken);
// const user = req.user.toJSON(); // Hash the refresh token
// res.status(200).send({ token, user }); const hash = crypto.createHash('sha256');
// } const hashedToken = hash.update(refreshToken).digest('hex');
// });
// } // Find the session with the hashed refresh token
// } else { const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken });
// res.statusCode = 401; if (session && session.expiration > new Date()) {
// res.send('Unauthorized'); const token = await setAuthTokens(userId, res, session._id);
// } const userObj = user.toJSON();
// }, res.status(200).send({ token, user: userObj });
// err => next(err) } else {
// ); res.status(401).send('Refresh token expired or not found for this user');
// } catch (err) { }
// res.statusCode = 401; } catch (err) {
// res.send('Unauthorized'); res.status(401).send('Invalid refresh token');
// } }
// } else { };
// res.statusCode = 401;
// res.send('Unauthorized');
// }
// };
module.exports = { module.exports = {
getUserController, getUserController,
// refreshController, refreshController,
registrationController, registrationController,
resetPasswordRequestController, resetPasswordRequestController,
resetPasswordController, resetPasswordController,

View file

@ -1,4 +1,5 @@
const User = require('../../../models/User'); const User = require('../../../models/User');
const { setAuthTokens } = require('../../services/AuthService');
const loginController = async (req, res) => { const loginController = async (req, res) => {
try { try {
@ -10,15 +11,7 @@ const loginController = async (req, res) => {
return res.status(400).json({ message: 'Invalid credentials' }); return res.status(400).json({ message: 'Invalid credentials' });
} }
const token = req.user.generateToken(); const token = await setAuthTokens(user._id, res);
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',
});
return res.status(200).send({ token, user }); return res.status(200).send({ token, user });
} catch (err) { } catch (err) {

View file

@ -1,12 +1,11 @@
const { logoutUser } = require('../../services/AuthService'); const { logoutUser } = require('../../services/AuthService');
const cookies = require('cookie');
const logoutController = async (req, res) => { const logoutController = async (req, res) => {
const { signedCookies = {} } = req; const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
const { refreshToken } = signedCookies;
try { try {
const logout = await logoutUser(req.user, refreshToken); const logout = await logoutUser(req.user._id, refreshToken);
const { status, message } = logout; const { status, message } = logout;
res.clearCookie('token');
res.clearCookie('refreshToken'); res.clearCookie('refreshToken');
return res.status(status).send({ message }); return res.status(status).send({ message });
} catch (err) { } catch (err) {

View file

@ -2,7 +2,7 @@ const express = require('express');
const { const {
resetPasswordRequestController, resetPasswordRequestController,
resetPasswordController, resetPasswordController,
// refreshController, refreshController,
registrationController, registrationController,
} = require('../controllers/AuthController'); } = require('../controllers/AuthController');
const { loginController } = require('../controllers/auth/LoginController'); const { loginController } = require('../controllers/auth/LoginController');
@ -20,7 +20,7 @@ const router = express.Router();
//Local //Local
router.post('/logout', requireJwtAuth, logoutController); router.post('/logout', requireJwtAuth, logoutController);
router.post('/login', loginLimiter, requireLocalAuth, loginController); router.post('/login', loginLimiter, requireLocalAuth, loginController);
// router.post('/refresh', requireJwtAuth, refreshController); router.post('/refresh', refreshController);
router.post('/register', registerLimiter, validateRegistration, registrationController); router.post('/register', registerLimiter, validateRegistration, registrationController);
router.post('/requestPasswordReset', resetPasswordRequestController); router.post('/requestPasswordReset', resetPasswordRequestController);
router.post('/resetPassword', resetPasswordController); router.post('/resetPassword', resetPasswordController);

View file

@ -1,17 +1,15 @@
const passport = require('passport'); const passport = require('passport');
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { loginLimiter } = require('../middleware');
const config = require('../../../config/loader'); const config = require('../../../config/loader');
const { setAuthTokens } = require('../services/AuthService');
const domains = config.domains; const domains = config.domains;
const isProduction = config.isProduction;
/** /**
* Google Routes * Google Routes
*/ */
router.get( router.get(
'/google', '/google',
loginLimiter,
passport.authenticate('google', { passport.authenticate('google', {
scope: ['openid', 'profile', 'email'], scope: ['openid', 'profile', 'email'],
session: false, session: false,
@ -26,20 +24,18 @@ router.get(
session: false, session: false,
scope: ['openid', 'profile', 'email'], scope: ['openid', 'profile', 'email'],
}), }),
(req, res) => { async (req, res) => {
const token = req.user.generateToken(); try {
res.cookie('token', token, { await setAuthTokens(req.user._id, res);
expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)), res.redirect(domains.client);
httpOnly: false, } catch (err) {
secure: isProduction, console.error('Error in setting authentication tokens:', err);
}); }
res.redirect(domains.client);
}, },
); );
router.get( router.get(
'/facebook', '/facebook',
loginLimiter,
passport.authenticate('facebook', { passport.authenticate('facebook', {
scope: ['public_profile'], scope: ['public_profile'],
profileFields: ['id', 'email', 'name'], profileFields: ['id', 'email', 'name'],
@ -56,20 +52,18 @@ router.get(
scope: ['public_profile'], scope: ['public_profile'],
profileFields: ['id', 'email', 'name'], profileFields: ['id', 'email', 'name'],
}), }),
(req, res) => { async (req, res) => {
const token = req.user.generateToken(); try {
res.cookie('token', token, { await setAuthTokens(req.user._id, res);
expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)), res.redirect(domains.client);
httpOnly: false, } catch (err) {
secure: isProduction, console.error('Error in setting authentication tokens:', err);
}); }
res.redirect(domains.client);
}, },
); );
router.get( router.get(
'/openid', '/openid',
loginLimiter,
passport.authenticate('openid', { passport.authenticate('openid', {
session: false, session: false,
}), }),
@ -82,20 +76,18 @@ router.get(
failureMessage: true, failureMessage: true,
session: false, session: false,
}), }),
(req, res) => { async (req, res) => {
const token = req.user.generateToken(); try {
res.cookie('token', token, { await setAuthTokens(req.user._id, res);
expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)), res.redirect(domains.client);
httpOnly: false, } catch (err) {
secure: isProduction, console.error('Error in setting authentication tokens:', err);
}); }
res.redirect(domains.client);
}, },
); );
router.get( router.get(
'/github', '/github',
loginLimiter,
passport.authenticate('github', { passport.authenticate('github', {
scope: ['user:email', 'read:user'], scope: ['user:email', 'read:user'],
session: false, session: false,
@ -110,20 +102,17 @@ router.get(
session: false, session: false,
scope: ['user:email', 'read:user'], scope: ['user:email', 'read:user'],
}), }),
(req, res) => { async (req, res) => {
const token = req.user.generateToken(); try {
res.cookie('token', token, { await setAuthTokens(req.user._id, res);
expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)), res.redirect(domains.client);
httpOnly: false, } catch (err) {
secure: isProduction, console.error('Error in setting authentication tokens:', err);
}); }
res.redirect(domains.client);
}, },
); );
router.get( router.get(
'/discord', '/discord',
loginLimiter,
passport.authenticate('discord', { passport.authenticate('discord', {
scope: ['identify', 'email'], scope: ['identify', 'email'],
session: false, session: false,
@ -138,14 +127,13 @@ router.get(
session: false, session: false,
scope: ['identify', 'email'], scope: ['identify', 'email'],
}), }),
(req, res) => { async (req, res) => {
const token = req.user.generateToken(); try {
res.cookie('token', token, { await setAuthTokens(req.user._id, res);
expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)), res.redirect(domains.client);
httpOnly: false, } catch (err) {
secure: isProduction, console.error('Error in setting authentication tokens:', err);
}); }
res.redirect(domains.client);
}, },
); );

View file

@ -1,32 +1,36 @@
const crypto = require('crypto'); const crypto = require('crypto');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const User = require('../../models/User'); const User = require('../../models/User');
const Session = require('../../models/Session');
const Token = require('../../models/schema/tokenSchema'); const Token = require('../../models/schema/tokenSchema');
const { registerSchema } = require('../../strategies/validators'); const { registerSchema } = require('../../strategies/validators');
const config = require('../../../config/loader'); const config = require('../../../config/loader');
const { sendEmail } = require('../utils'); const { sendEmail } = require('../utils');
const domains = config.domains; const domains = config.domains;
const isProduction = config.isProduction;
/** /**
* Logout user * Logout user
* *
* @param {Object} user * @param {String} userId
* @param {*} refreshToken * @param {*} refreshToken
* @returns * @returns
*/ */
const logoutUser = async (user, refreshToken) => { const logoutUser = async (userId, refreshToken) => {
try { try {
const userFound = await User.findById(user._id); const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const tokenIndex = userFound.refreshToken.findIndex(
(item) => item.refreshToken === refreshToken,
);
if (tokenIndex !== -1) { // Find the session with the matching user and refreshTokenHash
userFound.refreshToken.id(userFound.refreshToken[tokenIndex]._id).remove(); 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' }; return { status: 200, message: 'Logout successful' };
} catch (err) { } catch (err) {
return { status: 500, message: err.message }; return { status: 500, message: err.message };
@ -83,9 +87,6 @@ const registerUser = async (user) => {
role: isFirstRegisteredUser ? 'ADMIN' : 'USER', role: isFirstRegisteredUser ? 'ADMIN' : 'USER',
}); });
// todo: implement refresh token
// const refreshToken = newUser.generateRefreshToken();
// newUser.refreshToken.push({ refreshToken });
const salt = bcrypt.genSaltSync(10); const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync(newUser.password, salt); const hash = bcrypt.hashSync(newUser.password, salt);
newUser.password = hash; newUser.password = hash;
@ -188,9 +189,51 @@ const resetPassword = async (userId, token, password) => {
return { message: 'Password reset was successful' }; 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 = { module.exports = {
registerUser, registerUser,
logoutUser, logoutUser,
requestPasswordReset, requestPasswordReset,
resetPassword, 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", "preview-prod": "cross-env NODE_ENV=development dotenv -e ../.env -- vite preview",
"test": "cross-env NODE_ENV=test jest --watch", "test": "cross-env NODE_ENV=test jest --watch",
"test:ci": "cross-env NODE_ENV=test jest --ci", "test:ci": "cross-env NODE_ENV=test jest --ci",
"b:build": "NODE_ENV=production bunx --bun vite build", "b:build": "NODE_ENV=production bun vite build",
"b:dev": "NODE_ENV=development bunx --bun vite" "b:dev": "NODE_ENV=development bun vite"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -18,6 +18,15 @@ const setup = ({
data: {}, data: {},
isSuccess: false, isSuccess: false,
}, },
useRefreshTokenMutationReturnValue = {
isLoading: false,
isError: false,
mutate: jest.fn(),
data: {
token: 'mock-token',
user: {},
},
},
useGetStartupCongfigReturnValue = { useGetStartupCongfigReturnValue = {
isLoading: false, isLoading: false,
isError: false, isError: false,
@ -47,12 +56,17 @@ const setup = ({
.spyOn(mockDataProvider, 'useGetStartupConfig') .spyOn(mockDataProvider, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupCongfigReturnValue); .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 />); const renderResult = render(<Login />);
return { return {
...renderResult, ...renderResult,
mockUseLoginUser, mockUseLoginUser,
mockUseGetUserQuery, mockUseGetUserQuery,
mockUseGetStartupConfig, mockUseGetStartupConfig,
mockUseRefreshTokenMutation,
}; };
}; };

View file

@ -19,6 +19,15 @@ const setup = ({
isSuccess: false, isSuccess: false,
error: null as Error | null, error: null as Error | null,
}, },
useRefreshTokenMutationReturnValue = {
isLoading: false,
isError: false,
mutate: jest.fn(),
data: {
token: 'mock-token',
user: {},
},
},
useGetStartupCongfigReturnValue = { useGetStartupCongfigReturnValue = {
isLoading: false, isLoading: false,
isError: false, isError: false,
@ -48,7 +57,10 @@ const setup = ({
.spyOn(mockDataProvider, 'useGetStartupConfig') .spyOn(mockDataProvider, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupCongfigReturnValue); .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 />); const renderResult = render(<Registration />);
return { return {
@ -56,6 +68,7 @@ const setup = ({
mockUseRegisterUserMutation, mockUseRegisterUserMutation,
mockUseGetUserQuery, mockUseGetUserQuery,
mockUseGetStartupConfig, mockUseGetStartupConfig,
mockUseRefreshTokenMutation,
}; };
}; };

View file

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

View file

@ -113,6 +113,15 @@ const setup = ({
plugins: ['wolfram'], plugins: ['wolfram'],
}, },
}, },
useRefreshTokenMutationReturnValue = {
isLoading: false,
isError: false,
mutate: jest.fn(),
data: {
token: 'mock-token',
user: {},
},
},
useAvailablePluginsQueryReturnValue = { useAvailablePluginsQueryReturnValue = {
isLoading: false, isLoading: false,
isError: false, isError: false,
@ -137,6 +146,10 @@ const setup = ({
.spyOn(mockDataProvider, 'useGetUserQuery') .spyOn(mockDataProvider, 'useGetUserQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetUserQueryReturnValue); .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 mockSetIsOpen = jest.fn();
const renderResult = render(<PluginStoreDialog isOpen={true} setIsOpen={mockSetIsOpen} />); const renderResult = render(<PluginStoreDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
@ -145,6 +158,7 @@ const setup = ({
mockUseGetUserQuery, mockUseGetUserQuery,
mockUseAvailablePluginsQuery, mockUseAvailablePluginsQuery,
mockUseUpdateUserPluginsMutation, mockUseUpdateUserPluginsMutation,
mockUseRefreshTokenMutation,
mockSetIsOpen, mockSetIsOpen,
}; };
}; };

View file

@ -107,12 +107,7 @@ const AuthContextProvider = ({
}); });
}; };
const logout = () => { const logout = useCallback(() => {
document.cookie.split(';').forEach((c) => {
document.cookie = c
.replace(/^ +/, '')
.replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/');
});
logoutUser.mutate(undefined, { logoutUser.mutate(undefined, {
onSuccess: () => { onSuccess: () => {
setUserContext({ setUserContext({
@ -126,7 +121,25 @@ const AuthContextProvider = ({
doSetError((error as Error).message); 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(() => { useEffect(() => {
if (userQuery.data) { if (userQuery.data) {
@ -139,12 +152,7 @@ const AuthContextProvider = ({
doSetError(undefined); doSetError(undefined);
} }
if (!token || !isAuthenticated) { if (!token || !isAuthenticated) {
const tokenFromCookie = getCookieValue('token'); silentRefresh();
if (tokenFromCookie) {
setUserContext({ token: tokenFromCookie, isAuthenticated: true, user: userQuery.data });
} else {
navigate('/login', { replace: true });
}
} }
}, [ }, [
token, token,
@ -157,23 +165,23 @@ const AuthContextProvider = ({
setUserContext, setUserContext,
]); ]);
// const silentRefresh = useCallback(() => { useEffect(() => {
// refreshToken.mutate(undefined, { const handleTokenUpdate = (event) => {
// onSuccess: (data: TLoginResponse) => { console.log('tokenUpdated event received event');
// const { user, token } = data; const newToken = event.detail;
// setUserContext({ token, isAuthenticated: true, user }); setUserContext({
// }, token: newToken,
// onError: error => { isAuthenticated: true,
// setError(error.message); user: user,
// } });
// }); };
//
// }, [setUserContext]);
// useEffect(() => { window.addEventListener('tokenUpdated', handleTokenUpdate);
// if (token)
// silentRefresh(); return () => {
// }, [token, silentRefresh]); window.removeEventListener('tokenUpdated', handleTokenUpdate);
};
}, [setUserContext, user]);
// Make the provider update only when it should // Make the provider update only when it should
const memoedValue = useMemo( 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. Certain changes in the updates may impact cookies, leading to unexpected behaviors if not cleared properly.
## v0.5.8 ## 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. - 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. - 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, ...mainConfig.webServer,
command: `node ${absolutePath}`, command: `node ${absolutePath}`,
env: { env: {
NODE_ENV: 'production',
...process.env, ...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) 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. /* Run tests in files in parallel.
NOTE: This sometimes causes issues on Windows. NOTE: This sometimes causes issues on Windows.
Set to false if you experience issues running on a Windows machine. */ 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. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
/* Retry on CI only */ /* Retry on CI only */
@ -62,7 +62,8 @@ export default defineConfig({
env: { env: {
...process.env, ...process.env,
NODE_ENV: 'development', 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) { if (!baseURL) {
throw new Error('🤖: baseURL is not defined'); throw new Error('🤖: baseURL is not defined');
} }
await page.goto(baseURL); await page.goto(baseURL, { timeout: 5000 });
await login(page, user); 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`); await page.waitForURL(`${baseURL}/chat/new`);
console.log('🤖: ✔️ user successfully authenticated'); console.log('🤖: ✔️ user successfully authenticated');
// Set localStorage before navigating to the page // Set localStorage before navigating to the page

View file

@ -13,7 +13,7 @@ const enterTestKey = async (page: Page, endpoint: string) => {
test.describe('Key suite', () => { test.describe('Key suite', () => {
// npx playwright test --config=e2e/playwright.config.local.ts --headed e2e/specs/keys.spec.ts // npx playwright test --config=e2e/playwright.config.local.ts --headed e2e/specs/keys.spec.ts
test('Test Setting and Revoking Keys', async ({ page }) => { 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 endpoint = 'chatGPTBrowser';
const newTopicButton = page.getByTestId('new-conversation-menu'); 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 }) => { 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 endpoint = 'bingAI';
const newTopicButton = page.getByTestId('new-conversation-menu'); const newTopicButton = page.getByTestId('new-conversation-menu');

View file

@ -1,15 +1,14 @@
/* eslint-disable no-undef */
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
test.describe('Landing suite', () => { test.describe('Landing suite', () => {
test('Landing title', async ({ page }) => { 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'); const pageTitle = await page.textContent('#landing-title');
expect(pageTitle?.length).toBeGreaterThan(0); expect(pageTitle?.length).toBeGreaterThan(0);
}); });
test('Create Conversation', async ({ page }) => { test('Create Conversation', async ({ page }) => {
await page.goto('http://localhost:3080/'); await page.goto('http://localhost:3080/', { timeout: 5000 });
async function getItems() { async function getItems() {
const navDiv = await page.waitForSelector('nav > div'); const navDiv = await page.waitForSelector('nav > div');

View file

@ -19,7 +19,7 @@ const waitForServerStream = async (response: Response) => {
}; };
async function clearConvos(page: Page) { async function clearConvos(page: Page) {
await page.goto(initialUrl); await page.goto(initialUrl, { timeout: 5000 });
await page.getByRole('button', { name: 'test' }).click(); await page.getByRole('button', { name: 'test' }).click();
await page.getByText('Settings').click(); await page.getByText('Settings').click();
await page.getByTestId('clear-convos-initial').click(); await page.getByTestId('clear-convos-initial').click();
@ -47,7 +47,7 @@ test.afterAll(async () => {
}); });
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto(initialUrl); await page.goto(initialUrl, { timeout: 5000 });
}); });
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
@ -60,7 +60,7 @@ test.describe('Messaging suite', () => {
}) => { }) => {
test.setTimeout(120000); test.setTimeout(120000);
const message = 'hi'; const message = 'hi';
await page.goto(initialUrl); await page.goto(initialUrl, { timeout: 5000 });
await page.locator('#new-conversation-menu').click(); await page.locator('#new-conversation-menu').click();
await page.locator(`#${endpoint}`).click(); await page.locator(`#${endpoint}`).click();
await page.locator('form').getByRole('textbox').click(); await page.locator('form').getByRole('textbox').click();
@ -125,7 +125,7 @@ test.describe('Messaging suite', () => {
test('message should stop and continue', async ({ page }) => { test('message should stop and continue', async ({ page }) => {
const message = 'write me a 10 stanza poem about space'; 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('#new-conversation-menu').click();
await page.locator(`#${endpoint}`).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 // in this spec as we are testing post-message navigation, we are not testing the message response
test('Page navigations', async ({ page }) => { test('Page navigations', async ({ page }) => {
await page.goto(initialUrl); await page.goto(initialUrl, { timeout: 5000 });
await page.getByTestId('convo-icon').first().click({ timeout: 5000 }); await page.getByTestId('convo-icon').first().click({ timeout: 5000 });
const currentUrl = page.url(); const currentUrl = page.url();
const conversationId = currentUrl.split(basePath).pop() ?? ''; const conversationId = currentUrl.split(basePath).pop() ?? '';

View file

@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
test.describe('Navigation suite', () => { test.describe('Navigation suite', () => {
test('Navigation bar', async ({ page }) => { 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(); await page.locator('[id="headlessui-menu-button-\\:r0\\:"]').click();
const navBar = await page.locator('[id="headlessui-menu-button-\\:r0\\:"]').isVisible(); const navBar = await page.locator('[id="headlessui-menu-button-\\:r0\\:"]').isVisible();
@ -10,7 +10,7 @@ test.describe('Navigation suite', () => {
}); });
test('Settings modal', async ({ page }) => { 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.locator('[id="headlessui-menu-button-\\:r0\\:"]').click();
await page.getByText('Settings').click(); await page.getByText('Settings').click();

View file

@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
test.describe('Endpoints Presets suite', () => { test.describe('Endpoints Presets suite', () => {
test('Endpoints Suite', async ({ page }) => { 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(); await page.getByTestId('new-conversation-menu').click();
// includes the icon + endpoint names in obj property // 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.describe('Settings suite', () => {
test('Last Bing settings', async ({ page }) => { test('Last Bing settings', async ({ page }) => {
await page.goto('http://localhost:3080/'); await page.goto('http://localhost:3080/', { timeout: 5000 });
await page.evaluate(() => await page.evaluate(() =>
window.localStorage.setItem( window.localStorage.setItem(
'lastConversationSetup', '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 initialLocalStorage = await page.evaluate(() => window.localStorage);
const lastConvoSetup = JSON.parse(initialLocalStorage.lastConversationSetup); const lastConvoSetup = JSON.parse(initialLocalStorage.lastConversationSetup);

View file

@ -24,7 +24,7 @@
"upgrade": "node config/upgrade.js", "upgrade": "node config/upgrade.js",
"create-user": "node config/create-user.js", "create-user": "node config/create-user.js",
"backend": "cross-env NODE_ENV=production node api/server/index.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", "backend:stop": "node config/stop-backend.js",
"build:data-provider": "cd packages/data-provider && npm run build", "build:data-provider": "cd packages/data-provider && npm run build",
"frontend": "npm run build:data-provider && cd client && 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:ci": "playwright test --config=e2e/playwright.config.ts",
"e2e:debug": "cross-env PWDEBUG=1 playwright test --config=e2e/playwright.config.local.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: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:client": "cd client && npm run test",
"test:api": "cd api && npm run test", "test:api": "cd api && npm run test",
"e2e:update": "playwright test --config=e2e/playwright.config.js --update-snapshots", "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> { async function _get<T>(url: string, options?: AxiosRequestConfig): Promise<T> {
const response = await axios.get(url, { ...options }); const response = await axios.get(url, { ...options });

View file

@ -4,6 +4,9 @@
* All rights reserved. * All rights reserved.
*/ */
import { refreshToken } from './data-service';
import { setTokenHeader } from './headers-helpers';
var SSE = function (url, options) { var SSE = function (url, options) {
if (!(this instanceof SSE)) { if (!(this instanceof SSE)) {
return new SSE(url, options); return new SSE(url, options);
@ -102,12 +105,27 @@ var SSE = function (url, options) {
this.close(); this.close();
}; };
this._onStreamProgress = function (e) { this._onStreamProgress = async function (e) {
if (!this.xhr) { if (!this.xhr) {
return; 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); this._onStreamFailure(e);
return; return;
} }