Skip to content

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

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

feat(auth): add actual login --device for browserless sign-in#802
benw5483 wants to merge 4 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
@davidmiuraactualai

Copy link
Copy Markdown

Actual Adversarial Review

Key findings

  • ⚠️ APR-001 P2 Preserve refresh capability for a successful device login — the default device scope intentionally omits offline_access at oauth.rs:31, although the existing session contract requires a refresh token.

⚠️ APR-001 P2 Preserve refresh capability for a successful device login

Location: src/auth/oauth.rs:31 at 89d49335e0ecda9dbfd1d5e23de992ae6244419a

Risk: A user can complete device approval successfully yet receive a session that becomes unusable at access-token expiry and requires another interactive login.

Evidence and required correction
  • Code: the browser login default explicitly says offline_access is required for a refresh token, while DEFAULT_DEVICE_SCOPES drops it and labels the resulting server behavior unverified. credentials_from converts an omitted refresh_token to an empty string at line 271, and the shared refresh path treats that state as NotLoggedIn at line 375.
  • Public: OpenID Connect Core, Offline Access defines offline_access as the scope value used to request a Refresh Token. The device grant protocol does not guarantee that a server will issue one without its configured grant/consent conditions; see RFC 8628.
  • Tests: device-success fixtures always include refresh_token, so they cannot distinguish the production-compatible response that omits it. The existing test_refresh_empty_token_is_not_logged_in proves the failure that follows.

Required correction: Make the default device-login request preserve the normal login refresh-token contract, or explicitly reject a successful device grant that omits a refresh token with an actionable message before persisting a non-durable session. Add a regression for an approval response without refresh_token.

🤖 Corrective action — ⚠️ APR-001 P2 — Option A: request the established offline session scope

Tradeoff: This keeps actual login --device behavior aligned with the browser flow; it requires the server to support the already-established offline_access scope for the device grant.

Fix APR-001 P2, “Preserve refresh capability for a successful device login,” in actual-software/actual-cli at src/auth/oauth.rs:31 on 89d49335e0ecda9dbfd1d5e23de992ae6244419a.

Problem: DEFAULT_DEVICE_SCOPES drops offline_access even though the normal login scope uses it to obtain a refresh token. If the authorization server follows that contract and omits refresh_token, credentials_from persists an empty refresh token and the existing refresh path later returns NotLoggedIn after access-token expiry.

Required invariant: a successful actual login --device creates a session with the same refresh capability as normal actual login, or fails immediately and clearly instead of persisting a silently short-lived session.

Constraints: Follow AGENTS.md, src/AGENTS.md, and the repository secure-secrets and comprehensive-unit-testing ADRs; honor .gitignore. Preserve RFC 8628 polling behavior, the existing browser-login defaults, server-owned consent and scope enforcement, and compatibility for ACTUAL_OAUTH_SCOPES overrides.

Implement: include offline_access in the default device scope set if the server supports it. Also validate the successful device token response contains a refresh token before persisting credentials, with an actionable error if it does not.

Verify: add tests that the default device request includes offline_access and that an approved response lacking refresh_token cannot produce persisted usable credentials. Run cargo test device_login --lib and cargo test refresh_empty_token --lib.

Do not: silently accept an empty refresh token as a durable login, broaden unrelated scopes, alter server-side authorization, or print token material.

Copy into a coding agent to run.

Architecture intent

No architecture-intent decision is needed for preserving the established session-lifetime contract.


Review metadata

Important

One P2 finding remains unresolved at head 89d49335e0ecda9dbfd1d5e23de992ae6244419a. This is the canonical remediation record for the PR.

Compared: upstream main at cfcae4cd84645417e7d8042517a69cbd55d22404 … PR head 89d49335e0ecda9dbfd1d5e23de992ae6244419a
CI: Build for x86_64 and aarch64, Test, Lint, Coverage Enforcement, and CodeQL pass; Live E2E is skipped. No live device-grant endpoint verification is present, and the PR description explicitly marks the refresh-token assumption unverified.

benw5483 and others added 2 commits July 20, 2026 14:08
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>
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.

2 participants