Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

ReactJS - watch access token expiration

Tags:

reactjs

In my app, I have an access token (Spotify's) which must be valid at all times. When this access token expires, the app must hit a refresh token endpoint, and fetch another access token, every 60 minutes.

Authorize functions

For security reasons, these 2 calls, to /get_token and /refresh_token are dealt with python, server-side, and states are currently being handled at my Parent App.jsx, like so:

class App extends Component {
  constructor() {
    super();
    this.state = {
      users: [],
      isAuthenticated: false,
      isAuthorizedWithSpotify: false,
      spotifyToken: '',
      isTokenExpired:false,
      isTokenRefreshed:false,
      renewing: false,
      id: '',
    };

 componentDidMount() {
    this.userId(); //<--- this.getSpotifyToken() is called here, inside then(), after async call;
  };

 getSpotifyToken(event) {
    const options = {
      url: `${process.env.REACT_APP_WEB_SERVICE_URL}/get_token/${this.state.id}`,
      method: 'get',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${window.localStorage.authToken}`,
      }
    };
    // needed for sending cookies 
    axios.defaults.withCredentials = true
    return axios(options)
    .then((res) => {
      console.log(res.data)
      this.setState({
        spotifyToken: res.data.access_token,
        isTokenExpired: res.data.token_expired // <--- jwt returns expiration from server
      })
      // if token has expired, refresh it
      if (this.state.isTokenExpired === true){
        console.log('Access token was refreshed')
        this.refreshSpotifyToken();
    }
    })
    .catch((error) => { console.log(error); });

  };

  refreshSpotifyToken(event) {
    const options = {
      url: `${process.env.REACT_APP_WEB_SERVICE_URL}/refresh_token/${this.state.id}`,
      method: 'get',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${window.localStorage.authToken}`,
      }
    };
    axios.defaults.withCredentials = true
    return axios(options)
    .then((res) => {
      console.log(res.data)
      this.setState({
        spotifyToken: res.data.access_token,
        isTokenRefreshed: res.data.token_refreshed,
        isTokenExpired: false,
        isAuthorizedWithSpotify: true
      })
    })
    .catch((error) => { console.log(error); });
  };

Then, I pass this.props.spotifyToken to all my Child components, where requests are made with the access token, and it all works fine.


Watcher Function

The problem is that, when the app stays idle on a given page for more than 60 minutes and the user makes a request, this will find the access token expired, and its state will not be updated, so the request will be denied.

In order to solve this, I thought about having, at App.jsx, a watcher function tracking token expiration time on the background, like so:

willTokenExpire = () => {
    const accessToken = this.state.spotifyToken;
    console.log('access_token in willTokenExpire', accessToken)
    const expirationTime = 3600
    const token = { accessToken, expirationTime } // { accessToken, expirationTime }
    const threshold = 300 // 300s = 5 minute threshold for token expiration

    const hasToken = token && token.spotifyToken
    const now = (Date.now() / 1000) + threshold
    console.log('NOW', now)
    if(now > token.expirationTime){this.getSpotifyToken();}
    return !hasToken || (now > token.expirationTime)
  }

  handleCheckToken = () => {
    if (this.willTokenExpire()) {
      this.setState({ renewing: true })
    }
  }

and:

shouldComponentUpdate(nextProps, nextState) {
    return this.state.renewing !== nextState.renewing
  }

componentDidMount() {
    this.userId();
    this.timeInterval = setInterval(this.handleCheckToken, 20000)
  };

Child component

Then, from render() in Parent App.jsx, I would pass handleCheckToken() as a callback function, as well as this.props.spotifyToken, to the child component which might be idle, like so:

<Route exact path='/tracks' render={() => (
   <Track
    isAuthenticated={this.state.isAuthenticated}
    isAuthorizedWithSpotify={this.state.isAuthorizedWithSpotify}
    spotifyToken={this.state.spotifyToken}
    handleCheckToken={this.handleCheckToken}
    userId={this.state.id}
   />
)} />

and in the Child component, I would have:

class Tracks extends Component{
  constructor (props) {
    super(props);
    this.state = { 
        playlist:[],
        youtube_urls:[],
        artists:[],
        titles:[],
        spotifyToken: this.props.spotifyToken
    };
  };

  componentDidMount() {
    if (this.props.isAuthenticated) {
      this.props.handleCheckToken();
    };
  };

and a call where the valid, updated spotifyToken is needed, like so:

  getTrack(event) {
    const {userId} = this.props
    const options = {
       url: `${process.env.REACT_APP_WEB_SERVICE_URL}/get-tracks/${userId}/${this.props.spotifyToken}`,
       method: 'get',
       headers: {
                'Content-Type': 'application/json',
                 Authorization: `Bearer ${window.localStorage.authToken}`
       }
   };
   return axios(options)
    .then((res) => { 
     console.log(res.data.message)
    })
    .catch((error) => { console.log(error); });
    };

But this is not working.

At regular intervals, new tokens are being fetched with handleCheckToken, even if I'm idle at Child. But if I make the request after 60 minutes, in Child, this.props.spotifyToken being passed is expired, so props is not being passed down to Child.jsx correctly.

What am I missing?

like image 798
8-Bit Borges Avatar asked Oct 15 '25 04:10

8-Bit Borges


1 Answers

You are talking about exchanging refreshToken to accessToken mechanism and I think that you over complicated it.

A background, I've a similar setup, login generates an accessToken (valid for 10 mins) and a refreshToken as a cookie on the refreshToken end point (not necessary).

Then all my components are using a simple api service (which is a wrapper around Axios) in order to make ajax requests to the server. All of my end points are expecting to get a valid accessToken, if it expired, they returns 401 with an expiration message. My Axios has a response interceptor which check if the response is with status 401 & the special message, if so, it makes a request to the refreshToken endpoint, if the refreshToken is valid (expires after 12 hours) it returns an accessToken, otherwise returns 403. The interceptor gets the new accessToken and retries (max 3 times) the previous failed request.

The cool think is that in this way, accessToken can be saved in memory (not localStorage, since it is exposed to XSS attack). I save it on my api service, so, no Component handles anything related to tokens at all.

The other cool think is that it is valid for a full page reload as well, because if the user has a valid cookie with a refreshToken, the first api will fail with 401, and the entire mechanism will work, otherwise, it will fail.

// ApiService.js

import Axios from 'axios';

class ApiService {
  constructor() {
    this.axios = Axios.create();
    this.axios.interceptors.response.use(null, this.authInterceptor);

    this.get = this.axios.get.bind(this.axios);
    this.post = this.axios.post.bind(this.axios);
  }

  async login(username, password) {
    const { accessToken } = await this.axios.post('/api/login', {
      username,
      password,
    });
    this.setAccessToken(accessToken);
    return accessToken; // return it to the component that invoked it to store in some state
  }

  async getTrack(userId, spotifyToken) {
    return this.axios.get(
      `${process.env.REACT_APP_WEB_SERVICE_URL}/get-tracks/${userId}/${spotifyToken}`
    );
  }

  async updateAccessToken() {
    const { accessToken } = await this.axios.post(`/api/auth/refresh-token`, {});
    this.setAccessToken(accessToken);
  }

  async authInterceptor(error) {
    error.config.retries = error.config.retries || {
      count: 0,
    };

    if (this.isUnAuthorizedError(error) && this.shouldRetry(error.config)) {
      await this.updateAccessToken(); // refresh the access token
      error.config.retries.count += 1;

      return this.axios.rawRequest(error.config); // if succeed re-fetch the original request with the updated accessToken
    }
    return Promise.reject(error);
  }

  isUnAuthorizedError(error) {
    return error.config && error.response && error.response.status === 401;
  }

  shouldRetry(config) {
    return config.retries.count < 3;
  }

  setAccessToken(accessToken) {
    this.axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`; // assign all requests to use new accessToken
  }
}

export const apiService = new ApiService(); // this is a single instance of the service, each import of this file will get it

This mechanism is based on this article

Now with this ApiService you can create a single instance and import it in each Component that whats to make an api request.

import {apiService} from '../ApiService';

class Tracks extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      playlist: [],
      youtube_urls: [],
      artists: [],
      titles: [],
      spotifyToken: this.props.spotifyToken,
    };
  }

  async componentDidMount() {
    if (this.props.isAuthenticated) {
      const {userId, spotifyToken} = this.props;
      const tracks = await apiService.getTracks(userId, spotifyToken);
      this.setState({tracks});
    } else {
      this.setState({tracks: []});
    }
  }

  render() {
    return null;
  }
}

Edit (answers to comments)

  1. Handling of login flow can be done using this service as well, you can extract the accessToken from the login api, set it as a default header and return it to the caller (which may save it in a state for other component logic such as conditional rendering)(updated my snippet).
  2. it is just an example of component which needs to use api.
  3. there is only one instance of the ApiService it is created in the "module" of the file (at the end you can see the new ApiService), after that you just importing this exported instance to all the places that need to make an api call.
like image 111
felixmosh Avatar answered Oct 17 '25 12:10

felixmosh



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!