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.
2
Answers
It seems like the issue might be related to the way you’re updating the state with the new refreshToken in the useRefreshToken hook.
In the setAuth function, you are spreading the previous state (prev) and updating only the accessToken. However, you also need to update the refreshToken with the new value received from the backend. You should modify the setAuth function to include the updated refreshToken as well. Here’s the modified version:
This modification ensures that both accessToken and refreshToken are updated in the state. Now, when you call refresh, it should correctly update the refreshToken in the state, and subsequent requests using the updated auth object should work as expected.
In your authProvider can you change your code to the following
and when ever you want to update the state you can do
Let me know if this works.