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