Skip to content

feat(auth): add actual login --device for browserless sign-in#802

Open
benw5483 wants to merge 2 commits into
mainfrom
feat/login-device
Open

feat(auth): add actual login --device for browserless sign-in#802
benw5483 wants to merge 2 commits into
mainfrom
feat/login-device

Conversation

@benw5483

@benw5483 benw5483 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds actual login --device, the OAuth 2.0 device-authorization grant (RFC 8628), so a person at a shell with no local browser (remote dev, an SSH session) can sign in by approving a short code on another device. This is a browserless human-in-the-loop sign-in, not an unattended path — the device grant needs someone to approve the code, and CI has no approver (use actual auth create-token for unattended agents / CI). It builds on the existing actual login session handling rather than adding a second credential store.

  • Requests a device + user code from POST /api/oauth/device_authorization, then prints the verification URL and short code to stderr for the user to approve on any device (stdout stays clean for scripting).
  • Polls POST /api/oauth/token with grant_type=urn:ietf:params:oauth:grant-type:device_code, honoring the server's interval and backing off on slow_down, until the session is issued or the code is denied/expires.
  • On approval, resolves the identity via /whoami and persists the session through the existing encrypted credential store — the same one the browser flow uses, so logout, whoami, and silent refresh all work unchanged.
  • Requests the colon-form scopes adr:query adr:review mcp:invoke. Reuses the HTTPS-only / loopback transport guard and the --api-url (and ACTUAL_AUTH_URL / ACTUAL_OAUTH_*) overrides from the browser flow.
sequenceDiagram
    actor User
    participant CLI as actual login --device
    participant Auth as /api/oauth/device_authorization
    participant Token as /api/oauth/token
    participant Who as /whoami

    CLI->>Auth: POST client_id + scope
    Auth-->>CLI: device_code, user_code, verification_uri, interval, expires_in
    CLI-->>User: "Visit <uri>, enter <user_code>" (stderr)
    loop every interval (slow_down adds 5s) until approved or expired
        CLI->>Token: POST device_code grant
        Token-->>CLI: authorization_pending / slow_down / session
    end
    CLI->>Who: GET whoami (Bearer access_token)
    Who-->>CLI: organization_id, member_id
    CLI->>CLI: persist encrypted session
Loading

Draft / POC. The server-side device-authorization endpoints are proven end-to-end but are not yet on staging, so the full live round-trip stays deferred. The CLI is built against the documented request shape (the same one the reference demo client uses) and is repointed with --api-url for local testing against a mock or a local backend.

Security posture

The device flow handles a session credential, so the safety properties are worth stating explicitly:

Concern How it's handled
Token in transit HTTPS is enforced on every request; plain http:// is allowed only for loopback (localhost / 127.0.0.1 / [::1]) for local testing, reusing the browser flow's guard.
Token at rest The issued session is written through the existing encrypted credential store (0600), never to stdout or a log.
Token in output The verification prompt and success summary print only the URL, the short user code, and non-sensitive identity fields — never the access or refresh token.
Authorization binding The org / member / scope binding is decided on the server's approval screen and carried in the RS256 session; the client neither selects nor asserts it.

Authorization, consent, and scope enforcement live entirely on the server (out of scope here); the client only drives the public grant and stores the result.

Test plan

  • cargo fmt --check and cargo clippy -- -D warnings are clean.
  • Unit tests mock /api/oauth/device_authorization and /api/oauth/token and cover the full poll state machine: approval on the first poll, authorization_pending, slow_down backoff, access_denied, server expired_token, client-side poll-budget expiry, unknown / non-JSON error bodies, and the HTTPS guard. src/auth/oauth.rs stays at 100% line coverage.
  • Backward compatibility: actual login with no flag keeps the existing browser / loopback flow unchanged.
  • End-to-end run against staging — deferred until the device-code endpoints deploy there.

Follow-ups

  • Run the full device flow against staging once the server endpoints land, and capture the transcript.
  • The device grant drops offline_access and assumes the server returns a refresh token anyway; that is unverified against the live endpoint and should be confirmed (or offline_access restored) when the endpoints reach staging.
  • The commands that consume the session are unchanged and out of scope here.

Generated by the operator's software factory.
• On behalf of: @benw5483

benw5483 and others added 2 commits July 1, 2026 16:34
Add the OAuth 2.0 device-authorization grant (RFC 8628) as a `--device`
flag on `actual login`, so a terminal with no local browser (remote dev,
SSH, CI) can sign in by approving a short code on any other device.

The flow mirrors the existing browser login. It requests a device + user
code from `/api/oauth/device_authorization`, prints the verification URL
and code to stderr for the user to approve, then polls `/api/oauth/token`
with the device-code grant — honoring the server's interval and the
`slow_down` backoff — until the session is issued. It resolves the identity
via `/whoami` and persists the credentials through the existing encrypted
store, so `logout`, `whoami`, and silent refresh keep working unchanged. It
reuses the HTTPS/loopback transport guard and the `--api-url` / `ACTUAL_*`
overrides; the requested scopes are the colon-form `adr:query adr:review
mcp:invoke`.

Unit tests mock both endpoints and cover the full poll state machine
(pending, slow_down backoff, approval, denial, and both client- and
server-side expiry), keeping src/auth/oauth.rs at 100% line coverage. The
end-to-end run against the live server is deferred until the device-code
endpoints reach staging.

Generated by the operator's software factory.
On behalf of: @benw5483
Co-Authored-By: Actual AI Factory Bot <factory-bot@actual.ai.invalid>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The device-authorization grant (RFC 8628) is human-delegated — a person
must approve the code + URL on the approval page — so it is not an
unattended path. The `--device` help text listed "CI" as a use case;
correct it to a human signing in from a remote/SSH shell with no local
browser, and point unattended callers to `auth create-token` instead.

Also flag an unverified assumption: the device grant drops `offline_access`
and banks on the server returning a refresh token anyway. That has not been
confirmed against the live endpoint, so the scope comment now says so
rather than asserting it as fact.

Generated by the operator's software factory.
On behalf of: @benw5483
Co-Authored-By: Actual AI Factory Bot <factory-bot@actual.ai.invalid>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@benw5483 benw5483 force-pushed the feat/login-device branch from 55cbff8 to 89d4933 Compare July 6, 2026 14:24
@benw5483 benw5483 marked this pull request as ready for review July 6, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant