feat(auth): add headless jwt-bearer client (actual mint-token)#803
Open
benw5483 wants to merge 2 commits into
Open
feat(auth): add headless jwt-bearer client (actual mint-token)#803benw5483 wants to merge 2 commits into
benw5483 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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