Skip to content

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

Merged
benw5483 merged 6 commits into
mainfrom
feat/mint-token-jwt-bearer
Aug 12, 2026
Merged

feat(auth): add headless jwt-bearer client (actual mint-token)#803
benw5483 merged 6 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.

SEC1 EC keys: rejected with the conversion command, not converted

First-pass review asked that SEC1 keys (BEGIN EC PRIVATE KEY, RFC 5915) either be supported or be rejected with PKCS#8 guidance. Inferring ES256 from that header alone doesn't fail at inference time, which is the problem: it fails later, at signing, far from its cause.

This branch rejects them.

The premise was checked against the pinned jsonwebtoken rather than taken on report, since a version bump could have changed the answer. Its PEM decoder has arms for PKCS#1 and PKCS#8 only, the SEC1 label falls through to InvalidKeyFormat, and as_ec_private_key is documented there as "Can only be PKCS8". A test pins that premise, so if a later version does learn SEC1 it goes red, and the rejection doesn't quietly outlive its reason.

Converting was the other option the review allowed, and it's turned down here on dependency surface. The crates that can re-encode a key are dev-dependencies on this branch by design, so converting would promote a key re-encoder onto the credential path to fix what's really a wrong error message. Rejecting keeps the shipped binary's key handling at "read the PEM, hand it to the signer".

Both entry points give the guidance, not just the reported one. Omitting --alg goes through inference; passing --alg es256 skips inference and fails at the key loader instead. Either way you get the same error: the message names the encoding and the target, and the hint carries the exact openssl pkcs8 -topk8 -nocrypt command. Converting re-encodes the same key pair, so the registered public key, and therefore --kid, isn't touched.

flowchart TD
    K["SEC1 EC private key"] --> A{"--alg given?"}
    A -->|"no"| I["infer_from_pem"]
    A -->|"es256"| L["EncodingKey::from_ec_pem"]
    I -->|"SEC1 header"| E["sec1_ec_key_error()"]
    L -->|"load fails and key is SEC1"| E
    E --> S["stderr: message names PKCS#8, hint carries the openssl command"]
Loading

The RSA branch next door is correct, and it's now pinned either way. BEGIN RSA PRIVATE KEY is PKCS#1, which the library accepts directly, so answering RS256 on that header alone is safe where the EC one wasn't. That asymmetry is load-bearing and easy to tidy into a bug, so a test signs with a genuine PKCS#1 RSA key rather than leaving the claim in a comment.

What the tests control, and what they only cover

Each mechanism was checked by injection. One result is worth stating plainly, because the obvious reading of the end-to-end test is wrong:

Injection What goes red
infer_from_pem returns Es256 on the SEC1 header again, restoring the reported defect the unit test infer_from_pem_rejects_a_genuine_sec1_ec_key, and nothing else
the openssl command moves off the hint and into the message the end-to-end test mint_token_refuses_a_sec1_ec_key_and_shows_the_conversion_command

The end-to-end test is coverage of the user-visible surface. It isn't the regression control for the inference fix, and it can't be: two guards enforce one property, so restoring the original defect leaves the loader guard emitting identical stderr and every end-to-end test stays green, including the iteration that omits --alg. The control for that defect is the unit test named above, which drives inference directly.

Delivery is the thing it does control. The panel truncates every row to the terminal width, so moving the command into the message reds it with the remedy visibly cut off partway through. Its doc comment now says exactly that, so the next reader doesn't trust it for more than it does.

Live E2E at this head

The review also noted that no live client/server mint was exercised, and that the Live E2E job was skipped at the exact head. It can't be run there. The job is gated on github.event_name == 'merge_group', so it runs from the merge queue and never on a pull_request event, and the workflow carries no workflow_dispatch to force one.

Running it wouldn't close that gap on its own either. scripts/e2e.sh has no mint-token or service-account scenario today, so a live mint needs a new scenario plus a service-account key registered in the E2E environment. That's separate work, flagged here for a decision rather than picked up in this change.

Documentation

