From 0cac2d7e687d0ebc8f8e5d6628248ad9ea28ce9d Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 12 Dec 2024 17:57:46 +0200 Subject: [PATCH 01/25] Retry on network error --- lib/authn/util/poll.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/authn/util/poll.ts b/lib/authn/util/poll.ts index 6377472cb..41ba973db 100644 --- a/lib/authn/util/poll.ts +++ b/lib/authn/util/poll.ts @@ -16,6 +16,7 @@ import { isNumber, isObject, getLink, toQueryString, delay as delayFn } from '.. import { DEFAULT_POLLING_DELAY } from '../../constants'; import AuthSdkError from '../../errors/AuthSdkError'; import AuthPollStopError from '../../errors/AuthPollStopError'; +import { isAuthApiError } from '../../errors'; import { AuthnTransactionState } from '../types'; import { getStateToken } from './stateToken'; import { isIOS } from '../../features'; @@ -179,11 +180,13 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { } }) .catch(function(err) { + const isTooManyRequests = err.xhr && + (err.xhr.status === 0 || err.xhr.status === 429); + const isNetworkError = isAuthApiError(err) && err.message === 'Load failed'; + const canRetry = isTooManyRequests || isNetworkError; // Exponential backoff, up to 16 seconds - if (err.xhr && - (err.xhr.status === 0 || err.xhr.status === 429) && - retryCount <= 4) { - var delayLength = Math.pow(2, retryCount) * 1000; + if (canRetry && retryCount <= 4) { + var delayLength = isNetworkError ? 200 : Math.pow(2, retryCount) * 1000; retryCount++; return delayNextPoll(delayLength) .then(recursivePoll); From ee1d17e503f6eb0931d06a4f6762caf6a742bb39 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 13 Dec 2024 13:23:28 +0200 Subject: [PATCH 02/25] Add timeout --- lib/authn/util/poll.ts | 24 +++++++++++++++++++----- lib/constants.ts | 1 + 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/lib/authn/util/poll.ts b/lib/authn/util/poll.ts index 41ba973db..0b54dfbaa 100644 --- a/lib/authn/util/poll.ts +++ b/lib/authn/util/poll.ts @@ -13,10 +13,10 @@ import { post } from '../../http'; import { isNumber, isObject, getLink, toQueryString, delay as delayFn } from '../../util'; -import { DEFAULT_POLLING_DELAY } from '../../constants'; +import { DEFAULT_POLLING_DELAY, POLL_REQUEST_TIMEOUT_FOR_IOS } from '../../constants'; import AuthSdkError from '../../errors/AuthSdkError'; import AuthPollStopError from '../../errors/AuthPollStopError'; -import { isAuthApiError } from '../../errors'; +import { isAuthApiError, AuthApiError } from '../../errors'; import { AuthnTransactionState } from '../types'; import { getStateToken } from './stateToken'; import { isIOS } from '../../features'; @@ -78,10 +78,23 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { } var href = pollLink.href + toQueryString(opts); - return post(sdk, href, getStateToken(res), { + const postPromise = post(sdk, href, getStateToken(res), { saveAuthnState: false, withCredentials: true }); + + if (isIOS()) { + return Promise.race([ + postPromise, + new Promise((_, reject) => { + setTimeout(() => reject(new AuthApiError({ + errorSummary: 'Load timeout', + })), POLL_REQUEST_TIMEOUT_FOR_IOS); + }) + ]); + } else { + return postPromise; + } } const delayNextPoll = (ms) => { @@ -183,10 +196,11 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { const isTooManyRequests = err.xhr && (err.xhr.status === 0 || err.xhr.status === 429); const isNetworkError = isAuthApiError(err) && err.message === 'Load failed'; - const canRetry = isTooManyRequests || isNetworkError; + const isTimeout = isAuthApiError(err) && err.message === 'Load timeout'; + const canRetry = isTooManyRequests || isNetworkError || isTimeout; // Exponential backoff, up to 16 seconds if (canRetry && retryCount <= 4) { - var delayLength = isNetworkError ? 200 : Math.pow(2, retryCount) * 1000; + var delayLength = isTooManyRequests ? Math.pow(2, retryCount) * 1000 : 200; retryCount++; return delayNextPoll(delayLength) .then(recursivePoll); diff --git a/lib/constants.ts b/lib/constants.ts index abcf80db4..5e5c2396d 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -12,6 +12,7 @@ export const STATE_TOKEN_KEY_NAME = 'oktaStateToken'; export const DEFAULT_POLLING_DELAY = 500; +export const POLL_REQUEST_TIMEOUT_FOR_IOS = 5_000; export const DEFAULT_MAX_CLOCK_SKEW = 300; export const DEFAULT_CACHE_DURATION = 86400; export const TOKEN_STORAGE_NAME = 'okta-token-storage'; From 000c5cbe7dfa225e2d72e4d8a57d1dfb20f30622 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 17 Dec 2024 17:48:34 +0200 Subject: [PATCH 03/25] Use same logic for authn and idx --- lib/authn/util/poll.ts | 27 +++++----------------- lib/http/request.ts | 29 ++++++++++++++++++++---- lib/http/types.ts | 2 ++ lib/idx/idxState/v1/generateIdxAction.ts | 14 +++++++++--- 4 files changed, 44 insertions(+), 28 deletions(-) diff --git a/lib/authn/util/poll.ts b/lib/authn/util/poll.ts index 0b54dfbaa..239b58eae 100644 --- a/lib/authn/util/poll.ts +++ b/lib/authn/util/poll.ts @@ -16,7 +16,6 @@ import { isNumber, isObject, getLink, toQueryString, delay as delayFn } from '.. import { DEFAULT_POLLING_DELAY, POLL_REQUEST_TIMEOUT_FOR_IOS } from '../../constants'; import AuthSdkError from '../../errors/AuthSdkError'; import AuthPollStopError from '../../errors/AuthPollStopError'; -import { isAuthApiError, AuthApiError } from '../../errors'; import { AuthnTransactionState } from '../types'; import { getStateToken } from './stateToken'; import { isIOS } from '../../features'; @@ -78,23 +77,12 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { } var href = pollLink.href + toQueryString(opts); - const postPromise = post(sdk, href, getStateToken(res), { + return post(sdk, href, getStateToken(res), { saveAuthnState: false, - withCredentials: true + withCredentials: true, + canRetry: isIOS(), + timeout: isIOS() ? POLL_REQUEST_TIMEOUT_FOR_IOS : undefined, }); - - if (isIOS()) { - return Promise.race([ - postPromise, - new Promise((_, reject) => { - setTimeout(() => reject(new AuthApiError({ - errorSummary: 'Load timeout', - })), POLL_REQUEST_TIMEOUT_FOR_IOS); - }) - ]); - } else { - return postPromise; - } } const delayNextPoll = (ms) => { @@ -195,12 +183,9 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { .catch(function(err) { const isTooManyRequests = err.xhr && (err.xhr.status === 0 || err.xhr.status === 429); - const isNetworkError = isAuthApiError(err) && err.message === 'Load failed'; - const isTimeout = isAuthApiError(err) && err.message === 'Load timeout'; - const canRetry = isTooManyRequests || isNetworkError || isTimeout; // Exponential backoff, up to 16 seconds - if (canRetry && retryCount <= 4) { - var delayLength = isTooManyRequests ? Math.pow(2, retryCount) * 1000 : 200; + if (isTooManyRequests && retryCount <= 4) { + var delayLength = Math.pow(2, retryCount) * 1000; retryCount++; return delayNextPoll(delayLength) .then(recursivePoll); diff --git a/lib/http/request.ts b/lib/http/request.ts index 3f935cda0..56bc76782 100644 --- a/lib/http/request.ts +++ b/lib/http/request.ts @@ -22,7 +22,7 @@ import { RequestData, HttpResponse } from './types'; -import { AuthApiError, OAuthError, APIError, WWWAuthError } from '../errors'; +import { AuthApiError, OAuthError, APIError, WWWAuthError, isAuthApiError } from '../errors'; const formatError = (sdk: OktaAuthHttpInterface, error: HttpResponse | Error): AuthApiError | OAuthError => { @@ -113,7 +113,9 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) withCredentials = options.withCredentials === true, // default value is false storageUtil = sdk.options.storageUtil, storage = storageUtil!.storage, - httpCache = sdk.storageManager.getHttpCache(sdk.options.cookies); + httpCache = sdk.storageManager.getHttpCache(sdk.options.cookies), + timeout = options.timeout, + canRetry = options.canRetry; if (options.cacheResponse) { var cacheContents = httpCache.getStorage(); @@ -142,8 +144,21 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) withCredentials }; - var err, res; - return sdk.options.httpRequestClient!(method!, url!, ajaxOptions) + var err, res, promise; + const postPromise = sdk.options.httpRequestClient!(method!, url!, ajaxOptions) + if (timeout) { + promise = Promise.race([ + postPromise, + new Promise((_, reject) => { + setTimeout(() => reject(new AuthApiError({ + errorSummary: 'Load timeout', + })), timeout); + }) + ]); + } else { + promise = postPromise; + } + return promise .then(function(resp) { res = resp.responseText; if (res && isString(res)) { @@ -181,6 +196,12 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) .catch(function(resp) { err = formatError(sdk, resp); + const isNetworkError = isAuthApiError(err) && err.message === 'Load failed'; + const isTimeout = isAuthApiError(err) && err.message === 'Load timeout'; + if (canRetry && (isNetworkError || isTimeout)) { + return httpRequest(sdk, options); + } + if (err.errorCode === 'E0000011') { storage.delete(STATE_TOKEN_KEY_NAME); } diff --git a/lib/http/types.ts b/lib/http/types.ts index 46a060a16..b1ce56193 100644 --- a/lib/http/types.ts +++ b/lib/http/types.ts @@ -33,6 +33,8 @@ export interface RequestOptions { storageUtil?: StorageUtil; cacheResponse?: boolean; headers?: RequestHeaders; + canRetry?: boolean; + timeout?: number; } export interface FetchOptions { diff --git a/lib/idx/idxState/v1/generateIdxAction.ts b/lib/idx/idxState/v1/generateIdxAction.ts index 23d2c8971..f819f90d5 100644 --- a/lib/idx/idxState/v1/generateIdxAction.ts +++ b/lib/idx/idxState/v1/generateIdxAction.ts @@ -11,11 +11,13 @@ */ /* eslint-disable max-len, complexity */ -import { httpRequest } from '../../../http'; +import { httpRequest, RequestOptions } from '../../../http'; import { OktaAuthIdxInterface } from '../../types'; // auth-js/types import { IdxActionFunction, IdxActionParams, IdxResponse, IdxToPersist } from '../../types/idx-js'; import { divideActionParamsByMutability } from './actionParser'; import AuthApiError from '../../../errors/AuthApiError'; +import { POLL_REQUEST_TIMEOUT_FOR_IOS } from '../../../constants'; +import { isIOS } from '../../../features'; const generateDirectFetch = function generateDirectFetch(authClient: OktaAuthIdxInterface, { actionDefinition, @@ -36,13 +38,19 @@ const generateDirectFetch = function generateDirectFetch(authClient: OktaAuthIdx }); try { - const response = await httpRequest(authClient, { + const options: RequestOptions = { url: target, method: actionDefinition.method, headers, args: body, withCredentials: toPersist?.withCredentials ?? true - }); + }; + const isPolling = target.endsWith('/poll'); + if (isIOS() && isPolling) { + options.canRetry = true; + options.timeout = POLL_REQUEST_TIMEOUT_FOR_IOS; + } + const response = await httpRequest(authClient, options); return authClient.idx.makeIdxResponse({ ...response }, toPersist, true); } From b089ffc36dea4e7421331a468d500a2551960100 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 17 Dec 2024 18:35:02 +0200 Subject: [PATCH 04/25] revert . --- lib/authn/util/poll.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/authn/util/poll.ts b/lib/authn/util/poll.ts index 239b58eae..ebf42f691 100644 --- a/lib/authn/util/poll.ts +++ b/lib/authn/util/poll.ts @@ -181,10 +181,10 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { } }) .catch(function(err) { - const isTooManyRequests = err.xhr && - (err.xhr.status === 0 || err.xhr.status === 429); // Exponential backoff, up to 16 seconds - if (isTooManyRequests && retryCount <= 4) { + if (err.xhr && + (err.xhr.status === 0 || err.xhr.status === 429) && + retryCount <= 4) { var delayLength = Math.pow(2, retryCount) * 1000; retryCount++; return delayNextPoll(delayLength) From da33d79af6a923e3eabc04a90308e927a882733c Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Wed, 18 Dec 2024 16:55:42 +0200 Subject: [PATCH 05/25] wip awaken clean --- lib/authn/util/link2fn.ts | 5 +- lib/authn/util/poll.ts | 3 +- lib/constants.ts | 2 +- lib/http/request.ts | 120 +++++++++++++++++++---- lib/idx/idxState/v1/generateIdxAction.ts | 2 - 5 files changed, 106 insertions(+), 26 deletions(-) diff --git a/lib/authn/util/link2fn.ts b/lib/authn/util/link2fn.ts index 6ae097009..e4b46c3df 100644 --- a/lib/authn/util/link2fn.ts +++ b/lib/authn/util/link2fn.ts @@ -2,6 +2,7 @@ import { OktaAuthHttpInterface } from '../../http/types'; import { find, omit, toQueryString } from '../../util'; import AuthSdkError from '../../errors/AuthSdkError'; import { get } from '../../http'; +import { isIOS } from '../../features'; import { AuthnTransactionAPI, AuthnTransactionState } from '../types'; import { postToTransaction } from '../api'; import { addStateToken } from './stateToken'; @@ -98,7 +99,9 @@ export function link2fn(sdk: OktaAuthHttpInterface, tx: AuthnTransactionAPI, res data.profile = omit(data.profile, 'updatePhone'); } var href = link.href + toQueryString(params); - return postToTransaction(sdk, tx, href, data); + return postToTransaction(sdk, tx, href, data, { + canRetry: isIOS(), + }); }; } } diff --git a/lib/authn/util/poll.ts b/lib/authn/util/poll.ts index ebf42f691..b38f2c275 100644 --- a/lib/authn/util/poll.ts +++ b/lib/authn/util/poll.ts @@ -13,7 +13,7 @@ import { post } from '../../http'; import { isNumber, isObject, getLink, toQueryString, delay as delayFn } from '../../util'; -import { DEFAULT_POLLING_DELAY, POLL_REQUEST_TIMEOUT_FOR_IOS } from '../../constants'; +import { DEFAULT_POLLING_DELAY } from '../../constants'; import AuthSdkError from '../../errors/AuthSdkError'; import AuthPollStopError from '../../errors/AuthPollStopError'; import { AuthnTransactionState } from '../types'; @@ -81,7 +81,6 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { saveAuthnState: false, withCredentials: true, canRetry: isIOS(), - timeout: isIOS() ? POLL_REQUEST_TIMEOUT_FOR_IOS : undefined, }); } diff --git a/lib/constants.ts b/lib/constants.ts index 5e5c2396d..15dd0f46c 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -12,7 +12,7 @@ export const STATE_TOKEN_KEY_NAME = 'oktaStateToken'; export const DEFAULT_POLLING_DELAY = 500; -export const POLL_REQUEST_TIMEOUT_FOR_IOS = 5_000; +export const IOS_PAGE_AWAKEN_TIMEOUT = 500; export const DEFAULT_MAX_CLOCK_SKEW = 300; export const DEFAULT_CACHE_DURATION = 86400; export const TOKEN_STORAGE_NAME = 'okta-token-storage'; diff --git a/lib/http/request.ts b/lib/http/request.ts index 56bc76782..8c78bccd3 100644 --- a/lib/http/request.ts +++ b/lib/http/request.ts @@ -14,7 +14,7 @@ /* eslint-disable complexity */ import { isString, clone, isAbsoluteUrl, removeNils } from '../util'; -import { STATE_TOKEN_KEY_NAME, DEFAULT_CACHE_DURATION } from '../constants'; +import { STATE_TOKEN_KEY_NAME, DEFAULT_CACHE_DURATION, IOS_PAGE_AWAKEN_TIMEOUT } from '../constants'; import { OktaAuthHttpInterface, RequestOptions, @@ -22,9 +22,23 @@ import { RequestData, HttpResponse } from './types'; -import { AuthApiError, OAuthError, APIError, WWWAuthError, isAuthApiError } from '../errors'; +import { AuthApiError, OAuthError, APIError, WWWAuthError } from '../errors'; +import { isIOS } from '../features'; +// For iOS track last date of document become visible +let lastDateOfVisibleDocument = 0; +let globalVisibilityHandler: () => void; +if (isIOS()) { + lastDateOfVisibleDocument = Date.now(); + globalVisibilityHandler = () => { + if (!document.hidden) { + lastDateOfVisibleDocument = Date.now(); + } + }; + document.addEventListener('visibilitychange', globalVisibilityHandler); +} + const formatError = (sdk: OktaAuthHttpInterface, error: HttpResponse | Error): AuthApiError | OAuthError => { if (error instanceof Error) { // fetch() can throw exceptions @@ -96,6 +110,8 @@ const formatError = (sdk: OktaAuthHttpInterface, error: HttpResponse | Error): A return err; }; +let rcg = 0; + export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions): Promise { options = options || {}; @@ -114,7 +130,6 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) storageUtil = sdk.options.storageUtil, storage = storageUtil!.storage, httpCache = sdk.storageManager.getHttpCache(sdk.options.cookies), - timeout = options.timeout, canRetry = options.canRetry; if (options.cacheResponse) { @@ -145,19 +160,90 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) }; var err, res, promise; - const postPromise = sdk.options.httpRequestClient!(method!, url!, ajaxOptions) - if (timeout) { - promise = Promise.race([ - postPromise, - new Promise((_, reject) => { - setTimeout(() => reject(new AuthApiError({ - errorSummary: 'Load timeout', - })), timeout); - }) - ]); + + if (isIOS()) { + const waitForAwakenDocument = () => { + const timeSinceDocumentIsVisible = Date.now() - lastDateOfVisibleDocument; + if (isIOS() && timeSinceDocumentIsVisible < IOS_PAGE_AWAKEN_TIMEOUT) { + return new Promise((resolve) => setTimeout( () => { + if (!document.hidden) { + resolve(); + } else { + resolve(waitForVisibleAndAwakenDocument()); + } + }, IOS_PAGE_AWAKEN_TIMEOUT - timeSinceDocumentIsVisible)); + } else { + return Promise.resolve(); + } + }; + + const waitForVisibleAndAwakenDocument = () => { + if (document.hidden) { + let pageVisibilityHandler: () => void; + return new Promise((resolve) => { + pageVisibilityHandler = () => { + if (!document.hidden) { + document.removeEventListener('visibilitychange', pageVisibilityHandler); + resolve(waitForAwakenDocument()); + } + }; + document.addEventListener('visibilitychange', pageVisibilityHandler); + }); + } else { + return waitForAwakenDocument(); + } + }; + + const makePromise = (): Promise => { + return waitForVisibleAndAwakenDocument().then(makeProtectedFetchPromise); + }; + + // Restarts fetch if document become hidden / on network error + const makeProtectedFetchPromise = (): Promise => { + let timeoutId: ReturnType; + let pageVisibilityHandler: () => void; + + const postPromise = sdk.options.httpRequestClient!(method!, url!, ajaxOptions); + + // Reject if document become hidden + const backgroundPromise = new Promise((_, reject) => { + pageVisibilityHandler = () => { + if (document.hidden) { + reject({ reason: 'background' }); + } + }; + document.addEventListener('visibilitychange', pageVisibilityHandler); + }); + + // Reject on timeout + // const timeoutPromise = new Promise((_, reject) => { + // timeoutId = setTimeout(() => { + // reject({ reason: 'timeout' }); + // }, REQUEST_TIMEOUT_FOR_IOS); + // }); + + return Promise.race([ + postPromise, + //timeoutPromise, + backgroundPromise, + ]).finally(() => { + clearTimeout(timeoutId); + document.removeEventListener('visibilitychange', pageVisibilityHandler); + }).catch((err) => { + const isNetworkError = err?.message === 'Load failed'; + const isRejection = (err?.reason === 'background' || err?.reason === 'timeout'); // todo: remove 'timeout' + if (isNetworkError || isRejection) { // todo: canRetry && isNetworkError + return makePromise(); + } + throw err; + }) as Promise; + }; + + promise = makePromise(); } else { - promise = postPromise; + promise = sdk.options.httpRequestClient!(method!, url!, ajaxOptions); } + return promise .then(function(resp) { res = resp.responseText; @@ -196,12 +282,6 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) .catch(function(resp) { err = formatError(sdk, resp); - const isNetworkError = isAuthApiError(err) && err.message === 'Load failed'; - const isTimeout = isAuthApiError(err) && err.message === 'Load timeout'; - if (canRetry && (isNetworkError || isTimeout)) { - return httpRequest(sdk, options); - } - if (err.errorCode === 'E0000011') { storage.delete(STATE_TOKEN_KEY_NAME); } diff --git a/lib/idx/idxState/v1/generateIdxAction.ts b/lib/idx/idxState/v1/generateIdxAction.ts index f819f90d5..e509180d3 100644 --- a/lib/idx/idxState/v1/generateIdxAction.ts +++ b/lib/idx/idxState/v1/generateIdxAction.ts @@ -16,7 +16,6 @@ import { OktaAuthIdxInterface } from '../../types'; // auth-js/types import { IdxActionFunction, IdxActionParams, IdxResponse, IdxToPersist } from '../../types/idx-js'; import { divideActionParamsByMutability } from './actionParser'; import AuthApiError from '../../../errors/AuthApiError'; -import { POLL_REQUEST_TIMEOUT_FOR_IOS } from '../../../constants'; import { isIOS } from '../../../features'; const generateDirectFetch = function generateDirectFetch(authClient: OktaAuthIdxInterface, { @@ -48,7 +47,6 @@ const generateDirectFetch = function generateDirectFetch(authClient: OktaAuthIdx const isPolling = target.endsWith('/poll'); if (isIOS() && isPolling) { options.canRetry = true; - options.timeout = POLL_REQUEST_TIMEOUT_FOR_IOS; } const response = await httpRequest(authClient, options); From 264d775d78d5e5c734d386235053bab86c48fb1a Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Wed, 18 Dec 2024 17:01:01 +0200 Subject: [PATCH 06/25] clean --- lib/http/request.ts | 46 ++++++++------------------------------------- lib/http/types.ts | 1 - 2 files changed, 8 insertions(+), 39 deletions(-) diff --git a/lib/http/request.ts b/lib/http/request.ts index 8c78bccd3..a27e9eb10 100644 --- a/lib/http/request.ts +++ b/lib/http/request.ts @@ -194,49 +194,19 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) } }; - const makePromise = (): Promise => { - return waitForVisibleAndAwakenDocument().then(makeProtectedFetchPromise); - }; - - // Restarts fetch if document become hidden / on network error + // Restarts fetch on network error const makeProtectedFetchPromise = (): Promise => { - let timeoutId: ReturnType; - let pageVisibilityHandler: () => void; - - const postPromise = sdk.options.httpRequestClient!(method!, url!, ajaxOptions); - - // Reject if document become hidden - const backgroundPromise = new Promise((_, reject) => { - pageVisibilityHandler = () => { - if (document.hidden) { - reject({ reason: 'background' }); - } - }; - document.addEventListener('visibilitychange', pageVisibilityHandler); - }); - - // Reject on timeout - // const timeoutPromise = new Promise((_, reject) => { - // timeoutId = setTimeout(() => { - // reject({ reason: 'timeout' }); - // }, REQUEST_TIMEOUT_FOR_IOS); - // }); - - return Promise.race([ - postPromise, - //timeoutPromise, - backgroundPromise, - ]).finally(() => { - clearTimeout(timeoutId); - document.removeEventListener('visibilitychange', pageVisibilityHandler); - }).catch((err) => { + return sdk.options.httpRequestClient!(method!, url!, ajaxOptions).catch((err) => { const isNetworkError = err?.message === 'Load failed'; - const isRejection = (err?.reason === 'background' || err?.reason === 'timeout'); // todo: remove 'timeout' - if (isNetworkError || isRejection) { // todo: canRetry && isNetworkError + if (isNetworkError) { return makePromise(); } throw err; - }) as Promise; + }); + }; + + const makePromise = (): Promise => { + return waitForVisibleAndAwakenDocument().then(makeProtectedFetchPromise); }; promise = makePromise(); diff --git a/lib/http/types.ts b/lib/http/types.ts index b1ce56193..71c06aeff 100644 --- a/lib/http/types.ts +++ b/lib/http/types.ts @@ -34,7 +34,6 @@ export interface RequestOptions { cacheResponse?: boolean; headers?: RequestHeaders; canRetry?: boolean; - timeout?: number; } export interface FetchOptions { From 2f186ee4ee36657965a69e656a224c106ca01854 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Wed, 18 Dec 2024 20:15:55 +0200 Subject: [PATCH 07/25] comments --- lib/http/request.ts | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/lib/http/request.ts b/lib/http/request.ts index a27e9eb10..18bc79618 100644 --- a/lib/http/request.ts +++ b/lib/http/request.ts @@ -26,17 +26,17 @@ import { AuthApiError, OAuthError, APIError, WWWAuthError } from '../errors'; import { isIOS } from '../features'; -// For iOS track last date of document become visible -let lastDateOfVisibleDocument = 0; -let globalVisibilityHandler: () => void; +// For iOS track last date when document became visible +let dateDocumentBecameVisible = 0; +let trackDateDocumentBecameVisible: () => void; if (isIOS()) { - lastDateOfVisibleDocument = Date.now(); - globalVisibilityHandler = () => { + dateDocumentBecameVisible = Date.now(); + trackDateDocumentBecameVisible = () => { if (!document.hidden) { - lastDateOfVisibleDocument = Date.now(); + dateDocumentBecameVisible = Date.now(); } }; - document.addEventListener('visibilitychange', globalVisibilityHandler); + document.addEventListener('visibilitychange', trackDateDocumentBecameVisible); } const formatError = (sdk: OktaAuthHttpInterface, error: HttpResponse | Error): AuthApiError | OAuthError => { @@ -162,8 +162,11 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) var err, res, promise; if (isIOS()) { + // Safari on iOS has bug: + // Performing `fetch` right after document became visible can fail with `Load failed` error. + // Running fetch after short timeout fixes this issue. const waitForAwakenDocument = () => { - const timeSinceDocumentIsVisible = Date.now() - lastDateOfVisibleDocument; + const timeSinceDocumentIsVisible = Date.now() - dateDocumentBecameVisible; if (isIOS() && timeSinceDocumentIsVisible < IOS_PAGE_AWAKEN_TIMEOUT) { return new Promise((resolve) => setTimeout( () => { if (!document.hidden) { @@ -177,6 +180,7 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) } }; + // Returns a promise that resolves when document is visible for 500 ms const waitForVisibleAndAwakenDocument = () => { if (document.hidden) { let pageVisibilityHandler: () => void; @@ -194,22 +198,24 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) } }; - // Restarts fetch on network error - const makeProtectedFetchPromise = (): Promise => { + // Restarts fetch on 'Load failed' error + // This error can occur when `fetch` does not respond (due to CORS error, non-existing host, or network error) + const retryableFetch = (): Promise => { return sdk.options.httpRequestClient!(method!, url!, ajaxOptions).catch((err) => { const isNetworkError = err?.message === 'Load failed'; - if (isNetworkError) { - return makePromise(); + if (canRetry && isNetworkError) { + return recursiveFetch(); } throw err; }); }; - const makePromise = (): Promise => { - return waitForVisibleAndAwakenDocument().then(makeProtectedFetchPromise); + // Final promise to fetch that wraps logic with waiting for visible document and retrying fetch request on network error + const recursiveFetch = (): Promise => { + return waitForVisibleAndAwakenDocument().then(retryableFetch); }; - promise = makePromise(); + promise = recursiveFetch(); } else { promise = sdk.options.httpRequestClient!(method!, url!, ajaxOptions); } From 753c41064f290b1cc4b06329a54859e03d60d84c Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Wed, 18 Dec 2024 20:18:37 +0200 Subject: [PATCH 08/25] clean --- lib/http/request.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/http/request.ts b/lib/http/request.ts index 18bc79618..313c465d7 100644 --- a/lib/http/request.ts +++ b/lib/http/request.ts @@ -110,8 +110,6 @@ const formatError = (sdk: OktaAuthHttpInterface, error: HttpResponse | Error): A return err; }; -let rcg = 0; - export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions): Promise { options = options || {}; From 6fdf33a3da9280eb6b818a431488d4b4049d2ac2 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 19 Dec 2024 16:59:57 +0200 Subject: [PATCH 09/25] fix test --- test/spec/authn/mfa-challenge.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/spec/authn/mfa-challenge.js b/test/spec/authn/mfa-challenge.js index dbae91f59..f277f26f7 100644 --- a/test/spec/authn/mfa-challenge.js +++ b/test/spec/authn/mfa-challenge.js @@ -1592,17 +1592,17 @@ describe('MFA_CHALLENGE', function () { }, '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); - + context.httpSpy = jest.fn() + .mockResolvedValueOnce({responseText: JSON.stringify(mfaPush.response)}) + .mockResolvedValueOnce({responseText: JSON.stringify(mfaPush.response)}) + .mockResolvedValueOnce({responseText: JSON.stringify(mfaPush.response)}) + .mockResolvedValueOnce({responseText: JSON.stringify(success.response)}); const oktaAuth = new OktaAuth({ - issuer: 'https://auth-js-test.okta.com' + issuer: 'https://auth-js-test.okta.com', + httpRequestClient: context.httpSpy }); - + context.transaction = oktaAuth.tx.createTransaction(mfaPush.response); }); From 40717979252eedfd374a5896d6fa9228e53c9902 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 19 Dec 2024 17:09:18 +0200 Subject: [PATCH 10/25] lint fix --- lib/http/request.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/http/request.ts b/lib/http/request.ts index 313c465d7..dea80d466 100644 --- a/lib/http/request.ts +++ b/lib/http/request.ts @@ -110,6 +110,7 @@ const formatError = (sdk: OktaAuthHttpInterface, error: HttpResponse | Error): A return err; }; +// eslint-disable-next-line max-statements export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions): Promise { options = options || {}; @@ -160,10 +161,14 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) var err, res, promise; if (isIOS()) { + let waitForVisibleAndAwakenDocument: () => Promise; + let waitForAwakenDocument: () => Promise; + let recursiveFetch: () => Promise; + // Safari on iOS has bug: // Performing `fetch` right after document became visible can fail with `Load failed` error. // Running fetch after short timeout fixes this issue. - const waitForAwakenDocument = () => { + waitForAwakenDocument = () => { const timeSinceDocumentIsVisible = Date.now() - dateDocumentBecameVisible; if (isIOS() && timeSinceDocumentIsVisible < IOS_PAGE_AWAKEN_TIMEOUT) { return new Promise((resolve) => setTimeout( () => { @@ -179,7 +184,7 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) }; // Returns a promise that resolves when document is visible for 500 ms - const waitForVisibleAndAwakenDocument = () => { + waitForVisibleAndAwakenDocument = () => { if (document.hidden) { let pageVisibilityHandler: () => void; return new Promise((resolve) => { @@ -197,7 +202,8 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) }; // Restarts fetch on 'Load failed' error - // This error can occur when `fetch` does not respond (due to CORS error, non-existing host, or network error) + // This error can occur when `fetch` does not respond + // (due to CORS error, non-existing host, or network error) const retryableFetch = (): Promise => { return sdk.options.httpRequestClient!(method!, url!, ajaxOptions).catch((err) => { const isNetworkError = err?.message === 'Load failed'; @@ -208,8 +214,9 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) }); }; - // Final promise to fetch that wraps logic with waiting for visible document and retrying fetch request on network error - const recursiveFetch = (): Promise => { + // Final promise to fetch that wraps logic with waiting for visible document + // and retrying fetch request on network error + recursiveFetch = (): Promise => { return waitForVisibleAndAwakenDocument().then(retryableFetch); }; From edd5bb5f14dadfd648b6901b44dc03f7b9512d8f Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 19 Dec 2024 17:34:42 +0200 Subject: [PATCH 11/25] fix tests --- test/spec/TokenManager/expireEvents.ts | 3 ++- test/spec/idx/IdxStorageManager.ts | 3 ++- test/spec/oidc/OAuthStorageManager.ts | 3 ++- test/spec/oidc/endpoints/well-known.ts | 3 ++- test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts | 1 + test/spec/oidc/util/prepareTokenParams.ts | 1 + 6 files changed, 10 insertions(+), 4 deletions(-) diff --git a/test/spec/TokenManager/expireEvents.ts b/test/spec/TokenManager/expireEvents.ts index 265940f3d..356221ea5 100644 --- a/test/spec/TokenManager/expireEvents.ts +++ b/test/spec/TokenManager/expireEvents.ts @@ -1,7 +1,8 @@ jest.mock('../../../lib/features', () => { return { isLocalhost: () => true, // to allow configuring expireEarlySeconds - isIE11OrLess: () => false + isIE11OrLess: () => false, + isIOS: () => false }; }); diff --git a/test/spec/idx/IdxStorageManager.ts b/test/spec/idx/IdxStorageManager.ts index 113b6efc1..3438bb90f 100644 --- a/test/spec/idx/IdxStorageManager.ts +++ b/test/spec/idx/IdxStorageManager.ts @@ -28,7 +28,8 @@ jest.mock('../../../lib/util', () => { jest.mock('../../../lib/features', () => { return { - isBrowser: () => {} + isBrowser: () => {}, + isIOS: () => false }; }); diff --git a/test/spec/oidc/OAuthStorageManager.ts b/test/spec/oidc/OAuthStorageManager.ts index 1e6978467..4adb29a70 100644 --- a/test/spec/oidc/OAuthStorageManager.ts +++ b/test/spec/oidc/OAuthStorageManager.ts @@ -30,7 +30,8 @@ jest.mock('../../../lib/util', () => { jest.mock('../../../lib/features', () => { return { - isBrowser: () => {} + isBrowser: () => {}, + isIOS: () => false }; }); diff --git a/test/spec/oidc/endpoints/well-known.ts b/test/spec/oidc/endpoints/well-known.ts index 53148ed27..a4518a338 100644 --- a/test/spec/oidc/endpoints/well-known.ts +++ b/test/spec/oidc/endpoints/well-known.ts @@ -17,7 +17,8 @@ const mocked = { isHTTPS: () => false, isBrowser: () => typeof window !== 'undefined', isIE11OrLess: () => false, - isLocalhost: () => false + isLocalhost: () => false, + isIOS: () => false } }; jest.mock('../../../../lib/features', () => { diff --git a/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts index 4ab16cee3..fdf1f3939 100644 --- a/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts +++ b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts @@ -17,6 +17,7 @@ const mocked = { isLocalhost: () => true, isHTTPS: () => false, isPKCESupported: () => true, + isIOS: () => false, }, }; jest.mock('../../../../lib/features', () => { diff --git a/test/spec/oidc/util/prepareTokenParams.ts b/test/spec/oidc/util/prepareTokenParams.ts index 763b668da..c04db7cf6 100644 --- a/test/spec/oidc/util/prepareTokenParams.ts +++ b/test/spec/oidc/util/prepareTokenParams.ts @@ -20,6 +20,7 @@ const mocked = { isPKCESupported: () => true, hasTextEncoder: () => true, isDPoPSupported: () => true, + isIOS: () => false, }, wellKnown: { getWellKnown: (): Promise => Promise.resolve() From cda254ee78d41e160c9f83a5a909aa2ee8e117fd Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 19 Dec 2024 17:47:02 +0200 Subject: [PATCH 12/25] fix test --- lib/http/request.ts | 2 +- test/spec/TokenManager/browser.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/http/request.ts b/lib/http/request.ts index dea80d466..26c567dad 100644 --- a/lib/http/request.ts +++ b/lib/http/request.ts @@ -171,7 +171,7 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) waitForAwakenDocument = () => { const timeSinceDocumentIsVisible = Date.now() - dateDocumentBecameVisible; if (isIOS() && timeSinceDocumentIsVisible < IOS_PAGE_AWAKEN_TIMEOUT) { - return new Promise((resolve) => setTimeout( () => { + return new Promise((resolve) => setTimeout(() => { if (!document.hidden) { resolve(); } else { diff --git a/test/spec/TokenManager/browser.ts b/test/spec/TokenManager/browser.ts index 7efb7f0db..666b2e8b7 100644 --- a/test/spec/TokenManager/browser.ts +++ b/test/spec/TokenManager/browser.ts @@ -20,7 +20,8 @@ const mocked = { isBrowser: () => typeof window !== 'undefined', isIE11OrLess: () => false, isLocalhost: () => false, - isTokenVerifySupported: () => true + isTokenVerifySupported: () => true, + isIOS: () => false } }; jest.mock('../../../lib/features', () => { From 96bedc31b7681103aaec7baa7789f018764c6284 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 19 Dec 2024 19:56:30 +0200 Subject: [PATCH 13/25] add test --- test/spec/http/request.ts | 93 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/test/spec/http/request.ts b/test/spec/http/request.ts index d9bd60aec..d2193800c 100644 --- a/test/spec/http/request.ts +++ b/test/spec/http/request.ts @@ -13,16 +13,33 @@ declare var USER_AGENT: string; // set in jest config -import { httpRequest } from '../../../lib/http'; +import { httpRequest as originalHttpRequest } from '../../../lib/http'; import { OktaAuth, DEFAULT_CACHE_DURATION, AuthApiError, STATE_TOKEN_KEY_NAME } from '../../../lib/exports/core'; +import { setImmediate } from 'timers'; + + +jest.mock('../../../lib/features', () => { + return { + isBrowser: () => typeof window !== 'undefined', + isIE11OrLess: () => false, + isLocalhost: () => false, + isHTTPS: () => false, + isIOS: () => false + }; +}); + +const mocked = { + features: require('../../../lib/features'), +}; describe('HTTP Requestor', () => { let sdk; + let httpRequest = originalHttpRequest; let httpRequestClient; let url; let response1; @@ -347,4 +364,78 @@ describe('HTTP Requestor', () => { // TODO: OAuthError includes response object }); + describe('iOS 18 bug', () => { + beforeEach(() => { + jest.useFakeTimers(); + // Simulate iOS and reload `request.ts` module + jest.resetModules(); + jest.mock('../../../lib/features', () => { + return { + ...mocked.features, + isIOS: () => true + }; + }); + const { httpRequest: reloadedHttpRequest } = jest.requireActual('../../../lib/http'); + httpRequest = reloadedHttpRequest; + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + httpRequest = originalHttpRequest; + jest.mock('../../../lib/features', () => { + return { + ...mocked.features, + isIOS: () => false + }; + }); + }); + + const togglePageVisibility = () => { + (document as any).hidden = !document.hidden; + document.dispatchEvent(new Event('visibilitychange')); + }; + + const advanceTestTimers = async (ms?: number) => { + // see https://stackoverflow.com/a/52196951 for more info about jest/promises/timers + if (ms) { + jest.advanceTimersByTime(ms); + } else { + jest.runOnlyPendingTimers(); + } + // flushes promise queue + return new Promise(resolve => setImmediate(resolve)); + }; + + it('should wait for awaken document for 500 ms before making request', async () => { + createAuthClient(); + expect(document.hidden).toBe(false); + + // Document is hidden + togglePageVisibility(); + const requestPromise = httpRequest(sdk, { url }); + await advanceTestTimers(); + expect(httpRequestClient).toHaveBeenCalledTimes(0); + + // Document is visible for 200 ms + togglePageVisibility(); + await advanceTestTimers(200); + expect(httpRequestClient).toHaveBeenCalledTimes(0); + + // Document is visible for 600 ms + await advanceTestTimers(400); + expect(httpRequestClient).toHaveBeenCalledTimes(1); + expect(httpRequestClient).toHaveBeenCalledWith(undefined, url, { + data: undefined, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Okta-User-Agent-Extended': USER_AGENT + }, + withCredentials: false + }); + const res = await requestPromise; + expect(res).toBe(response1); + }); + }); }); \ No newline at end of file From 040bf62bc2e0b835d8fd19358563c00c38b24f0e Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 19 Dec 2024 20:32:48 +0200 Subject: [PATCH 14/25] fix test server --- test/spec/http/request.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/spec/http/request.ts b/test/spec/http/request.ts index d2193800c..db59b6fd6 100644 --- a/test/spec/http/request.ts +++ b/test/spec/http/request.ts @@ -364,7 +364,7 @@ describe('HTTP Requestor', () => { // TODO: OAuthError includes response object }); - describe('iOS 18 bug', () => { + (!!global.document ? describe : describe.skip)('iOS 18 bug', () => { beforeEach(() => { jest.useFakeTimers(); // Simulate iOS and reload `request.ts` module From bd82c586ee3bf122f260b93bbcfeafa7ea030c9f Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 19 Dec 2024 20:50:48 +0200 Subject: [PATCH 15/25] fix lint --- test/spec/http/request.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/spec/http/request.ts b/test/spec/http/request.ts index db59b6fd6..0e792a8cd 100644 --- a/test/spec/http/request.ts +++ b/test/spec/http/request.ts @@ -364,6 +364,8 @@ describe('HTTP Requestor', () => { // TODO: OAuthError includes response object }); + + // eslint-disable-next-line no-extra-boolean-cast (!!global.document ? describe : describe.skip)('iOS 18 bug', () => { beforeEach(() => { jest.useFakeTimers(); From 6bed4f6e69a85753c22e15a5e73061ea4bc6c886 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 19 Dec 2024 22:12:23 +0200 Subject: [PATCH 16/25] tris canRetry is excess --- lib/authn/util/link2fn.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/authn/util/link2fn.ts b/lib/authn/util/link2fn.ts index e4b46c3df..6ae097009 100644 --- a/lib/authn/util/link2fn.ts +++ b/lib/authn/util/link2fn.ts @@ -2,7 +2,6 @@ import { OktaAuthHttpInterface } from '../../http/types'; import { find, omit, toQueryString } from '../../util'; import AuthSdkError from '../../errors/AuthSdkError'; import { get } from '../../http'; -import { isIOS } from '../../features'; import { AuthnTransactionAPI, AuthnTransactionState } from '../types'; import { postToTransaction } from '../api'; import { addStateToken } from './stateToken'; @@ -99,9 +98,7 @@ export function link2fn(sdk: OktaAuthHttpInterface, tx: AuthnTransactionAPI, res data.profile = omit(data.profile, 'updatePhone'); } var href = link.href + toQueryString(params); - return postToTransaction(sdk, tx, href, data, { - canRetry: isIOS(), - }); + return postToTransaction(sdk, tx, href, data); }; } } From 8d9c2b2a13354f3f891f20205339a47216b5e0cb Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 20 Dec 2024 12:25:07 +0200 Subject: [PATCH 17/25] pollingIntent --- lib/authn/util/poll.ts | 2 +- lib/http/request.ts | 6 +++--- lib/http/types.ts | 2 +- lib/idx/idxState/v1/generateIdxAction.ts | 5 ++--- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/authn/util/poll.ts b/lib/authn/util/poll.ts index b38f2c275..3491ab002 100644 --- a/lib/authn/util/poll.ts +++ b/lib/authn/util/poll.ts @@ -80,7 +80,7 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { return post(sdk, href, getStateToken(res), { saveAuthnState: false, withCredentials: true, - canRetry: isIOS(), + pollingIntent: true, }); } diff --git a/lib/http/request.ts b/lib/http/request.ts index 26c567dad..f239e5234 100644 --- a/lib/http/request.ts +++ b/lib/http/request.ts @@ -129,7 +129,7 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) storageUtil = sdk.options.storageUtil, storage = storageUtil!.storage, httpCache = sdk.storageManager.getHttpCache(sdk.options.cookies), - canRetry = options.canRetry; + pollingIntent = options.pollingIntent; if (options.cacheResponse) { var cacheContents = httpCache.getStorage(); @@ -165,7 +165,7 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) let waitForAwakenDocument: () => Promise; let recursiveFetch: () => Promise; - // Safari on iOS has bug: + // Safari on iOS has a bug: // Performing `fetch` right after document became visible can fail with `Load failed` error. // Running fetch after short timeout fixes this issue. waitForAwakenDocument = () => { @@ -207,7 +207,7 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) const retryableFetch = (): Promise => { return sdk.options.httpRequestClient!(method!, url!, ajaxOptions).catch((err) => { const isNetworkError = err?.message === 'Load failed'; - if (canRetry && isNetworkError) { + if (pollingIntent && isNetworkError) { return recursiveFetch(); } throw err; diff --git a/lib/http/types.ts b/lib/http/types.ts index 71c06aeff..ac5d4accc 100644 --- a/lib/http/types.ts +++ b/lib/http/types.ts @@ -33,7 +33,7 @@ export interface RequestOptions { storageUtil?: StorageUtil; cacheResponse?: boolean; headers?: RequestHeaders; - canRetry?: boolean; + pollingIntent?: boolean; } export interface FetchOptions { diff --git a/lib/idx/idxState/v1/generateIdxAction.ts b/lib/idx/idxState/v1/generateIdxAction.ts index e509180d3..7956959ce 100644 --- a/lib/idx/idxState/v1/generateIdxAction.ts +++ b/lib/idx/idxState/v1/generateIdxAction.ts @@ -16,7 +16,6 @@ import { OktaAuthIdxInterface } from '../../types'; // auth-js/types import { IdxActionFunction, IdxActionParams, IdxResponse, IdxToPersist } from '../../types/idx-js'; import { divideActionParamsByMutability } from './actionParser'; import AuthApiError from '../../../errors/AuthApiError'; -import { isIOS } from '../../../features'; const generateDirectFetch = function generateDirectFetch(authClient: OktaAuthIdxInterface, { actionDefinition, @@ -45,8 +44,8 @@ const generateDirectFetch = function generateDirectFetch(authClient: OktaAuthIdx withCredentials: toPersist?.withCredentials ?? true }; const isPolling = target.endsWith('/poll'); - if (isIOS() && isPolling) { - options.canRetry = true; + if (isPolling) { + options.pollingIntent = true; } const response = await httpRequest(authClient, options); From 1a0410679888af2c97c8249d9b6b693b604aa3f7 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 20 Dec 2024 12:25:22 +0200 Subject: [PATCH 18/25] isPolling --- lib/idx/idxState/v1/generateIdxAction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/idx/idxState/v1/generateIdxAction.ts b/lib/idx/idxState/v1/generateIdxAction.ts index 7956959ce..13b01707b 100644 --- a/lib/idx/idxState/v1/generateIdxAction.ts +++ b/lib/idx/idxState/v1/generateIdxAction.ts @@ -43,7 +43,7 @@ const generateDirectFetch = function generateDirectFetch(authClient: OktaAuthIdx args: body, withCredentials: toPersist?.withCredentials ?? true }; - const isPolling = target.endsWith('/poll'); + const isPolling = actionDefinition.name === 'poll' || actionDefinition.name?.endsWith('-poll'); if (isPolling) { options.pollingIntent = true; } From f3d0b0889e28651dbc0fdf7c0c6fd035e20dd985 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 20 Dec 2024 12:28:31 +0200 Subject: [PATCH 19/25] not necessary isIOS check --- lib/http/request.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/http/request.ts b/lib/http/request.ts index f239e5234..37e4f6a4a 100644 --- a/lib/http/request.ts +++ b/lib/http/request.ts @@ -170,7 +170,7 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) // Running fetch after short timeout fixes this issue. waitForAwakenDocument = () => { const timeSinceDocumentIsVisible = Date.now() - dateDocumentBecameVisible; - if (isIOS() && timeSinceDocumentIsVisible < IOS_PAGE_AWAKEN_TIMEOUT) { + if (timeSinceDocumentIsVisible < IOS_PAGE_AWAKEN_TIMEOUT) { return new Promise((resolve) => setTimeout(() => { if (!document.hidden) { resolve(); From 0277135239acd9ddc0d02af72a58abd2f7b91e1e Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 20 Dec 2024 12:31:53 +0200 Subject: [PATCH 20/25] check pollingIntent for all new logic --- lib/http/request.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/http/request.ts b/lib/http/request.ts index 37e4f6a4a..6c54754af 100644 --- a/lib/http/request.ts +++ b/lib/http/request.ts @@ -160,7 +160,7 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) var err, res, promise; - if (isIOS()) { + if (pollingIntent && isIOS()) { let waitForVisibleAndAwakenDocument: () => Promise; let waitForAwakenDocument: () => Promise; let recursiveFetch: () => Promise; @@ -207,7 +207,7 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) const retryableFetch = (): Promise => { return sdk.options.httpRequestClient!(method!, url!, ajaxOptions).catch((err) => { const isNetworkError = err?.message === 'Load failed'; - if (pollingIntent && isNetworkError) { + if (isNetworkError) { return recursiveFetch(); } throw err; From 6f390615a0d14c1da5a4347db091c38ebdb2412d Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 20 Dec 2024 12:34:15 +0200 Subject: [PATCH 21/25] test fix --- test/spec/http/request.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/spec/http/request.ts b/test/spec/http/request.ts index 0e792a8cd..4e15f8204 100644 --- a/test/spec/http/request.ts +++ b/test/spec/http/request.ts @@ -366,7 +366,7 @@ describe('HTTP Requestor', () => { }); // eslint-disable-next-line no-extra-boolean-cast - (!!global.document ? describe : describe.skip)('iOS 18 bug', () => { + (!!global.document ? describe : describe.skip)('iOS18 polling', () => { beforeEach(() => { jest.useFakeTimers(); // Simulate iOS and reload `request.ts` module @@ -415,7 +415,10 @@ describe('HTTP Requestor', () => { // Document is hidden togglePageVisibility(); - const requestPromise = httpRequest(sdk, { url }); + const requestPromise = httpRequest(sdk, { + url, + pollingIntent: true, + }); await advanceTestTimers(); expect(httpRequestClient).toHaveBeenCalledTimes(0); From 54a6964bbb5bd530d96ea91d6803bf6702a5c597 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 20 Dec 2024 13:15:38 +0200 Subject: [PATCH 22/25] add test --- test/spec/http/request.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/spec/http/request.ts b/test/spec/http/request.ts index 4e15f8204..7621b0902 100644 --- a/test/spec/http/request.ts +++ b/test/spec/http/request.ts @@ -365,6 +365,8 @@ describe('HTTP Requestor', () => { // TODO: OAuthError includes response object }); + // 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', () => { beforeEach(() => { @@ -409,7 +411,7 @@ describe('HTTP Requestor', () => { return new Promise(resolve => setImmediate(resolve)); }; - it('should wait for awaken document for 500 ms before making request', async () => { + it('should wait for document to be visible for 500 ms before making request', async () => { createAuthClient(); expect(document.hidden).toBe(false); @@ -442,5 +444,33 @@ describe('HTTP Requestor', () => { const res = await requestPromise; expect(res).toBe(response1); }); + + it('should retry on network error', async () => { + httpRequestClient = jest.fn() + .mockRejectedValueOnce(new TypeError('Load failed')) + .mockResolvedValueOnce({ + responseText: JSON.stringify(response1) + }); + createAuthClient(); + expect(document.hidden).toBe(false); + const requestPromise = httpRequest(sdk, { + url, + pollingIntent: true, + }); + await advanceTestTimers(); + expect(httpRequestClient).toHaveBeenCalledTimes(2); + expect(httpRequestClient).toHaveBeenCalledWith(undefined, url, { + data: undefined, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Okta-User-Agent-Extended': USER_AGENT + }, + withCredentials: false + }); + const res = await requestPromise; + expect(res).toBe(response1); + }); + }); }); \ No newline at end of file From 793382cf356bd9f1c79965d1c9f3f3b31fe98391 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 20 Dec 2024 15:51:42 +0200 Subject: [PATCH 23/25] isMobileSafari18 . --- lib/authn/util/poll.ts | 6 +- lib/base/types.ts | 1 + lib/features.ts | 10 ++ lib/http/request.ts | 6 +- package.json | 3 +- test/spec/TokenManager/browser.ts | 2 +- test/spec/TokenManager/expireEvents.ts | 2 +- test/spec/authn/mfa-challenge.js | 4 +- test/spec/features/browser.ts | 40 ++++-- test/spec/http/request.ts | 6 +- test/spec/idx/IdxStorageManager.ts | 2 +- test/spec/oidc/OAuthStorageManager.ts | 2 +- test/spec/oidc/endpoints/well-known.ts | 2 +- .../util/prepareEnrollAuthenticatorParams.ts | 2 +- test/spec/oidc/util/prepareTokenParams.ts | 2 +- yarn.lock | 124 +++++++++++++----- 16 files changed, 154 insertions(+), 60 deletions(-) diff --git a/lib/authn/util/poll.ts b/lib/authn/util/poll.ts index 3491ab002..989da99bf 100644 --- a/lib/authn/util/poll.ts +++ b/lib/authn/util/poll.ts @@ -18,7 +18,7 @@ import AuthSdkError from '../../errors/AuthSdkError'; import AuthPollStopError from '../../errors/AuthPollStopError'; import { AuthnTransactionState } from '../types'; import { getStateToken } from './stateToken'; -import { isIOS } from '../../features'; +import { isMobileSafari18 } from '../../features'; interface PollOptions { delay?: number; @@ -86,7 +86,7 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { const delayNextPoll = (ms) => { // no need for extra logic in non-iOS environments, just continue polling - if (!isIOS()) { + if (!isMobileSafari18()) { return delayFn(ms); } @@ -136,7 +136,7 @@ export function getPollFn(sdk, res: AuthnTransactionState, ref) { } // don't trigger polling request if page is hidden wait until window is visible again - if (isIOS() && document.hidden) { + if (isMobileSafari18() && document.hidden) { let handler; return new Promise((resolve) => { handler = () => { diff --git a/lib/base/types.ts b/lib/base/types.ts index c5a665f7e..3b36b95ce 100644 --- a/lib/base/types.ts +++ b/lib/base/types.ts @@ -29,6 +29,7 @@ export interface FeaturesAPI { isIE11OrLess(): boolean; isDPoPSupported(): boolean; isIOS(): boolean; + isMobileSafari18(): boolean; } diff --git a/lib/features.ts b/lib/features.ts index d57f0181a..46452742d 100644 --- a/lib/features.ts +++ b/lib/features.ts @@ -13,6 +13,7 @@ /* eslint-disable node/no-unsupported-features/node-builtins */ /* global document, window, TextEncoder, navigator */ +import { UAParser } from 'ua-parser-js'; import { webcrypto } from './crypto'; const isWindowsPhone = /windows phone|iemobile|wpdesktop/i; @@ -95,3 +96,12 @@ export function isIOS () { // @ts-expect-error - MSStream is not in `window` type, unsurprisingly (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream); } + +export function isMobileSafari18 () { + if (isBrowser() && typeof navigator !== 'undefined' && typeof navigator.userAgent !== 'undefined') { + const { browser, os } = UAParser(navigator.userAgent); + return os.name?.toLowerCase() === 'ios' && !!browser.name?.toLowerCase()?.includes('safari') + && browser.major === '18'; + } + return false; +} diff --git a/lib/http/request.ts b/lib/http/request.ts index 6c54754af..7f13e9d82 100644 --- a/lib/http/request.ts +++ b/lib/http/request.ts @@ -23,13 +23,13 @@ import { HttpResponse } from './types'; import { AuthApiError, OAuthError, APIError, WWWAuthError } from '../errors'; -import { isIOS } from '../features'; +import { isMobileSafari18 } from '../features'; // For iOS track last date when document became visible let dateDocumentBecameVisible = 0; let trackDateDocumentBecameVisible: () => void; -if (isIOS()) { +if (isMobileSafari18()) { dateDocumentBecameVisible = Date.now(); trackDateDocumentBecameVisible = () => { if (!document.hidden) { @@ -160,7 +160,7 @@ export function httpRequest(sdk: OktaAuthHttpInterface, options: RequestOptions) var err, res, promise; - if (pollingIntent && isIOS()) { + if (pollingIntent && isMobileSafari18()) { let waitForVisibleAndAwakenDocument: () => Promise; let waitForAwakenDocument: () => Promise; let recursiveFetch: () => Promise; diff --git a/package.json b/package.json index a7f575c36..c00356066 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "node-cache": "^5.1.2", "p-cancelable": "^2.0.0", "tiny-emitter": "1.1.0", + "ua-parser-js": "^2.0.0", "webcrypto-shim": "^0.1.5", "xhr2": "0.1.3" }, @@ -211,7 +212,7 @@ "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-license": "^2.8.1", "rollup-plugin-multi-input": "^1.3.1", - "rollup-plugin-typescript2": "^0.30.0", + "rollup-plugin-typescript2": "^0.36.0", "rollup-plugin-visualizer": "~5.5.4", "shelljs": "0.8.5", "ts-jest": "^28.0.2", diff --git a/test/spec/TokenManager/browser.ts b/test/spec/TokenManager/browser.ts index 666b2e8b7..6f8ace6fe 100644 --- a/test/spec/TokenManager/browser.ts +++ b/test/spec/TokenManager/browser.ts @@ -21,7 +21,7 @@ const mocked = { isIE11OrLess: () => false, isLocalhost: () => false, isTokenVerifySupported: () => true, - isIOS: () => false + isMobileSafari18: () => false } }; jest.mock('../../../lib/features', () => { diff --git a/test/spec/TokenManager/expireEvents.ts b/test/spec/TokenManager/expireEvents.ts index 356221ea5..1c40e0f21 100644 --- a/test/spec/TokenManager/expireEvents.ts +++ b/test/spec/TokenManager/expireEvents.ts @@ -2,7 +2,7 @@ jest.mock('../../../lib/features', () => { return { isLocalhost: () => true, // to allow configuring expireEarlySeconds isIE11OrLess: () => false, - isIOS: () => false + isMobileSafari18: () => false }; }); diff --git a/test/spec/authn/mfa-challenge.js b/test/spec/authn/mfa-challenge.js index f277f26f7..bb06a88ab 100644 --- a/test/spec/authn/mfa-challenge.js +++ b/test/spec/authn/mfa-challenge.js @@ -32,7 +32,7 @@ jest.mock('lib/features', () => { const actual = jest.requireActual('../../../lib/features'); return { ...actual, - isIOS: () => false + isMobileSafari18: () => false }; }); import OktaAuth from '@okta/okta-auth-js'; @@ -1581,7 +1581,7 @@ describe('MFA_CHALLENGE', function () { }); // mocks iOS environment - jest.spyOn(mocked.features, 'isIOS').mockReturnValue(true); + jest.spyOn(mocked.features, 'isMobileSafari18').mockReturnValue(true); const { response: mfaPush } = await util.generateXHRPair({ uri: 'https://auth-js-test.okta.com' diff --git a/test/spec/features/browser.ts b/test/spec/features/browser.ts index ba246d601..bb0260451 100644 --- a/test/spec/features/browser.ts +++ b/test/spec/features/browser.ts @@ -64,29 +64,47 @@ describe('features (browser)', function() { }); }); - 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' - ]; + describe('isIOS, isMobileSafari18', () => { + 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', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/130.0.2849.80 Version/18.0 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_2_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/132.0.2957.32 Version/18.0 Mobile/15E148 Safari/604.1', + ]; + const mobileSafari18Agents = [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Teak/5.9 Version/18 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPad; CPU OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1', + ]; - for (let userAgent of iOSAgents) { + for (let userAgent of iOSAgents) { + it('can succeed for ' + userAgent, () => { jest.spyOn(global.navigator, 'userAgent', 'get').mockReturnValue(userAgent); expect(OktaAuth.features.isIOS()).toBe(true); - } - }); + expect(OktaAuth.features.isMobileSafari18()).toBe(false); + }); + } + for (let userAgent of mobileSafari18Agents) { + it('can succeed for ' + userAgent, () => { + jest.spyOn(global.navigator, 'userAgent', 'get').mockReturnValue(userAgent); + expect(OktaAuth.features.isIOS()).toBe(true); + expect(OktaAuth.features.isMobileSafari18()).toBe(true); + }); + } it('returns false if navigator is unavailable', () => { jest.spyOn(global, 'navigator', 'get').mockReturnValue(undefined as never); expect(OktaAuth.features.isIOS()).toBe(false); + expect(OktaAuth.features.isMobileSafari18()).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); + expect(OktaAuth.features.isMobileSafari18()).toBe(false); }); }); }); diff --git a/test/spec/http/request.ts b/test/spec/http/request.ts index 7621b0902..d0eb2fb91 100644 --- a/test/spec/http/request.ts +++ b/test/spec/http/request.ts @@ -29,7 +29,7 @@ jest.mock('../../../lib/features', () => { isIE11OrLess: () => false, isLocalhost: () => false, isHTTPS: () => false, - isIOS: () => false + isMobileSafari18: () => false }; }); @@ -376,7 +376,7 @@ describe('HTTP Requestor', () => { jest.mock('../../../lib/features', () => { return { ...mocked.features, - isIOS: () => true + isMobileSafari18: () => true }; }); const { httpRequest: reloadedHttpRequest } = jest.requireActual('../../../lib/http'); @@ -390,7 +390,7 @@ describe('HTTP Requestor', () => { jest.mock('../../../lib/features', () => { return { ...mocked.features, - isIOS: () => false + isMobileSafari18: () => false }; }); }); diff --git a/test/spec/idx/IdxStorageManager.ts b/test/spec/idx/IdxStorageManager.ts index 3438bb90f..207dae6c2 100644 --- a/test/spec/idx/IdxStorageManager.ts +++ b/test/spec/idx/IdxStorageManager.ts @@ -29,7 +29,7 @@ jest.mock('../../../lib/util', () => { jest.mock('../../../lib/features', () => { return { isBrowser: () => {}, - isIOS: () => false + isMobileSafari18: () => false }; }); diff --git a/test/spec/oidc/OAuthStorageManager.ts b/test/spec/oidc/OAuthStorageManager.ts index 4adb29a70..a9a360ae6 100644 --- a/test/spec/oidc/OAuthStorageManager.ts +++ b/test/spec/oidc/OAuthStorageManager.ts @@ -31,7 +31,7 @@ jest.mock('../../../lib/util', () => { jest.mock('../../../lib/features', () => { return { isBrowser: () => {}, - isIOS: () => false + isMobileSafari18: () => false }; }); diff --git a/test/spec/oidc/endpoints/well-known.ts b/test/spec/oidc/endpoints/well-known.ts index a4518a338..bd2ef8765 100644 --- a/test/spec/oidc/endpoints/well-known.ts +++ b/test/spec/oidc/endpoints/well-known.ts @@ -18,7 +18,7 @@ const mocked = { isBrowser: () => typeof window !== 'undefined', isIE11OrLess: () => false, isLocalhost: () => false, - isIOS: () => false + isMobileSafari18: () => false } }; jest.mock('../../../../lib/features', () => { diff --git a/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts index fdf1f3939..a8dfe7d99 100644 --- a/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts +++ b/test/spec/oidc/util/prepareEnrollAuthenticatorParams.ts @@ -17,7 +17,7 @@ const mocked = { isLocalhost: () => true, isHTTPS: () => false, isPKCESupported: () => true, - isIOS: () => false, + isMobileSafari18: () => false, }, }; jest.mock('../../../../lib/features', () => { diff --git a/test/spec/oidc/util/prepareTokenParams.ts b/test/spec/oidc/util/prepareTokenParams.ts index c04db7cf6..16bc627ad 100644 --- a/test/spec/oidc/util/prepareTokenParams.ts +++ b/test/spec/oidc/util/prepareTokenParams.ts @@ -20,7 +20,7 @@ const mocked = { isPKCESupported: () => true, hasTextEncoder: () => true, isDPoPSupported: () => true, - isIOS: () => false, + isMobileSafari18: () => false, }, wellKnown: { getWellKnown: (): Promise => Promise.resolve() diff --git a/yarn.lock b/yarn.lock index 85974a41b..5ec1e6298 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1930,7 +1930,7 @@ estree-walker "^1.0.1" picomatch "^2.2.2" -"@rollup/pluginutils@^4.1.0", "@rollup/pluginutils@^4.2.1": +"@rollup/pluginutils@^4.1.2", "@rollup/pluginutils@^4.2.1": version "4.2.1" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d" integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ== @@ -4882,6 +4882,11 @@ destroy@1.2.0: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== +detect-europe-js@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/detect-europe-js/-/detect-europe-js-0.1.2.tgz#aa76642e05dae786efc2e01a23d4792cd24c7b88" + integrity sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow== + detect-file@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" @@ -6195,7 +6200,7 @@ find-cache-dir@^2.0.0: make-dir "^2.0.0" pkg-dir "^3.0.0" -find-cache-dir@^3.3.1, find-cache-dir@^3.3.2: +find-cache-dir@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== @@ -6408,7 +6413,16 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-extra@8.1.0, fs-extra@^8.1.0: +fs-extra@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-extra@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== @@ -7551,7 +7565,7 @@ is-callable@^1.1.4, is-callable@^1.2.7: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-core-module@^2.2.0, is-core-module@^2.8.1, is-core-module@^2.9.0: +is-core-module@^2.8.1, is-core-module@^2.9.0: version "2.10.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed" integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg== @@ -7756,6 +7770,11 @@ is-shared-array-buffer@^1.0.2: dependencies: call-bind "^1.0.2" +is-standalone-pwa@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz#7a1b0459471a95378aa0764d5dc0a9cec95f2871" + integrity sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g== + is-stream@^2.0.0, is-stream@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -8689,6 +8708,15 @@ jsonfile@^4.0.0: optionalDependencies: graceful-fs "^4.1.6" +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + jsonpath-plus@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/jsonpath-plus/-/jsonpath-plus-6.0.1.tgz#9a3e16cedadfab07a3d8dc4e8cd5df4ed8f49c4d" @@ -10234,7 +10262,7 @@ path-key@^4.0.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18" integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== -path-parse@^1.0.6, path-parse@^1.0.7: +path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== @@ -11128,14 +11156,6 @@ resolve.exports@^1.1.0: resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-1.1.0.tgz#5ce842b94b05146c0e03076985d1d0e7e48c90c9" integrity sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ== -resolve@1.20.0: - version "1.20.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" - integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== - dependencies: - is-core-module "^2.2.0" - path-parse "^1.0.6" - resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.4.0, resolve@^1.9.0: version "1.22.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" @@ -11249,16 +11269,16 @@ rollup-plugin-multi-input@^1.3.1: fast-glob "^3.0.0" lodash "^4.17.11" -rollup-plugin-typescript2@^0.30.0: - version "0.30.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.30.0.tgz#1cc99ac2309bf4b9d0a3ebdbc2002aecd56083d3" - integrity sha512-NUFszIQyhgDdhRS9ya/VEmsnpTe+GERDMmFo0Y+kf8ds51Xy57nPNGglJY+W6x1vcouA7Au7nsTgsLFj2I0PxQ== +rollup-plugin-typescript2@^0.36.0: + version "0.36.0" + resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.36.0.tgz#309564eb70d710412f5901344ca92045e180ed53" + integrity sha512-NB2CSQDxSe9+Oe2ahZbf+B4bh7pHwjV5L+RSYpCu7Q5ROuN94F9b6ioWwKfz3ueL3KTtmX4o2MUH2cgHDIEUsw== dependencies: - "@rollup/pluginutils" "^4.1.0" - find-cache-dir "^3.3.1" - fs-extra "8.1.0" - resolve "1.20.0" - tslib "2.1.0" + "@rollup/pluginutils" "^4.1.2" + find-cache-dir "^3.3.2" + fs-extra "^10.0.0" + semver "^7.5.4" + tslib "^2.6.2" rollup-plugin-visualizer@~5.5.4: version "5.5.4" @@ -11473,6 +11493,11 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +semver@^7.5.4: + version "7.6.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== + semver@~7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" @@ -12051,7 +12076,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12069,6 +12094,15 @@ string-width@^1.0.1, string-width@^1.0.2: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -12129,7 +12163,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -12615,11 +12656,6 @@ tsconfig-paths@^4.1.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" - integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== - tslib@^1.8.1, tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -12772,6 +12808,20 @@ u2f-api-polyfill@0.4.3: resolved "https://registry.yarnpkg.com/u2f-api-polyfill/-/u2f-api-polyfill-0.4.3.tgz#b7ad165a6f962558517a867c5c4bf9399fcf7e98" integrity sha512-0DVykdzG3tKft2GciQCGzgO8BinDEfIhTBo7FKbLBmA+sVTPYmNOFbsZuduYQmnc3+ykUadTHNqXVqnvBfLCvg== +ua-is-frozen@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz#bfbc5f06336e379590e36beca444188c7dc3a7f3" + integrity sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw== + +ua-parser-js@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-2.0.0.tgz#fae88e352510198bd29a6dd41624c7cd0d2c7ade" + integrity sha512-SASgD4RlB7+SCMmlVNqrhPw0f/2pGawWBzJ2+LwGTD0GgNnrKGzPJDiraGHJDwW9Zm5DH2lTmUpqDpbZjJY4+Q== + dependencies: + detect-europe-js "^0.1.2" + is-standalone-pwa "^0.1.1" + ua-is-frozen "^0.1.2" + uglify-js@^3.1.4: version "3.17.3" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.3.tgz#f0feedf019c4510f164099e8d7e72ff2d7304377" @@ -12885,6 +12935,11 @@ universalify@^0.2.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== +universalify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== + unload@2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/unload/-/unload-2.3.1.tgz#9d16862d372a5ce5cb630ad1309c2fd6e35dacfe" @@ -13585,7 +13640,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -13611,6 +13666,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From b85ea1e03ef7b323ef59a43bc8627a3525497364 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 20 Dec 2024 16:06:58 +0200 Subject: [PATCH 24/25] lint fix --- lib/features.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/features.ts b/lib/features.ts index 46452742d..e2aed849f 100644 --- a/lib/features.ts +++ b/lib/features.ts @@ -99,6 +99,7 @@ export function isIOS () { export function isMobileSafari18 () { if (isBrowser() && typeof navigator !== 'undefined' && typeof navigator.userAgent !== 'undefined') { + // eslint-disable-next-line new-cap const { browser, os } = UAParser(navigator.userAgent); return os.name?.toLowerCase() === 'ios' && !!browser.name?.toLowerCase()?.includes('safari') && browser.major === '18'; From d17ad7982bbd39a6f80e6c9b514f86666f4a3550 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 20 Dec 2024 16:08:24 +0200 Subject: [PATCH 25/25] lint fix --- test/spec/features/browser.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/spec/features/browser.ts b/test/spec/features/browser.ts index bb0260451..90918b171 100644 --- a/test/spec/features/browser.ts +++ b/test/spec/features/browser.ts @@ -88,6 +88,7 @@ describe('features (browser)', function() { }); } for (let userAgent of mobileSafari18Agents) { + // eslint-disable-next-line jasmine/no-spec-dupes it('can succeed for ' + userAgent, () => { jest.spyOn(global.navigator, 'userAgent', 'get').mockReturnValue(userAgent); expect(OktaAuth.features.isIOS()).toBe(true);