Repository: auth0/auth0-spa-js (ts)

Issue:
Invalid State Error on Ipad  safari and chrome
### Describe the problem

> When logging in with a **loginWithRedirect**, you will be redirected back to your app with code and state values set as URL query parameters. These values are used by **handleRedirectCallback** And this result in Error: Invalid state

 
### Environment

> Please provide the following:

- **Version of `auth0-spa-js` used:** 1.10.0
- **Which browsers have you tested in?** Ipad "Chrome Mobile iOS 83.0.4103 / iOS 12.0.0", "Mobile Safari 12.0.0 / iOS 12.0.0", 
- **Which framework are you using, if applicable (Angular, React, etc):** "React" 


Pull request:
[SDK-1858] Create legacy samsite cookie by default
### Description

Add a legacy SameSite cookie (one without SameSite set to 'None') for legacy browsers.

Also adding the option to turn this off (to avoid browser console warning) with the caveat that this will break silent auth for incompatible clients https://www.chromium.org/updates/same-site/incompatible-clients

### References

See https://web.dev/samesite-cookie-recipes/#handling-incompatible-clients
Fixes #515 

### Testing

- [ ] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [ ] I have added documentation for new/changed functionality in this PR or in auth0.com/docs
- [ ] All active GitHub checks for tests, formatting, and security are passing
- [ ] The correct base branch is being used, if not `master`


Patch:
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index fc8c0dc..3216fd7 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -18,7 +18,12 @@ import { InMemoryCache, ICache, LocalStorageCache } from './cache';
 import TransactionManager from './transaction-manager';
 import { verify as verifyIdToken } from './jwt';
 import { AuthenticationError } from './errors';
