# 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 + flow
- `authcode/testseams.go` — `SetNowForTest` per-`Client` atomic.Pointer clock (copy deviceflow's pattern verbatim)
- `authcode/authcode_test.go` — httptest-driven

**`Client` struct** (mirror `deviceflow.Client`):

- `Transport http.RoundTripper`, `BaseURL`, `ClientID`, `Scope`, `UserAgent`
- `AuthorizePath string`, `TokenPath string`
- `RequestTimeout 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):

```go
// 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, method `S256`. 
- State: 24 random bytes base64url. 
- Listener: `net.ListenConfig{}.Listen(ctx, "tcp", "127.0.0.1:0")`, callback path `/callback`, redirect `http://127.0.0.1:<port>/callback`. 
- Authorize URL: `response_type=code`, `client_id`, `redirect_uri`, `scope` ( **add `cli 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), handle `error` param, require non-empty `code`, capture `iss`, 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.TokenSet` and reuse `oauthhttp` helpers instead of hand-rolling (entire-core hand-rolled because it predates auth-go). 
- Timeout: `context.WithTimeout` default ~5 min; graceful `Shutdown`.`

**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` (clamp `expires_in` → `TokenSet.ExpiresAt`).
- `ValidateOriginURL`/`IsLoopbackHost` for the BaseURL/loopback enforcement (HTTPS-required unless `AllowInsecureHTTP` + 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`): add `AuthorizePath` to `Provider`; 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.Client` alongside the existing `*deviceflow.Client`, built with the same issuer/scope/transport/`AllowInsecureHTTP` logic (including the `isLoopbackHTTP(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 shape `runLogin` already persists. Keep types aliased/wrapped so `login.go` stays decoupled from `auth-go` directly, exactly like `DeviceAuthStart`/`DeviceAuthPoll` today.

## 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`).
- `RunE`branches:

- `useDevice` → existing `runLogin` (device flow), unchanged.
  - else → new `runBrowserLogin`.
- **`runBrowserLogin(ctx, outW, errW, client, openURL):**

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.
- **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 `--device` on remote boxes. Flag this as a UX decision; the alternative is to hard-error and tell them to pass `--device`.
- `requireSecureBaseURL` / `--insecure-http-auth` unchanged — 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: note `login` now defaults to browser redirect, `--device` for the old flow.
- auth-go `CHANGELOG.md` + README non-goals (Phase 1).
- Any `--help` long-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 send `cli offline_access` or you lose the refresh token and silent refresh breaks.
- **Don't hand-roll the token exchange** like entire-core did — route it through `oauthhttp` so error/HTML/expiry handling matches the rest of auth-go.
- **Cross-region `iss`:** the CLI is split-host/multi-region; if the AS returns `iss`, the exchange must target it (entire-core does this). Ignoring it could break multi-region logins.
- **Loopback reachability:** the callback hits `127.0.0.1` on the CLI host — useless over plain SSH. The headless auto-fallback to device flow is what keeps remote logins working.
