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 {