ref:394d5b4a97f1d59bfb4095ab9dc34e9d1104dd53

feat(auth): device-flow login (RFC 8628)

`anvil auth login` now uses the OAuth 2.0 device authorization grant against the new server endpoints: it requests a device code, opens the verification URL in a browser, displays the user code, and polls /api/v1/oauth/token until the user approves on /login/device. The token is then verified via /users/me and saved. The --token flag is still honored for non-interactive (CI) setups. Includes a poll-response state machine factored out for unit testing (authorization_pending → keep polling, slow_down → back off, denied/ expired → fail with a clear error). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SHA: 394d5b4a97f1d59bfb4095ab9dc34e9d1104dd53
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-05-20 20:43
Parents: 0403191
1 files changed +264 -52
Type
src/commands/auth.rs +264 −52
@@ -1,7 +1,29 @@
use crate::config::Config;
use crate::output;
use clap::{Args, Subcommand};
use serde::Deserialize;
use std::time::{Duration, Instant};
const CLIENT_ID: &str = "anvil-cli";
/// Scopes the CLI requests when starting a device-flow login. Mirrors
/// the breadth of the CLI's command surface (no `admin:*` — anything
/// that needs admin should provision a PAT explicitly).
const DEFAULT_SCOPES: &[&str] = &[
"repo:read",
"repo:write",
"issues:read",
"issues:write",
"ci:read",
"ci:write",
"user:read",
"user:write",
"requirements:read",
"requirements:write",
"deploy:read",
"deploy:write",
];
#[derive(Args)]
pub struct AuthArgs {
#[command(subcommand)]
@@ -15,10 +37,10 @@
/// Anvil server URL (e.g. https://anvil.fangorn.io)
#[arg(long)]
url: Option<String>,
/// Personal access token (skips device flow; useful for CI)
/// Personal access token (or enter interactively)
#[arg(long)]
token: Option<String>,
/// Don't try to open the create-token page in the browser
/// Don't try to open the verification URL in the browser
#[arg(long)]
no_browser: bool,
},
@@ -40,11 +62,6 @@
}
}
/// URL of the per-user PAT-management page on the given server.
fn token_settings_url(server_url: &str) -> String {
format!("{}/users/settings/tokens", server_url.trim_end_matches('/'))
}
/// Try to open `url` in the user's default browser. Returns true on success.
/// Failure is non-fatal — the caller falls back to printing the URL.
fn try_open_browser(url: &str) -> bool {
@@ -65,6 +82,16 @@
.unwrap_or(false)
}
/// Normalize a user-entered server URL to the bare origin we store. Strips
/// trailing `/api/v1` so users can paste either form.
fn normalize_server_url(input: &str) -> String {
input
.trim_end_matches('/')
.trim_end_matches("/api/v1")
.trim_end_matches('/')
.to_string()
}
async fn login(
url: Option<String>,
token: Option<String>,
@@ -77,79 +104,193 @@
.default("https://anvil.fangorn.io".into())
.interact_text()?,
};
let base = normalize_server_url(&url);
let token = match token {
Some(t) => t,
None => device_flow_login(&base, no_browser).await?,
};
// Final sanity check against /users/me. Device flow already vouches for
// the token; this catches typos in the --token path and gives us a
// username for the success message.
let username = verify_token(&base, &token).await?;
let mut config = Config::load().unwrap_or_default();
config.server_url = Some(base.clone());
config.token = Some(token);
config.save()?;
match username {
Some(u) => output::success(&format!("Logged in to {base} as {u}")),
None => output::success(&format!("Logged in to {base}")),
}
output::info(&format!("Config saved to {}", Config::path().display()));
None => {
// Help the user find the PAT-creation page. Without this, they
// have to know the URL by heart or hunt through settings menus.
let token_url = token_settings_url(&url);
output::info(&format!("Create a token at: {token_url}"));
Ok(())
}
#[derive(Debug, Deserialize)]
struct DeviceCodeResponse {
if !no_browser && try_open_browser(&token_url) {
output::info("Opened the create-token page in your browser.");
device_code: String,
user_code: String,
verification_uri: String,
verification_uri_complete: Option<String>,
expires_in: u64,
interval: u64,
}
async fn device_flow_login(
base: &str,
no_browser: bool,
) -> Result<String, Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let scope = DEFAULT_SCOPES.join(" ");
let dc: DeviceCodeResponse = client
.post(format!("{base}/api/v1/oauth/device/code"))
.form(&[("client_id", CLIENT_ID), ("scope", scope.as_str())])
.send()
.await?
.error_for_status()
.map_err(|e| format!("device-code request failed: {e}"))?
.json()
.await?;
let target = dc
.verification_uri_complete
.clone()
.unwrap_or_else(|| dc.verification_uri.clone());
output::info(&format!("Open this URL to authorize: {target}"));
output::info(&format!("If prompted, confirm the code: {}", dc.user_code));
if !no_browser && try_open_browser(&target) {
output::info("Opened the authorization page in your browser.");
}
poll_for_token(&client, base, &dc.device_code, dc.interval, dc.expires_in).await
}
async fn poll_for_token(
client: &reqwest::Client,
base: &str,
device_code: &str,
initial_interval: u64,
expires_in: u64,
) -> Result<String, Box<dyn std::error::Error>> {
let deadline = Instant::now() + Duration::from_secs(expires_in);
let mut interval = Duration::from_secs(initial_interval.max(1));
loop {
if Instant::now() >= deadline {
return Err("device code expired before approval".into());
}
tokio::time::sleep(interval).await;
let resp = client
.post(format!("{base}/api/v1/oauth/token"))
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("device_code", device_code),
("client_id", CLIENT_ID),
])
.send()
.await?;
let status = resp.status();
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
match classify_poll_response(status, &body) {
PollOutcome::Success(token) => return Ok(token),
PollOutcome::Pending => continue,
PollOutcome::SlowDown => {
// RFC 8628 §3.5 — bump the interval and keep polling.
interval += Duration::from_secs(5);
continue;
}
PollOutcome::Denied => return Err("authorization denied".into()),
PollOutcome::Expired => return Err("device code expired".into()),
PollOutcome::Other(msg) => return Err(msg.into()),
}
}
}
#[derive(Debug, PartialEq, Eq)]
dialoguer::Password::new()
.with_prompt("Paste the token here")
.interact()?
enum PollOutcome {
Success(String),
Pending,
SlowDown,
Denied,
Expired,
Other(String),
}
fn classify_poll_response(status: reqwest::StatusCode, body: &serde_json::Value) -> PollOutcome {
if status.is_success() {
return match body.get("access_token").and_then(|v| v.as_str()) {
Some(token) => PollOutcome::Success(token.to_string()),
None => PollOutcome::Other("server returned no access_token".into()),
};
}
match body.get("error").and_then(|v| v.as_str()) {
Some("authorization_pending") => PollOutcome::Pending,
Some("slow_down") => PollOutcome::SlowDown,
Some("access_denied") => PollOutcome::Denied,
Some("expired_token") => PollOutcome::Expired,
Some(other) => {
let desc = body
.get("error_description")
.and_then(|v| v.as_str())
.unwrap_or(other);
PollOutcome::Other(format!("{other}: {desc}"))
}
None => PollOutcome::Other(format!("unexpected response (HTTP {status})")),
};
}
}
// /health doesn't require auth, so it can't reject a bad token — hit /users/me instead.
let me_url = format!(
"{}/api/v1/users/me",
url.trim_end_matches('/').trim_end_matches("/api/v1")
async fn verify_token(
base: &str,
token: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let me_url = format!("{base}/api/v1/users/me");
);
let resp = reqwest::Client::new()
.get(&me_url)
.bearer_auth(token)
.bearer_auth(&token)
.send()
.await;
let username: Option<String> = match resp {
match resp {
Ok(r) if r.status().is_success() => {
Ok(r.json::<serde_json::Value>().await.ok().and_then(|v| {
r.json::<serde_json::Value>().await.ok().and_then(|v| {
v.pointer("/username")
.or_else(|| v.pointer("/user/username"))
.or_else(|| v.pointer("/data/username"))
.and_then(|x| x.as_str().map(String::from))
})
}))
}
Ok(r)
if r.status() == reqwest::StatusCode::UNAUTHORIZED
|| r.status() == reqwest::StatusCode::FORBIDDEN =>
{
return Err(format!(
Err(format!(
"token rejected by {base} (status {}). Not saved.",
"Token rejected by {url} (status {}). Not saved.",
r.status()
)
.into());
.into())
}
Ok(r) => {
output::warn(&format!(
"Server returned status {} when verifying token — saving anyway",
r.status()
));
Ok(None)
None
}
Err(e) => {
output::warn(&format!("Could not reach server: {e} — saving anyway"));
None
Ok(None)
}
};
let mut config = Config::load().unwrap_or_default();
config.server_url = Some(url.clone());
config.token = Some(token);
config.save()?;
match username {
Some(u) => output::success(&format!("Logged in to {url} as {u}")),
None => output::success(&format!("Logged in to {url}")),
}
output::info(&format!("Config saved to {}", Config::path().display()));
Ok(())
}
async fn status() -> Result<(), Box<dyn std::error::Error>> {
@@ -186,6 +327,7 @@
mod tests {
use super::*;
use clap::Parser;
use serde_json::json;
#[derive(Parser)]
#[command(no_binary_name = true)]
@@ -199,19 +341,23 @@
}
#[test]
fn normalize_strips_trailing_slashes_and_api_prefix() {
fn token_url_strips_trailing_slash() {
assert_eq!(
token_settings_url("https://anvil.fangorn.io"),
"https://anvil.fangorn.io/users/settings/tokens"
normalize_server_url("https://anvil.fangorn.io"),
"https://anvil.fangorn.io"
);
assert_eq!(
normalize_server_url("https://anvil.fangorn.io/"),
"https://anvil.fangorn.io"
token_settings_url("https://anvil.fangorn.io/"),
"https://anvil.fangorn.io/users/settings/tokens"
);
assert_eq!(
token_settings_url("https://anvil.fangorn.io///"),
normalize_server_url("https://anvil.fangorn.io/api/v1"),
"https://anvil.fangorn.io"
"https://anvil.fangorn.io/users/settings/tokens"
);
assert_eq!(
normalize_server_url("https://anvil.fangorn.io/api/v1/"),
"https://anvil.fangorn.io"
);
}
#[test]
@@ -252,6 +398,72 @@
assert_eq!(token.as_deref(), Some("anvil_secret"));
}
_ => panic!("expected Login"),
}
}
#[test]
fn classify_success_returns_token() {
let body = json!({"access_token": "anvil_xyz", "token_type": "Bearer"});
assert_eq!(
classify_poll_response(reqwest::StatusCode::OK, &body),
PollOutcome::Success("anvil_xyz".into())
);
}
#[test]
fn classify_400_pending_returns_pending() {
let body = json!({"error": "authorization_pending"});
assert_eq!(
classify_poll_response(reqwest::StatusCode::BAD_REQUEST, &body),
PollOutcome::Pending
);
}
#[test]
fn classify_400_slow_down() {
let body = json!({"error": "slow_down"});
assert_eq!(
classify_poll_response(reqwest::StatusCode::BAD_REQUEST, &body),
PollOutcome::SlowDown
);
}
#[test]
fn classify_400_denied() {
let body = json!({"error": "access_denied"});
assert_eq!(
classify_poll_response(reqwest::StatusCode::BAD_REQUEST, &body),
PollOutcome::Denied
);
}
#[test]
fn classify_400_expired() {
let body = json!({"error": "expired_token"});
assert_eq!(
classify_poll_response(reqwest::StatusCode::BAD_REQUEST, &body),
PollOutcome::Expired
);
}
#[test]
fn classify_unknown_error_surfaces_description() {
let body = json!({"error": "invalid_grant", "error_description": "no such code"});
match classify_poll_response(reqwest::StatusCode::BAD_REQUEST, &body) {
PollOutcome::Other(msg) => {
assert!(msg.contains("invalid_grant"));
assert!(msg.contains("no such code"));
}
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn classify_success_without_token_is_other() {
let body = json!({"token_type": "Bearer"});
match classify_poll_response(reqwest::StatusCode::OK, &body) {
PollOutcome::Other(_) => {}
other => panic!("expected Other, got {other:?}"),
}
}
}