Skip to content

feat(auth): add headless jwt-bearer client (actual mint-token)#803

Open
benw5483 wants to merge 2 commits into
mainfrom
feat/mint-token-jwt-bearer
Open

feat(auth): add headless jwt-bearer client (actual mint-token)#803
benw5483 wants to merge 2 commits into
mainfrom
feat/mint-token-jwt-bearer

Conversation

@benw5483

@benw5483 benw5483 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

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, and auth create-token / login --device in #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:

  • Assertion header{ alg: RS256 | ES256, kid, typ: JWT }. HS* and none cannot be represented, let alone emitted — closing alg-confusion on the client the same way the server closes it.
  • Assertion claimsiss == sub == <service-account-id> (validated as a UUID before signing), aud defaulting to the issuer origin, a fresh unique jti per call, iat = now, and exp clamped to at most 300s after iat.
  • RequestPOST <issuer>/api/oauth/token with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, assertion=<JWS>, and an optional space-delimited scope.

Headless usage

Every input comes from a flag, an environment variable, or a file:

# Key inline via env (preferred with a secret manager — keeps it off argv):
export ACTUAL_SERVICE_ACCOUNT_KEY="$(cat service-account.pem)"
TOKEN=$(actual mint-token \
  --service-account-id 3f8a1c2e-4b5d-4e6f-8a9b-0c1d2e3f4a5b \
  --kid my-registered-key \
  --scope adr:query --scope adr:review)
# $TOKEN is exactly the access token — nothing else on stdout.

--key <PATH> (or ACTUAL_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 --alg is given. --json swaps 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:

  1. New top-level mint-token command, not an auth subcommand. feat(auth): add actual auth create-token for non-interactive auth #801 and feat(auth): add actual login --device for browserless sign-in #802 are still open and both restructure the auth surface; adding a sibling auth subcommand now would collide with them. A standalone command keeps this change independent. It can fold under auth once those land and that surface settles.
  2. Token goes to stdout; persisting it is deferred. The load-bearing deliverable is the headless mint with the stdout-capture contract — an agent captures the token and uses it directly. Persisting into the existing on-disk credential store is deferred: that store models a browser-login user session (it requires a member id), which a service-account grant has no natural value for. A follow-up can add an opt-in service-account store if wanted.
  3. RS256 and ES256 both supported — the server accepts both, so the client does too, inferring from the key type.
  4. Key generation + registration is out of scope (assumes an already-registered key), as this change is scoped to the sign→mint core.

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):

  • A signed assertion verifies under the server's exact validation for both RS256 and ES256 (ephemeral keypair generated at test time; verified with the pinned algorithm, required audience, and exp), and carries iss == sub == UUID, an accepted aud, exp - iat <= 300, a header alg ∈ {RS256, ES256} + kid, and a fresh unique jti per call.
  • Forbidden algorithms (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).
  • The mint request carries the right wire shape (grant_type, assertion, scope); a server invalid_grant surfaces cleanly with no stack trace or secret leakage.
  • End-to-end against a mock token endpoint: the real binary signs, mints, and prints only the token to stdout (status on stderr); --json stays 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 --release all 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

  • Transport — reuses the existing HTTPS-only guard; a non-HTTPS, non-loopback issuer is rejected before any assertion leaves the process.
  • No secret logging — the minted token's Debug impl redacts the token; nothing logs key or token material; the private key is read from a file or env, never a CLI arg.
  • One-shot assertions — a fresh jti per call and a short (default 60s, ≤300s) lifetime bound the replay/leak window; the server anti-replays on the jti.
  • No symmetric/unsigned algorithms — the client can only emit RS256 or ES256.

Test plan for a reviewer

  • cargo test (unit + tests/mint_token_cli.rs)
  • cargo clippy -- -D warnings and cargo fmt --check
  • actual mint-token --help reads clearly for a headless caller

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

benw5483 and others added 2 commits July 6, 2026 01:49
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>
@benw5483 benw5483 force-pushed the feat/mint-token-jwt-bearer branch from 27951c2 to 21f8500 Compare July 6, 2026 14:24
@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.

1 participant