Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
301 changes: 299 additions & 2 deletions src/components/security/__tests__/methods.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import {
AUTH_ERROR_REFRESH_TOKEN_NETWORK_ERROR,
} from '../constants';

import { refreshAccessToken, retryWithBackoff } from '../methods';
import {
refreshAccessToken,
retryWithBackoff,
getAccessToken,
ACCESS_TOKEN_SKEW_TIME,
ACCESS_TOKEN_REFRESH_AHEAD_SECS,
ACCESS_TOKEN_REFRESH_SPREAD_MS,
} from '../methods';

// Mock utils/methods imports used by security/methods
jest.mock('../../../utils/methods', () => ({
Expand All @@ -17,6 +24,7 @@ jest.mock('../../../utils/methods', () => ({
putOnLocalStorage: jest.fn(),
retryPromise: jest.fn(),
setSessionClearingState: jest.fn(),
isClearingSessionState: jest.fn(() => false),
}));

jest.mock('../../../utils/crypto', () => ({
Expand Down Expand Up @@ -53,7 +61,13 @@ jest.mock('../actions', () => ({
SET_LOGGED_USER: 'SET_LOGGED_USER',
}));

const { setSessionClearingState } = require('../../../utils/methods');
const {
setSessionClearingState,
getFromLocalStorage,
putOnLocalStorage,
removeFromLocalStorage,
retryPromise,
} = require('../../../utils/methods');

// Helper to set window globals needed by methods.js
const setupWindowGlobals = () => {
Expand Down Expand Up @@ -370,3 +384,286 @@ describe('retryWithBackoff', () => {
setTimeoutSpy.mockRestore();
});
});

describe('getAccessToken background refresh', () => {
// moment().unix() is mocked to 1000
const NOW = 1000;
const EXPIRES_IN = 7200;
const LIFETIME = EXPIRES_IN - ACCESS_TOKEN_SKEW_TIME;
// first second of the background refresh window
const SOFT_EXPIRY_ELAPSED = LIFETIME - ACCESS_TOKEN_REFRESH_AHEAD_SECS;
const realSetImmediate = jest.requireActual('timers').setImmediate;
const flushPromises = () => new Promise(resolve => realSetImmediate(resolve));

let storage;

const storeAuthInfoRaw = (authInfo) => {
storage.authInfo = JSON.stringify(authInfo);
};

const readAuthInfo = () => JSON.parse(storage.authInfo);

const authInfoWithElapsed = (elapsedSecs, expiresIn = EXPIRES_IN) => ({
accessToken: 'current-access-token',
expiresIn,
accessTokenUpdatedAt: NOW - elapsedSecs,
refreshToken: 'current-refresh-token',
});

const okRefreshResponse = () => ({
ok: true,
status: 200,
json: jest.fn().mockResolvedValue({
access_token: 'new-access-token',
expires_in: EXPIRES_IN,
}),
});

beforeEach(() => {
storage = {};
// storeAuthInfo stamps accessTokenUpdatedAt with Date.now()
jest.setSystemTime(NOW * 1000);
getFromLocalStorage.mockImplementation((key) => storage[key] ?? null);
putOnLocalStorage.mockImplementation((key, value) => { storage[key] = value; });
removeFromLocalStorage.mockImplementation((key) => { delete storage[key]; });
retryPromise.mockResolvedValue(true);
jest.spyOn(Math, 'random').mockReturnValue(0.5);
global.fetch = jest.fn().mockResolvedValue(okRefreshResponse());
});

afterEach(async () => {
// let a pending background refresh settle so it does not leak into the next test
jest.runOnlyPendingTimers();
await flushPromises();
Math.random.mockRestore();
});

it('does not refresh nor schedule anything while the token is outside the refresh window', async () => {
storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED - 1));

await expect(getAccessToken()).resolves.toBe('current-access-token');

expect(jest.getTimerCount()).toBe(0);
expect(global.fetch).not.toHaveBeenCalled();
});

it('returns the current token and refreshes it after a random delay inside the refresh window', async () => {
storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED));
const delay = ACCESS_TOKEN_REFRESH_SPREAD_MS / 2; // Math.random() = 0.5

await expect(getAccessToken()).resolves.toBe('current-access-token');
expect(global.fetch).not.toHaveBeenCalled();

jest.advanceTimersByTime(delay - 1);
await flushPromises();
expect(global.fetch).not.toHaveBeenCalled();

jest.advanceTimersByTime(1);
await flushPromises();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toMatchObject({
grant_type: 'refresh_token',
refresh_token: 'current-refresh-token',
});
expect(readAuthInfo()).toMatchObject({
accessToken: 'new-access-token',
accessTokenUpdatedAt: NOW + delay / 1000,
refreshToken: 'current-refresh-token',
});
});

it('spreads the background refresh across [0, spread) using Math.random', async () => {
storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED));
Math.random.mockReturnValue(0);

await getAccessToken();
jest.advanceTimersByTime(0);
await flushPromises();

expect(global.fetch).toHaveBeenCalledTimes(1);
});

it('schedules a single background refresh for several calls in the same tab', async () => {
storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED + 10));

await getAccessToken();
await getAccessToken();
await getAccessToken();
expect(jest.getTimerCount()).toBe(1);

jest.advanceTimersByTime(ACCESS_TOKEN_REFRESH_SPREAD_MS);
await flushPromises();

expect(global.fetch).toHaveBeenCalledTimes(1);
});

it('skips the background refresh when another tab already stored a newer token', async () => {
storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED));

await getAccessToken();
// another tab refreshes first and writes the shared storage
storeAuthInfoRaw({ ...authInfoWithElapsed(0), accessToken: 'other-tab-access-token' });

