Implicit Flow with silent refresh in React
Asked Answered
B

2

9

Background

I'm testing Implicit Flow auth in my React app and trying to implement so-called Silent Refresh capabilities, where I periodically ask for a new access token while the user is logged in, without the need to ask him for a new authorization.

The following is the Flow schema, where the Auth0 Tenant, in my case, is Spotify:

enter image description here

While SPAs(single page applications) using the Implicit Grant cannot use Refresh Tokens, there are other ways to provide similar functionality:

  • Use prompt=none when invoking the /authorize endpoint. The user will not see the login or consent dialogs.

  • Call /authorize from a hidden iframe and extract the new Access Token from the parent frame. The user will not see the redirects happening.


Another approach is the implementation of something like the package axios-auth-refresh, a library that

helps you implement automatic refresh of authorization via axios interceptors. You can easily intercept the original request when it fails, refresh the authorization and continue with the original request, without any user interaction.

Usage:

import axios from 'axios';
import createAuthRefreshInterceptor from 'axios-auth-refresh';

// Function that will be called to refresh authorization
const refreshAuthLogic = failedRequest => axios.post('https://www.example.com/auth/token/refresh').then(tokenRefreshResponse => {
    localStorage.setItem('token', tokenRefreshResponse.data.token);
    failedRequest.response.config.headers['Authorization'] = 'Bearer ' + tokenRefreshResponse.data.token;
    return Promise.resolve();
});

// Instantiate the interceptor (you can chain it as it returns the axios instance)
createAuthRefreshInterceptor(axios, refreshAuthLogic);

// Make a call. If it returns a 401 error, the refreshAuthLogic will be run, 
// and the request retried with the new token
axios.get('https://www.example.com/restricted/area')
    .then(/* ... */)
    .catch(/* ... */);

Set Up

This is my Parent component (please note that isAuthenticated state refers to my app authentication, not related to the Spotify token I need for Silent Refresh):

import SpotifyAuth from './components/spotify/Spotify';

class App extends Component {
  constructor() {
    super();
    this.state = {
      isAuthenticated: false,
      isAuthenticatedWithSpotify: false,
      spotifyToken: '',
      tokenRenewed:'' 
    };
    this.logoutUser = this.logoutUser.bind(this);
    this.loginUser = this.loginUser.bind(this);
    this.onConnectWithSpotify = this.onConnectWithSpotify.bind(this);
  };

  UNSAFE_componentWillMount() {
    if (window.localStorage.getItem('authToken')) {
      this.setState({ isAuthenticated: true });
    };
  };

  logoutUser() {
    window.localStorage.clear();
    this.setState({ isAuthenticated: false });
  };

  loginUser(token) {
    window.localStorage.setItem('authToken', token);
    this.setState({ isAuthenticated: true });
  };

  onConnectWithSpotify(token){
    this.setState({ spotifyToken: token,
                    isAuthenticatedWithSpotify: true
    }, () => {
       console.log('Spotify Token', this.state.spotifyToken)
    });
  }

  render() {
    return (
      <div>
        <NavBar
          title={this.state.title}
          isAuthenticated={this.state.isAuthenticated}
        />
        <section className="section">
          <div className="container">
            <div className="columns">
              <div className="column is-half">
                <br/>
                <Switch>
                  <Route exact path='/' render={() => (
                    <SpotifyAuth
                    onConnectWithSpotify={this.onConnectWithSpotify}
                    spotifyToken={this.state.spotifyToken}
                    />
                  )} />
                  <Route exact path='/login' render={() => (
                    <Form
                      formType={'Login'}
                      isAuthenticated={this.state.isAuthenticated}
                      loginUser={this.loginUser}
                      userId={this.state.id} 
                    />
                  )} />
                  <Route exact path='/logout' render={() => (
                    <Logout
                      logoutUser={this.logoutUser}
                      isAuthenticated={this.state.isAuthenticated}
                      spotifyToken={this.state.spotifyToken}
                    />
                  )} />
                </Switch>
              </div>
            </div>
          </div>
        </section>
      </div>
    )
  }
};

export default App;

and the following is my SpotifyAuth component, whereby the user clicks on a button in order to authorize and authenticate his Spotify account with the app when he logs in.

import Credentials from './spotify-auth.js'
import './Spotify.css'

