Skip to content

fix(auth): make browser OAuth login survive real-world callback conditions#3280

Open
honbou wants to merge 1 commit into
sipeed:mainfrom
honbou:fix-oauth-browser-login
Open

fix(auth): make browser OAuth login survive real-world callback conditions#3280
honbou wants to merge 1 commit into
sipeed:mainfrom
honbou:fix-oauth-browser-login

Conversation

@honbou

@honbou honbou commented Jul 21, 2026

Copy link
Copy Markdown

Problem

picoclaw auth login --provider <oauth-provider> fails on almost every headless / remote setup, and each failure happens after the user has already approved consent — the authorization code is burned and the whole flow has to restart.

Four independent causes, all found while authenticating Antigravity on a remote VM (related: #3274, #3278):

1. Callback listener binds IPv4 loopback only

listenOAuthCallback binds 127.0.0.1 only. Browsers that resolve localhost to ::1 first — Safari on macOS does — get connection refused, so the redirect ends in "cannot connect to server" even though the login succeeded on the provider's side. This also breaks the standard ssh -L workaround unless the user happens to forward the right family.

2. A stale callback kills the login in flight

Any request with a mismatched state pushes a fatal error onto the result channel:

if r.URL.Query().Get("state") != state {
    resultCh <- callbackResult{err: fmt.Errorf("state mismatch")}

So an old browser tab replaying a previous callback (or a prefetch, or a port scan) aborts the current login. In practice this is easy to hit: retry the login, an old tab reloads, and the fresh session dies with login failed: state mismatch — invalidating the code the user just obtained.

The state check is CSRF protection for the request; it should reject that request, not the session.

3. manual input canceled fires immediately without a TTY

The manual-paste goroutine treats stdin EOF as an explicit cancel:

input, _ := reader.ReadString('\n')
// ...
case manualInput := <-manualCh:
    if manualInput == "" {
        return nil, fmt.Errorf("manual input canceled")

Under systemd, a pipe, or nohup, ReadString returns EOF instantly, so the login aborts before the user can even open the URL — which is exactly the headless case the manual-paste path exists to serve.

4. Five-minute timeout is too short for the headless path

The message tells the user to complete login in a local browser and paste the redirect URL back. Moving the URL to another machine and setting up a tunnel routinely takes longer than five minutes.

Changes

  • Bind both loopback families on the same port. IPv6 is best-effort, so hosts without it keep working with IPv4 only.
  • Reject state-mismatched callbacks with 400 and keep waiting for the real one.
  • Ignore empty stdin reads instead of treating them as a cancel.
  • Default timeout 5m → 15m, overridable with PICOCLAW_OAUTH_TIMEOUT (any time.ParseDuration value).

No config, CLI, or provider changes; behaviour is unchanged for a normal desktop login that succeeds on the first try.

Tests

Four regression tests in pkg/auth/oauth_callback_test.go, one per bug — dual-family reachability (skips where there is no IPv6 loopback), stale callback followed by a successful real callback, empty-stdin login reaching the timeout instead of cancelling, and the timeout override. go test ./pkg/auth/ passes.

Verification

Built and deployed on the VM where the original failures occurred; picoclaw auth login --provider antigravity now completes through an ssh -L 51121:localhost:51121 tunnel without any of the four failure modes.

…tions

The browser login flow failed on almost every headless/VM setup for four
independent reasons, each of which aborted a login that had otherwise
succeeded on Google's side:

- The callback listener bound IPv4 loopback only. Browsers that resolve
  "localhost" to ::1 (Safari on macOS) could not reach it, so the redirect
  ended in "cannot connect to server". Bind both loopback families on the
  same port; IPv6 is best-effort.

- Any request with a mismatched state pushed a fatal error onto the result
  channel, so a stale tab replaying an older callback killed the login in
  flight and invalidated the fresh authorization code. Reject such requests
  with 400 and keep waiting for the real callback.

- The manual-paste reader treated stdin EOF as "manual input canceled".
  Under systemd, a pipe, or nohup that fires immediately, ending the login
  before the user could open the URL. Ignore empty reads.

- The five minute timeout is not enough when the URL has to be moved to
  another machine and a tunnel set up. Default to 15 minutes, overridable
  via PICOCLAW_OAUTH_TIMEOUT.

Adds regression tests for all four.

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

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


honbou seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants