From b18bb12028f752b38d898902e982274e566a7e4d Mon Sep 17 00:00:00 2001 From: Kiran Niranjan Date: Thu, 5 Dec 2024 08:35:25 +0530 Subject: [PATCH] SDA-4737 - Add retry logic for browser login (#2232) * SDA-4737 - Add retry logic for browser login * SDA-4725 - Update SDA title bar branding * SDA-4737 - Add abort & handle network changed use case --- config/Symphony.config | 1 + installer/mac/postinstall.sh | 4 + spec/config.spec.ts | 1 + spec/plistHandler.spec.ts | 1 + src/app/config-handler.ts | 2 + src/app/main-api-handler.ts | 228 ++++++++++++++++++++-------- src/app/plist-handler.ts | 1 + src/renderer/components/welcome.tsx | 4 + src/renderer/styles/welcome.less | 6 + 9 files changed, 183 insertions(+), 65 deletions(-) diff --git a/config/Symphony.config b/config/Symphony.config index 153bbd763..7a31c258c 100644 --- a/config/Symphony.config +++ b/config/Symphony.config @@ -8,6 +8,7 @@ "autoUpdateCheckInterval": "30", "enableBrowserLogin": false, "browserLoginAutoConnect": false, + "browserLoginRetryTimeout": "5", "overrideUserAgent": false, "minimizeOnClose" : "ENABLED", "launchOnStartup" : "ENABLED", diff --git a/installer/mac/postinstall.sh b/installer/mac/postinstall.sh index 50354c297..a541348dc 100755 --- a/installer/mac/postinstall.sh +++ b/installer/mac/postinstall.sh @@ -33,6 +33,7 @@ bring_to_front=$(sed -n '6p' ${settingsFilePath}); dev_tools_enabled=$(sed -n '7p' ${settingsFilePath}); enable_browser_login=$(sed -n '8p' ${settingsFilePath}); browser_login_autoconnect=$(sed -n '9p' ${settingsFilePath}); +browser_login_retry_timeout=$(sed -n '10p' ${settingsFilePath}); ## If any of the above values turn out to be empty, set default values ## if [ "$pod_url" = "" ]; then pod_url="https://my.symphony.com"; fi @@ -44,6 +45,7 @@ if [ "$bring_to_front" = "" ] || [ "$bring_to_front" = 'false' ]; then bring_to_ if [ "$dev_tools_enabled" = "" ]; then dev_tools_enabled=true; fi if [ "$enable_browser_login" = "" ]; then enable_browser_login=false; fi if [ "$browser_login_autoconnect" = "" ]; then browser_login_autoconnect=false; fi +if [ "$browser_login_retry_timeout" = "" ]; then browser_login_retry_timeout='5'; fi ## Add settings force auto update @@ -86,6 +88,7 @@ if [ "$EUID" -ne 0 ]; then defaults write "$plistFilePath" autoUpdateCheckInterval -string "30" defaults write "$plistFilePath" enableBrowserLogin -bool "$enable_browser_login" defaults write "$plistFilePath" browserLoginAutoConnect -bool "$browser_login_autoconnect" + defaults write "$plistFilePath" browserLoginRetryTimeout -string "$browser_login_retry_timeout" defaults write "$plistFilePath" overrideUserAgent -bool false defaults write "$plistFilePath" minimizeOnClose -string "$minimize_on_close" defaults write "$plistFilePath" launchOnStartup -string "$launch_on_startup" @@ -130,6 +133,7 @@ else sudo -u "$userName" defaults write "$plistFilePath" autoUpdateCheckInterval -string "30" sudo -u "$userName" defaults write "$plistFilePath" enableBrowserLogin -bool "$enable_browser_login" sudo -u "$userName" defaults write "$plistFilePath" browserLoginAutoConnect -bool "$browser_login_autoconnect" + sudo -u "$userName" defaults write "$plistFilePath" browserLoginRetryTimeout -string "$browser_login_retry_timeout" sudo -u "$userName" defaults write "$plistFilePath" overrideUserAgent -bool false sudo -u "$userName" defaults write "$plistFilePath" minimizeOnClose -string "$minimize_on_close" sudo -u "$userName" defaults write "$plistFilePath" launchOnStartup -string "$launch_on_startup" diff --git a/spec/config.spec.ts b/spec/config.spec.ts index 70b957e11..3719c3ef8 100644 --- a/spec/config.spec.ts +++ b/spec/config.spec.ts @@ -100,6 +100,7 @@ describe('config', () => { 'browserLoginAutoConnect', 'latestAutoUpdateChannelEnabled', 'betaAutoUpdateChannelEnabled', + 'browserLoginRetryTimeout', ]; const globalConfig: object = { url: 'test' }; const userConfig: object = { configVersion: '4.0.1' }; diff --git a/spec/plistHandler.spec.ts b/spec/plistHandler.spec.ts index 6d7f1427b..3d823c9fa 100644 --- a/spec/plistHandler.spec.ts +++ b/spec/plistHandler.spec.ts @@ -50,6 +50,7 @@ describe('Plist Handler', () => { betaAutoUpdateChannelEnabled: undefined, bringToFront: undefined, browserLoginAutoConnect: undefined, + browserLoginRetryTimeout: undefined, customFlags: { authNegotiateDelegateWhitelist: undefined, authServerWhitelist: undefined, diff --git a/src/app/config-handler.ts b/src/app/config-handler.ts index 256a847cb..4d31c7e42 100644 --- a/src/app/config-handler.ts +++ b/src/app/config-handler.ts @@ -39,6 +39,7 @@ export const ConfigFieldsDefaultValues: Partial = { browserLoginAutoConnect: false, latestAutoUpdateChannelEnabled: true, betaAutoUpdateChannelEnabled: true, + browserLoginRetryTimeout: '5', }; export const ConfigFieldsToRestart = new Set([ @@ -85,6 +86,7 @@ export interface IConfig { startedAfterAutoUpdate?: boolean; enableBrowserLogin?: boolean; browserLoginAutoConnect?: boolean; + browserLoginRetryTimeout?: string; betaAutoUpdateChannelEnabled?: boolean; latestAutoUpdateChannelEnabled?: boolean; forceAutoUpdate?: boolean; diff --git a/src/app/main-api-handler.ts b/src/app/main-api-handler.ts index d7dfb61e5..2cf582330 100644 --- a/src/app/main-api-handler.ts +++ b/src/app/main-api-handler.ts @@ -101,6 +101,8 @@ let loginUrl = ''; let formattedPodUrl = ''; let credentialsPromise; const credentialsPromiseRefHolder: { [key: string]: any } = {}; +const BROWSER_LOGIN_RETRY = 15 * 1000; // 15sec +const BROWSER_LOGIN_ABORT_TIMEOUT = 10 * 1000; // 10sec /** * Handle API related ipc messages from renderers. Only messages from windows @@ -467,7 +469,10 @@ ipcMain.on( ? userConfigURL : globalConfigURL; const { subdomain, domain, tld } = whitelistHandler.parseDomain(podUrl); - const localConfig = config.getConfigFields(['enableBrowserLogin']); + const localConfig = config.getConfigFields([ + 'enableBrowserLogin', + 'browserLoginRetryTimeout', + ]); formattedPodUrl = `https://${subdomain}.${domain}${tld}`; loginUrl = getBrowserLoginUrl(formattedPodUrl); @@ -483,7 +488,10 @@ ipcMain.on( 'check if sso is enabled for the pod', formattedPodUrl, ); - loadPodUrl(false); + const timeout = localConfig.browserLoginRetryTimeout + ? parseInt(localConfig.browserLoginRetryTimeout, 10) + : 0; + loadPodUrl(false, timeout); } else { logger.info( 'main-api-handler:', @@ -739,72 +747,162 @@ const logApiCallParams = (arg: any) => { } }; -const loadPodUrl = (proxyLogin = false) => { - logger.info('loading pod URL. Proxy: ', proxyLogin); - let onLogin = {}; - if (proxyLogin) { - onLogin = { - async onLogin(authInfo) { - // this 'authInfo' is the one received by the 'login' event. See https://www.electronjs.org/docs/latest/api/client-request#event-login - proxyDetails.hostname = authInfo.host || authInfo.realm; - await credentialsPromise; - return Promise.resolve({ - username: proxyDetails.username, - password: proxyDetails.password, - }); - }, - }; - } - fetch(`${formattedPodUrl}${AUTH_STATUS_PATH}`, onLogin) - .then(async (response) => { - const authResponse = (await response.json()) as IAuthResponse; - logger.info('main-api-handler:', 'check auth response', authResponse); - if (authResponse.authenticationType === 'sso') { - logger.info( - 'main-api-handler: browser login is enabled - logging in', - loginUrl, - ); - await shell.openExternal(loginUrl); - } else { - logger.info( - 'main-api-handler: no SSO - loading main window with', - formattedPodUrl, - ); - const mainWebContents = windowHandler.getMainWebContents(); - if (mainWebContents && !mainWebContents.isDestroyed()) { - windowHandler.setMainWindowOrigin(formattedPodUrl); - mainWebContents.loadURL(formattedPodUrl); - } +/** + * Loads the Pod URL and handles potential authentication challenges. + * + * This function attempts to fetch the Pod URL and handles various authentication scenarios: + * - Standard login (no proxy) + * - Proxy login with authentication window + * - Login retry logic for failed attempts + * + * @param {boolean} [proxyLogin=false] - Whether to use a proxy for the request. Defaults to false. + * @param {number} [retryDurationInMinutes=0] - The duration (in minutes) for the retry logic. Defaults to 0 (no retries). + */ +const loadPodUrl = (() => { + let isRetryInProgress: boolean = false; + let retryTimeoutId: NodeJS.Timeout | null = null; + + return (proxyLogin = false, retryDurationInMinutes = 0) => { + logger.info('main-api-handler: loading pod URL. Proxy: ', proxyLogin); + + const maxRetries = Math.floor( + (retryDurationInMinutes * 60 * 1000) / BROWSER_LOGIN_RETRY, + ); + let retryCount = 0; + + // Function to attempt fetching the endpoint + const attemptFetch = async () => { + if (retryTimeoutId) { + clearTimeout(retryTimeoutId); // Clear any existing timeout to avoid overlaps } - }) - .catch(async (error) => { - if ( - (error.type === 'proxy' && error.code === 'PROXY_AUTH_FAILED') || - (error.code === 'ERR_TOO_MANY_RETRIES' && proxyLogin) - ) { - credentialsPromise = new Promise((res, _rej) => { - credentialsPromiseRefHolder.resolutionCallback = res; - }); - const welcomeWindow = - windowHandler.getMainWindow() as ICustomBrowserWindow; - windowHandler.createBasicAuthWindow( - welcomeWindow, - proxyDetails.hostname, - proxyDetails.retries === 0, - undefined, - (username, password) => { - proxyDetails.username = username; - proxyDetails.password = password; - credentialsPromiseRefHolder.resolutionCallback(true); - loadPodUrl(true); + + logger.info( + 'main-api-handler: Attempting to fetch the pod URL. Attempt:', + retryCount + 1, + ); + + let onLogin = {}; + if (proxyLogin) { + onLogin = { + async onLogin(authInfo) { + // this 'authInfo' is the one received by the 'login' event. See https://www.electronjs.org/docs/latest/api/client-request#event-login + proxyDetails.hostname = authInfo.host || authInfo.realm; + await credentialsPromise; + return Promise.resolve({ + username: proxyDetails.username, + password: proxyDetails.password, + }); }, - ); - proxyDetails.retries += 1; + }; } - logger.error( - 'main-api-handler: browser login error. Details: ', - error.type, - error.code, + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + BROWSER_LOGIN_ABORT_TIMEOUT, ); + try { + const response = await fetch(`${formattedPodUrl}${AUTH_STATUS_PATH}`, { + ...onLogin, + signal: controller.signal, + }); + const authResponse = (await response.json()) as IAuthResponse; + logger.info('main-api-handler: check auth response', authResponse); + + if (authResponse.authenticationType === 'sso') { + logger.info( + 'main-api-handler: browser login is enabled - logging in', + loginUrl, + ); + await shell.openExternal(loginUrl); + } else { + logger.info( + 'main-api-handler: no SSO - loading main window with', + formattedPodUrl, + ); + const mainWebContents = windowHandler.getMainWebContents(); + if (mainWebContents && !mainWebContents.isDestroyed()) { + windowHandler.setMainWindowOrigin(formattedPodUrl); + mainWebContents.loadURL(formattedPodUrl); + } + } + + isRetryInProgress = false; + setLoginRetryState(isRetryInProgress); + retryTimeoutId = null; + } catch (error: any) { + if ( + (error.type === 'proxy' && error.code === 'PROXY_AUTH_FAILED') || + (error.code === 'ERR_TOO_MANY_RETRIES' && proxyLogin) + ) { + credentialsPromise = new Promise((res, _rej) => { + credentialsPromiseRefHolder.resolutionCallback = res; + }); + const welcomeWindow = + windowHandler.getMainWindow() as ICustomBrowserWindow; + windowHandler.createBasicAuthWindow( + welcomeWindow, + proxyDetails.hostname, + proxyDetails.retries === 0, + undefined, + (username, password) => { + proxyDetails.username = username; + proxyDetails.password = password; + credentialsPromiseRefHolder.resolutionCallback(true); + loadPodUrl(true); + }, + ); + proxyDetails.retries += 1; + } else { + logger.error( + 'main-api-handler: browser login error. Details: ', + error.type, + error.code, + ); + retryCount++; + if (retryCount < maxRetries || error.code === 'ERR_NETWORK_CHANGED') { + retryTimeoutId = setTimeout(attemptFetch, BROWSER_LOGIN_RETRY); + } else { + logger.error( + 'main-api-handler: Retry attempts exhausted. Endpoint unreachable.', + ); + isRetryInProgress = false; + setLoginRetryState(isRetryInProgress); + } + } + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + }; + + // Start the retry logic only if it's not already in progress + if (!isRetryInProgress) { + isRetryInProgress = true; + setLoginRetryState(isRetryInProgress); + attemptFetch(); + } else { + logger.info( + 'main-api-handler: Retry logic already in progress. Ignoring duplicate call.', + ); + } + }; +})(); + +/** + * Updates the login retry state in the main web content. + * + * Sends a message to the main web content indicating whether a login retry is in progress. + * This message is used to update the UI accordingly. + * + * @param {boolean} isRetryInProgress - A boolean indicating whether a login retry is in progress. + */ +const setLoginRetryState = (isRetryInProgress: boolean) => { + const mainWebContents = windowHandler.getMainWebContents(); + if (mainWebContents && !mainWebContents.isDestroyed()) { + mainWebContents.send('welcome', { + isRetryInProgress, }); + } }; diff --git a/src/app/plist-handler.ts b/src/app/plist-handler.ts index 0e62d3f11..a7047190c 100644 --- a/src/app/plist-handler.ts +++ b/src/app/plist-handler.ts @@ -19,6 +19,7 @@ const GENERAL_SETTINGS = { autoUpdateCheckInterval: 'string', enableBrowserLogin: 'boolean', browserLoginAutoConnect: 'boolean', + browserLoginRetryTimeout: 'string', overrideUserAgent: 'boolean', minimizeOnClose: 'string', launchOnStartup: 'string', diff --git a/src/renderer/components/welcome.tsx b/src/renderer/components/welcome.tsx index d14429b77..bdcde009f 100644 --- a/src/renderer/components/welcome.tsx +++ b/src/renderer/components/welcome.tsx @@ -12,6 +12,7 @@ interface IState { isBrowserLoginEnabled: boolean; browserLoginAutoConnect: boolean; isLoading: boolean; + isRetryInProgress: boolean; } const WELCOME_NAMESPACE = 'Welcome'; @@ -37,6 +38,7 @@ export default class Welcome extends React.Component<{}, IState> { isBrowserLoginEnabled: true, browserLoginAutoConnect: false, isLoading: false, + isRetryInProgress: false, }; this.updateState = this.updateState.bind(this); } @@ -52,6 +54,7 @@ export default class Welcome extends React.Component<{}, IState> { isLoading, isBrowserLoginEnabled, isFirstTimeLaunch, + isRetryInProgress, } = this.state; return (
@@ -126,6 +129,7 @@ export default class Welcome extends React.Component<{}, IState> { diff --git a/src/renderer/styles/welcome.less b/src/renderer/styles/welcome.less index 3874c25bf..fc0bd5666 100644 --- a/src/renderer/styles/welcome.less +++ b/src/renderer/styles/welcome.less @@ -242,6 +242,7 @@ body { &-continue-button-loading:disabled { background-color: @electricity-ui-50; + cursor: not-allowed; } &-redirect-info-text-container { @@ -271,6 +272,11 @@ body { color: @electricity-ui-30; } + &-retry-button:disabled { + cursor: not-allowed; + color: @graphite-60; + } + &-auto-connect-wrapper { display: flex; margin-top: 18px;