Repository: auth0/auth0-spa-js
Pull request #1208 (2023-12-06 18:27:18)

feat: support for hosted token worker
<!-- By submitting a PR to this repository, you agree to the terms within the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md). Please see the [contributing guidelines](https://github.com/auth0/.github/blob/master/CONTRIBUTING.md) for how to create and submit a high-quality PR for this repo. -->

### Changes

When the SDK is used in combination with a strict Content-Security-Policy (CSP), the policy must include `worker-src: blob:` which raises a concern of `unsafe-eval`. This change allows the SDK to be configured to load the worker code from a trusted URL, compliant with the CSP, and allows the user to mitigate the concern.

**Todo:**
- [x] Just waiting for https://github.com/auth0/auth0-spa-js/pull/1209 to land so that we can get e2e coverage of this 

### References

> As defined above, special URL schemes that refer to specific pieces of unique content, such as `data:`, `blob:` and `filesystem:` are excluded from matching a policy of * and must be explicitly listed. Policy authors should note that the content of such URLs is often derived from a response body or execution in a Document context, which may be unsafe. Especially for the [default-src](https://www.w3.org/TR/CSP2/#default_src) and [script-src](https://www.w3.org/TR/CSP2/#script_src) directives, policy authors should be aware that allowing `data:` URLs is equivalent to unsafe-inline and **allowing `blob:` or `filesystem:` URLs is equivalent to unsafe-eval**.

https://www.w3.org/TR/CSP2/#source-list-guid-matching

### Testing

<!--
Please describe how this can be tested by reviewers. Be specific about anything not tested and reasons why. If this library has unit and/or integration testing, tests should be added for new functionality and existing tests should complete without errors.
-->

- [X] This change adds unit test coverage
- [ ] This change adds integration test coverage
- [X] This change has been tested on the latest version of the platform/language

### Checklist

- [X] I have read the [Auth0 general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md)
- [X] I have read the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md)
- [X] All code quality tools/guidelines have been run/followed


DJMcK: Hey @frederikprijck 👋 

> I assume they need to ensure they take the file from their node_modules, and copy it as-is to their web-server without bundling?

That's right, or leverage our CDN distribution of the `...worker.production.js` file. 

There's an effort to explain that to the consumer via the [`src/global.ts`](https://github.com/auth0/auth0-spa-js/pull/1208/files#diff-0bec2401f35d4c80319a0256eadb80621ce9a826423909629bb0708b7758307d) docs. Tough one to articulate in a few sentences, I'll add a bit more in `Examples.md` too.

> It also looks like whoever opts to use the worker by URL, will have two copies of the worker (one in our bundle, one in the separate file), is that accurate and expected?

Yeah, that's right. You'd have the bundled `auth0-spa-js.production.js` which includes the integrated worker code and, if you opt to not use the eval approach, then you'd load the separate `auth0-spa-js.worker.production.js`. 

There's definitely a tradeoff being made between a bit of performance for security when you opt-in to the latter.
DJMcK: @frederikprijck I took a pass at an FAQ in https://github.com/auth0/auth0-spa-js/pull/1208/commits/1691d42098cd9e2d278b9513cf88c85831050214. Let me know your thoughts on that draft.
DJMcK: - Rebased to get the head on https://github.com/auth0/auth0-spa-js/pull/1209 (integration tests fix)
- Added the new integration test for this feature https://github.com/auth0/auth0-spa-js/pull/1208/commits/9044c636fcf41ac36d343ca0c90f676becf1d249



Patch:
diff --git a/FAQ.md b/FAQ.md
index dfa28b7..be5e0db 100644
--- a/FAQ.md
+++ b/FAQ.md
@@ -106,30 +106,31 @@ There are two ways to use our SDK when you want to rely on a Content Delivery Ne
 
 ### Using our own CDN bundle
 
-Our own CDN bundle exposes both `createAuth0Client` and `Auth0Client` on a global `auth0` variable, and can be used as shown below. 
+Our own CDN bundle exposes both `createAuth0Client` and `Auth0Client` on a global `auth0` variable, and can be used as shown below.
 
 ```html
 <script>
-const client = auth0.createAuth0Client({ ... });
-// or
-const client = new auth0.Auth0Client({ ... });
+  const client = auth0.createAuth0Client({ ... });
+  // or
+  const client = new auth0.Auth0Client({ ... });
 </script>
 ```
 
 ### Using import maps with unpkg
+
 If you want to use a CDN bundle together with import maps, you will need to use our ESM bundle from unpkg:
 
 ```html
 <script type="importmap">
-{
-  "imports": {
-    "@auth0/auth0-spa-js": "https://www.unpkg.com/@auth0/auth0-spa-js@2.1.2/dist/auth0-spa-js.production.esm.js"
+  {
+    "imports": {
+      "@auth0/auth0-spa-js": "https://www.unpkg.com/@auth0/auth0-spa-js@2.1.2/dist/auth0-spa-js.production.esm.js"
+    }
   }
-}
 </script>
 <script type="module">
   import { createAuth0Client, Auth0Client } from '@auth0/auth0-spa-js';
-  
+
   const client = createAuth0Client({ ... });
   // or
   const client = new Auth0Client({ ... });
@@ -137,6 +138,7 @@ If you want to use a CDN bundle together with import maps, you will need to use
 ```
 
 ## Why is isAuthenticated returning true when there are no tokens available to call an API?
+
 As long as the SDK has an id token, you are considered authenticated, because it knows who you are. It might be that there isn't a valid access token and you are unable to call an API, but the SDK still knows who you are because of the id token.
 
 Authentication is about who you are (id token), not what you can do (access token). The latter is authorization, which is also why you pass the access token to the API in the Authorization header.
@@ -145,3 +147,37 @@ So even when the refresh token fails, or `getTokenSilently` returns nothing, tha
 
 On top of that, the SDK can have multiple access tokens and multiple refresh tokens (e.g. when using multiple audience and scope combinations to call multiple API's), but only one id token.
 If there are multiple access and refresh tokens, and one of the refresh tokens fails, it doesn't mean the other access tokens or refresh tokens are invalid, they might still be perfectly usable.
+
+## The Token Worker is being blocked by my Content-Security-Policy (CSP), what should I do?
+
+When using refresh tokens - along with the default in-memory cache - the SDK will leverage a [`Worker`](https://developer.mozilla.org/en-US/docs/Web/API/Worker) to globally isolate the refresh token from the rest of your application.
+
+By default, to reduce the friction of getting started with the SDK, we ship that `Worker` code with the main SDK bundle and dynamically pass it as a string to create a new `Worker`.
+
+Unless configured to allow for that, Content-Security-Policy (CSP) will block the loading of the dynamic `blob:`.
+
+To allow you to keep strict Content-Security-Policy (CSP), and not have to allow `blob:` in your CSP, we also ship the `Worker` as a separate compiled JavaScript file. You can find that file in [`./dist/auth0-spa-js.worker.production.js`](./dist/auth0-spa-js.worker.production.js) or on our CDN. This allows you to either copy the worker JavaScript file to your web server's public assets folder or load it from our CDN.
+
+For example, if I have a folder called `static` in the root of my project then I could update my build script to copy the worker file to it:
+
+```sh
+my-build-script && cp ./node_modules/@auth0/auth0-spa-js/dist/auth0-spa-js.worker.development.js ./static/
+```
+
+Now when instantiating the SDK, I can configure it to load the worker code from that location:
+
+```ts
+import { createAuth0Client, Auth0Client } from '@auth0/auth0-spa-js';
+
+const client = createAuth0Client({
+  ...
+  workerUrl: '/static/auth0-spa-js.worker.production.js'
+});
+// or
+const client = new Auth0Client({
+  ...
+  workerUrl: '/static/auth0-spa-js.worker.production.js'
+});
+```
+
+In this case, the loading of the `Worker` would comply with a CSP that included `'self'`. You can follow similar steps if you'd prefer to copy the file to your own CDN instead.
diff --git a/rollup.config.js b/rollup.config.js
index d56fec2..9dde405 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -68,6 +68,19 @@ const getStatsPlugins = () => {
 };
 
 let bundles = [
+  {
+    input: 'src/worker/token.worker.ts',
+    output: {
+      name: EXPORT_NAME,
+      file: 'dist/auth0-spa-js.worker.development.js',
+      format: 'umd',
+      sourcemap: true
+    },
+    plugins: [...getPlugins(false)],
+    watch: {
+      clearScreen: false
+    }
+  },
   {
     input: 'src/index.ts',
     output: {
@@ -96,6 +109,17 @@ let bundles = [
 
 if (isProduction) {
   bundles = bundles.concat(
+    {
+      input: 'src/worker/token.worker.ts',
+      output: [
+        {
+          name: EXPORT_NAME,
+          file: 'dist/auth0-spa-js.worker.production.js',
+          format: 'umd'
+        }
+      ],
+      plugins: [...getPlugins(isProduction), ...getStatsPlugins()]
+    },
     {
       input: 'src/index.ts',
       output: [
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index 3d1dccc..b338a73 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -231,7 +231,11 @@ export class Auth0Client {
       this.options.useRefreshTokens &&
       cacheLocation === CACHE_LOCATION_MEMORY
     ) {
-      this.worker = new TokenWorker();
+      if (this.options.workerUrl) {
+        this.worker = new Worker(this.options.workerUrl);
+      } else {
+        this.worker = new TokenWorker();
+      }
     }
   }
 
diff --git a/src/global.ts b/src/global.ts
index ca29c3e..c86d3ca 100644
--- a/src/global.ts
+++ b/src/global.ts
@@ -259,6 +259,16 @@ export interface Auth0ClientOptions extends BaseLoginOptions {
    * **Note**: Using this improperly can potentially compromise the token validation.
    */
   nowProvider?: () => Promise<number> | number;
+
+  /**
+   * If provided, the SDK will load the token worker from this URL instead of the integrated `blob`. An example of when this is useful is if you have strict
+   * Content-Security-Policy (CSP) and wish to avoid needing to set `worker-src: blob:`. We recommend either serving the worker, which you can find in the module 
+   * at `<module_path>/dist/auth0-spa-js.worker.production.js`, from the same host as your application or using the Auth0 CDN 
+   * `https://cdn.auth0.com/js/auth0-spa-js/<version>/auth0-spa-js.worker.production.js`.
+   * 
+   * **Note**: The worker is only used when `useRefreshTokens: true`, `cacheLocation: 'memory'`, and the `cache` is not custom.
+   */
+  workerUrl?: string;
 }
 
 /**
diff --git a/static/index.html b/static/index.html
index 72c42dd..902713a 100644
--- a/static/index.html
+++ b/static/index.html
@@ -322,6 +322,21 @@
                 >Use iframe as a fallback for refresh tokens</label
               >
             </div>
+
+            <div class="custom-control custom-switch mb-5">
+              <input
+                type="checkbox"
+                class="custom-control-input"
+                id="worker_url_switch"
+                v-model="useWorkerUrl"
+              />
+              <label
+                for="worker_url_switch"
+                class="custom-control-label"
+                data-cy="switch-use-worker-url"
+                >Use Hosted Worker URL</label
+              >
+            </div>
           </div>
           <div class="col-md-6">
             <div class="custom-control custom-switch mb-5">
@@ -453,6 +468,7 @@
             useOrgAtLogin: data.useOrgAtLogin || false,
             useRefreshTokensFallback: data.useRefreshTokensFallback || false,
             clientOptions: '',
+            useWorkerUrl: data.useWorkerUrl || false,
             audienceScopes: [
               {
                 audience: data.audience || defaultAudience,
@@ -506,7 +522,11 @@
           useRefreshTokensFallback: function () {
             this.initializeClient();
             this.saveForm();
-          }
+          },
+          useWorkerUrl: function () {
+            this.initializeClient();
+            this.saveForm();
+          },
         },
         computed: {
           scopesWithSuffix: function () {
@@ -528,6 +548,7 @@
               useCookiesForTransactions: _self.useCookiesForTransactions,
               useFormData: _self.useFormData,
               useRefreshTokensFallback: _self.useRefreshTokensFallback,
+              workerUrl: _self.useWorkerUrl ? '/auth0-spa-js.worker.development.js' : undefined,
               authorizationParams: {
                 redirect_uri: window.location.origin
               }
@@ -594,7 +615,8 @@
                 useCache: this.useCache,
                 useFormData: this.useFormData,
                 useOrgAtLogin: this.useOrgAtLogin,
-                useRefreshTokensFallback: this.useRefreshTokensFallback
+                useRefreshTokensFallback: this.useRefreshTokensFallback,
+                useWorkerUrl: this.useWorkerUrl,
               })
             );
 
@@ -614,6 +636,7 @@
             this.useFormData = true;
             this.useOrgAtLogin = false;
             this.useRefreshTokensFallback = false;
+            this.useWorkerUrl = false;
             this.saveForm();
           },
           showAuth0Info: function () {


Test patch:
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index 29f43b5..ca537e0 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -158,6 +158,19 @@ describe('Auth0', () => {
       expect(auth0).toBeInstanceOf(Auth0Client);
     });
 
+    it('should load token worker from provided URL when provided', async () => {
+      const workerUrl = '/hosted/auth0.worker.js';
+
+      await createAuth0Client({
+        domain: TEST_DOMAIN,
+        clientId: TEST_CLIENT_ID,
+        useRefreshTokens: true,
+        workerUrl,
+      });
+
+      expect(window.Worker).toHaveBeenCalledWith(workerUrl);
+    });
+
     it('should call `utils.validateCrypto`', async () => {
       const { utils } = await setup();
 
diff --git a/cypress/e2e/getTokenSilently.cy.js b/cypress/e2e/getTokenSilently.cy.js
index 8cf1340..a8d48f9 100644
--- a/cypress/e2e/getTokenSilently.cy.js
+++ b/cypress/e2e/getTokenSilently.cy.js
@@ -168,5 +168,64 @@ describe('getTokenSilently', () => {
         );
       });
     });
+
+    describe('with workerUrl', () => {
+      const workerUrl = 'auth0-spa-js.worker.development.js';
+
+      it('loads the hosted worker file', () => {
+        whenReady();
+
+        cy.intercept({
+          method: 'GET',
+          url: workerUrl
+        }).as('workerLoaded');
+
+        cy.setSwitch('refresh-tokens', true);
+        cy.setSwitch('use-worker-url', true);
+
+        cy.wait('@workerLoaded').its('response.statusCode').should('eq', 200);
+      });
+
+      it('retrieves tokens using the hosted worker file', () => {
+        whenReady();
+
+        cy.setSwitch('refresh-tokens', true);
+        cy.setSwitch('use-worker-url', true);
+        cy.setSwitch('use-cache', false);
+
+        cy.intercept({
+          method: 'POST',
+          url: '**/oauth/token'
+        }).as('tokenApiCheck');
+
+        cy.login();
+        cy.getAccessTokens().should('have.length', 1);
+
+        cy.wait('@tokenApiCheck')
+          .its('request')
+          .then(request => {
+            cy.wrap(request)
+              .its('headers.referer')
+              .should('contain', workerUrl);
+            cy.wrap(request)
+              .its('body')
+              .should('contain', 'grant_type=authorization_code');
+          });
+
+        cy.getTokenSilently();
+        cy.getAccessTokens().should('have.length', 2);
+
+        cy.wait('@tokenApiCheck')
+          .its('request')
+          .then(request => {
+            cy.wrap(request)
+              .its('headers.referer')
+              .should('contain', workerUrl);
+            cy.wrap(request)
+              .its('body')
+              .should('contain', 'grant_type=refresh_token');
+          });
+      });
+    });
   });
 });