diff --git a/app/src/components/oauth/OAuthProviderButton.tsx b/app/src/components/oauth/OAuthProviderButton.tsx index 8a18de41d..56e4b5f37 100644 --- a/app/src/components/oauth/OAuthProviderButton.tsx +++ b/app/src/components/oauth/OAuthProviderButton.tsx @@ -25,8 +25,11 @@ interface OAuthProviderButtonProps { // Reset the loading state if the OAuth round-trip never completes — covers // the case where the user cancels in the system browser, or the backend -// redirect fails so the `openhuman://` deep link never fires. -const OAUTH_LOADING_TIMEOUT_MS = 90_000; +// redirect fails so the `openhuman://` deep link never fires. Kept >= the +// loopback listener lifetime (`DEFAULT_TIMEOUT_SECS`, 300s) so the button +// never re-enables while the loopback server is still legitimately waiting +// for a slow (2FA / consent) sign-in to redirect back. +const OAUTH_LOADING_TIMEOUT_MS = 300_000; // Pre-flight budget for `/health` before we open the system browser. Kept // short so a healthy backend adds barely any perceptible click→browser delay, diff --git a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx index e172f47b0..3ebfc7a22 100644 --- a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx +++ b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx @@ -166,7 +166,7 @@ describe('OAuthProviderButton', () => { expect(screen.getByRole('button', { name: 'Google' })).toBeEnabled(); }); - it('resets isLoading after the 90s safety timeout', async () => { + it('resets isLoading after the 300s safety timeout', async () => { render(); fireEvent.click(screen.getByRole('button', { name: 'Google' })); @@ -178,9 +178,16 @@ describe('OAuthProviderButton', () => { expect(screen.getByText('Connecting...')).toBeInTheDocument(); + // Loading must persist past the old 90s mark — the loopback listener now + // legitimately waits up to 300s for a slow (2FA / consent) sign-in. await act(async () => { vi.advanceTimersByTime(90_000); }); + expect(screen.getByText('Connecting...')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(210_000); + }); expect(screen.queryByText('Connecting...')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Google' })).toBeEnabled(); @@ -367,7 +374,7 @@ describe('OAuthProviderButton', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); - it('shows the unavailable banner if the 90s timeout fires while the backend is down', async () => { + it('shows the unavailable banner if the 300s timeout fires while the backend is down', async () => { vi.mocked(checkBackendHealthy) .mockResolvedValueOnce(healthyResult) .mockResolvedValueOnce({ healthy: false, reason: 'timeout', latencyMs: 6000 }); @@ -380,7 +387,7 @@ describe('OAuthProviderButton', () => { expect(openUrl).toHaveBeenCalledTimes(1); await act(async () => { - vi.advanceTimersByTime(90_000); + vi.advanceTimersByTime(300_000); // After the safety timer fires we kick off probeBackendOnReturn(). // Drain enough microtasks for that async probe to resolve and its // .then() to flush the alert into the DOM. diff --git a/app/src/utils/__tests__/loopbackOauthListener.test.ts b/app/src/utils/__tests__/loopbackOauthListener.test.ts index 07e326c91..b38a9ad55 100644 --- a/app/src/utils/__tests__/loopbackOauthListener.test.ts +++ b/app/src/utils/__tests__/loopbackOauthListener.test.ts @@ -28,7 +28,7 @@ describe('startLoopbackOauthListener', () => { expect(handle).toBeNull(); expect(mockInvoke).toHaveBeenCalledWith('start_loopback_oauth_listener', { port: 53824, - timeoutSecs: 60, + timeoutSecs: 300, }); }); diff --git a/app/src/utils/loopbackOauthListener.ts b/app/src/utils/loopbackOauthListener.ts index 715d73dd1..68c07f132 100644 --- a/app/src/utils/loopbackOauthListener.ts +++ b/app/src/utils/loopbackOauthListener.ts @@ -20,7 +20,14 @@ import { isTauri } from './tauriCommands/common'; */ const DEFAULT_PORT = 53824; -const DEFAULT_TIMEOUT_SECS = 60; +// The listener lifetime starts at the button click — before the system browser +// even opens — and the Rust shell hard-kills the loopback server when it +// elapses (`loopback_oauth.rs` `timeout(lifetime, run)`). A real GitHub/Google +// sign-in (account chooser, password manager, 2FA/OTP, first-time consent) +// routinely exceeds 60s; if the server dies first, the post-auth redirect lands +// on a dead port and the browser shows "127.0.0.1: not reached" even +// though OAuth succeeded. 5 minutes comfortably covers an interactive sign-in. +const DEFAULT_TIMEOUT_SECS = 300; const CALLBACK_EVENT = 'loopback-oauth-callback'; export interface LoopbackHandle {