mint-token is a new top-level command, so it needed a home in the docs this repo already points readers at. auth create-token is the exact precedent: it shipped with a README Commands line and a section in docs/AGENT_AUTH.md, and this follows it.

  • README gains a Commands line, and the paragraph routing readers of "non-interactive (CI / agent) authentication" to docs/AGENT_AUTH.md now names both headless paths rather than only the PAT one. That paragraph was the stale signpost. It was sending this feature's primary audience to a doc that never mentioned the feature.
  • docs/AGENT_AUTH.md gains a "Two headless paths" orientation near the top, and a "Service-account keys" section covering the flow end to end: the stdout capture contract, the two key-injection environment variables and why no flag accepts key material, the PKCS#8 requirement with the SEC1 refusal and its openssl pkcs8 -topk8 -nocrypt conversion, the algorithm/lifetime/audience flags, and a CI step that masks the minted token before it can reach a log. Its existing ## Endpoint heading is now ## Endpoint (create-token), since the doc describes two endpoints from here on.

Two doc-comment corrections ride along, both the same defect class caught once already this round. resolve_lifetime described 0 as "the unset sentinel from the default", but clap's default_value_t = 60 means an omitted flag arrives as 60, and 0 only ever arrives explicitly. The flag's own help said "clamped to 1..=300", which holds for every value except 0, the one a reader is most likely to try. Both now describe what the code actually does. The copy moved and the behavior did not, because what 0 should mean is a product call rather than a doc fix.

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.
• City: factory-main · Agent: local-core.builder-1
• On behalf of: @benw5483

@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
@davidmiuraactualai

Copy link
Copy Markdown

Actual Adversarial Review

Key findings


🛑 APR-001 P1 Parse the issuer before allowing plaintext loopback transport

Location: src/cli/commands/mint_token.rs:68 at 21f8500712b06c641ee591d0daed98730e7ae193
Risk: A signed JWT bearer assertion can be sent over plaintext to an attacker-controlled hostname and replayed for an access token during its validity window.

Evidence and required correction

Issue: The new command signs its assertion before calling the shared transport guard. That guard decides whether HTTP is loopback with string prefixes in src/auth/oauth.rs:146, so http://localhost.evil.invalid, http://localhost@evil.invalid, and http://127.0.0.1.evil.invalid are all treated as local. The command can also set --aud to the real authorization-server identity, so an endpoint controlled by that host receives an assertion the authorization server can accept.

Evidence:

  • Code: the new command invokes oauth::build_http_client(&base_url) only after build_and_sign_assertion; the predicate uses starts_with rather than parsed URL scheme and host identity.
  • ADR: docs/adr/8d11aed5-d4a3-4ef6-937a-085f723bd997-adopt-secure-secrets-management-in-ci-cd-pipeline.md requires secret-handling paths to prevent credential exposure.
  • Public: RFC 7523 section 2.1 defines the signed assertion as the JWT-bearer authorization grant submitted to the token endpoint; section 3 requires the authorization server to accept a valid assertion whose audience identifies that server within its expiration window.
  • Verification: ACTUAL_SERVICE_ACCOUNT_KEY=<ephemeral P-256 PEM> cargo run --quiet -- mint-token ... --issuer http://localhost.evil.invalid --aud https://app.actual.ai signed the assertion and reached Token mint request failed: error sending request, rather than returning the expected HTTPS configuration error. cargo test auth::oauth::tests::test_build_http_client --lib -- --nocapture passed, but the current test only checks http://example.com and does not exercise prefix lookalikes.

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 allowlist

Tradeoff: This preserves HTTP support for local development while making the exception depend on URL semantics rather than a textual prefix.

Fix APR-001 P1, Parse the issuer before allowing plaintext loopback transport, in actual-software/actual-cli at src/auth/oauth.rs:145 and src/cli/commands/mint_token.rs:68 on 21f8500712b06c641ee591d0daed98730e7ae193.

Problem: actual mint-token signs a JWT bearer assertion, then the shared HTTP guard accepts http://localhost.evil.invalid and related prefix lookalikes because it uses starts_with. With --aud set to the real authorization-server identity, an attacker controlling that hostname receives a bearer assertion that can be exchanged during its validity window.
Required invariant: no signed assertion or OAuth credential is sent over plaintext HTTP unless the parsed issuer host is an exact intended loopback host or loopback IP address.
Constraints: Follow AGENTS.md and src/AGENTS.md, including the secure-secrets ADR and comprehensive unit-testing requirement; honor .gitignore; preserve valid HTTPS issuers and supported local HTTP test endpoints.
Implement: parse and validate the issuer URL in the shared transport guard. Require HTTPS except for HTTP with an exact loopback hostname or loopback IP. Reject prefix lookalikes, user-info host confusion, malformed URLs, and non-loopback hosts before creating a client that can send the assertion.
Verify: Add unit coverage that rejects http://localhost.evil.invalid, http://localhost@evil.invalid, http://127.0.0.1.evil.invalid, and an IPv6 lookalike, while allowing http://localhost:<port>, http://127.0.0.1:<port>, and http://[::1]:<port>. Add a mint-token regression that fails with the HTTPS configuration error before any request. Run cargo test --workspace --features integration, cargo clippy -- -D warnings, and cargo fmt --check.
Do not: weaken HTTPS enforcement, send a real assertion to a non-loopback HTTP endpoint, log assertion or key material, or modify unrelated command behavior.

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

  • APR-001 Policy: JWT-bearer assertions and OAuth credentials may use HTTP only after parsed URL validation establishes an exact loopback destination.

Review metadata

Important

One finding remains unresolved at head 21f8500712b06c641ee591d0daed98730e7ae193. This is the canonical remediation record for the PR.

Compared: upstream main at cfcae4cd84645417e7d8042517a69cbd55d22404 … PR head 21f8500712b06c641ee591d0daed98730e7ae193
CI: Build, Test, Lint, Coverage Enforcement, and CodeQL succeeded for this head; Live E2E was skipped. Local targeted guard tests passed, while the manual prefix-lookalike invocation reproduced the guard bypass.

@benw5483
benw5483 requested a review from wattswolf July 27, 2026 15:21
austinborn
austinborn previously approved these changes Jul 27, 2026

@austinborn austinborn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff at head 21f85007, focused on assertion forgery, replay, and audience handling.

No blocking findings. Approving.

What holds up

Algorithm confusion is closed at the type level, which is stronger than the usual runtime check. AssertionAlgorithm has exactly two variants, so HS256 and none aren't merely rejected at parse time, they're unrepresentable. No code path can emit a symmetric or unsigned alg.

Replay and lifetime look right. Every call mints a fresh jti from Uuid::new_v4(), exp - iat is clamped to the server's 300-second ceiling with a 60-second default well under it, and iss == sub == service_account_id matches the server's contract.

is_uuid initially looked like a reimplementation of something the already-present uuid crate does. It isn't. Uuid::parse_str accepts any version and variant, while this enforces version 1-5 and variant 8/9/a/b specifically to mirror the server's isUuid, so the client accepts a principal id exactly when the server would. The doc comment says as much. Worth flagging anyway, so nobody "simplifies" it later.

Secret handling matches the module's standard: MintedToken hand-writes a redacting Debug, mint_error surfaces only OAuth error JSON, key-load failures never echo PEM content, and write_token_output keeps stdout to the token alone with all status on stderr.

Non-blocking

1. An explicit --aud combined with a redirected issuer would sign a correctly-audienced assertion and POST it to the wrong host. resolve_audience defaults aud to the resolved issuer, and that default is the safe case: point --issuer at an attacker and the assertion carries aud: https://attacker, which the real server rejects, so what leaks is useless. Passing --aud https://app.actual.ai while ACTUAL_AUTH_URL points somewhere else breaks that coupling. The attacker then holds a valid, real-audience assertion and roughly 60 seconds to replay it for a token carrying the principal's full scope whitelist.

I don't think this is blocking, and I'd rather be explicit about why than leave it implied. It needs a second misconfiguration stacked on the redirect, and anyone who can set ACTUAL_AUTH_URL in an agent's environment can usually read ACTUAL_SERVICE_ACCOUNT_KEY out of that same environment, so the marginal escalation is small. Closing it is cheap, though: warn, or refuse, when an explicit aud is neither the resolved issuer nor <issuer>/api/oauth/token. That's defense in depth for the unattended path, which is where a poisoned env var is most plausible in the first place.

2. resolve_lifetime's comment describes a sentinel that clap never produces. It treats 0 as "the unset sentinel from the default", but assertion_ttl_seconds is default_value_t = 60, so 0 only shows up when someone passes --assertion-ttl-seconds 0 explicitly. That input becomes 60 rather than clamping to 1. Defensible, but it isn't what the comment describes.

3. The key file is read with no permission check. load_key_pem reads whatever --key points at. Given that token_store goes out of its way to write 0600, a warning when a private key is group- or world-readable would round out the story.


Posted by the operator's software factory.
• City: factory-main · Agent: local-core.builder-1
• On behalf of: @austinborn

@wattswolf wattswolf left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-tested jwt-bearer client, but the loopback transport check lets a signed assertion go out over plaintext to an attacker-controlled host. Fix the guard before merge.

  • The HTTPS bypass keys off base_url.starts_with("http://localhost") / "http://127.0.0.1", so http://localhost.evil.invalid, http://localhost@evil.invalid, and http://127.0.0.1.evil.invalid all read as loopback and get plaintext. With --aud pointed at the real server, the minted assertion is a replayable bearer sent in the clear — src/auth/oauth.rs:146. Parse the URL and match the exact host (localhost, 127.0.0.1, ::1), not a prefix.
  • Same guard is reused by mint_token via build_http_client, so this new signed-assertion path inherits the weakness with no local override — src/cli/commands/mint_token.rs:68.

@wattswolf wattswolf left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The client implementation looks strong, but I’d hold approval until the integration evidence is complete.

The JWT handling is well designed: RS256/ES256-only signing, bounded claims and lifetime, fresh jti values, redacted debugging, token-only stdout, and substantial unit/E2E coverage. I also checked the #804-then-#803 merge order, and the hardened transport guard is preserved without conflict.

The remaining gaps are integration-related:

  • The current head, 7debf81, predates #804, so there isn’t yet a real post-#804 SHA with green CI.

  • The matching server grant in sprintreview#3267 is still open and conflicting on top of prerequisite #3259.

  • Because the server route has not landed, the complete client/server mint flow could not be verified live.

  • Before approval, I’d like to see #804 land, this PR refreshed onto the resulting main, CI green on the new head, and the #3259/#3267 server rollout confirmed.

One non-blocking hardening suggestion: consider preventing an explicit --aud from differing from --issuer, which would reduce the chance of a valid short-lived assertion being sent to the wrong HTTPS endpoint.

benw5483 and others added 2 commits July 30, 2026 11:08
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 7debf81 to 616a6c6 Compare July 30, 2026 15:08
@wattswolf

Copy link
Copy Markdown

Actual first-pass review — Request changes

Risk: Moderate · Confidence: High · Commit: 616a6c6a7b4c

SEC1 P-256 keys are recognized as ES256 but cannot be signed by the pinned library, so a common advertised key format deterministically fails.

Material risks

  • [P2] Handle SEC1 EC keys correctlysrc/auth/jwt_bearer.rs:98. Valid SEC1 P-256 keys fail before any token request, breaking an advertised ES256 path. Required: Support SEC1 or reject it with PKCS#8 guidance and a real regression test.

Review gaps

  • No live client/server mint was exercised; the exact-head Live E2E job was skipped.

Next step: Handle SEC1 explicitly, add a real SEC1 regression test, then rerun CI and the live mint path. A human reviewer owns the final decision.

