diff --git a/lib/authn/util/poll.ts b/lib/authn/util/poll.ts index c79d3a327..6377472cb 100644 --- a/lib/authn/util/poll.ts +++ b/lib/authn/util/poll.ts @@ -18,6 +18,7 @@ import AuthSdkError from '../../errors/AuthSdkError'; import AuthPollStopError from '../../errors/AuthPollStopError'; import { AuthnTransactionState } from '../types'; import { getStateToken } from './stateToken'; +import { isIOS } from '../../features'; interface PollOptions { delay?: number; @@ -82,6 +83,48 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { }); } + const delayNextPoll = (ms) => { + // no need for extra logic in non-iOS environments, just continue polling + if (!isIOS()) { + return delayFn(ms); + } + + let timeoutId: ReturnType; + const cancelableDelay = () => { + return new Promise((resolve) => { + timeoutId = setTimeout(resolve, ms); + }); + }; + + const delayForFocus = () => { + let pageVisibilityHandler; + return new Promise((resolve) => { + let pageDidHide = false; + pageVisibilityHandler = () => { + if (document.hidden) { + clearTimeout(timeoutId); + pageDidHide = true; + } + else if (pageDidHide) { + resolve(); + } + }; + + document.addEventListener('visibilitychange', pageVisibilityHandler); + }) + .then(() => { + document.removeEventListener('visibilitychange', pageVisibilityHandler); + }); + }; + + return Promise.race([ + // this function will never resolve if the page changes to hidden because the timeout gets cleared + cancelableDelay(), + // this function won't resolve until the page becomes visible after being hidden + delayForFocus(), + ]); + }; + ref.isPolling = true; var retryCount = 0; @@ -90,6 +133,24 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { if (!ref.isPolling) { return Promise.reject(new AuthPollStopError()); } + + // don't trigger polling request if page is hidden wait until window is visible again + if (isIOS() && document.hidden) { + let handler; + return new Promise((resolve) => { + handler = () => { + if (!document.hidden) { + resolve(); + } + }; + document.addEventListener('visibilitychange', handler); + }) + .then(() => { + document.removeEventListener('visibilitychange', handler); + return recursivePoll(); + }); + } + return pollFn() .then(function (pollRes) { // Reset our retry counter on success @@ -108,7 +169,7 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { } // Continue poll - return delayFn(delay).then(recursivePoll); + return delayNextPoll(delay).then(recursivePoll); } else { // Any non-waiting result, even if polling was stopped @@ -124,7 +185,7 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { retryCount <= 4) { var delayLength = Math.pow(2, retryCount) * 1000; retryCount++; - return delayFn(delayLength) + return delayNextPoll(delayLength) .then(recursivePoll); } throw err; diff --git a/lib/base/types.ts b/lib/base/types.ts index e4db88c0b..c5a665f7e 100644 --- a/lib/base/types.ts +++ b/lib/base/types.ts @@ -28,6 +28,7 @@ export interface FeaturesAPI { isPKCESupported(): boolean; isIE11OrLess(): boolean; isDPoPSupported(): boolean; + isIOS(): boolean; } diff --git a/lib/features.ts b/lib/features.ts index c9280a1c9..d57f0181a 100644 --- a/lib/features.ts +++ b/lib/features.ts @@ -88,3 +88,10 @@ export function isDPoPSupported () { hasTextEncoder() && isWebCryptoSubtleSupported(); } + +export function isIOS () { + // iOS detection from: http://stackoverflow.com/a/9039885/177710 + return isBrowser() && typeof navigator !== 'undefined' && typeof navigator.userAgent !== 'undefined' && + // @ts-expect-error - MSStream is not in `window` type, unsurprisingly + (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream); +} diff --git a/test/spec/authn/mfa-challenge.js b/test/spec/authn/mfa-challenge.js index 626d1f3f3..dbae91f59 100644 --- a/test/spec/authn/mfa-challenge.js +++ b/test/spec/authn/mfa-challenge.js @@ -10,13 +10,40 @@ * See the License for the specific language governing permissions and limitations under the License. */ +/* global document */ -jest.mock('../../../lib/util/misc', () => { +jest.mock('lib/util', () => { + const actual = jest.requireActual('../../../lib/util'); return { + ...actual, delay: () => { return Promise.resolve(); } }; }); + +jest.mock('lib/http', () => { + const actual = jest.requireActual('../../../lib/http'); + return { + ...actual, + post: actual.post + }; +}); + +jest.mock('lib/features', () => { + const actual = jest.requireActual('../../../lib/features'); + return { + ...actual, + isIOS: () => false + }; +}); +import OktaAuth from '@okta/okta-auth-js'; import util from '@okta/test.support/util'; +import { setImmediate } from 'timers'; + +const mocked = { + http: require('../../../lib/http'), + util: require('../../../lib/util'), + features: require('../../../lib/features') +}; describe('MFA_CHALLENGE', function () { @@ -1522,6 +1549,201 @@ describe('MFA_CHALLENGE', function () { expect(err.errorCauses).toBeUndefined(); } }); + + // OKTA-823470: iOS18 polling issue + // NOTE: only run these tests in browser environments + // eslint-disable-next-line no-extra-boolean-cast + (!!global.document ? describe : describe.skip)('iOS18 polling', () => { + const togglePageVisibility = () => { + document.hidden = !document.hidden; + document.dispatchEvent(new Event('visibilitychange')); + }; + + // see https://stackoverflow.com/a/52196951 for more info about jest/promises/timers + const advanceTestTimers = async () => { + jest.runOnlyPendingTimers(); + // flushes promise queue + return new Promise(resolve => setImmediate(resolve)); + }; + + const context = {}; + + beforeEach(async () => { + jest.useFakeTimers(); + document.hidden = false; + + // delay must be mocked (essentially to original implementation) because other tests + // mock this function to remove any timer delays + jest.spyOn(mocked.util, 'delay').mockImplementation((ms) => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); + + // mocks iOS environment + jest.spyOn(mocked.features, 'isIOS').mockReturnValue(true); + + const { response: mfaPush } = await util.generateXHRPair({ + uri: 'https://auth-js-test.okta.com' + }, 'mfa-challenge-push', 'https://auth-js-test.okta.com'); + + const { response: success } = await util.generateXHRPair({ + uri: 'https://auth-js-test.okta.com' + }, 'success', 'https://auth-js-test.okta.com'); + + // mocks flow of wait, wait, wait, success + context.httpSpy = jest.spyOn(mocked.http, 'post') + .mockResolvedValueOnce(mfaPush.response) + .mockResolvedValueOnce(mfaPush.response) + .mockResolvedValueOnce(mfaPush.response) + .mockResolvedValueOnce(success.response); + + + const oktaAuth = new OktaAuth({ + issuer: 'https://auth-js-test.okta.com' + }); + + context.transaction = oktaAuth.tx.createTransaction(mfaPush.response); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('should proceed with flow as normal if document is never hidden', async () => { + const { httpSpy, transaction } = context; + expect(document.hidden).toBe(false); + + let count = 0; + const pollPromise = transaction.poll({ + delay: 2000, + transactionCallBack: () => { + count += 1; + } + }); + + for (let i=0; i<4; i++) { + await advanceTestTimers(); + } + + const result = await pollPromise; + + expect(count).toEqual(3); + expect(httpSpy).toHaveBeenCalledTimes(4); + expect(result.status).toEqual('SUCCESS'); + }); + + it('should not proceed with flow if document is hidden', async () => { + const { httpSpy, transaction } = context; + expect(document.hidden).toBe(false); + + togglePageVisibility(); + + let count = 0; + const pollPromise = transaction.poll({ + delay: 2000, + transactionCallBack: () => { + count += 1; + } + }); + + // advance the timers so the flow would have succeed in normal circumstances + for (let i=0; i<4; i++) { + await advanceTestTimers(); + } + + // ensure flow did not advance, awaits document focus to return + expect(count).toEqual(0); + + togglePageVisibility(); + for (let i=0; i<4; i++) { + await advanceTestTimers(); + } + + const result = await pollPromise; + + expect(count).toEqual(3); + expect(httpSpy).toHaveBeenCalledTimes(4); + expect(result.status).toEqual('SUCCESS'); + }); + + it('should pause flow is document is hidden amidst polling', async () => { + const { httpSpy, transaction } = context; + expect(document.hidden).toBe(false); + + let count = 0; + const pollPromise = transaction.poll({ + delay: 2000, + transactionCallBack: () => { + count += 1; + } + }); + + // advance the timers so the flow would have succeed in normal circumstances + for (let i=0; i<4; i++) { + await advanceTestTimers(); + if (i == 1) { + // hide document in middle of flow + togglePageVisibility(); + } + } + + // ensure flow pauses, awaits document focus to return + expect(count).toEqual(2); + + togglePageVisibility(); + for (let i=0; i<2; i++) { + await advanceTestTimers(); + } + + const result = await pollPromise; + + expect(count).toEqual(3); + expect(httpSpy).toHaveBeenCalledTimes(4); + expect(result.status).toEqual('SUCCESS'); + }); + + it('should handle document visibility being toggled consistently', async () => { + const { httpSpy, transaction } = context; + expect(document.hidden).toBe(false); + + let count = 0; + const pollPromise = transaction.poll({ + delay: 2000, + transactionCallBack: () => { + count += 1; + } + }); + + for (let i=0; i<8; i++) { + if (i % 2 === 0) { + expect(document.hidden).toBe(false); + } + else { + expect(document.hidden).toBe(true); + } + + await advanceTestTimers(); + togglePageVisibility(); + + if (i % 2 === 0) { + expect(document.hidden).toBe(true); + } + else { + expect(document.hidden).toBe(false); + } + } + + expect(document.hidden).toBe(false); + + const result = await pollPromise; + + expect(count).toEqual(3); + expect(httpSpy).toHaveBeenCalledTimes(4); + expect(result.status).toEqual('SUCCESS'); + }); + }); }); describe('trans.prev', function () { diff --git a/test/spec/features/browser.ts b/test/spec/features/browser.ts index cadaa3fb8..ba246d601 100644 --- a/test/spec/features/browser.ts +++ b/test/spec/features/browser.ts @@ -63,4 +63,30 @@ describe('features (browser)', function() { expect(OktaAuth.features.isIE11OrLess()).toBe(true); }); }); + + describe('isIOS', () => { + it('can succeed', () => { + const iOSAgents = [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/92.0.4515.90 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.1 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/92.0.4515.90 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1' + ]; + + for (let userAgent of iOSAgents) { + jest.spyOn(global.navigator, 'userAgent', 'get').mockReturnValue(userAgent); + expect(OktaAuth.features.isIOS()).toBe(true); + } + }); + + it('returns false if navigator is unavailable', () => { + jest.spyOn(global, 'navigator', 'get').mockReturnValue(undefined as never); + expect(OktaAuth.features.isIOS()).toBe(false); + }); + + it('returns false if userAgent is unavailable', () => { + jest.spyOn(global.navigator, 'userAgent', 'get').mockReturnValue(undefined as never); + expect(OktaAuth.features.isIOS()).toBe(false); + }); + }); }); diff --git a/test/support/jest/jest.setup.js b/test/support/jest/jest.setup.js index 50373db98..58738dba5 100644 --- a/test/support/jest/jest.setup.js +++ b/test/support/jest/jest.setup.js @@ -33,3 +33,12 @@ global.console.warn = function() {}; // broadcast-channel should not detect node environment // https://github.com/pubkey/broadcast-channel/blob/master/src/util.js#L61 process[Symbol.toStringTag] = 'Process'; + +if (global.document) { + let docHidden = false; + Object.defineProperty(global.document, 'hidden', { + configurable: true, + get () { return docHidden; }, + set (bool) { docHidden = Boolean(bool); } + }); +} diff --git a/test/support/util.js b/test/support/util.js index 9d0c7cc49..3512cba38 100644 --- a/test/support/util.js +++ b/test/support/util.js @@ -115,6 +115,7 @@ function generateXHRPair(request, response, uri, responseVars) { }); }); } +util.generateXHRPair = generateXHRPair; function mockAjax(pairs) {