I have the following frontend:
AuthContext.js:
const { createContext, useState, useContext } = require("react");
const AuthContext = createContext();
export function useAuth() {
return useContext(AuthContext);
}
export function AuthProvider({ children }) {
const [auth, setAuth] = useState();
return (
<AuthContext.Provider value={{
auth,
setAuth
}}>
{children}
</AuthContext.Provider>
)
}
So here is the global [auth, setAuth] authentication object that will hold accessToken and refreshToken via JWT authentication. The auth object is populated upon login from database:
login.js:
import React, { useEffect, useState } from 'react'
import axios from '../api/axios';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const LOGIN_URL = '/login';
function Login() {
const { auth, setAuth } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || '/';
const errorMessage = location.state?.errorMessage;
const [user, setUser] = useState('');
const [pwd, setPwd] = useState('');
const [errMsg, setErrMsg] = useState('');
useEffect(() => {
setErrMsg('');
}, [user, pwd]);
useEffect(() => {
if (errorMessage) {
setErrMsg(errorMessage);
}
}, [])
async function handleSubmit(e) {
e.preventDefault();
try {
const res = await axios.post(LOGIN_URL, {
username: user,
password: pwd
});
const accessToken = res.data.accessToken;
const refreshToken = res.data.refreshToken;
const roles = res.data.roles;
setAuth({ user, pwd, accessToken, refreshToken, roles }); // POPULATING AUTHENTICATION OBEJCT
console.log(refreshToken); // debug what is current refresh token
setUser('');
setPwd('');
navigate(from, { replace: true });
} catch (err) {
if (err.response?.status === 403) {
setErrMsg('wrong username or password');
} else {
setErrMsg('Login Failed');
}
}
}
return (
<section>
<p className={errMsg ? 'errmsg' : 'hide'}>{errMsg}</p>
<h1>Sign In</h1>
<form onSubmit={handleSubmit}>
<label htmlFor="username">Username:</label>
<input type="text" id="username" onChange={e => setUser(e.target.value)} value={user} required />
<label htmlFor="password">Password:</label>
<input type="password" id="password" onChange={e => setPwd(e.target.value)} value={pwd} required />
<button>Sign In</button>
</form>
<p>
Need an Account? <br />
<NavLink className="link" to='/register'>Sign Up</NavLink>
</p>
</section>
)
}
export default Login
When the accessToken expires, the frontend send request to GET /refresh to get new accessToken. This works fine:
useRefreshToken.js:
import { useAuth } from '../context/AuthContext';
import axios from '../api/axios';
function useRefreshToken() {
const { auth, setAuth } = useAuth();
async function refresh() {
console.log(auth.refreshToken);
const res = await axios.get('/refresh', {
headers: {
'Refresh-Token': auth.refreshToken
}
});
setAuth(prev => {
return { ...prev, accessToken: res.data };
});
return res.data;
}
return refresh;
}
export default useRefreshToken;
Also, the request for /refresh is done automatically, via axios interceptors, whenever the accessToken expires (signaled via 401 http status for a protected resource):
useAxiosJwt.js:
import useRefreshToken from "./useRefreshToken"
import { useAuth } from '../context/AuthContext';
import { axiosJwt } from "../api/axios";
import { useEffect } from "react";
function useAxiosJwt() {
const { auth } = useAuth();
const refresh = useRefreshToken();
useEffect(() => {
const requestInterceptor = axiosJwt.interceptors.request.use(
conf => {
if (!conf.headers['Authorization']) {
conf.headers['Authorization'] = `Bearer ${auth.accessToken}`;
}
return conf;
}, err => Promise.reject(err)
)
const responseInterceptor = axiosJwt.interceptors.response.use(
res => res,
async err => {
const prevReq = err.config;
if (err.response.status === 401 && !prevReq.sent) {
prevReq.sent = true;
const newAccessToken = await refresh();
prevReq.headers['Authorization'] = `Bearer ${newAccessToken}`;
return axiosJwt(prevReq);
}
return Promise.reject(err);
}
)
return () => {
axiosJwt.interceptors.request.eject(requestInterceptor);
axiosJwt.interceptors.request.eject(responseInterceptor);
}
}, [auth]);
return axiosJwt;
}
export default useAxiosJwt
The automatic refreshing also works fine. The problem is when the refreshToken expires. When that happen, the user have to login again, so that auth object is populated with the new refreshToken (I will show backend doing this after this). The backend simply removes the refreshToken when it expires (when hitting /refresh) and creates new one upon login. And the backend does returns correct new refreshToken. But that new refreshToken is not reflected in the reacts state (setAuth({ user, pwd, accessToken, refreshToken, roles });), even though is confirmed by debugging console.log(refreshToken) that is indeed is new refreshToken (so backend is working fine). So when next /refresh is hit, react send old refreshToken which is no longer in backend (was removed when the old expired). So why is react not reflecting the new refreshToken returned from backend?
Here is the firefox console (with added comments by me for clarification):
50362bcc-7ee7-47c3-88bb-6748b4475c67 Login.js:43 // this is first login so it returns currently stored refresh token from backend's database
50362bcc-7ee7-47c3-88bb-6748b4475c67 useRefreshToken.js:8 // debugging the current token
XHRGET
http://localhost:8080/user // asking for protected resource, but access token has expired
[HTTP/1.1 401 15ms]
50362bcc-7ee7-47c3-88bb-6748b4475c67 useRefreshToken.js:8 // again debugging current refresh token
XHRGET
http://localhost:8080/refresh // automatically going for refresh to get new access token, this works fine
[HTTP/1.1 200 22ms]
XHRGET
http://localhost:8080/user //asking for protected resource with new accessToken, works fine
[HTTP/1.1 200 55ms]
XHRGET
http://localhost:8080/user // asking for protected resource again (for testing), again access token expired (it has deliberately low duration for testing)
[HTTP/1.1 401 10ms]
50362bcc-7ee7-47c3-88bb-6748b4475c67 useRefreshToken.js:8 // current refresh token
XHRGET
http://localhost:8080/refresh // now the refresh token itself expired (400 status, chosen by design), so the user has to login again
[HTTP/1.1 400 20ms]
XHRPOST
http://localhost:8080/login // logging to get new refresh token
[HTTP/1.1 200 196ms]
7eef8b87-9be6-42e3-85ae-4d607ceb5e27 Login.js:43 // this is the new refresh token from backend (previous was 50362bcc-7ee7-47c3-88bb-6748b4475c67)
XHRGET
http://localhost:8080/user // asking for protected resource with new refresh token
[HTTP/1.1 200 50ms]
XHRGET
http://localhost:8080/user // but access token expired so going to refresh
[HTTP/1.1 401 11ms]
50362bcc-7ee7-47c3-88bb-6748b4475c67 useRefreshToken.js:8 // debug wrong - old refresh token, the new one was not setAuth'ed, dont know why
XHRGET
http://localhost:8080/refresh // so axios sending the old refresh token (50362bcc-7ee7-47c3-88bb-6748b4475c67), instead of the new one (7eef8b87-9be6-42e3-85ae-4d607ceb5e27) so this is the error.
[HTTP/1.1 403 29ms]
From the console you can see the new refresh token is sent from backend, but not reflected by setAuth in react. Why?
Edit:
here is backend controller so that you know what endpoints return. As I said previously, backend works fine,the console.log debugging proves that. This is only for clarification:
AuthController.java:
@RestController
@AllArgsConstructor
public class AuthController {
private AuthenticationManager authenticationManager;
private AuthService authService;
private JwtService jwtService;
private RefreshTokenService refreshTokenService;
@PostMapping("/register")
public void register(@RequestBody RegisterDtoin registerDtoin) {
authService.createUserByRegistration(registerDtoin);
}
@PostMapping("/login") // the refresh token is deleted if expired (done in authService.getUserByLogin, not shown) and recreated and returned (again as correctly shown in console.log(refreshToken)) so LoginDtout is {String acessToken, String refreshToken, Long id, String username, and List roles}, all retuned by this endpoint
public ResponseEntity<LoginDtoout> login(@RequestBody LoginDtoin loginDtoIn) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginDtoIn.username,
loginDtoIn.password
)
);
if(authentication.isAuthenticated()){
return ResponseEntity.ok(authService.getUserByLogin(loginDtoIn));
} else {
throw new UsernameNotFoundException("invalid user request");
}
}
@GetMapping("/refresh") // returns only new accessToken, new refreshToken is obtained from login
public ResponseEntity<String> refresh(@RequestHeader("Refresh-Token") String rtoken) throws RefreshTokenExpiredException {
RefreshToken refreshToken = refreshTokenService.findRefreshTokenByToken(rtoken);
if(refreshTokenService.hasExpired(refreshToken)){
throw new RefreshTokenExpiredException("refresh token has expired. Please login again to create new one");
}
String newJwt = jwtService.generateToken(refreshToken.getUser().getUsername());
return ResponseEntity.ok(newJwt);
}
}
source code: https://github.com/shepherd123456/authentication_spring_and_react
This is for those downloading the source code from the provided github link: when git cloning the source code and running backend (spring boot), spring entities are mysql tables (and the database of your mysql has to be renamed in application.properties of course). When they are created for the first time, there is table "role" (Role entity). You have to populate it with
insert into role(name) values('USER'), ('EDITOR'), ('ADMIN');
so that when user is created (registered), the role "USER" is automatically assigned to him. That's all. Also, the react app is in src/main/frontend, and you need to install dependency npm i and then started the create-react-app development server by npm run start (anyone who has worked with react knows that). There should be no CORS problems, since spring boot SecurityConfig.java is allowing localhost:3000 origin, which is where react's development server runs by default. But as I said in my original post, the backend is not a problem here. I really want to know why is react's setAuth not setting new refreshToken upon login (when the old expired) and instead is using the old one. Because using the old one, which is deleted (automatically when expires), cause nullException of course. Because simply the new one, which is correctly creating HAS TO be setted in reacts state (via setAuth) after login.
You are not ejecting response in your useAxiosJwt
Your code:
return () => {
axiosJwt.interceptors.request.eject(requestInterceptor);
axiosJwt.interceptors.request.eject(responseInterceptor);
}
Both the time you are ejecting interceptors.request
Replace it with:
return () => {
axiosJwt.interceptors.request.eject(requestInterceptor);
axiosJwt.interceptors.response.eject(responseInterceptor);
}
Explanation:
As you are passing states into interceptors callback, the values of states get snapshotted. Whenever these callbacks are called they will use the same old state that got snapshotted. That's the reason whenever a state change happens you have to eject the old callback and assign the new callback with updated data.
Why was this working with the request interceptor?
You were properly ejecting the callback of the requester interceptor that's the reason they always had the latest values.
What happened with the response interceptor?
As they were not ejecting, they always had the old data, not only refresh token, but if you logged auth object you will see that everything inside it will be stale data like accessToken and rest of the things.
Read more about it here: https://react.dev/learn/state-as-a-snapshot
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With