Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SDK-1858] Create legacy samsite cookie by default #568

Merged
merged 3 commits into from
Sep 2, 2020
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions __tests__/Auth0Client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
});
9 changes: 7 additions & 2 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}));

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
122 changes: 100 additions & 22 deletions __tests__/storage.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand All @@ -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 => {
Expand All @@ -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}`);
});
});
26 changes: 20 additions & 6 deletions src/Auth0Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -128,13 +133,18 @@ export default class Auth0Client {
private tokenIssuer: string;
private defaultScope: string;
private scope: string;
private cookieStorage: ClientStorage;

cacheLocation: CacheLocation;
private worker: Worker;

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}"`);
Expand Down Expand Up @@ -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
});
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
});

Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
38 changes: 38 additions & 0 deletions src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down