From 0913b510fa8a2e2b391a20c157a01aabc07091c7 Mon Sep 17 00:00:00 2001 From: Ben Wisecup Date: Mon, 6 Jul 2026 01:49:47 -0400 Subject: [PATCH 1/2] feat(auth): add headless jwt-bearer client (actual mint-token) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 322 +++++++++++++ Cargo.toml | 7 + src/auth/jwt_bearer.rs | 805 +++++++++++++++++++++++++++++++++ src/auth/mod.rs | 1 + src/auth/oauth.rs | 5 +- src/cli/args.rs | 57 +++ src/cli/commands/mint_token.rs | 271 +++++++++++ src/cli/commands/mod.rs | 1 + src/lib.rs | 1 + tests/mint_token_cli.rs | 199 ++++++++ 10 files changed, 1667 insertions(+), 2 deletions(-) create mode 100644 src/auth/jwt_bearer.rs create mode 100644 src/cli/commands/mint_token.rs create mode 100644 tests/mint_token_cli.rs diff --git a/Cargo.lock b/Cargo.lock index 4fc58110..6b672b92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,12 +20,16 @@ dependencies = [ "glob", "ignore", "indicatif", + "jsonwebtoken", "mockito", "open", + "p256", "predicates", + "rand 0.8.5", "ratatui", "regex", "reqwest", + "rsa", "serde", "serde_json", "serde_yaml", @@ -218,12 +222,24 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -506,6 +522,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -621,6 +643,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -702,6 +736,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -775,6 +820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -826,12 +872,46 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -917,6 +997,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1098,6 +1188,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1194,6 +1285,17 @@ dependencies = [ "memmap2", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -1718,6 +1820,21 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kasuari" version = "0.4.11" @@ -1740,6 +1857,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2019,6 +2139,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2036,6 +2182,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2043,6 +2209,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2102,6 +2269,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2150,6 +2329,25 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2263,6 +2461,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2373,6 +2592,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2715,6 +2943,16 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2729,6 +2967,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2819,6 +3077,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.27" @@ -3022,6 +3294,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3034,6 +3316,18 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -3072,6 +3366,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3346,12 +3656,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3360,6 +3672,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index f3f0e443..2147703d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,8 @@ glob = "0.3" # Crypto sha2 = "0.10" base64 = "0.22" +# JWS signing for the headless RFC 7523 jwt-bearer client (RS256 / ES256) +jsonwebtoken = "9" # Async utilities futures = "0.3" @@ -112,3 +114,8 @@ mockito = "1" tokio = { version = "1", features = ["test-util"] } tracing-test = "0.2" tui-test = { path = "crates/tui-test" } +# Ephemeral keypair generation for the jwt-bearer client tests — generated at +# runtime so no private-key material is committed to this public repo. +rsa = { version = "0.9", features = ["pem"] } +p256 = { version = "0.13", features = ["pem", "pkcs8"] } +rand = "0.8" diff --git a/src/auth/jwt_bearer.rs b/src/auth/jwt_bearer.rs new file mode 100644 index 00000000..3a1036bb --- /dev/null +++ b/src/auth/jwt_bearer.rs @@ -0,0 +1,805 @@ +//! Headless RFC 7523 JWT-bearer client: sign a service-account assertion with a +//! registered private key and mint a short-lived access token from the OAuth +//! token endpoint, with **no browser and no human**. +//! +//! This is the unattended agent path — contrast `login` (browser OAuth) and +//! `login --device` (a human approves a code), which are the human-delegated +//! *enrollment* leg. Here the agent already holds a private key whose matching +//! PUBLIC key is registered server-side; it self-issues a one-shot signed +//! assertion and exchanges it for a token. +//! +//! The contract is mirrored exactly from the authorization server's RFC 7523 +//! jwt-bearer grant: +//! +//! - `grant_type` = [`JWT_BEARER_GRANT_TYPE`]. +//! - Assertion header: `{ alg: RS256 | ES256, kid, typ: JWT }`. HS*/`none` are +//! refused here (there is no way to construct them) and server-side, closing +//! alg-confusion. +//! - Assertion claims: `iss == sub == ` (a valid UUID), +//! `aud` one of the server's accepted audiences, a unique `jti` per call +//! (server anti-replays), `iat = now`, `exp <= iat + 300`. +//! - Request: POST `${issuer}/api/oauth/token` with `grant_type` + `assertion` +//! + optional `scope` (space-delimited subset of the principal's grant). +//! +//! Nothing in this module logs token or key material. + +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::ActualError; + +/// RFC 7523 JWT-bearer grant type URN. +pub const JWT_BEARER_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + +/// The authorization server's hard ceiling on an assertion's lifetime +/// (`exp - iat`), in seconds. An assertion whose window exceeds this is +/// rejected. +pub const MAX_ASSERTION_LIFETIME_SECONDS: u64 = 300; + +/// Default assertion lifetime. Deliberately well under +/// [`MAX_ASSERTION_LIFETIME_SECONDS`] so that clock rounding can never push +/// `exp - iat` over the cap, while still leaving ample room for the mint +/// round-trip. The assertion is one-shot and used immediately. +pub const DEFAULT_ASSERTION_LIFETIME_SECONDS: u64 = 60; + +/// The asymmetric JWS algorithms the authorization server accepts. Symmetric +/// (`HS*`) and unsigned (`none`) assertions are absent by design — they have no +/// public-key story and would open alg-confusion — so this enum has no way to +/// represent them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssertionAlgorithm { + /// RSASSA-PKCS1-v1_5 using SHA-256; signs with an RSA private key. + Rs256, + /// ECDSA using P-256 and SHA-256; signs with an EC (P-256) private key. + Es256, +} + +impl AssertionAlgorithm { + /// The `jsonwebtoken` algorithm this maps to. + fn to_jwt(self) -> Algorithm { + match self { + AssertionAlgorithm::Rs256 => Algorithm::RS256, + AssertionAlgorithm::Es256 => Algorithm::ES256, + } + } + + /// The canonical `alg` header string. + pub fn as_str(self) -> &'static str { + match self { + AssertionAlgorithm::Rs256 => "RS256", + AssertionAlgorithm::Es256 => "ES256", + } + } + + /// Parse an algorithm name, case-insensitively. Only the two asymmetric + /// algorithms the server accepts are recognized; `HS256`, `none`, `RS512`, + /// etc. are rejected so the client can never emit a forbidden `alg`. + pub fn parse(value: &str) -> Result { + match value.trim().to_ascii_uppercase().as_str() { + "RS256" => Ok(AssertionAlgorithm::Rs256), + "ES256" => Ok(AssertionAlgorithm::Es256), + other => Err(ActualError::ConfigError(format!( + "Unsupported assertion algorithm '{other}'. \ + Only RS256 and ES256 are accepted (HS*/none are refused)." + ))), + } + } + + /// Infer the algorithm from a private-key PEM: an RSA key signs RS256, an + /// EC key signs ES256. Used when the caller does not pass `--alg`. + pub fn infer_from_pem(private_key_pem: &[u8]) -> Result { + let text = std::str::from_utf8(private_key_pem).map_err(|_| { + ActualError::ConfigError("Private key is not valid UTF-8 PEM".to_string()) + })?; + if text.contains("BEGIN RSA PRIVATE KEY") { + return Ok(AssertionAlgorithm::Rs256); + } + if text.contains("BEGIN EC PRIVATE KEY") { + return Ok(AssertionAlgorithm::Es256); + } + // PKCS#8 ("BEGIN PRIVATE KEY") does not name the key type in its header, + // so try RSA then EC and let the key parser decide. + if text.contains("BEGIN PRIVATE KEY") { + if EncodingKey::from_rsa_pem(private_key_pem).is_ok() { + return Ok(AssertionAlgorithm::Rs256); + } + if EncodingKey::from_ec_pem(private_key_pem).is_ok() { + return Ok(AssertionAlgorithm::Es256); + } + } + Err(ActualError::ConfigError( + "Could not determine the key algorithm from the PEM. \ + Pass --alg rs256 or --alg es256 explicitly." + .to_string(), + )) + } +} + +/// RFC 7523 assertion claims. `iss` and `sub` are both the service-account id; +/// `aud` targets the authorization server; `jti` is unique per assertion. +#[derive(Debug, Serialize, Deserialize)] +struct AssertionClaims { + iss: String, + sub: String, + aud: String, + jti: String, + iat: u64, + exp: u64, +} + +/// Inputs to build one assertion. Borrowed so the caller owns the strings. +pub struct AssertionParams<'a> { + /// The service-account principal id — becomes `iss` and `sub`. Must be a + /// valid UUID (the server feeds it a uuid-typed column and rejects a + /// non-uuid `sub` with a generic `invalid_grant`). + pub service_account_id: &'a str, + /// The audience — one of the server's accepted values (its OAuth issuer, or + /// `${issuer}/api/oauth/token`). + pub audience: &'a str, + /// The registered key id, placed in the JWS header so the server knows + /// which public key to verify against. + pub kid: &'a str, + /// The signing algorithm; must match the registered key's algorithm. + pub algorithm: AssertionAlgorithm, + /// Requested lifetime in seconds. Clamped to `[1, 300]`. + pub lifetime_seconds: u64, +} + +/// Return `true` if `value` is a well-formed RFC 4122 UUID, matching the +/// server's `isUuid` check exactly (version 1–5, variant 8/9/a/b) so the client +/// accepts a principal id iff the server would. +pub fn is_uuid(value: &str) -> bool { + let bytes = value.as_bytes(); + // 8-4-4-4-12 with dashes at fixed positions. + if bytes.len() != 36 { + return false; + } + for (i, b) in bytes.iter().enumerate() { + match i { + 8 | 13 | 18 | 23 => { + if *b != b'-' { + return false; + } + } + 14 => { + // version nibble: 1..=5 + if !matches!(b, b'1'..=b'5') { + return false; + } + } + 19 => { + // variant nibble: 8, 9, a, b (case-insensitive) + if !matches!(b, b'8' | b'9' | b'a' | b'b' | b'A' | b'B') { + return false; + } + } + _ => { + if !b.is_ascii_hexdigit() { + return false; + } + } + } + } + true +} + +/// Build and sign an RFC 7523 assertion. `now_unix` is the current time in +/// seconds since the epoch (injected for testability). Each call mints a fresh +/// unique `jti`. +/// +/// Fails with a clean [`ActualError::ConfigError`] on a non-UUID principal id, +/// an empty `kid`, an unreadable/mismatched key, or a signing error — never a +/// panic, and never with key material in the message. +pub fn build_and_sign_assertion( + params: &AssertionParams<'_>, + private_key_pem: &[u8], + now_unix: u64, +) -> Result { + if !is_uuid(params.service_account_id) { + return Err(ActualError::ConfigError( + "Service account id must be a UUID (it is the assertion iss/sub)".to_string(), + )); + } + if params.kid.trim().is_empty() { + return Err(ActualError::ConfigError( + "A key id (--kid) is required".to_string(), + )); + } + if params.audience.trim().is_empty() { + return Err(ActualError::ConfigError( + "An assertion audience is required".to_string(), + )); + } + + let lifetime = params + .lifetime_seconds + .clamp(1, MAX_ASSERTION_LIFETIME_SECONDS); + let claims = AssertionClaims { + iss: params.service_account_id.to_string(), + sub: params.service_account_id.to_string(), + aud: params.audience.to_string(), + jti: Uuid::new_v4().to_string(), + iat: now_unix, + exp: now_unix + lifetime, + }; + + let mut header = Header::new(params.algorithm.to_jwt()); + header.kid = Some(params.kid.to_string()); + // `typ` defaults to "JWT" in jsonwebtoken's Header. + + let key = match params.algorithm { + AssertionAlgorithm::Rs256 => EncodingKey::from_rsa_pem(private_key_pem), + AssertionAlgorithm::Es256 => EncodingKey::from_ec_pem(private_key_pem), + } + .map_err(|_| { + ActualError::ConfigError(format!( + "Failed to load the {} private key from PEM (is it the right key type?)", + params.algorithm.as_str() + )) + })?; + + encode(&header, &claims, &key) + .map_err(|_| ActualError::ConfigError("Failed to sign the assertion".to_string())) +} + +/// The `/api/oauth/token` success response for the jwt-bearer grant. The server +/// mints `{ token_type, access_token, expires_in, scope }`; no refresh token is +/// issued for a service-account grant (the agent re-mints from a fresh +/// assertion). +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + #[serde(default = "default_bearer")] + token_type: String, + #[serde(default)] + expires_in: Option, + #[serde(default)] + scope: Option, +} + +fn default_bearer() -> String { + "Bearer".to_string() +} + +/// A minted access token. `access_token` is **sensitive**; the [`std::fmt::Debug`] +/// impl redacts it so it never reaches logs or `{:?}`. +#[derive(Clone, PartialEq, Eq)] +pub struct MintedToken { + /// The bearer access token. **Sensitive — never logged.** + pub access_token: String, + /// Token type, normally `Bearer`. + pub token_type: String, + /// Lifetime in seconds, if the server reported one. + pub expires_in: Option, + /// Space-delimited granted scopes, if the server reported them. + pub scope: Option, +} + +impl std::fmt::Debug for MintedToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MintedToken") + .field("access_token", &"") + .field("token_type", &self.token_type) + .field("expires_in", &self.expires_in) + .field("scope", &self.scope) + .finish() + } +} + +/// Map an unsuccessful token-endpoint response to a clean [`ActualError`]. The +/// body is OAuth error JSON (`{ error, error_description }`), not a secret; we +/// surface `error: description` when present, otherwise a truncated body. No +/// stack traces, no assertion or key material. +async fn mint_error(response: reqwest::Response) -> ActualError { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if let Ok(parsed) = serde_json::from_str::(&body) { + if let Some(error) = parsed.error { + let detail = parsed + .error_description + .filter(|d| !d.is_empty()) + .map(|d| format!(": {d}")) + .unwrap_or_default(); + return ActualError::ApiError(format!( + "Token mint failed (HTTP {status}): {error}{detail}" + )); + } + } + let truncated: String = body.chars().take(2048).collect(); + ActualError::ApiError(format!("Token mint failed (HTTP {status}): {truncated}")) +} + +#[derive(Debug, Deserialize)] +struct OAuthError { + #[serde(default)] + error: Option, + #[serde(default)] + error_description: Option, +} + +/// Exchange a signed assertion for an access token at +/// `${base_url}/api/oauth/token`. `scope`, when non-empty, is sent as the +/// space-delimited `scope` param (a subset the server clamps to the principal's +/// grant); when omitted the server mints the principal's full whitelist. +/// +/// `http` must already enforce HTTPS for non-loopback hosts — build it with +/// [`crate::auth::oauth::build_http_client`]. +pub async fn mint_token( + http: &reqwest::Client, + base_url: &str, + assertion: &str, + scope: Option<&str>, +) -> Result { + let base = base_url.trim_end_matches('/'); + let url = format!("{base}/api/oauth/token"); + let mut form: Vec<(&str, &str)> = vec![ + ("grant_type", JWT_BEARER_GRANT_TYPE), + ("assertion", assertion), + ]; + if let Some(scope) = scope { + if !scope.trim().is_empty() { + form.push(("scope", scope)); + } + } + + let response = http + .post(&url) + .form(&form) + .send() + .await + .map_err(|e| ActualError::ApiError(format!("Token mint request failed: {e}")))?; + + if !response.status().is_success() { + return Err(mint_error(response).await); + } + + let token = response + .json::() + .await + .map_err(|e| ActualError::ApiError(format!("Failed to parse token response: {e}")))?; + + Ok(MintedToken { + access_token: token.access_token, + token_type: token.token_type, + expires_in: token.expires_in, + scope: token.scope, + }) +} + +/// Write the minted token to `out` for machine capture. In the default mode the +/// output is **only** the raw access token followed by a newline — nothing +/// else, so `TOKEN=$(actual mint-token …)` captures exactly the token. With +/// `json`, the full response is written as a single-line JSON object. +/// +/// Status/diagnostics never go through `out`; the command writes those to +/// stderr. This function is the load-bearing "stdout carries only the token" +/// contract, and is unit-tested against an in-memory writer. +pub fn write_token_output( + out: &mut impl std::io::Write, + token: &MintedToken, + json: bool, +) -> std::io::Result<()> { + if json { + let value = serde_json::json!({ + "access_token": token.access_token, + "token_type": token.token_type, + "expires_in": token.expires_in, + "scope": token.scope, + }); + writeln!(out, "{value}") + } else { + writeln!(out, "{}", token.access_token) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{decode, DecodingKey, Validation}; + use p256::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; + use rsa::pkcs8::{ + EncodePrivateKey as RsaEncodePrivateKey, EncodePublicKey as RsaEncodePublicKey, + }; + use rsa::{RsaPrivateKey, RsaPublicKey}; + + const TEST_UUID: &str = "3f8a1c2e-4b5d-4e6f-8a9b-0c1d2e3f4a5b"; + const TEST_KID: &str = "test-key-1"; + const TEST_AUD: &str = "https://app.example.test"; + + /// Real wall-clock seconds — used where the assertion must still be + /// unexpired when `jsonwebtoken::decode` validates `exp` against `now`. + fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + } + + /// Decode an assertion's claims WITHOUT checking the signature or the time + /// claims — for tests that only inspect `jti`/`exp`/`iat` values. + fn decode_claims_only(assertion: &str, key: &DecodingKey, alg: Algorithm) -> AssertionClaims { + let mut validation = Validation::new(alg); + validation.insecure_disable_signature_validation(); + validation.validate_exp = false; + validation.validate_aud = false; + decode::(assertion, key, &validation) + .expect("decode claims") + .claims + } + + /// Generate an ephemeral RSA-2048 keypair as (private PKCS#8 PEM, public + /// SPKI PEM). Generated at runtime so no private key material is committed. + fn rsa_keypair() -> (Vec, Vec) { + let mut rng = rand::thread_rng(); + let private = RsaPrivateKey::new(&mut rng, 2048).expect("rsa keygen"); + let public = RsaPublicKey::from(&private); + let priv_pem = RsaEncodePrivateKey::to_pkcs8_pem(&private, LineEnding::LF) + .expect("rsa priv pem") + .as_bytes() + .to_vec(); + let pub_pem = RsaEncodePublicKey::to_public_key_pem(&public, LineEnding::LF) + .expect("rsa pub pem") + .into_bytes(); + (priv_pem, pub_pem) + } + + /// Generate an ephemeral EC P-256 keypair as (private PKCS#8 PEM, public + /// SPKI PEM). Generated at runtime; nothing committed. + fn ec_keypair() -> (Vec, Vec) { + let secret = p256::SecretKey::random(&mut rand::thread_rng()); + let priv_pem = secret + .to_pkcs8_pem(LineEnding::LF) + .expect("ec priv pem") + .as_bytes() + .to_vec(); + let pub_pem = secret + .public_key() + .to_public_key_pem(LineEnding::LF) + .expect("ec pub pem") + .into_bytes(); + (priv_pem, pub_pem) + } + + fn params(alg: AssertionAlgorithm) -> AssertionParams<'static> { + AssertionParams { + service_account_id: TEST_UUID, + audience: TEST_AUD, + kid: TEST_KID, + algorithm: alg, + lifetime_seconds: DEFAULT_ASSERTION_LIFETIME_SECONDS, + } + } + + /// Verify an assertion the way the server does: pin the algorithm, require + /// the audience, require `exp`, and allow the same 60s clock-skew leeway. + /// Returns the decoded claims for the caller to assert on. + fn server_style_verify( + assertion: &str, + alg: AssertionAlgorithm, + decoding_key: &DecodingKey, + ) -> AssertionClaims { + let mut validation = Validation::new(alg.to_jwt()); + validation.set_audience(&[TEST_AUD]); + validation.set_required_spec_claims(&["exp"]); + validation.leeway = 60; + validation.validate_exp = true; + decode::(assertion, decoding_key, &validation) + .expect("assertion should verify under server-style validation") + .claims + } + + #[test] + fn rs256_assertion_verifies_and_has_the_right_claims() { + let (priv_pem, pub_pem) = rsa_keypair(); + let now = now_secs(); + let assertion = + build_and_sign_assertion(¶ms(AssertionAlgorithm::Rs256), &priv_pem, now) + .expect("sign RS256"); + + let decoding_key = DecodingKey::from_rsa_pem(&pub_pem).expect("rsa decoding key"); + let claims = server_style_verify(&assertion, AssertionAlgorithm::Rs256, &decoding_key); + + assert_eq!(claims.iss, TEST_UUID); + assert_eq!(claims.sub, TEST_UUID); + assert_eq!(claims.iss, claims.sub, "iss must equal sub (RFC 7523)"); + assert_eq!(claims.aud, TEST_AUD); + assert_eq!(claims.iat, now); + assert_eq!(claims.exp, now + DEFAULT_ASSERTION_LIFETIME_SECONDS); + assert!( + claims.exp - claims.iat <= MAX_ASSERTION_LIFETIME_SECONDS, + "lifetime must be within the server cap" + ); + assert!(!claims.jti.is_empty(), "jti must be present"); + } + + #[test] + fn es256_assertion_verifies_and_has_the_right_claims() { + let (priv_pem, pub_pem) = ec_keypair(); + let now = now_secs(); + let assertion = + build_and_sign_assertion(¶ms(AssertionAlgorithm::Es256), &priv_pem, now) + .expect("sign ES256"); + + let decoding_key = DecodingKey::from_ec_pem(&pub_pem).expect("ec decoding key"); + let claims = server_style_verify(&assertion, AssertionAlgorithm::Es256, &decoding_key); + + assert_eq!(claims.iss, TEST_UUID); + assert_eq!(claims.sub, TEST_UUID); + assert_eq!(claims.aud, TEST_AUD); + assert_eq!(claims.exp - claims.iat, DEFAULT_ASSERTION_LIFETIME_SECONDS); + } + + #[test] + fn assertion_header_carries_alg_and_kid() { + let (priv_pem, _pub) = ec_keypair(); + let assertion = + build_and_sign_assertion(¶ms(AssertionAlgorithm::Es256), &priv_pem, 1_700_000_000) + .expect("sign"); + let header = jsonwebtoken::decode_header(&assertion).expect("decode header"); + assert_eq!(header.alg, Algorithm::ES256); + assert_eq!(header.kid.as_deref(), Some(TEST_KID)); + assert_eq!(header.typ.as_deref(), Some("JWT")); + } + + #[test] + fn each_assertion_gets_a_fresh_unique_jti() { + let (priv_pem, pub_pem) = ec_keypair(); + let a = + build_and_sign_assertion(¶ms(AssertionAlgorithm::Es256), &priv_pem, 1_700_000_000) + .unwrap(); + let b = + build_and_sign_assertion(¶ms(AssertionAlgorithm::Es256), &priv_pem, 1_700_000_000) + .unwrap(); + + let key = DecodingKey::from_ec_pem(&pub_pem).unwrap(); + let ja = decode_claims_only(&a, &key, Algorithm::ES256).jti; + let jb = decode_claims_only(&b, &key, Algorithm::ES256).jti; + assert_ne!( + ja, jb, + "each assertion must carry a unique jti (anti-replay)" + ); + } + + #[test] + fn lifetime_is_clamped_to_the_server_cap() { + let (priv_pem, pub_pem) = ec_keypair(); + let mut p = params(AssertionAlgorithm::Es256); + p.lifetime_seconds = 10_000; // absurd; must clamp to 300 + let now = 1_700_000_000; + let assertion = build_and_sign_assertion(&p, &priv_pem, now).unwrap(); + + let key = DecodingKey::from_ec_pem(&pub_pem).unwrap(); + let claims = decode_claims_only(&assertion, &key, Algorithm::ES256); + assert_eq!(claims.exp - claims.iat, MAX_ASSERTION_LIFETIME_SECONDS); + } + + #[test] + fn non_uuid_service_account_id_is_rejected_before_signing() { + let (priv_pem, _pub) = ec_keypair(); + let mut p = params(AssertionAlgorithm::Es256); + p.service_account_id = "not-a-uuid"; + let err = build_and_sign_assertion(&p, &priv_pem, 1_700_000_000).unwrap_err(); + assert!( + matches!(err, ActualError::ConfigError(ref m) if m.contains("UUID")), + "expected a clean UUID config error, got: {err:?}" + ); + } + + #[test] + fn empty_kid_is_rejected() { + let (priv_pem, _pub) = ec_keypair(); + let mut p = params(AssertionAlgorithm::Es256); + p.kid = " "; + let err = build_and_sign_assertion(&p, &priv_pem, 1_700_000_000).unwrap_err(); + assert!(matches!(err, ActualError::ConfigError(ref m) if m.contains("key id"))); + } + + #[test] + fn wrong_key_type_for_alg_is_a_clean_error_not_a_panic() { + // An EC key handed to the RS256 path must fail cleanly. + let (ec_priv, _pub) = ec_keypair(); + let mut p = params(AssertionAlgorithm::Rs256); + p.kid = TEST_KID; + let err = build_and_sign_assertion(&p, &ec_priv, 1_700_000_000).unwrap_err(); + assert!(matches!(err, ActualError::ConfigError(_))); + } + + #[test] + fn is_uuid_matches_the_server_contract() { + assert!(is_uuid("3f8a1c2e-4b5d-4e6f-8a9b-0c1d2e3f4a5b")); + assert!(is_uuid("3F8A1C2E-4B5D-4E6F-8A9B-0C1D2E3F4A5B")); // case-insensitive + assert!(!is_uuid("not-a-uuid")); + assert!(!is_uuid("3f8a1c2e4b5d4e6f8a9b0c1d2e3f4a5b")); // no dashes + assert!(!is_uuid("3f8a1c2e-4b5d-6e6f-8a9b-0c1d2e3f4a5b")); // version 6 + assert!(!is_uuid("3f8a1c2e-4b5d-4e6f-0a9b-0c1d2e3f4a5b")); // variant 0 + assert!(!is_uuid("")); // empty + } + + #[test] + fn algorithm_parse_rejects_symmetric_and_unsigned() { + assert_eq!( + AssertionAlgorithm::parse("rs256").unwrap(), + AssertionAlgorithm::Rs256 + ); + assert_eq!( + AssertionAlgorithm::parse("ES256").unwrap(), + AssertionAlgorithm::Es256 + ); + for bad in ["HS256", "hs512", "none", "None", "RS512", "EdDSA", ""] { + assert!( + AssertionAlgorithm::parse(bad).is_err(), + "algorithm '{bad}' must be refused" + ); + } + } + + #[test] + fn algorithm_infers_from_key_pem() { + let (rsa_priv, _r) = rsa_keypair(); + let (ec_priv, _e) = ec_keypair(); + assert_eq!( + AssertionAlgorithm::infer_from_pem(&rsa_priv).unwrap(), + AssertionAlgorithm::Rs256 + ); + assert_eq!( + AssertionAlgorithm::infer_from_pem(&ec_priv).unwrap(), + AssertionAlgorithm::Es256 + ); + } + + #[test] + fn write_token_output_default_is_only_the_token() { + let token = MintedToken { + access_token: "the-access-token".to_string(), + token_type: "Bearer".to_string(), + expires_in: Some(300), + scope: Some("adr:query".to_string()), + }; + let mut buf = Vec::new(); + write_token_output(&mut buf, &token, false).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!(out, "the-access-token\n", "stdout must be ONLY the token"); + } + + #[test] + fn write_token_output_json_carries_the_full_response() { + let token = MintedToken { + access_token: "the-access-token".to_string(), + token_type: "Bearer".to_string(), + expires_in: Some(300), + scope: Some("adr:query adr:review".to_string()), + }; + let mut buf = Vec::new(); + write_token_output(&mut buf, &token, true).unwrap(); + let out = String::from_utf8(buf).unwrap(); + let value: serde_json::Value = serde_json::from_str(out.trim()).unwrap(); + assert_eq!(value["access_token"], "the-access-token"); + assert_eq!(value["token_type"], "Bearer"); + assert_eq!(value["expires_in"], 300); + assert_eq!(value["scope"], "adr:query adr:review"); + } + + #[test] + fn minted_token_debug_redacts_the_access_token() { + let token = MintedToken { + access_token: "super-secret-token-value".to_string(), + token_type: "Bearer".to_string(), + expires_in: None, + scope: None, + }; + let debug = format!("{token:?}"); + assert!( + !debug.contains("super-secret-token-value"), + "debug leaked the token: {debug}" + ); + assert!(debug.contains("")); + } + + #[tokio::test] + async fn mint_token_sends_the_right_wire_shape_and_parses_the_token() { + let (priv_pem, _pub) = ec_keypair(); + let assertion = + build_and_sign_assertion(¶ms(AssertionAlgorithm::Es256), &priv_pem, 1_700_000_000) + .unwrap(); + + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/api/oauth/token") + .match_header("content-type", "application/x-www-form-urlencoded") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("grant_type".into(), JWT_BEARER_GRANT_TYPE.into()), + mockito::Matcher::UrlEncoded("assertion".into(), assertion.clone()), + mockito::Matcher::UrlEncoded("scope".into(), "adr:query adr:review".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"token_type":"Bearer","access_token":"minted-abc","expires_in":300,"scope":"adr:query adr:review"}"#, + ) + .create_async() + .await; + + let http = crate::auth::oauth::build_http_client(&server.url()).unwrap(); + let token = mint_token( + &http, + &server.url(), + &assertion, + Some("adr:query adr:review"), + ) + .await + .expect("mint should succeed"); + + mock.assert_async().await; + assert_eq!(token.access_token, "minted-abc"); + assert_eq!(token.token_type, "Bearer"); + assert_eq!(token.expires_in, Some(300)); + assert_eq!(token.scope.as_deref(), Some("adr:query adr:review")); + } + + #[tokio::test] + async fn mint_token_omits_scope_when_none_and_defaults_token_type() { + let (priv_pem, _pub) = ec_keypair(); + let assertion = + build_and_sign_assertion(¶ms(AssertionAlgorithm::Es256), &priv_pem, 1_700_000_000) + .unwrap(); + + // No `scope` form field when the caller passes None; the response omits + // token_type, which must default to Bearer. + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/api/oauth/token") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("grant_type".into(), JWT_BEARER_GRANT_TYPE.into()), + mockito::Matcher::UrlEncoded("assertion".into(), assertion.clone()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"access_token":"minted-noscope"}"#) + .create_async() + .await; + + let http = crate::auth::oauth::build_http_client(&server.url()).unwrap(); + let token = mint_token(&http, &server.url(), &assertion, None) + .await + .expect("mint should succeed without scope"); + mock.assert_async().await; + assert_eq!(token.access_token, "minted-noscope"); + assert_eq!(token.token_type, "Bearer", "token_type defaults to Bearer"); + assert_eq!(token.expires_in, None); + assert_eq!(token.scope, None); + } + + #[tokio::test] + async fn mint_token_surfaces_invalid_grant_cleanly() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("POST", "/api/oauth/token") + .with_status(400) + .with_header("content-type", "application/json") + .with_body( + r#"{"error":"invalid_grant","error_description":"Assertion verification failed"}"#, + ) + .create_async() + .await; + + let http = crate::auth::oauth::build_http_client(&server.url()).unwrap(); + let err = mint_token(&http, &server.url(), "bad.assertion.here", None) + .await + .unwrap_err(); + match err { + ActualError::ApiError(msg) => { + assert!( + msg.contains("invalid_grant"), + "should surface the server error: {msg}" + ); + assert!( + msg.contains("Assertion verification failed"), + "should surface the description: {msg}" + ); + assert!(!msg.contains("panic"), "no stack traces"); + } + other => panic!("expected ApiError, got {other:?}"), + } + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index afa2adfd..fa72b549 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -9,6 +9,7 @@ //! The browser OAuth + PKCE login flow and the `login` / `logout` / `whoami` //! commands build on top of the credential store provided here. +pub mod jwt_bearer; pub mod loopback; pub mod oauth; pub mod pkce; diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs index 564518a6..6da089c8 100644 --- a/src/auth/oauth.rs +++ b/src/auth/oauth.rs @@ -140,8 +140,9 @@ pub fn build_authorize_url( /// Build an HTTP client for the auth server. Enforces HTTPS for non-loopback /// URLs so tokens are never sent in clear text (loopback `http://` is allowed -/// for the local mock). -fn build_http_client(base_url: &str) -> Result { +/// for the local mock). Shared by the browser-login flow and the headless +/// jwt-bearer client ([`crate::auth::jwt_bearer`]). +pub(crate) fn build_http_client(base_url: &str) -> Result { let is_localhost = base_url.starts_with("http://localhost") || base_url.starts_with("http://127.0.0.1") || base_url.starts_with("http://[::1]"); diff --git a/src/cli/args.rs b/src/cli/args.rs index 78d3d34e..f598b94e 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -333,6 +333,8 @@ pub enum Command { Whoami, /// Ask the Advisor an org-scoped architecture question Advisor(AdvisorArgs), + /// Mint an access token headlessly via the RFC 7523 jwt-bearer grant + MintToken(MintTokenArgs), /// View or edit configuration Config(ConfigArgs), /// List all available AI backend runners @@ -367,6 +369,61 @@ pub struct AdvisorArgs { pub repo: Option, } +/// Arguments for the `mint-token` command — the fully-headless RFC 7523 +/// jwt-bearer client. Every input comes from a flag, an environment variable, +/// or a file: no browser, no prompt. The private key is read from a file +/// (`--key` / `ACTUAL_SERVICE_ACCOUNT_KEY_FILE`) or, preferably, from the +/// `ACTUAL_SERVICE_ACCOUNT_KEY` environment variable so it never lands on argv. +#[derive(Parser, Debug)] +pub struct MintTokenArgs { + /// Service-account id (a UUID). Becomes the assertion `iss`/`sub`. + #[arg(long, value_name = "UUID", env = "ACTUAL_SERVICE_ACCOUNT_ID")] + pub service_account_id: String, + + /// Registered key id, placed in the assertion header so the server picks + /// the matching public key. + #[arg(long, value_name = "KID", env = "ACTUAL_SERVICE_ACCOUNT_KID")] + pub kid: String, + + /// Path to the service-account PRIVATE key in PEM (RSA for RS256, EC P-256 + /// for ES256). Alternatively set `ACTUAL_SERVICE_ACCOUNT_KEY` to the PEM + /// contents directly — preferred with a secret manager, and it keeps the + /// key off argv. + #[arg(long, value_name = "PATH", env = "ACTUAL_SERVICE_ACCOUNT_KEY_FILE")] + pub key: Option, + + /// Signing algorithm (`rs256` or `es256`). Inferred from the key when + /// omitted. `HS*`/`none` are refused. + #[arg(long, value_name = "ALG")] + pub alg: Option, + + /// OAuth issuer base URL; the token endpoint is `/api/oauth/token`. + /// Defaults to the production server, falling back through + /// `ACTUAL_AUTH_URL`. Override for staging or a local server. + #[arg(long, value_name = "URL")] + pub issuer: Option, + + /// Assertion audience. Defaults to the resolved issuer origin, which the + /// server accepts alongside `/api/oauth/token`. + #[arg(long, value_name = "URL")] + pub aud: Option, + + /// Requested scope (repeatable), a subset of the principal's grant (e.g. + /// `adr:query`, `adr:review`). When omitted the server mints the + /// principal's full whitelist. + #[arg(long = "scope", value_name = "SCOPE")] + pub scopes: Vec, + + /// Assertion lifetime in seconds (clamped to 1..=300). Default 60. + #[arg(long, value_name = "SECONDS", default_value_t = 60)] + pub assertion_ttl_seconds: u64, + + /// Print the full token response as one line of JSON instead of just the + /// raw access token. + #[arg(long)] + pub json: bool, +} + /// Arguments for the `adr-bot` command #[derive(Parser, Debug, Clone)] pub struct SyncArgs { diff --git a/src/cli/commands/mint_token.rs b/src/cli/commands/mint_token.rs new file mode 100644 index 00000000..871fa5f5 --- /dev/null +++ b/src/cli/commands/mint_token.rs @@ -0,0 +1,271 @@ +//! `actual mint-token` — the fully-headless RFC 7523 jwt-bearer client. +//! +//! Signs a short-lived service-account assertion with a registered private key +//! and exchanges it for an access token, with **no browser and no human**. This +//! is the unattended agent path: contrast `login` (browser OAuth) and `login +//! --device` (a human approves a code), which enroll a *human-delegated* +//! session. Here the agent already holds a private key whose matching public +//! key is registered server-side. +//! +//! Output contract (mirrors `auth create-token` / the advisor command): the raw +//! access token is the **only** thing written to stdout, so +//! `TOKEN=$(actual mint-token …)` captures exactly the token. All status goes +//! to stderr. `--json` swaps stdout for the full token response as one JSON +//! line. The token never appears on stderr and is never logged. + +use std::path::Path; + +use crate::auth::jwt_bearer::{ + self, AssertionAlgorithm, AssertionParams, DEFAULT_ASSERTION_LIFETIME_SECONDS, +}; +use crate::auth::oauth; +use crate::cli::args::MintTokenArgs; +use crate::cli::ui::theme; +use crate::error::ActualError; + +/// Environment variable holding the private key PEM inline (used when no key +/// file is given). Preferred with a secret manager — keeps the key off argv. +const KEY_INLINE_ENV: &str = "ACTUAL_SERVICE_ACCOUNT_KEY"; + +pub fn exec(args: &MintTokenArgs) -> Result<(), ActualError> { + // Resolve the issuer base URL (flag > ACTUAL_AUTH_URL > prod default) and + // strip any trailing slash so URL/audience construction is stable. + let base_url = oauth::resolve_auth_url(args.issuer.as_deref())? + .trim_end_matches('/') + .to_string(); + + let key_pem = load_key_pem( + args.key.as_deref(), + std::env::var(KEY_INLINE_ENV).ok().as_deref(), + )?; + let algorithm = resolve_algorithm(args.alg.as_deref(), &key_pem)?; + let audience = resolve_audience(args.aud.as_deref(), &base_url); + let scope = resolve_scope(&args.scopes); + + let lifetime = if args.assertion_ttl_seconds == 0 { + DEFAULT_ASSERTION_LIFETIME_SECONDS + } else { + args.assertion_ttl_seconds + }; + + let assertion = jwt_bearer::build_and_sign_assertion( + &AssertionParams { + service_account_id: args.service_account_id.trim(), + audience: &audience, + kid: args.kid.trim(), + algorithm, + lifetime_seconds: lifetime, + }, + &key_pem, + now_unix()?, + )?; + + eprintln!( + "{} minting a token via jwt-bearer ({}) as {}…", + theme::hint("mint-token"), + algorithm.as_str(), + args.service_account_id.trim() + ); + + // The HTTPS transport guard lives in `build_http_client`: a non-HTTPS, + // non-loopback issuer is rejected before any assertion is sent. + let http = oauth::build_http_client(&base_url)?; + let token = build_runtime()?.block_on(jwt_bearer::mint_token( + &http, + &base_url, + &assertion, + scope.as_deref(), + ))?; + + // Token → stdout, ONLY the token (or the full JSON with --json). + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + jwt_bearer::write_token_output(&mut handle, &token, args.json) + .map_err(|e| ActualError::InternalError(format!("failed to write token to stdout: {e}")))?; + + // Non-sensitive success summary → stderr. Never the token itself. + let expiry = token + .expires_in + .map(|s| format!("expires in {s}s")) + .unwrap_or_else(|| "no expiry reported".to_string()); + let scope_note = token + .scope + .as_deref() + .map(|s| format!("scope: {s}")) + .unwrap_or_else(|| "scope: (server default)".to_string()); + eprintln!( + "{} minted token ({expiry}; {scope_note})", + theme::success(&theme::SUCCESS) + ); + + Ok(()) +} + +/// Load the private key PEM. A key **file** (`--key` / its env) wins when given; +/// otherwise the `inline` PEM (from [`KEY_INLINE_ENV`]) is used. Errors cleanly +/// when neither is supplied or the file cannot be read. +fn load_key_pem(key_path: Option<&Path>, inline: Option<&str>) -> Result, ActualError> { + if let Some(path) = key_path { + return std::fs::read(path).map_err(|e| { + ActualError::ConfigError(format!( + "Failed to read the private key file '{}': {e}", + path.display() + )) + }); + } + if let Some(pem) = inline { + if !pem.trim().is_empty() { + return Ok(pem.as_bytes().to_vec()); + } + } + Err(ActualError::ConfigError(format!( + "No private key provided. Pass --key , or set {KEY_INLINE_ENV} \ + (the PEM contents) or ACTUAL_SERVICE_ACCOUNT_KEY_FILE (a path)." + ))) +} + +/// Resolve the signing algorithm: the explicit `--alg` flag when present, +/// otherwise inferred from the key PEM. +fn resolve_algorithm( + alg_flag: Option<&str>, + key_pem: &[u8], +) -> Result { + match alg_flag { + Some(value) => AssertionAlgorithm::parse(value), + None => AssertionAlgorithm::infer_from_pem(key_pem), + } +} + +/// Resolve the assertion audience: the explicit `--aud` flag (trailing slash +/// trimmed) when present, otherwise the issuer base URL (which the server +/// accepts alongside `/api/oauth/token`). +fn resolve_audience(aud_flag: Option<&str>, base_url: &str) -> String { + match aud_flag { + Some(value) => value.trim_end_matches('/').to_string(), + None => base_url.to_string(), + } +} + +/// Join repeated `--scope` flags into a single space-delimited string, or `None` +/// when no scope was requested (the server then mints the principal's full +/// whitelist). +fn resolve_scope(scopes: &[String]) -> Option { + let joined = scopes + .iter() + .flat_map(|s| s.split_whitespace()) + .collect::>() + .join(" "); + if joined.is_empty() { + None + } else { + Some(joined) + } +} + +/// Current time in seconds since the Unix epoch. +fn now_unix() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .map_err(|e| { + ActualError::InternalError(format!("system clock is before the Unix epoch: {e}")) + }) +} + +/// Build a single-threaded tokio runtime so the sync CLI dispatch path can drive +/// the async mint (mirrors `commands::login`). +fn build_runtime() -> Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| ActualError::InternalError(format!("failed to build tokio runtime: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_scope_joins_and_normalizes() { + assert_eq!(resolve_scope(&[]), None); + assert_eq!( + resolve_scope(&["adr:query".to_string(), "adr:review".to_string()]), + Some("adr:query adr:review".to_string()) + ); + // A single space-delimited value is also accepted and normalized. + assert_eq!( + resolve_scope(&["adr:query adr:review".to_string()]), + Some("adr:query adr:review".to_string()) + ); + // Whitespace-only collapses to None. + assert_eq!(resolve_scope(&[" ".to_string()]), None); + } + + #[test] + fn resolve_audience_defaults_to_issuer_and_trims() { + assert_eq!( + resolve_audience(None, "https://app.example.test"), + "https://app.example.test" + ); + assert_eq!( + resolve_audience( + Some("https://aud.example.test/"), + "https://issuer.example.test" + ), + "https://aud.example.test" + ); + } + + #[test] + fn resolve_algorithm_honors_flag_then_infers() { + // Explicit flag wins and rejects forbidden algorithms. + assert_eq!( + resolve_algorithm(Some("es256"), b"unused").unwrap(), + AssertionAlgorithm::Es256 + ); + assert!(resolve_algorithm(Some("HS256"), b"unused").is_err()); + // With no flag and an unrecognizable key, inference errors cleanly. + assert!(resolve_algorithm(None, b"not a pem").is_err()); + } + + #[test] + fn load_key_pem_prefers_file_then_inline_then_errors() { + use std::io::Write; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(b"-----BEGIN PRIVATE KEY-----\nFILE\n-----END PRIVATE KEY-----\n") + .unwrap(); + + // File wins over inline. + let got = load_key_pem(Some(file.path()), Some("INLINE-PEM")).unwrap(); + assert!(String::from_utf8(got).unwrap().contains("FILE")); + + // Inline used when no file. + let got = load_key_pem(None, Some("INLINE-PEM")).unwrap(); + assert_eq!(got, b"INLINE-PEM"); + + // Neither → clean error. + let err = load_key_pem(None, None).unwrap_err(); + assert!(matches!(err, ActualError::ConfigError(ref m) if m.contains("No private key"))); + + // Whitespace-only inline is treated as absent. + let err = load_key_pem(None, Some(" ")).unwrap_err(); + assert!(matches!(err, ActualError::ConfigError(_))); + } + + #[test] + fn load_key_pem_missing_file_is_a_clean_error() { + let err = load_key_pem(Some(Path::new("/no/such/key.pem")), None).unwrap_err(); + assert!(matches!(err, ActualError::ConfigError(ref m) if m.contains("Failed to read"))); + } + + #[test] + fn build_runtime_succeeds() { + assert!(build_runtime().is_ok()); + } + + #[test] + fn now_unix_is_after_2020() { + // 2020-01-01T00:00:00Z + assert!(now_unix().unwrap() > 1_577_836_800); + } +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 4b7f41e0..2c05727e 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -13,6 +13,7 @@ pub mod cache; pub mod config; pub mod login; pub mod logout; +pub mod mint_token; pub mod models; pub mod runners; pub mod status; diff --git a/src/lib.rs b/src/lib.rs index 68bcb8ac..f587743f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub fn run(cli: Cli) -> Result<(), ActualError> { Command::Logout => cli::commands::logout::exec(), Command::Whoami => cli::commands::whoami::exec(), Command::Advisor(args) => cli::commands::advisor::exec(args), + Command::MintToken(args) => cli::commands::mint_token::exec(args), Command::Config(args) => cli::commands::config::exec(args), Command::Runners => cli::commands::runners::exec(), Command::Models(args) => cli::commands::models::exec(args.no_fetch), diff --git a/tests/mint_token_cli.rs b/tests/mint_token_cli.rs new file mode 100644 index 00000000..9a3f9e2d --- /dev/null +++ b/tests/mint_token_cli.rs @@ -0,0 +1,199 @@ +//! End-to-end tests for `actual mint-token`, the headless jwt-bearer client. +//! +//! These drive the REAL binary (arg parsing, env reading, key loading, signing, +//! the HTTP round-trip, and the stdout/stderr split) against a mock OAuth token +//! endpoint. The keypair is generated at runtime — no key material is committed. +//! +//! Note on live coverage: an end-to-end mint against a live authorization +//! server is not exercised here — the server-side jwt-bearer grant is not yet +//! reachable from this test suite (it needs a full app + database stack plus a +//! pre-registered key). Instead these tests prove the CLIENT end-to-end: the +//! unit tests verify the signed assertion under the server's exact RFC 7523 +//! validation semantics, and these tests confirm the binary captures and prints +//! the token correctly. + +use assert_cmd::cargo::cargo_bin_cmd; +use mockito::Matcher; +use p256::pkcs8::{EncodePrivateKey, LineEnding}; +use predicates::prelude::*; + +const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +const SERVICE_ACCOUNT_UUID: &str = "3f8a1c2e-4b5d-4e6f-8a9b-0c1d2e3f4a5b"; + +/// Generate an ephemeral EC P-256 private key as a PKCS#8 PEM string. +fn ec_private_key_pem() -> String { + p256::SecretKey::random(&mut rand::thread_rng()) + .to_pkcs8_pem(LineEnding::LF) + .expect("ec pkcs8 pem") + .to_string() +} + +/// A successful ES256 mint: stdout is EXACTLY the token, stderr carries status, +/// and the request reaches the token endpoint with the right grant + scope. +#[test] +fn mint_token_prints_only_the_token_on_success() { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/api/oauth/token") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("grant_type".into(), GRANT_TYPE.into()), + Matcher::UrlEncoded("scope".into(), "adr:query adr:review".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"token_type":"Bearer","access_token":"minted-token-e2e","expires_in":300,"scope":"adr:query adr:review"}"#, + ) + .create(); + + let key_pem = ec_private_key_pem(); + + let mut cmd = cargo_bin_cmd!("actual"); + cmd.env("ACTUAL_SERVICE_ACCOUNT_KEY", &key_pem).args([ + "mint-token", + "--service-account-id", + SERVICE_ACCOUNT_UUID, + "--kid", + "test-key-1", + "--alg", + "es256", + "--issuer", + &server.url(), + "--scope", + "adr:query", + "--scope", + "adr:review", + ]); + + cmd.assert() + .success() + // stdout carries ONLY the token followed by a newline — the load-bearing + // capture contract, so TOKEN=$(actual mint-token …) is exactly the token. + .stdout(predicate::eq("minted-token-e2e\n")) + // status goes to stderr, never the token. + .stderr(predicate::str::contains("minted token").and(predicate::str::contains("ES256"))) + .stderr(predicate::str::contains("minted-token-e2e").not()); + + mock.assert(); +} + +/// `--json` mode still keeps stdout machine-parseable and free of status noise. +#[test] +fn mint_token_json_mode_emits_the_full_response_on_stdout() { + let mut server = mockito::Server::new(); + let _mock = server + .mock("POST", "/api/oauth/token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"token_type":"Bearer","access_token":"minted-json","expires_in":120,"scope":"adr:query"}"#) + .create(); + + let key_pem = ec_private_key_pem(); + let mut cmd = cargo_bin_cmd!("actual"); + cmd.env("ACTUAL_SERVICE_ACCOUNT_KEY", &key_pem).args([ + "mint-token", + "--service-account-id", + SERVICE_ACCOUNT_UUID, + "--kid", + "test-key-1", + "--alg", + "es256", + "--issuer", + &server.url(), + "--json", + ]); + + let output = cmd.assert().success().get_output().stdout.clone(); + let value: serde_json::Value = + serde_json::from_slice(&output).expect("stdout must be a single JSON object"); + assert_eq!(value["access_token"], "minted-json"); + assert_eq!(value["expires_in"], 120); +} + +/// A server rejection surfaces cleanly (the OAuth error + description), with a +/// non-zero exit and no token on stdout. +#[test] +fn mint_token_surfaces_server_rejection_cleanly() { + let mut server = mockito::Server::new(); + let _mock = server + .mock("POST", "/api/oauth/token") + .with_status(400) + .with_header("content-type", "application/json") + .with_body( + r#"{"error":"invalid_grant","error_description":"Assertion verification failed"}"#, + ) + .create(); + + let key_pem = ec_private_key_pem(); + let mut cmd = cargo_bin_cmd!("actual"); + cmd.env("ACTUAL_SERVICE_ACCOUNT_KEY", &key_pem).args([ + "mint-token", + "--service-account-id", + SERVICE_ACCOUNT_UUID, + "--kid", + "test-key-1", + "--alg", + "es256", + "--issuer", + &server.url(), + ]); + + // The shared error panel truncates long messages to terminal width, so we + // assert on the stable prefix of the surfaced OAuth failure. The full + // `invalid_grant: Assertion verification failed` is covered by the + // function-level test `mint_token_surfaces_invalid_grant_cleanly`. What + // matters end-to-end: a clean non-zero exit, no token on stdout, and the + // failure surfaced (not a stack trace). + cmd.assert() + .failure() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("Token mint failed")) + .stderr(predicate::str::contains("400")); +} + +/// A non-HTTPS, non-loopback issuer is refused before any assertion is sent — +/// the transport guard protects the token in flight. +#[test] +fn mint_token_rejects_non_https_issuer() { + let key_pem = ec_private_key_pem(); + let mut cmd = cargo_bin_cmd!("actual"); + cmd.env("ACTUAL_SERVICE_ACCOUNT_KEY", &key_pem).args([ + "mint-token", + "--service-account-id", + SERVICE_ACCOUNT_UUID, + "--kid", + "test-key-1", + "--alg", + "es256", + "--issuer", + "http://not-loopback.example.com", + ]); + + cmd.assert() + .failure() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("HTTPS")); +} + +/// A non-UUID service-account id is rejected with a clean message and no token. +#[test] +fn mint_token_rejects_non_uuid_principal() { + let key_pem = ec_private_key_pem(); + let mut cmd = cargo_bin_cmd!("actual"); + cmd.env("ACTUAL_SERVICE_ACCOUNT_KEY", &key_pem).args([ + "mint-token", + "--service-account-id", + "not-a-uuid", + "--kid", + "test-key-1", + "--alg", + "es256", + "--issuer", + "https://app.example.test", + ]); + + cmd.assert() + .failure() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("UUID")); +} From 21f8500712b06c641ee591d0daed98730e7ae193 Mon Sep 17 00:00:00 2001 From: Ben Wisecup Date: Mon, 6 Jul 2026 03:35:07 -0400 Subject: [PATCH 2/2] test(auth): reach 100% coverage for the mint-token client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: --- .github/workflows/coverage.yml | 1 + src/auth/jwt_bearer.rs | 125 +++++++++++++++++++++++++++++++++ src/cli/commands/mint_token.rs | 59 +++++++++++++--- src/lib.rs | 27 +++++++ 4 files changed, 203 insertions(+), 9 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1f9e19e7..4aa14fdc 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -72,6 +72,7 @@ jobs: --test integration_sync_modes \ --test integration_tailor \ --test lib_test \ + --test mint_token_cli \ --ignore-filename-regex '(src/main\.rs|tests/|real_terminal\.rs|sync_kb_poller\.rs|tui/renderer\.rs|pty\.rs|session\.rs|test_support\.rs|cli/commands/login\.rs)' \ --lcov --output-path lcov.info 2>&1 | tee coverage-output.txt COV_EXIT=${PIPESTATUS[0]} diff --git a/src/auth/jwt_bearer.rs b/src/auth/jwt_bearer.rs index 3a1036bb..73fc8d53 100644 --- a/src/auth/jwt_bearer.rs +++ b/src/auth/jwt_bearer.rs @@ -802,4 +802,129 @@ mod tests { other => panic!("expected ApiError, got {other:?}"), } } + + #[test] + fn as_str_maps_both_algorithms() { + assert_eq!(AssertionAlgorithm::Rs256.as_str(), "RS256"); + assert_eq!(AssertionAlgorithm::Es256.as_str(), "ES256"); + } + + #[test] + fn infer_from_pem_rejects_non_utf8() { + // Invalid UTF-8 bytes cannot be a PEM; the error is clean, not a panic. + let err = AssertionAlgorithm::infer_from_pem(&[0xff, 0xfe, 0xfd]).unwrap_err(); + assert!(matches!(err, ActualError::ConfigError(ref m) if m.contains("UTF-8"))); + } + + #[test] + fn infer_from_pem_recognizes_pkcs1_rsa_header() { + // infer_from_pem only inspects the header substring, so a bare marker is + // enough to exercise it — and it keeps a private-key-shaped literal (which + // secret scanners flag) out of the source. A PKCS#1 header names RSA. + let pem = b"test fixture header BEGIN RSA PRIVATE KEY"; + assert_eq!( + AssertionAlgorithm::infer_from_pem(pem).unwrap(), + AssertionAlgorithm::Rs256 + ); + } + + #[test] + fn infer_from_pem_recognizes_sec1_ec_header() { + // A SEC1 header names EC directly (bare marker; see the RSA test). + let pem = b"test fixture header BEGIN EC PRIVATE KEY"; + assert_eq!( + AssertionAlgorithm::infer_from_pem(pem).unwrap(), + AssertionAlgorithm::Es256 + ); + } + + #[test] + fn infer_from_pem_pkcs8_with_unparseable_body_errors() { + // A PKCS#8 header ("BEGIN PRIVATE KEY", no algorithm named) whose body + // parses as neither RSA nor EC falls through to a clean error asking for + // an explicit --alg (bare marker; see the RSA test). + let pem = b"test fixture header BEGIN PRIVATE KEY with an unparseable body"; + let err = AssertionAlgorithm::infer_from_pem(pem).unwrap_err(); + assert!(matches!(err, ActualError::ConfigError(ref m) if m.contains("--alg"))); + } + + #[test] + fn is_uuid_rejects_wrong_char_at_dash_position() { + // 36 chars, correct everywhere except position 8 holds '0' where the + // 8-4-4-4-12 layout requires a dash. + assert!(!is_uuid("3f8a1c2e04b5d-4e6f-8a9b-0c1d2e3f4a5b")); + } + + #[test] + fn is_uuid_rejects_non_hex_digit() { + // 36 chars with valid dashes/version/variant, but 'z' at position 0 is + // not a hex digit. + assert!(!is_uuid("zf8a1c2e-4b5d-4e6f-8a9b-0c1d2e3f4a5b")); + } + + #[test] + fn empty_audience_is_rejected_before_signing() { + let (priv_pem, _pub) = ec_keypair(); + let mut p = params(AssertionAlgorithm::Es256); + p.audience = " "; + let err = build_and_sign_assertion(&p, &priv_pem, 1_700_000_000).unwrap_err(); + assert!(matches!(err, ActualError::ConfigError(ref m) if m.contains("audience"))); + } + + #[tokio::test] + async fn mint_token_surfaces_error_body_without_oauth_error_field() { + // A failure body that is not OAuth error JSON (here JSON with no `error` + // field) falls through to the truncated-body fallback — still a clean + // ApiError carrying the status, no panic and no secret material. + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("POST", "/api/oauth/token") + .with_status(503) + .with_header("content-type", "application/json") + .with_body(r#"{"detail":"upstream unavailable"}"#) + .create_async() + .await; + + let http = crate::auth::oauth::build_http_client(&server.url()).unwrap(); + let err = mint_token(&http, &server.url(), "some.assertion", None) + .await + .unwrap_err(); + match err { + ActualError::ApiError(msg) => { + assert!(msg.contains("503"), "surfaces the status: {msg}"); + assert!( + msg.contains("upstream unavailable"), + "surfaces the body: {msg}" + ); + } + other => panic!("expected ApiError, got {other:?}"), + } + } + + #[tokio::test] + async fn mint_token_surfaces_non_json_error_body() { + // A non-JSON failure body makes the OAuth-error parse fail, so the outer + // guard is skipped and the truncated-text fallback runs — still a clean + // ApiError carrying the status. + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("POST", "/api/oauth/token") + .with_status(502) + .with_header("content-type", "text/plain") + .with_body("upstream is down") + .create_async() + .await; + + let http = crate::auth::oauth::build_http_client(&server.url()).unwrap(); + let err = mint_token(&http, &server.url(), "some.assertion", None) + .await + .unwrap_err(); + match err { + ActualError::ApiError(msg) => { + assert!(msg.contains("502"), "surfaces the status: {msg}"); + assert!(msg.contains("upstream is down"), "surfaces the body: {msg}"); + } + other => panic!("expected ApiError, got {other:?}"), + } + } } diff --git a/src/cli/commands/mint_token.rs b/src/cli/commands/mint_token.rs index 871fa5f5..0320095f 100644 --- a/src/cli/commands/mint_token.rs +++ b/src/cli/commands/mint_token.rs @@ -42,11 +42,7 @@ pub fn exec(args: &MintTokenArgs) -> Result<(), ActualError> { let audience = resolve_audience(args.aud.as_deref(), &base_url); let scope = resolve_scope(&args.scopes); - let lifetime = if args.assertion_ttl_seconds == 0 { - DEFAULT_ASSERTION_LIFETIME_SECONDS - } else { - args.assertion_ttl_seconds - }; + let lifetime = resolve_lifetime(args.assertion_ttl_seconds); let assertion = jwt_bearer::build_and_sign_assertion( &AssertionParams { @@ -162,16 +158,35 @@ fn resolve_scope(scopes: &[String]) -> Option { } } -/// Current time in seconds since the Unix epoch. -fn now_unix() -> Result { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) +/// Resolve the assertion lifetime: `0` (the "unset" sentinel from the default) +/// falls back to [`DEFAULT_ASSERTION_LIFETIME_SECONDS`]; any other value is used +/// as given (`build_and_sign_assertion` clamps it to the server cap). +fn resolve_lifetime(ttl_seconds: u64) -> u64 { + if ttl_seconds == 0 { + DEFAULT_ASSERTION_LIFETIME_SECONDS + } else { + ttl_seconds + } +} + +/// Convert a `SystemTime` to whole seconds since the Unix epoch, erroring +/// cleanly if the instant precedes the epoch. Split out from [`now_unix`] so the +/// (otherwise unreachable) pre-epoch branch is unit-testable with an injected +/// time, mirroring how [`jwt_bearer::build_and_sign_assertion`] takes `now` as a +/// parameter for testability. +fn unix_seconds(time: std::time::SystemTime) -> Result { + time.duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .map_err(|e| { ActualError::InternalError(format!("system clock is before the Unix epoch: {e}")) }) } +/// Current time in seconds since the Unix epoch. +fn now_unix() -> Result { + unix_seconds(std::time::SystemTime::now()) +} + /// Build a single-threaded tokio runtime so the sync CLI dispatch path can drive /// the async mint (mirrors `commands::login`). fn build_runtime() -> Result { @@ -201,6 +216,14 @@ mod tests { assert_eq!(resolve_scope(&[" ".to_string()]), None); } + #[test] + fn resolve_lifetime_uses_default_only_for_zero() { + // 0 is the "unset" sentinel → default; every other value passes through. + assert_eq!(resolve_lifetime(0), DEFAULT_ASSERTION_LIFETIME_SECONDS); + assert_eq!(resolve_lifetime(1), 1); + assert_eq!(resolve_lifetime(300), 300); + } + #[test] fn resolve_audience_defaults_to_issuer_and_trims() { assert_eq!( @@ -268,4 +291,22 @@ mod tests { // 2020-01-01T00:00:00Z assert!(now_unix().unwrap() > 1_577_836_800); } + + #[test] + fn unix_seconds_converts_epoch_and_later() { + assert_eq!(unix_seconds(std::time::UNIX_EPOCH).unwrap(), 0); + assert!(unix_seconds(std::time::SystemTime::now()).unwrap() > 1_577_836_800); + } + + #[test] + fn unix_seconds_errors_before_epoch() { + // A time one second before the epoch surfaces a clean InternalError + // rather than panicking — the pre-epoch branch is otherwise unreachable + // with the real clock. + let before = std::time::UNIX_EPOCH - std::time::Duration::from_secs(1); + let err = unix_seconds(before).unwrap_err(); + assert!( + matches!(err, ActualError::InternalError(ref m) if m.contains("before the Unix epoch")) + ); + } } diff --git a/src/lib.rs b/src/lib.rs index f587743f..c29c0b48 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -137,4 +137,31 @@ mod tests { let cli = Cli::parse_from(["actual", "advisor", "why?"]); assert!(run(cli).is_err()); } + + #[test] + fn test_run_mint_token_missing_key_returns_err() { + // Exercises the Command::MintToken dispatch arm in-process. The + // subprocess end-to-end tests (tests/mint_token_cli.rs) cannot cover + // this arm: the binary exits via process::exit, which drops the + // coverage profile before it is written. Here an explicit HTTPS issuer + // and a --key path that does not exist make exec() fail cleanly at key + // load — before any HTTP client is built or any request is sent — so + // the arm is covered hermetically and deterministically, with no + // network and no dependence on ambient environment variables (the + // explicit --key wins over ACTUAL_SERVICE_ACCOUNT_KEY_FILE, and the + // Some(path) read short-circuits before ACTUAL_SERVICE_ACCOUNT_KEY). + let cli = Cli::parse_from([ + "actual", + "mint-token", + "--service-account-id", + "00000000-0000-0000-0000-000000000000", + "--kid", + "test-kid", + "--issuer", + "https://issuer.example.test", + "--key", + "/no/such/mint-token-key.pem", + ]); + assert!(run(cli).is_err()); + } }