login: fall back to the device flow when the loopback listener fails · Entire

login: fall back to the device flow when the loopback listener fails

1663939→main·

Soph·1mo ago·2 files·+40 added/-12 removed

If StartBrowserAuth couldn't bind the 127.0.0.1 listener (sandboxing, firewall policy, exhausted ports), entire login errored out even though the device-code flow would have worked fine. Warn on stderr with the bind error and continue with the device flow instead of stranding the user.

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

Sessions

247398f9ecc7View transcript

Changes

2

77 unmodified lines

// device-code flows and runs the chosen one. The browser flow is the
// default — no code to type, no poll latency — but it needs a browser that
// can reach this machine's 127.0.0.1, so headless terminals (CI, piped
// stdin) and SSH sessions fall back to the device flow with a one-line
// explanation; the same both-flows-with-fallback shape gh / gcloud /
// aws sso ship. --device forces the device flow without commentary.
func runLoginAuto(ctx context.Context, outW, errW io.Writer, deviceClient deviceAuthClient, startBrowser func(context.Context) (browserAuthFlow, error), openURL browserOpenFunc, useDevice, canPrompt, sshSession bool) error {
    if shouldUseBrowserLogin(useDevice, canPrompt, sshSession) {
        flow, err := startBrowser(ctx)
        if err != nil {
            return fmt.Errorf("start login: %w", err)
            // Binding the loopback listener can fail (sandboxing, firewall,
            // exhausted ports); that shouldn't strand the user — warn and
            // use the device flow instead.
            fmt.Fprintf(errW, "Warning: could not start browser sign-in (%v); falling back to the device-code flow.\n", err)
            return runLogin(ctx, outW, errW, deviceClient, openURL)
        }
        return runBrowserLogin(ctx, outW, errW, flow, deviceClient.BaseURL(), openURL, browserLoginTimeout)
    }
}

375 unmodified lines

// startBrowserStub returns a startBrowser func that records invocations and // returns the given flow. func startBrowserStub(calls *int, flow browserAuthFlow) func(context.Context) (browserAuthFlow, error) { // returns the given flow/error. return func(context.Context) (browserAuthFlow, error) { *calls++ return flow, nil } }

var errW bytes.Buffer err := runLoginAuto(context.Background(), &bytes.Buffer{}, &errW, &mockClient{}, startBrowserStub(&browserCalls, flow), noopOpen, startBrowserStub(&browserCalls, flow, nil), noopOpen, false /* useDevice /, true / canPrompt /, false / ssh */)

if browserCalls != 1 { t.Errorf("startBrowser calls = %d, want 1", browserCalls) } if !strings.Contains(errW.String(), "could not start browser sign-in") { t.Errorf("stderr missing fallback warning:\n%s", errW.String()) } // mockClient.StartDeviceAuth errors — proof the device flow was attempted. if err == nil || !strings.Contains(err.Error(), "not implemented in mock") { t.Fatalf("err = %v, want device-flow start error from mock", err) }

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

var errW bytes.Buffer err := runLoginAuto(context.Background(), &bytes.Buffer{}, &errW, &mockClient{}, startBrowserStub(&browserCalls, nil), noopOpen, startBrowserStub(&browserCalls, nil, nil), noopOpen, true /* useDevice /, true / canPrompt /, false / ssh */) if browserCalls != 0 {