diff --git a/client/src/Features/Auth/authSlice.js b/client/src/Features/Auth/authSlice.js index 5ee31944a..b4478f37d 100644 --- a/client/src/Features/Auth/authSlice.js +++ b/client/src/Features/Auth/authSlice.js @@ -5,6 +5,7 @@ import axios from "axios"; const initialState = { isLoading: false, authToken: "", + refreshToken: "", user: "", success: null, msg: null, @@ -138,6 +139,7 @@ const handleAuthFulfilled = (state, action) => { state.success = action.payload.success; state.msg = action.payload.msg; state.authToken = action.payload.data.token; + state.refreshToken = action.payload.data.refreshToken || ""; state.user = action.payload.data.user; }; const handleAuthRejected = (state, action) => { @@ -188,11 +190,15 @@ const authSlice = createSlice({ reducers: { clearAuthState: (state) => { state.authToken = ""; + state.refreshToken = ""; state.user = ""; state.isLoading = false; state.success = true; state.msg = "Logged out successfully"; }, + setAuthToken: (state, action) => { + state.authToken = action.payload; + }, }, extraReducers: (builder) => { // Register thunk @@ -246,4 +252,4 @@ const authSlice = createSlice({ }); export default authSlice.reducer; -export const { clearAuthState } = authSlice.actions; +export const { clearAuthState, setAuthToken } = authSlice.actions; diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index bd0e37b30..379cee10a 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -2,7 +2,7 @@ import axios from "axios"; import i18next from "i18next"; const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL; const FALLBACK_BASE_URL = "http://localhost:5000/api/v1"; -import { clearAuthState } from "../Features/Auth/authSlice"; +import { clearAuthState, setAuthToken } from "../Features/Auth/authSlice"; class NetworkService { constructor(store, dispatch, navigate) { this.store = store; @@ -26,7 +26,7 @@ class NetworkService { (config) => { const currentLanguage = i18next.language || "en"; - const { authToken } = store.getState().auth; + const { authToken, refreshToken } = store.getState().auth; config.headers = { Authorization: `Bearer ${authToken}`, @@ -34,15 +34,36 @@ class NetworkService { ...config.headers, }; + // Only send refresh token to the refresh endpoint + if (refreshToken && config.url?.includes("/auth/refresh")) { + config.headers["x-refresh-token"] = refreshToken; + } + return config; }, (error) => { return Promise.reject(error); } ); + + // Track if we're currently refreshing to prevent multiple refresh calls + this.isRefreshing = false; + this.failedQueue = []; + + const processQueue = (error, token = null) => { + this.failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + this.failedQueue = []; + }; + this.axiosInstance.interceptors.response.use( (response) => response, - (error) => { + async (error) => { // Handle network errors (server unreachable) if (error.code === "ERR_NETWORK") { // Navigate to server unreachable page @@ -51,8 +72,66 @@ class NetworkService { return Promise.reject(error); } - // Handle authentication errors + const originalRequest = error.config; + // Handle 403 - server is telling us to request a new access token + // Skip refresh logic for the refresh endpoint itself to prevent circular dependency + const isRefreshEndpoint = originalRequest.url?.includes("/auth/refresh"); + if ( + error.response && + error.response.status === 403 && + !originalRequest._retry && + !isRefreshEndpoint + ) { + // Check if we have a refresh token before attempting refresh + const { refreshToken } = store.getState().auth; + if (!refreshToken) { + dispatch(clearAuthState()); + navigate("/login"); + return Promise.reject(error); + } + + if (this.isRefreshing) { + // If already refreshing, queue this request + return new Promise((resolve, reject) => { + this.failedQueue.push({ resolve, reject }); + }) + .then((token) => { + originalRequest.headers["Authorization"] = `Bearer ${token}`; + return this.axiosInstance(originalRequest); + }) + .catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + this.isRefreshing = true; + + try { + // Call refresh endpoint + const response = await this.axiosInstance.post("/auth/refresh"); + const newToken = response.data.data.token; + + // Update the auth token in store + dispatch(setAuthToken(newToken)); + + // Process queued requests + processQueue(null, newToken); + + // Retry original request with new token + originalRequest.headers["Authorization"] = `Bearer ${newToken}`; + return this.axiosInstance(originalRequest); + } catch (refreshError) { + // Refresh failed, clear auth and redirect to login + processQueue(refreshError, null); + dispatch(clearAuthState()); + navigate("/login"); + return Promise.reject(refreshError); + } finally { + this.isRefreshing = false; + } + } + + // Handle 401 - authentication failed (invalid/expired refresh token or no token) if (error.response && error.response.status === 401) { dispatch(clearAuthState()); navigate("/login"); diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index d033aa39d..de28de5ea 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -36,11 +36,11 @@ class AuthController { req.body.email = req.body.email?.toLowerCase(); } await registrationBodyValidation.validateAsync(req.body); - const { user, token } = await this.userService.registerUser(req.body, req.file); + const { user, token, refreshToken } = await this.userService.registerUser(req.body, req.file); res.status(200).json({ success: true, msg: "User registered successfully", - data: { user, token }, + data: { user, token, refreshToken }, }); } catch (error) { next(error); @@ -53,7 +53,7 @@ class AuthController { req.body.email = req.body.email?.toLowerCase(); } await loginValidation.validateAsync(req.body); - const { user, token } = await this.userService.loginUser(req.body.email, req.body.password); + const { user, token, refreshToken } = await this.userService.loginUser(req.body.email, req.body.password); return res.status(200).json({ success: true, @@ -61,6 +61,7 @@ class AuthController { data: { user, token, + refreshToken, }, }); } catch (error) { @@ -128,11 +129,38 @@ class AuthController { resetPassword = async (req: Request, res: Response, next: NextFunction) => { try { await newPasswordValidation.validateAsync(req.body); - const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken); + const { user, token, refreshToken } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken); return res.status(200).json({ success: true, msg: "Password has been reset successfully", - data: { user, token }, + data: { user, token, refreshToken }, + }); + } catch (error) { + next(error); + } + }; + + refreshAuthToken = async (req: Request, res: Response, next: NextFunction) => { + try { + const authToken = req.headers.authorization?.split(" ")[1]; + const refreshToken = req.headers["x-refresh-token"] as string; + + if (!authToken || !refreshToken) { + return res.status(400).json({ + success: false, + msg: "Auth token and refresh token are required", + }); + } + + const { user, token, refreshToken: newRefreshToken } = await this.userService.refreshAuthToken( + authToken, + refreshToken + ); + + return res.status(200).json({ + success: true, + msg: "Token refreshed successfully", + data: { user, token, refreshToken: newRefreshToken }, }); } catch (error) { next(error); diff --git a/server/src/middleware/v1/verifyJWT.js b/server/src/middleware/v1/verifyJWT.js index cf983a6c8..d550e95cf 100755 --- a/server/src/middleware/v1/verifyJWT.js +++ b/server/src/middleware/v1/verifyJWT.js @@ -41,7 +41,9 @@ const verifyJWT = (req, res, next) => { if (err) { const errorMessage = err.name === "TokenExpiredError" ? stringService.expiredAuthToken : stringService.invalidAuthToken; err.details = { msg: errorMessage }; - err.status = 401; + // Return 403 for expired tokens to trigger client refresh flow + // Return 401 for invalid tokens to trigger logout + err.status = err.name === "TokenExpiredError" ? 403 : 401; err.service = SERVICE_NAME; err.method = "verifyJWT"; next(err); diff --git a/server/src/routes/v1/authRoute.js b/server/src/routes/v1/authRoute.js index 63b4c4201..9460828c4 100755 --- a/server/src/routes/v1/authRoute.js +++ b/server/src/routes/v1/authRoute.js @@ -15,6 +15,7 @@ class AuthRoutes { initRoutes() { this.router.post("/register", upload.single("profileImage"), this.authController.registerUser); this.router.post("/login", this.authController.loginUser); + this.router.post("/refresh", this.authController.refreshAuthToken); this.router.post("/recovery/request", this.authController.requestRecovery); this.router.post("/recovery/validate", this.authController.validateRecovery); diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 129ed1e59..12271bf53 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -62,6 +62,17 @@ class UserService { return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL }); }; + issueRefreshToken = (payload: any, appSettings: any) => { + const refreshTokenTTL = appSettings?.refreshTokenTTL ?? "7d"; + const refreshTokenSecret = appSettings?.refreshTokenSecret ?? appSettings?.jwtSecret; + const payloadData = { ...payload }; + // Remove sensitive data from refresh token payload + delete payloadData.password; + delete payloadData.profileImage; + delete payloadData.avatarImage; + return this.jwt.sign(payloadData, refreshTokenSecret, { expiresIn: refreshTokenTTL }); + }; + registerUser = async (user: any, file: any) => { // Create a new user // If superAdmin exists, a token should be attached to all further register requests @@ -92,6 +103,7 @@ class UserService { const appSettings = await this.settingsService.getSettings(); const token = this.issueToken(userForToken, appSettings); + const refreshToken = this.issueRefreshToken(userForToken, appSettings); try { const html = await this.emailService.buildEmail("welcomeEmailTemplate", { @@ -114,7 +126,7 @@ class UserService { }); } - return { user: newUser, token }; + return { user: newUser, token, refreshToken }; }; loginUser = async (email: string, password: string) => { @@ -134,9 +146,10 @@ class UserService { // Happy path, return token const appSettings = await this.settingsService.getSettings(); const token = this.issueToken(userWithoutPassword, appSettings); + const refreshToken = this.issueRefreshToken(userWithoutPassword, appSettings); // reset avatar image userWithoutPassword.avatarImage = user.avatarImage; - return { user: userWithoutPassword, token }; + return { user: userWithoutPassword, token, refreshToken }; }; editUser = async (updates: any, file: any, currentUser: any) => { @@ -191,7 +204,40 @@ class UserService { const user = await this.db.recoveryModule.resetPassword(password, recoveryToken); const appSettings = await this.settingsService.getSettings(); const token = this.issueToken(user._doc, appSettings); - return { user, token }; + const refreshToken = this.issueRefreshToken(user._doc, appSettings); + return { user, token, refreshToken }; + }; + + refreshAuthToken = async (oldAuthToken: string, refreshTokenFromHeader: string) => { + const appSettings = await this.settingsService.getSettings(); + const refreshTokenSecret = appSettings?.refreshTokenSecret ?? appSettings?.jwtSecret; + const jwtSecret = appSettings?.jwtSecret; + + // Verify refresh token + try { + this.jwt.verify(refreshTokenFromHeader, refreshTokenSecret); + } catch (err: any) { + const errorMessage = + err.name === "TokenExpiredError" ? "Refresh token has expired" : "Invalid refresh token"; + throw this.errorService.createAuthenticationError(errorMessage); + } + + // Refresh token is valid, get payload from old auth token (ignoring expiration) + let payloadData: any; + try { + payloadData = this.jwt.verify(oldAuthToken, jwtSecret, { ignoreExpiration: true }); + } catch (err: any) { + throw this.errorService.createAuthenticationError("Invalid auth token"); + } + + // Remove old token metadata + delete payloadData.iat; + delete payloadData.exp; + + // Issue new access token + const newAuthToken = this.issueToken(payloadData, appSettings); + + return { user: payloadData, token: newAuthToken, refreshToken: refreshTokenFromHeader }; }; deleteUser = async (user: any) => { diff --git a/server/src/service/system/settingsService.js b/server/src/service/system/settingsService.js index cea46f7ed..08566d116 100755 --- a/server/src/service/system/settingsService.js +++ b/server/src/service/system/settingsService.js @@ -3,6 +3,8 @@ const SERVICE_NAME = "SettingsService"; const envConfig = { jwtSecret: process.env.JWT_SECRET, jwtTTL: process.env.TOKEN_TTL, + refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET || process.env.JWT_SECRET, + refreshTokenTTL: process.env.REFRESH_TOKEN_TTL || "7d", systemEmailHost: process.env.SYSTEM_EMAIL_HOST, nodeEnv: process.env.NODE_ENV, logLevel: process.env.LOG_LEVEL,