Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/auth-oauth-inflight-timeout.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -152,6 +156,159 @@ 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();
// 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 () => {
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);
});

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', () => {
it('should use clientMetadataProvider for token refresh', async () => {
const clientMetadata = { 'app-version': '1.0.0' };
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading