Repository: auth0/auth0-spa-js
Pull request #482 (2020-05-26 16:06:58)

Add `checkSession` and ignore recoverable errors
### Description

Add `checkSession` method as a convenience for those who want to use the constructor.

e.g.

```js
const auth0 = new Auth0Client({ ... });
await auth0.checkSession();
const isAuthenticated = await auth0.isAuthenticated();
```

Also added some additional errors that are 'recoverable' - meaning that `checkSession` should ignore them because the user can recover from those errors and successfully login interactively.

### References

#402
#470 
#485
https://dev.to/kleeut/solving-consentrequired-auth0-spa-sdk-1le2

### Testing

- [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`


lgtm-com[bot]: This pull request **introduces 2 alerts** when merging b0457411f7c92b79ed1636cea5decf7221bb1f0a into 91b7683e541226a842f1f5a3dd7e32ed44b48ba2 - [view on LGTM.com](https://lgtm.com/projects/g/auth0/auth0-spa-js/rev/pr-ee7c8a792fe98803ff6ac68e6f73cccfb337ca89)

**new alerts:**

* 2 for Unused variable, import, function or class
lgtm-com[bot]: This pull request **introduces 2 alerts** when merging e1d0a51681adc801bddcdc28beba0d4e79ced1f2 into 91b7683e541226a842f1f5a3dd7e32ed44b48ba2 - [view on LGTM.com](https://lgtm.com/projects/g/auth0/auth0-spa-js/rev/pr-2951b28480cf48f7dc4d6d538c81b0664bc366f0)

**new alerts:**

* 2 for Unused variable, import, function or class

Patch:
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index 236667b..26107d7 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -25,7 +25,8 @@ import {
   DEFAULT_POPUP_CONFIG_OPTIONS,
   DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS,
   MISSING_REFRESH_TOKEN_ERROR_MESSAGE,
-  DEFAULT_SCOPE
+  DEFAULT_SCOPE,
+  RECOVERABLE_ERRORS
 } from './constants';
 
 import version from './version';
@@ -476,6 +477,38 @@ export default class Auth0Client {
     };
   }
 
+  /**
+   * ```js
+   * await auth0.checkSession();
+   * ```
+   *
+   * Check if the user is logged in using `getTokenSilently`. The difference
+   * with `getTokenSilently` is that this doesn't return a token, but it will
+   * pre-fill the token cache.
+   *
+   * It should be used for silently logging in the user when you instantiate the
+   * `Auth0Client` constructor. You should not need this if you are using the
+   * `createAuth0Client` factory.
+   *
+   * @param options
+   */
+  public async checkSession(options?: GetTokenSilentlyOptions) {
+    if (
+      this.cacheLocation === CACHE_LOCATION_MEMORY &&
+      !ClientStorage.get('auth0.is.authenticated')
+    ) {
+      return;
+    }
+
+    try {
+      await this.getTokenSilently(options);
+    } catch (error) {
+      if (!RECOVERABLE_ERRORS.includes(error.error)) {
+        throw error;
+      }
+    }
+  }
+
   /**
    * ```js
    * const token = await auth0.getTokenSilently(options);
diff --git a/src/constants.ts b/src/constants.ts
index 81b6e59..dc9c88f 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -36,3 +36,20 @@ export const MISSING_REFRESH_TOKEN_ERROR_MESSAGE =
  * @ignore
  */
 export const DEFAULT_SCOPE = 'openid profile email';
+
+/**
+ * A list of errors that can be issued by the authorization server which the
+ * user can recover from by signing in interactively.
+ * https://openid.net/specs/openid-connect-core-1_0.html#AuthError
+ * @ignore
+ */
+export const RECOVERABLE_ERRORS = [
+  'login_required',
+  'consent_required',
+  'interaction_required',
+  'account_selection_required',
+  // Strictly speaking the user can't recover from `access_denied` - but they
+  // can get more information about their access being denied by logging in
+  // interactively.
+  'access_denied'
+];
diff --git a/src/index.ts b/src/index.ts
index 764240d..369575e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,9 +9,7 @@ import 'fast-text-encoding';
 import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
 
 import Auth0Client from './Auth0Client';
-import * as ClientStorage from './storage';
 import { Auth0ClientOptions } from './global';
-import { CACHE_LOCATION_MEMORY } from './constants';
 
 import './global';
 
@@ -19,22 +17,7 @@ export * from './global';
 
 export default async function createAuth0Client(options: Auth0ClientOptions) {
   const auth0 = new Auth0Client(options);
-
-  if (
-    auth0.cacheLocation === CACHE_LOCATION_MEMORY &&
-    !ClientStorage.get('auth0.is.authenticated')
-  ) {
-    return auth0;
-  }
-
-  try {
-    await auth0.getTokenSilently();
-  } catch (error) {
-    if (error.error !== 'login_required') {
-      throw error;
-    }
-  }
-
+  await auth0.checkSession();
   return auth0;
 }
 


Test patch:
diff --git a/__tests__/Auth0Client.test.ts b/__tests__/Auth0Client.test.ts
index 0e08e53..bf6f316 100644
--- a/__tests__/Auth0Client.test.ts
+++ b/__tests__/Auth0Client.test.ts
@@ -1,4 +1,5 @@
 import 'fast-text-encoding';
+import * as esCookie from 'es-cookie';
 import Auth0Client from '../src/Auth0Client';
 import unfetch from 'unfetch';
 import { verify } from '../src/jwt';
@@ -9,6 +10,7 @@ import * as scope from '../src/scope';
 import { expectToHaveBeenCalledWithAuth0ClientParam } from './helpers';
 
 jest.mock('unfetch');
+jest.mock('es-cookie');
 jest.mock('../src/jwt');
 jest.mock('../src/token.worker');
 
@@ -615,4 +617,35 @@ describe('Auth0Client', () => {
 
     expect(access_token).toEqual('my_access_token');
   });
+
+  it("skips checking the auth0 session when there's no auth cookie", async () => {
+    const auth0 = setup();
+
+    jest.spyOn(<any>utils, 'runIframe');
+
+    await auth0.checkSession();
+
+    expect(utils.runIframe).not.toHaveBeenCalled();
+  });
+
+  it('checks the auth0 session when there is an auth cookie', async () => {
+    const auth0 = setup();
+
+    jest.spyOn(<any>utils, 'runIframe').mockResolvedValue({
+      access_token: 'my_access_token',
+      state: 'MTIz'
+    });
+    (<jest.Mock>esCookie.get).mockReturnValue(true);
+    mockFetch.mockResolvedValueOnce(
+      fetchResponse(true, {
+        id_token: 'my_id_token',
+        refresh_token: 'my_refresh_token',
+        access_token: 'my_access_token',
+        expires_in: 86400
+      })
+    );
+    await auth0.checkSession();
+
+    expect(utils.runIframe).toHaveBeenCalled();
+  });
 });
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index 1351580..7960048 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -206,7 +206,28 @@ describe('Auth0', () => {
       expect(utils.runIframe).toHaveBeenCalled();
     });
 
