From 0214c9ecf0eafdfc9c44880318a9cc2eadd4617c Mon Sep 17 00:00:00 2001 From: nicornk Date: Wed, 25 Feb 2026 14:37:35 +0100 Subject: [PATCH 01/12] feat: add standalone Rust OAuth2 CLI for Foundry Implements a cross-platform OAuth2 Authorization Code + PKCE CLI designed to work as Claude Code's apiKeyHelper. Supports browser-based and console login flows, concurrent token refresh with file locking, and platform-specific credential storage (macOS Keychain / Windows Credential Manager via keyring crate, JSON file on Linux). Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + libs/foundry-oauth-cli/Cargo.lock | 2368 ++++++++++++++++++++++++++ libs/foundry-oauth-cli/Cargo.toml | 29 + libs/foundry-oauth-cli/src/cache.rs | 402 +++++ libs/foundry-oauth-cli/src/cli.rs | 304 ++++ libs/foundry-oauth-cli/src/config.rs | 243 +++ libs/foundry-oauth-cli/src/error.rs | 77 + libs/foundry-oauth-cli/src/log.rs | 24 + libs/foundry-oauth-cli/src/main.rs | 107 ++ libs/foundry-oauth-cli/src/oauth.rs | 233 +++ libs/foundry-oauth-cli/src/server.rs | 114 ++ 11 files changed, 3904 insertions(+) create mode 100644 libs/foundry-oauth-cli/Cargo.lock create mode 100644 libs/foundry-oauth-cli/Cargo.toml create mode 100644 libs/foundry-oauth-cli/src/cache.rs create mode 100644 libs/foundry-oauth-cli/src/cli.rs create mode 100644 libs/foundry-oauth-cli/src/config.rs create mode 100644 libs/foundry-oauth-cli/src/error.rs create mode 100644 libs/foundry-oauth-cli/src/log.rs create mode 100644 libs/foundry-oauth-cli/src/main.rs create mode 100644 libs/foundry-oauth-cli/src/oauth.rs create mode 100644 libs/foundry-oauth-cli/src/server.rs diff --git a/.gitignore b/.gitignore index e5ff6aad..8a487cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ __about__.py # pdm .pdm-python .pdm-build + +# Rust +target/ diff --git a/libs/foundry-oauth-cli/Cargo.lock b/libs/foundry-oauth-cli/Cargo.lock new file mode 100644 index 00000000..157d285e --- /dev/null +++ b/libs/foundry-oauth-cli/Cargo.lock @@ -0,0 +1,2368 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "foundry-oauth-cli" +version = "0.1.0" +dependencies = [ + "base64", + "chrono", + "clap", + "dirs", + "fd-lock", + "keyring", + "open", + "rand", + "reqwest", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tiny_http", + "url", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/libs/foundry-oauth-cli/Cargo.toml b/libs/foundry-oauth-cli/Cargo.toml new file mode 100644 index 00000000..5601a657 --- /dev/null +++ b/libs/foundry-oauth-cli/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "foundry-oauth-cli" +version = "0.1.0" +edition = "2021" +description = "Standalone OAuth2 CLI for Foundry, designed for Claude Code apiKeyHelper" + +[[bin]] +name = "foundry-oauth" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +reqwest = { version = "0.12", features = ["json", "blocking"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +base64 = "0.22" +rand = "0.8" +tiny_http = "0.12" +open = "5" +dirs = "5" +url = "2" +chrono = { version = "0.4", features = ["serde"] } +fd-lock = "4" +keyring = { version = "3", features = ["apple-native", "windows-native"] } +thiserror = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/libs/foundry-oauth-cli/src/cache.rs b/libs/foundry-oauth-cli/src/cache.rs new file mode 100644 index 00000000..228607f0 --- /dev/null +++ b/libs/foundry-oauth-cli/src/cache.rs @@ -0,0 +1,402 @@ +use crate::error::{Error, Result}; +use crate::log; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +const LOCK_TIMEOUT_SECS: u64 = 30; +const KEYRING_SERVICE: &str = "foundry-oauth"; + +/// On-disk cache file (Linux only): maps hash keys to refresh tokens. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct CacheFile { + pub tokens: HashMap, +} + +/// Compute the cache key: sha256 of "{hostname}\0{client_id}\0{sorted scopes space-joined}". +pub fn cache_key(hostname: &str, client_id: &str, scopes: &[String]) -> String { + let mut sorted_scopes = scopes.to_vec(); + sorted_scopes.sort(); + let input = format!("{}\0{}\0{}", hostname, client_id, sorted_scopes.join(" ")); + let hash = Sha256::digest(input.as_bytes()); + hex::encode(hash) +} + +/// Hex encoding (no extra dependency — small inline helper). +mod hex { + pub fn encode(bytes: impl AsRef<[u8]>) -> String { + bytes + .as_ref() + .iter() + .map(|b| format!("{:02x}", b)) + .collect() + } +} + +/// Whether to use OS keyring (macOS/Windows) or JSON file (Linux). +fn use_keyring() -> bool { + cfg!(target_os = "macos") || cfg!(target_os = "windows") +} + +// --------------------------------------------------------------------------- +// Keyring backend (macOS / Windows) +// --------------------------------------------------------------------------- + +fn keyring_load(key: &str) -> Result> { + let entry = keyring::Entry::new(KEYRING_SERVICE, key) + .map_err(|e| Error::Keyring(format!("failed to create keyring entry: {}", e)))?; + match entry.get_password() { + Ok(token) => Ok(Some(token)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(Error::Keyring(format!("failed to read from keyring: {}", e))), + } +} + +fn keyring_save(key: &str, refresh_token: &str) -> Result<()> { + let entry = keyring::Entry::new(KEYRING_SERVICE, key) + .map_err(|e| Error::Keyring(format!("failed to create keyring entry: {}", e)))?; + entry + .set_password(refresh_token) + .map_err(|e| Error::Keyring(format!("failed to save to keyring: {}", e))) +} + +fn keyring_delete(key: &str) -> Result { + let entry = keyring::Entry::new(KEYRING_SERVICE, key) + .map_err(|e| Error::Keyring(format!("failed to create keyring entry: {}", e)))?; + match entry.delete_credential() { + Ok(()) => Ok(true), + Err(keyring::Error::NoEntry) => Ok(false), + Err(e) => Err(Error::Keyring(format!( + "failed to delete from keyring: {}", + e + ))), + } +} + +// --------------------------------------------------------------------------- +// JSON file backend (Linux) +// --------------------------------------------------------------------------- + +/// Ensure the cache directory exists with 0o700 permissions. +pub fn ensure_cache_dir(cache_dir: &Path) -> Result<()> { + if !cache_dir.exists() { + fs::create_dir_all(cache_dir).map_err(|e| Error::CacheDir { + path: cache_dir.to_path_buf(), + source: e, + })?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(cache_dir, fs::Permissions::from_mode(0o700)).map_err(|e| { + Error::CacheDir { + path: cache_dir.to_path_buf(), + source: e, + } + })?; + } + Ok(()) +} + +fn cache_file_path(cache_dir: &Path) -> PathBuf { + cache_dir.join("oauth-cache.json") +} + +fn lock_file_path(cache_dir: &Path) -> PathBuf { + cache_dir.join("oauth-cache.lock") +} + +/// Read the cache file, returning the parsed structure. +fn read_cache_file(cache_dir: &Path) -> Result { + let path = cache_file_path(cache_dir); + if !path.exists() { + return Ok(CacheFile::default()); + } + let data = fs::read_to_string(&path).map_err(|e| Error::CacheIo { + path: path.clone(), + source: e, + })?; + serde_json::from_str(&data).map_err(|e| Error::CacheParse { path, source: e }) +} + +/// Write the cache file with 0o600 permissions. +fn write_cache_file(cache_dir: &Path, cache: &CacheFile) -> Result<()> { + let path = cache_file_path(cache_dir); + let data = serde_json::to_string_pretty(cache).expect("cache serialization cannot fail"); + fs::write(&path, data).map_err(|e| Error::CacheIo { + path: path.clone(), + source: e, + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).map_err(|e| { + Error::CacheIo { + path: path.clone(), + source: e, + } + })?; + } + Ok(()) +} + +fn file_load(cache_dir: &Path, key: &str) -> Result> { + let cache = read_cache_file(cache_dir)?; + Ok(cache.tokens.get(key).cloned()) +} + +fn file_save(cache_dir: &Path, key: &str, refresh_token: &str) -> Result<()> { + ensure_cache_dir(cache_dir)?; + let mut cache = read_cache_file(cache_dir)?; + cache.tokens.insert(key.to_string(), refresh_token.to_string()); + write_cache_file(cache_dir, &cache) +} + +fn file_delete(cache_dir: &Path, key: &str) -> Result { + let mut cache = read_cache_file(cache_dir)?; + let removed = cache.tokens.remove(key).is_some(); + if removed { + write_cache_file(cache_dir, &cache)?; + } + Ok(removed) +} + +// --------------------------------------------------------------------------- +// Public API — dispatches to keyring or file backend +// --------------------------------------------------------------------------- + +/// Load a cached refresh token for the given parameters. +pub fn load( + cache_dir: &Path, + hostname: &str, + client_id: &str, + scopes: &[String], + debug: bool, +) -> Result> { + let key = cache_key(hostname, client_id, scopes); + + let result = if use_keyring() { + keyring_load(&key)? + } else { + file_load(cache_dir, &key)? + }; + + match &result { + Some(_) => { + let backend = if use_keyring() { "keyring" } else { "file" }; + log::debug_log( + debug, + cache_dir, + "CACHE_HIT", + &format!("refresh token found in {}", backend), + ); + } + None => { + log::debug_log( + debug, + cache_dir, + "CACHE_MISS", + &format!("no refresh token for key {}", &key[..12]), + ); + } + } + + Ok(result) +} + +/// Save a refresh token to the cache. +pub fn save( + cache_dir: &Path, + hostname: &str, + client_id: &str, + scopes: &[String], + refresh_token: &str, + debug: bool, +) -> Result<()> { + let key = cache_key(hostname, client_id, scopes); + + if use_keyring() { + keyring_save(&key, refresh_token)?; + } else { + file_save(cache_dir, &key, refresh_token)?; + } + + let backend = if use_keyring() { "keyring" } else { "file" }; + log::debug_log( + debug, + cache_dir, + "CACHE_SAVE", + &format!("refresh token saved to {}", backend), + ); + Ok(()) +} + +/// Delete the cached credential for the given parameters. +pub fn delete( + cache_dir: &Path, + hostname: &str, + client_id: &str, + scopes: &[String], +) -> Result { + let key = cache_key(hostname, client_id, scopes); + + if use_keyring() { + keyring_delete(&key) + } else { + file_delete(cache_dir, &key) + } +} + +/// Execute a closure while holding an exclusive file lock. +pub fn with_lock(cache_dir: &Path, debug: bool, f: F) -> Result +where + F: FnOnce() -> Result, +{ + ensure_cache_dir(cache_dir)?; + + let lock_path = lock_file_path(cache_dir); + let lock_file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&lock_path) + .map_err(|e| Error::CacheIo { + path: lock_path.clone(), + source: e, + })?; + + // Try to acquire the lock with a timeout + let start = Instant::now(); + let timeout = Duration::from_secs(LOCK_TIMEOUT_SECS); + let mut lock = fd_lock::RwLock::new(lock_file); + + loop { + match lock.try_write() { + Ok(_guard) => { + log::debug_log(debug, cache_dir, "LOCK_ACQUIRED", "exclusive lock acquired"); + let result = f(); + // _guard drops here, releasing the lock + return result; + } + Err(_) => { + if start.elapsed() >= timeout { + return Err(Error::LockTimeout { + seconds: LOCK_TIMEOUT_SECS, + }); + } + log::debug_log(debug, cache_dir, "LOCK_WAIT", "waiting to acquire file lock"); + std::thread::sleep(Duration::from_millis(200)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cache_key_deterministic() { + let key1 = cache_key("host.example.com", "client123", &["offline_access".into()]); + let key2 = cache_key("host.example.com", "client123", &["offline_access".into()]); + assert_eq!(key1, key2); + } + + #[test] + fn test_cache_key_scope_order_independent() { + let key1 = cache_key( + "host.example.com", + "client123", + &["offline_access".into(), "api:read".into()], + ); + let key2 = cache_key( + "host.example.com", + "client123", + &["api:read".into(), "offline_access".into()], + ); + assert_eq!(key1, key2); + } + + #[test] + fn test_cache_key_different_hosts() { + let key1 = cache_key("host1.example.com", "client123", &["offline_access".into()]); + let key2 = cache_key("host2.example.com", "client123", &["offline_access".into()]); + assert_ne!(key1, key2); + } + + #[test] + fn test_cache_key_is_sha256_hex() { + let key = cache_key("host.example.com", "client123", &["offline_access".into()]); + assert_eq!(key.len(), 64); // sha256 hex = 64 chars + assert!(key.chars().all(|c| c.is_ascii_hexdigit())); + } + + // File-backend tests (always work, regardless of platform) + #[test] + fn test_file_cache_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = dir.path(); + + let key = cache_key("host.example.com", "client123", &["offline_access".into()]); + + // Initially empty + assert!(file_load(cache_dir, &key).unwrap().is_none()); + + // Save + file_save(cache_dir, &key, "refresh_tok").unwrap(); + + // Load back + assert_eq!(file_load(cache_dir, &key).unwrap().unwrap(), "refresh_tok"); + + // Delete + assert!(file_delete(cache_dir, &key).unwrap()); + + // Gone + assert!(file_load(cache_dir, &key).unwrap().is_none()); + } + + // Integration test using the public API (dispatches to keyring on macOS/Windows) + #[test] + fn test_cache_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = dir.path(); + + // Initially empty + let result = + load(cache_dir, "host.example.com", "client123", &["offline_access".into()], false) + .unwrap(); + assert!(result.is_none()); + + // Save + save( + cache_dir, + "host.example.com", + "client123", + &["offline_access".into()], + "refresh_tok", + false, + ) + .unwrap(); + + // Load back + let result = + load(cache_dir, "host.example.com", "client123", &["offline_access".into()], false) + .unwrap(); + assert_eq!(result.unwrap(), "refresh_tok"); + + // Delete + let removed = + delete(cache_dir, "host.example.com", "client123", &["offline_access".into()]) + .unwrap(); + assert!(removed); + + // Gone + let result = + load(cache_dir, "host.example.com", "client123", &["offline_access".into()], false) + .unwrap(); + assert!(result.is_none()); + } +} diff --git a/libs/foundry-oauth-cli/src/cli.rs b/libs/foundry-oauth-cli/src/cli.rs new file mode 100644 index 00000000..aac1703c --- /dev/null +++ b/libs/foundry-oauth-cli/src/cli.rs @@ -0,0 +1,304 @@ +use crate::cache; +use crate::config::Config; +use crate::error::{Error, Result}; +use crate::log; +use crate::oauth; +use crate::server; +use std::io::{self, BufRead, Write}; + +/// Run the interactive login flow: open browser (or console mode), complete OAuth2, store tokens. +pub fn login(config: &Config) -> Result<()> { + log::debug_log( + config.debug, + &config.cache_dir, + "STARTED", + &format!("login — hostname={}, scopes={}", config.hostname, config.scopes_str()), + ); + + let pkce = oauth::generate_pkce(); + let state = oauth::generate_state(); + + let (redirect_uri, code) = if config.no_browser { + // Console mode: user manually copies auth code + let redirect_uri = config.callback_url(); + let auth_url = oauth::build_authorization_url(config, &pkce, &state, &redirect_uri); + + eprintln!("Open this URL in your browser to authorize:"); + eprintln!(); + eprintln!(" {}", auth_url); + eprintln!(); + eprint!("Paste the authorization code here: "); + io::stderr().flush().ok(); + + let mut code = String::new(); + io::stdin() + .lock() + .read_line(&mut code) + .map_err(|_| Error::OAuthAuthorization("failed to read authorization code".into()))?; + let code = code.trim().to_string(); + if code.is_empty() { + return Err(Error::OAuthAuthorization("empty authorization code".into())); + } + + (redirect_uri, code) + } else { + // Browser mode: start local server, open browser + log::debug_log(config.debug, &config.cache_dir, "LOGIN_TRIGGERED", "starting browser-based login"); + + let (port, callback) = { + // Start the server first so we know the actual port + let (port, _redirect_uri) = start_server_and_open_browser(config, &pkce, &state)?; + // wait_for_callback blocks until the browser redirects back + let (actual_port, callback) = server::wait_for_callback(port, 300)?; + // The actual_port should match, but use what we got + let _ = actual_port; + (port, callback) + }; + + // Validate state + if callback.state != state { + return Err(Error::StateMismatch { + expected: state, + got: callback.state, + }); + } + + let redirect_uri = config.local_redirect_uri(port); + (redirect_uri, callback.code) + }; + + // Exchange the authorization code for tokens + log::debug_log(config.debug, &config.cache_dir, "LOGIN_PENDING", "exchanging authorization code for tokens"); + let token_resp = oauth::exchange_code(config, &code, &pkce.code_verifier, &redirect_uri)?; + + // Save refresh token + if let Some(ref refresh_token) = token_resp.refresh_token { + cache::with_lock(&config.cache_dir, config.debug, || { + cache::save( + &config.cache_dir, + &config.hostname, + &config.client_id, + &config.scopes, + refresh_token, + config.debug, + ) + })?; + } + + log::debug_log(config.debug, &config.cache_dir, "LOGIN_OK", "login completed, refresh token saved"); + eprintln!("Login successful! Tokens cached for {}.", config.hostname); + + Ok(()) +} + +/// Start the local callback server and open the browser to the authorization URL. +/// Returns the port the server is bound to. +fn start_server_and_open_browser( + config: &Config, + pkce: &oauth::Pkce, + state: &str, +) -> Result<(u16, String)> { + // We need to know the port before building the URL, but wait_for_callback + // does the binding. We'll use a two-step approach: bind, build URL, open browser. + // Actually, server::wait_for_callback already binds and waits. But we need + // to open the browser *after* binding but *before* the callback arrives. + // + // Restructure: bind the server, open the browser, then wait for the callback. + // This requires splitting wait_for_callback. For simplicity, we'll use a + // thread: spawn the server wait in a thread, open the browser, then join. + + let port = config.port; + let redirect_uri = config.local_redirect_uri(port); + let auth_url = oauth::build_authorization_url(config, pkce, state, &redirect_uri); + + // Open browser (best effort) + eprintln!("Opening browser for authentication..."); + if let Err(e) = open::that(&auth_url) { + eprintln!("Failed to open browser: {}", e); + eprintln!("Please open this URL manually:"); + eprintln!(" {}", auth_url); + } + + Ok((port, redirect_uri)) +} + +/// Get a fresh access token, refreshing via cached refresh_token. Outputs token to stdout. +pub fn token(config: &Config) -> Result<()> { + log::debug_log( + config.debug, + &config.cache_dir, + "STARTED", + &format!("token — hostname={}, scopes={}", config.hostname, config.scopes_str()), + ); + + // Try refresh under the lock (fast path) + let result = cache::with_lock(&config.cache_dir, config.debug, || { + refresh_cached_token(config) + }); + + let access_token = match result { + Ok(token) => token, + Err(Error::LoginRequired) | Err(Error::TokenRefresh { .. }) => { + // No cached token or refresh failed — try auto-login OUTSIDE the lock + // so the interactive browser flow doesn't hold the lock for 30+ seconds + if let Err(ref e) = result { + log::debug_log(config.debug, &config.cache_dir, "LOGIN_TRIGGERED", + &format!("attempting auto-login: {}", e)); + } + try_auto_login(config)? + } + Err(e) => return Err(e), + }; + + // Print access token to stdout (the ONLY thing that goes to stdout) + println!("{}", access_token); + log::debug_log(config.debug, &config.cache_dir, "TOKEN_OUTPUT", "access token printed to stdout"); + log::debug_log(config.debug, &config.cache_dir, "EXIT", "0"); + + Ok(()) +} + +/// Try to refresh using a cached token (called while holding the lock). +/// Returns LoginRequired if no cached token exists. +fn refresh_cached_token(config: &Config) -> Result { + let cached = cache::load( + &config.cache_dir, + &config.hostname, + &config.client_id, + &config.scopes, + config.debug, + )?; + + match cached { + Some(refresh_tok) => { + log::debug_log(config.debug, &config.cache_dir, "REFRESH_START", "sending refresh token request"); + + match oauth::refresh_token(config, &refresh_tok) { + Ok(resp) => { + // Save the rotated refresh token + if let Some(ref new_refresh) = resp.refresh_token { + cache::save( + &config.cache_dir, + &config.hostname, + &config.client_id, + &config.scopes, + new_refresh, + config.debug, + )?; + } + log::debug_log(config.debug, &config.cache_dir, "REFRESH_OK", "new access token received"); + Ok(resp.access_token) + } + Err(e) => { + log::debug_log( + config.debug, + &config.cache_dir, + "REFRESH_FAIL", + &format!("{}", e), + ); + eprintln!("Token refresh failed: {}", e); + Err(e) + } + } + } + None => Err(Error::LoginRequired), + } +} + +/// Attempt auto-login (interactive). If not possible, return LoginRequired error. +/// Called OUTSIDE the lock so the browser flow doesn't block other processes. +fn try_auto_login(config: &Config) -> Result { + // Check if we're in an interactive terminal + if !atty_is_terminal() { + eprintln!("No cached credentials and not running interactively."); + eprintln!("Run `foundry-oauth login` in a terminal first."); + return Err(Error::LoginRequired); + } + + log::debug_log(config.debug, &config.cache_dir, "LOGIN_TRIGGERED", "no valid token, starting interactive login"); + eprintln!("No cached token found. Starting login flow..."); + + // Run login flow (this may open a browser and wait — NOT under the lock) + login(config)?; + + // After login, refresh under the lock to get an access token + cache::with_lock(&config.cache_dir, config.debug, || { + let refresh_tok = cache::load( + &config.cache_dir, + &config.hostname, + &config.client_id, + &config.scopes, + config.debug, + )? + .ok_or(Error::LoginRequired)?; + + let resp = oauth::refresh_token(config, &refresh_tok)?; + if let Some(ref new_refresh) = resp.refresh_token { + cache::save( + &config.cache_dir, + &config.hostname, + &config.client_id, + &config.scopes, + new_refresh, + config.debug, + )?; + } + + Ok(resp.access_token) + }) +} + +/// Check if stderr is a terminal (heuristic for interactivity). +fn atty_is_terminal() -> bool { + std::io::IsTerminal::is_terminal(&std::io::stderr()) +} + +/// Show authentication status. +pub fn status(config: &Config) -> Result<()> { + let has_token = cache::load( + &config.cache_dir, + &config.hostname, + &config.client_id, + &config.scopes, + config.debug, + )? + .is_some(); + + eprintln!(" Hostname: {}", config.hostname); + eprintln!(" Scopes: {}", config.scopes_str()); + eprintln!(" Has token: {}", if has_token { "yes" } else { "no" }); + + if !has_token { + eprintln!(); + eprintln!("Run `foundry-oauth login` to authenticate."); + } + + Ok(()) +} + +/// Clear stored credentials. +pub fn logout(config: &Config) -> Result<()> { + log::debug_log( + config.debug, + &config.cache_dir, + "STARTED", + &format!("logout — hostname={}", config.hostname), + ); + + let removed = cache::with_lock(&config.cache_dir, config.debug, || { + cache::delete( + &config.cache_dir, + &config.hostname, + &config.client_id, + &config.scopes, + ) + })?; + + if removed { + eprintln!("Credentials for {} removed.", config.hostname); + } else { + eprintln!("No credentials found for {}.", config.hostname); + } + + Ok(()) +} diff --git a/libs/foundry-oauth-cli/src/config.rs b/libs/foundry-oauth-cli/src/config.rs new file mode 100644 index 00000000..de75207b --- /dev/null +++ b/libs/foundry-oauth-cli/src/config.rs @@ -0,0 +1,243 @@ +use crate::error::{Error, Result}; +use std::path::PathBuf; + +/// Resolved configuration for the CLI. +#[derive(Debug, Clone)] +pub struct Config { + pub hostname: String, + pub client_id: String, + pub client_secret: Option, + pub scopes: Vec, + pub cache_dir: PathBuf, + pub port: u16, + pub no_browser: bool, + pub debug: bool, +} + +/// Raw values from CLI flags (all optional — flags override env vars). +#[derive(Debug, Default)] +pub struct CliFlags { + pub hostname: Option, + pub client_id: Option, + pub client_secret: Option, + pub scopes: Option, + pub cache_dir: Option, + pub port: Option, + pub no_browser: bool, + pub debug: bool, +} + +impl Config { + /// Build a resolved Config by merging: CLI flags → native env vars → FDT env vars → defaults. + pub fn resolve(flags: CliFlags) -> Result { + let hostname = flags + .hostname + .or_else(|| std::env::var("FOUNDRY_HOSTNAME").ok()) + .or_else(|| std::env::var("FDT_CREDENTIALS__DOMAIN").ok()) + .ok_or(Error::MissingConfig( + "hostname (--hostname, FOUNDRY_HOSTNAME, or FDT_CREDENTIALS__DOMAIN)", + ))?; + + let client_id = flags + .client_id + .or_else(|| std::env::var("FOUNDRY_CLIENT_ID").ok()) + .or_else(|| std::env::var("FDT_CREDENTIALS__OAUTH__CLIENT_ID").ok()) + .ok_or(Error::MissingConfig( + "client_id (--client-id, FOUNDRY_CLIENT_ID, or FDT_CREDENTIALS__OAUTH__CLIENT_ID)", + ))?; + + let client_secret = flags + .client_secret + .or_else(|| std::env::var("FOUNDRY_CLIENT_SECRET").ok()); + + let scopes = flags + .scopes + .or_else(|| std::env::var("FOUNDRY_SCOPES").ok()) + .map(|s| s.split_whitespace().map(String::from).collect()) + .unwrap_or_else(|| vec!["offline_access".to_string()]); + + let cache_dir = flags + .cache_dir + .or_else(|| std::env::var("FOUNDRY_CACHE_DIR").ok()) + .map(PathBuf::from) + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".foundry") + .join("oauth-cli") + }); + + let port = flags + .port + .or_else(|| { + std::env::var("FOUNDRY_OAUTH_PORT") + .ok() + .and_then(|s| s.parse().ok()) + }) + .unwrap_or(9876); + + let debug = flags.debug + || std::env::var("FOUNDRY_OAUTH_DEBUG") + .ok() + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + Ok(Config { + hostname, + client_id, + client_secret, + scopes, + cache_dir, + port, + no_browser: flags.no_browser, + debug, + }) + } + + /// Scopes as a space-delimited string (for OAuth requests). + pub fn scopes_str(&self) -> String { + self.scopes.join(" ") + } + + /// Authorization endpoint URL. + pub fn authorize_url(&self) -> String { + format!( + "https://{}/multipass/api/oauth2/authorize", + self.hostname + ) + } + + /// Token endpoint URL. + pub fn token_url(&self) -> String { + format!( + "https://{}/multipass/api/oauth2/token", + self.hostname + ) + } + + /// Callback URL used for console (no-browser) mode. + pub fn callback_url(&self) -> String { + format!( + "https://{}/multipass/api/oauth2/callback", + self.hostname + ) + } + + /// Local redirect URI for browser-based flow. + pub fn local_redirect_uri(&self, port: u16) -> String { + format!("http://127.0.0.1:{}/", port) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn flags_with_required(hostname: &str, client_id: &str) -> CliFlags { + CliFlags { + hostname: Some(hostname.to_string()), + client_id: Some(client_id.to_string()), + ..Default::default() + } + } + + #[test] + fn test_resolve_with_flags() { + let config = Config::resolve(flags_with_required("host.example.com", "my-client")).unwrap(); + assert_eq!(config.hostname, "host.example.com"); + assert_eq!(config.client_id, "my-client"); + assert_eq!(config.scopes, vec!["offline_access"]); + assert_eq!(config.port, 9876); + assert!(!config.debug); + assert!(!config.no_browser); + } + + #[test] + fn test_resolve_missing_hostname() { + let flags = CliFlags { + client_id: Some("id".into()), + ..Default::default() + }; + // Clear env vars that might interfere + std::env::remove_var("FOUNDRY_HOSTNAME"); + std::env::remove_var("FDT_CREDENTIALS__DOMAIN"); + let result = Config::resolve(flags); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_missing_client_id() { + let flags = CliFlags { + hostname: Some("host".into()), + ..Default::default() + }; + std::env::remove_var("FOUNDRY_CLIENT_ID"); + std::env::remove_var("FDT_CREDENTIALS__OAUTH__CLIENT_ID"); + let result = Config::resolve(flags); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_custom_scopes() { + let mut flags = flags_with_required("host", "id"); + flags.scopes = Some("api:read offline_access".into()); + let config = Config::resolve(flags).unwrap(); + assert_eq!(config.scopes, vec!["api:read", "offline_access"]); + } + + #[test] + fn test_resolve_custom_port() { + let mut flags = flags_with_required("host", "id"); + flags.port = Some(9999); + let config = Config::resolve(flags).unwrap(); + assert_eq!(config.port, 9999); + } + + #[test] + fn test_resolve_debug_flag() { + let mut flags = flags_with_required("host", "id"); + flags.debug = true; + let config = Config::resolve(flags).unwrap(); + assert!(config.debug); + } + + #[test] + fn test_scopes_str() { + let config = Config::resolve(flags_with_required("host", "id")).unwrap(); + assert_eq!(config.scopes_str(), "offline_access"); + } + + #[test] + fn test_authorize_url() { + let config = Config::resolve(flags_with_required("foundry.example.com", "id")).unwrap(); + assert_eq!( + config.authorize_url(), + "https://foundry.example.com/multipass/api/oauth2/authorize" + ); + } + + #[test] + fn test_token_url() { + let config = Config::resolve(flags_with_required("foundry.example.com", "id")).unwrap(); + assert_eq!( + config.token_url(), + "https://foundry.example.com/multipass/api/oauth2/token" + ); + } + + #[test] + fn test_callback_url() { + let config = Config::resolve(flags_with_required("foundry.example.com", "id")).unwrap(); + assert_eq!( + config.callback_url(), + "https://foundry.example.com/multipass/api/oauth2/callback" + ); + } + + #[test] + fn test_local_redirect_uri() { + let config = Config::resolve(flags_with_required("host", "id")).unwrap(); + assert_eq!(config.local_redirect_uri(9876), "http://127.0.0.1:9876/"); + assert_eq!(config.local_redirect_uri(9000), "http://127.0.0.1:9000/"); + } +} diff --git a/libs/foundry-oauth-cli/src/error.rs b/libs/foundry-oauth-cli/src/error.rs new file mode 100644 index 00000000..8aa5bf9e --- /dev/null +++ b/libs/foundry-oauth-cli/src/error.rs @@ -0,0 +1,77 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +#[allow(dead_code)] +pub enum Error { + // Configuration errors + #[error("missing required configuration: {0}")] + MissingConfig(&'static str), + + // OAuth errors + #[error("OAuth authorization failed: {0}")] + OAuthAuthorization(String), + + #[error("token exchange failed (HTTP {status}): {body}")] + TokenExchange { status: u16, body: String }, + + #[error("token refresh failed (HTTP {status}): {body}")] + TokenRefresh { status: u16, body: String }, + + #[error("PKCE generation failed: {0}")] + Pkce(String), + + #[error("state mismatch: expected {expected}, got {got}")] + StateMismatch { expected: String, got: String }, + + // Server errors + #[error("failed to bind callback server on {addr}: {source}")] + ServerBind { + addr: String, + source: std::io::Error, + }, + + #[error("callback server timed out waiting for authorization code")] + ServerTimeout, + + #[error("callback server error: {0}")] + ServerError(String), + + // Cache / storage errors + #[error("cache directory error ({path}): {source}")] + CacheDir { + path: PathBuf, + source: std::io::Error, + }, + + #[error("cache file I/O error ({path}): {source}")] + CacheIo { + path: PathBuf, + source: std::io::Error, + }, + + #[error("cache file parse error ({path}): {source}")] + CacheParse { + path: PathBuf, + source: serde_json::Error, + }, + + #[error("failed to acquire file lock after {seconds}s")] + LockTimeout { seconds: u64 }, + + #[error("keyring error: {0}")] + Keyring(String), + + // HTTP / network errors + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + + // General I/O + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + // Login required (no cached token, non-interactive context) + #[error("no cached credentials found — run `foundry-oauth login` first")] + LoginRequired, +} + +pub type Result = std::result::Result; diff --git a/libs/foundry-oauth-cli/src/log.rs b/libs/foundry-oauth-cli/src/log.rs new file mode 100644 index 00000000..be8524e9 --- /dev/null +++ b/libs/foundry-oauth-cli/src/log.rs @@ -0,0 +1,24 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; + +/// Append a debug log entry to the debug log file if debug mode is enabled. +pub fn debug_log(enabled: bool, cache_dir: &Path, event: &str, message: &str) { + if !enabled { + return; + } + + let log_path = cache_dir.join("oauth-debug.log"); + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + + let line = format!("[{}] {} — {}\n", timestamp, event, message); + + // Best-effort: silently ignore write failures for debug logging + if let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + { + let _ = file.write_all(line.as_bytes()); + } +} diff --git a/libs/foundry-oauth-cli/src/main.rs b/libs/foundry-oauth-cli/src/main.rs new file mode 100644 index 00000000..3b5e8c7d --- /dev/null +++ b/libs/foundry-oauth-cli/src/main.rs @@ -0,0 +1,107 @@ +mod cache; +mod cli; +mod config; +mod error; +mod log; +mod oauth; +mod server; + +use clap::{Parser, Subcommand}; +use config::CliFlags; +use std::process; + +#[derive(Parser)] +#[command( + name = "foundry-oauth", + about = "OAuth2 CLI for Foundry — provides Bearer tokens for Claude Code", + version +)] +struct Cli { + #[command(subcommand)] + command: Command, + + /// Foundry hostname (e.g., foundry.example.com) + #[arg(long, global = true)] + hostname: Option, + + /// OAuth2 client ID + #[arg(long, global = true)] + client_id: Option, + + /// OAuth2 client secret (for confidential clients) + #[arg(long, global = true)] + client_secret: Option, + + /// OAuth2 scopes (space-separated) + #[arg(long, global = true)] + scopes: Option, + + /// Cache directory (default: ~/.foundry/) + #[arg(long, global = true)] + cache_dir: Option, + + /// Local server port for OAuth callback (default: 8888) + #[arg(long, global = true)] + port: Option, + + /// Enable debug logging to ~/.foundry/oauth-debug.log + #[arg(long, global = true)] + debug: bool, +} + +#[derive(Subcommand)] +enum Command { + /// Interactive login: open browser, complete OAuth2 flow, store refresh token + Login { + /// Use console mode instead of browser (for headless/SSH environments) + #[arg(long)] + no_browser: bool, + }, + + /// Output a fresh access token to stdout (refresh if needed) + Token, + + /// Show authentication status and cached credentials + Status, + + /// Clear stored credentials + Logout, +} + +fn main() { + let cli = Cli::parse(); + + let no_browser = matches!(cli.command, Command::Login { no_browser: true }); + + let flags = CliFlags { + hostname: cli.hostname, + client_id: cli.client_id, + client_secret: cli.client_secret, + scopes: cli.scopes, + cache_dir: cli.cache_dir, + port: cli.port, + no_browser, + debug: cli.debug, + }; + + let config = match config::Config::resolve(flags) { + Ok(c) => c, + Err(e) => { + eprintln!("Configuration error: {}", e); + process::exit(1); + } + }; + + let result = match cli.command { + Command::Login { .. } => cli::login(&config), + Command::Token => cli::token(&config), + Command::Status => cli::status(&config), + Command::Logout => cli::logout(&config), + }; + + if let Err(e) = result { + log::debug_log(config.debug, &config.cache_dir, "EXIT", &format!("1 — {}", e)); + eprintln!("Error: {}", e); + process::exit(1); + } +} diff --git a/libs/foundry-oauth-cli/src/oauth.rs b/libs/foundry-oauth-cli/src/oauth.rs new file mode 100644 index 00000000..93513285 --- /dev/null +++ b/libs/foundry-oauth-cli/src/oauth.rs @@ -0,0 +1,233 @@ +use crate::config::Config; +use crate::error::{Error, Result}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use rand::Rng; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; + +/// PKCE pair: code_verifier and the derived code_challenge. +#[derive(Debug, Clone)] +pub struct Pkce { + pub code_verifier: String, + pub code_challenge: String, +} + +/// Token response from the Foundry OAuth2 token endpoint. +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: Option, + pub refresh_token: Option, +} + +/// Characters allowed in a PKCE code_verifier (RFC 7636 §4.1). +const PKCE_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + +/// Generate a PKCE code_verifier and code_challenge (S256). +pub fn generate_pkce() -> Pkce { + let mut rng = rand::thread_rng(); + let verifier: String = (0..128) + .map(|_| { + let idx = rng.gen_range(0..PKCE_CHARS.len()); + PKCE_CHARS[idx] as char + }) + .collect(); + + let digest = Sha256::digest(verifier.as_bytes()); + let challenge = URL_SAFE_NO_PAD.encode(digest); + + Pkce { + code_verifier: verifier, + code_challenge: challenge, + } +} + +/// Generate a random state parameter for CSRF protection. +pub fn generate_state() -> String { + let mut rng = rand::thread_rng(); + let bytes: Vec = (0..32).map(|_| rng.gen()).collect(); + URL_SAFE_NO_PAD.encode(&bytes) +} + +/// Build the full authorization URL for the browser redirect. +pub fn build_authorization_url( + config: &Config, + pkce: &Pkce, + state: &str, + redirect_uri: &str, +) -> String { + let params = [ + ("response_type", "code"), + ("client_id", &config.client_id), + ("redirect_uri", redirect_uri), + ("scope", &config.scopes_str()), + ("state", state), + ("code_challenge", &pkce.code_challenge), + ("code_challenge_method", "S256"), + ]; + + let query = params + .iter() + .map(|(k, v)| { + format!( + "{}={}", + url::form_urlencoded::byte_serialize(k.as_bytes()).collect::(), + url::form_urlencoded::byte_serialize(v.as_bytes()).collect::() + ) + }) + .collect::>() + .join("&"); + + format!("{}?{}", config.authorize_url(), query) +} + +/// Exchange an authorization code for tokens. +pub fn exchange_code( + config: &Config, + code: &str, + code_verifier: &str, + redirect_uri: &str, +) -> Result { + let mut params = HashMap::new(); + params.insert("grant_type", "authorization_code"); + params.insert("code", code); + params.insert("redirect_uri", redirect_uri); + params.insert("client_id", &config.client_id); + params.insert("code_verifier", code_verifier); + + let client_secret_owned; + if let Some(ref secret) = config.client_secret { + client_secret_owned = secret.clone(); + params.insert("client_secret", &client_secret_owned); + } + + let client = reqwest::blocking::Client::new(); + let resp = client + .post(&config.token_url()) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(¶ms) + .send()?; + + let status = resp.status().as_u16(); + if status != 200 { + let body = resp.text().unwrap_or_default(); + return Err(Error::TokenExchange { status, body }); + } + + Ok(resp.json()?) +} + +/// Refresh an access token using a refresh_token. +pub fn refresh_token(config: &Config, refresh_tok: &str) -> Result { + let mut params = HashMap::new(); + params.insert("grant_type", "refresh_token"); + params.insert("refresh_token", refresh_tok); + params.insert("client_id", &config.client_id); + + let client_secret_owned; + if let Some(ref secret) = config.client_secret { + client_secret_owned = secret.clone(); + params.insert("client_secret", &client_secret_owned); + } + + let client = reqwest::blocking::Client::new(); + let resp = client + .post(&config.token_url()) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(¶ms) + .send()?; + + let status = resp.status().as_u16(); + if status != 200 { + let body = resp.text().unwrap_or_default(); + return Err(Error::TokenRefresh { status, body }); + } + + Ok(resp.json()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_pkce_verifier_length() { + let pkce = generate_pkce(); + assert_eq!(pkce.code_verifier.len(), 128); + } + + #[test] + fn test_generate_pkce_verifier_chars() { + let pkce = generate_pkce(); + let allowed: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + for c in pkce.code_verifier.chars() { + assert!( + allowed.contains(c), + "unexpected char '{}' in code_verifier", + c + ); + } + } + + #[test] + fn test_generate_pkce_challenge_is_base64url() { + let pkce = generate_pkce(); + // base64url_no_pad of sha256(128 bytes) = 43 chars + assert_eq!(pkce.code_challenge.len(), 43); + // Should only contain base64url chars + for c in pkce.code_challenge.chars() { + assert!( + c.is_ascii_alphanumeric() || c == '-' || c == '_', + "unexpected char '{}' in code_challenge", + c + ); + } + } + + #[test] + fn test_generate_pkce_challenge_is_sha256_of_verifier() { + let pkce = generate_pkce(); + let expected_digest = sha2::Sha256::digest(pkce.code_verifier.as_bytes()); + let expected_challenge = URL_SAFE_NO_PAD.encode(expected_digest); + assert_eq!(pkce.code_challenge, expected_challenge); + } + + #[test] + fn test_generate_state_unique() { + let s1 = generate_state(); + let s2 = generate_state(); + assert_ne!(s1, s2); + } + + #[test] + fn test_build_authorization_url() { + let config = Config { + hostname: "foundry.example.com".into(), + client_id: "my-client-id".into(), + client_secret: None, + scopes: vec!["offline_access".into()], + cache_dir: "/tmp/test".into(), + port: 8888, + no_browser: false, + debug: false, + }; + let pkce = Pkce { + code_verifier: "test-verifier".into(), + code_challenge: "test-challenge".into(), + }; + let url = build_authorization_url(&config, &pkce, "test-state", "http://127.0.0.1:8888/"); + + assert!(url.starts_with("https://foundry.example.com/multipass/api/oauth2/authorize?")); + assert!(url.contains("response_type=code")); + assert!(url.contains("client_id=my-client-id")); + assert!(url.contains("redirect_uri=http")); + assert!(url.contains("scope=offline_access")); + assert!(url.contains("state=test-state")); + assert!(url.contains("code_challenge=test-challenge")); + assert!(url.contains("code_challenge_method=S256")); + } +} diff --git a/libs/foundry-oauth-cli/src/server.rs b/libs/foundry-oauth-cli/src/server.rs new file mode 100644 index 00000000..d883e513 --- /dev/null +++ b/libs/foundry-oauth-cli/src/server.rs @@ -0,0 +1,114 @@ +use crate::error::{Error, Result}; +use std::collections::HashMap; +use url::Url; + +/// Result of the callback server: the authorization code and state parameter. +pub struct CallbackResult { + pub code: String, + pub state: String, +} + +const SUCCESS_HTML: &str = r#" + +Authorization Successful + +

