ChatGPT解决这个技术问题 Extra ChatGPT

How to use Redux to refresh JWT token?

Our React Native Redux app uses JWT tokens for authentication. There are many actions that require such tokens and a lot of them are dispatched simultaneously e.g. when app loads.

E.g.

componentDidMount() {
    dispath(loadProfile());
    dispatch(loadAssets());
    ...
}

Both loadProfile and loadAssets require JWT. We save the token in the state and AsyncStorage. My question is how to handle token expiration.

Originally I was going to use middleware for handling token expiration

// jwt-middleware.js

export function refreshJWTToken({ dispatch, getState }) {

  return (next) => (action) => {
    if (isExpired(getState().auth.token)) {
      return dispatch(refreshToken())
          .then(() => next(action))
          .catch(e => console.log('error refreshing token', e));
    }
    return next(action);
};

}

The problem that I ran into was that refreshing of the token will happen for both loadProfile and loadAssets actions because at the time when they are dispatch the token will be expired. Ideally I would like to "pause" actions that require authentication until the token is refreshed. Is there a way to do that with middleware?

I suggest you to look at a library called redux-saga... It solves this problem perfectly.
@KevinHe: can you share a bit more how redux-saga solves this problem?

S
Sagar Guhe

I found a way to solve this. I am not sure if this is best practice approach and there are probably some improvements that could be made to it.

My original idea stays: JWT refresh is in the middleware. That middleware has to come before thunk if thunk is used.

...
const createStoreWithMiddleware = applyMiddleware(jwt, thunk)(createStore);

Then in the middleware code we check to see if token is expired before any async action. If it is expired we also check if we are already are refreshing the token -- to be able to have such check we add promise for fresh token to the state.

import { refreshToken } from '../actions/auth';

export function jwt({ dispatch, getState }) {

    return (next) => (action) => {

        // only worry about expiring token for async actions
        if (typeof action === 'function') {

            if (getState().auth && getState().auth.token) {

                // decode jwt so that we know if and when it expires
                var tokenExpiration = jwtDecode(getState().auth.token).<your field for expiration>;

                if (tokenExpiration && (moment(tokenExpiration) - moment(Date.now()) < 5000)) {

                    // make sure we are not already refreshing the token
                    if (!getState().auth.freshTokenPromise) {
                        return refreshToken(dispatch).then(() => next(action));
                    } else {
                        return getState().auth.freshTokenPromise.then(() => next(action));
                    }
                }
            }
        }
        return next(action);
    };
}

The most important part is refreshToken function. That function needs to dispatch action when token is being refreshed so that the state will contain the promise for the fresh token. That way if we dispatch multiple async actions that use token auth simultaneously the token gets refreshed only once.

export function refreshToken(dispatch) {

    var freshTokenPromise = fetchJWTToken()
        .then(t => {
            dispatch({
                type: DONE_REFRESHING_TOKEN
            });

            dispatch(saveAppToken(t.token));

            return t.token ? Promise.resolve(t.token) : Promise.reject({
                message: 'could not refresh token'
            });
        })
        .catch(e => {

            console.log('error refreshing token', e);

            dispatch({
                type: DONE_REFRESHING_TOKEN
            });
            return Promise.reject(e);
        });



    dispatch({
        type: REFRESHING_TOKEN,

        // we want to keep track of token promise in the state so that we don't try to refresh
        // the token again while refreshing is in process
        freshTokenPromise
    });

    return freshTokenPromise;
}

I realize that this is pretty complicated. I am also a bit worried about dispatching actions in refreshToken which is not an action itself. Please let me know of any other approach you know that handles expiring JWT token with redux.


You could make refreshToken receive a "postponedAction" that will be dispatched if the refresh is done successfully instead of returning a new Promise. At least that's how I solved this.
@Shvetusya I wouldn't be worried about dispatching actions in refreshToken which is not an action itself. refreshToken is essentially an action creator and dispatching other actions in an actionCreator like this is pretty common practice
Thx a lot for this piece of code! Maybe after all action, we need to remove freshTokenPromise object from the state? return getState() .auth.freshTokenPromise.then(() => next(action)) .then(() => { dispatch({ type: REFRESHING_TOKEN_PROMISE_CLEAN, freshTokenPromise: null, }) })
beautiful ! a little note for the ones with redux-persist, persisting a promise is not supported, freshTokenPromise has to be excluded/blacklisted with a transformer
@Jawla here's an example gist.github.com/hatemalimam/5e196f4953f50187b130600f62a99856 hope it helps
Z
ZekeDroid

Instead of "waiting" for an action to finish, you could instead keep a store variable to know if you're still fetching tokens:

Sample reducer

const initialState = {
    fetching: false,
};
export function reducer(state = initialState, action) {
    switch(action.type) {
        case 'LOAD_FETCHING':
            return {
                ...state,
                fetching: action.fetching,
            }
    }
}

Now the action creator:

export function loadThings() {
    return (dispatch, getState) => {
        const { auth, isLoading } = getState();

        if (!isExpired(auth.token)) {
            dispatch({ type: 'LOAD_FETCHING', fetching: false })
            dispatch(loadProfile());
            dispatch(loadAssets());
       } else {
            dispatch({ type: 'LOAD_FETCHING', fetching: true })
            dispatch(refreshToken());
       }
    };
}

This gets called when the component mounted. If the auth key is stale, it will dispatch an action to set fetching to true and also refresh the token. Notice that we aren't going to load the profile or assets yet.

New component:

componentDidMount() {
    dispath(loadThings());
    // ...
}

componentWillReceiveProps(newProps) {
    const { fetching, token } = newProps; // bound from store

    // assuming you have the current token stored somewhere
    if (token === storedToken) {
        return; // exit early
    }

    if (!fetching) {
        loadThings()
    } 
}

Notice that now you attempt to load your things on mount but also under certain conditions when receiving props (this will get called when the store changes so we can keep fetching there) When the initial fetch fails, it will trigger the refreshToken. When that is done, it'll set the new token in the store, updating the component and hence calling componentWillReceiveProps. If it's not still fetching (not sure this check is necessary), it will load things.


Thanks! This definitely makes sense for the initial load. But I am not sure if it works for expiring tokens after the app is loaded and is in use. Every call to the API requires valid token. We have many pop up views that require login and load data so I am not sure if handling expiration through props for those views would work.
You can change the logic to check for the expiration of the token instead of difference in token. The idea is that any action will trigger this lifecycle method so you can make use of it to update the fetching variable and react accordingly
My first issue with adding dispatch({ type: 'LOAD_FETCHING', fetching: true }) to every action that requires JWT is code duplication. Second problem is how to know when the refresh completed. Say there is an "Add to Favourites" button that dispatches an api call that requires auth. Do I want to add "if token expired refresh then make a call" logic to that action? What about other similar actions? This is why I am trying to use middleware. In other frameworks / languages I have used decorators but I am not sure if I can do that with React.
Ah yes, it would get repetitive and definitely should be middleware. Decorators would make sense but I'm not sure you can use them either. One other strategy would be to 'queue' your actions, like 'ADD_TO_FAVS', into an queue array, by the middleware. Immediately try to dispatch but if the token is stale, refresh it. Meanwhile, subscribe to this change and on any change attempt to empty the queue. There will be a delay in the dispatching but no more than expected for this sort of handshaking.
M
MikeM

I made a simple wrapper around redux-api-middleware to postpone actions and refresh access token.

middleware.js

import { isRSAA, apiMiddleware } from 'redux-api-middleware';

import { TOKEN_RECEIVED, refreshAccessToken } from './actions/auth'
import { refreshToken, isAccessTokenExpired } from './reducers'


export function createApiMiddleware() {
  const postponedRSAAs = []

  return ({ dispatch, getState }) => {
    const rsaaMiddleware = apiMiddleware({dispatch, getState})

    return (next) => (action) => {
      const nextCheckPostponed = (nextAction) => {
          // Run postponed actions after token refresh
          if (nextAction.type === TOKEN_RECEIVED) {
            next(nextAction);
            postponedRSAAs.forEach((postponed) => {
              rsaaMiddleware(next)(postponed)
            })
          } else {
            next(nextAction)
          }
      }

      if(isRSAA(action)) {
        const state = getState(),
              token = refreshToken(state)

        if(token && isAccessTokenExpired(state)) {
          postponedRSAAs.push(action)
          if(postponedRSAAs.length === 1) {
            return  rsaaMiddleware(nextCheckPostponed)(refreshAccessToken(token))
          } else {
            return
          }
        }
      
        return rsaaMiddleware(next)(action);
      }
      return next(action);
    }
  }
}

export default createApiMiddleware();

I keep tokens in the state, and use a simple helper to inject Acess token into a request headers

export function withAuth(headers={}) {
  return (state) => ({
    ...headers,
    'Authorization': `Bearer ${accessToken(state)}`
  })
}

So redux-api-middleware actions stays almost unchanged

export const echo = (message) => ({
  [RSAA]: {
      endpoint: '/api/echo/',
      method: 'POST',
      body: JSON.stringify({message: message}),
      headers: withAuth({ 'Content-Type': 'application/json' }),
      types: [
        ECHO_REQUEST, ECHO_SUCCESS, ECHO_FAILURE
      ]
  }
})

I wrote the article and shared the project example, that shows JWT refresh token workflow in action


U
ULazdins

I think that redux is not the right tool for enforcing the atomicity of token refresh.

Instead I can offer you an atomic function that can be called from anywhere and ensures that you will always get a valid token:

/*
    The non-atomic refresh function
*/

const refreshToken = async () => {
    // Do whatever you need to do here ...
}

/*
    Promise locking-queueing structure
*/

var promiesCallbacks = [];

const resolveQueue = value => {
  promiesCallbacks.forEach(x => x.resolve(value));
  promiesCallbacks = [];
};
const rejectQueue = value => {
  promiesCallbacks.forEach(x => x.reject(value));
  promiesCallbacks = [];
};
const enqueuePromise = () => {
  return new Promise((resolve, reject) => {
    promiesCallbacks.push({resolve, reject});
  });
};

/*
    The atomic function!
*/

var actionInProgress = false;

const refreshTokenAtomically = () => {
  if (actionInProgress) {
    return enqueuePromise();
  }

  actionInProgress = true;

  return refreshToken()
    .then(({ access }) => {
      resolveQueue(access);
      return access;
    })
    .catch((error) => {
      rejectQueue(error);
      throw error;
    })
    .finally(() => {
      actionInProgress = false;
    });
};

Posted also here: https://stackoverflow.com/a/68154638/683763