8-Bit Borges
8-Bit Borges

Reputation: 10033

ReactJS - watch access token expiration

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?

Upvotes: 4

Views: 3836

Answers (3)

felixmosh
felixmosh

Reputation: 35473

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.

Upvotes: 5

Gabriele Mastrapasqua
Gabriele Mastrapasqua

Reputation: 159

props will not updates on the child, because for a child component they are like immutable options: https://github.com/uberVU/react-guide/blob/master/props-vs-state.md

So you will need some ways to re-render the Child component.

The Child component has already been constructed so will not update and re-render. So you will need to use "getDerivedStateFromProps()" as a replacement from the deprecated "componentWillReceiveProps" function inside the Child component, so that when receiving updated props from the parent the child will re-render, like this:

class Child extends Component {
    state = {
        spotifyToken: null,
    };

    static getDerivedStateFromProps(props, state) {
        console.log("updated props", props.spotifyToken);
        if (props.spotifyToken !== state.spotifyToken) {
            return {
               spotifyToken: props.spotifyToken,
            };
        }

        // Return null if the state hasn't changed
        return null;
    }

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

};

Note that in the getTrack function you will use the Child state value and not the props.

Upvotes: 0

Seth Lutske
Seth Lutske

Reputation: 10676

If you want to force a rerender of your Child component when the state of the parent component changes, you can use the key prop. Use the spotify token as the key. When the spotify token is re-fetched and updated, it will remount your child component with the new token as well:

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

Though this may reset any internal state that you had in your child component, as it is essentially unmounting and remounting the Child.

Edit: More details

The key prop is a special prop used in React components. React uses keys to determine whether or not a component is unique, from one component to another, or from one render to another. They are typically used when mapping an array to a set of components, but can be used in this context as well. The react docs have an excellent explanation. Basically when the key prop of a component changes, it tells React that this is now a new component, and therefore must be completely rerendered. So when you fetch a new spotifyToken, and assign that new token as the key, React will completely remount the Child with the new props. Hopefully that makes things more clear.

The key prop will not be available from within your Child - this.props.key inside of your child will not be useful to try to access. But in your case, you are passing the same value to the spotifyToken inside the Child, so you'll have the value available there. Its common to use another prop with the same value as key when that value is needed in the child component.

Upvotes: 0

Related Questions