ref:main

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

merged colechristensen cole.christensen@gmail.com wants to merge feat/auth-rotate into main

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.

Created Jul 22, 2026 at 07:57 UTC | Merged Jul 22, 2026 at 10:02 UTC by colechristensen cole.christensen@gmail.com