benw5483 and others added 4 commits August 10, 2026 13:51
`AssertionAlgorithm::infer_from_pem` returned `Es256` for a key whose PEM
header said `BEGIN EC PRIVATE KEY`, on the header string alone and with no
parser check. That header is SEC1 (RFC 5915), and the pinned jsonwebtoken
cannot sign with it: its PEM decoder handles PKCS#1 and PKCS#8 only, and the
SEC1 label falls through to `InvalidKeyFormat`. So the inference returned a
wrong answer that surfaced later, at signing time, far from its cause. SEC1 is
what `openssl ecparam -genkey` writes by default, which makes it a likely
shape for a user to arrive with.

Reject it at inference instead, naming PKCS#8 and the exact conversion command.
The explicit `--alg es256` path skips inference and fails in the key loader, so
it gets the same remedy there rather than the generic wrong-key-type message.

The guidance rides on a new `Sec1KeyUnsupported` variant that carries its hint,
the way `OrgMismatch` already does. That split is load-bearing: the error panel
truncates every row to the terminal width, so a conversion command baked into
Display is the first thing a user loses. Verified against the real binary — both
message and hint now render whole at 80 columns.

The RSA branch has the same header-match shape and was checked rather than
assumed. It is sound: the decoder maps `BEGIN RSA PRIVATE KEY` to PKCS#1 and
`as_rsa_key` returns it directly. A test now pins that asymmetry, so a library
change that drops PKCS#1 is caught here instead of at a user's signing call.

Tests use genuine SEC1 P-256 and PKCS#1 RSA keys generated at runtime, matching
this branch's existing practice of committing no key material. Each mechanism
was checked by injection: reverting the inference fix, dropping the loader arm,
or moving the command back into the message each reds exactly its own test.

Generated by the operator's software factory.
City: factory-main · Agent: local-core.builder-2
On behalf of: @benw5483
Co-Authored-By: <operator-factory-bot> <factory-bot@actual.invalid>
The doc comment on mint_token_refuses_a_sec1_ec_key_and_shows_the_conversion_command
asserted that its two loop iterations cover the inference and explicit-alg entry
points as distinct code paths. They do not, and cannot. infer_from_pem and the
EncodingKey loader both answer a SEC1 key with the same sec1_ec_key_error(), so
two guards enforce one property and an injection at either site alone is absorbed
by the other.

Verified by injection rather than reasoned. Restoring the original defect in
infer_from_pem (returning Ok(Es256) on the header match) reds only the unit test
infer_from_pem_rejects_a_genuine_sec1_ec_key; all six end-to-end tests stay green,
including the iteration that omits --alg, because the loader guard then emits
identical stderr. Moving the openssl command off the hint and into the message
does red the end-to-end test, and reproduces the truncation it exists to catch:
the row arrives as "PKCS#8 is required. Co..." with the remedy cut off.

The comment now states what the test pins, which is the remedy arriving whole at
the terminal, names the injection axis that controls it, which is the shared
emitter, and names the unit test that is the real control for the inference fix.
Coverage of both entry points is kept and described as coverage.

Comment only. The guards, the error text, and every assertion are untouched.

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.invalid>
The PR adds a top-level `actual mint-token` and documented it nowhere, which
leaves two stale signposts behind. README's Commands block enumerates the CLI
surface and omitted the new command, and the paragraph under it routes anyone
reading about "non-interactive (CI / agent) authentication" to
docs/AGENT_AUTH.md, a doc that covered only `auth create-token`. This feature's
primary audience was being sent to a page that never mentioned it.

README gets a Commands line, and the signpost paragraph now names both headless
paths rather than only the PAT one.

docs/AGENT_AUTH.md gets a "Two headless paths" orientation near the top and a
"Service-account keys" section at the end covering the flow end to end: the
capture contract, where the key comes from and why no flag accepts key material,
the PKCS#8 requirement with the SEC1 refusal and its openssl conversion, the
algorithm/lifetime/audience flags, and a CI step that masks the minted token.
Its existing "Endpoint" heading is now "Endpoint (create-token)", since the doc
describes two endpoints from here on.

Documentation only. No behavior change, and `auth create-token` is the exact
precedent: it shipped with both a README line and this doc.

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.invalid>
Two descriptions of the same flag disagreed with the code, in opposite
directions.

