feat(auth): add headless jwt-bearer client (actual mint-token)#803
feat(auth): add headless jwt-bearer client (actual mint-token)#803benw5483 wants to merge 2 commits into
Conversation
Add `actual mint-token`, a fully-headless RFC 7523 jwt-bearer client: it signs a short-lived service-account assertion with a registered private key (RS256 or ES256) and exchanges it for an access token at the OAuth token endpoint — no browser, no human, no stored long-lived secret. This is the unattended-agent path the existing enrollment commands do not cover. - New `src/auth/jwt_bearer.rs`: build and sign the assertion, mint the token, and the stdout-only-token output contract. - New `src/cli/commands/mint_token.rs`: the headless command handler. - Reuse the existing HTTPS transport guard in `src/auth/oauth.rs`. - Only RS256/ES256 can be emitted; HS*/none are refused. The minted token is redacted in Debug and printed only to stdout; status goes to stderr. Verified with unit and end-to-end tests: a signed assertion verifies under the server's exact validation for both algorithms, forbidden algorithms and malformed inputs fail cleanly, and the real binary mints against a mock token endpoint printing only the token to stdout. Generated by the operator's software factory. On behalf of: @benw5483 Co-Authored-By: Actual Factory Bot <factory-bot@actual.ai.invalid> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Coverage Enforcement gate was red on three files, not the single lib.rs line the first review flagged: lib.rs (the MintToken dispatch arm), auth/jwt_bearer.rs (15 lines), and cli/commands/mint_token.rs (55 lines, essentially the whole exec() body). Root cause: tests/mint_token_cli.rs — the end-to-end client tests that drive exec() and the mint round-trip — was never added to the coverage workflow's --test list, so it never ran under instrumentation. The binary exits via process::exit, which flushes the llvm-cov profile, so these subprocess tests do contribute coverage once they run. Register the test file in coverage.yml, as that file's own reminder requires. That covers exec()'s orchestration. The remaining gaps are branches on their own lines that no end-to-end test reaches, so cover them the way the surrounding code already does — with small, unit-testable seams: - Extract resolve_lifetime() next to resolve_scope/resolve_audience/ resolve_algorithm, and unit-test the ttl==0 default path. - Split unix_seconds(SystemTime) out of now_unix(), so the clock-before-epoch error path is testable with an injected time (the same pattern build_and_sign_assertion already uses for now). Add unit tests for the jwt_bearer.rs error branches that had no coverage: non-UTF-8 and PKCS#1/SEC1/PKCS#8 key-header inference, the is_uuid dash-position and non-hex-digit rejections, the empty-audience guard, and the mint_error fallback for a non-OAuth error body. Add an in-process run() dispatch test for the MintToken arm in lib.rs, matching the sibling per-command dispatch tests. No production behavior changes; the two extractions are pure refactors and the 100% coverage gate is left intact. Generated by the operator's software factory. On behalf of: @benw5483 Co-Authored-By: <operator-factory-bot> <factory-bot@actual.ai.invalid>
27951c2 to
21f8500
Compare
Actual Adversarial ReviewKey findings
🛑 APR-001 P1 Parse the issuer before allowing plaintext loopback transportLocation: Evidence and required correctionIssue: The new command signs its assertion before calling the shared transport guard. That guard decides whether HTTP is loopback with string prefixes in Evidence:
Required correction: Parse the issuer as a URL before signing or dispatching. Permit plaintext only when the parsed scheme is HTTP and the parsed host is exactly an intended loopback host or loopback IP address; require HTTPS otherwise. Reject malformed URLs, user-info host confusion, and lookalike hostnames before an assertion can leave the process. Add coverage for the rejected lookalikes and the allowed loopback forms. 🤖 Corrective action — 🛑 APR-001 P1 — Option A: parsed loopback allowlistTradeoff: This preserves HTTP support for local development while making the exception depend on URL semantics rather than a textual prefix. Copy into a coding agent to run. 🧭 ADR option — APR-001: Service-account assertion transport boundary · Policy: JWT-bearer assertions and OAuth credentials may use HTTP only after parsed URL validation establishes an exact loopback destination. — Create · Update Architecture intent🧭 Suggested ADR: Service-account assertion transport boundary — Create · Update
Review metadataImportant One finding remains unresolved at head Compared: upstream |
Summary
Adds
actual mint-token, a fully-headless RFC 7523 jwt-bearer client: it signs a short-lived service-account assertion with a registered private key and exchanges it for an access token — no browser, no human, no stored long-lived secret. This is the unattended-agent path that the existing enrollment commands (login, andauth create-token/login --devicein #801 / #802) do not cover: those establish a human-delegated session, whereas an autonomous agent on a dev box or in CI holds its own key and self-issues an assertion.The command mirrors the authorization server's jwt-bearer grant exactly:
{ alg: RS256 | ES256, kid, typ: JWT }.HS*andnonecannot be represented, let alone emitted — closing alg-confusion on the client the same way the server closes it.iss == sub == <service-account-id>(validated as a UUID before signing),auddefaulting to the issuer origin, a fresh uniquejtiper call,iat = now, andexpclamped to at most 300s afteriat.POST <issuer>/api/oauth/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:jwt-bearer,assertion=<JWS>, and an optional space-delimitedscope.Headless usage
Every input comes from a flag, an environment variable, or a file:
--key <PATH>(orACTUAL_SERVICE_ACCOUNT_KEY_FILE) reads the key from a file instead. The algorithm is inferred from the key (RSA → RS256, EC P-256 → ES256) unless--algis given.--jsonswaps stdout for the full token response.Output contract: the raw access token is the only thing on stdout, so
TOKEN=$(actual mint-token …)captures exactly the token; all status goes to stderr. This matches the capture contract used by the other auth commands.Scope decisions
A few calls the spec left open, surfaced here for review:
mint-tokencommand, not anauthsubcommand. feat(auth): addactual auth create-tokenfor non-interactive auth #801 and feat(auth): addactual login --devicefor browserless sign-in #802 are still open and both restructure theauthsurface; adding a siblingauthsubcommand now would collide with them. A standalone command keeps this change independent. It can fold underauthonce those land and that surface settles.Test plan
Unit tests (
src/auth/jwt_bearer.rs,src/cli/commands/mint_token.rs) and end-to-end binary tests (tests/mint_token_cli.rs):exp), and carriesiss == sub ==UUID, an acceptedaud,exp - iat <= 300, a headeralg ∈ {RS256, ES256}+kid, and a fresh uniquejtiper call.HS256,none,RS512, …) are refused; a non-UUID principal and a wrong-key-for-alg fail cleanly (no panic, no key material in the message).grant_type,assertion,scope); a serverinvalid_grantsurfaces cleanly with no stack trace or secret leakage.--jsonstays machine-parseable; a non-HTTPS non-loopback issuer is refused before anything is sent; a non-UUID principal exits non-zero with an empty stdout.cargo fmt --check,cargo clippy -- -D warnings,cargo test,cargo build --releaseall green.A live end-to-end mint against the running server is not included: the server-side grant is not yet reachable from this repo's test environment (it needs a full app + database stack and a pre-registered key). The client is instead proven to produce a spec-compliant, server-verifiable assertion (the unit tests verify the signature under the server's exact validation) and to capture the token correctly.
Security notes
Debugimpl redacts the token; nothing logs key or token material; the private key is read from a file or env, never a CLI arg.jtiper call and a short (default 60s, ≤300s) lifetime bound the replay/leak window; the server anti-replays on thejti.Test plan for a reviewer
cargo test(unit +tests/mint_token_cli.rs)cargo clippy -- -D warningsandcargo fmt --checkactual mint-token --helpreads clearly for a headless caller