diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx index ec8f03987..2ef02a95c 100644 --- a/frontend/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -213,12 +213,16 @@ export const SetupWizard: React.FC = ({ onComplete }) => { const params = new URLSearchParams(hash); const accessToken = params.get('access_token'); const type = params.get('type') || parsed.searchParams.get('type'); - const accessTokenFromHash = params.get('access_token'); - const accessTokenFromQuery = parsed.searchParams.get('access_token'); - const serverFromQuery = parsed.searchParams.get('server'); - - // Handle self-hosted SSO deep link + // Self-hosted SSO deep links are normally handled by authService.loginWithSelfHostedOAuth. + // Fallback here only if no in-flight auth listener exists (e.g. renderer reload mid-flow). if (type === 'sso' || type === 'sso-selfhosted') { + if (authService.isSelfHostedDeepLinkFlowActive()) { + return; + } + + const accessTokenFromHash = params.get('access_token'); + const accessTokenFromQuery = parsed.searchParams.get('access_token'); + const serverFromQuery = parsed.searchParams.get('server'); const token = accessTokenFromHash || accessTokenFromQuery; const serverUrl = serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL; if (!token || !serverUrl) { diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts index 3095b6cb2..121e60d61 100644 --- a/frontend/src/desktop/services/authService.ts +++ b/frontend/src/desktop/services/authService.ts @@ -42,6 +42,7 @@ export class AuthService { private lastTokenSaveTime: number = 0; private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>(); private refreshPromise: Promise | null = null; + private selfHostedDeepLinkFlowActive = false; static getInstance(): AuthService { if (!AuthService.instance) { @@ -176,6 +177,10 @@ export class AuthService { }; } + isSelfHostedDeepLinkFlowActive(): boolean { + return this.selfHostedDeepLinkFlowActive; + } + private notifyListeners() { this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo)); } @@ -782,22 +787,27 @@ export class AuthService { // ignore URL parsing failures } - // Open in system browser and wait for deep link callback - if (await this.openInSystemBrowser(authUrl)) { - return this.waitForDeepLinkCompletion(trimmedServer); - } - - throw new Error('Unable to open system browser for SSO. Please check your system settings.'); + // Register deep-link listener before opening browser to avoid callback races on first launch. + return this.waitForDeepLinkCompletion(trimmedServer, async () => { + if (!(await this.openInSystemBrowser(authUrl))) { + throw new Error('Unable to open system browser for SSO. Please check your system settings.'); + } + }); } /** * Wait for a deep-link event to complete self-hosted SSO after system browser OAuth */ - private async waitForDeepLinkCompletion(serverUrl: string): Promise { + private async waitForDeepLinkCompletion( + serverUrl: string, + startFlow?: () => Promise + ): Promise { if (!isTauri()) { throw new Error('Deep link authentication is only supported in Tauri desktop app.'); } + this.selfHostedDeepLinkFlowActive = true; + return new Promise((resolve, reject) => { let completed = false; let unlisten: (() => void) | null = null; @@ -807,6 +817,7 @@ export class AuthService { completed = true; if (unlisten) unlisten(); sessionStorage.removeItem('oauth_nonce'); + this.selfHostedDeepLinkFlowActive = false; reject(new Error('SSO login timed out. Please try again.')); } }, 120_000); @@ -825,6 +836,7 @@ export class AuthService { if (unlisten) unlisten(); clearTimeout(timeoutId); sessionStorage.removeItem('oauth_nonce'); + this.selfHostedDeepLinkFlowActive = false; reject(new Error(error || 'Authentication was not successful.')); return; } @@ -845,6 +857,7 @@ export class AuthService { if (unlisten) unlisten(); clearTimeout(timeoutId); sessionStorage.removeItem('oauth_nonce'); + this.selfHostedDeepLinkFlowActive = false; console.error('[Desktop AuthService] Nonce validation failed - potential CSRF attack'); reject(new Error('Invalid authentication state. Nonce validation failed.')); return; @@ -854,6 +867,7 @@ export class AuthService { if (unlisten) unlisten(); clearTimeout(timeoutId); sessionStorage.removeItem('oauth_nonce'); + this.selfHostedDeepLinkFlowActive = false; console.log('[Desktop AuthService] Nonce validated successfully'); const userInfo = await this.completeSelfHostedSession(serverUrl, token); @@ -870,10 +884,39 @@ export class AuthService { if (unlisten) unlisten(); clearTimeout(timeoutId); sessionStorage.removeItem('oauth_nonce'); + this.selfHostedDeepLinkFlowActive = false; reject(err instanceof Error ? err : new Error('Failed to complete SSO')); } - }).then((fn) => { + }).then(async (fn) => { unlisten = fn; + + if (!startFlow || completed) { + return; + } + + try { + await startFlow(); + } catch (err) { + if (completed) { + return; + } + completed = true; + if (unlisten) unlisten(); + clearTimeout(timeoutId); + sessionStorage.removeItem('oauth_nonce'); + this.selfHostedDeepLinkFlowActive = false; + reject(err instanceof Error ? err : new Error('Failed to start SSO login')); + } + }).catch((err) => { + if (completed) { + return; + } + completed = true; + if (unlisten) unlisten(); + clearTimeout(timeoutId); + sessionStorage.removeItem('oauth_nonce'); + this.selfHostedDeepLinkFlowActive = false; + reject(err instanceof Error ? err : new Error('Failed to listen for deep link events')); }); }); }