Repository: auth0/auth0-spa-js
Pull request #315 (2019-12-18 17:25:43)

[SDK-1179] Support for rotating refresh tokens
### Description

This PR adds support for rotating refresh tokens. Some highlights:

* A new option on `createAuth0Client` called `useRefreshTokens` has been implemented and set to `false` by default. When this is `true`, the SDK will attempt to call the `/token` endpoint using the `refresh_token` grant type along with a refresh token from the cache.
* When `useRefreshTokens` is `false`, the existing code path that attempts to retrieve a new access token using a hidden iframe and `prompt=none` remains.

**Note:** This PR does not yet implement a retry mechanism for when there is a network error when trying to retrieve a new access token.

**Note 2:** This PR builds upon https://github.com/auth0/auth0-spa-js/tree/feature/storage. The two PRs will likely go in together and will be merged into https://github.com/auth0/auth0-spa-js/tree/release/rtr

### Testing

For manual testing, the playground as enhanced by feature/storage has been extended to support toggling the use of refresh tokens. This helps when visualizing the network traffic so you can see what network requests are being made. Clone, `npm install` then `npm start` to run the playground on `http://localhost:3000`. To make use of the RTR feature, your Auth0 tenant must have the feature flag enabled, and RTR turned on for a specific client using a call to API 2.

- [X] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [X] I have added documentation for new/changed functionality in this PR or in auth0.com/docs
- [X] All active GitHub checks for tests, formatting, and security are passing
- [X] The correct base branch is being used, if not `master`



Patch:
diff --git a/cypress/integration/getTokenSilently.js b/cypress/integration/getTokenSilently.js
index ded4764..15f7373 100644
--- a/cypress/integration/getTokenSilently.js
+++ b/cypress/integration/getTokenSilently.js
@@ -9,8 +9,9 @@ import {
 
 describe('getTokenSilently', function() {
   beforeEach(cy.resetTests);
+  afterEach(cy.logout);
 
-  it('return error when not logged in', function(done) {
+  it('returns an error when not logged in', function(done) {
     whenReady().then(win =>
       win.auth0.getTokenSilently().catch(error => {
         shouldBe('login_required', error.error);
@@ -19,73 +20,84 @@ describe('getTokenSilently', function() {
     );
   });
 
-  it.skip('Builds URL correctly', function(done) {
-    cy.login().then(() => {
-      whenReady().then(win => {
-        var iframe = win.document.createElement('iframe');
-        cy.stub(win.document, 'createElement', type =>
-          type === 'iframe' ? iframe : window.document.createElement
-        );
-        return win.auth0.getTokenSilently().then(() => {
-          const parsedUrl = new URL(iframe.src);
-          shouldBe(parsedUrl.host, 'brucke.auth0.com');
-          const pageParams = decode(parsedUrl.search.substr(1));
-          shouldBeUndefined(pageParams.code_verifier);
-          shouldNotBeUndefined(pageParams.code_challenge);
-          shouldNotBeUndefined(pageParams.code_challenge_method);
-          shouldNotBeUndefined(pageParams.state);
-          shouldNotBeUndefined(pageParams.nonce);
-          shouldBe(pageParams.redirect_uri, win.location.origin);
-          shouldBe(pageParams.response_mode, 'web_message');
-          shouldBe(pageParams.response_type, 'code');
-          shouldBe(pageParams.scope, 'openid profile email');
-          shouldBe(pageParams.client_id, 'wLSIP47wM39wKdDmOj6Zb5eSEw3JVhVp');
-          done();
+  describe('when using an iframe', () => {
+    describe('using an in-memory store', () => {
+      it('gets a new access token', () => {
+        return whenReady().then(win => {
+          cy.login().then(() => {
+            cy.get('[data-cy=get-token]').click();
+            cy.get('[data-cy=access-token]').should('have.length', 2); // 1 from handleRedirectCallback, 1 from clicking "Get access token"
+            cy.get('[data-cy=error]').should('not.exist');
+          });
         });
       });
-    });
-  });
 
-  it('return cached token after login', function(done) {
-    cy.login().then(() => {
-      whenReady().then(win =>
-        win.auth0.getTokenSilently().then(token => {
-          shouldNotBeUndefined(token);
-          win.auth0.getTokenSilently().then(token2 => {
-            shouldNotBeUndefined(token2);
-            shouldBe(token, token2);
-            done();
+      it('can get the access token after refreshing the page', () => {
+        return whenReady().then(win => {
+          cy.login().then(() => {
+            cy.reload();
+
+            cy.get('[data-cy=get-token]')
+              .click()
+              .wait(500)
+              .get('[data-cy=access-token]')
+              .should('have.length', 1);
+
+            cy.get('[data-cy=error]').should('not.exist');
           });
-        })
-      );
+        });
+      });
     });
-  });
 
-  it('ignores cache if `ignoreCache:true`', function(done) {
-    cy.login().then(() => {
-      whenReady().then(win =>
-        win.auth0.getTokenSilently().then(token => {
-          shouldNotBeUndefined(token);
-          win.auth0.getTokenSilently({ ignoreCache: true }).then(token2 => {
-            shouldNotBeUndefined(token2);
-            shouldNotBe(token, token2);
-            done();
+    describe('using local storage', () => {
+      it('can get the access token after refreshing the page', () => {
+        return whenReady().then(win => {
+          cy.toggleSwitch('local-storage');
+
+          cy.login().then(() => {
+            cy.reload();
+
+            cy.get('[data-cy=get-token]')
+              .click()
+              .wait(500)
+              .get('[data-cy=access-token]')
+              .should('have.length', 1)
+              .then(() => {
+                expect(
+                  win.localStorage.getItem(
+                    '@@auth0spajs@@::wLSIP47wM39wKdDmOj6Zb5eSEw3JVhVp::default::openid profile email'
+                  )
+                ).to.not.be.null;
+              });
+
+            cy.get('[data-cy=error]').should('not.exist');
           });
-        })
-      );
-    });
-  });
+        });
+      });
+
+      describe('when using refresh tokens', () => {
+        it('displays an error when trying to get an access token when the RT is missing', () => {
+          return whenReady().then(win => {
+            cy.toggleSwitch('local-storage');
+            cy.toggleSwitch('use-cache');
+
+            cy.login().then(() => {
+              cy.reload();
 
-  it('returns consent_required when using an audience without consent', function(done) {
-    cy.login().then(() => {
-      whenReady().then(win =>
-        win.auth0
-          .getTokenSilently({ audience: 'https://brucke.auth0.com/api/v2/' })
-          .catch(error => {
-            shouldBe('consent_required', error.error);
-            done();
-          })
-      );
+              cy.toggleSwitch('refresh-tokens').wait(500);
+
+              cy.get('[data-cy=get-token]')
+                .click()
+                .wait(500);
+
+              cy.get('[data-cy=error]').should(
+                'contain',
+                'No refresh token is available to fetch a new access token'
+              );
+            });
+          });
+        });
+      });
     });
   });
 });
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 7d14c42..b5e3465 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -12,19 +12,36 @@ import { whenReady } from './utils';
 //
 //
 // -- This is a parent command --
+
 Cypress.Commands.add('login', () => {
   cy.get('#login_redirect').click();
 
   cy.get('.auth0-lock-input-username .auth0-lock-input')
     .clear()
     .type('johnfoo+integration@gmail.com');
+
   cy.get('.auth0-lock-input-password .auth0-lock-input')
     .clear()
     .type('1234');
   cy.get('.auth0-lock-submit').click();
-  return whenReady().then(win => win.auth0.handleRedirectCallback());
+
+  return whenReady().then(() => {
+    cy.get('#handle_redirect_callback').click();
+    return cy.wait(250);
+  });
 });
 
+Cypress.Commands.add('handleRedirectCallback', () => {
+  cy.get('#handle_redirect_callback').click();
+  return cy.wait(250);
+});
+
+Cypress.Commands.add('logout', () => cy.get('[data-cy=logout]').click());
+
+Cypress.Commands.add('toggleSwitch', name =>
+  cy.get(`[data-cy=switch-${name}]`).click()
+);
+
 Cypress.Commands.add('loginNoCallback', () => {
   cy.get('#login_redirect').click();
 
@@ -39,5 +56,6 @@ Cypress.Commands.add('loginNoCallback', () => {
 
 Cypress.Commands.add('resetTests', () => {
   cy.visit('http://localhost:3000');
+  cy.get('#reset-config').click();
   cy.get('#logout').click();
 });
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index 8c7de7b..376ad59 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -5,7 +5,7 @@ import {
   createQueryParams,
   runPopup,
   parseQueryResult,
-  encodeState,
+  encode,
   createRandomString,
   runIframe,
   sha256,
@@ -17,7 +17,7 @@ import {
 import { InMemoryCache, ICache, LocalStorageCache } from './cache';
 import TransactionManager from './transaction-manager';
 import { verify as verifyIdToken } from './jwt';
-import { AuthenticationError } from './errors';
+import { AuthenticationError, GenericError } from './errors';
 import * as ClientStorage from './storage';
 import { DEFAULT_POPUP_CONFIG_OPTIONS } from './constants';
 import version from './version';
@@ -81,7 +81,13 @@ export default class Auth0Client {
     code_challenge: string,
     redirect_uri: string
   ): AuthorizeOptions {
-    const { domain, leeway, ...withoutDomain } = this.options;
+    const {
+      domain,
+      leeway,
+      useRefreshTokens,
+      cacheStrategy,
+      ...withoutDomain
+    } = this.options;
     return {
       ...withoutDomain,
       ...authorizeOptions,
@@ -134,13 +140,20 @@ export default class Auth0Client {
   public async buildAuthorizeUrl(
     options: RedirectLoginOptions = {}
   ): Promise<string> {
-    const { redirect_uri, appState, ...authorizeOptions } = options;
-    const stateIn = encodeState(createRandomString());
-    const nonceIn = createRandomString();
+    const {
+      redirect_uri,
+      appState,
+      cacheStrategy,
+      ...authorizeOptions
+    } = options;
+
+    const stateIn = encode(createRandomString());
+    const nonceIn = encode(createRandomString());
     const code_verifier = createRandomString();
     const code_challengeBuffer = await sha256(code_verifier);
     const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
     const fragment = options.fragment ? `#${options.fragment}` : '';
+
     const params = this._getParams(
       authorizeOptions,
       stateIn,
@@ -148,7 +161,9 @@ export default class Auth0Client {
       code_challenge,
       redirect_uri
     );
+
     const url = this._authorizeUrl(params);
+
     this.transactionManager.create(stateIn, {
       nonce: nonceIn,
       code_verifier,
@@ -156,6 +171,7 @@ export default class Auth0Client {
       scope: params.scope,
       audience: params.audience || 'default'
     });
+
     return url + fragment;
   }
 
@@ -181,11 +197,12 @@ export default class Auth0Client {
   ) {
     const popup = await openPopup();
     const { ...authorizeOptions } = options;
-    const stateIn = encodeState(createRandomString());
-    const nonceIn = createRandomString();
+    const stateIn = encode(createRandomString());
+    const nonceIn = encode(createRandomString());
     const code_verifier = createRandomString();
     const code_challengeBuffer = await sha256(code_verifier);
     const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
+
     const params = this._getParams(
       authorizeOptions,
       stateIn,
@@ -193,22 +210,28 @@ export default class Auth0Client {
       code_challenge,
       this.options.redirect_uri || window.location.origin
     );
+
     const url = this._authorizeUrl({
       ...params,
       response_mode: 'web_message'
     });
+
     const codeResult = await runPopup(popup, url, config);
+
     if (stateIn !== codeResult.state) {
       throw new Error('Invalid state');
     }
+
     const authResult = await oauthToken({
       baseUrl: this.domainUrl,
-      audience: options.audience || this.options.audience,
       client_id: this.options.client_id,
       code_verifier,
-      code: codeResult.code
-    });
+      code: codeResult.code,
+      grant_type: 'authorization_code'
+    } as OAuthTokenOptions);
+
     const decodedToken = this._verifyIdToken(authResult.id_token, nonceIn);
+
     const cacheEntry = {
       ...authResult,
       decodedToken,
@@ -216,7 +239,9 @@ export default class Auth0Client {
       audience: params.audience || 'default',
       client_id: this.options.client_id
     };
+
     this.cache.save(cacheEntry);
+
     ClientStorage.save('auth0.is.authenticated', true, { daysUntilExpire: 1 });
   }
 
@@ -261,7 +286,11 @@ export default class Auth0Client {
       scope: this.options.scope || this.DEFAULT_SCOPE
     }
   ) {
-    options.scope = getUniqueScopes(this.DEFAULT_SCOPE, options.scope);
+    options.scope = getUniqueScopes(
+      this.DEFAULT_SCOPE,
+      this.options.scope,
+      options.scope
+    );
 
     const cache = this.cache.get({
       client_id: this.options.client_id,
@@ -317,11 +346,11 @@ export default class Auth0Client {
 
     const authResult = await oauthToken({
       baseUrl: this.domainUrl,
-      audience: this.options.audience,
       client_id: this.options.client_id,
       code_verifier: transaction.code_verifier,
-      code
-    });
+      code,
+      grant_type: 'authorization_code'
+    } as OAuthTokenOptions);
 
     const decodedToken = this._verifyIdToken(
       authResult.id_token,
@@ -358,14 +387,17 @@ export default class Auth0Client {
    *
    * @param options
    */
-  public async getTokenSilently(
-    options: GetTokenSilentlyOptions = {
+  public async getTokenSilently(options: GetTokenSilentlyOptions = {}) {
+    options = {
       audience: this.options.audience,
-      scope: this.options.scope || this.DEFAULT_SCOPE,
-      ignoreCache: false
-    }
-  ) {
-    options.scope = getUniqueScopes(this.DEFAULT_SCOPE, options.scope);
+      scope: getUniqueScopes(
+        this.DEFAULT_SCOPE,
+        this.options.scope,
+        options.scope
+      ),
+      ignoreCache: false,
+      ...options
+    };
 
     try {
       await lock.acquireLock(GET_TOKEN_SILENTLY_LOCK_KEY, 5000);
@@ -383,56 +415,11 @@ export default class Auth0Client {
         }
       }
 
-      const stateIn = encodeState(createRandomString());
-      const nonceIn = createRandomString();
-      const code_verifier = createRandomString();
-      const code_challengeBuffer = await sha256(code_verifier);
-      const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
-
-      const authorizeOptions = {
-        audience: options.audience,
-        scope: options.scope
-      };
-
-      const params = this._getParams(
-        authorizeOptions,
-        stateIn,
-        nonceIn,
-        code_challenge,
-        this.options.redirect_uri || window.location.origin
-      );
-
-      const url = this._authorizeUrl({
-        ...params,
-        prompt: 'none',
-        response_mode: 'web_message'
-      });
-
-      const codeResult = await runIframe(url, this.domainUrl);
-
-      if (stateIn !== codeResult.state) {
-        throw new Error('Invalid state');
-      }
-
-      const authResult = await oauthToken({
-        baseUrl: this.domainUrl,
-        audience: options.audience || this.options.audience,
-        client_id: this.options.client_id,
-        code_verifier,
-        code: codeResult.code
-      });
-
-      const decodedToken = this._verifyIdToken(authResult.id_token, nonceIn);
-
-      const cacheEntry = {
-        ...authResult,
-        decodedToken,
-        scope: params.scope,
-        audience: params.audience || 'default',
-        client_id: this.options.client_id
-      };
+      const authResult = this.options.useRefreshTokens
+        ? await this._getTokenUsingRefreshToken(options)
+        : await this._getTokenFromIFrame(options);
 
-      this.cache.save(cacheEntry);
+      this.cache.save({ client_id: this.options.client_id, ...authResult });
 
       ClientStorage.save('auth0.is.authenticated', true, {
         daysUntilExpire: 1
@@ -519,4 +506,89 @@ export default class Auth0Client {
     const url = this._url(`/v2/logout?${createQueryParams(logoutOptions)}`);
     window.location.assign(`${url}${federatedQuery}`);
   }
+
+  private async _getTokenFromIFrame(
+    options: GetTokenSilentlyOptions
+  ): Promise<any> {
+    const stateIn = encode(createRandomString());
+    const nonceIn = encode(createRandomString());
+    const code_verifier = createRandomString();
+    const code_challengeBuffer = await sha256(code_verifier);
+    const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
+
+    const authorizeOptions = {
+      audience: options.audience,
+      scope: options.scope
+    };
+
+    const params = this._getParams(
+      authorizeOptions,
+      stateIn,
+      nonceIn,
+      code_challenge,
+      this.options.redirect_uri || window.location.origin
+    );
+
+    const url = this._authorizeUrl({
+      ...params,
+      prompt: 'none',
+      response_mode: 'web_message'
+    });
+
+    const codeResult = await runIframe(url, this.domainUrl);
+
+    if (stateIn !== codeResult.state) {
+      throw new Error('Invalid state');
+    }
+
+    const tokenResult = await oauthToken({
+      baseUrl: this.domainUrl,
+      client_id: this.options.client_id,
+      code_verifier,
+      code: codeResult.code,
+      grant_type: 'authorization_code'
+    } as OAuthTokenOptions);
+
+    const decodedToken = this._verifyIdToken(tokenResult.id_token, nonceIn);
+
+    return {
+      ...tokenResult,
+      decodedToken,
+      scope: params.scope,
+      audience: params.audience || 'default'
+    };
+  }
+
+  private async _getTokenUsingRefreshToken(
+    options: GetTokenSilentlyOptions
+  ): Promise<any> {
+    const cache = this.cache.get({
+      scope: options.scope,
+      audience: options.audience || 'default',
+      client_id: this.options.client_id
+    });
+
+    if (!cache || !cache.refresh_token) {
+      throw new GenericError(
+        'missing_refresh_token',
+        'No refresh token is available to fetch a new access token. The user should be reauthenticated.'
+      );
+    }
+
+    const tokenResult = await oauthToken({
+      baseUrl: this.domainUrl,
+      client_id: this.options.client_id,
+      grant_type: 'refresh_token',
+      refresh_token: cache.refresh_token
+    } as RefreshTokenOptions);
+
+    const decodedToken = this._verifyIdToken(tokenResult.id_token);
+
+    return {
+      ...tokenResult,
+      decodedToken,
+      scope: options.scope,
+      audience: options.audience || 'default'
+    };
+  }
 }
diff --git a/src/cache.ts b/src/cache.ts
index cca46c8..6cba73f 100644
--- a/src/cache.ts
+++ b/src/cache.ts
@@ -17,6 +17,7 @@ interface CacheEntry {
   audience: string;
   scope: string;
   client_id: string;
+  refresh_token?: string;
 }
 
 interface CachedTokens {
diff --git a/src/errors.ts b/src/errors.ts
index d169e28..ba23c6a 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -1,10 +1,13 @@
-export class AuthenticationError extends Error {
-  constructor(
-    public error: string,
-    public error_description: string,
-    public state: string
-  ) {
+export class GenericError extends Error {
+  constructor(public error: string, public error_description: string) {
     super(error_description);
+
+    Object.setPrototypeOf(this, GenericError.prototype);
+  }
+}
+export class AuthenticationError extends GenericError {
+  constructor(error: string, error_description: string, public state: string) {
+    super(error, error_description);
     //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
     Object.setPrototypeOf(this, AuthenticationError.prototype);
   }
diff --git a/src/global.ts b/src/global.ts
index 8d40afa..1c91dd2 100644
--- a/src/global.ts
+++ b/src/global.ts
@@ -92,10 +92,16 @@ interface Auth0ClientOptions extends BaseLoginOptions {
   leeway?: number;
 
   /**
-   * The strategy to use when storing cache data. Valid values are `memory` or `localstorage`.
+   * The location to use when storing cache data. Valid values are `memory` or `localstorage`.
    * The default setting is `memory`.
    */
   cacheLocation?: 'memory' | 'localstorage';
+
+  /**
+   * If true, refresh tokens are used to fetch new access tokens from the Auth0 server. If false, the legacy technique of using a hidden iframe and the `authorization_code` grant with `prompt=none` is used.
+   * The default setting is 'false'.
+   */
+  useRefreshTokens?: boolean;
 }
 
 /**
@@ -169,7 +175,7 @@ interface getIdTokenClaimsOptions {
   audience: string;
 }
 
-interface GetTokenSilentlyOptions extends GetUserOptions {
+interface GetTokenSilentlyOptions {
   /**
    * When `true`, ignores the cache and always sends a
    * request to Auth0.
@@ -186,6 +192,16 @@ interface GetTokenSilentlyOptions extends GetUserOptions {
    */
   redirect_uri?: string;
 
+  /**
+   * The scope that was used in the authentication request
+   */
+  scope?: string;
+
+  /**
+   * The audience that was used in the authentication request
+   */
+  audience?: string;
+
   /**
    * If you need to send custom parameters to the Authorization Server,
    * make sure to use the original parameter name.
@@ -231,17 +247,27 @@ interface AuthenticationResult {
   error_description?: string;
 }
 
+interface TokenEndpointOptions {
+  baseUrl: string;
+  client_id: string;
+  grant_type: string;
+}
+
 /**
  * @ignore
  */
-interface OAuthTokenOptions {
-  baseUrl: string;
-  client_id: string;
-  audience?: string;
+interface OAuthTokenOptions extends TokenEndpointOptions {
   code_verifier: string;
   code: string;
 }
 
+/**
+ * @ignore
+ */
+interface RefreshTokenOptions extends TokenEndpointOptions {
+  refresh_token: string;
+}
+
 /**
  * @ignore
  */
diff --git a/src/index.ts b/src/index.ts
index ccc605b..c988307 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -10,11 +10,15 @@ import * as ClientStorage from './storage';
 
 //this is necessary to export the type definitions used in this file
 import './global';
-import { validateCrypto } from './utils';
+import { validateCrypto, getUniqueScopes } from './utils';
 
 export default async function createAuth0Client(options: Auth0ClientOptions) {
   validateCrypto();
 
+  if (options.useRefreshTokens) {
+    options.scope = getUniqueScopes(options.scope, 'offline_access');
+  }
+
   const auth0 = new Auth0Client(options);
 
   if (!ClientStorage.get('auth0.is.authenticated')) {
diff --git a/src/utils.ts b/src/utils.ts
index 1bc56d2..061c381 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -105,8 +105,8 @@ export const createRandomString = () => {
   return random;
 };
 
-export const encodeState = (state: string) => btoa(state);
-export const decodeState = (state: string) => atob(state);
+export const encode = (value: string) => btoa(value);
+export const decode = (value: string) => atob(value);
 
 export const createQueryParams = (params: any) => {
   return Object.keys(params)
@@ -187,11 +187,13 @@ const getJSON = async (url, options) => {
   return success;
 };
 
-export const oauthToken = async ({ baseUrl, ...options }: OAuthTokenOptions) =>
+export const oauthToken = async ({
+  baseUrl,
+  ...options
+}: TokenEndpointOptions) =>
   await getJSON(`${baseUrl}/oauth/token`, {
     method: 'POST',
     body: JSON.stringify({
-      grant_type: 'authorization_code',
       redirect_uri: window.location.origin,
       ...options
     }),
diff --git a/static/index.html b/static/index.html
index ff72b10..024e961 100644
--- a/static/index.html
+++ b/static/index.html
@@ -33,13 +33,21 @@
             Login redirect
           </button>
 
-          <button class="btn btn-success" @click="loginHandleRedirectCallback">
+          <button
+            class="btn btn-success"
+            @click="loginHandleRedirectCallback"
+            id="handle_redirect_callback"
+          >
             Login redirect callback
           </button>
         </div>
 
         <div class="btn-group mb-3">
-          <button class="btn btn-secondary" @click="getToken">
+          <button
+            class="btn btn-secondary"
+            @click="getToken"
+            data-cy="get-token"
+          >
             Get access token
           </button>
 
@@ -57,7 +65,12 @@
         </div>
 
         <div class="btn-group mb-3">
-          <button class="btn btn-outline-primary" @click="logout" id="logout">
+          <button
+            class="btn btn-outline-primary"
+            @click="logout"
+            id="logout"
+            data-cy="logout"
+          >
             logout (default)
           </button>
 
@@ -66,6 +79,12 @@
           </button>
         </div>
 
+        <template v-if="error">
+          <hr />
+          <h3>Last error</h3>
+          <pre><code data-cy="error">{{JSON.stringify(error, null, 2)}}</code></pre>
+        </template>
+
         <hr />
 
         <div class="card mb-3" v-if="profile">
@@ -83,7 +102,7 @@
           <div class="card-header">Access Tokens</div>
           <div class="card-body">
             <ul v-for="token in access_tokens">
-              <li>{{token}}</li>
+              <li data-cy="access-token">{{token}}</li>
             </ul>
           </div>
         </div>
@@ -124,13 +143,53 @@
             id="storage-switch"
             v-model="useLocalStorage"
           />
-          <label for="storage-switch" class="custom-control-label"
+          <label
+            for="storage-switch"
+            class="custom-control-label"
+            data-cy="switch-local-storage"
             >Use local storage</label
           >
         </div>
 
-        <button type="submit" class="btn btn-primary">Save</button>
-        <button @click="resetForm" class="btn btn-outline-primary">
+        <div class="custom-control custom-switch mb-5">
+          <input
+            type="checkbox"
+            class="custom-control-input"
+            id="refresh_token_switch"
+            v-model="useRefreshTokens"
+          />
+          <label
+            for="refresh_token_switch"
+            class="custom-control-label"
+            data-cy="switch-refresh-tokens"
+            >Use refresh tokens</label
+          >
+        </div>
+
+        <div class="custom-control custom-switch mb-5">
+          <input
+            type="checkbox"
+            class="custom-control-input"
+            id="cache-switch"
+            v-model="useCache"
+          />
+          <label
+            for="cache-switch"
+            class="custom-control-label"
+            data-cy="switch-use-cache"
+            >Use token cache when fetching new tokens</label
+          >
+        </div>
+
+        <button @click="saveForm" class="btn btn-primary">
+          Save
+        </button>
+
+        <button
+          @click="resetForm"
+          class="btn btn-outline-primary"
+          id="reset-config"
+        >
           Reset
         </button>
       </form>
@@ -144,44 +203,51 @@
 
       var app = new Vue({
         el: '#app',
-        data() {
+        data: function() {
+          var savedData = localStorage.getItem('spa-playground-data');
+
+          var data = savedData ? JSON.parse(savedData) : {};
+
           return {
             auth0: null,
             loading: true,
-            useLocalStorage: false,
+            useLocalStorage: data.useLocalStorage || false,
+            useRefreshTokens: data.useRefreshTokens || false,
+            useCache: data.useCache === false ? false : true,
             profile: null,
             access_tokens: [],
             id_token: '',
             isAuthenticated: false,
-            domain: defaultDomain,
-            clientId: defaultClientId
+            domain: data.domain || defaultDomain,
+            clientId: data.clientId || defaultClientId,
+            error: null
           };
         },
-        created() {
+        created: function() {
           this.initializeClient();
-
-          var savedData = localStorage.getItem('spa-playground-data');
-
-          if (savedData) {
-            var data = JSON.parse(savedData);
-            this.domain = data.domain;
-            this.clientId = data.clientId;
-            this.useLocalStorage = data.useLocalStorage || false;
-          }
         },
         watch: {
-          useLocalStorage() {
+          useLocalStorage: function() {
+            this.initializeClient();
+            this.saveForm();
+          },
+          useRefreshTokens: function() {
             this.initializeClient();
+            this.saveForm();
+          },
+          useCache: function() {
+            this.saveForm();
           }
         },
         methods: {
-          initializeClient() {
+          initializeClient: function() {
             var _self = this;
 
             createAuth0Client({
               domain: _self.domain,
               client_id: _self.clientId,
-              cacheLocation: _self.useLocalStorage ? 'localstorage' : 'memory'
+              cacheLocation: _self.useLocalStorage ? 'localstorage' : 'memory',
+              useRefreshTokens: _self.useRefreshTokens
             }).then(function(auth0) {
               _self.auth0 = auth0;
               window.auth0 = auth0; // Cypress integration tests support
@@ -192,22 +258,27 @@
               });
             });
           },
-          saveForm() {
+          saveForm: function() {
             localStorage.setItem(
               'spa-playground-data',
               JSON.stringify({
                 domain: this.domain,
                 clientId: this.clientId,
-                useLocalStorage: this.useLocalStorage
+                useLocalStorage: this.useLocalStorage,
+                useRefreshTokens: this.useRefreshTokens,
+                useCache: this.useCache
               })
             );
           },
-          resetForm() {
+          resetForm: function() {
             this.domain = defaultDomain;
             this.clientId = defaultClientId;
+            this.useLocalStorage = false;
+            this.useRefreshTokens = false;
+            this.useCache = true;
             this.saveForm();
           },
-          showAuth0Info() {
+          showAuth0Info: function() {
             var _self = this;
             _self.access_tokens = [];
 
@@ -223,7 +294,7 @@
               _self.id_token = claims.__raw;
             });
           },
-          loginPopup() {
+          loginPopup: function() {
             var _self = this;
 
             _self.auth0
@@ -234,12 +305,12 @@
                 _self.showAuth0Info();
               });
           },
-          loginRedirect() {
+          loginRedirect: function() {
             this.auth0.loginWithRedirect({
               redirect_uri: 'http://localhost:3000/'
             });
           },
-          loginHandleRedirectCallback() {
+          loginHandleRedirectCallback: function() {
             var _self = this;
 
             _self.auth0.handleRedirectCallback().then(function() {
@@ -249,17 +320,32 @@
                 window.location.origin + '/'
               );
 
-              _self.showAuth0Info();
+              auth0.isAuthenticated().then(function(isAuthenticated) {
+                _self.isAuthenticated = isAuthenticated;
+                _self.showAuth0Info();
+              });
             });
           },
-          getToken() {
+          getToken: function() {
             var _self = this;
 
-            _self.auth0.getTokenSilently().then(function(token) {
-              _self.access_tokens.push(token);
-            });
+            _self.auth0
+              .getTokenSilently({ ignoreCache: !_self.useCache })
+              .then(function(token) {
+                _self.access_tokens.push(token);
+                _self.error = null;
+              })
+              .catch(function(e) {
+                console.error(e);
+
+                if (e.message) {
+                  _self.error = e.message;
+                } else {
+                  _self.error = e;
+                }
+              });
           },
-          getMultipleTokens() {
+          getMultipleTokens: function() {
             var _self = this;
 
             Promise.all([
@@ -269,7 +355,7 @@
               _self.access_tokens = [tokens[0], tokens[1]];
             });
           },
-          getTokenPopup() {
+          getTokenPopup: function() {
             var _self = this;
 
             _self.auth0
@@ -281,13 +367,14 @@
                 _self.access_tokens = [token];
               });
           },
-          getTokenAudience() {
+          getTokenAudience: function() {
             var _self = this;
 
             var differentAudienceOptions = {
               audience: 'https://brucke.auth0.com/api/v2/',
               scope: 'read:rules',
-              redirect_uri: 'http://localhost:3000/callback.html'
+              redirect_uri: 'http://localhost:3000/callback.html',
+              ignoreCache: !_self.useCache
             };
 
             _self.auth0
@@ -296,12 +383,12 @@
                 _self.access_tokens = [token];
               });
           },
-          logout() {
+          logout: function() {
             this.auth0.logout({
               returnTo: 'http://localhost:3000/'
             });
           },
-          logoutNoClient() {
+          logoutNoClient: function() {
             this.auth0.logout({
               client_id: null,
               returnTo: 'http://localhost:3000/'


Test patch:
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index 67507ab..ab7ac64 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -23,6 +23,7 @@ const TEST_BASE64_ENCODED_STRING = 'base64-url-encoded-string';
 const TEST_CODE = 'code';
 const TEST_ID_TOKEN = 'id-token';
 const TEST_ACCESS_TOKEN = 'access-token';
+const TEST_REFRESH_TOKEN = 'refresh-token';
 const TEST_USER_ID = 'user-id';
 const TEST_USER_EMAIL = 'user@email.com';
 const TEST_APP_STATE = { bestPet: 'dog' };
@@ -66,7 +67,7 @@ const setup = async (options = {}) => {
 
   utils.createQueryParams.mockReturnValue(TEST_QUERY_PARAMS);
   utils.getUniqueScopes.mockReturnValue(TEST_SCOPES);
-  utils.encodeState.mockReturnValue(TEST_ENCODED_STATE);
+  utils.encode.mockReturnValue(TEST_ENCODED_STATE);
   utils.createRandomString.mockReturnValue(TEST_RANDOM_STRING);
   utils.sha256.mockReturnValue(Promise.resolve(TEST_ARRAY_BUFFER));
   utils.bufferToBase64UrlEncoded.mockReturnValue(TEST_BASE64_ENCODED_STRING);
@@ -159,7 +160,7 @@ describe('Auth0', () => {
       const { auth0, utils } = await setup();
 
       await auth0.loginWithPopup({});
-      expect(utils.encodeState).toHaveBeenCalledWith(TEST_RANDOM_STRING);
+      expect(utils.encode).toHaveBeenCalledWith(TEST_RANDOM_STRING);
     });
     it('creates `code_challenge` by using `utils.sha256` with the result of `utils.createRandomString`', async () => {
       const { auth0, utils } = await setup();
@@ -182,7 +183,7 @@ describe('Auth0', () => {
         response_type: TEST_CODE,
         response_mode: 'web_message',
         state: TEST_ENCODED_STATE,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         redirect_uri: 'http://localhost',
         code_challenge: TEST_BASE64_ENCODED_STRING,
         code_challenge_method: 'S256',
@@ -201,7 +202,7 @@ describe('Auth0', () => {
         response_type: TEST_CODE,
         response_mode: 'web_message',
         state: TEST_ENCODED_STATE,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         redirect_uri: 'http://localhost',
         code_challenge: TEST_BASE64_ENCODED_STRING,
         code_challenge_method: 'S256',
@@ -221,7 +222,7 @@ describe('Auth0', () => {
         response_type: TEST_CODE,
         response_mode: 'web_message',
         state: TEST_ENCODED_STATE,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         redirect_uri,
         code_challenge: TEST_BASE64_ENCODED_STRING,
         code_challenge_method: 'S256'
@@ -238,7 +239,7 @@ describe('Auth0', () => {
         response_type: TEST_CODE,
         response_mode: 'web_message',
         state: TEST_ENCODED_STATE,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         redirect_uri: 'http://localhost',
         code_challenge: TEST_BASE64_ENCODED_STRING,
         code_challenge_method: 'S256'
@@ -282,12 +283,13 @@ describe('Auth0', () => {
       const { auth0, utils } = await setup();
 
       await auth0.loginWithPopup({});
+
       expect(utils.oauthToken).toHaveBeenCalledWith({
-        audience: undefined,
         baseUrl: 'https://test.auth0.com',
         client_id: TEST_CLIENT_ID,
         code: TEST_CODE,
-        code_verifier: TEST_RANDOM_STRING
+        code_verifier: TEST_RANDOM_STRING,
+        grant_type: 'authorization_code'
       });
     });
     it('calls oauth/token with correct params', async () => {
@@ -295,11 +297,11 @@ describe('Auth0', () => {
 
       await auth0.loginWithPopup({ audience: 'test-audience' });
       expect(utils.oauthToken).toHaveBeenCalledWith({
-        audience: 'test-audience',
         baseUrl: 'https://test.auth0.com',
         client_id: TEST_CLIENT_ID,
         code: TEST_CODE,
-        code_verifier: TEST_RANDOM_STRING
+        code_verifier: TEST_RANDOM_STRING,
+        grant_type: 'authorization_code'
       });
     });
     it('calls `tokenVerifier.verify` with the `id_token` from in the oauth/token response', async () => {
@@ -308,7 +310,7 @@ describe('Auth0', () => {
       await auth0.loginWithPopup({});
       expect(tokenVerifier).toHaveBeenCalledWith({
         id_token: TEST_ID_TOKEN,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         aud: 'test-client-id',
         iss: 'https://test.auth0.com/',
         leeway: undefined,
@@ -324,7 +326,7 @@ describe('Auth0', () => {
       expect(tokenVerifier).toHaveBeenCalledWith({
         aud: 'test-client-id',
         id_token: TEST_ID_TOKEN,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         iss: 'https://test-123.auth0.com/',
         leeway: undefined,
         max_age: undefined
@@ -336,7 +338,7 @@ describe('Auth0', () => {
       await auth0.loginWithPopup({});
       expect(tokenVerifier).toHaveBeenCalledWith({
         id_token: TEST_ID_TOKEN,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         aud: 'test-client-id',
         iss: 'https://test.auth0.com/',
         leeway: 10,
@@ -349,7 +351,7 @@ describe('Auth0', () => {
       await auth0.loginWithPopup({});
       expect(tokenVerifier).toHaveBeenCalledWith({
         id_token: TEST_ID_TOKEN,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         aud: 'test-client-id',
         iss: 'https://test.auth0.com/',
         leeway: undefined,
@@ -362,7 +364,7 @@ describe('Auth0', () => {
       await auth0.loginWithPopup({});
       expect(tokenVerifier).toHaveBeenCalledWith({
         id_token: TEST_ID_TOKEN,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         aud: 'test-client-id',
         iss: 'https://test.auth0.com/',
         leeway: undefined,
@@ -375,7 +377,7 @@ describe('Auth0', () => {
       await auth0.loginWithPopup({});
       expect(tokenVerifier).toHaveBeenCalledWith({
         id_token: TEST_ID_TOKEN,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         aud: 'test-client-id',
         iss: 'https://test.auth0.com/',
         leeway: undefined,
@@ -416,18 +418,20 @@ describe('Auth0', () => {
     });
   });
 
-  describe('buildAuthorizeUrl()', () => {
+  describe('buildAuthorizeUrl', () => {
     const REDIRECT_OPTIONS = {
       redirect_uri: 'https://redirect.uri',
       appState: TEST_APP_STATE,
       connection: 'test-connection'
     };
+
     it('encodes state with random string', async () => {
       const { auth0, utils } = await setup();
 
       await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS);
-      expect(utils.encodeState).toHaveBeenCalledWith(TEST_RANDOM_STRING);
+      expect(utils.encode).toHaveBeenCalledWith(TEST_RANDOM_STRING);
     });
+
     it('creates `code_challenge` by using `utils.sha256` with the result of `utils.createRandomString`', async () => {
       const { auth0, utils } = await setup();
 
@@ -437,23 +441,64 @@ describe('Auth0', () => {
         TEST_ARRAY_BUFFER
       );
     });
+
     it('creates correct query params', async () => {
       const { auth0, utils } = await setup();
 
       await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS);
+
+      expect(utils.getUniqueScopes).toHaveBeenCalledWith(
+        'openid profile email',
+        undefined,
+        undefined
+      );
+
       expect(utils.createQueryParams).toHaveBeenCalledWith({
         client_id: TEST_CLIENT_ID,
         scope: TEST_SCOPES,
         response_type: TEST_CODE,
         response_mode: 'query',
         state: TEST_ENCODED_STATE,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         redirect_uri: REDIRECT_OPTIONS.redirect_uri,
         code_challenge: TEST_BASE64_ENCODED_STRING,
         code_challenge_method: 'S256',
         connection: 'test-connection'
       });
     });
+
+    it('creates correct query params when using refresh tokens', async () => {
+      const utils = require('../src/utils');
+      utils.getUniqueScopes.mockReturnValue('offline_access');
+
+      const { auth0 } = await setup({
+        useRefreshTokens: true
+      });
+
+      // utils.getUniqueScopes.mockReturnValue(`${TEST_SCOPES} offline_access`);
+
+      await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS);
+
+      expect(utils.getUniqueScopes).toHaveBeenCalledWith(
+        'openid profile email',
+        'offline_access',
+        undefined
+      );
+
+      expect(utils.createQueryParams).toHaveBeenCalledWith({
+        client_id: TEST_CLIENT_ID,
+        scope: TEST_SCOPES,
+        response_type: TEST_CODE,
+        response_mode: 'query',
+        state: TEST_ENCODED_STATE,
+        nonce: TEST_ENCODED_STATE,
+        redirect_uri: REDIRECT_OPTIONS.redirect_uri,
+        code_challenge: TEST_BASE64_ENCODED_STRING,
+        code_challenge_method: 'S256',
+        connection: 'test-connection'
+      });
+    });
+
     it('creates correct query params without leeway', async () => {
       const { auth0, utils } = await setup({ leeway: 10 });
 
@@ -464,13 +509,14 @@ describe('Auth0', () => {
         response_type: TEST_CODE,
         response_mode: 'query',
         state: TEST_ENCODED_STATE,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         redirect_uri: REDIRECT_OPTIONS.redirect_uri,
         code_challenge: TEST_BASE64_ENCODED_STRING,
         code_challenge_method: 'S256',
         connection: 'test-connection'
       });
     });
+
     it('creates correct query params when providing a default redirect_uri', async () => {
       const redirect_uri = 'https://custom-redirect-uri/callback';
       const { redirect_uri: _ignore, ...options } = REDIRECT_OPTIONS;
@@ -486,13 +532,14 @@ describe('Auth0', () => {
         response_type: TEST_CODE,
         response_mode: 'query',
         state: TEST_ENCODED_STATE,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         redirect_uri,
         code_challenge: TEST_BASE64_ENCODED_STRING,
         code_challenge_method: 'S256',
         connection: 'test-connection'
       });
     });
+
     it('creates correct query params when overriding redirect_uri', async () => {
       const redirect_uri = 'https://custom-redirect-uri/callback';
       const { auth0, utils } = await setup({
@@ -507,17 +554,19 @@ describe('Auth0', () => {
         response_type: TEST_CODE,
         response_mode: 'query',
         state: TEST_ENCODED_STATE,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         redirect_uri: REDIRECT_OPTIONS.redirect_uri,
         code_challenge: TEST_BASE64_ENCODED_STRING,
         code_challenge_method: 'S256',
         connection: 'test-connection'
       });
     });
+
     it('creates correct query params with custom params', async () => {
       const { auth0, utils } = await setup();
 
       await auth0.buildAuthorizeUrl({ ...REDIRECT_OPTIONS, audience: 'test' });
+
       expect(utils.createQueryParams).toHaveBeenCalledWith({
         audience: 'test',
         client_id: TEST_CLIENT_ID,
@@ -525,13 +574,14 @@ describe('Auth0', () => {
         response_type: TEST_CODE,
         response_mode: 'query',
         state: TEST_ENCODED_STATE,
-        nonce: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
         redirect_uri: REDIRECT_OPTIONS.redirect_uri,
         code_challenge: TEST_BASE64_ENCODED_STRING,
         code_challenge_method: 'S256',
         connection: 'test-connection'
       });
     });
+
     it('calls `transactionManager.create` with new transaction', async () => {
       const { auth0, transactionManager } = await setup();
 
@@ -542,25 +592,29 @@ describe('Auth0', () => {
           appState: TEST_APP_STATE,
           audience: 'default',
           code_verifier: TEST_RANDOM_STRING,
-          nonce: TEST_RANDOM_STRING,
+          nonce: TEST_ENCODED_STATE,
           scope: TEST_SCOPES
         }
       );
     });
+
     it('returns the url', async () => {
       const { auth0 } = await setup();
 
       const url = await auth0.buildAuthorizeUrl({
         ...REDIRECT_OPTIONS
       });
+
       expect(url).toBe(
         `https://test.auth0.com/authorize?query=params${TEST_TELEMETRY_QUERY_STRING}`
       );
     });
+
     it('returns the url when no arguments are passed', async () => {
       const { auth0 } = await setup();
 
       const url = await auth0.buildAuthorizeUrl();
+
       expect(url).toBe(
         `https://test.auth0.com/authorize?query=params${TEST_TELEMETRY_QUERY_STRING}`
       );
@@ -573,6 +627,7 @@ describe('Auth0', () => {
       appState: TEST_APP_STATE,
       connection: 'test-connection'
     };
+
     it('calls `window.location.assign` with the correct url', async () => {
       const { auth0 } = await setup();
 
@@ -581,6 +636,7 @@ describe('Auth0', () => {
         `https://test.auth0.com/authorize?query=params${TEST_TELEMETRY_QUERY_STRING}`
       );
     });
+
     it('calls `window.location.assign` with the correct url and fragment if provided', async () => {
       const { auth0 } = await setup();
 
@@ -592,6 +648,7 @@ describe('Auth0', () => {
         `https://test.auth0.com/authorize?query=params${TEST_TELEMETRY_QUERY_STRING}#/reset`
       );
     });
+
     it('can be called with no arguments', async () => {
       const { auth0 } = await setup();
 
@@ -621,7 +678,7 @@ describe('Auth0', () => {
         const result = await setup();
         result.transactionManager.get.mockReturnValue({
           code_verifier: TEST_RANDOM_STRING,
-          nonce: TEST_RANDOM_STRING,
+          nonce: TEST_ENCODED_STATE,
           audience: 'default',
           scope: TEST_SCOPES,
           appState: TEST_APP_STATE
@@ -724,11 +781,11 @@ describe('Auth0', () => {
         await auth0.handleRedirectCallback();
 
         expect(utils.oauthToken).toHaveBeenCalledWith({
-          audience: undefined,
           baseUrl: 'https://test.auth0.com',
           client_id: TEST_CLIENT_ID,
           code: TEST_CODE,
-          code_verifier: TEST_RANDOM_STRING
+          code_verifier: TEST_RANDOM_STRING,
+          grant_type: 'authorization_code'
         });
       });
       it('calls `tokenVerifier.verify` with the `id_token` from in the oauth/token response', async () => {
@@ -738,7 +795,7 @@ describe('Auth0', () => {
 
         expect(tokenVerifier).toHaveBeenCalledWith({
           id_token: TEST_ID_TOKEN,
-          nonce: TEST_RANDOM_STRING,
+          nonce: TEST_ENCODED_STATE,
           aud: 'test-client-id',
           iss: 'https://test.auth0.com/',
           leeway: undefined,
@@ -794,7 +851,7 @@ describe('Auth0', () => {
         const result = await setup();
         result.transactionManager.get.mockReturnValue({
           code_verifier: TEST_RANDOM_STRING,
-          nonce: TEST_RANDOM_STRING,
+          nonce: TEST_ENCODED_STATE,
           audience: 'default',
           scope: TEST_SCOPES,
           appState: TEST_APP_STATE
@@ -905,11 +962,11 @@ describe('Auth0', () => {
         await auth0.handleRedirectCallback();
 
         expect(utils.oauthToken).toHaveBeenCalledWith({
-          audience: undefined,
           baseUrl: 'https://test.auth0.com',
           client_id: TEST_CLIENT_ID,
           code: TEST_CODE,
-          code_verifier: TEST_RANDOM_STRING
+          code_verifier: TEST_RANDOM_STRING,
+          grant_type: 'authorization_code'
         });
       });
       it('calls `tokenVerifier.verify` with the `id_token` from in the oauth/token response', async () => {
@@ -919,7 +976,7 @@ describe('Auth0', () => {
 
         expect(tokenVerifier).toHaveBeenCalledWith({
           id_token: TEST_ID_TOKEN,
-          nonce: TEST_RANDOM_STRING,
+          nonce: TEST_ENCODED_STATE,
           aud: 'test-client-id',
           iss: 'https://test.auth0.com/',
           leeway: undefined,
@@ -1030,6 +1087,7 @@ describe('Auth0', () => {
       const decodedToken = await auth0.getIdTokenClaims();
       expect(decodedToken).toBeUndefined();
     });
+
     it('returns full decoded token if there is a cache entry', async () => {
       const { auth0, cache } = await setup();
       const userIn = {
@@ -1046,41 +1104,97 @@ describe('Auth0', () => {
       const userOut = await auth0.getIdTokenClaims();
       expect(userOut).toEqual(userIn.decodedToken.claims);
     });
-    it('uses default options', async () => {
-      const { auth0, utils, cache } = await setup();
 
-      await auth0.getIdTokenClaims();
+    describe('when using refresh tokens', () => {
+      it('uses default options with offine_access', async () => {
+        const utils = require('../src/utils');
+        utils.getUniqueScopes.mockReturnValue('offline_access');
 
-      expect(cache.get).toHaveBeenCalledWith({
-        audience: 'default',
-        scope: TEST_SCOPES,
-        client_id: TEST_CLIENT_ID
+        const { auth0, cache } = await setup({
+          useRefreshTokens: true
+        });
+
+        await auth0.getIdTokenClaims();
+
+        expect(cache.get).toHaveBeenCalledWith({
+          audience: 'default',
+          scope: TEST_SCOPES,
+          client_id: TEST_CLIENT_ID
+        });
+
+        expect(utils.getUniqueScopes).toHaveBeenCalledWith(
+          'openid profile email',
+          'offline_access',
+          'offline_access'
+        );
       });
 
-      expect(utils.getUniqueScopes).toHaveBeenCalledWith(
-        'openid profile email',
-        'openid profile email'
-      );
-    });
+      it('uses custom options when provided with offline_access', async () => {
+        const utils = require('../src/utils');
+        utils.getUniqueScopes.mockReturnValue('offline_access');
 
-    it('uses custom options when provided', async () => {
-      const { auth0, utils, cache } = await setup();
+        const { auth0, cache } = await setup({
+          useRefreshTokens: true
+        });
 
-      await auth0.getIdTokenClaims({
-        audience: 'the-audience',
-        scope: 'the-scope'
+        await auth0.getIdTokenClaims({
+          audience: 'the-audience',
+          scope: 'the-scope'
+        });
+
+        expect(cache.get).toHaveBeenCalledWith({
+          audience: 'the-audience',
+          scope: TEST_SCOPES,
+          client_id: TEST_CLIENT_ID
+        });
+
+        expect(utils.getUniqueScopes).toHaveBeenCalledWith(
+          'openid profile email',
+          'offline_access',
+          'the-scope'
+        );
       });
+    });
 
-      expect(cache.get).toHaveBeenCalledWith({
-        audience: 'the-audience',
-        scope: TEST_SCOPES,
-        client_id: TEST_CLIENT_ID
+    describe('when not using refresh tokens', () => {
+      it('uses default options', async () => {
+        const { auth0, utils, cache } = await setup();
+
+        await auth0.getIdTokenClaims();
+
+        expect(cache.get).toHaveBeenCalledWith({
+          audience: 'default',
+          scope: TEST_SCOPES,
+          client_id: TEST_CLIENT_ID
+        });
+
+        expect(utils.getUniqueScopes).toHaveBeenCalledWith(
+          'openid profile email',
+          undefined,
+          'openid profile email'
+        );
       });
 
-      expect(utils.getUniqueScopes).toHaveBeenCalledWith(
-        'openid profile email',
-        'the-scope'
-      );
+      it('uses custom options when provided', async () => {
+        const { auth0, utils, cache } = await setup();
+
+        await auth0.getIdTokenClaims({
+          audience: 'the-audience',
+          scope: 'the-scope'
+        });
+
+        expect(cache.get).toHaveBeenCalledWith({
+          audience: 'the-audience',
+          scope: TEST_SCOPES,
+          client_id: TEST_CLIENT_ID
+        });
+
+        expect(utils.getUniqueScopes).toHaveBeenCalledWith(
+          'openid profile email',
+          undefined,
+          'the-scope'
+        );
+      });
     });
   });
 
@@ -1105,51 +1219,156 @@ describe('Auth0', () => {
 
   describe('getTokenSilently()', () => {
     describe('when `options.ignoreCache` is false', async () => {
-      it('calls `cache.get` with the correct options', async () => {
-        const { auth0, cache, utils } = await setup();
-        cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN });
+      describe('when refresh tokens are not used', () => {
+        it('calls `cache.get` with the correct options', async () => {
+          const { auth0, cache, utils } = await setup();
+          cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN });
+
+          await auth0.getTokenSilently();
+
+          expect(cache.get).toHaveBeenCalledWith({
+            audience: 'default',
+            scope: TEST_SCOPES,
+            client_id: TEST_CLIENT_ID
+          });
+
+          expect(utils.getUniqueScopes).toHaveBeenCalledWith(
+            'openid profile email',
+            undefined,
+            undefined
+          );
+        });
 
-        await auth0.getTokenSilently();
+        it('returns cached access_token when there is a cache', async () => {
+          const { auth0, cache } = await setup();
+          cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN });
 
-        expect(cache.get).toHaveBeenCalledWith({
-          audience: 'default',
-          scope: TEST_SCOPES,
-          client_id: TEST_CLIENT_ID
+          const token = await auth0.getTokenSilently();
+
+          expect(token).toBe(TEST_ACCESS_TOKEN);
         });
 
-        expect(utils.getUniqueScopes).toHaveBeenCalledWith(
-          'openid profile email',
-          'openid profile email'
-        );
-      });
+        it('acquires and releases lock when there is a cache', async () => {
+          const { auth0, cache, lock } = await setup();
+          cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN });
 
-      it('returns cached access_token when there is a cache', async () => {
-        const { auth0, cache } = await setup();
-        cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN });
+          await auth0.getTokenSilently();
+          expect(lock.acquireLockMock).toHaveBeenCalledWith(
+            GET_TOKEN_SILENTLY_LOCK_KEY,
+            5000
+          );
+          expect(lock.releaseLockMock).toHaveBeenCalledWith(
+            GET_TOKEN_SILENTLY_LOCK_KEY
+          );
+        });
 
-        const token = await auth0.getTokenSilently();
+        it('continues method execution when there is no cache available', async () => {
+          const { auth0, utils } = await setup();
 
-        expect(token).toBe(TEST_ACCESS_TOKEN);
-      });
-      it('acquires and releases lock when there is a cache', async () => {
-        const { auth0, cache, lock } = await setup();
-        cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN });
+          await auth0.getTokenSilently();
 
-        await auth0.getTokenSilently();
-        expect(lock.acquireLockMock).toHaveBeenCalledWith(
-          GET_TOKEN_SILENTLY_LOCK_KEY,
-          5000
-        );
-        expect(lock.releaseLockMock).toHaveBeenCalledWith(
-          GET_TOKEN_SILENTLY_LOCK_KEY
-        );
+          //we only evaluate that the code didn't bail out because of the cache
+          expect(utils.encode).toHaveBeenCalledWith(TEST_RANDOM_STRING);
+        });
       });
-      it('continues method execution when there is no cache available', async () => {
-        const { auth0, utils } = await setup();
 
-        await auth0.getTokenSilently();
-        //we only evaluate that the code didn't bail out because of the cache
-        expect(utils.encodeState).toHaveBeenCalledWith(TEST_RANDOM_STRING);
+      describe('when refresh tokens are used', () => {
+        it('calls `cache.get` with the correct options', async () => {
+          const utils = require('../src/utils');
+          utils.getUniqueScopes.mockReturnValue('offline_access');
+
+          const { auth0, cache } = await setup({
+            useRefreshTokens: true
+          });
+
+          utils.getUniqueScopes.mockReturnValue(
+            `${TEST_SCOPES} offline_access`
+          );
+
+          cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN });
+
+          await auth0.getTokenSilently();
+
+          expect(cache.get).toHaveBeenCalledWith({
+            audience: 'default',
+            scope: `${TEST_SCOPES} offline_access`,
+            client_id: TEST_CLIENT_ID
+          });
+
+          expect(utils.getUniqueScopes).toHaveBeenCalledWith(
+            'openid profile email',
+            'offline_access',
+            undefined
+          );
+        });
+
+        it('calls the token endpoint with the correct params', async () => {
+          const { auth0, cache, utils } = await setup({
+            useRefreshTokens: true
+          });
+
+          utils.getUniqueScopes.mockReturnValue(
+            `${TEST_SCOPES} offline_access`
+          );
+
+          utils.oauthToken.mockReturnValue(
+            Promise.resolve({
+              id_token: TEST_ID_TOKEN,
+              access_token: TEST_ACCESS_TOKEN,
+              refresh_token: TEST_REFRESH_TOKEN
+            })
+          );
+
+          cache.get.mockReturnValue({ refresh_token: TEST_REFRESH_TOKEN });
+
+          await auth0.getTokenSilently({ ignoreCache: true });
+
+          expect(cache.get).toHaveBeenCalledWith({
+            audience: 'default',
+            scope: `${TEST_SCOPES} offline_access`,
+            client_id: TEST_CLIENT_ID
+          });
+
+          expect(utils.oauthToken).toHaveBeenCalledWith({
+            baseUrl: 'https://test.auth0.com',
+            refresh_token: TEST_REFRESH_TOKEN,
+            client_id: TEST_CLIENT_ID,
+            grant_type: 'refresh_token'
+          });
+
+          expect(cache.save).toHaveBeenCalledWith({
+            client_id: TEST_CLIENT_ID,
+            refresh_token: TEST_REFRESH_TOKEN,
+            access_token: TEST_ACCESS_TOKEN,
+            id_token: TEST_ID_TOKEN,
+            scope: `${TEST_SCOPES} offline_access`,
+            audience: 'default',
+            decodedToken: {
+              claims: { sub: TEST_USER_ID, aud: TEST_CLIENT_ID },
+              user: { sub: TEST_USER_ID }
+            }
+          });
+        });
+
+        it('fails with an error when no refresh token is available in the cache', async () => {
+          const { auth0, cache, utils } = await setup({
+            useRefreshTokens: true
+          });
+
+          utils.getUniqueScopes.mockReturnValue(
+            `${TEST_SCOPES} offline_access`
+          );
+
+          cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN });
+
+          await auth0.getTokenSilently({ ignoreCache: true }).catch(e => {
+            expect(e.error).toBe('missing_refresh_token');
+            expect(e.error_description).toBe(
+              'No refresh token is available to fetch a new access token. The user should be reauthenticated.'
+            );
+            expect(utils.oauthToken).not.toHaveBeenCalled();
+          });
+        });
       });
     });
 
@@ -1181,7 +1400,7 @@ describe('Auth0', () => {
         const { auth0, utils } = await setup();
 
         await auth0.getTokenSilently(defaultOptionsIgnoreCacheTrue);
-        expect(utils.encodeState).toHaveBeenCalledWith(TEST_RANDOM_STRING);
+        expect(utils.encode).toHaveBeenCalledWith(TEST_RANDOM_STRING);
       });
 
       it('creates `code_challenge` by using `utils.sha256` with the result of `utils.createRandomString`', async () => {
@@ -1198,6 +1417,7 @@ describe('Auth0', () => {
         const { auth0, utils } = await setup();
 
         await auth0.getTokenSilently(defaultOptionsIgnoreCacheTrue);
+
         expect(utils.createQueryParams).toHaveBeenCalledWith({
           audience: defaultOptionsIgnoreCacheTrue.audience,
           client_id: TEST_CLIENT_ID,
@@ -1206,7 +1426,7 @@ describe('Auth0', () => {
           response_mode: 'web_message',
           prompt: 'none',
           state: TEST_ENCODED_STATE,
-          nonce: TEST_RANDOM_STRING,
+          nonce: TEST_ENCODED_STATE,
           redirect_uri: 'http://localhost',
           code_challenge: TEST_BASE64_ENCODED_STRING,
           code_challenge_method: 'S256'
@@ -1225,7 +1445,7 @@ describe('Auth0', () => {
           response_mode: 'web_message',
           prompt: 'none',
           state: TEST_ENCODED_STATE,
-          nonce: TEST_RANDOM_STRING,
+          nonce: TEST_ENCODED_STATE,
           redirect_uri: 'http://localhost',
           code_challenge: TEST_BASE64_ENCODED_STRING,
           code_challenge_method: 'S256'
@@ -1247,7 +1467,7 @@ describe('Auth0', () => {
           response_mode: 'web_message',
           prompt: 'none',
           state: TEST_ENCODED_STATE,
-          nonce: TEST_RANDOM_STRING,
+          nonce: TEST_ENCODED_STATE,
           redirect_uri,
           code_challenge: TEST_BASE64_ENCODED_STRING,
           code_challenge_method: 'S256'
@@ -1258,6 +1478,7 @@ describe('Auth0', () => {
         const { auth0, utils } = await setup();
 
         await auth0.getTokenSilently(defaultOptionsIgnoreCacheTrue);
+
         expect(utils.createQueryParams).toHaveBeenCalledWith({
           audience: defaultOptionsIgnoreCacheTrue.audience,
           client_id: TEST_CLIENT_ID,
@@ -1266,11 +1487,12 @@ describe('Auth0', () => {
           response_mode: 'web_message',
           prompt: 'none',
           state: TEST_ENCODED_STATE,
-          nonce: TEST_RANDOM_STRING,
+          nonce: TEST_ENCODED_STATE,
           redirect_uri: 'http://localhost',
           code_challenge: TEST_BASE64_ENCODED_STRING,
           code_challenge_method: 'S256'
         });
+
         expect(utils.getUniqueScopes).toHaveBeenCalledWith(
           'openid profile email',
           undefined,
@@ -1309,21 +1531,23 @@ describe('Auth0', () => {
         const { auth0, utils } = await setup();
 
         await auth0.getTokenSilently(defaultOptionsIgnoreCacheTrue);
+
         expect(utils.oauthToken).toHaveBeenCalledWith({
-          audience: defaultOptionsIgnoreCacheTrue.audience,
           baseUrl: 'https://test.auth0.com',
           client_id: TEST_CLIENT_ID,
           code: TEST_CODE,
-          code_verifier: TEST_RANDOM_STRING
+          code_verifier: TEST_RANDOM_STRING,
+          grant_type: 'authorization_code'
         });
       });
+
       it('calls `tokenVerifier.verify` with the `id_token` from in the oauth/token response', async () => {
         const { auth0, tokenVerifier } = await setup();
 
         await auth0.getTokenSilently(defaultOptionsIgnoreCacheTrue);
         expect(tokenVerifier).toHaveBeenCalledWith({
           id_token: TEST_ID_TOKEN,
-          nonce: TEST_RANDOM_STRING,
+          nonce: TEST_ENCODED_STATE,
           aud: 'test-client-id',
           iss: 'https://test.auth0.com/',
           leeway: undefined,
@@ -1537,7 +1761,9 @@ describe('default creation function', () => {
 
   it('calls getTokenSilently if there is a storage item with key `auth0.is.authenticated`', async () => {
     Auth0Client.prototype.getTokenSilently = jest.fn();
+
     require('../src/storage').get = () => true;
+
     const auth0 = await createAuth0Client({
       domain: TEST_DOMAIN,
       client_id: TEST_CLIENT_ID
@@ -1545,25 +1771,68 @@ describe('default creation function', () => {
 
     expect(auth0.getTokenSilently).toHaveBeenCalledWith({
       audience: undefined,
-      ignoreCache: true,
-      scope: undefined
+      ignoreCache: true
     });
   });
-  it('calls getTokenSilently with audience and scope', async () => {
-    const options = {
-      audience: 'the-audience',
-      scope: 'the-scope'
-    };
-    Auth0Client.prototype.getTokenSilently = jest.fn();
-    require('../src/storage').get = () => true;
-    const auth0 = await createAuth0Client({
-      domain: TEST_DOMAIN,
-      client_id: TEST_CLIENT_ID,
-      ...options
+
+  describe('when refresh tokens are not used', () => {
+    it('calls getTokenSilently with audience and scope', async () => {
+      const utils = require('../src/utils');
+
+      const options = {
+        audience: 'the-audience',
+        scope: 'the-scope'
+      };
+
+      Auth0Client.prototype.getTokenSilently = jest.fn();
+
+      require('../src/storage').get = () => true;
+      utils.getUniqueScopes = jest.fn(() => options.scope);
+
+      const auth0 = await createAuth0Client({
+        domain: TEST_DOMAIN,
+        client_id: TEST_CLIENT_ID,
+        ...options
+      });
+
+      expect(auth0.getTokenSilently).toHaveBeenCalledWith({
+        ignoreCache: true,
+        ...options
+      });
     });
-    expect(auth0.getTokenSilently).toHaveBeenCalledWith({
-      ignoreCache: true,
-      ...options
+  });
+
+  describe('when refresh tokens are used', () => {
+    it('creates the client with the correct scopes', async () => {
+      const utils = require('../src/utils');
+
+      const options = {
+        audience: 'the-audience',
+        scope: 'the-scope',
+        useRefreshTokens: true
+      };
+
+      Auth0Client.prototype.getTokenSilently = jest.fn();
+
+      require('../src/storage').get = () => true;
+      utils.getUniqueScopes = jest.fn(() => `${options.scope} offline_access`);
+
+      const auth0 = await createAuth0Client({
+        domain: TEST_DOMAIN,
+        client_id: TEST_CLIENT_ID,
+        ...options
+      });
+
+      expect(utils.getUniqueScopes).toHaveBeenCalledWith(
+        'the-scope',
+        'offline_access'
+      );
+
+      expect(auth0.getTokenSilently).toHaveBeenCalledWith({
+        ignoreCache: true,
+        scope: 'the-scope offline_access',
+        audience: 'the-audience'
+      });
     });
   });
 });
diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts
index 584f2d3..dd1f88e 100644
--- a/__tests__/utils.test.ts
+++ b/__tests__/utils.test.ts
@@ -5,8 +5,8 @@ import {
   createQueryParams,
   bufferToBase64UrlEncoded,
   createRandomString,
-  encodeState,
-  decodeState,
+  encode,
+  decode,
   sha256,
   openPopup,
   runPopup,
@@ -138,14 +138,14 @@ describe('utils', () => {
       expect(result.length).toBeLessThanOrEqual(128);
     });
   });
-  describe('encodeState', () => {
+  describe('encode', () => {
     it('encodes state', () => {
-      expect(encodeState('test')).toBe('dGVzdA==');
+      expect(encode('test')).toBe('dGVzdA==');
     });
   });
-  describe('decodeState', () => {
+  describe('decode', () => {
     it('decodes state', () => {
-      expect(decodeState('dGVzdA==')).toBe('test');
+      expect(decode('dGVzdA==')).toBe('test');
     });
   });
   describe('sha256', () => {
@@ -270,14 +270,16 @@ describe('utils', () => {
         )
       );
       await oauthToken({
+        grant_type: 'authorization_code',
         baseUrl: 'https://test.com',
         client_id: 'client_idIn',
         code: 'codeIn',
         code_verifier: 'code_verifierIn'
       });
+
       expect(mockUnfetch).toHaveBeenCalledWith('https://test.com/oauth/token', {
         body:
-          '{"grant_type":"authorization_code","redirect_uri":"http://localhost","client_id":"client_idIn","code":"codeIn","code_verifier":"code_verifierIn"}',
+          '{"redirect_uri":"http://localhost","grant_type":"authorization_code","client_id":"client_idIn","code":"codeIn","code_verifier":"code_verifierIn"}',
         headers: { 'Content-type': 'application/json' },
         method: 'POST'
       });