mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
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:
parent
75be9a3279
commit
33f087d38f
31 changed files with 420 additions and 232 deletions
10
.env.example
10
.env.example
|
@ -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
|
||||||
|
|
1
.github/workflows/playwright.yml
vendored
1
.github/workflows/playwright.yml
vendored
|
@ -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
59
api/models/Session.js
Normal 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;
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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
BIN
bun.lockb
Binary file not shown.
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,6 @@ const Logout = forwardRef(() => {
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
window.location.reload();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
|
|
@ -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)
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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() ?? '';
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue