Skip to content

feat(auth): add actual auth create-token for non-interactive auth#801

Open
benw5483 wants to merge 6 commits into
mainfrom
feat/auth-create-token
Open

feat(auth): add actual auth create-token for non-interactive auth#801
benw5483 wants to merge 6 commits into
mainfrom
feat/auth-create-token

Conversation

@benw5483

@benw5483 benw5483 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds actual auth create-token, a scoped personal access token (PAT) path so CI jobs and autonomous agents can authenticate without a browser. It builds on the existing actual login session, so it doesn't add a second login flow.

  • Reuses the logged-in session: loads stored credentials, refreshes the access token if near expiry, calls the authenticated issuance endpoint with that token as the bearer, and prints the raw actl_pat_… once.
  • Stores the token in the OS keychain (the keyring crate) with an XChaCha20-Poly1305 + Argon2id encrypted-file fallback for headless Linux / CI where no OS keychain is available.
  • Non-interactive callers resolve a token from ACTUAL_TOKEN, then the keychain, then the encrypted file.
  • actual auth with no subcommand keeps its existing runner-auth check, so this is backward compatible.

Proof of concept / draft. The server-side issuance endpoint isn't live yet. The CLI is built against the documented contract (POST <base>/api/oauth/tokens, the session token as bearer) and is repointed with --api-url / ACTUAL_API_URL. See docs/AGENT_AUTH.md.

Headless-storage finding (the question this POC answers)

Does the keychain library degrade gracefully on a headless Linux box / CI runner with no desktop keyring?

The finding turned out sharper than we'd have guessed from a runtime check. The keyring crate's Secret Service backend links the system libdbus at build time through pkg-config, so a host without libdbus-1-dev can't compile the CLI at all. On stock Linux CI runners every job went red on The system library dbus-1 required by crate libdbus-sys was not found. A missing runtime daemon is recoverable. A binary that never builds is not.

The fix is a Linux backend with no build-time system dependency. This CLI uses linux-native, the kernel keyutils keyring, reached through raw syscalls: no libdbus, no pkg-config, no D-Bus daemon. It compiles on any Linux, including a bare CI container, and stores secrets headless. macOS and Windows keep their native keychains.

Runtime degradation stays explicit. In the default auto mode a keychain error routes to the encrypted-file store when ACTUAL_TOKEN_PASSPHRASE is set, and otherwise fails loudly rather than writing a weaker store. keyutils is session / persistent-keyring scoped, so for durable non-interactive use the recommended paths are ACTUAL_TOKEN (CI) and the encrypted-file fallback (headless Linux).

Library chosen: keyring v3 — linux-native (keyutils) on Linux, apple-native / windows-native elsewhere. Fallback: XChaCha20-Poly1305 AEAD, key derived by Argon2id from a passphrase, file written 0600. Without a passphrase the fallback is refused rather than writing anything weaker.

Security model for agents

Two rules, documented in --help and docs/AGENT_AUTH.md:

  • A dedicated token per agent (--name <agent>): never share the human's interactive login. One PAT per agent makes every action attributable and lets a single agent be revoked on its own.
  • Never in the model's context: the secret lives only in the keychain or ACTUAL_TOKEN, never in a prompt, shell history, command-line argument, or log. This guards against the agent-specific prompt-injection exfiltration path, where untrusted input an agent reads can instruct it to leak any secret in its context.

The raw token is printed once to stdout (for capture) and is never written to a log or Debug output. The Debug impls redact it, and the encrypted file holds no plaintext.

Test plan

  • cargo fmt --check and cargo clippy -- -D warnings are clean.
  • Unit tests cover token storage (the keychain backends via an injectable in-memory keystore, the encrypted-file round-trip and error paths, env / backend selection), the issuance client (mocked HTTP), and command orchestration (mint → store → retrieve, and the token-refresh path). The three new modules sit at 100% line coverage.
  • Backward compatibility: bare actual auth still runs the runner-auth check.
  • End-to-end run against a local mock issuance endpoint (transcript below, secret masked).
End-to-end run against a mock issuance endpoint (secret masked)
$ actual auth create-token --name demo-agent --scopes adr:query,adr:review --api-url http://127.0.0.1:$PORT
✔ Created scoped access token "demo-agent"
  Token id:    tok_ed9529ca
  Scopes:      adr:query adr:review
  Expires:     2026-09-28T00:00:00Z
  Stored in:   encrypted file

Your token is shown once below. Copy it now:

[stdout, MASKED] actl_pat_Bic9…Tmxi
create-token exit: 0

# The mock confirmed the request arrived authenticated at the issuance route.
[mock] POST /api/oauth/tokens authenticated=yes name='demo-agent' scopes=['adr:query', 'adr:review']

# At-rest check: the stored file is ciphertext, not the plaintext token.
OK: plaintext token is NOT present on disk
file mode: 600
{
  "version": 1,
  "kdf": "argon2id",
  "salt": "qfUeK/5DQCw8b7xgBYdU4g==",
  "nonce": "77FsOPjRsQUGBOpDsEB30Ou24tfLG6PR",
  "ciphertext": "LsjnH1ydrhsGrZXr8wL5kaYMRacShkh1…"
}

Follow-ups

  • Point the issuance call at the real endpoint once the server side ships, and verify the full path against staging.
  • The PAT → short-lived-JWT exchange and the commands that consume the token are tracked separately and are out of scope here.

Revision — never orphan an unrevocable token on store failure

A self-review found the PAT was minted server-side before the local store ran, so a store error bailed via ? before the token or its id were printed — losing a live, unrevocable token. The likeliest trigger is the target env itself (headless Linux, no ACTUAL_TOKEN_PASSPHRASE, no OS keychain). This revision closes the gap from both ends:

  • token_store::precheck is a non-writing probe that confirms a backend is available; create-token now calls it before minting, so the predictable headless / no-passphrase case fails with no token minted at all.
  • On any residual post-mint store failure (a full disk, a keychain lock race the precheck cannot foresee), the token and its id are still surfaced — stdout=secret, stderr=chrome — so the user can capture and revoke, and the error still propagates.
  • New tests cover the pre-mint precheck refusal, the store-fails-after-mint recovery, and that stdout carries only the token.

Audit matrix

Resource Rule Threat Covered
create-token mint/store ordering A minted token is always capturable + revocable; never mint an uncapturable token, or surface it on store failure Orphaned unrevocable live token on a headless-Linux store failure (the target env) yes — pre-mint precheck + post-mint surface + store-fail test
token output channel stdout carries ONLY the token; chrome / status to stderr Secret token leaks into logs mixed with UI text; the capture contract breaks yes — stdout-carries-only-the-token assertion test

Generated by the operator's software factory.
• On behalf of: @benw5483

…e tokens

Add a scoped personal access token (PAT) path so CI jobs and autonomous
agents can authenticate without a browser. `actual auth create-token`
reuses the existing `actual login` session: it loads the stored
credentials, refreshes the access token if needed, calls the authenticated
issuance endpoint, and prints the raw `actl_pat_...` once.

Storage is the OS keychain via the `keyring` crate, with an
XChaCha20-Poly1305 + Argon2id encrypted-file fallback for headless Linux /
CI that has no Secret Service daemon. Non-interactive callers resolve a
token from `ACTUAL_TOKEN`, then the keychain, then the encrypted file. The
raw value is never written to a log or `Debug` output.

`actual auth` keeps its existing runner-auth check when invoked with no
subcommand, so the change is backward compatible.

Proof of concept: the issuance endpoint is developed against the documented
contract and repointed with `--api-url` / `ACTUAL_API_URL` once the server
side ships.

Generated by the operator's software factory.
On behalf of: @benw5483
Co-Authored-By: Actual AI Factory Bot <factory-bot@actual.ai.invalid>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/auth/token_store.rs Fixed
benw5483 and others added 2 commits June 30, 2026 21:34
Point `create-token` at the real issuance route and make the crate build on
headless / CI Linux, then bring the new modules to full line coverage.

The issuance endpoint is `POST /api/oauth/tokens` (not `/api/auth/tokens`),
and `--scopes` now shows the enforced colon vocabulary (`adr:query`,
`adr:review`, `mcp:invoke`) that the rest of the login flow already uses.

The Linux keychain backend moves from the Secret Service feature to the
dbus-free kernel keyutils backend (`linux-native`). The Secret Service backend
links `libdbus` at build time, so the CLI failed to compile on any Linux host
without `libdbus-1-dev` — the real headless-storage finding, now documented in
`docs/AGENT_AUTH.md`. keyutils needs no system library and no daemon, so it
builds everywhere and works headless; macOS and Windows keep their native
keychains.

Add unit coverage for the keychain backends (through an injectable in-memory
keystore), the encrypted-file error paths, and the token-refresh and
result-printing branches.

Generated by the operator's software factory.
On behalf of: @benw5483
Co-Authored-By: Actual AI Factory Bot <factory-bot@actual.ai.invalid>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two CI follow-ups on the create-token work.

- The AEAD salt and nonce are now returned from `random_bytes::<N>()` rather
  than filling a caller-owned `[0u8; N]` in place. Behaviour is identical (still
  an `OsRng`-filled salt and nonce), but the random value no longer flows out of
  a zero-initialized binding, which clears a false-positive
  `rust/hard-coded-cryptographic-value` CodeQL alert on the salt.
- Add a test that routes `actual auth create-token` through the `auth` command
  dispatch, returning `NotLoggedIn`, so `src/cli/commands/auth.rs` is back at
  full line coverage.

Generated by the operator's software factory.
On behalf of: @benw5483
Co-Authored-By: Actual AI Factory Bot <factory-bot@actual.ai.invalid>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/auth/token_store.rs Dismissed
benw5483 and others added 2 commits July 4, 2026 19:01
`create-token` minted the PAT server-side and only then ran the local
store; a store error bailed via `?` before the token or its id were ever
printed, so the raw token was lost. The most likely store failure is a
headless Linux box with no `ACTUAL_TOKEN_PASSPHRASE` and no OS keychain —
exactly the target environment — so every failed attempt burned a live,
unrevocable token the user could neither capture nor revoke.

Close the gap from both ends:

- Add `token_store::precheck`, a non-writing probe that confirms a backend
  is available, and call it BEFORE minting. The predictable headless /
  no-passphrase case now fails cleanly with no token minted at all.
- On any residual post-mint store failure (a full disk, a keychain lock
  race the precheck cannot foresee), still surface the token and its id so
  the user can capture and revoke it, then propagate the error. A minted
  token is never silently dropped.

Route `create-token` output through injected writers so the
stdout=secret / stderr=chrome split — a load-bearing capture contract — is
unit-testable. New tests cover: the pre-mint precheck refusal, the
store-fails-after-mint recovery, and that stdout carries only the token.

Generated by the operator's software factory.
On behalf of: @benw5483
Co-Authored-By: Actual AI Factory Bot <factory-bot@actual.ai.invalid>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The store-failure revision added `token_store::precheck` (keyring/auto arms) and
`keyring_available`, and routed `create-token` output through injected writers
with `?` error propagation. Those new lines were not fully exercised, so the
100%-per-file coverage gate failed on `token_store.rs` (97.16%) and
`create_token.rs` (95.93%).

Add test-only coverage; no production code changes:

- `precheck` under keyring and auto modes, both available and unavailable, via
  the existing in-memory keyring mock — extended with a fail-on-build toggle so
  `keyring_available`'s entry-init-failure leg is reachable without OS trickery.
- `print_unstored_token`'s no-id branch (a mint whose response carries no id).
- a failing-writer sweep that drives every `writeln!` `?` error path in
  `print_result` and `print_unstored_token`, asserting a broken output stream
  surfaces as an error rather than a false success.

Both files are now at 100% line coverage.

Generated by the operator's software factory.
On behalf of: @benw5483
Co-Authored-By: Actual AI Factory Bot <factory-bot@actual.ai.invalid>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@benw5483
benw5483 force-pushed the feat/auth-create-token branch from f1b811f to 92954b0 Compare July 6, 2026 14:23
@benw5483
benw5483 marked this pull request as ready for review July 6, 2026 17:27
@davidmiuraactualai

Copy link
Copy Markdown

Actual Adversarial Review

Key findings

  • ⚠️ APR-001 P2 Emit a recoverable token before fallible stderr output — the post-mint recovery path can return before writing the token to stdout at create_token.rs:113.

⚠️ APR-001 P2 Emit a recoverable token before fallible stderr output

Location: src/cli/commands/create_token.rs:113 at 92954b0b2242e2e7de9fb0e9f96b1c001230569c

Risk: A live PAT can again become unrecoverable when local storage fails and stderr is unavailable, even though stdout remains writable.

Evidence and required correction
  • Code: after a successful server-side mint, run_inner calls print_unstored_token(out, err, ...) and deliberately discards its result. That helper first writes error chrome to err at line 194 with ?; an I/O failure therefore returns before reaching the only stdout token write at line 223. The caller then returns only store_err.
  • Repository ADR: secure secrets management requires secure credential handling across CLI commands and runtime environments.
  • Public: Rust std::io::Write documents that writes and formatted writes return I/O errors; a fallible stderr write is a normal error path, not a guaranteed side effect.
  • Verification: cargo test print_unstored_token_propagates_stderr_write_failure --lib -- --nocapture passed. That existing test proves the helper returns an error for a failing stderr writer, but it does not assert that the available stdout writer still receives the minted secret.

Required correction: Once a PAT has been minted and local storage fails, attempt the stdout-only token emission before any fallible stderr diagnostics, and independently make best-effort diagnostics afterward. Add a regression with a writable stdout buffer and an immediately failing stderr writer that asserts stdout contains exactly the minted token and the command still returns the storage error.

🤖 Corrective action — ⚠️ APR-001 P2 — Option A: preserve stdout recovery first

Tradeoff: This retains the current stream contract while prioritizing the only recoverability channel; stderr diagnostics may be incomplete when stderr itself is broken.

Fix APR-001 P2, “Emit a recoverable token before fallible stderr output,” in actual-software/actual-cli at src/cli/commands/create_token.rs:113 on 92954b0b2242e2e7de9fb0e9f96b1c001230569c.

Problem: after PAT issuance succeeds but token_store::store fails, run_inner ignores print_unstored_token errors. That helper writes multiple diagnostics to stderr before it writes the secret to stdout. If stderr fails first while stdout remains available, the helper returns early, the token is never emitted, and the already-live PAT cannot be captured or revoked.

Required invariant: after a successful mint with failed local storage, the command attempts to emit the raw PAT exactly once on stdout whenever stdout is writable, regardless of stderr availability; it still returns the storage failure.

Constraints: Follow AGENTS.md, src/AGENTS.md, and docs/adr/8d11aed5-d4a3-4ef6-937a-085f723bd997-adopt-secure-secrets-management-in-ci-cd-pipeline.md; honor .gitignore. Preserve stdout as token-only, never write the PAT to stderr or logs, retain the pre-mint storage check, and keep normal successful output behavior compatible.

Implement: restructure only the post-mint store-failure recovery path so stdout token emission is attempted before fallible stderr chrome, then perform diagnostics independently on a best-effort basis. Preserve the original storage error as the command result.

Verify: add a regression using a writable stdout buffer and a stderr writer that fails on its first write. Assert stdout is exactly the minted token plus newline, stderr contains no secret, and run_inner returns the original storage error. Run cargo test print_unstored_token --lib and cargo test create_token --lib.

Do not: print a raw PAT on stderr, logs, command arguments, or a second stdout line; silently report success after storage failure; or change unrelated token-storage backends.

Copy into a coding agent to run.

Architecture intent

No architecture-intent decision is needed for this localized recovery-order correction.


Review metadata

Important

One P2 finding remains unresolved at head 92954b0b2242e2e7de9fb0e9f96b1c001230569c. This is the canonical remediation record for the PR.

Compared: upstream main at cfcae4cd84645417e7d8042517a69cbd55d22404 … PR head 92954b0b2242e2e7de9fb0e9f96b1c001230569c
CI: Build for x86_64 and aarch64, Test, Lint, Coverage Enforcement, and CodeQL pass; Live E2E is skipped. Local verification: the focused stderr-failure unit test passes, and git diff --check reports no whitespace errors.

…hrome

On the post-mint store-failure path, print_unstored_token wrote the human-facing
error/warning chrome to stderr (with `?`) before emitting the token to stdout. A
failing or closed stderr made the early `?` return before the token line ran, so
a live, already-minted PAT that was never persisted locally could be lost
unrecoverably even when stdout was writable — the one irrecoverable artifact on
this path, stranded by a broken secondary stream.

Emit the token to stdout as the very first output here, then write the stderr
guidance strictly best-effort (ignore its result rather than propagating with
`?`), so a stderr failure can never prevent or discard the stdout token write.
The stdout=secret / stderr=chrome split is unchanged.

Repurpose the stderr-write-failure unit test to assert the token still reaches
stdout, alone, when stderr fails on its very first write (both the has-id and
no-id arms); the file stays at 100% line coverage.

Addresses adversarial review finding APR-001 (P2).

Generated by the operator's software factory.
City: factory-main · Agent: local-core.builder-1
On behalf of: @benw5483
Co-Authored-By: operator-factory-bot <factory-bot@actual.ai.invalid>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants