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/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..73fc8d53 --- /dev/null +++ b/src/auth/jwt_bearer.rs @@ -0,0 +1,930 @@ +//! 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:?}"), + } + } + + #[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/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..0320095f --- /dev/null +++ b/src/cli/commands/mint_token.rs @@ -0,0 +1,312 @@ +//! `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 = resolve_lifetime(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) + } +} + +/// 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 { + 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_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!( + 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); + } + + #[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/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..c29c0b48 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), @@ -136,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()); + } } 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")); +}