Search code examples
javascriptreactjsiframeaccess-token

ReactJS - use iframe to silently renew token


My SPA has this working component that fetches an access token that will be encrypted and passed to other components via props. This is it:

import React, { Component } from 'react';
//import { Redirect } from 'react-router-dom';
import axios from 'axios';
import Credentials from './spotify-auth.js'
import './Spotify.css'


class SpotifyAuth extends Component {  
  constructor (props) {
    super(props);
    this.state = {
      isAuthenticatedWithSpotify: false,
    };
    this.state.handleRedirect = this.handleRedirect.bind(this);
  };

  generateRandomString(length) {
    let text = '';
    const possible =
      'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    for (let i = 0; i < length; i++) {
      text += possible.charAt(Math.floor(Math.random() * possible.length));
    }
    return text;
    } 

  getHashParams() {
    const hashParams = {};
    const r = /([^&;=]+)=?([^&;]*)/g;
    const q = window.location.hash.substring(1);
    let e = r.exec(q);
    while (e) {
      hashParams[e[1]] = decodeURIComponent(e[2]);
      e = r.exec(q);
    }
    return hashParams;
  }

  componentDidMount() {
    //if (this.props.isAuthenticated) {
    const params = this.getHashParams();

    const access_token = params.access_token;
    const state = params.state;
    const storedState = localStorage.getItem(Credentials.stateKey);
    localStorage.setItem('spotifyAuthToken', access_token);
    localStorage.getItem('spotifyAuthToken');

    if (window.localStorage.getItem('authToken')) {
      this.setState({ isAuthenticatedWithSpotify: true });
    };
    if (access_token && (state == null || state !== storedState)) {
      alert('Click "ok" to finish authentication with Spotify');
    } else {
      localStorage.removeItem(Credentials.stateKey);
    }
    // DO STUFF WITH ACCEES TOKEN HERE
    this.props.onConnectWithSpotify(access_token); 
  };

  handleRedirect(event) {
    event.preventDefault()
    this.props.createMessage('You linked your Spotify account!', 'success');
    // get client features at authenticating
    const params = this.getHashParams();
    const access_token = params.access_token;
    console.log(access_token);

    const state = this.generateRandomString(16);
    localStorage.setItem(Credentials.stateKey, state);

    let url = 'https://accounts.spotify.com/authorize';
    url += '?response_type=token';
    url += '&client_id=' + encodeURIComponent(Credentials.client_id);
    url += '&scope=' + encodeURIComponent(Credentials.scope);
    url += '&redirect_uri=' + encodeURIComponent(Credentials.redirect_uri);
    url += '&state=' + encodeURIComponent(state);
    window.location = url; 
  };

  render() {
      return (
        <div className="button_container">
        <h1 className="title is-3"><font color="#C86428">{"Welcome"}</font></h1>
            <div className="Line" /><br/>
              <button className="sp_button" onClick={(event) => this.handleRedirect(event)}>
                <strong>LINK YOUR SPOTIFY ACCOUNT</strong>
              </button>
        </div>
      )
  }
}
export default SpotifyAuth;

This token credentials last for 60 minutes.

I've learned that the standard option for SPAs is to use iframes to silently renew tokens and not use refresh tokens at all.


How do one spin up an iframe and silently get a new access token every hour in a React component like the one above? I have looked everywhere for this and haven't found anything.


