diff --git a/src/components/security/__tests__/methods.test.js b/src/components/security/__tests__/methods.test.js index f1e6089a..375a8793 100644 --- a/src/components/security/__tests__/methods.test.js +++ b/src/components/security/__tests__/methods.test.js @@ -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', () => ({ @@ -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', () => ({ @@ -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 = () => { @@ -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'); + }); +}); diff --git a/src/components/security/methods.js b/src/components/security/methods.js index 95339055..d5b42f0c 100644 --- a/src/components/security/methods.js +++ b/src/components/security/methods.js @@ -8,6 +8,7 @@ import { putOnLocalStorage, retryPromise, setSessionClearingState, + isClearingSessionState, } from "../../utils/methods"; import moment from "moment-timezone"; import request from 'superagent/lib/client'; @@ -43,6 +44,11 @@ const GET_TOKEN_SILENTLY_LOCK_KEY = 'openstackuicore.lock.getTokenSilently'; const GET_TOKEN_SILENTLY_LOCK_KEY_TIMEOUT = 6000; const NONCE_LEN = 16; export const ACCESS_TOKEN_SKEW_TIME = 60; +// a still valid token starts being refreshed in background this many seconds before it expires +export const ACCESS_TOKEN_REFRESH_AHEAD_SECS = 300; +// the background refresh runs after a random delay in [0, spread) so clients that ask for a +// token on the same shared event (e.g. a real time push) do not refresh on the same second +export const ACCESS_TOKEN_REFRESH_SPREAD_MS = 30000; export const RESPONSE_TYPE_IMPLICIT = "token id_token"; export const RESPONSE_TYPE_CODE = 'code'; const AUTH_INFO = 'authInfo'; @@ -294,15 +300,33 @@ export const retryWithBackoff = async (fn, maxRetries = MAX_RETRIES, baseDelayMs } }; -const processRefreshToken = async (flow, refreshToken) => { +const canRefreshAccessToken = (flow) => flow === RESPONSE_TYPE_CODE && useOAuth2RefreshToken(); - if (flow === RESPONSE_TYPE_CODE && useOAuth2RefreshToken()) { +/** + * @param flow + * @param refreshToken + * @param withRetry retry transient errors with backoff + * @param expectedAuthInfo when set, the response is stored only if the stored session is still + * this one; returns null otherwise + * @returns {Promise<*>} + */ +const processRefreshToken = async (flow, refreshToken, withRetry = true, expectedAuthInfo = null) => { + + if (canRefreshAccessToken(flow)) { if (!refreshToken) { clearAuthInfo(); throw Error(AUTH_ERROR_MISSING_REFRESH_TOKEN); } - let response = await retryWithBackoff(() => refreshAccessToken(refreshToken)); + let response = withRetry ? + await retryWithBackoff(() => refreshAccessToken(refreshToken)) : + await refreshAccessToken(refreshToken); + if (expectedAuthInfo && !isSameSession(getAuthInfo(), expectedAuthInfo)) { + // logged out or logged in again while the request was in flight: do not bring the + // previous session back + console.log(`openstack-uicore-foundation::Security::methods::processRefreshToken session changed, discarding refreshed token`); + return null; + } let {access_token, expires_in, refresh_token, id_token} = response; if (typeof refresh_token === 'undefined') { refresh_token = null; // not using rotate policy @@ -338,38 +362,135 @@ const _getAccessToken = async () => { if (timeElapsedSecs >= expiresIn || accessToken == null) { console.log(`openstack-uicore-foundation::Security::methods::_getAccessToken access token expired, refreshing it ...`); accessToken = await processRefreshToken(flow, refreshToken); + } else if ( + refreshToken && + refreshToken !== rejectedRefreshToken && + canRefreshAccessToken(flow) && + timeElapsedSecs >= expiresIn - getRefreshAheadSecs(expiresIn) + ) { + // still valid but about to expire: hand it back now and refresh it in background + scheduleBackgroundRefresh(); } return accessToken; } /** - * @returns {Promise<*|undefined>} + * Seconds before expiry at which a still valid token starts being refreshed in background. + * Capped to a quarter of the token lifetime so a short lived token is not refreshed right + * after being issued. + * @param lifetimeSecs + * @returns {number} + */ +const getRefreshAheadSecs = (lifetimeSecs) => + Math.min(ACCESS_TOKEN_REFRESH_AHEAD_SECS, Math.floor(lifetimeSecs / 4)); + +/** + * Both describe the same session: same refresh token, same access token issue time. + * @param authInfo + * @param expected + * @returns {boolean} + */ +const isSameSession = (authInfo, expected) => + !!authInfo && + authInfo.refreshToken === expected.refreshToken && + authInfo.accessTokenUpdatedAt === expected.accessTokenUpdatedAt; + +let backgroundRefreshTimer = null; +// refresh token the IDP rejected on a background refresh; no more background attempts with it, +// the hard expiry path handles the logout +let rejectedRefreshToken = null; + +/** + * Schedules one background refresh per tab after a random delay. Callers that ask for a token + * on the same shared event (a real time push reaches every open tab at once) would otherwise + * all refresh on the same second once their tokens expire. + */ +const scheduleBackgroundRefresh = () => { + if (backgroundRefreshTimer !== null || typeof window === 'undefined') return; + const delay = Math.floor(Math.random() * ACCESS_TOKEN_REFRESH_SPREAD_MS); + console.log(`openstack-uicore-foundation::Security::methods::scheduleBackgroundRefresh in ${delay} ms`); + backgroundRefreshTimer = setTimeout(async () => { + try { + await withAccessTokenLock(_backgroundRefresh); + } catch (err) { + // the current token is still valid; the hard expiry path refreshes it if this keeps failing + console.log(`openstack-uicore-foundation::Security::methods::scheduleBackgroundRefresh error`, err); + } finally { + backgroundRefreshTimer = null; + } + }, delay); +} + +/** + * Runs under the access token lock. + * @returns {Promise} + * @private + */ +const _backgroundRefresh = async () => { + const authInfo = getAuthInfo(); + if (!authInfo || !authInfo.accessToken || !authInfo.refreshToken) return; + + const {accessTokenUpdatedAt, refreshToken} = authInfo; + const expiresIn = authInfo.expiresIn - ACCESS_TOKEN_SKEW_TIME; + const timeElapsedSecs = moment().unix() - accessTokenUpdatedAt; + + if (timeElapsedSecs < expiresIn - getRefreshAheadSecs(expiresIn)) { + // another tab (or a call that hit the hard expiry) already stored a newer token + console.log(`openstack-uicore-foundation::Security::methods::_backgroundRefresh token already refreshed`); + return; + } + + console.log(`openstack-uicore-foundation::Security::methods::_backgroundRefresh refreshing access token ...`); + const wasClearingSessionState = isClearingSessionState(); + try { + // single attempt: retrying with backoff would hold the lock for every tab while the + // current token is still valid + await processRefreshToken(getOAuth2Flow(), refreshToken, false, authInfo); + } catch (err) { + if (err.message && err.message.startsWith(AUTH_ERROR_REFRESH_TOKEN_REQUEST_ERROR)) { + // refreshAccessToken flags the session as being cleared on a rejected refresh token, + // which makes initLogin skip the re-login. Nobody handles this error here, so leave + // the logout to the hard expiry path, which surfaces it to the caller. + setSessionClearingState(wasClearingSessionState); + rejectedRefreshToken = refreshToken; + } + throw err; + } +} + +/** + * Runs fn holding the access token lock, which is shared across tabs. + * @param fn + * @returns {Promise<*>} */ -export const getAccessToken = async () => { +const withAccessTokenLock = async (fn) => { if (typeof navigator !== 'undefined' && navigator.locks) { return await navigator.locks.request(GET_TOKEN_SILENTLY_LOCK_KEY, async lock => { - console.log(`openstack-uicore-foundation::Security::methods::getAccessToken web lock api`, lock); - return await _getAccessToken(); + console.log(`openstack-uicore-foundation::Security::methods::withAccessTokenLock web lock api`, lock); + return await fn(); }); - } else { - if ( - await retryPromise( - () => Lock.acquireLock(GET_TOKEN_SILENTLY_LOCK_KEY, GET_TOKEN_SILENTLY_LOCK_KEY_TIMEOUT), - 10 - ) - ) { - try { - return await _getAccessToken(); - } finally { - await Lock.releaseLock(GET_TOKEN_SILENTLY_LOCK_KEY); - } - } else { - // error on locking - throw Error(AUTH_ERROR_LOCK_ACQUIRE_ERROR); + } + if ( + await retryPromise( + () => Lock.acquireLock(GET_TOKEN_SILENTLY_LOCK_KEY, GET_TOKEN_SILENTLY_LOCK_KEY_TIMEOUT), + 10 + ) + ) { + try { + return await fn(); + } finally { + await Lock.releaseLock(GET_TOKEN_SILENTLY_LOCK_KEY); } } + // error on locking + throw Error(AUTH_ERROR_LOCK_ACQUIRE_ERROR); } +/** + * @returns {Promise<*|undefined>} + */ +export const getAccessToken = async () => withAccessTokenLock(_getAccessToken); + /** * @private */