-import { CookieStorage, SessionStorage } from './storage';
+import {
+  ClientStorage,
+  CookieStorage,
+  CookieStorageWithLegacySameSite,
+  SessionStorage
+} from './storage';
 
 import {
   CACHE_LOCATION_MEMORY,
@@ -128,6 +133,7 @@ export default class Auth0Client {
   private tokenIssuer: string;
   private defaultScope: string;
   private scope: string;
+  private cookieStorage: ClientStorage;
 
   cacheLocation: CacheLocation;
   private worker: Worker;
@@ -135,6 +141,10 @@ export default class Auth0Client {
   constructor(private options: Auth0ClientOptions) {
     typeof window !== 'undefined' && validateCrypto();
     this.cacheLocation = options.cacheLocation || CACHE_LOCATION_MEMORY;
+    this.cookieStorage =
+      options.legacySameSiteCookie === false
+        ? CookieStorage
+        : CookieStorageWithLegacySameSite;
 
     if (!cacheFactory(this.cacheLocation)) {
       throw new Error(`Invalid cache location "${this.cacheLocation}"`);
@@ -367,7 +377,9 @@ export default class Auth0Client {
 
     this.cache.save(cacheEntry);
 
-    CookieStorage.save('auth0.is.authenticated', true, { daysUntilExpire: 1 });
+    this.cookieStorage.save('auth0.is.authenticated', true, {
+      daysUntilExpire: 1
+    });
   }
 
   /**
@@ -501,7 +513,9 @@ export default class Auth0Client {
 
     this.cache.save(cacheEntry);
 
-    CookieStorage.save('auth0.is.authenticated', true, { daysUntilExpire: 1 });
+    this.cookieStorage.save('auth0.is.authenticated', true, {
+      daysUntilExpire: 1
+    });
 
     return {
       appState: transaction.appState
@@ -526,7 +540,7 @@ export default class Auth0Client {
   public async checkSession(options?: GetTokenSilentlyOptions) {
     if (
       this.cacheLocation === CACHE_LOCATION_MEMORY &&
-      !CookieStorage.get('auth0.is.authenticated')
+      !this.cookieStorage.get('auth0.is.authenticated')
     ) {
       return;
     }
@@ -613,7 +627,7 @@ export default class Auth0Client {
 
       this.cache.save({ client_id: this.options.client_id, ...authResult });
 
-      CookieStorage.save('auth0.is.authenticated', true, {
+      this.cookieStorage.save('auth0.is.authenticated', true, {
         daysUntilExpire: 1
       });
 
@@ -708,7 +722,7 @@ export default class Auth0Client {
     }
 
     this.cache.clear();
-    CookieStorage.remove('auth0.is.authenticated');
+    this.cookieStorage.remove('auth0.is.authenticated');
 
     if (localOnly) {
       return;
diff --git a/src/global.ts b/src/global.ts
index 49ee571..514b3f2 100644
--- a/src/global.ts
+++ b/src/global.ts
@@ -130,6 +130,16 @@ export interface Auth0ClientOptions extends BaseLoginOptions {
    */
   auth0Client?: { name: string; version: string };
 
+  /**
+   * Sets an additional cookie with no SameSite attribute to support legacy browsers
+   * that are not compatible with the latest SameSite changes.
+   * This will log a warning on modern browsers, you can disable the warning by setting
+   * this to false but be aware that some older useragents will not work,
+   * See https://www.chromium.org/updates/same-site/incompatible-clients
+   * Defaults to true
+   */
+  legacySameSiteCookie?: boolean;
+
   /**
    * Changes to recommended defaults, like defaultScope
    */
diff --git a/src/storage.ts b/src/storage.ts
index aa47819..95d79df 100644
--- a/src/storage.ts
+++ b/src/storage.ts
@@ -42,6 +42,44 @@ export const CookieStorage = {
   }
 } as ClientStorage;
 
+/**
+ * @ignore
+ */
+const LEGACY_PREFIX = '_legacy_';
+
+/**
+ * Cookie storage that creates a cookie for modern and legacy browsers.
+ * See: https://web.dev/samesite-cookie-recipes/#handling-incompatible-clients
+ */
+export const CookieStorageWithLegacySameSite = {
+  get<T extends Object>(key: string) {
+    const value = CookieStorage.get(key);
+    if (value) {
+      return value;
+    }
+    return CookieStorage.get(`${LEGACY_PREFIX}${key}`);
+  },
+
+  save(key: string, value: any, options?: ClientStorageOptions): void {
+    let cookieAttributes: Cookies.CookieAttributes = {};
+    if ('https:' === window.location.protocol) {
+      cookieAttributes = { secure: true };
+    }
+    cookieAttributes.expires = options.daysUntilExpire;
+    Cookies.set(
+      `${LEGACY_PREFIX}${key}`,
+      JSON.stringify(value),
+      cookieAttributes
+    );
+    CookieStorage.save(key, value, options);
+  },
+
+  remove(key: string) {
+    CookieStorage.remove(key);
+    CookieStorage.remove(`${LEGACY_PREFIX}${key}`);
+  }
+} as ClientStorage;
+
 /**
  * A storage protocol for marshalling data to/from session storage
  */


Test patch:
diff --git a/__tests__/Auth0Client.test.ts b/__tests__/Auth0Client.test.ts
index 1ba743b..4a9f107 100644
--- a/__tests__/Auth0Client.test.ts
+++ b/__tests__/Auth0Client.test.ts
@@ -860,4 +860,29 @@ describe('Auth0Client', () => {
 
     expect(utils.runIframe).toHaveBeenCalled();
   });
+
+  it('checks the legacy samesite cookie', async () => {
+    const auth0 = setup();
+    (<jest.Mock>esCookie.get).mockReturnValueOnce(undefined);
+    await auth0.checkSession();
+    expect(<jest.Mock>esCookie.get).toHaveBeenCalledWith(
+      'auth0.is.authenticated'
+    );
+    expect(<jest.Mock>esCookie.get).toHaveBeenCalledWith(
+      '_legacy_auth0.is.authenticated'
+    );
+  });
+
+  it('skips checking the legacy samesite cookie when configured', async () => {
+    const auth0 = setup({
+      legacySameSiteCookie: false
+    });
+    await auth0.checkSession();
+    expect(<jest.Mock>esCookie.get).toHaveBeenCalledWith(
+      'auth0.is.authenticated'
+    );
+    expect(<jest.Mock>esCookie.get).not.toHaveBeenCalledWith(
+      '_legacy_auth0.is.authenticated'
+    );
+  });
 });
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index 4c41cb5..9a0e416 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -15,6 +15,11 @@ jest.mock('../src/storage', () => ({
     get: jest.fn(),
     save: jest.fn(),
     remove: jest.fn()
+  },
+  CookieStorageWithLegacySameSite: {
+    get: jest.fn(),
+    save: jest.fn(),
+    remove: jest.fn()
   }
 }));
 
@@ -132,7 +137,7 @@ const setup = async (clientOptions: Partial<Auth0ClientOptions> = {}) => {
 
   return {
     auth0,
-    cookieStorage: require('../src/storage').CookieStorage,
+    cookieStorage: require('../src/storage').CookieStorageWithLegacySameSite,
     cache,
     tokenVerifier,
     transactionManager,
@@ -2222,7 +2227,7 @@ describe('default creation function', () => {
   it('does nothing if there is nothing in storage', async () => {
     jest.spyOn(Auth0Client.prototype, 'getTokenSilently');
     const getSpy = jest
-      .spyOn(require('../src/storage').CookieStorage, 'get')
+      .spyOn(require('../src/storage').CookieStorageWithLegacySameSite, 'get')
       .mockReturnValueOnce(false);
 
     const auth0 = await createAuth0Client({
diff --git a/__tests__/storage.test.ts b/__tests__/storage.test.ts
index 389b820..c4fb416 100644
--- a/__tests__/storage.test.ts
+++ b/__tests__/storage.test.ts
@@ -1,22 +1,23 @@
-import { CookieStorage } from '../src/storage';
+import * as esCookie from 'es-cookie';
+import { mocked } from 'ts-jest/utils';
+import { CookieStorage, CookieStorageWithLegacySameSite } from '../src/storage';
+
 jest.mock('es-cookie');
 
-describe('cookie storage', () => {
+describe('CookieStorage', () => {
+  let cookieMock;
+
   beforeEach(() => {
-    jest.resetAllMocks();
+    cookieMock = mocked(esCookie);
   });
   it('saves object', () => {
     const key = 'key';
     const value = { some: 'value' };
     const options = { daysUntilExpire: 1 };
     CookieStorage.save(key, value, options);
-    expect(require('es-cookie').set).toHaveBeenCalledWith(
-      key,
-      JSON.stringify(value),
-      {
-        expires: options.daysUntilExpire
-      }
-    );
+    expect(cookieMock.set).toHaveBeenCalledWith(key, JSON.stringify(value), {
+      expires: options.daysUntilExpire
+    });
   });
   it('saves object with secure flag and samesite=none when on https', () => {
     const key = 'key';
@@ -26,19 +27,15 @@ describe('cookie storage', () => {
     delete window.location;
     window.location = { ...originalLocation, protocol: 'https:' };
     CookieStorage.save(key, value, options);
-    expect(require('es-cookie').set).toHaveBeenCalledWith(
-      key,
-      JSON.stringify(value),
-      {
-        expires: options.daysUntilExpire,
-        secure: true,
-        sameSite: 'none'
-      }
-    );
+    expect(cookieMock.set).toHaveBeenCalledWith(key, JSON.stringify(value), {
+      expires: options.daysUntilExpire,
+      secure: true,
+      sameSite: 'none'
+    });
     window.location = originalLocation;
   });
   it('returns undefined when there is no object', () => {
-    const Cookie = require('es-cookie');
+    const Cookie = cookieMock;
     const key = 'key';
     Cookie.get = k => {
       expect(k).toBe(key);
@@ -48,7 +45,7 @@ describe('cookie storage', () => {
     expect(outputValue).toBeUndefined();
   });
   it('gets object', () => {
-    const Cookie = require('es-cookie');
+    const Cookie = cookieMock;
     const key = 'key';
     const value = { some: 'value' };
     Cookie.get = k => {
@@ -59,9 +56,90 @@ describe('cookie storage', () => {
     expect(outputValue).toMatchObject(value);
   });
   it('removes object', () => {
-    const Cookie = require('es-cookie');
+    const Cookie = cookieMock;
     const key = 'key';
     CookieStorage.remove(key);
     expect(Cookie.remove).toHaveBeenCalledWith(key);
   });
 });
+
+describe('CookieStorageWithLegacySameSite', () => {
+  let cookieMock;
+
+  beforeEach(() => {
+    cookieMock = mocked(esCookie);
+  });
+  it('saves object', () => {
+    const key = 'key';
+    const value = { some: 'value' };
+    const options = { daysUntilExpire: 1 };
+    CookieStorageWithLegacySameSite.save(key, value, options);
+    expect(cookieMock.set).toHaveBeenCalledWith(key, JSON.stringify(value), {
+      expires: options.daysUntilExpire
+    });
+    expect(cookieMock.set).toHaveBeenCalledWith(
+      `_legacy_${key}`,
+      JSON.stringify(value),
+      {
+        expires: options.daysUntilExpire
+      }
+    );
+  });
+  it('saves object with secure flag and samesite=none and legacy with no samesite when on https', () => {
+    const key = 'key';
+    const value = { some: 'value' };
+    const options = { daysUntilExpire: 1 };
+    const originalLocation = window.location;
+    delete window.location;
+    window.location = { ...originalLocation, protocol: 'https:' };
+    CookieStorageWithLegacySameSite.save(key, value, options);
+    expect(cookieMock.set).toHaveBeenCalledWith(key, JSON.stringify(value), {
+      expires: options.daysUntilExpire,
+      secure: true,
+      sameSite: 'none'
+    });
+    expect(cookieMock.set).toHaveBeenCalledWith(
+      `_legacy_${key}`,
+      JSON.stringify(value),
+      {
+        expires: options.daysUntilExpire,
+        secure: true
+      }
+    );
+    window.location = originalLocation;
+  });
+  it('returns undefined when there is no object', () => {
+    const Cookie = cookieMock;
+    const key = 'key';
+    Cookie.get = k => undefined;
+    const outputValue = CookieStorageWithLegacySameSite.get(key);
+    expect(outputValue).toBeUndefined();
+  });
+  it('returns modern samesite cookie when available', () => {
+    const Cookie = cookieMock;
+    const key = 'key';
+    Cookie.get = k => {
+      if (k === key) return JSON.stringify({ foo: 1 });
+      return JSON.stringify({ bar: 2 });
+    };
+    const outputValue = CookieStorageWithLegacySameSite.get(key);
+    expect(outputValue).toEqual({ foo: 1 });
+  });
+  it('falls back to legacy cookie when modern cookie is unavailable', () => {
+    const Cookie = cookieMock;
+    const key = 'key';
+    Cookie.get = k => {
+      if (k === key) return false;
+      return JSON.stringify({ bar: 2 });
+    };
+    const outputValue = CookieStorageWithLegacySameSite.get(key);
+    expect(outputValue).toEqual({ bar: 2 });
+  });
+  it('removes objects', () => {
+    const Cookie = cookieMock;
+    const key = 'key';
+    CookieStorageWithLegacySameSite.remove(key);
+    expect(Cookie.remove).toHaveBeenCalledWith(key);
+    expect(Cookie.remove).toHaveBeenCalledWith(`_legacy_${key}`);
+  });
+});