Solution

  • You can do the following:

    1. Create some watcher function, which checks the expiration time of the access token. If the token is about to expire, it is time to renew it.
    2. Render an iframe tag, the src should be the same URL which you are using for redirecting to the Auth server, with one difference: change the return URL to a static file, let's call it redirect.html. The server should know about the user calling this URL, from the stored cookie, so it should just simply redirect you to the redirect.html file, now with a fresh access token.
    3. In this redirect.html write a short script, which takes out the token from the URL and override it with the one you already have in local storage.
    4. Destroy the iframe.

    This is about it, the token is renewd. Keep the watcher going and renew it everytime its about to expire (do it like 5 minutes before it expire).

    An example implementation for the AccessToken component in React, most parts of the code will actually work, but you need to replace the constants with your stuff. Also, some functions like extractTokenFromUrl are missing, but that should be easy enough to make:

    import React, { Component } from 'react'
    
    export class SilentTokenRenew extends Component {
      constructor(props) {
        super(props)
    
        this.state = { renewing: false }
        this.currentAttempt = 0
        this.maxNumberOfAttempts = 20
      }
    
      shouldComponentUpdate(nextProps, nextState) {
        return this.state.renewing !== nextState.renewing
      }
    
      componentDidMount() {
        this.timeInterval = setInterval(this.handleCheckToken, 20000)
      }
    
      componentWillUnmount() {
        clearInterval(this.timeInterval)
      }
    
      willTokenExpire = () => {
        const token = YOUR_ACCESS_TOKEN_OBJECT // { accessToken, expirationTime }
        const threshold = 300 // 300s = 5 minute threshold for token expiration
    
        const hasToken = token && token.accessToken
        const now = (Date.now() / 1000) + threshold
    
        return !hasToken || (now > token.accessToken.expirationTime)
      }
    
      handleCheckToken = () => {
        if (this.willTokenExpire()) {
          this.setState({ renewing: true })
          clearInterval(this.timeInterval)
        }
      }
    
      silentRenew = () => {
        return new Promise((resolve, reject) => {
          const checkRedirect = () => {
            // This can be e
            const redirectUrl = localStorage[YOUR_REDIRECT_URL_FROM_THE_REDIRECT_HTML_FILE] // /redirect.html#access_token=......
            if (!redirectUrl) {
              this.currentAttempt += 1
              if (this.currentAttempt > this.maxNumberOfAttempts) {
                reject({
                  message: 'Silent renew failed after maximum number of attempts.',
                  short: 'max_number_of_attempts_reached',
                })
                return
              }
              setTimeout(() => checkRedirect(), 500)
              return
            }
    
            // Clean up your localStorage for the next silent renewal
            localStorage.removeItem(YOUR_REDIRECT_URL_FROM_THE_REDIRECT_HTML_FILE)
    
            // Put some more error handlers here
    
            // Silent renew worked as expected, lets update the access token
            const session = extractTokenFromUrl(redirectUrl) // write some function to get out the access token from the URL
            // Following your code you provided, here is the time to set
            // the extracted access token back to your localStorage under a key Credentials.stateKey
            localStorage.setItem(Credentials.stateKey, JSON.stringify(session))
            resolve(session)
          }
    
          checkRedirect()
        })
      }
    
      handleOnLoad = () => {
        this.silentRenew()
          .then(() => {
            this.setState({ renewing: false })
            this.currentAttempt = 0
            this.timeInterval = setInterval(this.handleCheckToken, 60000)
            // Access token renewed silently.
          })
          .catch(error => {
            this.setState({ renewing: false })
            // handle the errors
          })
      }
    
      renderIframe = () => {
        const url = new URL(YOUR_AUTHORIZE_URL_TO_TH_AUTH_SERVER)
        url.searchParams.set('redirect_uri', 'http://localhost:3000/redirect.html') // the redirect.html file location
        url.searchParams.set('prompt', 'none')
    
        return (
          <iframe
            style={{ width: 0, height: 0, position: 'absolute', left: 0, top: 0, display: 'none', visibility: 'hidden' }}
            width={0}
            height={0}
            title="silent-token-renew"
            src={url.href}
            onLoad={this.handleOnLoad}
          />
        )
      }
    
      render() {
        const { renewing } = this.state
    
        return renewing ? this.renderIframe() : null
      }
    }
    

    Sample code fore the redirect.html file:

    <!DOCTYPE html>
    <html>
    
    <head>
        <title>OAuth - Redirect</title>
    </head>
    
    <body>
    <p>Renewing...</p>
    <script>
      // Get name of window which was set by the parent to be the unique request key
      // or if no parameter was specified, we have a silent renew from iframe
      const requestKey = YOUR_REDIRECT_URL_FROM_THE_REDIRECT_HTML_FILE;
      // Update corresponding entry with the redirected url which should contain either access token or failure reason in the query parameter / hash
      window.localStorage.setItem(requestKey, window.location.href);
      window.close();
    </script>
    </body>
    
    </html>