auth: durable cross-device Fjord sign-in via OAuth device flow (--device) #226

Merged
stephen merged 2 commits from feat/oidc-device-flow into main 2026-07-21 21:01:53 +00:00
Owner

Implements the OAuth 2.0 Device Authorization Grant client (RFC 8628) so a user whose browser is on a different device than fj (an iPad while fj runs on a Mac/server, or fully headless) can sign in to a Fjord Account. Consumes the merged Paragon OP side (paragon #863), re-exposed by fjord-platform at /oauth/device_authorization and /oauth/token. This is the durable replacement for the --manual paste interim for the cross-device case.

What the user sees

fj auth login --fjord --device:

→ Sign in to https://fjord.sh from any device (device flow)

  To sign in, open https://fjord.sh/device
  on any device (phone, iPad, laptop) and enter code: WXYZ-ABCD

  Or open this link directly (it fills in the code for you):
  https://fjord.sh/device?user_code=WXYZ-ABCD

  Scan to open on a phone or iPad:
  <scannable terminal QR of verification_uri_complete>

  Waiting for you to approve the sign-in… (the code expires in about 10 min)

The QR is optional-friendly: it degrades to the plain URL if rendering fails (render_qr returns None).

Flow

  • POST device_authorization (form client_id=fj-cli, optional scope), parse the device_code / user_code / verification URIs / expires_in / interval.
  • Poll /oauth/token (grant_type=urn:ietf:params:oauth:grant-type:device_code, client_id, device_code) every interval seconds, honoring authorization_pending (keep polling), slow_down (+5s), access_denied / expired_token / invalid_grant (abort with a clear message), and the expires_in deadline. A non-OAuth failure (proxy 502, HTML body) aborts rather than hot-looping.
  • On 200, persist through the existing store_tokens path, so the session is identical to the loopback one: same keychain slots (bearer + identity-bound refresh), silent refresh available, and fj auth status shows the Fjord Account. Public client, no secret, no PKCE (the device code binds the exchange).

Auto-fallback

Device flow is auto-selected when no local browser is reachable (a $SSH_CONNECTION / $SSH_TTY session, or a Unix session with no DISPLAY/WAYLAND_DISPLAY); loopback stays the desktop default. FJ_FORCE_LOOPBACK=1 opts back out. Explicit --device / --manual always win (they conflict at the CLI layer). The intro line explains the auto-choice and how to override.

Tests

cargo fmt + clippy -D warnings + full suite green (678 passing). New wiremock coverage for both endpoints: device_authorization parse, each poll outcome (pending / slow_down / access_denied / expired_token / invalid_grant / approved), a non-OAuth failure, and the loop's happy path, slow_down back-off, denied/expired aborts, and client-side deadline timeout. Pure-unit tests for choose_oidc_capture precedence and QR rendering.

Implements the OAuth 2.0 Device Authorization Grant client (RFC 8628) so a user whose browser is on a different device than `fj` (an iPad while `fj` runs on a Mac/server, or fully headless) can sign in to a Fjord Account. Consumes the merged Paragon OP side (paragon #863), re-exposed by fjord-platform at `/oauth/device_authorization` and `/oauth/token`. This is the durable replacement for the `--manual` paste interim for the cross-device case. ## What the user sees `fj auth login --fjord --device`: ``` → Sign in to https://fjord.sh from any device (device flow) To sign in, open https://fjord.sh/device on any device (phone, iPad, laptop) and enter code: WXYZ-ABCD Or open this link directly (it fills in the code for you): https://fjord.sh/device?user_code=WXYZ-ABCD Scan to open on a phone or iPad: <scannable terminal QR of verification_uri_complete> Waiting for you to approve the sign-in… (the code expires in about 10 min) ``` The QR is optional-friendly: it degrades to the plain URL if rendering fails (`render_qr` returns `None`). ## Flow - POST `device_authorization` (form `client_id=fj-cli`, optional `scope`), parse the `device_code` / `user_code` / verification URIs / `expires_in` / `interval`. - Poll `/oauth/token` (`grant_type=urn:ietf:params:oauth:grant-type:device_code`, `client_id`, `device_code`) every `interval` seconds, honoring `authorization_pending` (keep polling), `slow_down` (+5s), `access_denied` / `expired_token` / `invalid_grant` (abort with a clear message), and the `expires_in` deadline. A non-OAuth failure (proxy 502, HTML body) aborts rather than hot-looping. - On `200`, persist through the existing `store_tokens` path, so the session is **identical** to the loopback one: same keychain slots (bearer + identity-bound refresh), silent refresh available, and `fj auth status` shows the Fjord Account. Public client, no secret, no PKCE (the device code binds the exchange). ## Auto-fallback Device flow is **auto-selected** when no local browser is reachable (a `$SSH_CONNECTION` / `$SSH_TTY` session, or a Unix session with no `DISPLAY`/`WAYLAND_DISPLAY`); loopback stays the desktop default. `FJ_FORCE_LOOPBACK=1` opts back out. Explicit `--device` / `--manual` always win (they conflict at the CLI layer). The intro line explains the auto-choice and how to override. ## Tests `cargo fmt` + `clippy -D warnings` + full suite green (678 passing). New wiremock coverage for both endpoints: device_authorization parse, each poll outcome (pending / slow_down / access_denied / expired_token / invalid_grant / approved), a non-OAuth failure, and the loop's happy path, slow_down back-off, denied/expired aborts, and client-side deadline timeout. Pure-unit tests for `choose_oidc_capture` precedence and QR rendering.
auth: durable cross-device Fjord sign-in via OAuth device flow (--device)
Some checks failed
Forseti review / forseti review (advisory) (pull_request_target) Successful in 2m2s
ci / check (pull_request) Successful in 11m8s
ci / live-e2e (pull_request) Successful in 1m59s
ci / coverage (pull_request) Failing after 10m25s
8a3b165f07
Implement the OAuth 2.0 Device Authorization Grant client (RFC 8628) so a
user whose browser is on a different device than `fj` (an iPad while `fj`
runs on a Mac/server, or fully headless) can sign in to a Fjord Account.
Consumes the merged Paragon OP side (paragon #863), re-exposed by
fjord-platform at /oauth/device_authorization and /oauth/token.

`fj auth login --fjord --device` posts device_authorization for `fj-cli`,
shows the user code, the verification link, and a scannable terminal QR of
verification_uri_complete, then polls /oauth/token (grant_type
device_code) honoring authorization_pending, slow_down (+5s), access_denied,
expired_token, invalid_grant, and the expires_in deadline. Tokens persist
through the same store_tokens path as loopback, so the session is identical
(same keychain slots, silent refresh, `fj auth status` shows Fjord Account).

Device flow is auto-selected when no local browser is reachable (an
$SSH_CONNECTION session, or a Unix session with no display server), with
FJ_FORCE_LOOPBACK=1 to opt out; loopback stays the desktop default. The QR
is optional-friendly: it degrades to the plain URL if rendering fails.
Supersedes --manual for the cross-device case.

Superseded by Forseti review for 3aaa970d975c.

Forseti review

No blocking findings from the lead reviewer.

Findings

  • P2 src/fjord/oidc.rs:815 — The loop checks Instant::now() >= deadline before sleeping, then always sleeps the full poll interval and calls poll_device_token afterward. If the remaining lifetime is shorter than the interval, the client will wait past the advertised expiration and still make a token request after the deadline instead of stopping when expires_in elapses. This weakens the promised client-side deadline behavior and can make short-lived or slowed-down device codes wait longer than intended before reporting expiry.

  • PR: rasterstate/fj#226

  • Head SHA: 8a3b165f076d

  • Review job: sha256:30ee45c756b7021377b8eb32c10a8119026850115564d1b410586e45b44a4b58

  • Provider pair: openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001

  • Blocking findings: 0

  • Inline findings: 1

  • Token source: GITHUB_TOKEN

  • Runner: 8073074ba165

  • Run: https://rasterhub.com/rasterstate/fj/actions/runs/377

<!-- forseti:review {"version":2,"repo":"rasterstate/fj","pr":226,"head_sha":"8a3b165f076ddce7d70be88d4943c220cf4469b4","provider_pair":"openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001","policy_version":"stub-policy-v1","prompt_version":"prompt-v2","context_fingerprint":"fnv64:302a333510c38d5e","review_job_key":"sha256:30ee45c756b7021377b8eb32c10a8119026850115564d1b410586e45b44a4b58","base_sha":"763f26830dd52f6c6317ad36f59704b9a6358a94","role":"summary","status":"superseded"} --> > Superseded by Forseti review for `3aaa970d975c`. ## Forseti review No blocking findings from the lead reviewer. ### Findings - **P2** `src/fjord/oidc.rs:815` — The loop checks `Instant::now() >= deadline` before sleeping, then always sleeps the full poll interval and calls `poll_device_token` afterward. If the remaining lifetime is shorter than the interval, the client will wait past the advertised expiration and still make a token request after the deadline instead of stopping when `expires_in` elapses. This weakens the promised client-side deadline behavior and can make short-lived or slowed-down device codes wait longer than intended before reporting expiry. - PR: `rasterstate/fj#226` - Head SHA: `8a3b165f076d` - Review job: `sha256:30ee45c756b7021377b8eb32c10a8119026850115564d1b410586e45b44a4b58` - Provider pair: `openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001` - Blocking findings: `0` - Inline findings: `1` - Token source: `GITHUB_TOKEN` - Runner: `8073074ba165` - Run: https://rasterhub.com/rasterstate/fj/actions/runs/377
auth: exclude auth_login I/O handlers from intent-precise coverage
All checks were successful
Forseti review / forseti review (advisory) (pull_request_target) Successful in 2m7s
ci / check (pull_request) Successful in 10m17s
ci / live-e2e (pull_request) Successful in 1m57s
ci / coverage (pull_request) Successful in 4m24s
3aaa970d97
Adding a unit-test module to auth_login.rs took it out of COV_IGNORE (the
sync test requires a unit-test target not be whole-file-ignored), so its
untested sign-in glue started counting toward `make coverage-strict
COV_MIN=73` and dropped the floor. Mark the interactive / network / keychain
handlers `#[cfg_attr(coverage_nightly, coverage(off))]`, the same pattern the
other heavy-glue cli/ modules use, leaving the pure unit-tested
`choose_oidc_capture` counted.

Forseti review

No blocking findings from the lead reviewer.

Findings

  • P2 src/fjord/oidc.rs:805 — The loop checks Instant::now() >= deadline only before tokio::time::sleep(interval). If the remaining lifetime is shorter than interval—for example after slow_down, a short expires_in, or a server-provided interval larger than the remaining time—the client sleeps past the deadline and then still calls poll_device_token once before noticing expiry on the next iteration. That contradicts the stated client-side deadline behavior and can make the CLI hang longer than the code lifetime.

  • PR: rasterstate/fj#226

  • Head SHA: 3aaa970d975c

  • Review job: sha256:1f3881cf4caf575237f18e41a0224270645c543c9ef1791856359b27c9b02e73

  • Provider pair: openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001

  • Blocking findings: 0

  • Inline findings: 1

  • Token source: GITHUB_TOKEN

  • Runner: 479cae1bae02

  • Run: https://rasterhub.com/rasterstate/fj/actions/runs/379

<!-- forseti:review {"version":2,"repo":"rasterstate/fj","pr":226,"head_sha":"3aaa970d975cef9c4df46d4441e15690309a5729","provider_pair":"openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001","policy_version":"stub-policy-v1","prompt_version":"prompt-v2","context_fingerprint":"fnv64:17ca43697a14aa1a","review_job_key":"sha256:1f3881cf4caf575237f18e41a0224270645c543c9ef1791856359b27c9b02e73","base_sha":"763f26830dd52f6c6317ad36f59704b9a6358a94","role":"summary","status":"current"} --> ## Forseti review No blocking findings from the lead reviewer. ### Findings - **P2** `src/fjord/oidc.rs:805` — The loop checks `Instant::now() >= deadline` only before `tokio::time::sleep(interval)`. If the remaining lifetime is shorter than `interval`—for example after `slow_down`, a short `expires_in`, or a server-provided interval larger than the remaining time—the client sleeps past the deadline and then still calls `poll_device_token` once before noticing expiry on the next iteration. That contradicts the stated client-side deadline behavior and can make the CLI hang longer than the code lifetime. - PR: `rasterstate/fj#226` - Head SHA: `3aaa970d975c` - Review job: `sha256:1f3881cf4caf575237f18e41a0224270645c543c9ef1791856359b27c9b02e73` - Provider pair: `openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001` - Blocking findings: `0` - Inline findings: `1` - Token source: `GITHUB_TOKEN` - Runner: `479cae1bae02` - Run: https://rasterhub.com/rasterstate/fj/actions/runs/379
stephen deleted branch feat/oidc-device-flow 2026-07-21 21:01:53 +00:00
stephen referenced this pull request from a commit 2026-07-22 15:18:04 +00:00
stephen referenced this pull request from a commit 2026-07-22 15:40:40 +00:00
Sign in to join this conversation.
No description provided.