Refresh Auth Token Rotation (Node js & React ) — Part 2

Tókos Bence
12 min readMar 18, 2024

--

Auth Token Rotation ( Node js & React js )

Welcome back! In this section, we’ll create a small frontend demo to test the token rotation solution we implemented in the previous part. Let’s start by creating a new React app.

We create a React app with vite:

npm create vite@latest frontend react

And install the axios:

npm install axios

We’ll also install React Redux because it makes token handling much easier:

npm install react-redux
npm install @reduxjs/toolkit
npm install react-router-dom
npm install redux-persist
npm install jwt-decode@3.1.2

Introduction to Redux Store:

Redux is a state management library commonly used with React applications to manage application state in a predictable and centralized manner. The Redux store serves as a single source of truth for the entire application state, allowing components to access and update state consistently across the application. It helps simplify complex data flow and makes it easier to manage application state, especially in large-scale applications with many components.

Let’s implement the redux store what we will use in our little demo. Make a folder store and create two files: authSlice and store.

Here is the authSlice:

import { createSlice } from "@reduxjs/toolkit";
import jwt_decode from "jwt-decode";

const initialState = {
token: null,
userId: null,
};

const authTokenSlice = createSlice({
name: "authToken",
initialState,
reducers: {
addToken(state, action) {
const token = action.payload;
const decode = jwt_decode(token);
console.log(decode);
const id = decode._id;
console.log("ID", id);
state.token = token;
state.userId = id;
},
deleteToken(state, action) {
state.token = null;
state.userId = null;
},
},
});

export const authTokenActions = authTokenSlice.actions;

export default authTokenSlice;

In this code snippet, we’re defining a Redux slice named authTokenSlice using createSlice from the Redux Toolkit. This slice manages the authentication-related state in our Redux store. The initial state includes properties for the authentication token (token), user ID (userId). The slice contains reducer functions to add or delete the authentication token from the state. When adding a token, jwt_decode is used to extract the user ID from the JWT token payload, which is then stored in the state. This slice provides actions like addToken and deleteToken, which can be dispatched to update the authentication state in the Redux store.

And here is the store:

import { configureStore } from "@reduxjs/toolkit";
import authTokenSlice from "./authSlice";
//Persist states import
import storageSession from "redux-persist/lib/storage/session";
import { persistStore, persistReducer } from "redux-persist";

const authTokenPersistConfig = {
key: "authToken",
storage: storageSession,
};



export const store = configureStore({
reducer: {
authToken: persistReducer(authTokenPersistConfig, authTokenSlice.reducer),
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
});

export const persistor = persistStore(store);

In this code snippet, we’re configuring the Redux store using configureStore from the Redux Toolkit. We’re also setting up state persistence using redux-persist to persist the authToken slice’s state across browser sessions. The authTokenPersistConfig defines the configuration for persisting the authentication token slice using sessionStorage. This ensures that the authentication state persists even after the user closes and reopens the browser. Finally, we export the configured Redux store and persistor for use throughout our application. This setup provides a robust and reliable mechanism for managing authentication state in our React application.

Now we need to use it so let’s add to our main.jsx:

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import { BrowserRouter } from "react-router-dom";
import { store, persistor } from "./store/store";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";

ReactDOM.createRoot(document.getElementById('root')).render(

<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<BrowserRouter>
<App />
</BrowserRouter>
</PersistGate>
</Provider>

)

In this code snippet, we’re setting up the entry point for our React application. First, we import necessary dependencies such as React, ReactDOM, App component, and stylesheets. We also import BrowserRouter from “react-router-dom” to enable client-side routing in our application. Next, we import our Redux store and persistor from “./store/store” to provide centralized state management across the application. We then import Provider from “react-redux” to wrap our entire application with the Redux store, allowing components to access the Redux state. Additionally, we import PersistGate from “redux-persist/integration/react” to ensure that our Redux store is rehydrated with persisted state before rendering the application. Finally, we use ReactDOM.createRoot to render our application into the root element in the HTML document. Within the render method, we wrap our entire application with Provider and PersistGate, ensuring that the Redux store and persisted state are available throughout the component tree. Inside the Provider, we nest BrowserRouter to enable routing within our application, with the App component serving as the root component. Overall, this setup ensures seamless integration of Redux state management and client-side routing in our React application, providing a smooth and responsive user experience.

Alright, now that we’ve configured the Redux store and routing, it’s time to create our pages. We’ll start by setting up a Pages folder where we'll implement our pages. Given that this tutorial focuses on token rotation, we'll create just three pages: a main protected page, a login page, and a sign-up page.

First we implement the login page:

import React, { useState } from "react";
import AxiosInstance from "../axios/axiosInstance";
import { useDispatch } from "react-redux";
import { authTokenActions } from "../store/authSlice";
import { useNavigate } from "react-router-dom";

const Login = () => {
const [loginData, setLoginData] = useState({
email: "",
password: "",
});
const [errors, setErrors] = useState({});
const dispatch = useDispatch();
const navigate = useNavigate();
const axiosInstance = AxiosInstance({
"Content-Type": "application/json",
});

const onInputChangeHandle = (event) => {
const { name, value } = event.target;
setLoginData({ ...loginData, [name]: value });
if (name === "email") {
errors.email = "";
}
if (name === "password") {
errors.password = "";
}
};

const onLogin = async (e) => {
e.preventDefault();
const validationErrors = validateForm(loginData);
if (Object.keys(validationErrors).length === 0) {
console.log(loginData);
await axiosInstance
.post("/users/signIn", loginData)
.then((response) => {
if (response.data.accessToken) {
dispatch(authTokenActions.addToken(response.data.accessToken));
navigate("/");
}
})
.catch((error) => {
console.log(error);
});
} else {
setErrors(validationErrors);
}
};

const validateForm = (data) => {
const errors = {};
if (!data.password) {
errors.password = "Password is required";
}
if (!data.email) {
errors.email = "Email is required";
} else if (!isValidEmail(data.email)) {
errors.email = "Invalid email format";
}
return errors;
};

const isValidEmail = (email) => {
const emailPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/;

// Test the email against the pattern
return emailPattern.test(email);
};

return (
<React.Fragment>
<form style={{padding:"16px", display:"flex", flexDirection: "column"}}>
<div>
<h1>Welcome!</h1>
</div>

<label >
<input

name="email"
type="text"
placeholder="Email Address"
onChange={onInputChangeHandle}
/>
{errors.email && <div >{errors.email}</div>}
</label>
<label >
<input

name="password"
type="password"
placeholder="Password"
onChange={onInputChangeHandle}
required
/>
{errors.password && (
<div className={styles.error}>{errors.password}</div>
)}
</label>

<div style={{padding: "16px"}}>

<button


onClick={onLogin}
color="#faaf90"
>Sign in</button>
</div>
</form>
</React.Fragment>
);
};

export default Login;

This React component, named Login, is your gateway to accessing our application. Picture it as a welcoming doorman, ready to assist you in entering our digital realm. As you enter your credentials, the component dynamically updates its state to reflect your input, ensuring a smooth and responsive experience. Once you hit that “Sign in” button, your information is securely sent to our backend server for authentication.

It utilizes React hooks such as useState to manage component state and useDispatch to dispatch actions to the Redux store. Upon user input, the onInputChangeHandle function updates the state with the entered email and password, while also performing basic form validation to ensure both fields are filled correctly. When the user submits the form, the onLogin function is triggered, which further validates the form data and sends a POST request to the backend API endpoint “/users/signIn” using Axios. If the authentication is successful, the response data containing an access token is stored in the Redux store using dispatch. Additionally, the user is redirected to the home page (“/”) using the useNavigate hook from React Router.

Now let’s see the sign up page:

import React, { useState } from "react";

import AxiosInstance from "../axios/axiosInstance";
import { useDispatch } from "react-redux";
import { authTokenActions } from "../store/authSlice";
import { useNavigate } from "react-router-dom";

const SignUp = () => {
const [formData, setFormData] = useState({
//name: "",
email: "",
password: "",
confirmPassword: "",
});
const [errors, setErrors] = useState({});
const dispatch = useDispatch();
const navigate = useNavigate();
const axiosInstance = AxiosInstance({
"Content-Type": "application/json",
});

const onInputChangeHandle = (event) => {
const { name, value } = event.target;
setFormData({ ...formData, [name]: value });
/*if (name === "name") {
errors.name = "";
}*/
if (name === "email") {
errors.email = "";
}
if (name === "password") {
errors.password = "";
}
if (name === "confirmPassword") {
errors.passwordConfirm = "";
}
};

const onAuth = async (e) => {
e.preventDefault();
const validationErrors = validateForm(formData);
if (Object.keys(validationErrors).length === 0) {
console.log(formData);
await axiosInstance
.post("/users/signUp", formData)
.then((response) => {
if (response.data.accessToken) {
dispatch(authTokenActions.addToken(response.data.accessToken));
navigate("/");
}
})
.catch((error) => {
console.log(error);
});
} else {
setErrors(validationErrors);
}
};

const validateForm = (data) => {
const passwordPattern = /^(?=.*[A-Z])(?=.*\d).+/;
const errors = {};

if (!data.password && !data.confirmPassword) {
errors.password = "Password is required";
} else {
if (data.password.trim().length < 8) {
errors.password = "Password is too short";
}
if (!passwordPattern.test(data.password)) {
errors.password = "Password not contains number or uppercase";
}
if (data.password !== data.confirmPassword) {
errors.password = "The passwords are not equal";
}
}
if (!data.email) {
errors.email = "Email is required";
} else if (!isValidEmail(data.email)) {
errors.email = "Invalid email format";
}
return errors;
};

const isValidEmail = (email) => {
const emailPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/;

// Test the email against the pattern
return emailPattern.test(email);
};

return (
<React.Fragment>
<form style={{padding: "16px", display: "flex", flexDirection:"column"}}>
<div >
<h1>Create your account!</h1>
</div>



<label >
<input

type="text"
placeholder="Email"
name="email"
onChange={onInputChangeHandle}
/>
{errors.email && <div >{errors.email}</div>}
</label>

<label >
<input

type="password"
placeholder="Password"
name="password"
onChange={onInputChangeHandle}
/>
{errors.password && (
<div >{errors.password}</div>
)}
</label>

<label >
<input

type="password"
placeholder="Confirm password"
name="confirmPassword"
onChange={onInputChangeHandle}
/>
</label>

<div style={{padding: "16px"}}>

<button

title="Sign Up"
onClick={onAuth}
color="#faaf90"
>Sign Up </button>
</div>
</form>
</React.Fragment>
);
};

export default SignUp;

This code defines a React component called SignUp, serving as the gateway for users to create new accounts within our application. It harnesses the power of React hooks, such as useState, to manage component state, and useDispatch to dispatch actions to our Redux store. As users input their desired email and password, the onInputChangeHandle function dynamically updates the component’s state, while also performing essential form validation to ensure data integrity. Upon form submission, the onAuth function orchestrates the validation process, sending a POST request to the “/users/signUp” API endpoint via Axios. If the authentication is successful, the response data — including an access token — is stored in the Redux store using dispatch. Furthermore, users are gracefully navigated to the home page (“/”) using the useNavigate hook from React Router. In summary, this component seamlessly integrates account creation functionality into our application, offering users a secure and intuitive signup experience.

As mentioned earlier, we’re developing a third secure view. Before beginning the implementation of this view, it’s essential to create a private secure route component.

import React from "react";
import { Outlet, Navigate } from "react-router-dom";
import { useSelector } from "react-redux";

const PrivateRoute = () => {
const authToken = useSelector((state) => state.authToken.token);
return authToken ? <Outlet /> : <Navigate to="/login" />;
};

export default PrivateRoute;

In this code, we’re defining a React component called PrivateRoute, which serves as a wrapper for routes that require authentication in our application. The component utilizes React Router’s Outlet and Navigate components for routing and useSelector hook from react-redux for accessing the Redux store.

Inside the PrivateRoute component, we extract the authToken from the Redux store using useSelector. The authToken represents the user’s authentication token, indicating whether the user is authenticated or not.

Next, we use a ternary operator to conditionally render the Outlet component if authToken exists, implying that the user is authenticated and should have access to the protected route. The Outlet component is a placeholder provided by React Router for rendering child routes within the parent route.

If authToken does not exist (i.e., the user is not authenticated), we render the Navigate component, which redirects the user to the login page (“/login”). Navigate is a component provided by React Router for declarative navigation.

Overall, this PrivateRoute component acts as a guard for protected routes in our application, ensuring that only authenticated users can access them. If an unauthenticated user tries to access a protected route, they are automatically redirected to the login page. This enhances the security and user experience of our application by enforcing authentication requirements for sensitive routes.

Now, we can create the main page:

import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { authTokenActions } from "../store/authSlice";
import { useNavigate } from "react-router-dom";
import AxiosInstance from "../axios/axiosInstance";

const Main = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const userId = useSelector((state) => state.authToken.userId);
const [userName, setUsername] = useState("");

const axiosInstance = AxiosInstance({
"Content-Type": "application/json",
});

const onLogout = async () => {
await axiosInstance
.get("/users/logout")
.then((response) => {
console.log(response);
dispatch(authTokenActions.deleteToken());
navigate("/login");
})
.catch((error) => {
console.log(error);
});
};

const getUserData = async () => {
await axiosInstance
.get("/users/getUser/" + userId)
.then((response) => {
console.log(response);
setUsername(response.data.userEmail);
})
.catch((error) => {
console.log(error);
});
};

return (
<React.Fragment>
<div>Protected Main</div>
<div style={{ display: "flex", flexDirection: "row", gap: "10px" }}>
<button onClick={onLogout}>LogOut</button>
<button onClick={getUserData}>Get User Data</button>
</div>
{userName !== "" && <div>{userName}</div>}
</React.Fragment>
);
};