Authorization successful!

+

You can close this browser tab and return to the terminal.

+ +"#; + +const ERROR_HTML: &str = r#" + +Authorization Failed + +

Authorization failed

+

Missing authorization code. Please try again.

+ +"#; + +/// Start a local HTTP server on 127.0.0.1 and wait for the OAuth callback. +/// +/// Tries ports starting from `start_port` up to `start_port + 99`. +/// Returns the actual port used and the callback result. +pub fn wait_for_callback(start_port: u16, _timeout_secs: u64) -> Result<(u16, CallbackResult)> { + let (server, port) = bind_server(start_port)?; + + // Set a timeout so we don't hang forever + server + .incoming_requests() + .next() + .map(|request| { + // Check timeout manually isn't needed — tiny_http blocks on .next() + // We rely on the user completing the flow within a reasonable time. + let url_str = format!("http://127.0.0.1:{}{}", port, request.url()); + let parsed = Url::parse(&url_str).map_err(|e| Error::ServerError(e.to_string()))?; + let params: HashMap = parsed.query_pairs().into_owned().collect(); + + if let (Some(code), Some(state)) = (params.get("code"), params.get("state")) { + // Return success page + let response = tiny_http::Response::from_string(SUCCESS_HTML) + .with_header( + tiny_http::Header::from_bytes( + &b"Content-Type"[..], + &b"text/html; charset=utf-8"[..], + ) + .unwrap(), + ); + let _ = request.respond(response); + + Ok(( + port, + CallbackResult { + code: code.clone(), + state: state.clone(), + }, + )) + } else if let Some(error) = params.get("error") { + let desc = params + .get("error_description") + .cloned() + .unwrap_or_default(); + let response = tiny_http::Response::from_string(ERROR_HTML).with_header( + tiny_http::Header::from_bytes( + &b"Content-Type"[..], + &b"text/html; charset=utf-8"[..], + ) + .unwrap(), + ); + let _ = request.respond(response); + Err(Error::OAuthAuthorization(format!("{}: {}", error, desc))) + } else { + let response = tiny_http::Response::from_string(ERROR_HTML).with_header( + tiny_http::Header::from_bytes( + &b"Content-Type"[..], + &b"text/html; charset=utf-8"[..], + ) + .unwrap(), + ); + let _ = request.respond(response); + Err(Error::ServerError( + "callback missing 'code' and 'state' parameters".into(), + )) + } + }) + .unwrap_or(Err(Error::ServerTimeout)) +} + +/// Try to bind to ports starting from `start_port`, incrementing up to 99 times. +fn bind_server(start_port: u16) -> Result<(tiny_http::Server, u16)> { + for offset in 0..100 { + let port = start_port + offset; + let addr = format!("127.0.0.1:{}", port); + match tiny_http::Server::http(&addr) { + Ok(server) => return Ok((server, port)), + Err(_) if offset < 99 => continue, + Err(e) => { + return Err(Error::ServerBind { + addr, + source: std::io::Error::new(std::io::ErrorKind::AddrInUse, e.to_string()), + }); + } + } + } + unreachable!() +} From bcee74056cf405f1dcf0b65e5e356236289bca0b Mon Sep 17 00:00:00 2001 From: nicornk Date: Thu, 26 Feb 2026 13:50:06 +0100 Subject: [PATCH 02/12] refactor: rename OAuth CLI to foundry-dev-tools-oauth-cli and clean up - Rename directory libs/foundry-oauth-cli -> libs/oauth-cli - Rename Cargo package to foundry-dev-tools-oauth-cli - Rename binary to foundry-dev-tools-oauth - Remove all Claude Code and Anthropic references, make tool generic - Simplify bind_server to single port (no port scanning) - Fix clippy warnings (ServerError -> ServerCallback, needless borrows) - Apply cargo fmt formatting - Add Rust pre-commit hooks (fmt, cargo-check) Co-Authored-By: Claude Opus 4.6 --- .pre-commit-config.yaml | 9 ++- .../Cargo.lock | 2 +- .../Cargo.toml | 6 +- .../src/cache.rs | 61 ++++++++++++---- .../src/cli.rs | 73 +++++++++++++++---- .../src/config.rs | 15 +--- .../src/error.rs | 4 +- .../src/log.rs | 6 +- .../src/main.rs | 11 ++- .../src/oauth.rs | 4 +- .../src/server.rs | 58 ++++++--------- 11 files changed, 156 insertions(+), 93 deletions(-) rename libs/{foundry-oauth-cli => oauth-cli}/Cargo.lock (99%) rename libs/{foundry-oauth-cli => oauth-cli}/Cargo.toml (79%) rename libs/{foundry-oauth-cli => oauth-cli}/src/cache.rs (90%) rename libs/{foundry-oauth-cli => oauth-cli}/src/cli.rs (83%) rename libs/{foundry-oauth-cli => oauth-cli}/src/config.rs (95%) rename libs/{foundry-oauth-cli => oauth-cli}/src/error.rs (94%) rename libs/{foundry-oauth-cli => oauth-cli}/src/log.rs (83%) rename libs/{foundry-oauth-cli => oauth-cli}/src/main.rs (89%) rename libs/{foundry-oauth-cli => oauth-cli}/src/oauth.rs (99%) rename libs/{foundry-oauth-cli => oauth-cli}/src/server.rs (65%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f1f522a..97985e34 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,4 +11,11 @@ repos: - id: ruff args: ["--fix","--exit-non-zero-on-fix","--show-fixes"] - id: ruff-format - +- repo: https://github.com/FeryET/pre-commit-rust + rev: v1.2.1 + hooks: + - id: fmt + args: ["--manifest-path", "libs/oauth-cli/Cargo.toml", "--"] + - id: cargo-check + args: ["--manifest-path", "libs/oauth-cli/Cargo.toml"] + diff --git a/libs/foundry-oauth-cli/Cargo.lock b/libs/oauth-cli/Cargo.lock similarity index 99% rename from libs/foundry-oauth-cli/Cargo.lock rename to libs/oauth-cli/Cargo.lock index 157d285e..f8e0a602 100644 --- a/libs/foundry-oauth-cli/Cargo.lock +++ b/libs/oauth-cli/Cargo.lock @@ -378,7 +378,7 @@ dependencies = [ ] [[package]] -name = "foundry-oauth-cli" +name = "foundry-dev-tools-oauth-cli" version = "0.1.0" dependencies = [ "base64", diff --git a/libs/foundry-oauth-cli/Cargo.toml b/libs/oauth-cli/Cargo.toml similarity index 79% rename from libs/foundry-oauth-cli/Cargo.toml rename to libs/oauth-cli/Cargo.toml index 5601a657..caac7121 100644 --- a/libs/foundry-oauth-cli/Cargo.toml +++ b/libs/oauth-cli/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "foundry-oauth-cli" +name = "foundry-dev-tools-oauth-cli" version = "0.1.0" edition = "2021" -description = "Standalone OAuth2 CLI for Foundry, designed for Claude Code apiKeyHelper" +description = "Standalone OAuth2 CLI for Palantir Foundry" [[bin]] -name = "foundry-oauth" +name = "foundry-dev-tools-oauth" path = "src/main.rs" [dependencies] diff --git a/libs/foundry-oauth-cli/src/cache.rs b/libs/oauth-cli/src/cache.rs similarity index 90% rename from libs/foundry-oauth-cli/src/cache.rs rename to libs/oauth-cli/src/cache.rs index 228607f0..b9ce3300 100644 --- a/libs/foundry-oauth-cli/src/cache.rs +++ b/libs/oauth-cli/src/cache.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; const LOCK_TIMEOUT_SECS: u64 = 30; -const KEYRING_SERVICE: &str = "foundry-oauth"; +const KEYRING_SERVICE: &str = "foundry-dev-tools-oauth"; /// On-disk cache file (Linux only): maps hash keys to refresh tokens. #[derive(Debug, Serialize, Deserialize, Default)] @@ -51,7 +51,10 @@ fn keyring_load(key: &str) -> Result> { match entry.get_password() { Ok(token) => Ok(Some(token)), Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(Error::Keyring(format!("failed to read from keyring: {}", e))), + Err(e) => Err(Error::Keyring(format!( + "failed to read from keyring: {}", + e + ))), } } @@ -151,7 +154,9 @@ fn file_load(cache_dir: &Path, key: &str) -> Result> { fn file_save(cache_dir: &Path, key: &str, refresh_token: &str) -> Result<()> { ensure_cache_dir(cache_dir)?; let mut cache = read_cache_file(cache_dir)?; - cache.tokens.insert(key.to_string(), refresh_token.to_string()); + cache + .tokens + .insert(key.to_string(), refresh_token.to_string()); write_cache_file(cache_dir, &cache) } @@ -287,7 +292,12 @@ where seconds: LOCK_TIMEOUT_SECS, }); } - log::debug_log(debug, cache_dir, "LOCK_WAIT", "waiting to acquire file lock"); + log::debug_log( + debug, + cache_dir, + "LOCK_WAIT", + "waiting to acquire file lock", + ); std::thread::sleep(Duration::from_millis(200)); } } @@ -365,9 +375,14 @@ mod tests { let cache_dir = dir.path(); // Initially empty - let result = - load(cache_dir, "host.example.com", "client123", &["offline_access".into()], false) - .unwrap(); + let result = load( + cache_dir, + "host.example.com", + "client123", + &["offline_access".into()], + false, + ) + .unwrap(); assert!(result.is_none()); // Save @@ -382,21 +397,35 @@ mod tests { .unwrap(); // Load back - let result = - load(cache_dir, "host.example.com", "client123", &["offline_access".into()], false) - .unwrap(); + let result = load( + cache_dir, + "host.example.com", + "client123", + &["offline_access".into()], + false, + ) + .unwrap(); assert_eq!(result.unwrap(), "refresh_tok"); // Delete - let removed = - delete(cache_dir, "host.example.com", "client123", &["offline_access".into()]) - .unwrap(); + let removed = delete( + cache_dir, + "host.example.com", + "client123", + &["offline_access".into()], + ) + .unwrap(); assert!(removed); // Gone - let result = - load(cache_dir, "host.example.com", "client123", &["offline_access".into()], false) - .unwrap(); + let result = load( + cache_dir, + "host.example.com", + "client123", + &["offline_access".into()], + false, + ) + .unwrap(); assert!(result.is_none()); } } diff --git a/libs/foundry-oauth-cli/src/cli.rs b/libs/oauth-cli/src/cli.rs similarity index 83% rename from libs/foundry-oauth-cli/src/cli.rs rename to libs/oauth-cli/src/cli.rs index aac1703c..80ea7153 100644 --- a/libs/foundry-oauth-cli/src/cli.rs +++ b/libs/oauth-cli/src/cli.rs @@ -12,7 +12,11 @@ pub fn login(config: &Config) -> Result<()> { config.debug, &config.cache_dir, "STARTED", - &format!("login — hostname={}, scopes={}", config.hostname, config.scopes_str()), + &format!( + "login — hostname={}, scopes={}", + config.hostname, + config.scopes_str() + ), ); let pkce = oauth::generate_pkce(); @@ -43,7 +47,12 @@ pub fn login(config: &Config) -> Result<()> { (redirect_uri, code) } else { // Browser mode: start local server, open browser - log::debug_log(config.debug, &config.cache_dir, "LOGIN_TRIGGERED", "starting browser-based login"); + log::debug_log( + config.debug, + &config.cache_dir, + "LOGIN_TRIGGERED", + "starting browser-based login", + ); let (port, callback) = { // Start the server first so we know the actual port @@ -68,7 +77,12 @@ pub fn login(config: &Config) -> Result<()> { }; // Exchange the authorization code for tokens - log::debug_log(config.debug, &config.cache_dir, "LOGIN_PENDING", "exchanging authorization code for tokens"); + log::debug_log( + config.debug, + &config.cache_dir, + "LOGIN_PENDING", + "exchanging authorization code for tokens", + ); let token_resp = oauth::exchange_code(config, &code, &pkce.code_verifier, &redirect_uri)?; // Save refresh token @@ -85,7 +99,12 @@ pub fn login(config: &Config) -> Result<()> { })?; } - log::debug_log(config.debug, &config.cache_dir, "LOGIN_OK", "login completed, refresh token saved"); + log::debug_log( + config.debug, + &config.cache_dir, + "LOGIN_OK", + "login completed, refresh token saved", + ); eprintln!("Login successful! Tokens cached for {}.", config.hostname); Ok(()) @@ -128,7 +147,11 @@ pub fn token(config: &Config) -> Result<()> { config.debug, &config.cache_dir, "STARTED", - &format!("token — hostname={}, scopes={}", config.hostname, config.scopes_str()), + &format!( + "token — hostname={}, scopes={}", + config.hostname, + config.scopes_str() + ), ); // Try refresh under the lock (fast path) @@ -142,8 +165,12 @@ pub fn token(config: &Config) -> Result<()> { // No cached token or refresh failed — try auto-login OUTSIDE the lock // so the interactive browser flow doesn't hold the lock for 30+ seconds if let Err(ref e) = result { - log::debug_log(config.debug, &config.cache_dir, "LOGIN_TRIGGERED", - &format!("attempting auto-login: {}", e)); + log::debug_log( + config.debug, + &config.cache_dir, + "LOGIN_TRIGGERED", + &format!("attempting auto-login: {}", e), + ); } try_auto_login(config)? } @@ -152,7 +179,12 @@ pub fn token(config: &Config) -> Result<()> { // Print access token to stdout (the ONLY thing that goes to stdout) println!("{}", access_token); - log::debug_log(config.debug, &config.cache_dir, "TOKEN_OUTPUT", "access token printed to stdout"); + log::debug_log( + config.debug, + &config.cache_dir, + "TOKEN_OUTPUT", + "access token printed to stdout", + ); log::debug_log(config.debug, &config.cache_dir, "EXIT", "0"); Ok(()) @@ -171,7 +203,12 @@ fn refresh_cached_token(config: &Config) -> Result { match cached { Some(refresh_tok) => { - log::debug_log(config.debug, &config.cache_dir, "REFRESH_START", "sending refresh token request"); + log::debug_log( + config.debug, + &config.cache_dir, + "REFRESH_START", + "sending refresh token request", + ); match oauth::refresh_token(config, &refresh_tok) { Ok(resp) => { @@ -186,7 +223,12 @@ fn refresh_cached_token(config: &Config) -> Result { config.debug, )?; } - log::debug_log(config.debug, &config.cache_dir, "REFRESH_OK", "new access token received"); + log::debug_log( + config.debug, + &config.cache_dir, + "REFRESH_OK", + "new access token received", + ); Ok(resp.access_token) } Err(e) => { @@ -211,11 +253,16 @@ fn try_auto_login(config: &Config) -> Result { // Check if we're in an interactive terminal if !atty_is_terminal() { eprintln!("No cached credentials and not running interactively."); - eprintln!("Run `foundry-oauth login` in a terminal first."); + eprintln!("Run `foundry-dev-tools-oauth login` in a terminal first."); return Err(Error::LoginRequired); } - log::debug_log(config.debug, &config.cache_dir, "LOGIN_TRIGGERED", "no valid token, starting interactive login"); + log::debug_log( + config.debug, + &config.cache_dir, + "LOGIN_TRIGGERED", + "no valid token, starting interactive login", + ); eprintln!("No cached token found. Starting login flow..."); // Run login flow (this may open a browser and wait — NOT under the lock) @@ -270,7 +317,7 @@ pub fn status(config: &Config) -> Result<()> { if !has_token { eprintln!(); - eprintln!("Run `foundry-oauth login` to authenticate."); + eprintln!("Run `foundry-dev-tools-oauth login` to authenticate."); } Ok(()) diff --git a/libs/foundry-oauth-cli/src/config.rs b/libs/oauth-cli/src/config.rs similarity index 95% rename from libs/foundry-oauth-cli/src/config.rs rename to libs/oauth-cli/src/config.rs index de75207b..01e64930 100644 --- a/libs/foundry-oauth-cli/src/config.rs +++ b/libs/oauth-cli/src/config.rs @@ -101,26 +101,17 @@ impl Config { /// Authorization endpoint URL. pub fn authorize_url(&self) -> String { - format!( - "https://{}/multipass/api/oauth2/authorize", - self.hostname - ) + format!("https://{}/multipass/api/oauth2/authorize", self.hostname) } /// Token endpoint URL. pub fn token_url(&self) -> String { - format!( - "https://{}/multipass/api/oauth2/token", - self.hostname - ) + format!("https://{}/multipass/api/oauth2/token", self.hostname) } /// Callback URL used for console (no-browser) mode. pub fn callback_url(&self) -> String { - format!( - "https://{}/multipass/api/oauth2/callback", - self.hostname - ) + format!("https://{}/multipass/api/oauth2/callback", self.hostname) } /// Local redirect URI for browser-based flow. diff --git a/libs/foundry-oauth-cli/src/error.rs b/libs/oauth-cli/src/error.rs similarity index 94% rename from libs/foundry-oauth-cli/src/error.rs rename to libs/oauth-cli/src/error.rs index 8aa5bf9e..9713156d 100644 --- a/libs/foundry-oauth-cli/src/error.rs +++ b/libs/oauth-cli/src/error.rs @@ -34,7 +34,7 @@ pub enum Error { ServerTimeout, #[error("callback server error: {0}")] - ServerError(String), + ServerCallback(String), // Cache / storage errors #[error("cache directory error ({path}): {source}")] @@ -70,7 +70,7 @@ pub enum Error { Io(#[from] std::io::Error), // Login required (no cached token, non-interactive context) - #[error("no cached credentials found — run `foundry-oauth login` first")] + #[error("no cached credentials found — run `foundry-dev-tools-oauth login` first")] LoginRequired, } diff --git a/libs/foundry-oauth-cli/src/log.rs b/libs/oauth-cli/src/log.rs similarity index 83% rename from libs/foundry-oauth-cli/src/log.rs rename to libs/oauth-cli/src/log.rs index be8524e9..624fd2b2 100644 --- a/libs/foundry-oauth-cli/src/log.rs +++ b/libs/oauth-cli/src/log.rs @@ -14,11 +14,7 @@ pub fn debug_log(enabled: bool, cache_dir: &Path, event: &str, message: &str) { let line = format!("[{}] {} — {}\n", timestamp, event, message); // Best-effort: silently ignore write failures for debug logging - if let Ok(mut file) = OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - { + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) { let _ = file.write_all(line.as_bytes()); } } diff --git a/libs/foundry-oauth-cli/src/main.rs b/libs/oauth-cli/src/main.rs similarity index 89% rename from libs/foundry-oauth-cli/src/main.rs rename to libs/oauth-cli/src/main.rs index 3b5e8c7d..bb0e0899 100644 --- a/libs/foundry-oauth-cli/src/main.rs +++ b/libs/oauth-cli/src/main.rs @@ -12,8 +12,8 @@ use std::process; #[derive(Parser)] #[command( - name = "foundry-oauth", - about = "OAuth2 CLI for Foundry — provides Bearer tokens for Claude Code", + name = "foundry-dev-tools-oauth", + about = "OAuth2 CLI for Palantir Foundry — obtain and manage Bearer tokens", version )] struct Cli { @@ -100,7 +100,12 @@ fn main() { }; if let Err(e) = result { - log::debug_log(config.debug, &config.cache_dir, "EXIT", &format!("1 — {}", e)); + log::debug_log( + config.debug, + &config.cache_dir, + "EXIT", + &format!("1 — {}", e), + ); eprintln!("Error: {}", e); process::exit(1); } diff --git a/libs/foundry-oauth-cli/src/oauth.rs b/libs/oauth-cli/src/oauth.rs similarity index 99% rename from libs/foundry-oauth-cli/src/oauth.rs rename to libs/oauth-cli/src/oauth.rs index 93513285..41bd6e91 100644 --- a/libs/foundry-oauth-cli/src/oauth.rs +++ b/libs/oauth-cli/src/oauth.rs @@ -107,7 +107,7 @@ pub fn exchange_code( let client = reqwest::blocking::Client::new(); let resp = client - .post(&config.token_url()) + .post(config.token_url()) .header("Content-Type", "application/x-www-form-urlencoded") .form(¶ms) .send()?; @@ -136,7 +136,7 @@ pub fn refresh_token(config: &Config, refresh_tok: &str) -> Result "#; -/// Start a local HTTP server on 127.0.0.1 and wait for the OAuth callback. +/// Start a local HTTP server on 127.0.0.1:{port} and wait for the OAuth callback. /// -/// Tries ports starting from `start_port` up to `start_port + 99`. -/// Returns the actual port used and the callback result. -pub fn wait_for_callback(start_port: u16, _timeout_secs: u64) -> Result<(u16, CallbackResult)> { - let (server, port) = bind_server(start_port)?; +/// Returns the port used and the callback result. +pub fn wait_for_callback(port: u16, _timeout_secs: u64) -> Result<(u16, CallbackResult)> { + let (server, port) = bind_server(port)?; // Set a timeout so we don't hang forever server @@ -41,19 +40,18 @@ pub fn wait_for_callback(start_port: u16, _timeout_secs: u64) -> Result<(u16, Ca // Check timeout manually isn't needed — tiny_http blocks on .next() // We rely on the user completing the flow within a reasonable time. let url_str = format!("http://127.0.0.1:{}{}", port, request.url()); - let parsed = Url::parse(&url_str).map_err(|e| Error::ServerError(e.to_string()))?; + let parsed = Url::parse(&url_str).map_err(|e| Error::ServerCallback(e.to_string()))?; let params: HashMap = parsed.query_pairs().into_owned().collect(); if let (Some(code), Some(state)) = (params.get("code"), params.get("state")) { // Return success page - let response = tiny_http::Response::from_string(SUCCESS_HTML) - .with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"text/html; charset=utf-8"[..], - ) - .unwrap(), - ); + let response = tiny_http::Response::from_string(SUCCESS_HTML).with_header( + tiny_http::Header::from_bytes( + &b"Content-Type"[..], + &b"text/html; charset=utf-8"[..], + ) + .unwrap(), + ); let _ = request.respond(response); Ok(( @@ -64,10 +62,7 @@ pub fn wait_for_callback(start_port: u16, _timeout_secs: u64) -> Result<(u16, Ca }, )) } else if let Some(error) = params.get("error") { - let desc = params - .get("error_description") - .cloned() - .unwrap_or_default(); + let desc = params.get("error_description").cloned().unwrap_or_default(); let response = tiny_http::Response::from_string(ERROR_HTML).with_header( tiny_http::Header::from_bytes( &b"Content-Type"[..], @@ -86,7 +81,7 @@ pub fn wait_for_callback(start_port: u16, _timeout_secs: u64) -> Result<(u16, Ca .unwrap(), ); let _ = request.respond(response); - Err(Error::ServerError( + Err(Error::ServerCallback( "callback missing 'code' and 'state' parameters".into(), )) } @@ -94,21 +89,14 @@ pub fn wait_for_callback(start_port: u16, _timeout_secs: u64) -> Result<(u16, Ca .unwrap_or(Err(Error::ServerTimeout)) } -/// Try to bind to ports starting from `start_port`, incrementing up to 99 times. -fn bind_server(start_port: u16) -> Result<(tiny_http::Server, u16)> { - for offset in 0..100 { - let port = start_port + offset; - let addr = format!("127.0.0.1:{}", port); - match tiny_http::Server::http(&addr) { - Ok(server) => return Ok((server, port)), - Err(_) if offset < 99 => continue, - Err(e) => { - return Err(Error::ServerBind { - addr, - source: std::io::Error::new(std::io::ErrorKind::AddrInUse, e.to_string()), - }); - } - } +/// Bind to the specified port. Fails if the port is unavailable. +fn bind_server(port: u16) -> Result<(tiny_http::Server, u16)> { + let addr = format!("127.0.0.1:{}", port); + match tiny_http::Server::http(&addr) { + Ok(server) => Ok((server, port)), + Err(e) => Err(Error::ServerBind { + addr, + source: std::io::Error::new(std::io::ErrorKind::AddrInUse, e.to_string()), + }), } - unreachable!() } From 907f6416f8196e347c283f93ba15919df6755bfa Mon Sep 17 00:00:00 2001 From: nicornk Date: Thu, 26 Feb 2026 17:06:42 +0100 Subject: [PATCH 03/12] feat: improve CI and make --no-browser a global CLI flag - Bump actions/checkout from v3 to v5 - Add Rust CI job with fmt, clippy, build, test, and cargo audit - Add CARGO_TERM_COLOR and --locked flags for reproducible builds - Move --no-browser from login-only to global flag so it works with the token command's auto-login flow Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 28 +++++++++++++++++++++++++++- libs/oauth-cli/src/main.rs | 16 +++++++--------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc3b761a..8861122a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - "3.10" - "3.12" # newest Python that is supported by pyspark steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: {fetch-depth: 0} # deep clone for git tag - uses: pdm-project/setup-pdm@v4 with: @@ -55,3 +55,29 @@ jobs: run: pdm run lint - name: Run python tests run: pdm run unit + + rust: + runs-on: ubuntu-latest + defaults: + run: + working-directory: libs/oauth-cli + env: + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: libs/oauth-cli + - name: Check formatting + run: cargo fmt --check + - name: Run clippy + run: cargo clippy -- -D warnings + - name: Build + run: cargo build --locked + - name: Run tests + run: cargo test --locked + - name: Audit dependencies + run: cargo install cargo-audit && cargo audit diff --git a/libs/oauth-cli/src/main.rs b/libs/oauth-cli/src/main.rs index bb0e0899..63896cbb 100644 --- a/libs/oauth-cli/src/main.rs +++ b/libs/oauth-cli/src/main.rs @@ -47,16 +47,16 @@ struct Cli { /// Enable debug logging to ~/.foundry/oauth-debug.log #[arg(long, global = true)] debug: bool, + + /// Use console mode instead of browser (for headless/SSH environments) + #[arg(long, global = true)] + no_browser: bool, } #[derive(Subcommand)] enum Command { /// Interactive login: open browser, complete OAuth2 flow, store refresh token - Login { - /// Use console mode instead of browser (for headless/SSH environments) - #[arg(long)] - no_browser: bool, - }, + Login, /// Output a fresh access token to stdout (refresh if needed) Token, @@ -71,8 +71,6 @@ enum Command { fn main() { let cli = Cli::parse(); - let no_browser = matches!(cli.command, Command::Login { no_browser: true }); - let flags = CliFlags { hostname: cli.hostname, client_id: cli.client_id, @@ -80,7 +78,7 @@ fn main() { scopes: cli.scopes, cache_dir: cli.cache_dir, port: cli.port, - no_browser, + no_browser: cli.no_browser, debug: cli.debug, }; @@ -93,7 +91,7 @@ fn main() { }; let result = match cli.command { - Command::Login { .. } => cli::login(&config), + Command::Login => cli::login(&config), Command::Token => cli::token(&config), Command::Status => cli::status(&config), Command::Logout => cli::logout(&config), From 299fa7767fcb97d99d316ed4f67dc1df428489cd Mon Sep 17 00:00:00 2001 From: nicornk Date: Mon, 2 Mar 2026 10:31:35 +0100 Subject: [PATCH 04/12] refactor: move OAuth storage to foundry-dev-tools config directory Resolve the OAuth config directory using the same multi-location strategy as the Python foundry-dev-tools: check for config.toml in ~/.foundry-dev-tools/, ~/.config/foundry-dev-tools/, and the platform-native config path, then store OAuth data in an oauth/ subfolder. Defaults to ~/.foundry-dev-tools/oauth/ if no config.toml is found. Removes --cache-dir flag and FOUNDRY_CACHE_DIR env var; the config directory is now fully auto-resolved. Co-Authored-By: Claude Opus 4.6 --- libs/oauth-cli/src/cache.rs | 103 ++++++++++++++++++----------------- libs/oauth-cli/src/cli.rs | 48 ++++++++-------- libs/oauth-cli/src/config.rs | 43 ++++++++++----- libs/oauth-cli/src/log.rs | 4 +- libs/oauth-cli/src/main.rs | 9 +-- libs/oauth-cli/src/oauth.rs | 2 +- 6 files changed, 113 insertions(+), 96 deletions(-) diff --git a/libs/oauth-cli/src/cache.rs b/libs/oauth-cli/src/cache.rs index b9ce3300..680503e2 100644 --- a/libs/oauth-cli/src/cache.rs +++ b/libs/oauth-cli/src/cache.rs @@ -84,19 +84,19 @@ fn keyring_delete(key: &str) -> Result { // --------------------------------------------------------------------------- /// Ensure the cache directory exists with 0o700 permissions. -pub fn ensure_cache_dir(cache_dir: &Path) -> Result<()> { - if !cache_dir.exists() { - fs::create_dir_all(cache_dir).map_err(|e| Error::CacheDir { - path: cache_dir.to_path_buf(), +pub fn ensure_config_dir(config_dir: &Path) -> Result<()> { + if !config_dir.exists() { + fs::create_dir_all(config_dir).map_err(|e| Error::CacheDir { + path: config_dir.to_path_buf(), source: e, })?; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - fs::set_permissions(cache_dir, fs::Permissions::from_mode(0o700)).map_err(|e| { + fs::set_permissions(config_dir, fs::Permissions::from_mode(0o700)).map_err(|e| { Error::CacheDir { - path: cache_dir.to_path_buf(), + path: config_dir.to_path_buf(), source: e, } })?; @@ -104,17 +104,17 @@ pub fn ensure_cache_dir(cache_dir: &Path) -> Result<()> { Ok(()) } -fn cache_file_path(cache_dir: &Path) -> PathBuf { - cache_dir.join("oauth-cache.json") +fn cache_file_path(config_dir: &Path) -> PathBuf { + config_dir.join("oauth-cache.json") } -fn lock_file_path(cache_dir: &Path) -> PathBuf { - cache_dir.join("oauth-cache.lock") +fn lock_file_path(config_dir: &Path) -> PathBuf { + config_dir.join("oauth-cache.lock") } /// Read the cache file, returning the parsed structure. -fn read_cache_file(cache_dir: &Path) -> Result { - let path = cache_file_path(cache_dir); +fn read_cache_file(config_dir: &Path) -> Result { + let path = cache_file_path(config_dir); if !path.exists() { return Ok(CacheFile::default()); } @@ -126,8 +126,8 @@ fn read_cache_file(cache_dir: &Path) -> Result { } /// Write the cache file with 0o600 permissions. -fn write_cache_file(cache_dir: &Path, cache: &CacheFile) -> Result<()> { - let path = cache_file_path(cache_dir); +fn write_cache_file(config_dir: &Path, cache: &CacheFile) -> Result<()> { + let path = cache_file_path(config_dir); let data = serde_json::to_string_pretty(cache).expect("cache serialization cannot fail"); fs::write(&path, data).map_err(|e| Error::CacheIo { path: path.clone(), @@ -146,25 +146,25 @@ fn write_cache_file(cache_dir: &Path, cache: &CacheFile) -> Result<()> { Ok(()) } -fn file_load(cache_dir: &Path, key: &str) -> Result> { - let cache = read_cache_file(cache_dir)?; +fn file_load(config_dir: &Path, key: &str) -> Result> { + let cache = read_cache_file(config_dir)?; Ok(cache.tokens.get(key).cloned()) } -fn file_save(cache_dir: &Path, key: &str, refresh_token: &str) -> Result<()> { - ensure_cache_dir(cache_dir)?; - let mut cache = read_cache_file(cache_dir)?; +fn file_save(config_dir: &Path, key: &str, refresh_token: &str) -> Result<()> { + ensure_config_dir(config_dir)?; + let mut cache = read_cache_file(config_dir)?; cache .tokens .insert(key.to_string(), refresh_token.to_string()); - write_cache_file(cache_dir, &cache) + write_cache_file(config_dir, &cache) } -fn file_delete(cache_dir: &Path, key: &str) -> Result { - let mut cache = read_cache_file(cache_dir)?; +fn file_delete(config_dir: &Path, key: &str) -> Result { + let mut cache = read_cache_file(config_dir)?; let removed = cache.tokens.remove(key).is_some(); if removed { - write_cache_file(cache_dir, &cache)?; + write_cache_file(config_dir, &cache)?; } Ok(removed) } @@ -175,7 +175,7 @@ fn file_delete(cache_dir: &Path, key: &str) -> Result { /// Load a cached refresh token for the given parameters. pub fn load( - cache_dir: &Path, + config_dir: &Path, hostname: &str, client_id: &str, scopes: &[String], @@ -186,7 +186,7 @@ pub fn load( let result = if use_keyring() { keyring_load(&key)? } else { - file_load(cache_dir, &key)? + file_load(config_dir, &key)? }; match &result { @@ -194,7 +194,7 @@ pub fn load( let backend = if use_keyring() { "keyring" } else { "file" }; log::debug_log( debug, - cache_dir, + config_dir, "CACHE_HIT", &format!("refresh token found in {}", backend), ); @@ -202,7 +202,7 @@ pub fn load( None => { log::debug_log( debug, - cache_dir, + config_dir, "CACHE_MISS", &format!("no refresh token for key {}", &key[..12]), ); @@ -214,7 +214,7 @@ pub fn load( /// Save a refresh token to the cache. pub fn save( - cache_dir: &Path, + config_dir: &Path, hostname: &str, client_id: &str, scopes: &[String], @@ -226,13 +226,13 @@ pub fn save( if use_keyring() { keyring_save(&key, refresh_token)?; } else { - file_save(cache_dir, &key, refresh_token)?; + file_save(config_dir, &key, refresh_token)?; } let backend = if use_keyring() { "keyring" } else { "file" }; log::debug_log( debug, - cache_dir, + config_dir, "CACHE_SAVE", &format!("refresh token saved to {}", backend), ); @@ -241,7 +241,7 @@ pub fn save( /// Delete the cached credential for the given parameters. pub fn delete( - cache_dir: &Path, + config_dir: &Path, hostname: &str, client_id: &str, scopes: &[String], @@ -251,18 +251,18 @@ pub fn delete( if use_keyring() { keyring_delete(&key) } else { - file_delete(cache_dir, &key) + file_delete(config_dir, &key) } } /// Execute a closure while holding an exclusive file lock. -pub fn with_lock(cache_dir: &Path, debug: bool, f: F) -> Result +pub fn with_lock(config_dir: &Path, debug: bool, f: F) -> Result where F: FnOnce() -> Result, { - ensure_cache_dir(cache_dir)?; + ensure_config_dir(config_dir)?; - let lock_path = lock_file_path(cache_dir); + let lock_path = lock_file_path(config_dir); let lock_file = fs::OpenOptions::new() .create(true) .write(true) @@ -281,7 +281,12 @@ where loop { match lock.try_write() { Ok(_guard) => { - log::debug_log(debug, cache_dir, "LOCK_ACQUIRED", "exclusive lock acquired"); + log::debug_log( + debug, + config_dir, + "LOCK_ACQUIRED", + "exclusive lock acquired", + ); let result = f(); // _guard drops here, releasing the lock return result; @@ -294,7 +299,7 @@ where } log::debug_log( debug, - cache_dir, + config_dir, "LOCK_WAIT", "waiting to acquire file lock", ); @@ -348,35 +353,35 @@ mod tests { #[test] fn test_file_cache_roundtrip() { let dir = tempfile::tempdir().unwrap(); - let cache_dir = dir.path(); + let config_dir = dir.path(); let key = cache_key("host.example.com", "client123", &["offline_access".into()]); // Initially empty - assert!(file_load(cache_dir, &key).unwrap().is_none()); + assert!(file_load(config_dir, &key).unwrap().is_none()); // Save - file_save(cache_dir, &key, "refresh_tok").unwrap(); + file_save(config_dir, &key, "refresh_tok").unwrap(); // Load back - assert_eq!(file_load(cache_dir, &key).unwrap().unwrap(), "refresh_tok"); + assert_eq!(file_load(config_dir, &key).unwrap().unwrap(), "refresh_tok"); // Delete - assert!(file_delete(cache_dir, &key).unwrap()); + assert!(file_delete(config_dir, &key).unwrap()); // Gone - assert!(file_load(cache_dir, &key).unwrap().is_none()); + assert!(file_load(config_dir, &key).unwrap().is_none()); } // Integration test using the public API (dispatches to keyring on macOS/Windows) #[test] fn test_cache_roundtrip() { let dir = tempfile::tempdir().unwrap(); - let cache_dir = dir.path(); + let config_dir = dir.path(); // Initially empty let result = load( - cache_dir, + config_dir, "host.example.com", "client123", &["offline_access".into()], @@ -387,7 +392,7 @@ mod tests { // Save save( - cache_dir, + config_dir, "host.example.com", "client123", &["offline_access".into()], @@ -398,7 +403,7 @@ mod tests { // Load back let result = load( - cache_dir, + config_dir, "host.example.com", "client123", &["offline_access".into()], @@ -409,7 +414,7 @@ mod tests { // Delete let removed = delete( - cache_dir, + config_dir, "host.example.com", "client123", &["offline_access".into()], @@ -419,7 +424,7 @@ mod tests { // Gone let result = load( - cache_dir, + config_dir, "host.example.com", "client123", &["offline_access".into()], diff --git a/libs/oauth-cli/src/cli.rs b/libs/oauth-cli/src/cli.rs index 80ea7153..bfccbc48 100644 --- a/libs/oauth-cli/src/cli.rs +++ b/libs/oauth-cli/src/cli.rs @@ -10,7 +10,7 @@ use std::io::{self, BufRead, Write}; pub fn login(config: &Config) -> Result<()> { log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "STARTED", &format!( "login — hostname={}, scopes={}", @@ -49,7 +49,7 @@ pub fn login(config: &Config) -> Result<()> { // Browser mode: start local server, open browser log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "LOGIN_TRIGGERED", "starting browser-based login", ); @@ -79,7 +79,7 @@ pub fn login(config: &Config) -> Result<()> { // Exchange the authorization code for tokens log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "LOGIN_PENDING", "exchanging authorization code for tokens", ); @@ -87,9 +87,9 @@ pub fn login(config: &Config) -> Result<()> { // Save refresh token if let Some(ref refresh_token) = token_resp.refresh_token { - cache::with_lock(&config.cache_dir, config.debug, || { + cache::with_lock(&config.config_dir, config.debug, || { cache::save( - &config.cache_dir, + &config.config_dir, &config.hostname, &config.client_id, &config.scopes, @@ -101,7 +101,7 @@ pub fn login(config: &Config) -> Result<()> { log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "LOGIN_OK", "login completed, refresh token saved", ); @@ -145,7 +145,7 @@ fn start_server_and_open_browser( pub fn token(config: &Config) -> Result<()> { log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "STARTED", &format!( "token — hostname={}, scopes={}", @@ -155,7 +155,7 @@ pub fn token(config: &Config) -> Result<()> { ); // Try refresh under the lock (fast path) - let result = cache::with_lock(&config.cache_dir, config.debug, || { + let result = cache::with_lock(&config.config_dir, config.debug, || { refresh_cached_token(config) }); @@ -167,7 +167,7 @@ pub fn token(config: &Config) -> Result<()> { if let Err(ref e) = result { log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "LOGIN_TRIGGERED", &format!("attempting auto-login: {}", e), ); @@ -181,11 +181,11 @@ pub fn token(config: &Config) -> Result<()> { println!("{}", access_token); log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "TOKEN_OUTPUT", "access token printed to stdout", ); - log::debug_log(config.debug, &config.cache_dir, "EXIT", "0"); + log::debug_log(config.debug, &config.config_dir, "EXIT", "0"); Ok(()) } @@ -194,7 +194,7 @@ pub fn token(config: &Config) -> Result<()> { /// Returns LoginRequired if no cached token exists. fn refresh_cached_token(config: &Config) -> Result { let cached = cache::load( - &config.cache_dir, + &config.config_dir, &config.hostname, &config.client_id, &config.scopes, @@ -205,7 +205,7 @@ fn refresh_cached_token(config: &Config) -> Result { Some(refresh_tok) => { log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "REFRESH_START", "sending refresh token request", ); @@ -215,7 +215,7 @@ fn refresh_cached_token(config: &Config) -> Result { // Save the rotated refresh token if let Some(ref new_refresh) = resp.refresh_token { cache::save( - &config.cache_dir, + &config.config_dir, &config.hostname, &config.client_id, &config.scopes, @@ -225,7 +225,7 @@ fn refresh_cached_token(config: &Config) -> Result { } log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "REFRESH_OK", "new access token received", ); @@ -234,7 +234,7 @@ fn refresh_cached_token(config: &Config) -> Result { Err(e) => { log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "REFRESH_FAIL", &format!("{}", e), ); @@ -259,7 +259,7 @@ fn try_auto_login(config: &Config) -> Result { log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "LOGIN_TRIGGERED", "no valid token, starting interactive login", ); @@ -269,9 +269,9 @@ fn try_auto_login(config: &Config) -> Result { login(config)?; // After login, refresh under the lock to get an access token - cache::with_lock(&config.cache_dir, config.debug, || { + cache::with_lock(&config.config_dir, config.debug, || { let refresh_tok = cache::load( - &config.cache_dir, + &config.config_dir, &config.hostname, &config.client_id, &config.scopes, @@ -282,7 +282,7 @@ fn try_auto_login(config: &Config) -> Result { let resp = oauth::refresh_token(config, &refresh_tok)?; if let Some(ref new_refresh) = resp.refresh_token { cache::save( - &config.cache_dir, + &config.config_dir, &config.hostname, &config.client_id, &config.scopes, @@ -303,7 +303,7 @@ fn atty_is_terminal() -> bool { /// Show authentication status. pub fn status(config: &Config) -> Result<()> { let has_token = cache::load( - &config.cache_dir, + &config.config_dir, &config.hostname, &config.client_id, &config.scopes, @@ -327,14 +327,14 @@ pub fn status(config: &Config) -> Result<()> { pub fn logout(config: &Config) -> Result<()> { log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "STARTED", &format!("logout — hostname={}", config.hostname), ); - let removed = cache::with_lock(&config.cache_dir, config.debug, || { + let removed = cache::with_lock(&config.config_dir, config.debug, || { cache::delete( - &config.cache_dir, + &config.config_dir, &config.hostname, &config.client_id, &config.scopes, diff --git a/libs/oauth-cli/src/config.rs b/libs/oauth-cli/src/config.rs index 01e64930..df4957a0 100644 --- a/libs/oauth-cli/src/config.rs +++ b/libs/oauth-cli/src/config.rs @@ -8,7 +8,7 @@ pub struct Config { pub client_id: String, pub client_secret: Option, pub scopes: Vec, - pub cache_dir: PathBuf, + pub config_dir: PathBuf, pub port: u16, pub no_browser: bool, pub debug: bool, @@ -21,12 +21,38 @@ pub struct CliFlags { pub client_id: Option, pub client_secret: Option, pub scopes: Option, - pub cache_dir: Option, pub port: Option, pub no_browser: bool, pub debug: bool, } +/// Resolve the OAuth config directory by checking for an existing `config.toml` +/// in the same candidate directories the Python side uses (config.py:63-66). +/// Returns `{config_parent}/oauth/` where `config_parent` is the first directory +/// containing a `config.toml`, or `~/.foundry-dev-tools/` as the default. +fn resolve_config_dir() -> PathBuf { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + // Candidates in the same order as the Python side (config.py:63-66) + let candidates = [ + home.join(".foundry-dev-tools"), + home.join(".config").join("foundry-dev-tools"), + dirs::config_dir() + .unwrap_or_else(|| home.join(".config")) + .join("foundry-dev-tools"), + ]; + + // Use the first dir where config.toml exists (matches Python behavior) + for candidate in &candidates { + if candidate.join("config.toml").exists() { + return candidate.join("oauth"); + } + } + + // No config.toml found anywhere — default to ~/.foundry-dev-tools/oauth/ + candidates[0].join("oauth") +} + impl Config { /// Build a resolved Config by merging: CLI flags → native env vars → FDT env vars → defaults. pub fn resolve(flags: CliFlags) -> Result { @@ -56,16 +82,7 @@ impl Config { .map(|s| s.split_whitespace().map(String::from).collect()) .unwrap_or_else(|| vec!["offline_access".to_string()]); - let cache_dir = flags - .cache_dir - .or_else(|| std::env::var("FOUNDRY_CACHE_DIR").ok()) - .map(PathBuf::from) - .unwrap_or_else(|| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".foundry") - .join("oauth-cli") - }); + let config_dir = resolve_config_dir(); let port = flags .port @@ -87,7 +104,7 @@ impl Config { client_id, client_secret, scopes, - cache_dir, + config_dir, port, no_browser: flags.no_browser, debug, diff --git a/libs/oauth-cli/src/log.rs b/libs/oauth-cli/src/log.rs index 624fd2b2..7d9c290f 100644 --- a/libs/oauth-cli/src/log.rs +++ b/libs/oauth-cli/src/log.rs @@ -3,12 +3,12 @@ use std::io::Write; use std::path::Path; /// Append a debug log entry to the debug log file if debug mode is enabled. -pub fn debug_log(enabled: bool, cache_dir: &Path, event: &str, message: &str) { +pub fn debug_log(enabled: bool, config_dir: &Path, event: &str, message: &str) { if !enabled { return; } - let log_path = cache_dir.join("oauth-debug.log"); + let log_path = config_dir.join("oauth-debug.log"); let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); let line = format!("[{}] {} — {}\n", timestamp, event, message); diff --git a/libs/oauth-cli/src/main.rs b/libs/oauth-cli/src/main.rs index 63896cbb..ba94865f 100644 --- a/libs/oauth-cli/src/main.rs +++ b/libs/oauth-cli/src/main.rs @@ -36,15 +36,11 @@ struct Cli { #[arg(long, global = true)] scopes: Option, - /// Cache directory (default: ~/.foundry/) - #[arg(long, global = true)] - cache_dir: Option, - /// Local server port for OAuth callback (default: 8888) #[arg(long, global = true)] port: Option, - /// Enable debug logging to ~/.foundry/oauth-debug.log + /// Enable debug logging to {config_dir}/oauth-debug.log #[arg(long, global = true)] debug: bool, @@ -76,7 +72,6 @@ fn main() { client_id: cli.client_id, client_secret: cli.client_secret, scopes: cli.scopes, - cache_dir: cli.cache_dir, port: cli.port, no_browser: cli.no_browser, debug: cli.debug, @@ -100,7 +95,7 @@ fn main() { if let Err(e) = result { log::debug_log( config.debug, - &config.cache_dir, + &config.config_dir, "EXIT", &format!("1 — {}", e), ); diff --git a/libs/oauth-cli/src/oauth.rs b/libs/oauth-cli/src/oauth.rs index 41bd6e91..a357e114 100644 --- a/libs/oauth-cli/src/oauth.rs +++ b/libs/oauth-cli/src/oauth.rs @@ -210,7 +210,7 @@ mod tests { client_id: "my-client-id".into(), client_secret: None, scopes: vec!["offline_access".into()], - cache_dir: "/tmp/test".into(), + config_dir: "/tmp/test".into(), port: 8888, no_browser: false, debug: false, From a024a9ef97603363876c9b3cf17bc48e03732616 Mon Sep 17 00:00:00 2001 From: nicornk Date: Mon, 2 Mar 2026 10:43:04 +0100 Subject: [PATCH 05/12] deps: update oauth-cli dependencies and switch to native-tls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update reqwest 0.12→0.13 (with native-tls and form features), rand 0.8→0.10, and dirs 5→6. Adapt code to rand 0.10 API changes (thread_rng→rng, gen_range→random_range, gen→random). Co-Authored-By: Claude Opus 4.6 --- libs/oauth-cli/Cargo.lock | 374 +++++------------------------------- libs/oauth-cli/Cargo.toml | 6 +- libs/oauth-cli/src/oauth.rs | 10 +- 3 files changed, 53 insertions(+), 337 deletions(-) diff --git a/libs/oauth-cli/Cargo.lock b/libs/oauth-cli/Cargo.lock index f8e0a602..1dc386da 100644 --- a/libs/oauth-cli/Cargo.lock +++ b/libs/oauth-cli/Cargo.lock @@ -140,6 +140,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.44" @@ -241,6 +252,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -263,23 +283,23 @@ dependencies = [ [[package]] name = "dirs" -version = "5.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -293,15 +313,6 @@ dependencies = [ "syn", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -315,7 +326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -341,12 +352,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -394,7 +399,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", - "thiserror 2.0.18", + "thiserror", "tiny_http", "url", ] @@ -478,29 +483,11 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core", "wasip2", "wasip3", ] -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -577,7 +564,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -589,22 +575,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - [[package]] name = "hyper-tls" version = "0.6.0" @@ -639,11 +609,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -907,12 +875,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "mio" version = "1.1.1" @@ -1037,9 +999,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -1062,15 +1024,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1107,68 +1060,51 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ - "ppv-lite86", + "chacha20", + "getrandom 0.4.1", "rand_core", ] [[package]] name = "rand_core" -version = "0.6.4" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "redox_users" -version = "0.4.6" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 1.0.69", + "thiserror", ] [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-channel", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", "log", - "mime", "native-tls", "percent-encoding", "pin-project-lite", @@ -1188,20 +1124,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1212,20 +1134,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" -dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "windows-sys 0.59.0", ] [[package]] @@ -1237,17 +1146,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -1373,7 +1271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1417,12 +1315,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.117" @@ -1454,27 +1346,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tempfile" version = "3.26.0" @@ -1485,16 +1356,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", + "windows-sys 0.59.0", ] [[package]] @@ -1503,18 +1365,7 @@ version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -1574,29 +1425,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "tower" version = "0.5.3" @@ -1685,12 +1513,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.8" @@ -1904,17 +1726,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -1933,24 +1744,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -1978,21 +1771,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -2026,12 +1804,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2044,12 +1816,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2062,12 +1828,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2092,12 +1852,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2110,12 +1864,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2128,12 +1876,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2146,12 +1888,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2281,26 +2017,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.6" diff --git a/libs/oauth-cli/Cargo.toml b/libs/oauth-cli/Cargo.toml index caac7121..2eb24f9a 100644 --- a/libs/oauth-cli/Cargo.toml +++ b/libs/oauth-cli/Cargo.toml @@ -10,15 +10,15 @@ path = "src/main.rs" [dependencies] clap = { version = "4", features = ["derive"] } -reqwest = { version = "0.12", features = ["json", "blocking"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "blocking", "native-tls", "form"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" base64 = "0.22" -rand = "0.8" +rand = "0.10" tiny_http = "0.12" open = "5" -dirs = "5" +dirs = "6" url = "2" chrono = { version = "0.4", features = ["serde"] } fd-lock = "4" diff --git a/libs/oauth-cli/src/oauth.rs b/libs/oauth-cli/src/oauth.rs index a357e114..0a7b70f6 100644 --- a/libs/oauth-cli/src/oauth.rs +++ b/libs/oauth-cli/src/oauth.rs @@ -2,7 +2,7 @@ use crate::config::Config; use crate::error::{Error, Result}; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; -use rand::Rng; +use rand::RngExt; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -29,10 +29,10 @@ const PKCE_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz /// Generate a PKCE code_verifier and code_challenge (S256). pub fn generate_pkce() -> Pkce { - let mut rng = rand::thread_rng(); + let mut rng = rand::rng(); let verifier: String = (0..128) .map(|_| { - let idx = rng.gen_range(0..PKCE_CHARS.len()); + let idx = rng.random_range(0..PKCE_CHARS.len()); PKCE_CHARS[idx] as char }) .collect(); @@ -48,8 +48,8 @@ pub fn generate_pkce() -> Pkce { /// Generate a random state parameter for CSRF protection. pub fn generate_state() -> String { - let mut rng = rand::thread_rng(); - let bytes: Vec = (0..32).map(|_| rng.gen()).collect(); + let mut rng = rand::rng(); + let bytes: Vec = (0..32).map(|_| rng.random()).collect(); URL_SAFE_NO_PAD.encode(&bytes) } From 34b4aeb6e7fd19c6c41159c651162ed11ddb6ff3 Mon Sep 17 00:00:00 2001 From: nicornk Date: Mon, 2 Mar 2026 11:19:16 +0100 Subject: [PATCH 06/12] refactor: fix code smells in oauth-cli and add code signing research - server.rs: implement actual timeout via recv_timeout instead of ignoring _timeout_secs; restructure into CallbackServer with separate bind() and wait_for_callback() methods; extract respond_html helper to reduce duplication - cli.rs: fix race condition by binding server before opening browser; remove defunct start_server_and_open_browser function - oauth.rs: reuse HTTP client via OnceLock; use form_urlencoded Serializer instead of manual encoding; use Vec for form params instead of HashMap + clone-to-extend-lifetime hack; remove redundant Content-Type header; remove unnecessary Clone on Pkce; remove unused TokenResponse fields - error.rs: remove #[allow(dead_code)] and unused Error::Pkce variant - cache.rs: use explicit truncate(false) on lock file; remove unnecessary truncate(true) - config.rs: add #[serial] to all tests that read/mutate env vars - Cargo.toml: add serial_test dev-dependency - docs: add code signing research for macOS/Windows in GitHub Actions Co-Authored-By: Claude Opus 4.6 --- docs/dev/plans/oauth-cli-code-signing.md | 285 +++++++++++++++++++++++ libs/oauth-cli/Cargo.lock | 108 ++++++++- libs/oauth-cli/Cargo.toml | 1 + libs/oauth-cli/src/cache.rs | 2 +- libs/oauth-cli/src/cli.rs | 58 ++--- libs/oauth-cli/src/config.rs | 12 + libs/oauth-cli/src/error.rs | 4 - libs/oauth-cli/src/oauth.rs | 84 +++---- libs/oauth-cli/src/server.rs | 125 +++++----- 9 files changed, 510 insertions(+), 169 deletions(-) create mode 100644 docs/dev/plans/oauth-cli-code-signing.md diff --git a/docs/dev/plans/oauth-cli-code-signing.md b/docs/dev/plans/oauth-cli-code-signing.md new file mode 100644 index 00000000..7b98900e --- /dev/null +++ b/docs/dev/plans/oauth-cli-code-signing.md @@ -0,0 +1,285 @@ +# Research: Code Signing for foundry-dev-tools-oauth CLI + +## Problem + +macOS Keychain attaches an Access Control List (ACL) to each stored credential. For unsigned binaries, the ACL records the binary's path and hash. Every recompilation changes the hash, causing macOS to prompt "foundry-dev-tools-oauth wants to access your keychain" on first access after every update. + +Windows Credential Manager does **not** gate access by code signing identity -- it uses the logged-in user session. Code signing on Windows is only about SmartScreen reputation and user trust. + +## How macOS Keychain Identifies Applications + +Keychain does not match by file path or binary hash. It stores a **Designated Requirement (DR)** extracted from the binary's code signature. For a Developer ID-signed binary, the DR looks like: + +``` +identifier "com.yourcompany.foundry-dev-tools-oauth-cli" + and anchor apple generic + and certificate leaf[subject.OU] = "K1234ABCDE" +``` + +This checks: +1. The **code signing identifier** (the `--identifier` / `-i` value passed to `codesign`) +2. The certificate chain is rooted in Apple's CA +3. The leaf certificate is a Developer ID Application certificate +4. The **Team ID** (`subject.OU`) matches + +The DR does **not** depend on the binary hash, file path, file size, modification date, or specific certificate serial number. This means **any future build signed with the same identifier and Team ID is granted Keychain access silently**. + +### What would break Keychain access + +- Changing the code signing identifier +- Switching to a different Apple Developer account (different Team ID) +- Distributing an unsigned binary +- Switching certificate types (e.g., Developer ID to ad-hoc) + +## Requirements + +### macOS + +| Item | Notes | +|------|-------| +| Apple Developer Program | $99/year, mandatory, no free path | +| Developer ID Application certificate | Created in Apple Developer portal, valid 5 years | +| Stable code signing identifier | Must never change once users store Keychain items | +| Notarization | Required for Gatekeeper since macOS 10.15 | + +### Windows (optional) + +| Item | Notes | +|------|-------| +| OV code signing certificate | $200-500/year | +| Hardware-backed private key | Required since June 2023 (FIPS 140-2 Level 2) | +| Cloud HSM (Azure Key Vault or SSL.com eSigner) | Physical USB tokens impractical for CI | + +## Chosen Code Signing Identifier + +**This value is permanent. Changing it breaks Keychain access for all existing users.** + +``` +com.palantir.foundry-dev-tools-oauth-cli +``` + +## Certificate Setup (One-Time) + +### macOS + +1. Enroll in the Apple Developer Program at https://developer.apple.com ($99/year) +2. Go to Certificates, Identifiers & Profiles +3. Create a **Developer ID Application** certificate +4. Generate a CSR via Keychain Access on a Mac +5. Upload CSR, download the `.cer`, import into Keychain Access +6. Export as `.p12` (select cert + private key, right-click, "Export 2 items") +7. Base64-encode for GitHub Secrets: + ```bash + base64 -i DeveloperIDApplication.p12 -o cert_base64.txt + ``` +8. Generate an app-specific password at https://appleid.apple.com (for notarization) + +### Windows (if needed) + +1. Create an Azure Key Vault instance (Premium tier, ~$1/month) +2. Generate a certificate request with RSA-HSM key type +3. Submit CSR to a CA (DigiCert, Sectigo, SSL.com) for an OV code signing certificate +4. Merge the issued certificate back into Key Vault +5. Create an Azure AD service principal with Sign + Get permissions on the vault + +## GitHub Secrets + +### macOS (required) + +| Secret | Value | +|--------|-------| +| `MACOS_CERTIFICATE` | Base64-encoded `.p12` file | +| `MACOS_CERTIFICATE_PWD` | Password used when exporting `.p12` | +| `MACOS_CERTIFICATE_NAME` | `"Developer ID Application: Name (TEAMID)"` | +| `APPLE_ID` | Apple ID email address | +| `APPLE_TEAM_ID` | 10-character Team ID from developer.apple.com | +| `APPLE_APP_PASSWORD` | App-specific password from appleid.apple.com | +| `KEYCHAIN_PWD` | Any random password (for temporary CI keychain) | + +### Windows (optional) + +| Secret | Value | +|--------|-------| +| `AZURE_KEY_VAULT_URI` | `https://your-vault.vault.azure.net` | +| `AZURE_CLIENT_ID` | Service principal client ID | +| `AZURE_TENANT_ID` | Azure AD tenant ID | +| `AZURE_CLIENT_SECRET` | Service principal secret | +| `AZURE_CERT_NAME` | Certificate name in Key Vault | + +## GitHub Actions Workflow + +### macOS + +```yaml +jobs: + build-macos: + runs-on: macos-latest + strategy: + matrix: + target: [x86_64-apple-darwin, aarch64-apple-darwin] + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + run: rustup target add ${{ matrix.target }} + + - name: Import code signing certificate + uses: apple-actions/import-codesign-certs@v3 + with: + p12-file-base64: ${{ secrets.MACOS_CERTIFICATE }} + p12-password: ${{ secrets.MACOS_CERTIFICATE_PWD }} + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + working-directory: libs/oauth-cli + + - name: Sign binary + run: | + codesign --sign "${{ secrets.MACOS_CERTIFICATE_NAME }}" \ + --force \ + --timestamp \ + --options runtime \ + --identifier "com.palantir.foundry-dev-tools-oauth-cli" \ + target/${{ matrix.target }}/release/foundry-dev-tools-oauth + working-directory: libs/oauth-cli + + - name: Verify signature + run: | + codesign --verify --verbose=2 \ + target/${{ matrix.target }}/release/foundry-dev-tools-oauth + codesign -d -r- \ + target/${{ matrix.target }}/release/foundry-dev-tools-oauth + working-directory: libs/oauth-cli + + - name: Notarize binary + run: | + xcrun notarytool store-credentials "notary-profile" \ + --apple-id "${{ secrets.APPLE_ID }}" \ + --team-id "${{ secrets.APPLE_TEAM_ID }}" \ + --password "${{ secrets.APPLE_APP_PASSWORD }}" + + cd libs/oauth-cli + zip -j foundry-dev-tools-oauth-${{ matrix.target }}.zip \ + target/${{ matrix.target }}/release/foundry-dev-tools-oauth + + xcrun notarytool submit \ + foundry-dev-tools-oauth-${{ matrix.target }}.zip \ + --keychain-profile "notary-profile" \ + --wait + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: foundry-dev-tools-oauth-${{ matrix.target }} + path: libs/oauth-cli/foundry-dev-tools-oauth-${{ matrix.target }}.zip +``` + +### Windows (optional) + +```yaml +jobs: + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Build + run: cargo build --release + working-directory: libs/oauth-cli + + - name: Sign binary with AzureSignTool + run: | + dotnet tool install --global AzureSignTool + AzureSignTool sign ` + -kvu "${{ secrets.AZURE_KEY_VAULT_URI }}" ` + -kvi "${{ secrets.AZURE_CLIENT_ID }}" ` + -kvt "${{ secrets.AZURE_TENANT_ID }}" ` + -kvs "${{ secrets.AZURE_CLIENT_SECRET }}" ` + -kvc "${{ secrets.AZURE_CERT_NAME }}" ` + -tr http://timestamp.digicert.com ` + -td sha256 ` + libs/oauth-cli/target/release/foundry-dev-tools-oauth.exe +``` + +### Linux (no signing needed) + +```yaml +jobs: + build-linux: + runs-on: ubuntu-latest + strategy: + matrix: + target: [x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu] + steps: + - uses: actions/checkout@v4 + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + working-directory: libs/oauth-cli + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: foundry-dev-tools-oauth-${{ matrix.target }} + path: libs/oauth-cli/target/${{ matrix.target }}/release/foundry-dev-tools-oauth +``` + +## codesign Flags Reference + +| Flag | Purpose | +|------|---------| +| `--sign` / `-s` | Signing identity (full cert CN or Team ID hash) | +| `--force` / `-f` | Replace any existing signature | +| `--timestamp` | Embed secure timestamp (required for notarization, ensures signature validity after cert expiry) | +| `--options runtime` | Enable Hardened Runtime (required for notarization) | +| `--identifier` / `-i` | Code signing identifier in reverse-DNS format | + +## Notarization Notes + +- `xcrun notarytool submit` requires a `.zip`, `.pkg`, or `.dmg` -- not a bare Mach-O binary +- Processing typically takes 1-5 minutes, can take 30+ minutes +- `--wait` flag polls until complete +- **Stapling is not possible for bare Mach-O binaries** -- `xcrun stapler` only works on `.app`, `.pkg`, `.dmg` +- First-run Gatekeeper verification requires an internet connection (since stapling is not possible) +- To enable offline verification, distribute inside a `.pkg` and staple the ticket to the `.pkg` +- Check failure details: `xcrun notarytool log --keychain-profile "notary-profile"` + +## Certificate Renewal + +- Developer ID Application certificates are valid for **5 years** +- Renewal does **not** change the Team ID (it's tied to the Apple Developer account) +- Keychain ACLs continue to work after renewal without user prompts +- Update the `MACOS_CERTIFICATE` GitHub Secret with the new `.p12` +- Azure Key Vault credentials should be rotated every 90 days + +## Alternative: `rcodesign` (Cross-Platform Signing) + +The `apple-codesign` Rust crate provides `rcodesign`, an open-source reimplementation of Apple code signing and notarization that runs on Linux. This could allow signing on cheaper Linux runners instead of macOS runners. Still requires the Apple Developer Program membership for the certificate. + +Source: https://gregoryszorc.com/blog/2022/08/08/achieving-a-completely-open-source-implementation-of-apple-code-signing-and-notarization/ + +## Alternative: Homebrew Distribution + +`brew install` does not apply the quarantine attribute to binaries, which means Gatekeeper does not check the binary and Keychain prompts are handled differently. This is how `gh`, `ripgrep`, and `rustup` handle macOS distribution without code signing. If Homebrew is the primary distribution channel, code signing becomes less urgent (but still recommended for direct downloads). + +## Cost Summary + +| Item | Cost | Frequency | Required? | +|------|------|-----------|-----------| +| Apple Developer Program (Individual) | $99 | Annual | Yes (macOS Keychain) | +| Windows OV code signing certificate | $200-500 | Annual | No | +| Azure Key Vault (Premium) | ~$12 | Annual | Only for Windows | +| **Minimum (macOS only)** | **$99** | **Annual** | | +| **Both platforms** | **$310-610** | **Annual** | | + +## References + +- [Federico Terzi: macOS code signing in GitHub Actions](https://federicoterzi.com/blog/automatic-code-signing-and-notarization-for-macos-apps-using-github-actions/) +- [PaperAge: Notarizing Rust CLI binaries](https://www.randomerrata.com/articles/2024/notarize/) +- [Apple: Notarizing macOS software](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution) +- [Tauri: macOS signing docs](https://v2.tauri.app/distribute/sign/macos/) +- [apple-actions/import-codesign-certs](https://github.com/marketplace/actions/import-code-signing-certificates) +- [taiki-e/upload-rust-binary-action](https://github.com/taiki-e/upload-rust-binary-action) +- [Windows code signing with EV cert on GitHub Actions](https://melatonin.dev/blog/how-to-code-sign-windows-installers-with-an-ev-cert-on-github-actions/) +- [Gregory Szorc: Open-source Apple code signing](https://gregoryszorc.com/blog/2022/08/08/achieving-a-completely-open-source-implementation-of-apple-code-signing-and-notarization/) +- [Apple TN2206: Code Signing In Depth](https://developer.apple.com/library/archive/technotes/tn2206/_index.html) diff --git a/libs/oauth-cli/Cargo.lock b/libs/oauth-cli/Cargo.lock index 1dc386da..0f71b750 100644 --- a/libs/oauth-cli/Cargo.lock +++ b/libs/oauth-cli/Cargo.lock @@ -299,7 +299,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -326,7 +326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -397,6 +397,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serial_test", "sha2", "tempfile", "thiserror", @@ -420,6 +421,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -863,6 +875,15 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -985,6 +1006,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -1075,6 +1119,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -1134,7 +1187,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1158,6 +1211,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.28" @@ -1167,6 +1229,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "security-framework" version = "2.11.1" @@ -1264,6 +1338,32 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1356,7 +1456,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/libs/oauth-cli/Cargo.toml b/libs/oauth-cli/Cargo.toml index 2eb24f9a..eb6d74df 100644 --- a/libs/oauth-cli/Cargo.toml +++ b/libs/oauth-cli/Cargo.toml @@ -27,3 +27,4 @@ thiserror = "2" [dev-dependencies] tempfile = "3" +serial_test = "3" diff --git a/libs/oauth-cli/src/cache.rs b/libs/oauth-cli/src/cache.rs index 680503e2..22e7e3d0 100644 --- a/libs/oauth-cli/src/cache.rs +++ b/libs/oauth-cli/src/cache.rs @@ -265,8 +265,8 @@ where let lock_path = lock_file_path(config_dir); let lock_file = fs::OpenOptions::new() .create(true) + .truncate(false) .write(true) - .truncate(true) .open(&lock_path) .map_err(|e| Error::CacheIo { path: lock_path.clone(), diff --git a/libs/oauth-cli/src/cli.rs b/libs/oauth-cli/src/cli.rs index bfccbc48..260eb89e 100644 --- a/libs/oauth-cli/src/cli.rs +++ b/libs/oauth-cli/src/cli.rs @@ -46,7 +46,8 @@ pub fn login(config: &Config) -> Result<()> { (redirect_uri, code) } else { - // Browser mode: start local server, open browser + // Browser mode: bind server first, then open browser, then wait for callback. + // This ordering ensures the port is listening before the browser redirects back. log::debug_log( config.debug, &config.config_dir, @@ -54,15 +55,20 @@ pub fn login(config: &Config) -> Result<()> { "starting browser-based login", ); - let (port, callback) = { - // Start the server first so we know the actual port - let (port, _redirect_uri) = start_server_and_open_browser(config, &pkce, &state)?; - // wait_for_callback blocks until the browser redirects back - let (actual_port, callback) = server::wait_for_callback(port, 300)?; - // The actual_port should match, but use what we got - let _ = actual_port; - (port, callback) - }; + let callback_server = server::CallbackServer::bind(config.port)?; + let port = callback_server.port(); + let redirect_uri = config.local_redirect_uri(port); + let auth_url = oauth::build_authorization_url(config, &pkce, &state, &redirect_uri); + + // Open browser (best effort — print URL as fallback) + eprintln!("Opening browser for authentication..."); + if let Err(e) = open::that(&auth_url) { + eprintln!("Failed to open browser: {}", e); + eprintln!("Please open this URL manually:"); + eprintln!(" {}", auth_url); + } + + let callback = callback_server.wait_for_callback(300)?; // Validate state if callback.state != state { @@ -72,7 +78,6 @@ pub fn login(config: &Config) -> Result<()> { }); } - let redirect_uri = config.local_redirect_uri(port); (redirect_uri, callback.code) }; @@ -110,37 +115,6 @@ pub fn login(config: &Config) -> Result<()> { Ok(()) } -/// Start the local callback server and open the browser to the authorization URL. -/// Returns the port the server is bound to. -fn start_server_and_open_browser( - config: &Config, - pkce: &oauth::Pkce, - state: &str, -) -> Result<(u16, String)> { - // We need to know the port before building the URL, but wait_for_callback - // does the binding. We'll use a two-step approach: bind, build URL, open browser. - // Actually, server::wait_for_callback already binds and waits. But we need - // to open the browser *after* binding but *before* the callback arrives. - // - // Restructure: bind the server, open the browser, then wait for the callback. - // This requires splitting wait_for_callback. For simplicity, we'll use a - // thread: spawn the server wait in a thread, open the browser, then join. - - let port = config.port; - let redirect_uri = config.local_redirect_uri(port); - let auth_url = oauth::build_authorization_url(config, pkce, state, &redirect_uri); - - // Open browser (best effort) - eprintln!("Opening browser for authentication..."); - if let Err(e) = open::that(&auth_url) { - eprintln!("Failed to open browser: {}", e); - eprintln!("Please open this URL manually:"); - eprintln!(" {}", auth_url); - } - - Ok((port, redirect_uri)) -} - /// Get a fresh access token, refreshing via cached refresh_token. Outputs token to stdout. pub fn token(config: &Config) -> Result<()> { log::debug_log( diff --git a/libs/oauth-cli/src/config.rs b/libs/oauth-cli/src/config.rs index df4957a0..3e7ecfe4 100644 --- a/libs/oauth-cli/src/config.rs +++ b/libs/oauth-cli/src/config.rs @@ -140,6 +140,7 @@ impl Config { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; fn flags_with_required(hostname: &str, client_id: &str) -> CliFlags { CliFlags { @@ -150,6 +151,7 @@ mod tests { } #[test] + #[serial] fn test_resolve_with_flags() { let config = Config::resolve(flags_with_required("host.example.com", "my-client")).unwrap(); assert_eq!(config.hostname, "host.example.com"); @@ -161,6 +163,7 @@ mod tests { } #[test] + #[serial] fn test_resolve_missing_hostname() { let flags = CliFlags { client_id: Some("id".into()), @@ -174,6 +177,7 @@ mod tests { } #[test] + #[serial] fn test_resolve_missing_client_id() { let flags = CliFlags { hostname: Some("host".into()), @@ -186,6 +190,7 @@ mod tests { } #[test] + #[serial] fn test_resolve_custom_scopes() { let mut flags = flags_with_required("host", "id"); flags.scopes = Some("api:read offline_access".into()); @@ -194,6 +199,7 @@ mod tests { } #[test] + #[serial] fn test_resolve_custom_port() { let mut flags = flags_with_required("host", "id"); flags.port = Some(9999); @@ -202,6 +208,7 @@ mod tests { } #[test] + #[serial] fn test_resolve_debug_flag() { let mut flags = flags_with_required("host", "id"); flags.debug = true; @@ -210,12 +217,14 @@ mod tests { } #[test] + #[serial] fn test_scopes_str() { let config = Config::resolve(flags_with_required("host", "id")).unwrap(); assert_eq!(config.scopes_str(), "offline_access"); } #[test] + #[serial] fn test_authorize_url() { let config = Config::resolve(flags_with_required("foundry.example.com", "id")).unwrap(); assert_eq!( @@ -225,6 +234,7 @@ mod tests { } #[test] + #[serial] fn test_token_url() { let config = Config::resolve(flags_with_required("foundry.example.com", "id")).unwrap(); assert_eq!( @@ -234,6 +244,7 @@ mod tests { } #[test] + #[serial] fn test_callback_url() { let config = Config::resolve(flags_with_required("foundry.example.com", "id")).unwrap(); assert_eq!( @@ -243,6 +254,7 @@ mod tests { } #[test] + #[serial] fn test_local_redirect_uri() { let config = Config::resolve(flags_with_required("host", "id")).unwrap(); assert_eq!(config.local_redirect_uri(9876), "http://127.0.0.1:9876/"); diff --git a/libs/oauth-cli/src/error.rs b/libs/oauth-cli/src/error.rs index 9713156d..54221d08 100644 --- a/libs/oauth-cli/src/error.rs +++ b/libs/oauth-cli/src/error.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; #[derive(Debug, thiserror::Error)] -#[allow(dead_code)] pub enum Error { // Configuration errors #[error("missing required configuration: {0}")] @@ -17,9 +16,6 @@ pub enum Error { #[error("token refresh failed (HTTP {status}): {body}")] TokenRefresh { status: u16, body: String }, - #[error("PKCE generation failed: {0}")] - Pkce(String), - #[error("state mismatch: expected {expected}, got {got}")] StateMismatch { expected: String, got: String }, diff --git a/libs/oauth-cli/src/oauth.rs b/libs/oauth-cli/src/oauth.rs index 0a7b70f6..fbfacfcc 100644 --- a/libs/oauth-cli/src/oauth.rs +++ b/libs/oauth-cli/src/oauth.rs @@ -5,10 +5,9 @@ use base64::Engine; use rand::RngExt; use serde::Deserialize; use sha2::{Digest, Sha256}; -use std::collections::HashMap; /// PKCE pair: code_verifier and the derived code_challenge. -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct Pkce { pub code_verifier: String, pub code_challenge: String, @@ -16,11 +15,8 @@ pub struct Pkce { /// Token response from the Foundry OAuth2 token endpoint. #[derive(Debug, Deserialize)] -#[allow(dead_code)] pub struct TokenResponse { pub access_token: String, - pub token_type: String, - pub expires_in: Option, pub refresh_token: Option, } @@ -60,27 +56,16 @@ pub fn build_authorization_url( state: &str, redirect_uri: &str, ) -> String { - let params = [ - ("response_type", "code"), - ("client_id", &config.client_id), - ("redirect_uri", redirect_uri), - ("scope", &config.scopes_str()), - ("state", state), - ("code_challenge", &pkce.code_challenge), - ("code_challenge_method", "S256"), - ]; - - let query = params - .iter() - .map(|(k, v)| { - format!( - "{}={}", - url::form_urlencoded::byte_serialize(k.as_bytes()).collect::(), - url::form_urlencoded::byte_serialize(v.as_bytes()).collect::() - ) - }) - .collect::>() - .join("&"); + let scopes = config.scopes_str(); + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("response_type", "code") + .append_pair("client_id", &config.client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", &scopes) + .append_pair("state", state) + .append_pair("code_challenge", &pkce.code_challenge) + .append_pair("code_challenge_method", "S256") + .finish(); format!("{}?{}", config.authorize_url(), query) } @@ -92,23 +77,19 @@ pub fn exchange_code( code_verifier: &str, redirect_uri: &str, ) -> Result { - let mut params = HashMap::new(); - params.insert("grant_type", "authorization_code"); - params.insert("code", code); - params.insert("redirect_uri", redirect_uri); - params.insert("client_id", &config.client_id); - params.insert("code_verifier", code_verifier); - - let client_secret_owned; + let mut params: Vec<(&str, &str)> = vec![ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", &config.client_id), + ("code_verifier", code_verifier), + ]; if let Some(ref secret) = config.client_secret { - client_secret_owned = secret.clone(); - params.insert("client_secret", &client_secret_owned); + params.push(("client_secret", secret)); } - let client = reqwest::blocking::Client::new(); - let resp = client + let resp = http_client() .post(config.token_url()) - .header("Content-Type", "application/x-www-form-urlencoded") .form(¶ms) .send()?; @@ -123,21 +104,17 @@ pub fn exchange_code( /// Refresh an access token using a refresh_token. pub fn refresh_token(config: &Config, refresh_tok: &str) -> Result { - let mut params = HashMap::new(); - params.insert("grant_type", "refresh_token"); - params.insert("refresh_token", refresh_tok); - params.insert("client_id", &config.client_id); - - let client_secret_owned; + let mut params: Vec<(&str, &str)> = vec![ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_tok), + ("client_id", &config.client_id), + ]; if let Some(ref secret) = config.client_secret { - client_secret_owned = secret.clone(); - params.insert("client_secret", &client_secret_owned); + params.push(("client_secret", secret)); } - let client = reqwest::blocking::Client::new(); - let resp = client + let resp = http_client() .post(config.token_url()) - .header("Content-Type", "application/x-www-form-urlencoded") .form(¶ms) .send()?; @@ -150,6 +127,13 @@ pub fn refresh_token(config: &Config, refresh_tok: &str) -> Result reqwest::blocking::Client { + use std::sync::OnceLock; + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(reqwest::blocking::Client::new).clone() +} + #[cfg(test)] mod tests { use super::*; diff --git a/libs/oauth-cli/src/server.rs b/libs/oauth-cli/src/server.rs index dc0d0b90..90f11918 100644 --- a/libs/oauth-cli/src/server.rs +++ b/libs/oauth-cli/src/server.rs @@ -1,5 +1,6 @@ use crate::error::{Error, Result}; use std::collections::HashMap; +use std::time::Duration; use url::Url; /// Result of the callback server: the authorization code and state parameter. @@ -26,77 +27,65 @@ const ERROR_HTML: &str = r#" "#; -/// Start a local HTTP server on 127.0.0.1:{port} and wait for the OAuth callback. -/// -/// Returns the port used and the callback result. -pub fn wait_for_callback(port: u16, _timeout_secs: u64) -> Result<(u16, CallbackResult)> { - let (server, port) = bind_server(port)?; - - // Set a timeout so we don't hang forever - server - .incoming_requests() - .next() - .map(|request| { - // Check timeout manually isn't needed — tiny_http blocks on .next() - // We rely on the user completing the flow within a reasonable time. - let url_str = format!("http://127.0.0.1:{}{}", port, request.url()); - let parsed = Url::parse(&url_str).map_err(|e| Error::ServerCallback(e.to_string()))?; - let params: HashMap = parsed.query_pairs().into_owned().collect(); - - if let (Some(code), Some(state)) = (params.get("code"), params.get("state")) { - // Return success page - let response = tiny_http::Response::from_string(SUCCESS_HTML).with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"text/html; charset=utf-8"[..], - ) - .unwrap(), - ); - let _ = request.respond(response); - - Ok(( - port, - CallbackResult { - code: code.clone(), - state: state.clone(), - }, - )) - } else if let Some(error) = params.get("error") { - let desc = params.get("error_description").cloned().unwrap_or_default(); - let response = tiny_http::Response::from_string(ERROR_HTML).with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"text/html; charset=utf-8"[..], - ) - .unwrap(), - ); - let _ = request.respond(response); - Err(Error::OAuthAuthorization(format!("{}: {}", error, desc))) - } else { - let response = tiny_http::Response::from_string(ERROR_HTML).with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"text/html; charset=utf-8"[..], - ) - .unwrap(), - ); - let _ = request.respond(response); - Err(Error::ServerCallback( - "callback missing 'code' and 'state' parameters".into(), - )) - } - }) - .unwrap_or(Err(Error::ServerTimeout)) +/// A bound callback server ready to accept the OAuth redirect. +pub struct CallbackServer { + server: tiny_http::Server, + port: u16, } -/// Bind to the specified port. Fails if the port is unavailable. -fn bind_server(port: u16) -> Result<(tiny_http::Server, u16)> { - let addr = format!("127.0.0.1:{}", port); - match tiny_http::Server::http(&addr) { - Ok(server) => Ok((server, port)), - Err(e) => Err(Error::ServerBind { +impl CallbackServer { + /// Bind a local HTTP server on 127.0.0.1:{port}. + /// Call this before opening the browser so the port is ready to receive the callback. + pub fn bind(port: u16) -> Result { + let addr = format!("127.0.0.1:{}", port); + let server = tiny_http::Server::http(&addr).map_err(|e| Error::ServerBind { addr, source: std::io::Error::new(std::io::ErrorKind::AddrInUse, e.to_string()), - }), + })?; + Ok(Self { server, port }) } + + /// The port this server is bound to. + pub fn port(&self) -> u16 { + self.port + } + + /// Wait for the OAuth callback, blocking up to `timeout_secs`. + pub fn wait_for_callback(self, timeout_secs: u64) -> Result { + let timeout = Duration::from_secs(timeout_secs); + let request = self + .server + .recv_timeout(timeout) + .map_err(|_| Error::ServerTimeout)? + .ok_or(Error::ServerTimeout)?; + + let url_str = format!("http://127.0.0.1:{}{}", self.port, request.url()); + let parsed = Url::parse(&url_str).map_err(|e| Error::ServerCallback(e.to_string()))?; + let params: HashMap = parsed.query_pairs().into_owned().collect(); + + if let (Some(code), Some(state)) = (params.get("code"), params.get("state")) { + respond_html(request, SUCCESS_HTML); + Ok(CallbackResult { + code: code.clone(), + state: state.clone(), + }) + } else if let Some(error) = params.get("error") { + let desc = params.get("error_description").cloned().unwrap_or_default(); + respond_html(request, ERROR_HTML); + Err(Error::OAuthAuthorization(format!("{}: {}", error, desc))) + } else { + respond_html(request, ERROR_HTML); + Err(Error::ServerCallback( + "callback missing 'code' and 'state' parameters".into(), + )) + } + } +} + +fn respond_html(request: tiny_http::Request, body: &str) { + let response = tiny_http::Response::from_string(body).with_header( + tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) + .unwrap(), + ); + let _ = request.respond(response); } From edee0eb750189ec8c746567f66ab96d5fe2ed2c8 Mon Sep 17 00:00:00 2001 From: nicornk Date: Mon, 2 Mar 2026 11:38:05 +0100 Subject: [PATCH 07/12] security: fix hostname injection, callback server hardening, and TOCTOU file permissions - Add hostname validation rejecting URL injection characters (slashes, colons, @, query strings) to prevent credential theft via crafted FOUNDRY_HOSTNAME values - Harden callback server to only accept GET requests to root path, ignoring favicon fetches, POST requests, and other spurious traffic by looping until a valid OAuth callback arrives or timeout expires - Fix TOCTOU race in file permissions: use OpenOptions::mode() and DirBuilder::mode() to set 0o600/0o700 atomically at creation time instead of creating with default umask then chmod after - Standardize env vars to FDT_CREDENTIALS__* namespace, removing inconsistent FOUNDRY_* variants Co-Authored-By: Claude Opus 4.6 --- libs/oauth-cli/src/cache.rs | 140 ++++++++++++++++++----- libs/oauth-cli/src/config.rs | 120 ++++++++++++++++++-- libs/oauth-cli/src/error.rs | 3 + libs/oauth-cli/src/server.rs | 213 ++++++++++++++++++++++++++++++----- 4 files changed, 407 insertions(+), 69 deletions(-) diff --git a/libs/oauth-cli/src/cache.rs b/libs/oauth-cli/src/cache.rs index 22e7e3d0..27b7c4d1 100644 --- a/libs/oauth-cli/src/cache.rs +++ b/libs/oauth-cli/src/cache.rs @@ -83,24 +83,34 @@ fn keyring_delete(key: &str) -> Result { // JSON file backend (Linux) // --------------------------------------------------------------------------- -/// Ensure the cache directory exists with 0o700 permissions. +/// Ensure the cache directory exists with 0o700 permissions (Unix). +/// Uses DirBuilder::mode() to set permissions at creation time, avoiding a TOCTOU race. pub fn ensure_config_dir(config_dir: &Path) -> Result<()> { - if !config_dir.exists() { - fs::create_dir_all(config_dir).map_err(|e| Error::CacheDir { - path: config_dir.to_path_buf(), - source: e, - })?; + if config_dir.exists() { + return Ok(()); } + #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(config_dir, fs::Permissions::from_mode(0o700)).map_err(|e| { - Error::CacheDir { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(config_dir) + .map_err(|e| Error::CacheDir { path: config_dir.to_path_buf(), source: e, - } + })?; + } + + #[cfg(not(unix))] + { + fs::create_dir_all(config_dir).map_err(|e| Error::CacheDir { + path: config_dir.to_path_buf(), + source: e, })?; } + Ok(()) } @@ -125,24 +135,34 @@ fn read_cache_file(config_dir: &Path) -> Result { serde_json::from_str(&data).map_err(|e| Error::CacheParse { path, source: e }) } -/// Write the cache file with 0o600 permissions. +/// Write the cache file atomically with 0o600 permissions (Unix). +/// Uses OpenOptions::mode() to set permissions at creation time, avoiding a TOCTOU race +/// where the file is briefly world-readable between write and chmod. fn write_cache_file(config_dir: &Path, cache: &CacheFile) -> Result<()> { + use std::io::Write; + let path = cache_file_path(config_dir); let data = serde_json::to_string_pretty(cache).expect("cache serialization cannot fail"); - fs::write(&path, data).map_err(|e| Error::CacheIo { + + let mut opts = fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + + let mut file = opts.open(&path).map_err(|e| Error::CacheIo { path: path.clone(), source: e, })?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).map_err(|e| { - Error::CacheIo { - path: path.clone(), - source: e, - } + file.write_all(data.as_bytes()) + .map_err(|e| Error::CacheIo { + path: path.clone(), + source: e, })?; - } + Ok(()) } @@ -263,15 +283,17 @@ where ensure_config_dir(config_dir)?; let lock_path = lock_file_path(config_dir); - let lock_file = fs::OpenOptions::new() - .create(true) - .truncate(false) - .write(true) - .open(&lock_path) - .map_err(|e| Error::CacheIo { - path: lock_path.clone(), - source: e, - })?; + let mut lock_opts = fs::OpenOptions::new(); + lock_opts.create(true).truncate(false).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + lock_opts.mode(0o600); + } + let lock_file = lock_opts.open(&lock_path).map_err(|e| Error::CacheIo { + path: lock_path.clone(), + source: e, + })?; // Try to acquire the lock with a timeout let start = Instant::now(); @@ -433,4 +455,62 @@ mod tests { .unwrap(); assert!(result.is_none()); } + + #[cfg(unix)] + #[test] + fn test_cache_file_permissions_0o600() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path(); + let key = cache_key("host.example.com", "client123", &["offline_access".into()]); + + file_save(config_dir, &key, "secret_token").unwrap(); + + let path = cache_file_path(config_dir); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "cache file should have 0o600 permissions, got {:o}", + mode + ); + } + + #[cfg(unix)] + #[test] + fn test_config_dir_permissions_0o700() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join("new-oauth-dir"); + + ensure_config_dir(&config_dir).unwrap(); + + let mode = fs::metadata(&config_dir).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o700, + "config dir should have 0o700 permissions, got {:o}", + mode + ); + } + + #[cfg(unix)] + #[test] + fn test_lock_file_permissions_0o600() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path(); + + // with_lock creates the lock file + with_lock(config_dir, false, || Ok(())).unwrap(); + + let path = lock_file_path(config_dir); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "lock file should have 0o600 permissions, got {:o}", + mode + ); + } } diff --git a/libs/oauth-cli/src/config.rs b/libs/oauth-cli/src/config.rs index 3e7ecfe4..51c6efee 100644 --- a/libs/oauth-cli/src/config.rs +++ b/libs/oauth-cli/src/config.rs @@ -53,32 +53,67 @@ fn resolve_config_dir() -> PathBuf { candidates[0].join("oauth") } +/// Validate that the hostname is a plain domain name, not a URL or path-injected string. +/// Rejects any hostname containing characters that could cause URL injection when +/// interpolated into `https://{hostname}/multipass/api/...`. +fn validate_hostname(hostname: &str) -> Result<()> { + if hostname.is_empty() { + return Err(Error::InvalidHostname { + hostname: hostname.to_string(), + }); + } + + // Must contain only valid domain characters: alphanumeric, hyphens, dots + let is_valid = hostname + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.'); + + if !is_valid { + return Err(Error::InvalidHostname { + hostname: hostname.to_string(), + }); + } + + // Must not start or end with a dot or hyphen + if hostname.starts_with('.') + || hostname.starts_with('-') + || hostname.ends_with('.') + || hostname.ends_with('-') + { + return Err(Error::InvalidHostname { + hostname: hostname.to_string(), + }); + } + + Ok(()) +} + impl Config { - /// Build a resolved Config by merging: CLI flags → native env vars → FDT env vars → defaults. + /// Build a resolved Config by merging: CLI flags → FDT env vars → defaults. pub fn resolve(flags: CliFlags) -> Result { let hostname = flags .hostname - .or_else(|| std::env::var("FOUNDRY_HOSTNAME").ok()) .or_else(|| std::env::var("FDT_CREDENTIALS__DOMAIN").ok()) .ok_or(Error::MissingConfig( - "hostname (--hostname, FOUNDRY_HOSTNAME, or FDT_CREDENTIALS__DOMAIN)", + "hostname (--hostname or FDT_CREDENTIALS__DOMAIN)", ))?; + validate_hostname(&hostname)?; + let client_id = flags .client_id - .or_else(|| std::env::var("FOUNDRY_CLIENT_ID").ok()) .or_else(|| std::env::var("FDT_CREDENTIALS__OAUTH__CLIENT_ID").ok()) .ok_or(Error::MissingConfig( - "client_id (--client-id, FOUNDRY_CLIENT_ID, or FDT_CREDENTIALS__OAUTH__CLIENT_ID)", + "client_id (--client-id or FDT_CREDENTIALS__OAUTH__CLIENT_ID)", ))?; let client_secret = flags .client_secret - .or_else(|| std::env::var("FOUNDRY_CLIENT_SECRET").ok()); + .or_else(|| std::env::var("FDT_CREDENTIALS__OAUTH__CLIENT_SECRET").ok()); let scopes = flags .scopes - .or_else(|| std::env::var("FOUNDRY_SCOPES").ok()) + .or_else(|| std::env::var("FDT_CREDENTIALS__OAUTH__SCOPES").ok()) .map(|s| s.split_whitespace().map(String::from).collect()) .unwrap_or_else(|| vec!["offline_access".to_string()]); @@ -87,14 +122,14 @@ impl Config { let port = flags .port .or_else(|| { - std::env::var("FOUNDRY_OAUTH_PORT") + std::env::var("FDT_CREDENTIALS__OAUTH__PORT") .ok() .and_then(|s| s.parse().ok()) }) .unwrap_or(9876); let debug = flags.debug - || std::env::var("FOUNDRY_OAUTH_DEBUG") + || std::env::var("FDT_CREDENTIALS__OAUTH__DEBUG") .ok() .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); @@ -169,8 +204,6 @@ mod tests { client_id: Some("id".into()), ..Default::default() }; - // Clear env vars that might interfere - std::env::remove_var("FOUNDRY_HOSTNAME"); std::env::remove_var("FDT_CREDENTIALS__DOMAIN"); let result = Config::resolve(flags); assert!(result.is_err()); @@ -183,7 +216,6 @@ mod tests { hostname: Some("host".into()), ..Default::default() }; - std::env::remove_var("FOUNDRY_CLIENT_ID"); std::env::remove_var("FDT_CREDENTIALS__OAUTH__CLIENT_ID"); let result = Config::resolve(flags); assert!(result.is_err()); @@ -260,4 +292,68 @@ mod tests { assert_eq!(config.local_redirect_uri(9876), "http://127.0.0.1:9876/"); assert_eq!(config.local_redirect_uri(9000), "http://127.0.0.1:9000/"); } + + // ---- hostname validation tests ---- + + #[test] + fn test_validate_hostname_valid() { + assert!(validate_hostname("foundry.example.com").is_ok()); + assert!(validate_hostname("my-host.example.co.uk").is_ok()); + assert!(validate_hostname("localhost").is_ok()); + assert!(validate_hostname("a").is_ok()); + assert!(validate_hostname("host-1.test").is_ok()); + } + + #[test] + fn test_validate_hostname_rejects_empty() { + assert!(validate_hostname("").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_slashes() { + // URL injection: evil.com/steal?x= would redirect credentials + assert!(validate_hostname("evil.com/steal?x=").is_err()); + assert!(validate_hostname("host/path").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_colons() { + assert!(validate_hostname("host:8080").is_err()); + assert!(validate_hostname("https://host").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_query_and_fragment() { + assert!(validate_hostname("host?query=1").is_err()); + assert!(validate_hostname("host#fragment").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_at_sign() { + // Userinfo injection: user@evil.com + assert!(validate_hostname("user@evil.com").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_spaces_and_special() { + assert!(validate_hostname("host name").is_err()); + assert!(validate_hostname("host\tname").is_err()); + assert!(validate_hostname("host\nname").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_leading_trailing_dot_hyphen() { + assert!(validate_hostname(".example.com").is_err()); + assert!(validate_hostname("example.com.").is_err()); + assert!(validate_hostname("-example.com").is_err()); + assert!(validate_hostname("example.com-").is_err()); + } + + #[test] + #[serial] + fn test_resolve_rejects_injected_hostname() { + let flags = flags_with_required("evil.com/steal?x=", "id"); + let result = Config::resolve(flags); + assert!(result.is_err()); + } } diff --git a/libs/oauth-cli/src/error.rs b/libs/oauth-cli/src/error.rs index 54221d08..4d907041 100644 --- a/libs/oauth-cli/src/error.rs +++ b/libs/oauth-cli/src/error.rs @@ -6,6 +6,9 @@ pub enum Error { #[error("missing required configuration: {0}")] MissingConfig(&'static str), + #[error("invalid hostname '{hostname}': must be a plain domain name (no slashes, ports, or path components)")] + InvalidHostname { hostname: String }, + // OAuth errors #[error("OAuth authorization failed: {0}")] OAuthAuthorization(String), diff --git a/libs/oauth-cli/src/server.rs b/libs/oauth-cli/src/server.rs index 90f11918..5a7ea2b1 100644 --- a/libs/oauth-cli/src/server.rs +++ b/libs/oauth-cli/src/server.rs @@ -42,7 +42,15 @@ impl CallbackServer { addr, source: std::io::Error::new(std::io::ErrorKind::AddrInUse, e.to_string()), })?; - Ok(Self { server, port }) + // Resolve the actual bound port (important when port 0 is used for OS-assigned ports) + let actual_port = match server.server_addr() { + tiny_http::ListenAddr::IP(addr) => addr.port(), + _ => port, + }; + Ok(Self { + server, + port: actual_port, + }) } /// The port this server is bound to. @@ -51,33 +59,56 @@ impl CallbackServer { } /// Wait for the OAuth callback, blocking up to `timeout_secs`. + /// Only accepts GET requests to "/" (the expected redirect path). + /// Ignores unrelated requests (e.g. from browser extensions, favicon fetches) + /// and keeps waiting until a valid callback arrives or the timeout expires. pub fn wait_for_callback(self, timeout_secs: u64) -> Result { - let timeout = Duration::from_secs(timeout_secs); - let request = self - .server - .recv_timeout(timeout) - .map_err(|_| Error::ServerTimeout)? - .ok_or(Error::ServerTimeout)?; - - let url_str = format!("http://127.0.0.1:{}{}", self.port, request.url()); - let parsed = Url::parse(&url_str).map_err(|e| Error::ServerCallback(e.to_string()))?; - let params: HashMap = parsed.query_pairs().into_owned().collect(); - - if let (Some(code), Some(state)) = (params.get("code"), params.get("state")) { - respond_html(request, SUCCESS_HTML); - Ok(CallbackResult { - code: code.clone(), - state: state.clone(), - }) - } else if let Some(error) = params.get("error") { - let desc = params.get("error_description").cloned().unwrap_or_default(); - respond_html(request, ERROR_HTML); - Err(Error::OAuthAuthorization(format!("{}: {}", error, desc))) - } else { - respond_html(request, ERROR_HTML); - Err(Error::ServerCallback( - "callback missing 'code' and 'state' parameters".into(), - )) + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs); + + loop { + let remaining = deadline + .checked_duration_since(std::time::Instant::now()) + .ok_or(Error::ServerTimeout)?; + + let request = self + .server + .recv_timeout(remaining) + .map_err(|_| Error::ServerTimeout)? + .ok_or(Error::ServerTimeout)?; + + // Only accept GET requests + if request.method() != &tiny_http::Method::Get { + respond_html(request, ERROR_HTML); + continue; + } + + // Only accept requests to the root path (with query string) + let request_url = request.url(); + if !request_url.starts_with("/?") && request_url != "/" { + respond_html(request, ERROR_HTML); + continue; + } + + let url_str = format!("http://127.0.0.1:{}{}", self.port, request_url); + let parsed = Url::parse(&url_str).map_err(|e| Error::ServerCallback(e.to_string()))?; + let params: HashMap = parsed.query_pairs().into_owned().collect(); + + if let (Some(code), Some(state)) = (params.get("code"), params.get("state")) { + respond_html(request, SUCCESS_HTML); + return Ok(CallbackResult { + code: code.clone(), + state: state.clone(), + }); + } else if let Some(error) = params.get("error") { + let desc = params.get("error_description").cloned().unwrap_or_default(); + respond_html(request, ERROR_HTML); + return Err(Error::OAuthAuthorization(format!("{}: {}", error, desc))); + } else { + // Missing code/state but on the right path — could be a partial redirect. + // Keep waiting rather than failing, in case the real callback follows. + respond_html(request, ERROR_HTML); + continue; + } } } } @@ -89,3 +120,131 @@ fn respond_html(request: tiny_http::Request, body: &str) { ); let _ = request.respond(response); } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpStream; + + /// Helper: send a raw HTTP request to the server and return the response. + fn send_raw_request(port: u16, request: &str) -> String { + let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)).unwrap(); + stream.write_all(request.as_bytes()).unwrap(); + stream.flush().unwrap(); + let mut response = String::new(); + // Read with a short timeout so we don't block forever + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let _ = stream.read_to_string(&mut response); + response + } + + #[test] + fn test_callback_accepts_valid_get_with_code_and_state() { + let server = CallbackServer::bind(0).unwrap(); + let port = server.port(); + + let handle = std::thread::spawn(move || server.wait_for_callback(5)); + + send_raw_request( + port, + "GET /?code=test_code&state=test_state HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + let result = handle.join().unwrap().unwrap(); + assert_eq!(result.code, "test_code"); + assert_eq!(result.state, "test_state"); + } + + #[test] + fn test_callback_ignores_post_then_accepts_valid_get() { + let server = CallbackServer::bind(0).unwrap(); + let port = server.port(); + + let handle = std::thread::spawn(move || server.wait_for_callback(5)); + + // First: POST request — should be ignored + send_raw_request( + port, + "POST /?code=bad&state=bad HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", + ); + + // Then: valid GET + send_raw_request( + port, + "GET /?code=real_code&state=real_state HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + let result = handle.join().unwrap().unwrap(); + assert_eq!(result.code, "real_code"); + assert_eq!(result.state, "real_state"); + } + + #[test] + fn test_callback_ignores_favicon_then_accepts_valid_get() { + let server = CallbackServer::bind(0).unwrap(); + let port = server.port(); + + let handle = std::thread::spawn(move || server.wait_for_callback(5)); + + // First: favicon request — wrong path, should be ignored + send_raw_request(port, "GET /favicon.ico HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); + + // Then: valid callback + send_raw_request( + port, + "GET /?code=abc&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + let result = handle.join().unwrap().unwrap(); + assert_eq!(result.code, "abc"); + assert_eq!(result.state, "xyz"); + } + + #[test] + fn test_callback_ignores_root_without_params_then_accepts_valid() { + let server = CallbackServer::bind(0).unwrap(); + let port = server.port(); + + let handle = std::thread::spawn(move || server.wait_for_callback(5)); + + // GET / with no query params — should be ignored (missing code/state) + send_raw_request(port, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); + + // Then: valid callback + send_raw_request( + port, + "GET /?code=c&state=s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + let result = handle.join().unwrap().unwrap(); + assert_eq!(result.code, "c"); + assert_eq!(result.state, "s"); + } + + #[test] + fn test_callback_returns_error_on_oauth_error_response() { + let server = CallbackServer::bind(0).unwrap(); + let port = server.port(); + + let handle = std::thread::spawn(move || server.wait_for_callback(5)); + + send_raw_request( + port, + "GET /?error=access_denied&error_description=user+denied HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + let result = handle.join().unwrap(); + assert!(result.is_err()); + } + + #[test] + fn test_callback_timeout() { + let server = CallbackServer::bind(0).unwrap(); + // Very short timeout — should expire with no requests + let result = server.wait_for_callback(1); + assert!(result.is_err()); + } +} From 87e1b43a741c33bd3fa9b237cf03f067ebcaabdf Mon Sep 17 00:00:00 2001 From: nicornk Date: Mon, 2 Mar 2026 13:10:52 +0100 Subject: [PATCH 08/12] security: disable HTTP redirect following on token endpoint requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 6749 §3.2 and OAuth 2.0 Security BCP §4.11, token endpoint requests must not follow redirects to prevent leaking credentials (auth codes, client secrets, PKCE verifiers) to a redirect target. Co-Authored-By: Claude Opus 4.6 --- libs/oauth-cli/src/oauth.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/libs/oauth-cli/src/oauth.rs b/libs/oauth-cli/src/oauth.rs index fbfacfcc..bd927996 100644 --- a/libs/oauth-cli/src/oauth.rs +++ b/libs/oauth-cli/src/oauth.rs @@ -128,10 +128,20 @@ pub fn refresh_token(config: &Config, refresh_tok: &str) -> Result reqwest::blocking::Client { use std::sync::OnceLock; static CLIENT: OnceLock = OnceLock::new(); - CLIENT.get_or_init(reqwest::blocking::Client::new).clone() + CLIENT + .get_or_init(|| { + reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build HTTP client") + }) + .clone() } #[cfg(test)] From 4a89f690caf17036daae19975147e97887870ed7 Mon Sep 17 00:00:00 2001 From: nicornk Date: Mon, 2 Mar 2026 14:45:31 +0100 Subject: [PATCH 09/12] feat: show full binary path in login-required messages and add api:read-data default scope Use std::env::current_exe() to display the full path to the binary in error messages so users (and tools like Claude Code) get a copy-pasteable login command. Also add "api:read-data" to default scopes alongside "offline_access" and extract both into a DEFAULT_SCOPES constant. Co-Authored-By: Claude Opus 4.6 --- libs/oauth-cli/src/cli.rs | 10 ++++++++-- libs/oauth-cli/src/config.rs | 9 ++++++--- libs/oauth-cli/src/error.rs | 2 +- libs/oauth-cli/src/main.rs | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/libs/oauth-cli/src/cli.rs b/libs/oauth-cli/src/cli.rs index 260eb89e..2dace5b9 100644 --- a/libs/oauth-cli/src/cli.rs +++ b/libs/oauth-cli/src/cli.rs @@ -226,8 +226,11 @@ fn refresh_cached_token(config: &Config) -> Result { fn try_auto_login(config: &Config) -> Result { // Check if we're in an interactive terminal if !atty_is_terminal() { + let exe = std::env::current_exe() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "foundry-dev-tools-oauth".to_string()); eprintln!("No cached credentials and not running interactively."); - eprintln!("Run `foundry-dev-tools-oauth login` in a terminal first."); + eprintln!("Run `{exe} login` in a terminal first."); return Err(Error::LoginRequired); } @@ -290,8 +293,11 @@ pub fn status(config: &Config) -> Result<()> { eprintln!(" Has token: {}", if has_token { "yes" } else { "no" }); if !has_token { + let exe = std::env::current_exe() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "foundry-dev-tools-oauth".to_string()); eprintln!(); - eprintln!("Run `foundry-dev-tools-oauth login` to authenticate."); + eprintln!("Run `{exe} login` to authenticate."); } Ok(()) diff --git a/libs/oauth-cli/src/config.rs b/libs/oauth-cli/src/config.rs index 51c6efee..fba9abb9 100644 --- a/libs/oauth-cli/src/config.rs +++ b/libs/oauth-cli/src/config.rs @@ -1,6 +1,9 @@ use crate::error::{Error, Result}; use std::path::PathBuf; +/// Default OAuth2 scopes requested when none are explicitly provided. +pub const DEFAULT_SCOPES: &[&str] = &["offline_access", "api:read-data"]; + /// Resolved configuration for the CLI. #[derive(Debug, Clone)] pub struct Config { @@ -115,7 +118,7 @@ impl Config { .scopes .or_else(|| std::env::var("FDT_CREDENTIALS__OAUTH__SCOPES").ok()) .map(|s| s.split_whitespace().map(String::from).collect()) - .unwrap_or_else(|| vec!["offline_access".to_string()]); + .unwrap_or_else(|| DEFAULT_SCOPES.iter().map(|s| String::from(*s)).collect()); let config_dir = resolve_config_dir(); @@ -191,7 +194,7 @@ mod tests { let config = Config::resolve(flags_with_required("host.example.com", "my-client")).unwrap(); assert_eq!(config.hostname, "host.example.com"); assert_eq!(config.client_id, "my-client"); - assert_eq!(config.scopes, vec!["offline_access"]); + assert_eq!(config.scopes, vec!["offline_access", "api:read-data"]); assert_eq!(config.port, 9876); assert!(!config.debug); assert!(!config.no_browser); @@ -252,7 +255,7 @@ mod tests { #[serial] fn test_scopes_str() { let config = Config::resolve(flags_with_required("host", "id")).unwrap(); - assert_eq!(config.scopes_str(), "offline_access"); + assert_eq!(config.scopes_str(), "offline_access api:read-data"); } #[test] diff --git a/libs/oauth-cli/src/error.rs b/libs/oauth-cli/src/error.rs index 4d907041..bb91d823 100644 --- a/libs/oauth-cli/src/error.rs +++ b/libs/oauth-cli/src/error.rs @@ -69,7 +69,7 @@ pub enum Error { Io(#[from] std::io::Error), // Login required (no cached token, non-interactive context) - #[error("no cached credentials found — run `foundry-dev-tools-oauth login` first")] + #[error("no cached credentials found — run `{exe} login` first", exe = std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_else(|_| "foundry-dev-tools-oauth".into()))] LoginRequired, } diff --git a/libs/oauth-cli/src/main.rs b/libs/oauth-cli/src/main.rs index ba94865f..a5438a5a 100644 --- a/libs/oauth-cli/src/main.rs +++ b/libs/oauth-cli/src/main.rs @@ -32,7 +32,7 @@ struct Cli { #[arg(long, global = true)] client_secret: Option, - /// OAuth2 scopes (space-separated) + /// OAuth2 scopes (space-separated, default: "offline_access api:read-data") #[arg(long, global = true)] scopes: Option, From 690a11fd87e29e1d6806f67d34754508acdd696f Mon Sep 17 00:00:00 2001 From: nicornk Date: Mon, 2 Mar 2026 17:02:53 +0100 Subject: [PATCH 10/12] fix: clean up code smells in oauth-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove dead code (no-op if block, unused scopes_from_flag binding), fix port default in help text (8888 → 9876), track --debug in explicit_cli_args, include explicit CLI args in LoginRequired error Display, print real client_secret value in CLI args, remove TOCTOU in ensure_config_dir, and document intentional stdout usage in try_auto_login. Co-Authored-By: Claude Opus 4.6 --- libs/oauth-cli/src/cache.rs | 37 ++++++++++++++++++++++-------------- libs/oauth-cli/src/cli.rs | 36 ++++++++++++++++++++++++++--------- libs/oauth-cli/src/config.rs | 25 ++++++++++++++++++++++++ libs/oauth-cli/src/error.rs | 8 ++++++-- libs/oauth-cli/src/main.rs | 9 +++++++-- libs/oauth-cli/src/oauth.rs | 1 + 6 files changed, 89 insertions(+), 27 deletions(-) diff --git a/libs/oauth-cli/src/cache.rs b/libs/oauth-cli/src/cache.rs index 27b7c4d1..8694e410 100644 --- a/libs/oauth-cli/src/cache.rs +++ b/libs/oauth-cli/src/cache.rs @@ -84,31 +84,40 @@ fn keyring_delete(key: &str) -> Result { // --------------------------------------------------------------------------- /// Ensure the cache directory exists with 0o700 permissions (Unix). -/// Uses DirBuilder::mode() to set permissions at creation time, avoiding a TOCTOU race. +/// Uses DirBuilder::mode() to set permissions at creation time, avoiding a TOCTOU race +/// between creation and chmod. pub fn ensure_config_dir(config_dir: &Path) -> Result<()> { - if config_dir.exists() { - return Ok(()); - } - #[cfg(unix)] { use std::os::unix::fs::DirBuilderExt; - fs::DirBuilder::new() + match fs::DirBuilder::new() .recursive(true) .mode(0o700) .create(config_dir) - .map_err(|e| Error::CacheDir { - path: config_dir.to_path_buf(), - source: e, - })?; + { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => { + return Err(Error::CacheDir { + path: config_dir.to_path_buf(), + source: e, + }) + } + } } #[cfg(not(unix))] { - fs::create_dir_all(config_dir).map_err(|e| Error::CacheDir { - path: config_dir.to_path_buf(), - source: e, - })?; + match fs::create_dir_all(config_dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => { + return Err(Error::CacheDir { + path: config_dir.to_path_buf(), + source: e, + }) + } + } } Ok(()) diff --git a/libs/oauth-cli/src/cli.rs b/libs/oauth-cli/src/cli.rs index 2dace5b9..5167d3ed 100644 --- a/libs/oauth-cli/src/cli.rs +++ b/libs/oauth-cli/src/cli.rs @@ -135,7 +135,7 @@ pub fn token(config: &Config) -> Result<()> { let access_token = match result { Ok(token) => token, - Err(Error::LoginRequired) | Err(Error::TokenRefresh { .. }) => { + Err(Error::LoginRequired { .. }) | Err(Error::TokenRefresh { .. }) => { // No cached token or refresh failed — try auto-login OUTSIDE the lock // so the interactive browser flow doesn't hold the lock for 30+ seconds if let Err(ref e) = result { @@ -217,7 +217,9 @@ fn refresh_cached_token(config: &Config) -> Result { } } } - None => Err(Error::LoginRequired), + None => Err(Error::LoginRequired { + args: config.explicit_cli_args.clone(), + }), } } @@ -229,9 +231,17 @@ fn try_auto_login(config: &Config) -> Result { let exe = std::env::current_exe() .map(|p| p.display().to_string()) .unwrap_or_else(|_| "foundry-dev-tools-oauth".to_string()); - eprintln!("No cached credentials and not running interactively."); - eprintln!("Run `{exe} login` in a terminal first."); - return Err(Error::LoginRequired); + // Intentionally print to stdout, not stderr: when this binary is used as an + // apiKeyHelper (e.g. by Claude Code), stdout is captured and displayed to the user. + // stderr is swallowed. Using stdout ensures the login instructions are visible. + println!("No cached credentials and not running interactively."); + println!( + "Run `{exe} login{args}` in a terminal first and restart the session.", + args = config.explicit_cli_args, + ); + return Err(Error::LoginRequired { + args: config.explicit_cli_args.clone(), + }); } log::debug_log( @@ -254,7 +264,9 @@ fn try_auto_login(config: &Config) -> Result { &config.scopes, config.debug, )? - .ok_or(Error::LoginRequired)?; + .ok_or(Error::LoginRequired { + args: config.explicit_cli_args.clone(), + })?; let resp = oauth::refresh_token(config, &refresh_tok)?; if let Some(ref new_refresh) = resp.refresh_token { @@ -272,9 +284,12 @@ fn try_auto_login(config: &Config) -> Result { }) } -/// Check if stderr is a terminal (heuristic for interactivity). +/// Check if running interactively (both stdin and stderr must be terminals). +/// When stdout is piped (e.g. apiKeyHelper in Claude Code), stdin won't be a terminal, +/// so we skip the interactive login flow and show an error instead. fn atty_is_terminal() -> bool { - std::io::IsTerminal::is_terminal(&std::io::stderr()) + std::io::IsTerminal::is_terminal(&std::io::stdin()) + && std::io::IsTerminal::is_terminal(&std::io::stderr()) } /// Show authentication status. @@ -297,7 +312,10 @@ pub fn status(config: &Config) -> Result<()> { .map(|p| p.display().to_string()) .unwrap_or_else(|_| "foundry-dev-tools-oauth".to_string()); eprintln!(); - eprintln!("Run `{exe} login` to authenticate."); + eprintln!( + "Run `{exe} login{args}` to authenticate.", + args = config.explicit_cli_args + ); } Ok(()) diff --git a/libs/oauth-cli/src/config.rs b/libs/oauth-cli/src/config.rs index fba9abb9..ba2086e7 100644 --- a/libs/oauth-cli/src/config.rs +++ b/libs/oauth-cli/src/config.rs @@ -15,6 +15,9 @@ pub struct Config { pub port: u16, pub no_browser: bool, pub debug: bool, + /// CLI flags that were explicitly passed (not from env vars), + /// formatted as command-line arguments for display in error messages. + pub explicit_cli_args: String, } /// Raw values from CLI flags (all optional — flags override env vars). @@ -94,8 +97,12 @@ fn validate_hostname(hostname: &str) -> Result<()> { impl Config { /// Build a resolved Config by merging: CLI flags → FDT env vars → defaults. pub fn resolve(flags: CliFlags) -> Result { + // Track which args were explicitly passed as CLI flags + let mut cli_args = Vec::new(); + let hostname = flags .hostname + .inspect(|h| cli_args.push(format!("--hostname {h}"))) .or_else(|| std::env::var("FDT_CREDENTIALS__DOMAIN").ok()) .ok_or(Error::MissingConfig( "hostname (--hostname or FDT_CREDENTIALS__DOMAIN)", @@ -105,6 +112,7 @@ impl Config { let client_id = flags .client_id + .inspect(|c| cli_args.push(format!("--client-id {c}"))) .or_else(|| std::env::var("FDT_CREDENTIALS__OAUTH__CLIENT_ID").ok()) .ok_or(Error::MissingConfig( "client_id (--client-id or FDT_CREDENTIALS__OAUTH__CLIENT_ID)", @@ -112,10 +120,12 @@ impl Config { let client_secret = flags .client_secret + .inspect(|s| cli_args.push(format!("--client-secret {s}"))) .or_else(|| std::env::var("FDT_CREDENTIALS__OAUTH__CLIENT_SECRET").ok()); let scopes = flags .scopes + .inspect(|s| cli_args.push(format!("--scopes \"{s}\""))) .or_else(|| std::env::var("FDT_CREDENTIALS__OAUTH__SCOPES").ok()) .map(|s| s.split_whitespace().map(String::from).collect()) .unwrap_or_else(|| DEFAULT_SCOPES.iter().map(|s| String::from(*s)).collect()); @@ -124,6 +134,7 @@ impl Config { let port = flags .port + .inspect(|p| cli_args.push(format!("--port {p}"))) .or_else(|| { std::env::var("FDT_CREDENTIALS__OAUTH__PORT") .ok() @@ -131,12 +142,25 @@ impl Config { }) .unwrap_or(9876); + if flags.no_browser { + cli_args.push("--no-browser".to_string()); + } + if flags.debug { + cli_args.push("--debug".to_string()); + } + let debug = flags.debug || std::env::var("FDT_CREDENTIALS__OAUTH__DEBUG") .ok() .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); + let explicit_cli_args = if cli_args.is_empty() { + String::new() + } else { + format!(" {}", cli_args.join(" ")) + }; + Ok(Config { hostname, client_id, @@ -146,6 +170,7 @@ impl Config { port, no_browser: flags.no_browser, debug, + explicit_cli_args, }) } diff --git a/libs/oauth-cli/src/error.rs b/libs/oauth-cli/src/error.rs index bb91d823..0b6fa043 100644 --- a/libs/oauth-cli/src/error.rs +++ b/libs/oauth-cli/src/error.rs @@ -69,8 +69,12 @@ pub enum Error { Io(#[from] std::io::Error), // Login required (no cached token, non-interactive context) - #[error("no cached credentials found — run `{exe} login` first", exe = std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_else(|_| "foundry-dev-tools-oauth".into()))] - LoginRequired, + #[error("no cached credentials found — run `{exe} login{args}` first", exe = std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_else(|_| "foundry-dev-tools-oauth".into()))] + LoginRequired { + /// Explicit CLI args to include in the error message (e.g. " --hostname foo"). + /// Empty string when no extra args are needed. + args: String, + }, } pub type Result = std::result::Result; diff --git a/libs/oauth-cli/src/main.rs b/libs/oauth-cli/src/main.rs index a5438a5a..75f51077 100644 --- a/libs/oauth-cli/src/main.rs +++ b/libs/oauth-cli/src/main.rs @@ -8,6 +8,7 @@ mod server; use clap::{Parser, Subcommand}; use config::CliFlags; +use error::Error; use std::process; #[derive(Parser)] @@ -36,7 +37,7 @@ struct Cli { #[arg(long, global = true)] scopes: Option, - /// Local server port for OAuth callback (default: 8888) + /// Local server port for OAuth callback (default: 9876) #[arg(long, global = true)] port: Option, @@ -99,7 +100,11 @@ fn main() { "EXIT", &format!("1 — {}", e), ); - eprintln!("Error: {}", e); + // LoginRequired already printed a helpful message to stdout in try_auto_login, + // so skip the redundant stderr error for that case. + if !matches!(e, Error::LoginRequired { .. }) { + eprintln!("Error: {}", e); + } process::exit(1); } } diff --git a/libs/oauth-cli/src/oauth.rs b/libs/oauth-cli/src/oauth.rs index bd927996..d5e6934a 100644 --- a/libs/oauth-cli/src/oauth.rs +++ b/libs/oauth-cli/src/oauth.rs @@ -208,6 +208,7 @@ mod tests { port: 8888, no_browser: false, debug: false, + explicit_cli_args: String::new(), }; let pkce = Pkce { code_verifier: "test-verifier".into(), From 81ecaea8d2e7be796f3664619a4ceea234807ad1 Mon Sep 17 00:00:00 2001 From: nicornk Date: Mon, 2 Mar 2026 17:26:17 +0100 Subject: [PATCH 11/12] security: set 0o600 permissions on debug log file The debug log file was created with default permissions, which on systems with a permissive umask (e.g. 0o022) results in a world-readable file. Set mode 0o600 at creation time on Unix, consistent with the cache file and lock file handling in cache.rs. Co-Authored-By: Claude Opus 4.6 --- libs/oauth-cli/src/log.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libs/oauth-cli/src/log.rs b/libs/oauth-cli/src/log.rs index 7d9c290f..4ce0ddb3 100644 --- a/libs/oauth-cli/src/log.rs +++ b/libs/oauth-cli/src/log.rs @@ -14,7 +14,16 @@ pub fn debug_log(enabled: bool, config_dir: &Path, event: &str, message: &str) { let line = format!("[{}] {} — {}\n", timestamp, event, message); // Best-effort: silently ignore write failures for debug logging - if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) { + let mut opts = OpenOptions::new(); + opts.create(true).append(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + + if let Ok(mut file) = opts.open(&log_path) { let _ = file.write_all(line.as_bytes()); } } From 1bcffda164c656f762983c8b905283d7db1fcd4b Mon Sep 17 00:00:00 2001 From: nicornk Date: Sat, 28 Mar 2026 20:11:56 +0100 Subject: [PATCH 12/12] fix scopes --- .github/workflows/ci.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/release.yml | 4 ++-- libs/oauth-cli/src/config.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8861122a..2194bb63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,4 +80,4 @@ jobs: - name: Run tests run: cargo test --locked - name: Audit dependencies - run: cargo install cargo-audit && cargo audit + run: cargo install cargo-audit --version 0.22.1 --locked && cargo audit diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d8546f83..6032013f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,7 +20,7 @@ jobs: pages: write id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: {fetch-depth: 0} # deep clone for git tag - uses: pdm-project/setup-pdm@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ee77419..7cc4b1ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: {fetch-depth: 0} # deep clone for git tag - uses: pdm-project/setup-pdm@v4 with: @@ -35,7 +35,7 @@ jobs: id-token: write environment: release steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - uses: pdm-project/setup-pdm@v4 with: python-version: "3.12" diff --git a/libs/oauth-cli/src/config.rs b/libs/oauth-cli/src/config.rs index ba2086e7..af114ece 100644 --- a/libs/oauth-cli/src/config.rs +++ b/libs/oauth-cli/src/config.rs @@ -2,7 +2,7 @@ use crate::error::{Error, Result}; use std::path::PathBuf; /// Default OAuth2 scopes requested when none are explicitly provided. -pub const DEFAULT_SCOPES: &[&str] = &["offline_access", "api:read-data"]; +pub const DEFAULT_SCOPES: &[&str] = &["offline_access", "api:use-language-models-execute", "api:language-models-execute"]; /// Resolved configuration for the CLI. #[derive(Debug, Clone)]