Skip to content

feat: headless device-code login for servers and Docker (#96)#99

Merged
dubadub merged 11 commits into
mainfrom
feat/headless-device-login
Jun 8, 2026
Merged

feat: headless device-code login for servers and Docker (#96)#99
dubadub merged 11 commits into
mainfrom
feat/headless-device-login

Conversation

@dubadub

@dubadub dubadub commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds OAuth 2.0 device-authorization-grant login (RFC 8628) so users can authenticate cook-sync on a headless server or in Docker, where the existing browser/loopback flow can't run. Ported from cookcli's implementation; the cook.md server already exposes the endpoints (POST /oauth/device/code, POST /oauth/device/token).

  • New src/auth/device_flow.rs: device-code request, RFC 8628 token-polling state machine (pure, unit-tested interpreter + networked loop with per-request timeouts), client_name/server_host_label helpers.
  • New AuthManager::device_login(): prints a verification URL + user code, best-effort opens the browser, polls with a progress indicator and Ctrl-C cancellation, then converges on the existing set_session().
  • cook-sync login gains --headless. Flow selection: --headless always uses device flow; otherwise it auto-detects (Docker, or Linux with no DISPLAY/WAYLAND_DISPLAY); else the browser flow runs unchanged. macOS/Windows only auto-trigger via the flag.

Closes #96.

Notes / out of scope

  • Token storage already works headless: Linux uses a file-based session store (not Secret Service).
  • cook-sync start (the daemon) still requires a system tray and won't run on a truly headless host — headless login is delivered here; headless daemon operation should be a separate follow-up issue.

Test Plan

  • cargo test — 102 + 12 pass (10 device-flow interpreter tests, 6 flow-selection matrix tests)
  • cargo clippy --all-targets -- -D warnings -A dead_code — clean
  • cargo fmt --check — clean
  • cook-sync login --help shows --headless
  • Manual: cook-sync login --headless on a server → enter code at the URL → session saved and sync works

@dubadub

dubadub commented Jun 6, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Jun 6, 2026

Copy link
Copy Markdown

Claude finished @dubadub's task in 2m 26s —— View job


Code Review

  • Gather context and understand the request
  • Read src/auth/device_flow.rs
  • Read src/auth/device_flow_test.rs
  • Read src/auth/mod.rs changes
  • Read src/main.rs changes
  • Read spec doc
  • Analyze and post review feedback

Overall this is a clean RFC 8628 implementation. The separation of the pure interpret_token_response interpreter from the networked poll_for_token loop is good design, and the prefer_device_flow_from testability seam mirrors the repo's existing patterns well. Below are the issues I found, ordered by severity.

Robustness

1. Unbounded interval growth on slow_downsrc/auth/device_flow.rs:156

PollOutcome::SlowDown => interval += Duration::from_secs(5),

RFC 8628 says to increase the interval by 5s per slow_down, but doesn't cap it. If a server sends repeated slow_down responses (buggy server or during heavy load), the polling interval could grow to minutes before the code expires, leaving the user staring at dots. A ceiling of, say, 30s is standard practice:

PollOutcome::SlowDown => interval = (interval + Duration::from_secs(5)).min(Duration::from_secs(30)),

Fix this →

2. Zero-interval guard — src/auth/device_flow.rs:137

If the server returns interval: 0, tokio::time::sleep(Duration::from_secs(0)) yields immediately, creating a tight loop for the full expires_in window. Legitimate servers won't do this, but it's inexpensive to guard:

let actual_interval = interval.max(Duration::from_secs(5));
_ = tokio::time::sleep(actual_interval) => {}

Fix this →


Design / duplication

3. /.dockerenv check duplicated in two places

prefer_device_flow() in main.rs:480 and server_host_label() in device_flow.rs:186 both independently call std::path::Path::new("/.dockerenv").exists(). If the detection heuristic ever changes (e.g., checking DOCKER_* env vars as a fallback), it needs updating in two places. One of them should call through to the other, or extract a shared is_running_in_docker() function.

4. Fresh reqwest::Client bypasses CookApi configuration — src/auth/mod.rs:291

let client = reqwest::Client::new();

CookApi has its own client (presumably configured at construction). A fresh client with no custom TLS settings, proxy config, or user-agent loses those. Since the device endpoints are at the site root (not under /api), using a separate client may be intentional, but the inconsistency is worth documenting or, ideally, exposing CookApi::client() / CookApi::base_site_url() and threading it through.


Tests

5. Missing test: --headless flag on non-Linux — src/main.rs:1025

The test headless_flag_always_prefers_device_flow is named "always" but only covers is_linux = true. The flag should also force device flow on macOS/Windows (is_linux = false, has_display = true):

#[test]
fn headless_flag_works_on_non_linux() {
    assert!(prefer_device_flow_from(true, false, false, true));
}

Fix this →


Minor / nits

6. Unreachable None branch — src/auth/mod.rs:359

match self.get_session() {
    Some(session) => println!("Logged in as {}", session.email.unwrap_or(session.user_id)),
    None => println!("Logged in"),   // logically unreachable: set_session just succeeded
}

set_session stores the session before returning; get_session immediately after will always return Some. The None arm is dead code. Just print the success message unconditionally, or call set_session with a ? and then directly display the known email from the SecureSession it just built.

7. Redundant dot_handle.abort()src/auth/mod.rs:353

cancel.cancel() is called one line earlier (line 352), which causes the dot task to exit via its cancel.cancelled() select arm. The subsequent abort() is harmless but redundant.


Summary

The RFC 8628 state machine is correct, the test coverage is solid for the interpreter and flow-selection matrix, and the Ctrl+C cancellation is idiomatic. The items worth addressing before merge are #1 (unbounded slow_down interval), #3 (duplicated Docker detection), and #5 (incomplete test coverage for the headless flag on non-Linux). The rest are low-priority polish.

@dubadub dubadub merged commit 372b03c into main Jun 8, 2026
2 checks passed
@dubadub dubadub deleted the feat/headless-device-login branch June 8, 2026 09:53
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.

Headless login for use on a server

1 participant