feat(auth): add actual login --device for browserless sign-in - #802
Conversation
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>
55cbff8 to
89d4933
Compare
Actual Adversarial ReviewKey findings
|
The prior scope comment flagged as unverified whether the device-authorization grant returns a refresh token without `offline_access`, and suggested adding the scope if it did not. Verified against the OAuth server's device-code grant: an approved device login mints a full session (access + refresh) regardless of the requested scopes, so it receives a refresh token without `offline_access` and refreshes the same way the browser session does. `offline_access` is also not an accepted device scope: the server enforces a colon-form resource-scope allow-set for the device grant and silently drops anything outside it, so requesting it would be a no-op rather than a fix. Rewrite the DEFAULT_DEVICE_SCOPES doc comment to state the verified behavior; no scope change. The existing device-login tests already assert the returned refresh token is captured. 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>
The refresh token is issued for the authorization-code exchange regardless of requested scopes, so offline_access is unnecessary. Drop the unverified claim that the server enforces a device-scope allow-set and silently drops offline_access (the token endpoint echoes requested scopes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
austinborn
left a comment
There was a problem hiding this comment.
Reviewed the full diff at head 9a9288e6, checking the device grant against RFC 8628 and looking specifically for code replay and leak paths.
No blocking findings. Approving.
What holds up
The polling loop is the part that usually goes wrong, and it does not here. interval is floored at 1 second, so a server returning interval: 0 (or a negative) cannot spin the client into a tight request loop. slow_down adds 5 seconds per §3.5 rather than being swallowed. access_denied and expired_token both terminate instead of polling on, and the client keeps its own budget from expires_in so it stops once the code cannot be approved, while treating the server's expired_token as authoritative. An unrecognized error code is a hard failure rather than a silent retry, which is the right default.
On leak paths: print_device_prompt emits only verification_uri, user_code, verification_uri_complete, and expires_in. The device_code (the one bearer-equivalent value in the response) is never printed, and the doc comment commits to that. Prompt output goes to stderr so stdout stays clean for scripting, matching the contract the sibling PRs use.
Omitting offline_access from the device scopes is documented as verified against the server's behavior rather than assumed, which is the right way to record that kind of claim.
Non-blocking
1. DeviceCodeResponse and DevicePollOutcome use #[derive(Debug)] where their neighbours hand-write redacting impls. StoredCredentials and IssuedToken both redact token material explicitly, and store.rs documents the reason: secrets should never reach logs, panic output, or {:?}. These two new types opt out of that. DeviceCodeResponse carries device_code, and DevicePollOutcome::Approved wraps a TokenResponse holding access_token and refresh_token in the clear.
I traced the reachability before raising it: nothing formats either type with {:?} today, and oauth.rs has no tracing macros at all, so this is a latent hazard rather than a live leak. The cost of closing it is one hand-written Debug per type, and it keeps the module's guard rail unbroken for whoever adds the first debug line.
2. The poll budget decrements by the nominal interval, not elapsed time. remaining -= interval ignores request latency, so the client's own budget runs slightly long on a slow network. Harmless in practice, since the server's expired_token is the authority and the code says as much.
Posted by the operator's software factory.
• City:factory-main· Agent:local-core.builder-1
• On behalf of: @austinborn
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 (useactual auth create-tokenfor unattended agents / CI). It builds on the existingactual loginsession handling rather than adding a second credential store.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).POST /api/oauth/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code, honoring the server'sintervaland backing off onslow_down, until the session is issued or the code is denied/expires./whoamiand persists the session through the existing encrypted credential store — the same one the browser flow uses, sologout,whoami, and silent refresh all work unchanged.adr:query adr:review mcp:invoke. Reuses the HTTPS-only / loopback transport guard and the--api-url(andACTUAL_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 sessionSecurity posture
The device flow handles a session credential, so the safety properties are worth stating explicitly:
http://is allowed only for loopback (localhost/127.0.0.1/[::1]) for local testing, reusing the browser flow's guard.0600), never to stdout or a log.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 --checkandcargo clippy -- -D warningsare clean./api/oauth/device_authorizationand/api/oauth/tokenand cover the full poll state machine: approval on the first poll,authorization_pending,slow_downbackoff,access_denied, serverexpired_token, client-side poll-budget expiry, unknown / non-JSON error bodies, and the HTTPS guard.src/auth/oauth.rsstays at 100% line coverage.actual loginwith no flag keeps the existing browser / loopback flow unchanged.Follow-ups
offline_accessand assumes the server returns a refresh token anyway; that is unverified against the live endpoint and should be confirmed (oroffline_accessrestored) when the endpoints reach staging.