Switch Login Flow to Browser-Based Default · Entire
Plan: loopback authorization-code login (default) + --device fallback
Phase 0 — Confirm backend facts (blocking)
The entire-core prior art and the CLI talk to different hosts, so don't assume its paths transfer:
| Fact | entire-core (prior art) | CLI today | Need to confirm |
|---|---|---|---|
| client_id | entire-core-cli |
entire-cli |
Is entire-cli registered as a public client with a loopback redirect (http://127.0.0.1/callback, any port)? |
| Login host | entire-core directly | auth host (us.auth.entire.io, v2/OIDC) |
Does the auth host serve the authorize endpoint, or does login route through core like entire-core does? |
| Authorize path | /cli/login (interstitial → /authorize) |
n/a | Exact path on the CLI's auth host. OIDC discovery doc (/.well-known/openid-configuration) authorization_endpoint is the source of truth. |
| Token path | /oauth/token, grant_type=authorization_code |
/oauth/token (already used for device + refresh) |
Confirm authorization_code grant + PKCE accepted at the existing token path. |
RFC 9207 iss callback hint |
used for cross-region routing | split-host, multi-region | Does the AS return iss on the loopback redirect? If so, honor it (entire-core does). |
Action: pull the auth host's /.well-known/openid-configuration and verify the client registration. Everything below assumes: authorize endpoint on the auth host, PKCE S256, loopback any-port redirect allowed.
Phase 1 — New authcode package in auth-go
Files (mirror deviceflow/):
authcode/authcode.go— client + flowauthcode/testseams.go—SetNowForTestper-Clientatomic.Pointer clock (copy deviceflow's pattern verbatim)authcode/authcode_test.go— httptest-driven
Client struct (mirror deviceflow.Client):
Transport http.RoundTripper,BaseURL,ClientID,Scope,UserAgentAuthorizePath string,TokenPath stringRequestTimeout time.Duration,AllowInsecureHTTP bool,nowOverride atomic.Pointer[...]New(c *Client) (*Client, error)validating required fields.
Public API — recommend a decomposed shape so the CLI keeps control of browser-open and user messaging (the CLI already owns openBrowser/TTY logic):
// Step 1: generate PKCE+state, bind loopback listener, build authorize URL.
func (c *Client) StartCallbackServer(ctx context.Context) (*AuthSession, error)
type AuthSession struct {
AuthorizationURL string // open this in the browser
RedirectURI string // http://127.0.0.1:<port>/callback
// unexported: verifier, state, listener, resultCh
}
// Step 2: block until the browser hits the loopback callback (or ctx/timeout).
func (s *AuthSession) Wait(ctx context.Context) (code string, issuer string, err error)
// Step 3: exchange code+verifier at the token endpoint.
func (c *Client) Exchange(ctx context.Context, s *AuthSession, code string) (*tokens.TokenSet, error)
func (s *AuthSession) Close() error // shut down listener
Rationale: a one-shot Login(ctx, openFn) is tempting but couples the lib to browser-opening and progress output. Decomposed mirrors how deviceflow splits StartDeviceAuth/PollDeviceAuth and keeps the lib I/O-free except the loopback server. Return the RFC 9207 issuer from Wait so the CLI can route the exchange cross-region.
Port the entire-core internals (client/clilogin/loopback.go), adapting to auth-go conventions:
- PKCE:
newPKCEPair()— 48 random bytes → verifier,base64.RawURLEncoding(SHA256(verifier))→ challenge, methodS256. - State: 24 random bytes base64url.
- Listener:
net.ListenConfig{}.Listen(ctx, "tcp", "127.0.0.1:0"), callback path/callback, redirecthttp://127.0.0.1:<port>/callback. - Authorize URL:
response_type=code,client_id,redirect_uri,scope( addcli offline_access— entire-core omitted scope; the CLI needs the refresh token, matching the device-flow client),state,code_challenge,code_challenge_method=S256. - Callback handler: validate
state(400 on mismatch, don't kill flow), handleerrorparam, require non-emptycode, captureiss, write success HTML, signal buffered channel. - Success HTML constant ("You're signed in, close this tab").
- Exchange: POST
TokenPath,grant_type=authorization_code+code/redirect_uri/code_verifier/client_id. (loopback.go:278–346) — but parse into*tokens.TokenSetand reuseoauthhttphelpers instead of hand-rolling (entire-core hand-rolled because it predates auth-go). - Timeout:
context.WithTimeoutdefault ~5 min; gracefulShutdown.`
Reuse internal/oauthhttp (importable — same module):
HTTPClient(transport),ResolveURL(base, path, allowInsecure)for both authorize + token URLs (also gives the absolute-path/redirect-attack defense for free).ReadAndDecodeJSON(token response),ReadOAuthError+SanitizeDescription(error bodies),ExpiresInDuration(clampexpires_in→TokenSet.ExpiresAt).ValidateOriginURL/IsLoopbackHostfor the BaseURL/loopback enforcement (HTTPS-required unlessAllowInsecureHTTP+ loopback — identical posture to deviceflow).
Error sentinels (deviceflow style): ErrStateMismatch, ErrCallbackMissingCode, ErrAuthorizationDenied (maps error=access_denied), ErrListenerClosed, ErrTimeout.
Conventions: Go 1.26.4, errors.New sentinels, //nolint:wrapcheck on oauthhttp pass-throughs, parallel-safe per-Client clock, httptest servers with AllowInsecureHTTP=true. Add a doc.go blurb and a CHANGELOG.md``## Unreleased → ### Added entry. Also flip README's non-goals — it currently lists "PKCE / authorization code flow" as out of scope (README:203).
Release: tag auth-go (e.g. v0.5.0), since this is additive. The CLI consumes via a go.mod bump.
Phase 2 — Wire into the CLI's auth package
Provider config (
cmd/entire/cli/auth/provider.go): addAuthorizePathtoProvider; populate v1 (/oauth/authorize?) and v2 (from discovery doc, likely/authorize). Confirmed in Phase 0.auth.Client(cmd/entire/cli/auth/client.go): hold an*authcode.Clientalongside the existing*deviceflow.Client, built with the same issuer/scope/transport/AllowInsecureHTTPlogic (including theisLoopbackHTTP(issuer)auto-permit). Add shim methods:StartBrowserAuth(ctx) (*BrowserAuthSession, error)WaitForCallback(ctx, session) (code, issuer, error)ExchangeCode(ctx, session, code) (access, refresh string, error)— returns the same(access, refresh)pair shaperunLoginalready persists. Keep types aliased/wrapped sologin.gostays decoupled fromauth-godirectly, exactly likeDeviceAuthStart/DeviceAuthPolltoday.
Phase 3 — entire login command surface (cmd/entire/cli/login.go)
Add
var useDevice bool+cmd.Flags().BoolVar(&useDevice, "device", false, "Use device-code flow (verify a code in your browser) instead of the default browser redirect"). Keep it visible (unlike--insecure-http-auth).RunEbranches:useDevice→ existingrunLogin(device flow), unchanged.- else → new
runBrowserLogin.
- else → new
`runBrowserLogin(ctx, outW, errW, client, openURL):
session := client.StartBrowserAuth(ctx)(binds loopback, builds URL).- If
interactive.CanPromptInteractively(): print "Opening your browser to sign in…",openURL(ctx, session.AuthorizationURL); on open failure, print the URL for manual paste (the loopback server is already listening — same-host browser still works). code, issuer := client.WaitForCallback(ctx, session)with a spinner/"Waiting for sign-in…" message.access, refresh := client.ExchangeCode(ctx, session, code).- Reuse the existing tail of
runLoginverbatim:validateReceivedToken(useissuerif returned, elseclient.BaseURL()),store.SaveToken,auth.RecordLoginContext(access, refresh, true), "Login complete." Extract that tail into a sharedpersistLogin(...)helper so both flows share it.
- Headless / non-interactive auto-fallback (recommended): when
!interactive.CanPromptInteractively()(CI, SSH without/dev/tty, piped), the loopback browser flow can't work — auto-fall back to the device flow and print a one-line note ("No interactive terminal; using device-code flow"). This avoids forcing users to remember--deviceon remote boxes. Flag this as a UX decision; the alternative is to hard-error and tell them to pass--device. requireSecureBaseURL/--insecure-http-authunchanged — already covers the auth host the authorize+token calls hit.
Phase 4 — Tests
auth-go (authcode_test.go): httptest server implementing /authorize-style redirect + /oauth/token. Cover: happy path (PKCE verifier echoed → token), state mismatch rejected, error=access_denied param, missing code, token-endpoint OAuth error, timeout via canceled ctx, refresh token plumbed into TokenSet. Mirror deviceflow_test.go structure + SetNowForTest.
CLI unit (login_test.go): add a browserAuthClient interface + fake; test runBrowserLogin success, open-browser-fails fallback path, ctx-cancel, and validateReceivedToken still gates the issuer. Test the --device flag routes to runLogin. Test the headless auto-fallback decision.
CLI integration (integration_test/login_test.go): extend the mock server (currently device-only) with the authorize-redirect + authorization_code token branch. The test harness must (a) read the printed authorize URL, (b) extract redirect_uri, (c) GET the loopback callback with a valid code + matching state, (d) assert Login complete. This replaces the device-flow /approve POST with a loopback callback GET. Keep the device-flow integration tests too (now exercised via --device).
Vogon/E2E:entire login isn't in the agent E2E path, so no Vogon changes expected — verify no canary references the device prompt wording.
Phase 5 — Docs
CLAUDE.md/command-layout docs: noteloginnow defaults to browser redirect,--devicefor the old flow.- auth-go
CHANGELOG.md+ README non-goals (Phase 1). - Any
--helplong-text on the login command.
Key risks / footguns
- Backend path/registration mismatch (Phase 0) — the single thing that can invalidate the whole plan. Confirm before coding.
- Scope: entire-core's loopback omits
scope; you must sendcli offline_accessor you lose the refresh token and silent refresh breaks. - Don't hand-roll the token exchange like entire-core did — route it through
oauthhttpso error/HTML/expiry handling matches the rest of auth-go. - Cross-region
iss: the CLI is split-host/multi-region; if the AS returnsiss, the exchange must target it (entire-core does this). Ignoring it could break multi-region logins. - Loopback reachability: the callback hits
127.0.0.1on the CLI host — useless over plain SSH. The headless auto-fallback to device flow is what keeps remote logins working.