From 085e9d1116a9fdafb7eaf7165fb99ff232019da5 Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 9 Oct 2026 09:10:54 -0300 Subject: [PATCH 1/2] feat(security): refresh the access token in background ahead of expiry getAccessToken only refreshed once the token had expired, so every caller that asked for a token on the same shared event (event-site asks on every real time push, in every open tab) refreshed on the same second, which showed up as synchronized spikes on POST /oauth2/token. A still valid token inside its last ACCESS_TOKEN_REFRESH_AHEAD_SECS (capped to a quarter of its lifetime) is now returned right away and refreshed once per tab after a random delay in [0, ACCESS_TOKEN_REFRESH_SPREAD_MS), under the same cross tab lock. The refresh is skipped if another tab already stored a newer token, makes a single attempt, and on a rejected refresh token restores the session clearing flag so the hard expiry path keeps handling the logout. Expired tokens are still refreshed synchronously. --- .../security/__tests__/methods.test.js | 224 +++++++++++++++++- src/components/security/methods.js | 141 +++++++++-- 2 files changed, 339 insertions(+), 26 deletions(-) diff --git a/src/components/security/__tests__/methods.test.js b/src/components/security/__tests__/methods.test.js index 3fef8ea0..34e0eb0c 100644 --- a/src/components/security/__tests__/methods.test.js +++ b/src/components/security/__tests__/methods.test.js @@ -3,7 +3,15 @@ import { AUTH_ERROR_REFRESH_TOKEN_NETWORK_ERROR, } from '../constants'; -import { refreshAccessToken, retryWithBackoff, getAccessToken, setAccessTokenResolver } from '../methods'; +import { + refreshAccessToken, + retryWithBackoff, + getAccessToken, + setAccessTokenResolver, + 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 +25,7 @@ jest.mock('../../../utils/methods', () => ({ putOnLocalStorage: jest.fn(), retryPromise: jest.fn(), setSessionClearingState: jest.fn(), + isClearingSessionState: jest.fn(() => false), })); jest.mock('../../../utils/crypto', () => ({ @@ -53,7 +62,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 = () => { @@ -412,3 +427,208 @@ describe('setAccessTokenResolver / getAccessToken', () => { expect(resolver).toHaveBeenCalledTimes(1); }); }); + +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'); + }); + + it('leaves the logout to the hard expiry path when the background refresh token is rejected', async () => { + storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED)); + 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('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 729a2772..d80ff663 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,19 @@ 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()) { +const processRefreshToken = async (flow, refreshToken, withRetry = true) => { + + 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); let {access_token, expires_in, refresh_token, id_token} = response; if (typeof refresh_token === 'undefined') { refresh_token = null; // not using rotate policy @@ -338,6 +348,13 @@ 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 && + canRefreshAccessToken(flow) && + timeElapsedSecs >= expiresIn - getRefreshAheadSecs(expiresIn) + ) { + // still valid but about to expire: hand it back now and refresh it in background + scheduleBackgroundRefresh(); } return accessToken; } @@ -374,34 +391,110 @@ export const setAccessTokenResolver = (resolver) => { }; /** - * @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} */ -export const getAccessToken = async () => { - const resolveAccessToken = _global[ACCESS_TOKEN_RESOLVER_KEY]; - if (resolveAccessToken) return resolveAccessToken(); +const getRefreshAheadSecs = (lifetimeSecs) => + Math.min(ACCESS_TOKEN_REFRESH_AHEAD_SECS, Math.floor(lifetimeSecs / 4)); + +let backgroundRefreshTimer = 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); + } 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); + } + throw err; + } +} +/** + * Runs fn holding the access token lock, which is shared across tabs. + * @param fn + * @returns {Promise<*>} + */ +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 () => { + const resolveAccessToken = _global[ACCESS_TOKEN_RESOLVER_KEY]; + if (resolveAccessToken) return resolveAccessToken(); + + return withAccessTokenLock(_getAccessToken); } /** From 50f45847288421d4d7acf64fb1d970da71881abe Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 9 Oct 2026 09:28:05 -0300 Subject: [PATCH 2/2] fix(security): keep background refresh from touching a changed session - Store the refreshed token only if the stored session is still the one the background refresh started from (same refresh token and issue time), so a logout or a new login during the request is not undone. - Stop scheduling background refreshes for a refresh token the IDP rejected; the hard expiry path handles the logout. Transient errors stay retryable. - Skip a pending background refresh once an access token resolver is registered. --- .../security/__tests__/methods.test.js | 99 ++++++++++++++++++- src/components/security/methods.js | 39 +++++++- 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/src/components/security/__tests__/methods.test.js b/src/components/security/__tests__/methods.test.js index 34e0eb0c..6ca661fe 100644 --- a/src/components/security/__tests__/methods.test.js +++ b/src/components/security/__tests__/methods.test.js @@ -586,8 +586,9 @@ describe('getAccessToken background refresh', () => { 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)); + 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' }); @@ -601,6 +602,83 @@ describe('getAccessToken background refresh', () => { 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)); @@ -622,6 +700,25 @@ describe('getAccessToken background refresh', () => { expect(jest.getTimerCount()).toBe(0); }); + it('skips a pending background refresh once an access token resolver is registered', async () => { + storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED)); + const before = storage.authInfo; + + await expect(getAccessToken()).resolves.toBe('current-access-token'); + expect(jest.getTimerCount()).toBe(1); + + setAccessTokenResolver(jest.fn().mockResolvedValue('resolver-access-token')); + try { + jest.advanceTimersByTime(ACCESS_TOKEN_REFRESH_SPREAD_MS); + await flushPromises(); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(storage.authInfo).toBe(before); + } finally { + setAccessTokenResolver(null); + } + }); + it('does not schedule a background refresh on the implicit flow', async () => { global.window.OAUTH2_FLOW = 'token id_token'; storeAuthInfoRaw(authInfoWithElapsed(SOFT_EXPIRY_ELAPSED)); diff --git a/src/components/security/methods.js b/src/components/security/methods.js index d80ff663..d05b667d 100644 --- a/src/components/security/methods.js +++ b/src/components/security/methods.js @@ -302,7 +302,15 @@ export const retryWithBackoff = async (fn, maxRetries = MAX_RETRIES, baseDelayMs const canRefreshAccessToken = (flow) => flow === RESPONSE_TYPE_CODE && useOAuth2RefreshToken(); -const processRefreshToken = async (flow, refreshToken, withRetry = true) => { +/** + * @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) { @@ -313,6 +321,12 @@ const processRefreshToken = async (flow, refreshToken, withRetry = true) => { 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 @@ -350,6 +364,7 @@ const _getAccessToken = async () => { accessToken = await processRefreshToken(flow, refreshToken); } else if ( refreshToken && + refreshToken !== rejectedRefreshToken && canRefreshAccessToken(flow) && timeElapsedSecs >= expiresIn - getRefreshAheadSecs(expiresIn) ) { @@ -400,7 +415,21 @@ export const setAccessTokenResolver = (resolver) => { 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 @@ -429,6 +458,11 @@ const scheduleBackgroundRefresh = () => { * @private */ const _backgroundRefresh = async () => { + if (_global[ACCESS_TOKEN_RESOLVER_KEY]) { + // a resolver registered after this refresh was scheduled owns the access token now + console.log(`openstack-uicore-foundation::Security::methods::_backgroundRefresh access token resolver registered, skipping`); + return; + } const authInfo = getAuthInfo(); if (!authInfo || !authInfo.accessToken || !authInfo.refreshToken) return; @@ -447,13 +481,14 @@ const _backgroundRefresh = async () => { 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); + 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; }