From 27eac9a9adf6a026d3b9f9958f0b8ea3dbd2aac4 Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 9 Oct 2026 09:10:47 -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 | 223 +++++++++++++++++- src/components/security/methods.js | 135 +++++++++-- 2 files changed, 334 insertions(+), 24 deletions(-) diff --git a/src/components/security/__tests__/methods.test.js b/src/components/security/__tests__/methods.test.js index f1e6089a..21a9597a 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,208 @@ 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'); + }); + + 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 95339055..b86bc9d7 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,38 +348,119 @@ 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; } /** - * @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 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 () => withAccessTokenLock(_getAccessToken); + /** * @private */ From a48e2196a565898fd389001a28458857e760efae Mon Sep 17 00:00:00 2001 From: smarcet Date: Fri, 9 Oct 2026 09:28:03 -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. --- .../security/__tests__/methods.test.js | 80 ++++++++++++++++++- src/components/security/methods.js | 34 +++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/components/security/__tests__/methods.test.js b/src/components/security/__tests__/methods.test.js index 21a9597a..375a8793 100644 --- a/src/components/security/__tests__/methods.test.js +++ b/src/components/security/__tests__/methods.test.js @@ -543,8 +543,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' }); @@ -558,6 +559,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)); diff --git a/src/components/security/methods.js b/src/components/security/methods.js index b86bc9d7..d5b42f0c 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) ) { @@ -369,7 +384,21 @@ const _getAccessToken = async () => { 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 @@ -416,13 +445,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; }