class SpotifyAuth extends Component {  
  constructor (props) {
    super(props);
    this.state = {
      isAuthenticatedWithSpotify: this.props.isAuthenticatedWithSpotify
    };
    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);
    }
    this.props.onConnectWithSpotify(access_token); 
  };


  handleRedirect(event) {
    event.preventDefault()
    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-4"><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;

Silent Refresh, however, would not need the button above, nor render anything.


For the sake of completeness, this is the endpoint I use for my app authentication process, which uses jwt -json web tokens to encrypt tokens and pass them via cookies from server to client (but this encryption tool is not being used for Spotify token being passed to my client, so far):

@auth_blueprint.route('/auth/login', methods=['POST'])
def login_user():
    # get post data
    post_data = request.get_json()
    response_object = {
        'status': 'fail',
        'message': 'Invalid payload.'
    }
    if not post_data:
        return jsonify(response_object), 400
    email = post_data.get('email')
    password = post_data.get('password')
    try:
        user = User.query.filter_by(email=email).first()
        if user and bcrypt.check_password_hash(user.password, password):
            auth_token = user.encode_auth_token(user.id)
            if auth_token:
                response_object['status'] = 'success'
                response_object['message'] = 'Successfully logged in.'
                response_object['auth_token'] = auth_token.decode()
                return jsonify(response_object), 200
        else:
            response_object['message'] = 'User does not exist.'
            return jsonify(response_object), 404
    except Exception:
        response_object['message'] = 'Try again.'
        return jsonify(response_object), 500

QUESTION

Considering the options and the code above, how do I use my set up in order to add Silent refresh and handle the redirect to Spotify and get a new token every hour on the background?

Something that sits in between this solution and my code?

Bragg answered 31/12, 2019 at 18:15 Comment(3)
Did you try silently refreshing your access token using an iframe?Harri
no, that's what I ask: how to do precisely this.Bragg
You will need to introduce a hidden iframe, and handle the redirection flow from there, then upon getting an access_token within your iframe hash, you would store it in your application. I'm currently at this very moment doing it, I achieved getting a new token but having issues with the iframe check my question I posted today maybe it gives you a hint. #59657472Harri
H
4

So basically you would need to do one of the followings:-

Assuming your access token expires within 1 hour.

Option 1) Set a timeout that gets triggered to get a new access token after let's say 45 minutes of user activity.

Option 2) Avoid setting timeouts, and you would introduce a technique to detect user activity and silently get a token, for example, if you are protecting your routes by a getToken method that would check the token expiration time, you would here add another method that will trigger a silent refresh.

method(){
let iframeElement = this.getDocument().getElementById("anyId");
if (iframeElement == null) {
  iframeElement = document.createElement("iframe");
  iframeElement.setAttribute("id", "anyId");
  document.getElementsByTagName("body")[0].appendChild(iframeElement);
}
  iframeElement.setAttribute("src", tokenUrl); //token url is the authorization server token endpoint
},

Now your iframe will get a new access token within the hash, note that your tokenUrl needs to have prompt=none within the parameters.

The way you handle the new token storage depends on how you store the token in your applicaiton, maybe you would need to call parent.storing_method to store it.

Harri answered 9/1, 2020 at 5:20 Comment(5)
github.com/keycloak/keycloak/blob/master/adapters/oidc/js/src/… You can look at checkSsoSilently(), in keycloak.js library they do something similar to what I am trying to doHarri
tokenUrl is Spotify,'s url mentioned above, no? should your code above go in a js file and be imported into react component, in jsx context? also, a setTimeout() function for option 1) would be very much appreciated, for the sake of completeness to your answer. maybe it's simpler: merging setTimeout() with <SpotifyAuth> or using it with OnConnectWithSpotify()Bragg
Yes, it's the url you have in your code, but you will need to add an extra query parameter prompt=none (url+='promot=none') that's because you don't want to re-enter your credentials, you want to get an access token right away. I personally work with the authorization code using vanilla JS, in a service, then you can include this service in your react application by importing it and using it's functions. Unfortunately as you can see I have an issue with timeout and iframe so I can't give you a robust answer at least for now. But I gave you the main thread you should focus intoHarri
For example, you should protect your react app with the validity of the token. If you store the tokens in a cookie, before rendering your route, you would do something like myService.checktoken(), and if you have a valid token, you would render the route.. componentDidMount(){ myService.checktoken().then(//your code)} inside checktoken(), you would retrieve the stored tokens and check the expiration time, if the token will expire in 15min, you can call method() which will execute the iframe role by setting the attribute. I can give an exact answer maybe tomorrow after I solve mineHarri
Ok, I will wait for your edited answer tomorrow or soon. thanks a lot mate!Bragg
S
0

It is not immediately clear to me that you will be able to do a silent authentication request with the spotify API. According to their authorization guide implicit flow is temporary and does not include refreshing the token.

In the context of Auth0, you would utilize an iFrame to send a silent request with the users cookie, where a session would validate the request and issue a new access token. This is done using the prompt=none option like you mentioned above.

Stipend answered 3/1, 2020 at 13:54 Comment(4)
Right...but you mentioned two things that are present in the question already. My question asks for code as to how to achieve this, considering what I have so farBragg
To be more direct, you cannot do what you are describing while maintaining best practices. In addition, you should not be storing tokens or any sensitive data in localStorage. If you need proof that what you are requesting is not possible atm see this issue: github.com/spotify/web-api/issues/1215 edit: it may be worth submitting a feature request as it seems like something other developers are requesting.Stipend
'not possible' and 'not recommended' are different things. Yes, it can be done. if I can get as many tokens as a want it by clicking on my form multiples times, it can be done programatically. and that is what the question asks. my token will be encrypted, like I said on the question, and will not leak. client will deal with cookies encoding tokens, not tokens by themselves. and it's not a product anyway, it's just a test for my understanding of how silent refresh works. thanks for the advice anyway..Bragg
if you would like to post some code as to how implement silent refresh using React and iFrame, I could accept your answer, because all I've found was with angular.Bragg

© 2022 - 2024 — McMap. All rights reserved.