Repository: auth0/auth0-spa-js
Pull request #364 (2020-02-27 16:27:54)

[SDK-1386] Fall back to iframe method if no refresh token is available
### Description

This PR adds the capability for the SDK to fall back to retrieving an access token using the hidden iframe method in the event that no refresh token is available.

This would be the case when the refresh token feature is turned on for the first time and there is desire to silently enrol the user onto a refresh token without having them log in interactively.

For this to work, the API must have the "Allow Skipping User Consent" flag on for their Auth0 API, otherwise the call will fail with a `consent_required` error.

### Testing

This adds unit tests. It also _changes_ an existing integration test. Originally, this test checked for a `missing_refresh_token` error, which will no longer be thrown. Instead, it checks for a `consent_required` error to prove that the SDK tried to go down the iframe route ([consent is always required for localhost](https://auth0.com/docs/api-auth/user-consent#skip-consent-for-first-party-applications) regardless of the API setting).

- [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 6373388..272449c 100644
--- a/cypress/integration/getTokenSilently.js
+++ b/cypress/integration/getTokenSilently.js
@@ -1,11 +1,4 @@
-import { decode } from 'qss';
-import {
-  shouldBe,
-  shouldNotBe,
-  shouldBeUndefined,
-  shouldNotBeUndefined,
-  whenReady
-} from '../support/utils';
+import { shouldBe, whenReady } from '../support/utils';
 
 describe('getTokenSilently', function() {
   beforeEach(cy.resetTests);
@@ -76,7 +69,11 @@ describe('getTokenSilently', function() {
       });
 
       describe('when using refresh tokens', () => {
-        it('displays an error when trying to get an access token when the RT is missing', () => {
+        /**
+         * This test will fail with a 'consent_required' error when running on localhost, but the fact that it does
+         * proves that the iframe method was attempted even though we're supposed to be using refresh tokens.
+         */
+        it.only('attempts to retrieve an access token by falling back to the iframe method', () => {
           return whenReady().then(win => {
             cy.toggleSwitch('local-storage');
             cy.toggleSwitch('use-cache');
@@ -87,12 +84,11 @@ describe('getTokenSilently', function() {
 
               cy.get('[data-cy=get-token]')
                 .click()
-                .wait(500);
+                .wait(500)
+                .get('[data-cy=access-token]')
+                .should('have.length', 1);
 
-              cy.get('[data-cy=error]').should(
-                'contain',
-                'No refresh token is available to fetch a new access token'
-              );
+              cy.get('[data-cy=error]').should('contain', 'consent_required');
             });
           });
         });
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index 14e8a25..ac58609 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -439,7 +439,7 @@ export default class Auth0Client {
    * @param options
    */
   public async getTokenSilently(options: GetTokenSilentlyOptions = {}) {
-    const { ignoreCache, ...refreshTokenOptions } = {
+    const { ignoreCache, ...getTokenOptions } = {
       audience: this.options.audience,
       scope: getUniqueScopes(
         this.DEFAULT_SCOPE,
@@ -453,8 +453,8 @@ export default class Auth0Client {
     try {
       if (!ignoreCache) {
         const cache = this.cache.get({
-          scope: refreshTokenOptions.scope,
-          audience: refreshTokenOptions.audience || 'default',
+          scope: getTokenOptions.scope,
+          audience: getTokenOptions.audience || 'default',
           client_id: this.options.client_id
         });
 
@@ -466,8 +466,8 @@ export default class Auth0Client {
       await lock.acquireLock(GET_TOKEN_SILENTLY_LOCK_KEY, 5000);
 
       const authResult = this.options.useRefreshTokens
-        ? await this._getTokenUsingRefreshToken(refreshTokenOptions)
-        : await this._getTokenFromIFrame(refreshTokenOptions);
+        ? await this._getTokenUsingRefreshToken(getTokenOptions)
+        : await this._getTokenFromIFrame(getTokenOptions);
 
       this.cache.save({ client_id: this.options.client_id, ...authResult });
 
@@ -625,10 +625,7 @@ export default class Auth0Client {
     });
 
     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.'
-      );
+      return await this._getTokenFromIFrame(options);
     }
 
     const redirect_uri =


Test patch:
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index 47021f9..2521be0 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -1442,26 +1442,6 @@ describe('Auth0', () => {
             }
           });
         });
-
-        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();
-          });
-        });
       });
     });
 
@@ -1736,6 +1716,23 @@ describe('Auth0', () => {
         );
       });
     });
+
+    describe('when refresh tokens are used', () => {
+      it('falls back to using a hidden iframe when no refresh token is available', async () => {
+        const { auth0, cache, utils } = await setup({
+          useRefreshTokens: true
+        });
+
+        utils.getUniqueScopes.mockReturnValue(`${TEST_SCOPES} offline_access`);
+
+        cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN });
+
+        const result = await auth0.getTokenSilently({ ignoreCache: true });
+
+        expect(result).toEqual(TEST_ACCESS_TOKEN);
+        expect(utils.runIframe).toHaveBeenCalled();
+      });
+    });
   });
 
   describe('getTokenWithPopup()', async () => {