ref:7c350b4afc6f4cf9f95fe7656f9ca559c92b1f80

feat(auth): add `anvil auth rotate` (#41)

Replaces the token the CLI is currently using with a fresh one, revokes the old one, and rewrites the config file. Requires the server endpoint added in fangorn/anvil#207 (`POST /api/v1/user/tokens/rotate`), which mints the replacement and destroys the presenting token in one transaction. **Merge that first** — until it deploys, this command 404s. ``` $ anvil auth rotate ? Rotate the token for https://anvil.fangorn.io? The current token stops working immediately. yes ✓ Rotated the token for https://anvil.fangorn.io Revoked anvil_9Qb… New token anvil_m8Z… Config /home/you/.config/anvil/config.json ``` The replacement carries the same name, scopes and expiry — rotation swaps a credential, it does not widen or extend access. ## Failure modes, since this one can lock you out - **Prompts first.** The old token dies immediately and there is no undo. When stdin is not a TTY it refuses outright rather than letting `dialoguer` fail obscurely; CI passes `--yes`. - **If the config write fails after the server has revoked the old token**, it prints the plaintext rather than leaving you locked out. This is the one path where losing the value is unrecoverable. - **`ANVIL_TOKEN` overrides the config file** (`config.rs:42-51`), so rotating with it set would revoke the env token and write the replacement somewhere that stays shadowed — the next command would use a dead token. Detected, with the export line printed. - A rejected rotation leaves the stored token untouched. ## Also here `Config::save()` now writes the file `0600`. It holds a bearer token, and `fs::write` honours the umask, which on most distros left it `0644` — readable by every account on the machine. Mine was `-rw-rw-r--`. ## Testing 5 integration tests driving the real binary against a mock Anvil, each with `XDG_CONFIG_HOME` pointed at a scratch dir so a developer's real credentials are never touched: the happy path rewrites the stored token; no-TTY-without-`--yes` refuses and leaves it alone; a server rejection leaves it alone; `--json` emits the envelope; and the written file is `0600` (starting from a deliberately relaxed `0644` to prove `save()` tightens it). 185 tests pass, `clippy -D warnings` and `fmt --check` clean.
SHA: 7c350b4afc6f4cf9f95fe7656f9ca559c92b1f80
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-22 10:02
Parents: d2c888b
3 files changed +470 -8
Type
src/commands/auth.rs +115 −1
@@ -49,6 +49,12 @@
},
/// Show current authentication status
Status,
/// Replace the stored token with a fresh one and revoke the old one
Rotate {
/// Skip the confirmation prompt (required when stdin is not a terminal)
#[arg(long, short = 'y')]
yes: bool,
},
/// Log out and remove stored credentials
Logout,
}
@@ -61,6 +67,7 @@
no_browser,
} => login(url, token, no_browser).await,
AuthCommand::Status => status().await,
AuthCommand::Rotate { yes } => rotate(yes).await,
AuthCommand::Logout => logout().await,
}
}
@@ -302,7 +309,7 @@
match (&config.server_url, &config.token) {
(Some(url), Some(token)) => {
output::detail("Server", url);
output::detail("Token", &format!("{}…", truncate(token)));
output::detail("Token", &format!("{}…", &token[..8.min(token.len())]));
if let Some(ref repo) = config.default_repo {
output::detail("Default repo", repo);
}
@@ -311,6 +318,113 @@
println!("Not logged in. Run `anvil auth login` to authenticate.");
}
}
Ok(())
}
/// First 8 characters of a token, for display. Deliberately char-based: `&s[..8]`
/// panics if byte 8 lands inside a multi-byte character, and a token is server
/// -supplied, so we don't get to assume it's ASCII.
fn truncate(token: &str) -> String {
token.chars().take(8).collect()
}
/// Whether stdin is a terminal. Used to refuse an interactive confirmation
/// prompt in CI, where `dialoguer` would otherwise fail with an opaque error.
fn stdin_is_tty() -> bool {
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
/// Rotate the stored credential: the server mints a replacement with the same
/// name, scopes and expiry, and destroys the presenting token in the same
/// transaction. There is no undo, so we confirm first unless `--yes`.
async fn rotate(yes: bool) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load()?;
// Fail before touching the server if there's nothing to rotate.
let server = config.server_url()?.to_string();
let old = config.token()?.to_string();
// ANVIL_TOKEN shadows the config file (see Config::load), so rotating would
// revoke the env token while writing the replacement somewhere that stays
// shadowed — the next command would use a dead token. Say so up front.
let env_shadowed = std::env::var("ANVIL_TOKEN").is_ok_and(|v| !v.is_empty());
if !yes {
// Prompting is wrong in both non-interactive cases: with no terminal
// there is nobody to answer, and under --json the caller is a script
// whose stdout is a data channel, not a conversation.
if !stdin_is_tty() || output::is_json() {
return Err(
"refusing to rotate without confirmation — pass --yes to rotate non-interactively"
.into(),
);
}
let prompt =
format!("Rotate the token for {server}? The current token stops working immediately.");
if !dialoguer::Confirm::new()
.with_prompt(prompt)
.default(false)
.interact()?
{
output::info("Rotation cancelled — nothing changed.");
return Ok(());
}
}
let client = crate::client::Client::from_config()?;
let resp: serde_json::Value = client
.post("/user/tokens/rotate", &serde_json::json!({}))
.await?;
// A 200 with no token means the server rotated but we can't see the result,
// so we must not claim the old token still works — it almost certainly
// doesn't. Point at the recovery path instead.
let new_token = resp.get("token").and_then(|v| v.as_str()).ok_or(
"server accepted the rotation but returned no token — your old token is likely \
revoked; issue a new one at /users/settings/tokens",
)?;
// Update only the token: `load()` folds ANVIL_SERVER_URL in, so saving that
// would quietly persist a transient env override as the stored server.
let mut config = Config::load_from_disk().unwrap_or_default();
config.token = Some(new_token.to_string());
// The old token is already dead server-side. From here on, losing the new
// value locks the user out, so every failure path must surface it — on
// stderr, so it can't corrupt a --json stdout.
if let Err(e) = config.save() {
output::error(&format!("Could not write the new token to disk: {e}"));
eprintln!(
"\nYour previous token has already been revoked. Save this now:\n\n{new_token}\n"
);
return Err("rotation succeeded but the config could not be saved".into());
}
// Warn on stderr in both modes — a --json caller is the one most likely to
// be running with ANVIL_TOKEN set, and it would otherwise never be told.
if env_shadowed {
output::warn(
"ANVIL_TOKEN is set and overrides the config file — the token you just revoked \
is still in your environment.",
);
eprintln!("\nUpdate it to the new value:\n\n export ANVIL_TOKEN={new_token}\n");
}
if output::is_json() {
output::json_ok("rotated", resp);
return Ok(());
}
output::success(&format!("Rotated the token for {server}"));
let revoked = resp
.get("revoked")
.and_then(|r| r.get("token_prefix"))
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| truncate(&old));
output::detail("Revoked", &format!("{revoked}…"));
output::detail("New token", &format!("{}…", truncate(new_token)));
output::detail("Config", &Config::path().display().to_string());
Ok(())
}
src/config.rs +84 −7
@@ -21,22 +21,40 @@
}
impl Config {
/// Location of the credentials file. `ANVIL_CONFIG` overrides it outright,
/// which is how tests get an isolated config without depending on
/// `XDG_CONFIG_HOME` — `dirs::config_dir()` only honours that on Linux, and
/// returns `~/Library/Application Support` on macOS regardless.
pub fn path() -> PathBuf {
if let Some(explicit) = std::env::var_os("ANVIL_CONFIG") {
if !explicit.is_empty() {
return PathBuf::from(explicit);
}
}
let dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("anvil");
dir.join("config.json")
}
/// The config exactly as stored on disk, with no environment overlay.
///
/// Use this when about to *write* the config back: `load()` folds
pub fn load() -> Result<Self, ConfigError> {
/// `ANVIL_SERVER_URL`/`ANVIL_TOKEN` in, so saving what it returns would
/// persist a transient environment override into the file.
pub fn load_from_disk() -> Result<Self, ConfigError> {
let path = Self::path();
if path.exists() {
let mut cfg: Self = if path.exists() {
let contents = std::fs::read_to_string(&path)?;
serde_json::from_str(&contents)?
Ok(serde_json::from_str(&contents)?)
} else {
Ok(Self::default())
Self::default()
};
}
}
pub fn load() -> Result<Self, ConfigError> {
let mut cfg = Self::load_from_disk()?;
// Env vars take precedence over the config file so CI can inject
// credentials without writing to disk.
if let Ok(url) = std::env::var("ANVIL_SERVER_URL") {
@@ -59,8 +77,7 @@
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(self)?;
std::fs::write(&path, contents)?;
Ok(())
write_private(&path, contents.as_bytes())
}
pub fn server_url(&self) -> Result<&str, ConfigError> {
@@ -70,6 +87,66 @@
pub fn token(&self) -> Result<&str, ConfigError> {
self.token.as_deref().ok_or(ConfigError::NotLoggedIn)
}
}
/// Write `contents` to `path` atomically, never letting the bytes exist at a
/// mode another account can read.
///
/// The file holds a bearer token, so both properties matter:
///
/// * **Atomic.** `fs::write` truncates in place, so an interrupted write
/// leaves a half-written config. `anvil auth rotate` revokes the old token
/// before saving, so a truncated file there means no working credential at
/// all. Writing a temp file and renaming makes the swap all-or-nothing.
/// * **Private from the first byte.** Creating with mode 0600 (rather than
/// chmod-ing afterwards) means the secret is never on disk world-readable,
/// and because `rename` carries the temp file's mode across, an existing
/// 0644 config is *replaced* rather than written into.
///
/// Setting the mode at creation also avoids a fatal `set_permissions` call on
/// filesystems that don't implement it (DrvFs, NTFS, CIFS) — there the mode is
/// simply ignored and the write still succeeds.
fn write_private(path: &std::path::Path, contents: &[u8]) -> Result<(), ConfigError> {
use std::io::Write;
let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
let stem = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("config.json");
// pid + nanos so concurrent writers can't collide on the temp path.
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = dir.join(format!(".{stem}.{}.{nanos}.tmp", std::process::id()));
let written = (|| -> Result<(), std::io::Error> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&tmp)?;
file.write_all(contents)?;
// Durable before the rename, so a crash can't leave an empty file
// renamed over a previously-good config.
file.sync_all()
})();
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
Ok(())
}
/// Resolve org/repo from explicit arg, --repo flag, or git remote.
tests/auth_rotate.rs +271 −0
@@ -1,0 +1,271 @@
//! End-to-end tests for `anvil auth rotate`.
//!
//! Rotation is the one command that can lock a user out of their own server, so
//! these drive the real binary against a mock Anvil and assert on what actually
//! lands in `config.json`.
//!
//! Each test points `ANVIL_CONFIG` at a scratch file so the developer's real
//! credentials are never touched. Note this deliberately does *not* use
//! `XDG_CONFIG_HOME`: `dirs::config_dir()` only honours that on Linux and
//! returns `~/Library/Application Support` on macOS, so an XDG-based harness
//! would silently drive the developer's live config there.
use serde_json::{json, Value};
use std::path::PathBuf;
use std::process::Output;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A unique scratch config file, seeded with a config.json holding `token`.
struct ConfigHome {
root: PathBuf,
}
impl ConfigHome {
fn new(tag: &str, server_url: &str, token: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let root = std::env::temp_dir().join(format!("anvil-rotate-{tag}-{nanos}"));
std::fs::create_dir_all(&root).expect("create scratch config dir");
let me = Self { root };
std::fs::write(
me.config_path(),
serde_json::to_string_pretty(&json!({
"server_url": server_url,
"token": token,
"default_repo": null,
}))
.unwrap(),
)
.expect("seed config.json");
me
}
fn config_path(&self) -> PathBuf {
self.root.join("config.json")
}
fn stored(&self) -> Value {
let raw = std::fs::read_to_string(self.config_path()).expect("read config.json");
serde_json::from_str(&raw).expect("config.json is valid JSON")
}
fn run(&self, args: &[&str]) -> Output {
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.args(args)
.env("ANVIL_CONFIG", self.config_path())
// Must stay unset: these shadow the config file.
.env_remove("ANVIL_TOKEN")
.env_remove("ANVIL_SERVER_URL")
.output()
.expect("run anvil")
}
/// Like `run`, but with `ANVIL_SERVER_URL` exported — the shadowing case.
fn run_with_server_env(&self, args: &[&str], server_url: &str) -> Output {
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.args(args)
.env("ANVIL_CONFIG", self.config_path())
.env("ANVIL_SERVER_URL", server_url)
.env_remove("ANVIL_TOKEN")
.output()
.expect("run anvil")
}
}
impl Drop for ConfigHome {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
/// Mock the rotate endpoint, requiring the *old* token as the bearer.
async fn mock_rotate(server: &MockServer, old_token: &str, new_token: &str) {
Mock::given(method("POST"))
.and(path("/api/v1/user/tokens/rotate"))
.and(header(
"authorization",
format!("Bearer {old_token}").as_str(),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"token": new_token,
"id": "11111111-2222-3333-4444-555555555555",
"name": "laptop",
"scopes": ["repo:read"],
"token_prefix": "anvil_new",
"expires_at": null,
"revoked": {"id": "99999999-0000-0000-0000-000000000000", "token_prefix": "anvil_old"}
})))
.mount(server)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_replaces_the_stored_token() {
let server = MockServer::start().await;
mock_rotate(&server, "anvil_oldtoken", "anvil_newtoken").await;
let home = ConfigHome::new("replace", &server.uri(), "anvil_oldtoken");
let out = home.run(&["auth", "rotate", "--yes"]);
assert!(
out.status.success(),
"rotate failed\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(home.stored()["token"], "anvil_newtoken");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_refuses_without_confirmation_when_not_a_tty() {
let server = MockServer::start().await;
// Deliberately no mock: the command must fail before issuing any request.
let home = ConfigHome::new("noconfirm", &server.uri(), "anvil_oldtoken");
let out = home.run(&["auth", "rotate"]);
assert!(!out.status.success(), "expected a non-zero exit");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--yes"),
"error should point at --yes; got: {stderr}"
);
// The stored credential must be untouched.
assert_eq!(home.stored()["token"], "anvil_oldtoken");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_leaves_the_old_token_in_place_when_the_server_rejects() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/tokens/rotate"))
.respond_with(
ResponseTemplate::new(403)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"forbidden"}"#),
)
.mount(&server)
.await;
let home = ConfigHome::new("rejected", &server.uri(), "anvil_oldtoken");
let out = home.run(&["auth", "rotate", "--yes"]);
assert!(!out.status.success(), "expected a non-zero exit");
assert_eq!(
home.stored()["token"],
"anvil_oldtoken",
"a rejected rotation must not disturb the stored token"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_emits_a_json_envelope_under_json_mode() {
let server = MockServer::start().await;
mock_rotate(&server, "anvil_oldtoken", "anvil_newtoken").await;
let home = ConfigHome::new("json", &server.uri(), "anvil_oldtoken");
let out = home.run(&["--json", "auth", "rotate", "--yes"]);
assert!(out.status.success(), "rotate failed");
let body: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not JSON: {e}\nstdout: {}",
String::from_utf8_lossy(&out.stdout)
)
});
assert_eq!(body["ok"], true);
assert_eq!(body["rotated"]["token"], "anvil_newtoken");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_refuses_without_yes_under_json() {
let server = MockServer::start().await;
// No mock: --json without --yes must fail before issuing any request,
// rather than trying to prompt a script.
let home = ConfigHome::new("jsonconfirm", &server.uri(), "anvil_oldtoken");
let out = home.run(&["--json", "auth", "rotate"]);
assert!(!out.status.success(), "expected a non-zero exit");
assert!(
String::from_utf8_lossy(&out.stderr).contains("--yes"),
"error should point at --yes"
);
assert_eq!(home.stored()["token"], "anvil_oldtoken");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_does_not_persist_an_env_server_url() {
let server = MockServer::start().await;
mock_rotate(&server, "anvil_oldtoken", "anvil_newtoken").await;
// The file names one server; the environment transiently overrides it.
let home = ConfigHome::new("envurl", "https://stored.invalid", "anvil_oldtoken");
let out = home.run_with_server_env(&["auth", "rotate", "--yes"], &server.uri());
assert!(
out.status.success(),
"rotate failed\nstderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(home.stored()["token"], "anvil_newtoken");
assert_eq!(
home.stored()["server_url"],
"https://stored.invalid",
"a transient ANVIL_SERVER_URL must not be written into the config"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_warns_on_stderr_when_env_token_shadows_the_config() {
let server = MockServer::start().await;
mock_rotate(&server, "anvil_envtoken", "anvil_newtoken").await;
let home = ConfigHome::new("shadow", &server.uri(), "anvil_filetoken");
let out = std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.args(["--json", "auth", "rotate", "--yes"])
.env("ANVIL_CONFIG", home.config_path())
.env("ANVIL_TOKEN", "anvil_envtoken")
.env_remove("ANVIL_SERVER_URL")
.output()
.expect("run anvil");
assert!(out.status.success(), "rotate failed");
// stdout stays parseable JSON; the warning goes to stderr.
let body: Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
assert_eq!(body["ok"], true);
assert!(
String::from_utf8_lossy(&out.stderr).contains("ANVIL_TOKEN"),
"a --json caller must still be warned that its env token is now dead"
);
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rotate_writes_the_config_owner_readable_only() {
use std::os::unix::fs::PermissionsExt;
let server = MockServer::start().await;
mock_rotate(&server, "anvil_oldtoken", "anvil_newtoken").await;
let home = ConfigHome::new("perms", &server.uri(), "anvil_oldtoken");
// Start from a deliberately permissive mode to prove save() tightens it.
std::fs::set_permissions(home.config_path(), std::fs::Permissions::from_mode(0o644))
.expect("relax perms");
assert!(home.run(&["auth", "rotate", "--yes"]).status.success());
let mode = std::fs::metadata(home.config_path())
.expect("stat config.json")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o600,
"config.json holds a bearer token; got {mode:o}"
);
}