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/):

Client struct (mirror deviceflow.Client):

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:

Reuse internal/oauthhttp (importable — same module):

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

Phase 3 — entire login command surface (cmd/entire/cli/login.go)

  1. session := client.StartBrowserAuth(ctx) (binds loopback, builds URL).
  2. 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).
  3. code, issuer := client.WaitForCallback(ctx, session) with a spinner/"Waiting for sign-in…" message.
  4. access, refresh := client.ExchangeCode(ctx, session, code).
  5. Reuse the existing tail of runLogin verbatim: validateReceivedToken (use issuer if returned, else client.BaseURL()), store.SaveToken, auth.RecordLoginContext(access, refresh, true), "Login complete." Extract that tail into a shared persistLogin(...) helper so both flows share it.

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


Key risks / footguns