# opencode console login uses RFC 8628 device-code polling, not a localhost callback
*anomalyco/opencode@dev · informative · 6 markers*

> **Who talks to whom.** The `opencode` CLI on your machine is the only thing that polls; it polls the **opencode console server** — the `[url]` you pass to `opencode console login`, normalized by `normalizeServerUrl`. The server never calls back.
>
> **What the CLI does.** It asks the server for a `device_code`/`user_code` at `/auth/device/code`, prints the user code, then repeatedly POSTs the `device_code` to `/auth/device/token` on the server-supplied `interval` until the server answers `success` (or `denied`/`expired`).
>
> **Why it matters.** Every request is outbound CLI → server, so there is no `redirect_uri` to `127.0.0.1` and the flow works unchanged over **SSH**: open the printed URL+code on any device while the headless CLI keeps polling.

**Author:** Fernando Ramirez · **Updated:** 2026-06-02T18:46:29.749Z

## Markers
1. **CLI starts login, prints code** — `packages/opencode/src/cli/cmd/account.ts:41–52`
   This is the `opencode` CLI running on your machine. `loginEffect` calls `service.login(url)` against the console server, then prints `login.url` and `login.user` (the user code) and calls `openBrowser`. Opening the browser is only a convenience — the URL and code are printed first, so on a headless/SSH box you open them on any other device.
2. **CLI → server: POST /auth/device/code** — `packages/opencode/src/account/account.ts:373–393`
   `Account.login` is the CLI's HTTP layer. It POSTs `{ client_id }` to `${normalizedServer}/auth/device/code` — `normalizedServer` is the console server URL you passed to `opencode console login` — and decodes a `DeviceAuth` with `device_code`, `user_code`, `expires_in`, and `interval`. The server, not any local listener, mints the pending authorization the CLI will poll on.
3. **CLI loop: when to ask again** — `packages/opencode/src/cli/cmd/account.ts:54–66`
   This recursive `poll` is the CLI's clock. It sleeps for `wait`, calls `service.poll(login)` (one request to the server), and recurses on `PollPending`. On `PollSlow` it adds 5s to `wait`. The whole loop is wrapped in `Effect.timeout(login.expiry)`, which yields `PollExpired` once the device code's lifetime runs out. The CLI decides the cadence; the server only answers.
4. **CLI → server: POST /auth/device/token** — `packages/opencode/src/account/account.ts:395–413`
   Each tick of the loop lands here. `Account.poll` POSTs `grant_type: urn:ietf:params:oauth:grant-type:device_code` plus the `device_code` to `${input.server}/auth/device/token`. The server answers with a `DeviceToken` union — either a `DeviceTokenSuccess` carrying tokens, or a `DeviceTokenError` the loop interprets. This is the request that repeats until the user authorizes.
5. **RFC 8628 error strings → PollResult tags** — `packages/opencode/src/account/account.ts:105–111`
   `DeviceTokenError.toPollResult` maps the standard device-flow errors to typed results: `authorization_pending` → `PollPending` (keep polling), `slow_down` → `PollSlow` (back off), `expired_token` → `PollExpired`, `access_denied` → `PollDenied`. This is the contract that lets the CLI loop know whether to retry, wait longer, or stop.
6. **On success: fetch user/orgs, persist tokens** — `packages/opencode/src/account/account.ts:424–438`
   Once `/auth/device/token` returns tokens, `Account.poll` fetches the user and orgs, computes `expiry`, and calls `repo.persistAccount` with the access/refresh tokens before returning `PollSuccess`. The CLI's `poll` recursion ends here and reports `Logged in as <email>`.

---
Interactive view: https://app.principal-ade.com/trail/dc577428-6eb3-4180-9002-6494a2ef2c7b
JSON: https://app.principal-ade.com/api/trails/by-id/dc577428-6eb3-4180-9002-6494a2ef2c7b
Authored at `8f2afba7a` (dev).
To open a trail or tour locally in the interactive viewer, see https://app.principal-ade.com for the Principal CLI quickstart.