export default Main;

In this code, we have a React component called Main, responsible for rendering the main page of our application. This component includes functionality for logging out the user and fetching user data. Let’s break down the key components:

  1. useState and useSelector:
  • We use the useState hook to manage the local state of userName, representing the user’s name.
  • The useSelector hook is utilized to access the userId from the Redux store, retrieved using the authToken slice.

2. useDispatch and authTokenActions:

  • useDispatch hook is used to get the Redux dispatch function, enabling us to dispatch actions to the Redux store.
  • authTokenActions contains action creators for managing authentication-related actions, such as adding or deleting authentication tokens.

3. useNavigate:

  • useNavigate hook from React Router DOM is employed for declarative navigation within the application.

4. AxiosInstance:

  • AxiosInstance is a configured instance of Axios, a library for making HTTP requests. It’s set up to send requests with a specific content type of “application/json”.

5. onLogout Function:

  • This function is triggered when the user clicks the “LogOut” button. It sends a GET request to “/users/logout” endpoint using AxiosInstance, logging out the user on the server-side. Upon successful response, it dispatches the deleteToken action to remove the authentication token from the Redux store and navigates the user to the login page.

6. getUserData Function:

  • This function fetches user data from the server by sending a GET request to “/users/getUser/{userId}” endpoint. Upon receiving a response, it sets the userName state to the retrieved user’s email. This function is invoked when the user clicks the “Get User Data” button.

7. Rendering:

  • The render function displays a message indicating that the page is protected.
  • Two buttons are provided: one for logging out and the other for fetching user data.
  • If the userName state is not empty (i.e., user data has been fetched successfully), the user’s email is displayed on the page.

Now, let’s add the routes to the app.jsx file:

import './App.css'
import { Routes, Route } from "react-router-dom";
import Login from './pages/Login';
import SignUp from './pages/SignUp';
import Main from './pages/MainProtected';
import PrivateRoute from '../utils/PrivateRoute';

function App() {

return (
<>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signUp" element={<SignUp/>}/>
<Route element={<PrivateRoute />}>
<Route path="/" element={<Main/>}/>
</Route>
</Routes>
</>
)
}

export default App;

The Routes component from react-router-dom is used to define the application’s routes. Each Route element specifies a path and the corresponding component to render. The PrivateRoute component ensures that certain routes are only accessible to authenticated users. Inside the Routes component, Login and SignUp pages are rendered directly, while the Main page is wrapped inside the PrivateRoute component to restrict access to authenticated users only. Overall, this App component sets up the application’s routing system, directing users to the appropriate pages based on the requested URLs.

Let me explain how it works!

To run the frontend, navigate to the frontend folder and:

npm run dev

To run the backend, navigate to the backend folder and:

node server.js

First you create an account:

Sign Up page

After logging in or signing up, you will be directed to the main page:

Main page

Because we’ve set the access token lifetime to 10 seconds, after this period, the access token expires. Attempting to retrieve user data results in a 403 Forbidden error due to the expired access token. This issue involves our Axios instance, as upon encountering the Forbidden error, we send a request with our refresh token from the cookies. Subsequently, we generate a new access token, pass it to the original request, and successfully retrieve the user data. If the refresh token also expires, the user will be logged out and prompted to log in again to create new refresh and access tokens.

Arhitecture
Main page after get new access token

This process may seem complex initially, but it’s simpler than you might expect. Remember, you can adjust the access token lifetime, but it should always be shorter than the refresh token’s lifetime.

I hope you found this tutorial enjoyable and informative. If you want to verify and ensure that everything is done correctly, please visit my GitHub repository for this code. Don’t forget to check out my other stories!

See you soon!

--

--

Tókos Bence

Hi everyone! I'm an enthusiastic full-stack developer. Please feel free to reach out to me via email (tokosbex@gmail.com) or Twitter (@tokosbex).