How to persist Auth0 login status in browser for React SPA
Asked Answered
B

2

9

Currently when I create my routes, I check an Auth0 method - isAuthenticated() - to determine whether or not to return a protected page or redirect to login. However, this state only exists in memory and does not keep a user on their page upon browser refresh and I would like to do so.

This is a React/RR4/React Context app and my Auth0 methods are listed in Auth.js (below).

It is highly inadvisable to store login state in localStorage. And if I store my Auth0 tokens in cookies, I'm not sure how I would validate the tokens since there is no server validation set up. What is the correct condition to check that will enable secure data persistence?

ProtectedRoutes.jsx:

   <Route
      exact
      key={route.path}
      path={route.path}
      render={() => (
        // CONDITION TO CHECK
        context.auth.isAuthenticated()
          ? (
            <div>
              <route.component />
            </div>
          ) : <Redirect to="/login" />
        )}
      />

Auth.js (added for reference):

import auth0 from 'auth0-js';
import authConfig from './auth0-variables';

class Auth {
  accessToken;
  idToken;
  expiresAt;
  tokenRenewalTimeout;

  auth0 = new auth0.WebAuth({
    domain: authConfig.domain,
    clientID: authConfig.clientId,
    redirectUri: authConfig.callbackUrl,
    responseType: 'token id_token',
    scope: 'openid'
  });

  constructor() {
    this.scheduleRenewal();
    this.login = this.login.bind(this);
    this.logout = this.logout.bind(this);
    this.handleAuthentication = this.handleAuthentication.bind(this);
    this.isAuthenticated = this.isAuthenticated.bind(this);
    this.getAccessToken = this.getAccessToken.bind(this);
    this.getIdToken = this.getIdToken.bind(this);
    this.renewSession = this.renewSession.bind(this);
    this.scheduleRenewal = this.scheduleRenewal.bind(this);
  }

  login() {
    console.log('logging in!');
    this.auth0.authorize();
  }

  handleAuthentication() {
    return new Promise((resolve, reject) => {
      this.auth0.parseHash((err, authResult) => {
        if (err) return reject(err);
        console.log(authResult);
        if (!authResult || !authResult.idToken) {
          return reject(err);
        }
        this.setSession(authResult);
        resolve();
      });
    });
  }

  getAccessToken() {
    return this.accessToken;
  }

  getIdToken() {
    return this.idToken;
  }

  getExpiration() {
    return new Date(this.expiresAt);
  }

  isAuthenticated() {
    let expiresAt = this.expiresAt;
    return new Date().getTime() < expiresAt;
  }

  setSession(authResult) {
    localStorage.setItem('isLoggedIn', 'true');
    let expiresAt = (authResult.expiresIn * 1000) + new Date().getTime();
    this.accessToken = authResult.accessToken;
    this.idToken = authResult.idToken;
    this.expiresAt = expiresAt;
    this.scheduleRenewal();
  }

  renewSession() {
    this.auth0.checkSession({}, (err, authResult) => {
      if (authResult && authResult.accessToken && authResult.idToken) {
        this.setSession(authResult);
      } else if (err) {
        this.logout();
        console.log(`Could not get a new token. (${err.error}: ${err.error_description})`);
      }
    });
  }

  scheduleRenewal() {
    let expiresAt = this.expiresAt;
    const timeout = expiresAt - Date.now();
    if (timeout > 0) {
      this.tokenRenewalTimeout = setTimeout(() => {
        this.renewSession();
      }, timeout);
    }
  }

  logout() {
    this.accessToken = null;
    this.idToken = null;
    this.expiresAt = 0;
    localStorage.removeItem('isLoggedIn');
    clearTimeout(this.tokenRenewalTimeout);
    console.log('logged out!');
  }
}

export default Auth;
Bjorn answered 28/2, 2019 at 19:28 Comment(0)
C
8

You can use Silent authentication to renew the tokens on browser refresh.

Specifically for your react SPA app

  • setup a state say tokenRenewed to false in your main App component
  • you already have a renewToken method in your auth.js so call that in componentDidMount method
componentDidMount() {
   this.auth.renewToken(() => {
      this.setState({tokenRenewed : true});
   })
}
  • update renewToken to accept a callback cb like below
renewSession(cb) {
    this.auth0.checkSession({}, (err, authResult) => {
      if (authResult && authResult.accessToken && authResult.idToken) {
        this.setSession(authResult);
      } else if (err) {
        this.logout();
        console.log(`Could not get a new token. (${err.error}: ${err.error_description})`);
      }
      if(cb) cb(err, authResult);
    });
  }
  • Make sure you don't load the App component unless tokenRenewed is true i.e. unless you have the valid tokens renewed via silent authentication
render() {
    if(!this.state.tokenRenewed) return "loading...";
    return (
      // Your App component
    );
}

Notes:

  1. You may want to make sure you have the correct Allowed Web Origins set in the Application settings for this to work
  2. Silent authentication has some limitations as in it requires 3rd party cookies enabled on the browser and in case ITP for Safari. You should setup a custom domain to avoid that. Refer to the official auth0 docs to learn more here.
  3. More details on how to store token securely here
Coffeepot answered 3/3, 2019 at 13:14 Comment(1)
I would add that state changes might not work if the route is already redirected so I changed the redirect component to an animated loaderBjorn
G
3

I'd like to quote official Auth0 docs here.

The Auth0 SPA SDK stores tokens in memory by default. However, this does not provide persistence across page refreshes and browser tabs. Instead, you can opt-in to store tokens in local storage by setting the cacheLocation property to localstorage when initializing the SDK. This can help to mitigate some of the effects of browser privacy technology that prevents access to the Auth0 session cookie by storing Access Tokens for longer.

Storing tokens in browser local storage provides persistence across page refreshes and browser tabs. However, if an attacker can achieve running JavaScript in the SPA using a cross-site scripting (XSS) attack, they can retrieve the tokens stored in local storage. A vulnerability leading to a successful XSS attack can be either in the SPA source code or in any third-party JavaScript code (such as bootstrap, jQuery, or Google Analytics) included in the SPA.

Source

In short, the docs never said that storing tokens is local storage is a security issue by itself. XSS is the vulnerability (and it's not related to auth0 implementation in an app), because stealing user tokens is just one thing in a long list of bad things an attacker can do.

I liked the accepted answer, because it provides a working solution to the issue I had with auth0. But I don't think that refreshing should be done every time page reloads rather than on expiration.

Also I had a different behaviour in different browsers. This logout issue occasionally takes place in chrome and it happens every second time in safari. I think the implementation must be reliable and work consistently for all popular browsers/versions.

An example of how to apply the setting:

<Auth0Provider
  domain={domain}
  clientId={clientId}
  redirectUri={redirectUri}
  onRedirectCallback={onRedirectCallback}
  audience={audience}
  // Possible values: memory, localstorage, sessionstorage or cookies
  cacheLocation={'localstorage'}
>
  {children}
</Auth0Provider>
Galang answered 4/1, 2022 at 10:59 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.