# login: time out the browser sign-in wait after 5 minutes

`86c9baa`→[main](/content/gh/entireio/cli/commits/main/index.html)·  
  
Soph·1mo ago·2 files·+76 added/-17 removed

The device flow is bounded by the AS's expires\_in (capped at 15m), but  
the browser flow waited on the loopback redirect with no deadline — a  
closed tab left `entire login` hanging until Ctrl-C. Bound the wait at  
5 minutes, starting after the Enter prompt so reading time isn't  
counted, and point the timeout error at `--device` as the escape hatch.  
Parent-context cancellation (Ctrl-C) still surfaces as cancellation,  
not as a timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

## Sessions

8979d0646ea0View transcript

## Changes

2

- cmd/entire/cli

- Mlogin.go+20/-6

- Mlogin\_test.go+56/-11

```
24 unmodified lines

25
26
27
28
29
30
31
32
33
34
35
36
50 unmodified lines

87
88
89
84
90
91
92
93
59 unmodified lines

153
154
155
150
151
152
153
156
157
158
159
160
161
162
24 unmodified lines

187
188
189
184
190
191
192
193
194
195
196
197
198
199
200
201
202

24 unmodified lines

const maxExpiresIn = 15 * time.Minute
const maxTransientErrors = 5

// browserLoginTimeout bounds how long the browser flow waits for the
// loopback redirect. The device flow is bounded by the AS's expires_in
// (capped at maxExpiresIn); without a bound here a closed browser tab
// would hang `entire login` forever.
const browserLoginTimeout = 5 * time.Minute

// browserOpenFunc is the signature for opening a URL in the user's browser.
type browserOpenFunc func(ctx context.Context, url string) error

50 unmodified lines

if err != nil {
				return fmt.Errorf("start login: %w", err)
			}
			return runBrowserLogin(cmd.Context(), outW, errW, flow, client.BaseURL(), openBrowser)
			return runBrowserLogin(cmd.Context(), outW, errW, flow, client.BaseURL(), openBrowser, browserLoginTimeout)
		}
		if !useDevice {
			fmt.Fprintln(errW, "No interactive terminal detected; using device-code flow.")
59 unmodified lines

// runBrowserLogin runs the loopback authorization-code flow on an
// already-started flow: open the authorization URL in the user's browser,
// wait for the redirect back to the local listener, then exchange the code
// for tokens. Shares the token validation + persistence tail with runLogin
// via persistLogin.
func runBrowserLogin(ctx context.Context, outW, errW io.Writer, flow browserAuthFlow, baseURL string, openURL browserOpenFunc) error {
// wait up to waitTimeout for the redirect back to the local listener, then
// exchange the code for tokens. Shares the token validation + persistence
// tail with runLogin via persistLogin.
func runBrowserLogin(ctx context.Context, outW, errW io.Writer, flow browserAuthFlow, baseURL string, openURL browserOpenFunc, waitTimeout time.Duration) error {
    // Wait tears the listener down on return, but Close is idempotent and
    // covers the error paths before Wait runs.
    defer func() { _ = flow.Close() }()
24 unmodified lines

fmt.Fprint(outW, "Waiting for sign-in... ")

code, err := flow.Wait(ctx)
	// The clock starts here, after the Enter prompt, so time spent reading
	// the prompt isn't counted against the sign-in itself.
	waitCtx, cancel := context.WithTimeout(ctx, waitTimeout)
	defer cancel()

code, err := flow.Wait(waitCtx)
	if err != nil {
		if errors.Is(waitCtx.Err(), context.DeadlineExceeded) {
			return fmt.Errorf("timed out waiting for sign-in after %v; run `entire login` again, or use `entire login --device`", waitTimeout)
		}
		return fmt.Errorf("complete login: %w", err)
	}
```

Mcmd/entire/cli/login.go+20/-6

```
303 unmodified lines

304
305
306
307
308
309
310
311
312
307
308
309
310
311
312
313
314
315
316
1 unmodified line

318
319
320
320
321
322
323
324
325
326
327
328
329
330
40 unmodified lines

371
372
373
367
374
375
376
377
23 unmodified lines

401
402
403
397
404
405
406
407
12 unmodified lines

420
421
422
416
423
424
425
426
9 unmodified lines

436
437
438
432
439
440
441
442
1 unmodified line

444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484

303 unmodified lines

// fakeBrowserFlow implements the browserAuthFlow interface for unit tests.
type fakeBrowserFlow struct {

authURL     string
	waitCode    string
	waitErr     error
	exchAccess  string
	exchRefresh string
	exchErr     error

authURL       string
	waitCode      string
	waitErr       error
	waitUntilDone bool // Wait blocks until ctx is done and returns ctx.Err()
	exchAccess    string
	exchRefresh   string
	exchErr       error

gotExchangeCode string
	closed          bool
1 unmodified line

func (f *fakeBrowserFlow) AuthorizationURL() string { return f.authURL }

func (f *fakeBrowserFlow) Wait(context.Context) (string, error) { return f.waitCode, f.waitErr }
func (f *fakeBrowserFlow) Wait(ctx context.Context) (string, error) {
	if f.waitUntilDone {
		<-ctx.Done()
		return "", ctx.Err()
	}
	return f.waitCode, f.waitErr
}

func (f *fakeBrowserFlow) Exchange(_ context.Context, code string) (string, string, error) {
f.gotExchangeCode = code
40 unmodified lines

// The stubbed Wait returns an error, so runBrowserLogin stops before
	// persistLogin (which would hit the real keyring); we assert on the
	// side effects up to that point.
	if err := runBrowserLogin(context.Background(), &out, &bytes.Buffer{}, flow, "https://auth.test", openURL); err == nil {
	if err := runBrowserLogin(context.Background(), &out, &bytes.Buffer{}, flow, "https://auth.test", openURL, browserLoginTimeout); err == nil {
		t.Fatal("expected error from stubbed Wait")
	}

23 unmodified lines

failOpen := func(context.Context, string) error { return errors.New("no browser") }

var out, errW bytes.Buffer
	if err := runBrowserLogin(context.Background(), &out, &errW, flow, "https://auth.test", failOpen); err == nil {
	if err := runBrowserLogin(context.Background(), &out, &errW, flow, "https://auth.test", failOpen, browserLoginTimeout); err == nil {
		t.Fatal("expected error from stubbed Wait")
	}

12 unmodified lines

flow := &fakeBrowserFlow{authURL: "https://auth.test/authorize", waitErr: denied}
	noopOpen := func(context.Context, string) error { return nil }

err := runBrowserLogin(context.Background(), &bytes.Buffer{}, &bytes.Buffer{}, flow, "https://auth.test", noopOpen)  
	err := runBrowserLogin(context.Background(), &bytes.Buffer{}, &bytes.Buffer{}, flow, "https://auth.test", noopOpen, browserLoginTimeout)
	if !errors.Is(err, denied) {
		t.Fatalf("err = %v, want wrapped %v", err, denied)
	}
9 unmodified lines

}
	noopOpen := func(context.Context, string) error { return nil }

err := runBrowserLogin(context.Background(), &bytes.Buffer{}, &bytes.Buffer{}, flow, "https://auth.test", noopOpen) 
	err := runBrowserLogin(context.Background(), &bytes.Buffer{}, &bytes.Buffer{}, flow, "https://auth.test", noopOpen, browserLoginTimeout)
	if err == nil || !strings.Contains(err.Error(), "complete login") {
		t.Fatalf("err = %v, want complete login error", err)
	}
1 unmodified line

t.Errorf("Exchange got code %q, want the-code", flow.gotExchangeCode)
	}
}

func TestRunBrowserLogin_WaitTimeout(t *testing.T) {
	t.Parallel()

// The fake blocks until the wait context expires — the deadline must
	// come from runBrowserLogin's own timeout, or this test would hang.
	flow := &fakeBrowserFlow{authURL: "https://auth.test/authorize", waitUntilDone: true}
	noopOpen := func(context.Context, string) error { return nil }

err := runBrowserLogin(context.Background(), &bytes.Buffer{}, &bytes.Buffer{}, flow, "https://auth.test", noopOpen, 50*time.Millisecond)
	if err == nil || !strings.Contains(err.Error(), "timed out waiting for sign-in") {
		t.Fatalf("err = %v, want sign-in timeout", err)
	}
	if !strings.Contains(err.Error(), "--device") {
		t.Errorf("timeout error should point at the --device escape hatch, got: %v", err)
	}
	if !flow.closed {
									t.Error("flow was not closed")
	}
}

func TestRunBrowserLogin_ParentCancelNotReportedAsTimeout(t *testing.T) {
	t.Parallel()

ctx, cancel := context.WithCancel(context.Background())
	cancel() // user hit Ctrl-C before the redirect arrived

flow := &fakeBrowserFlow{authURL: "https://auth.test/authorize", waitUntilDone: true}
	noopOpen := func(context.Context, string) error { return nil }

err := runBrowserLogin(ctx, &bytes.Buffer{}, &bytes.Buffer{}, flow, "https://auth.test", noopOpen, time.Minute)
	if !errors.Is(err, context.Canceled) {
		t.Fatalf("err = %v, want wrapped context.Canceled", err)
	}
	if strings.Contains(err.Error(), "timed out") {
			t.Errorf("cancellation must not be reported as a timeout: %v", err)
	}
}