jest.advanceTimersByTime(ACCESS_TOKEN_REFRESH_SPREAD_MS);
await flushPromises();

expect(global.fetch).not.toHaveBeenCalled();
expect(readAuthInfo().accessToken).toBe('other-tab-access-token');
});

it('still refreshes synchronously once the token is past its expiry', async () => {
storeAuthInfoRaw(authInfoWithElapsed(LIFETIME));

await expect(getAccessToken()).resolves.toBe('new-access-token');

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
});

it('keeps the current token and does not retry when the background refresh hits a transient error', async () => {
storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED));
const before = storage.authInfo;
global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'));

await getAccessToken();
jest.advanceTimersByTime(ACCESS_TOKEN_REFRESH_SPREAD_MS);
await flushPromises();

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(storage.authInfo).toBe(before);
expect(setSessionClearingState).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);

// a later call inside the window schedules a new attempt
global.fetch = jest.fn().mockResolvedValue(okRefreshResponse());
await getAccessToken();
jest.advanceTimersByTime(ACCESS_TOKEN_REFRESH_SPREAD_MS);
await flushPromises();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(readAuthInfo().accessToken).toBe('new-access-token');
});

// the rejected refresh token is remembered per module, so these tests use their own tokens
it('leaves the logout to the hard expiry path when the background refresh token is rejected', async () => {
storeAuthInfoRaw({ ...authInfoWithElapsed(SOFT_EXPIRY_ELAPSED), refreshToken: 'revoked-refresh-token-1' });
const before = storage.authInfo;
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 400, statusText: 'Bad Request' });

await expect(getAccessToken()).resolves.toBe('current-access-token');
jest.advanceTimersByTime(ACCESS_TOKEN_REFRESH_SPREAD_MS);
await flushPromises();

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(storage.authInfo).toBe(before);
// refreshAccessToken sets it to true, the background path restores the previous value
expect(setSessionClearingState.mock.calls).toEqual([[true], [false]]);
});

it('does not retry a rejected refresh token in background, but resumes for a new session', async () => {
storeAuthInfoRaw({ ...authInfoWithElapsed(SOFT_EXPIRY_ELAPSED), refreshToken: 'revoked-refresh-token-2' });
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 400, statusText: 'Bad Request' });

await getAccessToken();
jest.advanceTimersByTime(ACCESS_TOKEN_REFRESH_SPREAD_MS);
await flushPromises();
expect(global.fetch).toHaveBeenCalledTimes(1);

// still inside the window with the same rejected token: nothing is scheduled
await expect(getAccessToken()).resolves.toBe('current-access-token');
expect(jest.getTimerCount()).toBe(0);

// a new login brings a new refresh token
storeAuthInfoRaw({ ...authInfoWithElapsed(SOFT_EXPIRY_ELAPSED), refreshToken: 'new-login-refresh-token' });
global.fetch = jest.fn().mockResolvedValue(okRefreshResponse());
await getAccessToken();
expect(jest.getTimerCount()).toBe(1);
});

it('still retries a transient background failure on a later call', async () => {
storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED));
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable' });

await getAccessToken();
jest.advanceTimersByTime(ACCESS_TOKEN_REFRESH_SPREAD_MS);
await flushPromises();
expect(global.fetch).toHaveBeenCalledTimes(1);

await getAccessToken();
expect(jest.getTimerCount()).toBe(1);
});

describe('when the session changes while the background request is in flight', () => {
let resolveFetch;

beforeEach(() => {
global.fetch = jest.fn(() => new Promise(resolve => { resolveFetch = resolve; }));
});

const startBackgroundRefresh = async () => {
storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED));
await getAccessToken();
jest.advanceTimersByTime(ACCESS_TOKEN_REFRESH_SPREAD_MS);
await flushPromises();
expect(global.fetch).toHaveBeenCalledTimes(1);
};

it('does not restore the credentials after a logout', async () => {
await startBackgroundRefresh();

// LOGOUT_USER clears the auth info without taking the lock
delete storage.authInfo;
resolveFetch(okRefreshResponse());
await flushPromises();

expect(storage.authInfo).toBeUndefined();
});

it('does not overwrite a new login', async () => {
await startBackgroundRefresh();

// onUserAuth stores a new session without taking the lock
const newLogin = {
accessToken: 'new-login-access-token',
expiresIn: EXPIRES_IN,
accessTokenUpdatedAt: NOW,
refreshToken: 'new-login-refresh-token',
};
storeAuthInfoRaw(newLogin);
resolveFetch(okRefreshResponse());
await flushPromises();

expect(readAuthInfo()).toEqual(newLogin);
});
});

it('caps the refresh window to a quarter of a short token lifetime', async () => {
const expiresIn = 300; // lifetime 240s, window 60s
storeAuthInfoRaw(authInfoWithElapsed(100, expiresIn));

await getAccessToken();
expect(jest.getTimerCount()).toBe(0);

storeAuthInfoRaw(authInfoWithElapsed(180, expiresIn));
await getAccessToken();
expect(jest.getTimerCount()).toBe(1);
});

it('does not schedule a background refresh without a refresh token', async () => {
const { refreshToken, ...withoutRefreshToken } = authInfoWithElapsed(SOFT_EXPIRY_ELAPSED);
storeAuthInfoRaw(withoutRefreshToken);

await expect(getAccessToken()).resolves.toBe('current-access-token');

expect(jest.getTimerCount()).toBe(0);
});

it('does not schedule a background refresh on the implicit flow', async () => {
global.window.OAUTH2_FLOW = 'token id_token';
storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED));

await expect(getAccessToken()).resolves.toBe('current-access-token');

expect(jest.getTimerCount()).toBe(0);
expect(readAuthInfo().accessToken).toBe('current-access-token');
});
});
Loading
Loading