ref:main
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)]
pub command: AuthCommand,
}
#[derive(Subcommand)]
pub enum AuthCommand {
/// Log in to an Anvil server
Login {
/// Anvil server URL (e.g. https://anvil.fangorn.io)
#[arg(long)]
url: Option<String>,
/// Personal access token (skips device flow; useful for CI)
#[arg(long)]
token: Option<String>,
/// Don't try to open the verification URL in the browser
#[arg(long)]
no_browser: bool,
},
/// Show current authentication status
Status,
/// Log out and remove stored credentials
Logout,
}
pub async fn run(args: AuthArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
AuthCommand::Login {
url,
token,
no_browser,
} => login(url, token, no_browser).await,
AuthCommand::Status => status().await,
AuthCommand::Logout => logout().await,
}
}
/// 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 {
let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
("open", vec![url])
} else if cfg!(target_os = "windows") {
("cmd", vec!["/C", "start", "", url])
} else {
("xdg-open", vec![url])
};
std::process::Command::new(cmd)
.args(&args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.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>,
no_browser: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let url = match url {
Some(u) => u,
None => dialoguer::Input::<String>::new()
.with_prompt("Anvil server URL")
.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()));
Ok(())
}
#[derive(Debug, Deserialize)]
struct DeviceCodeResponse {
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)]
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})")),
}
}
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)
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => {
Ok(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 =>
{
Err(format!(
"token rejected by {base} (status {}). Not saved.",
r.status()
)
.into())
}
Ok(r) => {
output::warn(&format!(
"Server returned status {} when verifying token — saving anyway",
r.status()
));
Ok(None)
}
Err(e) => {
output::warn(&format!("Could not reach server: {e} — saving anyway"));
Ok(None)
}
}
}
async fn status() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load()?;
match (&config.server_url, &config.token) {
(Some(url), Some(token)) => {
output::detail("Server", url);
output::detail("Token", &format!("{}…", &token[..8.min(token.len())]));
if let Some(ref repo) = config.default_repo {
output::detail("Default repo", repo);
}
}
_ => {
println!("Not logged in. Run `anvil auth login` to authenticate.");
}
}
Ok(())
}
async fn logout() -> Result<(), Box<dyn std::error::Error>> {
let path = Config::path();
if path.exists() {
std::fs::remove_file(&path)?;
output::success("Logged out — credentials removed");
} else {
println!("Not logged in.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use serde_json::json;
#[derive(Parser)]
#[command(no_binary_name = true)]
struct AuthCli {
#[command(subcommand)]
command: AuthCommand,
}
fn parse(args: &[&str]) -> AuthCommand {
AuthCli::try_parse_from(args).expect("parse").command
}
#[test]
fn normalize_strips_trailing_slashes_and_api_prefix() {
assert_eq!(
normalize_server_url("https://anvil.fangorn.io"),
"https://anvil.fangorn.io"
);
assert_eq!(
normalize_server_url("https://anvil.fangorn.io/"),
"https://anvil.fangorn.io"
);
assert_eq!(
normalize_server_url("https://anvil.fangorn.io/api/v1"),
"https://anvil.fangorn.io"
);
assert_eq!(
normalize_server_url("https://anvil.fangorn.io/api/v1/"),
"https://anvil.fangorn.io"
);
}
#[test]
fn login_parses_with_defaults() {
match parse(&["login"]) {
AuthCommand::Login {
url,
token,
no_browser,
} => {
assert!(url.is_none());
assert!(token.is_none());
assert!(!no_browser);
}
_ => panic!("expected Login"),
}
}
#[test]
fn login_parses_no_browser_flag() {
match parse(&["login", "--no-browser"]) {
AuthCommand::Login { no_browser, .. } => assert!(no_browser),
_ => panic!("expected Login"),
}
}
#[test]
fn login_parses_url_and_token_args() {
match parse(&[
"login",
"--url",
"https://example.test",
"--token",
"anvil_secret",
]) {
AuthCommand::Login { url, token, .. } => {
assert_eq!(url.as_deref(), Some("https://example.test"));
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:?}"),
}
}
}