Login Flow Improvements and Fallback Handling

Review: PR #1366 — login: default to browser sign-in, add --device fallback
Verdict: Approve. Solid PR — clean structure, good test coverage at both unit and integration levels, careful comments, and the security fundamentals (PKCE S256, loopback-only listener, URL scheme validation, token validation before persist) are all in place. Unit tests pass locally. Two real-world UX gaps worth considering before or shortly after merge.
What it does
entire login now defaults to the RFC 8252 loopback authorization-code flow with PKCE (via auth-go v0.5.0's authcode package) when an interactive terminal is present, falling back to the device-code flow otherwise. --device forces the old flow. Both flows converge on a shared persistLogin tail (validate → keyring → contexts.json).
Strengths
- Good factoring. Extracting
persistLogininstead of duplicating the validate/save/record tail, and thebrowserAuthFlowinterface mirroring the existingdeviceAuthClientpattern, keepslogin.goconsistent with itself.shouldUseBrowserLoginas a pure function with a table test is the right shape. - Test design. The integration test's trick —
openBrowserreports failure under test so the fallback URL lands on stdout, then the test plays the browser against the loopback callback — exercises the real listener, real PKCE (code_verifierasserted at the token endpoint), and real state round-trip without spawning anything. Theinteractive.UnderTest()guards inwaitForEnterandopenBrowserfollow the documented project pattern. - Housekeeping done. The "before merge" item (re-pin from the auth-go branch pseudo-version to tagged v0.5.0) is already done in
go.mod/go.sum. Error wrapping preserves sentinels forerrors.Is(verified byTestRunBrowserLogin_WaitError), and the//nolint:wrapcheckcomments explain why.
Issues worth considering
-
SSH with a TTY gets a broken default (
cmd/entire/cli/login.go:79).CanPromptInteractively()is true overssh -t, so the browser flow is chosen — but the loopback listener binds 127.0.0.1 on the remote host, which the user's local browser can't reach. Even the printed fallback URL can't complete. The user's only recourse is Ctrl-C and--device.ghandgclouddetect this (e.g.SSH_CONNECTION/SSH_TTYenv vars) and route to the code flow. Suggest adding an SSH check toshouldUseBrowserLogin, or at minimum mentioning--devicein the "Waiting for sign-in..." context so a stuck user knows the escape hatch. -
Waithas no deadline (login.go:184). The device flow bounds waiting viaexpires_in(capped at 15 min); the browser flow waits on the command context, which has no timeout — if the user closes the browser tab without completing,entire loginhangs until Ctrl-C. Acontext.WithTimeoutaroundflow.Wait(5–15 min) would match the device flow's behavior and most peer CLIs.
Minor / non-blocking
- No fallback when the listener can't start (
login.go:80-83). IfStartBrowserAuthfails (port bind, firewall), the command errors out rather than falling back to the device flow. Falling back here would be friendlier and costs little. TestLogin_BrowserFlow_SavesTokendoesn't assert the saved token — it checks "Login complete." only. This matches the existingTestLogin_SavesTokenAfterApprovalconvention exactly, so it's fine as-is, but both names slightly overpromise; asserting theENTIRE_TEST_AUTH_STORE_FILEcontents would make them honest. Could be a follow-up touching both.waitForBrowserPrompt's 10s deadline only fires between reads (aReadStringblock ignores it) — but this mirrors the pre-existingwaitForLoginPrompt, and the pipe closes when the process exits, so it's not a new hazard.
Security notes
All good: PKCE with S256 (verified against prod discovery doc per the PR body), state round-tripped and validated by the authcode lib, openBrowser still rejects non-HTTP(S) URLs before the test guard, token iss/exp cross-checked before persisting, and the loopback redirect was verified registered server-side for any-port 127.0.0.1/callback. The full authorize URL (with PKCE challenge) deliberately isn't printed on the happy path — nice touch.
can you do a branch on top of the current branch, and then fix 1 + 2 (I even wonder if 5mins is to long for the browser flow, but just stick to it.) and then do the first nit too, can you commit meaningful
All done. Created branch login-browser-flow-hardening on top of login-loopback-browser-flow with three commits, one per fix:
-
86c9baa83— login: time out the browser sign-in wait after 5 minutes (review issue 2).runBrowserLoginnow takes awaitTimeoutand wraps the loopback wait in acontext.WithTimeout(5 min, per your call). The clock starts after the Enter prompt so reading time isn't counted, the timeout error points atentire login --deviceas the escape hatch, and Ctrl-C is still reported as cancellation, not a timeout (there's a test pinning that distinction). -
4f8ab5e5d— login: fall back to the device flow in SSH sessions (review issue 1). NewisSSHSession()checks theSSH_CONNECTION/SSH_CLIENT/SSH_TTYvars sshd sets, andshouldUseBrowserLoginnow requires not-SSH. Users over SSH get a one-line stderr explanation ("SSH session detected; using device-code flow…") instead of a loopback URL their browser can't reach. This commit also extracts the flow choice from the cobraRunEinto a testablerunLoginAuto(...)that takes astartBrowserfunc and the environment facts as plain bools — the SSH/headless/--devicemessage paths each have a unit test. One subtlety: the browser-flow integration test inheritsos.Environ(), so it now blanks theSSH_*vars — otherwise running the test suite over SSH would silently flip it to the device flow. -
166393942— login: fall back to the device flow when the loopback listener fails (the first nit). IfStartBrowserAuthcan't bind the listener, login warns on stderr with the bind error and continues with the device flow instead of erroring out.
Verification: mise run fmt + mise run lint pass, all new and existing login unit tests pass, the login integration tests (including the browser flow) pass, and a full mise run check exits 0. One caveat: the very first mise run check run had a single test failure somewhere outside the login code that I couldn't attribute (the output scrolled past), and two subsequent full test:ci runs were completely green — so it looks like an unrelated flake, but worth knowing it happened.
The branch is local only — say the word if you want it pushed / a PR opened against login-loopback-browser-flow.
/simplify → 4 cleanup agents in parallel → apply the fixes
You are improving the quality of the changed code, not hunting for bugs. Review
it for reuse, simplification, efficiency, and altitude issues, then fix what you
find. Do not look for correctness bugs — that is what /code-review is for.
Phase 0 — Gather the diff
Run git diff @{upstream}...HEAD (or git diff main...HEAD / git diff HEAD~1
if there's no upstream) to get the unified diff under review. If there are
uncommitted changes, or the range diff is empty, also run git diff HEAD and
include the working-tree changes in scope — the review often runs before the
commit. If a PR number, branch name, or file path was passed as an argument,
review that target instead. Treat this diff as the review scope.
Phase 1 — Review (4 cleanup agents in parallel)
Launch 4 independent review agents via the Agent tool, all in a
single message so they run concurrently. Pass each agent the diff and one of
the four angles below. Each returns its findings with file, line, a
one-line summary, and the concrete cost (what is duplicated, wasted, or
harder to maintain).
Reuse
Flag new code that re-implements something the codebase already has — Grep shared/utility modules and files adjacent to the change, and name the existing helper to call instead.
Simplification
Flag unnecessary complexity the diff adds: redundant or derivable state, copy-paste with slight variation, deep nesting, dead code left behind. Name the simpler form that does the same job.
Efficiency
Flag wasted work the diff introduces: redundant computation or repeated I/O, independent operations run sequentially, blocking work added to startup or hot paths. Also flag long-lived objects built from closures or captured environments — they keep the entire enclosing scope alive for the object's lifetime (a memory leak when that scope holds large values); prefer a class/struct that copies only the fields it needs. Name the cheaper alternative.
Altitude
Check that each change is implemented at the right depth, not as a fragile bandaid. Special cases layered on shared infrastructure are a sign the fix isn't deep enough — prefer generalizing the underlying mechanism over adding special cases.
Phase 2 — Apply the fixes
Wait for all four agents to complete, dedup findings that point at the same line or mechanism, and fix each remaining one directly. Skip any finding whose fix would change intended behavior, require changes well outside the reviewed diff, or that you judge to be a false positive — note the skip rather than arguing with it. Finish with a brief summary of what was fixed and what was skipped (or confirm the code was already clean).
Simplify pass complete — committed as 334df9885 on login-browser-flow-hardening. Full mise run check passes (exit 0).
Applied (4 findings, deduped from 4 agents):
- Bool plumbing → struct (simplification):
runLoginAuto's three trailing bools became aloginFlowFactsstruct with field comments, andshouldUseBrowserLogintakes it too. Call sites in tests went fromfalse /* useDevice */, true /* canPrompt */, false /* ssh */tologinFlowFacts{canPrompt: true}. - Redundant
/dev/ttyprobe (efficiency): my refactor had leftrunLogincallinginteractive.CanPromptInteractively()again on the device path, afterRunEhad already probed once.runLoginnow takescanPromptas a parameter. - SSH blanking centralized (altitude): blanking
SSH_CONNECTION/SSH_CLIENT/SSH_TTYmoved from the one browser-flow test intostartLoginProcess, so every future login subprocess test is SSH-safe by default instead of relying on a per-test ritual — same idea asGitIsolatedEnv.extraEnvstill appends last, so a test could set them deliberately. - Test boilerplate (simplification): the
startBrowseradapter closure shrank from 8 lines to a directreturn client.StartBrowserAuth(ctx)(the typed-nil interface on error is harmless since callers checkerrfirst), and seven inlinenoopOpenlambdas collapsed into one sharednoopOpenURLhelper.
Skipped (3 findings):
- Move
isSSHSessioninto theinteractivepackage (reuse agent) — the altitude agent independently argued the opposite, and I agree with it:interactiveanswers "can we prompt?", which SSH doesn't affect (SSH sessions prompt fine). SSH-ness is a browser-reachability fact specific to login; nothing else in the repo needs it. - Switch → if/else in
runLoginAuto— the switch with one case per fallback reason (including the documented empty--devicecase) reads as a priority list; an if/else chain repeating!useDevice &&would be no simpler. startBrowserStubreturning a struct with a calls-getter instead of taking*int— more machinery, not less, for a five-line test stub.