Skip to content

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

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

feat(auth): add actual auth create-token for non-interactive auth#801
benw5483 wants to merge 5 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
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.

2 participants