resolve_lifetime's doc comment called `0` "the unset sentinel from the default".
The flag carries clap's own `default_value_t = 60`, so an omitted flag arrives
as `60` and never as `0`; the only way into the `0` arm is to pass
`--assertion-ttl-seconds 0` explicitly. The inline comment in
resolve_lifetime_uses_default_only_for_zero repeated the same false framing.
Both now say what actually reaches the function.

The flag's own help said "clamped to 1..=300", which is true of every value
except the one a reader is most likely to try: `0` resolves to 60, not 1,
because resolve_lifetime intercepts it before build_and_sign_assertion's
`.clamp(1, MAX_ASSERTION_LIFETIME_SECONDS)`. The help now says so. The copy
moved, not the behavior, since changing what `0` does is a product call rather
than a doc fix.

Comment and help text only.

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.invalid>
@benw5483

Copy link
Copy Markdown
Contributor Author

Actual first-pass review — Request changes

Risk: Moderate · Confidence: High · Commit: 616a6c6a7b4c

SEC1 P-256 keys are recognized as ES256 but cannot be signed by the pinned library, so a common advertised key format deterministically fails.

Material risks

  • [P2] Handle SEC1 EC keys correctlysrc/auth/jwt_bearer.rs:98. Valid SEC1 P-256 keys fail before any token request, breaking an advertised ES256 path. Required: Support SEC1 or reject it with PKCS#8 guidance and a real regression test.

Review gaps

  • No live client/server mint was exercised; the exact-head Live E2E job was skipped.

Next step: Handle SEC1 explicitly, add a real SEC1 regression test, then rerun CI and the live mint path. A human reviewer owns the final decision.

@wattswolf This has been addressed.
SEC1 EC keys are refused at both entry points, inference and the key loader, with a message naming PKCS#8 and a hint carrying the exact openssl pkcs8 -topk8 -nocrypt command

@wattswolf wattswolf left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 05c6516f5ca8. The SEC1 issue is resolved in both inference and explicit ES256 paths with genuine SEC1 regression coverage. Build, tests, lint, coverage, and CodeQL pass. No blocking code findings remain. The only remaining gap is a live client/server mint, which this PR enables.”

@benw5483
benw5483 added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit a638955 Aug 12, 2026
10 checks passed
@benw5483
benw5483 deleted the feat/mint-token-jwt-bearer branch August 12, 2026 17:34
@wattswolf

Copy link
Copy Markdown

Actual first-pass review — Needs additional review

Risk: Unknown · Confidence: High · Commit: 05c6516f5ca8

No blocking code defect was identified. The SEC1 failure is now handled at inference and explicit ES256 loading with real-key regression coverage; exact-head CI is green and both server prerequisites are merged. The real client/server mint remains unverified.

Material risks

None identified.

Review gaps

  • No live service-account assertion was exchanged against the deployed authorization server; the PR Live E2E job is skipped and does not include mint-token.

Next step: Proceed with human review. A human reviewer owns the final decision.

Review evidence

Full findings

  • None.

Open questions and missing evidence

  • Whether the team accepts the live mint as an immediate follow-up because this CLI change is what enables that test.

Skipped or inconclusive checks

  • No target code was executed locally under the review workflow; GitHub exact-head build, test, lint, coverage, and CodeQL results were used.

Coverage and verification ledger

  • Target: PR base cebb2a6, merge-base cebb2a6, head 05c6516.
  • Instructions: Root AGENTS.md, src/AGENTS.md, tests/AGENTS.md, and the machine operating contract were reviewed.
  • Review lenses: Correctness, security, blast radius, test quality, production operability, dependency changes, and token/key handling.
  • ADRs: Complete: modular library architecture, protocol-based public API, secure secrets management, and comprehensive unit testing.
  • Checks: GitHub reports no required checks configured. At head 05c6516, both builds, tests, lint, coverage, and CodeQL succeeded; Live E2E was skipped.
  • Inspection: Final safe inspection fingerprint d942754e786932e36d725e4177e3ea719c8892cac80999751e2f995792061859 with no inspection limitations.

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.

4 participants