-    it('should throw other errors that are not "login_required"', async () => {
+    it('should absorb other recoverable errors', async () => {
+      const { utils, storage } = await setup();
+      storage.get.mockReturnValue(true);
+      const recoverableErrors = [
+        'consent_required',
+        'interaction_required',
+        'account_selection_required',
+        'access_denied'
+      ];
+      for (let error of recoverableErrors) {
+        utils.runIframe.mockRejectedValue({ error });
+        const auth0 = await createAuth0Client({
+          domain: TEST_DOMAIN,
+          client_id: TEST_CLIENT_ID
+        });
+        expect(auth0).toBeInstanceOf(Auth0Client);
+        expect(utils.runIframe).toHaveBeenCalledTimes(1);
+        utils.runIframe.mockClear();
+      }
+    });
+
+    it('should throw for other errors that are not recoverable', async () => {
       const { utils, storage } = await setup();
 
       utils.runIframe.mockImplementation(() => {
@@ -218,18 +239,17 @@ describe('Auth0', () => {
 
       storage.get.mockReturnValue(true);
 
-      // We expect one assertion, meaning that if the function under test
-      // does not throw, it will still fail the test.
-      expect.assertions(1);
+      await expect(Promise.reject(new Error('foo'))).rejects.toThrow(Error);
 
-      try {
-        await createAuth0Client({
+      await expect(
+        createAuth0Client({
           domain: TEST_DOMAIN,
           client_id: TEST_CLIENT_ID
-        });
-      } catch (e) {
-        expect(e.error).toEqual('some_other_error');
-      }
+        })
+      ).rejects.toStrictEqual({
+        error: 'some_other_error',
+        error_message: 'This is a different error to login_required'
+      });
     });
   });
 
@@ -2177,7 +2197,7 @@ describe('default creation function', () => {
       client_id: TEST_CLIENT_ID
     });
 
-    expect(auth0.getTokenSilently).toHaveBeenCalledWith();
+    expect(auth0.getTokenSilently).toHaveBeenCalledWith(undefined);
   });
 
   describe('when refresh tokens are not used', () => {
@@ -2199,7 +2219,7 @@ describe('default creation function', () => {
         ...options
       });
 
-      expect(auth0.getTokenSilently).toHaveBeenCalledWith();
+      expect(auth0.getTokenSilently).toHaveBeenCalledWith(undefined);
     });
   });
 
@@ -2223,7 +2243,7 @@ describe('default creation function', () => {
 
       expect((<any>auth0).scope).toBe('the-scope offline_access');
 
-      expect(auth0.getTokenSilently).toHaveBeenCalledWith();
+      expect(auth0.getTokenSilently).toHaveBeenCalledWith(undefined);
     });
   });
 
@@ -2247,7 +2267,7 @@ describe('default creation function', () => {
         ...options
       });
 
-      expect(auth0.getTokenSilently).toHaveBeenCalledWith();
+      expect(auth0.getTokenSilently).toHaveBeenCalledWith(undefined);
     });
   });
 });