From ee97efb20b72905931336efedc70d7aa46a33b42 Mon Sep 17 00:00:00 2001 From: Michael Sober Date: Thu, 30 Jul 2026 11:31:53 +0000 Subject: [PATCH 1/2] fix(auth): bound inflight OAuth wait and broaden web cancel detection Cancelling a native sign-in sheet (e.g. Sign in with Apple on iOS Safari) left the inflightOAuth flag set, so every subsequent auth call hung forever awaiting an unsettleable promise. - TokenOrchestrator: bound the inflight wait with an internal 60s timeout that clears the persisted inflight flag and settles all waiters, so getTokens() proceeds as no-session instead of hanging. - cancelOAuthFlow: detect cancellation on visibilitychange/focus in addition to bfcache pageshow, guarded against OAuth response params, a grace period and the inflight flag; listeners are removed once the flow settles. fixes #14900 --- .../cognito/tokenOrchestrator.test.ts | 96 +++++++++- .../utils/oauth/cancelOAuthFlow.test.ts | 176 ++++++++++++++++++ .../tokenProvider/TokenOrchestrator.ts | 45 ++++- .../cognito/utils/oauth/cancelOAuthFlow.ts | 89 ++++++++- 4 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 packages/auth/__tests__/providers/cognito/utils/oauth/cancelOAuthFlow.test.ts diff --git a/packages/auth/__tests__/providers/cognito/tokenOrchestrator.test.ts b/packages/auth/__tests__/providers/cognito/tokenOrchestrator.test.ts index 704f08175d4..41222a2515a 100644 --- a/packages/auth/__tests__/providers/cognito/tokenOrchestrator.test.ts +++ b/packages/auth/__tests__/providers/cognito/tokenOrchestrator.test.ts @@ -5,7 +5,10 @@ import { Hub, ResourcesConfig } from '@aws-amplify/core'; import { AMPLIFY_SYMBOL } from '@aws-amplify/core/internals/utils'; import { TokenOrchestrator } from '../../../src/providers/cognito/tokenProvider'; -import { addInflightPromise } from '../../../src/providers/cognito/utils/oauth/inflightPromise'; +import { + addInflightPromise, + resolveAndClearInflightPromises, +} from '../../../src/providers/cognito/utils/oauth/inflightPromise'; import { oAuthStore } from '../../../src/providers/cognito/utils/oauth'; jest.mock('../../../src/providers/cognito/utils/oauth/oAuthStore'); @@ -42,6 +45,7 @@ const validAuthConfig: ResourcesConfig = { jest.mock('../../../src/providers/cognito/utils/oauth/inflightPromise', () => ({ addInflightPromise: jest.fn(), + resolveAndClearInflightPromises: jest.fn(), })); const currentDate = new Date(); @@ -152,6 +156,96 @@ describe('TokenOrchestrator', () => { }); }); + describe('inflight OAuth timeout', () => { + const INFLIGHT_OAUTH_TIMEOUT_MS = 60_000; + let orchestrator: TokenOrchestrator; + + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick'] }); + jest.clearAllMocks(); + // never resolved externally: simulates a cancelled native sign-in sheet + mockAddInflightPromise.mockImplementation(() => undefined); + (oAuthStore.loadOAuthInFlight as jest.Mock).mockResolvedValue(true); + (oAuthStore.clearOAuthInflightData as jest.Mock).mockResolvedValue( + undefined, + ); + mockAuthTokenStore.loadTokens.mockResolvedValue(null); + mockAuthTokenStore.getLastAuthUser.mockResolvedValue('test-username'); + orchestrator = new TokenOrchestrator(); + orchestrator.setAuthConfig(validAuthConfig.Auth!); + orchestrator.setAuthTokenStore(mockAuthTokenStore); + orchestrator.setTokenRefresher(mockTokenRefresher); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not settle the inflight wait before the timeout elapses', async () => { + let settled = false; + const wait = orchestrator.waitForInflightOAuth().then(() => { + settled = true; + }); + + await Promise.resolve(); + jest.advanceTimersByTime(INFLIGHT_OAUTH_TIMEOUT_MS - 1); + await new Promise(resolve => { + process.nextTick(resolve); + }); + + expect(settled).toBe(false); + expect(oAuthStore.clearOAuthInflightData).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + await wait; + expect(settled).toBe(true); + }); + + it('clears the persisted inflight flag and settles waiters on timeout', async () => { + const wait = orchestrator.waitForInflightOAuth(); + + await new Promise(resolve => { + process.nextTick(resolve); + }); + jest.advanceTimersByTime(INFLIGHT_OAUTH_TIMEOUT_MS); + + await expect(wait).resolves.toBeUndefined(); + expect(oAuthStore.clearOAuthInflightData).toHaveBeenCalledTimes(1); + expect(resolveAndClearInflightPromises).toHaveBeenCalledTimes(1); + }); + + it('getTokens returns no session on timeout instead of hanging', async () => { + const tokensPromise = orchestrator.getTokens(); + + await new Promise(resolve => { + process.nextTick(resolve); + }); + jest.advanceTimersByTime(INFLIGHT_OAUTH_TIMEOUT_MS); + + await expect(tokensPromise).resolves.toBeNull(); + expect(oAuthStore.clearOAuthInflightData).toHaveBeenCalledTimes(1); + }); + + it('does not clear inflight data when the OAuth flow completes normally', async () => { + mockAddInflightPromise.mockImplementation(resolver => { + resolver(); + }); + mockAuthTokenStore.loadTokens.mockResolvedValue(validAuthTokens); + + const tokens = await orchestrator.getTokens(); + + expect(tokens?.accessToken).toEqual(validAuthTokens.accessToken); + + jest.advanceTimersByTime(INFLIGHT_OAUTH_TIMEOUT_MS * 2); + await new Promise(resolve => { + process.nextTick(resolve); + }); + + expect(oAuthStore.clearOAuthInflightData).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }); + }); + describe('setClientMetadataProvider', () => { it('should use clientMetadataProvider for token refresh', async () => { const clientMetadata = { 'app-version': '1.0.0' }; diff --git a/packages/auth/__tests__/providers/cognito/utils/oauth/cancelOAuthFlow.test.ts b/packages/auth/__tests__/providers/cognito/utils/oauth/cancelOAuthFlow.test.ts new file mode 100644 index 00000000000..198c87032bc --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/oauth/cancelOAuthFlow.test.ts @@ -0,0 +1,176 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { listenForOAuthFlowCancellation } from '../../../../../src/providers/cognito/utils/oauth/cancelOAuthFlow'; +import { handleFailure } from '../../../../../src/providers/cognito/utils/oauth/handleFailure'; +import { OAuthStore } from '../../../../../src/providers/cognito/utils/types'; + +jest.mock('../../../../../src/providers/cognito/utils/oauth/handleFailure'); + +const mockHandleFailure = handleFailure as jest.Mock; + +const setUrl = (url: string) => { + window.history.replaceState({}, '', url); +}; + +const setVisibility = (state: DocumentVisibilityState) => { + Object.defineProperty(document, 'visibilityState', { + value: state, + configurable: true, + }); +}; + +const flush = () => + new Promise(resolve => { + process.nextTick(resolve); + }); + +describe('listenForOAuthFlowCancellation', () => { + let store: OAuthStore; + + beforeEach(() => { + jest.clearAllMocks(); + mockHandleFailure.mockResolvedValue(undefined); + setUrl('/'); + setVisibility('visible'); + store = { + loadOAuthInFlight: jest.fn().mockResolvedValue(true), + } as unknown as OAuthStore; + }); + + describe('bfcache pageshow', () => { + it('cancels the flow on a bfcache restore while inflight', async () => { + listenForOAuthFlowCancellation(store); + + window.dispatchEvent( + new PageTransitionEvent('pageshow', { persisted: true }), + ); + await flush(); + + expect(mockHandleFailure).toHaveBeenCalledTimes(1); + expect(mockHandleFailure.mock.calls[0][0].message).toBe( + 'User cancelled OAuth flow.', + ); + }); + + it('does not cancel when the pageshow is not from bfcache', async () => { + listenForOAuthFlowCancellation(store); + + window.dispatchEvent( + new PageTransitionEvent('pageshow', { persisted: false }), + ); + await flush(); + + expect(mockHandleFailure).not.toHaveBeenCalled(); + }); + }); + + describe('regained foreground (native sheet dismissal)', () => { + beforeEach(() => { + jest + .useFakeTimers({ doNotFake: ['nextTick'] }) + .setSystemTime(new Date('2026-01-01T00:00:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const advancePastGracePeriod = () => { + jest.setSystemTime(new Date('2026-01-01T00:00:05Z')); + }; + + it('cancels the flow on visibilitychange back to visible', async () => { + listenForOAuthFlowCancellation(store); + advancePastGracePeriod(); + + document.dispatchEvent(new Event('visibilitychange')); + await flush(); + + expect(store.loadOAuthInFlight).toHaveBeenCalled(); + expect(mockHandleFailure).toHaveBeenCalledTimes(1); + }); + + it('cancels the flow on window focus', async () => { + listenForOAuthFlowCancellation(store); + advancePastGracePeriod(); + + window.dispatchEvent(new Event('focus')); + await flush(); + + expect(mockHandleFailure).toHaveBeenCalledTimes(1); + }); + + it('ignores visibilitychange when the page became hidden', async () => { + listenForOAuthFlowCancellation(store); + advancePastGracePeriod(); + setVisibility('hidden'); + + document.dispatchEvent(new Event('visibilitychange')); + await flush(); + + expect(mockHandleFailure).not.toHaveBeenCalled(); + }); + + it('does not cancel within the grace period', async () => { + listenForOAuthFlowCancellation(store); + + window.dispatchEvent(new Event('focus')); + await flush(); + + expect(mockHandleFailure).not.toHaveBeenCalled(); + }); + + it.each(['code=abc', 'error=access_denied', 'state=xyz'])( + 'does not cancel during a legitimate redirect (?%s)', + async param => { + setUrl(`/?${param}`); + listenForOAuthFlowCancellation(store); + advancePastGracePeriod(); + + window.dispatchEvent(new Event('focus')); + await flush(); + + expect(mockHandleFailure).not.toHaveBeenCalled(); + }, + ); + + it('does not cancel during an implicit flow redirect (hash tokens)', async () => { + setUrl('/#access_token=abc&token_type=Bearer'); + listenForOAuthFlowCancellation(store); + advancePastGracePeriod(); + + window.dispatchEvent(new Event('focus')); + await flush(); + + expect(mockHandleFailure).not.toHaveBeenCalled(); + }); + + it('does not cancel when no flow is inflight', async () => { + (store.loadOAuthInFlight as jest.Mock).mockResolvedValue(false); + listenForOAuthFlowCancellation(store); + advancePastGracePeriod(); + + window.dispatchEvent(new Event('focus')); + await flush(); + + expect(mockHandleFailure).not.toHaveBeenCalled(); + }); + + it('cancels only once and removes its listeners afterwards', async () => { + listenForOAuthFlowCancellation(store); + advancePastGracePeriod(); + + window.dispatchEvent(new Event('focus')); + await flush(); + window.dispatchEvent(new Event('focus')); + document.dispatchEvent(new Event('visibilitychange')); + window.dispatchEvent( + new PageTransitionEvent('pageshow', { persisted: true }), + ); + await flush(); + + expect(mockHandleFailure).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/auth/src/providers/cognito/tokenProvider/TokenOrchestrator.ts b/packages/auth/src/providers/cognito/tokenProvider/TokenOrchestrator.ts index 7077e42c483..ac6ace906e6 100644 --- a/packages/auth/src/providers/cognito/tokenProvider/TokenOrchestrator.ts +++ b/packages/auth/src/providers/cognito/tokenProvider/TokenOrchestrator.ts @@ -18,7 +18,10 @@ import { import { assertServiceError } from '../../../errors/utils/assertServiceError'; import { AuthError } from '../../../errors/AuthError'; import { oAuthStore } from '../utils/oauth/oAuthStore'; -import { addInflightPromise } from '../utils/oauth/inflightPromise'; +import { + addInflightPromise, + resolveAndClearInflightPromises, +} from '../utils/oauth/inflightPromise'; import { ClientMetadata, CognitoAuthSignInDetails } from '../types'; import { @@ -30,6 +33,15 @@ import { TokenRefresher, } from './types'; +/** + * Upper bound for how long token fetching may be blocked by an inflight OAuth + * flow. Some platforms (e.g. a dismissed native "Sign in with Apple" sheet on + * iOS Safari) provide no reliable cancellation signal, which would otherwise + * leave the inflight flag set forever and hang every subsequent auth call. + * See https://github.com/aws-amplify/amplify-js/issues/14900 + */ +const INFLIGHT_OAUTH_TIMEOUT_MS = 60_000; + export class TokenOrchestrator implements AuthTokenOrchestrator { private authConfig?: AuthConfig; clientMetadataProvider?: ClientMetadataProvider; @@ -50,8 +62,35 @@ export class TokenOrchestrator implements AuthTokenOrchestrator { // to block async calls that require fetching tokens before the oauth flow completes // e.g. getCurrentUser, fetchAuthSession etc. - this.inflightPromise = new Promise((resolve, _reject) => { - addInflightPromise(resolve); + this.inflightPromise = new Promise(resolve => { + let settled = false; + let timeoutId: ReturnType | undefined; + + addInflightPromise(() => { + settled = true; + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + resolve(); + }); + + if (settled) { + return; + } + + timeoutId = setTimeout(() => { + timeoutId = undefined; + const clearAndSettle = async () => { + try { + await oAuthStore.clearOAuthInflightData(); + } finally { + resolveAndClearInflightPromises(); + resolve(); + } + }; + clearAndSettle().catch(() => undefined); + }, INFLIGHT_OAUTH_TIMEOUT_MS); }); return this.inflightPromise; diff --git a/packages/auth/src/providers/cognito/utils/oauth/cancelOAuthFlow.ts b/packages/auth/src/providers/cognito/utils/oauth/cancelOAuthFlow.ts index 597df182255..4a538045a9f 100644 --- a/packages/auth/src/providers/cognito/utils/oauth/cancelOAuthFlow.ts +++ b/packages/auth/src/providers/cognito/utils/oauth/cancelOAuthFlow.ts @@ -6,14 +6,97 @@ import { OAuthStore } from '../types'; import { createOAuthError } from './createOAuthError'; import { handleFailure } from './handleFailure'; +/** + * Minimum time that must elapse after starting an OAuth flow before a regained + * focus/visibility event may be interpreted as a cancellation. Guards against + * the focus/visibility events that fire while the browser is still handing the + * page over to the identity provider. + */ +const CANCELLATION_GRACE_PERIOD_MS = 2000; + +const OAUTH_RESPONSE_PARAMS = [ + 'code', + 'error', + 'state', + 'access_token', + 'id_token', +]; + +const hasOAuthResponseParams = (): boolean => { + const { search, hash } = window.location; + const searchParams = new URLSearchParams(search); + const hashParams = new URLSearchParams( + hash.startsWith('#') ? hash.substring(1) : hash, + ); + + return OAUTH_RESPONSE_PARAMS.some( + param => searchParams.has(param) || hashParams.has(param), + ); +}; + export const listenForOAuthFlowCancellation = (store: OAuthStore) => { + const flowStartedAt = Date.now(); + let settled = false; + + const cleanUpListeners = () => { + settled = true; + window.removeEventListener('pageshow', handleCancelOAuthFlow); + window.removeEventListener('focus', handleRegainedForeground); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + + const cancelFlow = async () => { + cleanUpListeners(); + await handleFailure(createOAuthError('User cancelled OAuth flow.')); + }; + async function handleCancelOAuthFlow(event: PageTransitionEvent) { + if (settled) { + return; + } const isBfcache = event.persisted; if (isBfcache && (await store.loadOAuthInFlight())) { - const error = createOAuthError('User cancelled OAuth flow.'); - await handleFailure(error); + await cancelFlow(); + + return; } - window.removeEventListener('pageshow', handleCancelOAuthFlow); + cleanUpListeners(); + } + + async function handleRegainedForeground() { + if (settled) { + return; + } + + // a legitimate redirect round-trip is being processed; never interfere + if (hasOAuthResponseParams()) { + cleanUpListeners(); + + return; + } + + // too early to distinguish a dismissed provider UI from the hand-off to it + if (Date.now() - flowStartedAt < CANCELLATION_GRACE_PERIOD_MS) { + return; + } + + if (!(await store.loadOAuthInFlight())) { + cleanUpListeners(); + + return; + } + + await cancelFlow(); } + + function handleVisibilityChange() { + if (document.visibilityState !== 'visible') { + return; + } + handleRegainedForeground().catch(() => undefined); + } + window.addEventListener('pageshow', handleCancelOAuthFlow); + window.addEventListener('focus', handleRegainedForeground); + document.addEventListener('visibilitychange', handleVisibilityChange); }; From 1ef88c1c15c7f7b53841d41f2d4d94ca9b0c89a4 Mon Sep 17 00:00:00 2001 From: Michael Sober Date: Thu, 30 Jul 2026 11:40:14 +0000 Subject: [PATCH 2/2] fix(auth): guard inflight OAuth timeout against races and add changeset - capture a generation token when arming the inflight timeout and verify it before and after clearing, so a stale timer cannot wipe the inflight state of a newly started flow - add an explicit settled guard at the top of the timeout callback so the invariant does not rely solely on clearTimeout - cover both guards with unit tests --- .changeset/auth-oauth-inflight-timeout.md | 13 ++++ .../cognito/tokenOrchestrator.test.ts | 63 +++++++++++++++++++ .../tokenProvider/TokenOrchestrator.ts | 25 +++++++- 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 .changeset/auth-oauth-inflight-timeout.md diff --git a/.changeset/auth-oauth-inflight-timeout.md b/.changeset/auth-oauth-inflight-timeout.md new file mode 100644 index 00000000000..d65340c3928 --- /dev/null +++ b/.changeset/auth-oauth-inflight-timeout.md @@ -0,0 +1,13 @@ +--- +'@aws-amplify/auth': patch +--- + +fix(auth): prevent signInWithRedirect hang after native sign-in sheet cancel + +Cancelling a native sign-in sheet (for example "Sign in with Apple" on iOS Safari) left the persisted `inflightOAuth` flag set, because dismissing the sheet is neither a navigation nor a bfcache restore and therefore did not trigger the existing cancellation listener. Every subsequent `signIn`, `getCurrentUser`, or `fetchAuthSession` then awaited an inflight promise that had no timeout and no rejection path, so those calls hung permanently until the user cleared site storage. + +The inflight OAuth wait is now bounded by an internal timeout. When it elapses, the persisted inflight flag is cleared and all waiters are settled, so token fetching resumes and reports no session in progress instead of hanging. A generation guard ensures an expired timer can never clear the state of an OAuth flow that started after it. + +Web cancellation detection now also covers `visibilitychange` and window `focus` while a flow is inflight, guarded against OAuth response parameters in the URL and against a short grace period so a legitimate redirect round-trip is never interrupted. Successful flows, the react-native cancellation path, and existing public APIs are unchanged. + +Fixes [#14900](https://github.com/aws-amplify/amplify-js/issues/14900). diff --git a/packages/auth/__tests__/providers/cognito/tokenOrchestrator.test.ts b/packages/auth/__tests__/providers/cognito/tokenOrchestrator.test.ts index 41222a2515a..d3d06f66fe6 100644 --- a/packages/auth/__tests__/providers/cognito/tokenOrchestrator.test.ts +++ b/packages/auth/__tests__/providers/cognito/tokenOrchestrator.test.ts @@ -179,6 +179,10 @@ describe('TokenOrchestrator', () => { afterEach(() => { jest.useRealTimers(); + // restore the immediately-resolving default so later suites are unaffected + mockAddInflightPromise.mockImplementation(resolver => { + resolver(); + }); }); it('does not settle the inflight wait before the timeout elapses', async () => { @@ -244,6 +248,65 @@ describe('TokenOrchestrator', () => { expect(oAuthStore.clearOAuthInflightData).not.toHaveBeenCalled(); expect(jest.getTimerCount()).toBe(0); }); + + it('does not let a stale timeout clear the inflight data of a newer flow', async () => { + const firstWait = orchestrator.waitForInflightOAuth(); + await new Promise(resolve => { + process.nextTick(resolve); + }); + + jest.advanceTimersByTime(INFLIGHT_OAUTH_TIMEOUT_MS / 2); + + // a second flow starts and takes ownership of the inflight state + orchestrator.inflightPromise = undefined; + const secondWait = orchestrator.waitForInflightOAuth(); + await new Promise(resolve => { + process.nextTick(resolve); + }); + + // the first flow's timer fires: it must not touch the second flow's state + jest.advanceTimersByTime(INFLIGHT_OAUTH_TIMEOUT_MS / 2); + await expect(firstWait).resolves.toBeUndefined(); + expect(oAuthStore.clearOAuthInflightData).not.toHaveBeenCalled(); + expect(resolveAndClearInflightPromises).not.toHaveBeenCalled(); + + // the second flow's own timer still self-heals on schedule + jest.advanceTimersByTime(INFLIGHT_OAUTH_TIMEOUT_MS / 2); + await expect(secondWait).resolves.toBeUndefined(); + expect(oAuthStore.clearOAuthInflightData).toHaveBeenCalledTimes(1); + expect(resolveAndClearInflightPromises).toHaveBeenCalledTimes(1); + }); + + it('does not clear inflight data when the wait was already settled', async () => { + // simulate a future code path that settles the wait without cancelling + // the timer: the `settled` guard must still prevent a second clear + const clearTimeoutSpy = jest + .spyOn(global, 'clearTimeout') + .mockImplementation(() => undefined); + let registeredResolver: (() => void) | undefined; + mockAddInflightPromise.mockImplementation(resolver => { + registeredResolver = resolver; + }); + + const wait = orchestrator.waitForInflightOAuth(); + await new Promise(resolve => { + process.nextTick(resolve); + }); + + registeredResolver!(); + await expect(wait).resolves.toBeUndefined(); + expect(jest.getTimerCount()).toBe(1); + + jest.advanceTimersByTime(INFLIGHT_OAUTH_TIMEOUT_MS); + await new Promise(resolve => { + process.nextTick(resolve); + }); + + expect(oAuthStore.clearOAuthInflightData).not.toHaveBeenCalled(); + expect(resolveAndClearInflightPromises).not.toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + }); }); describe('setClientMetadataProvider', () => { diff --git a/packages/auth/src/providers/cognito/tokenProvider/TokenOrchestrator.ts b/packages/auth/src/providers/cognito/tokenProvider/TokenOrchestrator.ts index ac6ace906e6..0b3cbd18202 100644 --- a/packages/auth/src/providers/cognito/tokenProvider/TokenOrchestrator.ts +++ b/packages/auth/src/providers/cognito/tokenProvider/TokenOrchestrator.ts @@ -44,6 +44,12 @@ const INFLIGHT_OAUTH_TIMEOUT_MS = 60_000; export class TokenOrchestrator implements AuthTokenOrchestrator { private authConfig?: AuthConfig; + /** + * Incremented every time a new inflight OAuth wait is armed. A pending + * timeout captures the value current at arming time so a stale timer can + * never clear the inflight state of a flow that started after it. + */ + private inflightOAuthGeneration = 0; clientMetadataProvider?: ClientMetadataProvider; tokenStore?: AuthTokenStore; tokenRefresher?: TokenRefresher; @@ -62,6 +68,8 @@ export class TokenOrchestrator implements AuthTokenOrchestrator { // to block async calls that require fetching tokens before the oauth flow completes // e.g. getCurrentUser, fetchAuthSession etc. + const generation = ++this.inflightOAuthGeneration; + this.inflightPromise = new Promise(resolve => { let settled = false; let timeoutId: ReturnType | undefined; @@ -81,11 +89,26 @@ export class TokenOrchestrator implements AuthTokenOrchestrator { timeoutId = setTimeout(() => { timeoutId = undefined; + + if (settled) { + return; + } + + if (generation !== this.inflightOAuthGeneration) { + // a newer flow owns the inflight state, so never touch it; still + // settle this superseded promise so its callers cannot hang + resolve(); + + return; + } + const clearAndSettle = async () => { try { await oAuthStore.clearOAuthInflightData(); } finally { - resolveAndClearInflightPromises(); + if (!settled && generation === this.inflightOAuthGeneration) { + resolveAndClearInflightPromises(); + } resolve(); } };