React-admin JWT authentication refresh token problem
Asked Answered
B

3

9

I want to implement my own authProvider for react-admin but I'm stuck.
I use a Django-Rest-Framework backend and a JWT token authentication system.
I want to refresh the JWT token if it's almost expired before every request. According to the documentation the authProvider's checkAuth function gets called before every API call, which is true. My problem is that with my code it doesn't wait for the promise to finish and it uses the old access token which results in a 401 and I get redirected to the login page. Any guidance what am I missing?

import jwt from 'jsonwebtoken';


export default {
    login: async ({ username, password }) => {
        const request = new Request('http://localhost:8000/api/token/', {
            method: 'POST',
            body: JSON.stringify({ username, password }),
            headers: new Headers({ 'Content-Type': 'application/json' }),
        });

        const response = await fetch(request);
        if (response.status < 200 || response.status >= 300) {
            throw new Error(response.statusText);
        }
        const { refresh, access } = await response.json();
        localStorage.setItem("refreshToken", refresh);
        localStorage.setItem("accessToken", access);
    },

    logout: params => {
        console.log("logout");
        localStorage.setItem('accessToken', "");
        localStorage.setItem('refreshToken', "");

        return Promise.resolve();
    },

    checkAuth: (params) => {
        const accessToken = localStorage.getItem('accessToken');
        const refreshToken = localStorage.getItem('refreshToken');

        if (accessToken && refreshToken) {
            console.log(accessToken);
            const { exp } = jwt.decode(accessToken);
            if (exp > (new Date().getTime() / 1000) - 10) {
                return Promise.resolve();
            } else {
                const request = new Request('http://localhost:8000/api/token/refresh/', {
                    method: 'POST',
                    body: JSON.stringify({ "refresh": refreshToken }),
                    headers: new Headers({ 'Content-Type': 'application/json' }),
                });

                const response = fetch(request)
                .then(response => {
                    if (response.status !== 200) {
                        throw new Error(response.statusText);
                    }
                    return response.json();
                })
                .then(({ token }) => {
                    localStorage.setItem('accessToken', token);
                    return Promise.resolve();
                });

                return response;
            }
        }
        return Promise.reject();
    },

    checkError: error => {
        if (error.status === 401 || error.status === 403) {
            return Promise.reject();
        }
        return Promise.resolve();
    },
    getPermissions: params => Promise.resolve(),
}
Biosphere answered 14/2, 2020 at 20:21 Comment(3)
how did you go about doing this in the end? In our project, we ended up intercepting the response with axios and triggering the refresh token call in case of 401Barbirolli
Did you get this to work? I would love to know how you did it.Masterson
It seems query from dataProvider is fired before checkAuth fired, so the updated access token is not used.Sulphonate
U
2

2023

In the current version of react admin you should use

import { addRefreshAuthToDataProvider } from "react-admin";
import { addRefreshAuthToAuthProvider } from "react-admin";

to decorate your auth and data provider.

// Add token refresh functionality to your authProvider
export const authProvider = addRefreshAuthToAuthProvider(
  customAuthProvider,
  refreshAuth,
);

Taking a look at the internal source of those methods, the refreshAuth function is called before the providers method, so the new token is applied correctly.

export const addRefreshAuthToAuthProvider = (
    provider: AuthProvider,
    refreshAuth: () => Promise<void>
): AuthProvider => {
    const proxy = new Proxy(provider, {
        get(_, name) {
            const shouldIntercept =
                AuthProviderInterceptedMethods.includes(name.toString()) &&
                provider[name.toString()] != null;

            if (shouldIntercept) {
                return async (...args: any[]) => {
                    await refreshAuth();
                    return provider[name.toString()](...args);
                };
            }

            return provider[name.toString()];
        },
    });

    return proxy;
};

Race conditions in the refreshAuthMethod

The refreshAuth method has to be created yourself and calls the backend to set a new token. Such a method call is async by nature, we ran into a race condition here where react admin called the refreshAuth function twice. The first refresh succeeded the second one was using the old stale token which got blacklisted by the call prior.

Thus for us it was necessary to synchronize this method call:

import { Mutex } from "async-mutex";

const refreshMutex = new Mutex();

/**
 * Check if token needs to be refresh and save it to local storage in order for the auth provider to fetch it.
 */
export const refreshAuth = async () => {
  return refreshMutex.runExclusive(async () => {
    const { accessToken, refreshToken } = getAuthTokensFromLocalStorage();
    if (!accessToken) {
      return Promise.reject("No token present. Not possible to refresh");
    } else if (accessToken.exp < Date.now() / 1000) {
      //Token needs to be refreshed. Query endpoint and optionally save if not working with cookies
      await refreshToken();
    }
    return Promise.resolve();
  });
};
Unscreened answered 14/8, 2023 at 9:33 Comment(1)
Confirm in 2024 that is the solution!Vic
O
1

Can you try something like that

checkAuth: async (params) =>

And

  const request = new Request(...);

  let data;
  const response = await fetch(request);
  if (response.ok) data = await response.json()
  else throw new Error(response.statusText);

  if (data && data.token) {
      localStorage.setItem('accessToken', data.token);
      console.log(data.token);
      return Promise.resolve();
  } else return Promise.reject();
Outoftheway answered 14/2, 2020 at 21:3 Comment(1)
I've already had my laps with async/await but that didn't work either. I've tried it again with your code but sadly, it doesn't work. Maybe I shouldn't refresh the token in this function? There's not too much information about it in the documentation.Biosphere
H
0

This line has an error:

if (exp > (new Date().getTime() / 1000) - 10) {

This condition means that you delay the expiration of the token for 10 seconds, as if rewinding time back. In fact, the token will expire on time (before this condition is met) and the logout() method will be executed.

You probably wanted to write something like this:

// simulate as if the token should expire sooner
if (exp - 10 > (new Date().getTime() / 1000)) {

// or we're ahead of time
// if (exp > (new Date().getTime() / 1000) + 10) {

However, this approach will only work if the transition occurs exactly 10 seconds before the token expires. If the token has already expired, this will not help and the user will be redirected to the login page.

Heyduck answered 9/6, 2023 at 21:15 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.