diff --git a/README.md b/README.md index 3f4d5fe2..e5169e5b 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ # iii workers Workers for the [iii engine](https://github.com/iii-hq/iii). Each directory is a -self-contained worker module: a process that connects to the engine over +self-contained worker: a process that connects to the engine over WebSocket, registers functions + triggers, and does something useful. Workers are discoverable through the [registry](registry/index.json) and — for binary-shipped workers — installed via `iii worker add `, which pulls the matching GitHub Release asset for the host's target triple. -## Modules +## Workers | Worker | Kind | Summary | |---|---|---| +| [`auth`](auth/) | Rust | OAuth authority under `auth::*`: RBAC validation, discovery, DCR, JWKS, token issuance. | | [`auth-credentials`](auth-credentials/) | Rust | Provider credential vault under `auth::*` — API keys and OAuth tokens. | | [`session`](session/) | Rust | Session storage under `session-tree::*` and per-session inbox under `session-inbox::*` (push, drain, peek). | | [`provider-router`](provider-router/) | Rust | `router::stream_assistant` provider router plus `router::abort` and `router::push_steering` / `push_followup` helpers. | @@ -49,11 +50,11 @@ cargo build --release ``` Node/Python workers follow the standard `npm install` / `pip install -e .` -flow — see each module's README for specifics. +flow — see each worker README for specifics. ## Binary releases -All Rust workers ship as standalone binaries — see the modules table above +All Rust workers ship as standalone binaries — see the workers table above — and are released via GitHub Actions: 1. Trigger the **Create Tag** workflow (Actions tab) — pick a worker, bump diff --git a/auth-credentials/Cargo.lock b/auth-credentials/Cargo.lock index 7c19d957..2eeffb90 100644 --- a/auth-credentials/Cargo.lock +++ b/auth-credentials/Cargo.lock @@ -91,7 +91,9 @@ dependencies = [ "anyhow", "async-trait", "clap", + "cucumber", "iii-sdk", + "schemars", "serde", "serde_json", "serde_yaml", @@ -127,12 +129,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.11.1" @@ -181,6 +199,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -207,6 +226,18 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-hex" version = "1.19.0" @@ -219,6 +250,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -244,6 +284,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -254,12 +319,92 @@ dependencies = [ "typenum", ] +[[package]] +name = "cucumber" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16cbb27bc2064274afa3a3d8bc9a0e71333589850573aa632ec4520e4af14d94" +dependencies = [ + "anyhow", + "clap", + "console", + "cucumber-codegen", + "cucumber-expressions", + "derive_more", + "either", + "futures", + "gherkin", + "globwalk", + "humantime", + "inventory", + "itertools", + "linked-hash-map", + "pin-project", + "ref-cast", + "regex", + "sealed", + "smart-default", +] + +[[package]] +name = "cucumber-codegen" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a1afaf9c422380861111c6be56f39b324e351fd9efc07a1486268798bf79cfd" +dependencies = [ + "cucumber-expressions", + "inflections", + "itertools", + "proc-macro2", + "quote", + "regex", + "syn", + "synthez", +] + +[[package]] +name = "cucumber-expressions" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6401038de3af44fe74e6fccdb8a5b7db7ba418f480c8e9ad584c6f65c05a27a6" +dependencies = [ + "derive_more", + "either", + "nom", + "nom_locate", + "regex", + "regex-syntax", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -293,6 +438,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -336,6 +487,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -343,6 +509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -362,6 +529,12 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.32" @@ -391,10 +564,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -449,6 +625,47 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gherkin" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70197ce7751bfe8bc828e3a855502d3a869a1e9416b58b10c4bde5cf8a0a3cb3" +dependencies = [ + "heck", + "peg", + "quote", + "serde", + "serde_json", + "syn", + "textwrap", + "thiserror", + "typed-builder", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + [[package]] name = "h2" version = "0.4.13" @@ -539,6 +756,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "1.9.0" @@ -721,6 +944,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "iii-sdk" version = "0.11.3" @@ -759,6 +998,21 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -826,6 +1080,18 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[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.2" @@ -879,6 +1145,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -1027,6 +1313,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "peg" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f76678828272f177ac33b7e2ac2e3e73cc6c1cd1e3e387928aa69562fa51367" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636d60acf97633e48d266d7415a9355d4389cea327a193f87df395d88cd2b14d" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1257,6 +1570,38 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1332,6 +1677,28 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[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.40" @@ -1391,6 +1758,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1430,6 +1806,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1586,6 +1973,23 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.3" @@ -1645,6 +2049,39 @@ dependencies = [ "syn", ] +[[package]] +name = "synthez" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8a928f38f1bc873f28e0d2ba8298ad65374a6ac2241dabd297271531a736cd" +dependencies = [ + "syn", + "synthez-codegen", + "synthez-core", +] + +[[package]] +name = "synthez-codegen" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb83b8df4238e11746984dfb3819b155cd270de0e25847f45abad56b3671047" +dependencies = [ + "syn", + "synthez-core", +] + +[[package]] +name = "synthez-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "906fba967105d822e7c7ed60477b5e76116724d33de68a585681fb253fc30d5c" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn", +] + [[package]] name = "sysinfo" version = "0.38.4" @@ -1659,6 +2096,27 @@ dependencies = [ "windows", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1963,6 +2421,26 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typenum" version = "1.20.0" @@ -1981,6 +2459,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2053,6 +2549,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2220,6 +2726,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/auth-credentials/Cargo.toml b/auth-credentials/Cargo.toml index a9b744f7..78f1c998 100644 --- a/auth-credentials/Cargo.toml +++ b/auth-credentials/Cargo.toml @@ -28,12 +28,18 @@ tokio = { version = "1", features = ["full"] } anyhow = "1" async-trait = "0.1" clap = { version = "4", features = ["derive", "env"] } +schemars = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } [dev-dependencies] +cucumber = "0.22" serde_json = "1" +[[test]] +name = "bdd" +harness = false + [lints.rust] unsafe_code = "forbid" diff --git a/auth-credentials/README.md b/auth-credentials/README.md index aef56676..9fc490f8 100644 --- a/auth-credentials/README.md +++ b/auth-credentials/README.md @@ -86,10 +86,31 @@ engine_url: "ws://127.0.0.1:49134" store: iii_state ``` +## Testing Against a Real Engine + +`store: iii_state` requires the engine to expose `state::get`, `state::set`, +`state::delete`, and `state::list`. For a minimal local engine, start only the +state worker: + +```bash +iii --config auth-credentials/tests/e2e/engine-state.yaml --no-update-check +``` + +Then run the live worker tests from another shell: + +```bash +IIITEST_ENGINE_URL=ws://127.0.0.1:49134 \ + cargo test --manifest-path auth-credentials/Cargo.toml --all-features + +IIITEST_ENGINE_URL=ws://127.0.0.1:49134 \ +IIITEST_WORKER_BIN="$PWD/auth-credentials/target/debug/auth-credentials" \ + cargo test --manifest-path auth-credentials/Cargo.toml --test restart_e2e -- --ignored +``` + ## Additional Resources -- [Removing a provider credential](skills/delete_token.md) -- [Reading a provider credential before an API call](skills/get_token.md) -- [Listing providers with stored credentials](skills/list_providers.md) -- [Storing a provider credential](skills/set_token.md) -- [Checking whether a credential is configured](skills/status.md) +- [Removing a provider credential](skills/auth/delete_token.md) +- [Reading a provider credential before an API call](skills/auth/get_token.md) +- [Listing providers with stored credentials](skills/auth/list_providers.md) +- [Storing a provider credential](skills/auth/set_token.md) +- [Checking whether a credential is configured](skills/auth/status.md) diff --git a/auth-credentials/docs/quickstart.md b/auth-credentials/docs/quickstart.md index 2fb2d273..3363921a 100644 --- a/auth-credentials/docs/quickstart.md +++ b/auth-credentials/docs/quickstart.md @@ -47,3 +47,24 @@ print(result) ``` The example calls `auth::get_token`. Other entry points: `auth::set_token`, `auth::delete_token`, `auth::list_providers`, and `auth::status`. + +## Testing Against a Real Engine + +`store: iii_state` requires the engine to expose `state::get`, `state::set`, +`state::delete`, and `state::list`. For a minimal local engine, start only the +state worker: + +```bash +iii --config auth-credentials/tests/e2e/engine-state.yaml --no-update-check +``` + +Then run the live worker tests from another shell: + +```bash +IIITEST_ENGINE_URL=ws://127.0.0.1:49134 \ + cargo test --manifest-path auth-credentials/Cargo.toml --all-features + +IIITEST_ENGINE_URL=ws://127.0.0.1:49134 \ +IIITEST_WORKER_BIN="$PWD/auth-credentials/target/debug/auth-credentials" \ + cargo test --manifest-path auth-credentials/Cargo.toml --test restart_e2e -- --ignored +``` diff --git a/auth-credentials/skill.md b/auth-credentials/skill.md deleted file mode 100644 index c5655c3d..00000000 --- a/auth-credentials/skill.md +++ /dev/null @@ -1,21 +0,0 @@ - - -# auth-credentials - -Provider credential vault on the iii bus. Store, fetch, and revoke API keys and OAuth tokens through `auth::*` so adapters and agents never read raw secrets. Reads fall through stored credentials to the process environment, so a setup with `ANTHROPIC_API_KEY` exported keeps working until a stored credential overrides it. - -Prefer `auth::status` over `auth::get_token` for pre-flight gating: `status` returns no token bytes, so it is safe to log, and the `source` field distinguishes a stored credential from an environment-variable fallback. - -For surfacing `auth::*` to LLM agents, pair with the [`skills`](../skills) worker: - -```bash -iii worker add skills -``` - -## Additional Resources - -- [Removing a provider credential](skills/delete_token.md) -- [Reading a provider credential before an API call](skills/get_token.md) -- [Listing providers with stored credentials](skills/list_providers.md) -- [Storing a provider credential](skills/set_token.md) -- [Checking whether a credential is configured](skills/status.md) diff --git a/auth-credentials/skills/auth/delete_token.md b/auth-credentials/skills/auth/delete_token.md new file mode 100644 index 00000000..cedd2873 --- /dev/null +++ b/auth-credentials/skills/auth/delete_token.md @@ -0,0 +1,67 @@ +--- +type: how-to +function_id: auth::delete_token +title: Remove a stored provider credential +--- + +# When to use + +Call `auth::delete_token` when the stored credential for a provider should stop being used. The operation is idempotent and only affects the stored backend, not process environment variables. + +Reach for it when: + +- A user revokes or disconnects a provider credential. +- OAuth refresh has failed repeatedly and the next call should force re-authentication. +- A test wrote a temporary credential and needs to clean it up. + +Use [`auth::status`](iii://auth-credentials/auth/status) after deletion when you need to know whether an environment fallback still keeps the provider configured. + +# Inputs + +```json +{ + "provider": "anthropic" // required, non-empty provider id +} +``` + +`provider` identifies the stored credential record to remove. + +# Outputs + +```json +{ + "ok": true // true even when no stored record existed +} +``` + +- Deleting a missing stored credential is not an error. +- Environment fallbacks are not removed; unset the environment variable outside this worker if fallback should disappear too. + +# Side effects + +Deletes one stored backend record: + +```json +{ + "scope": "auth_credentials", + "key": "credential:anthropic" +} +``` + +With the `memory` backend, the provider entry is removed from the current process map. + +# Worked example + +Remove a stored Anthropic credential: + +```json +{ + "provider": "anthropic" +} +``` + +# Related + +- `auth::get_token` — confirm whether an environment fallback still resolves. +- `auth::status` — check the active credential source after deletion. +- `auth::set_token` — store a replacement credential. diff --git a/auth-credentials/skills/auth/get_token.md b/auth-credentials/skills/auth/get_token.md new file mode 100644 index 00000000..56ac7022 --- /dev/null +++ b/auth-credentials/skills/auth/get_token.md @@ -0,0 +1,58 @@ +--- +type: how-to +function_id: auth::get_token +title: Read a provider credential +--- + +# When to use + +Call `auth::get_token` when a provider adapter is about to make an API request and needs the credential it should send to the external provider. Resolution checks stored credentials first, then the matching process environment variable. + +Reach for it when: + +- `provider-anthropic`, `provider-openai`, or another adapter needs credentials immediately before an API call. +- A worker wants the same stored-then-environment fallback behavior as every other provider adapter. +- A setup still relies on variables such as `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`. + +Use [`auth::status`](iii://auth-credentials/auth/status) instead for health checks, diagnostics, or any path that may log the response. + +# Inputs + +```json +{ + "provider": "anthropic" // required, non-empty provider id +} +``` + +`provider` is matched against stored records first. If no stored record exists, known provider ids fall back to their mapped environment variable. + +# Outputs + +```json +{ + "type": "api_key", // "api_key" or "oauth" + "key": "sk-ant-..." // present for type "api_key" +} +``` + +- Returns `null` when neither a stored credential nor an environment fallback exists. +- Stored credentials have precedence over environment variables. +- OAuth credentials return `access_token`, optional `refresh_token`, optional `expires_at`, `scopes`, and `provider_extra`. + +# Worked example + +Read the Anthropic credential before calling the provider: + +```json +{ + "provider": "anthropic" +} +``` + +If `auth::set_token` stored a credential for `anthropic`, that credential is returned. Otherwise, the worker checks `ANTHROPIC_API_KEY` and returns an `api_key` credential when the variable is present and non-empty. + +# Related + +- `auth::set_token` — store or rotate the credential this function reads. +- `auth::status` — inspect whether resolution will succeed without returning the secret. +- `auth::delete_token` — remove the stored credential so environment fallback can take over. diff --git a/auth-credentials/skills/auth/list_providers.md b/auth-credentials/skills/auth/list_providers.md new file mode 100644 index 00000000..0f03f29b --- /dev/null +++ b/auth-credentials/skills/auth/list_providers.md @@ -0,0 +1,54 @@ +--- +type: how-to +function_id: auth::list_providers +title: List providers with stored credentials +--- + +# When to use + +Call `auth::list_providers` when a caller needs to enumerate which providers have credentials stored in the auth backend without exposing credential bytes. + +Reach for it when: + +- Building a settings surface that shows connected providers. +- Auditing stored credentials without reading secrets. +- Deciding which provider-specific status checks to run next. + +Use [`auth::status`](iii://auth-credentials/auth/status) instead when environment-variable fallback providers must be included. + +# Inputs + +```json +{} +``` + +The function accepts an empty object. Any provider filtering should happen client-side after this call. + +# Outputs + +```json +{ + "providers": [ + "anthropic", + "openai" + ] +} +``` + +- Returns stored provider names only; environment-variable fallbacks do not appear. +- Provider names are sorted lexicographically and duplicate names are removed. +- Token bytes are never included in the response. + +# Worked example + +List providers with stored credentials: + +```json +{} +``` + +# Related + +- `auth::status` — include stored and environment-backed configuration for one provider. +- `auth::get_token` — read the credential for a provider after selecting it. +- `auth::delete_token` — remove a provider returned by this list. diff --git a/auth-credentials/skills/auth/set_token.md b/auth-credentials/skills/auth/set_token.md new file mode 100644 index 00000000..9af25cb2 --- /dev/null +++ b/auth-credentials/skills/auth/set_token.md @@ -0,0 +1,96 @@ +--- +type: how-to +function_id: auth::set_token +title: Store a provider credential +--- + +# When to use + +Call `auth::set_token` when a provider credential should become the stored credential of record for later `auth::*` reads. The write replaces any existing stored credential for the same provider. + +Reach for it when: + +- A user supplies an API key during setup. +- An OAuth worker returns a fresh access token that downstream provider adapters need to read. +- A credential is rotated after a security incident. + +Use [`auth::status`](iii://auth-credentials/auth/status) instead when you only need to know whether a provider is configured. + +# Inputs + +```json +{ + "provider": "anthropic", // required, non-empty provider id + "credential": { + "type": "api_key", // required, "api_key" or "oauth" + "key": "sk-ant-..." // required for type "api_key" + } +} +``` + +For OAuth credentials, `credential` uses `type: "oauth"` with `access_token`, optional `refresh_token`, optional `expires_at`, optional `scopes`, and optional `provider_extra`. + +# Outputs + +```json +{ + "ok": true // true when the credential was stored +} +``` + +- Validation fails when `provider` is empty or only whitespace. +- Writes overwrite the whole stored credential for the provider; there is no merge. + +# Side effects + +Persists one credential record in the configured backend: + +```json +{ + "provider": "anthropic", + "credential": { + "type": "api_key", + "key": "sk-ant-..." + } +} +``` + +With the default `iii_state` backend, the record is written under scope `auth_credentials` and key `credential:`. With the `memory` backend, the record only lives for the current worker process. + +# Worked example + +Store an Anthropic API key: + +```json +{ + "provider": "anthropic", + "credential": { + "type": "api_key", + "key": "sk-ant-redacted" + } +} +``` + +Store an OAuth credential: + +```json +{ + "provider": "anthropic", + "credential": { + "type": "oauth", + "access_token": "access-token-redacted", + "refresh_token": "refresh-token-redacted", + "expires_at": 1893456000, + "scopes": ["messages:write"], + "provider_extra": { + "tenant": "workspace-a" + } + } +} +``` + +# Related + +- `auth::get_token` — read the credential after it is stored. +- `auth::status` — verify configuration without exposing token bytes. +- `auth::delete_token` — remove a stored credential. diff --git a/auth-credentials/skills/auth/status.md b/auth-credentials/skills/auth/status.md new file mode 100644 index 00000000..f473bbbc --- /dev/null +++ b/auth-credentials/skills/auth/status.md @@ -0,0 +1,57 @@ +--- +type: how-to +function_id: auth::status +title: Check provider credential status +--- + +# When to use + +Call `auth::status` when a caller needs to know whether a provider is configured without returning the credential itself. It uses the same stored-then-environment resolution order as `auth::get_token`. + +Reach for it when: + +- A provider adapter wants to short-circuit before making an API request. +- A health endpoint needs to expose "auth configured" safely. +- Diagnostics need to distinguish stored credentials from environment fallbacks. + +Use [`auth::get_token`](iii://auth-credentials/auth/get_token) instead only on the execution path that will immediately use the credential. + +# Inputs + +```json +{ + "provider": "anthropic" // required, non-empty provider id +} +``` + +`provider` is checked against stored records first, then against the known environment-variable map. + +# Outputs + +```json +{ + "configured": true, // true when stored or environment credential exists + "source": "stored", // "stored" or "environment"; omitted when unconfigured + "label": "api-key:sk-ant…" // redacted display hint; omitted when unconfigured +} +``` + +- `source` is omitted when `configured` is false. +- `label` never contains the full credential. API key labels include only the first six characters. +- OAuth credentials use the label `"oauth"`. + +# Worked example + +Check whether Anthropic auth is configured: + +```json +{ + "provider": "anthropic" +} +``` + +# Related + +- `auth::get_token` — read the credential only when a provider call needs it. +- `auth::set_token` — store a credential when status is unconfigured. +- `auth::delete_token` — remove the stored source reported by status. diff --git a/auth-credentials/skills/delete_token.md b/auth-credentials/skills/delete_token.md deleted file mode 100644 index 64d1299f..00000000 --- a/auth-credentials/skills/delete_token.md +++ /dev/null @@ -1,14 +0,0 @@ - - -# Removing a provider credential - -## When to use - -- A user revokes their API key. -- An OAuth refresh has been failing repeatedly and the credential should be cleared so the next call forces re-authentication. -- Cleaning up after a test run that wrote a temporary credential. - -## Notes - -- Deletes are idempotent: removing a credential that was never stored is not an error. -- Only the stored credential is removed; an environment fallback (e.g. `ANTHROPIC_API_KEY`) still resolves on the next `auth::get_token` call until it is unset in the shell. diff --git a/auth-credentials/skills/get_token.md b/auth-credentials/skills/get_token.md deleted file mode 100644 index cb726df2..00000000 --- a/auth-credentials/skills/get_token.md +++ /dev/null @@ -1,14 +0,0 @@ - - -# Reading a provider credential before an API call - -## When to use - -- A provider adapter (e.g. `provider-anthropic`) is about to make an API call and needs the current credential. -- Pre-flight check before issuing a request that requires a credential. - -## Notes - -- Resolution order is: stored credential → matching environment variable → null. Callers never need to read the environment directly. -- A `null` return means neither a stored credential nor an env fallback exists. To distinguish from "credential exists but is empty", call `auth::status`. -- Bus errors surface as `Err`; treat them as transient (engine restart, IPC hiccup) and retry per caller policy. diff --git a/auth-credentials/skills/index.md b/auth-credentials/skills/index.md new file mode 100644 index 00000000..d8a306ce --- /dev/null +++ b/auth-credentials/skills/index.md @@ -0,0 +1,22 @@ +--- +type: index +title: auth-credentials +--- + +# auth-credentials + +Provider credential vault on the iii bus. Use it to store, read, inspect, and revoke provider API keys or OAuth tokens through `auth::*` so provider adapters and agents do not read raw secrets from their own configuration paths. + +- **Credentials** (`auth::*`) — durable credential reads and writes for providers such as `anthropic`, `openai`, and `google`. + +Prefer `auth::status` over `auth::get_token` for pre-flight gating. `auth::status` returns no token bytes, so it is safe to log, and the `source` field distinguishes a stored credential from an environment-variable fallback. + +## How-tos + +### `auth::*` + +- [`auth::set_token`](iii://auth-credentials/auth/set_token) — store or rotate an API key or OAuth credential. +- [`auth::get_token`](iii://auth-credentials/auth/get_token) — read the credential a provider adapter should use for an API call. +- [`auth::delete_token`](iii://auth-credentials/auth/delete_token) — remove a stored credential without touching environment fallbacks. +- [`auth::list_providers`](iii://auth-credentials/auth/list_providers) — list provider names that have stored credentials. +- [`auth::status`](iii://auth-credentials/auth/status) — check whether a provider is configured without returning secret bytes. diff --git a/auth-credentials/skills/list_providers.md b/auth-credentials/skills/list_providers.md deleted file mode 100644 index b7fc11c5..00000000 --- a/auth-credentials/skills/list_providers.md +++ /dev/null @@ -1,14 +0,0 @@ - - -# Listing providers with stored credentials - -## When to use - -- Building a settings surface that shows which providers a workspace has authenticated with. -- Auditing connected providers without revealing token bytes. -- Pre-flight: detecting which providers are usable without trying each in turn. - -## Notes - -- Returns provider names only. Token bytes are never exposed by this function. -- The list reflects stored credentials only. Providers backed by an environment variable do not appear here; use `auth::status` to detect those. diff --git a/auth-credentials/skills/set_token.md b/auth-credentials/skills/set_token.md deleted file mode 100644 index 31aa2f0a..00000000 --- a/auth-credentials/skills/set_token.md +++ /dev/null @@ -1,15 +0,0 @@ - - -# Storing a provider credential - -## When to use - -- A user just supplied an API key during setup. -- An OAuth flow (e.g. `oauth-anthropic`) returned a fresh access token that downstream adapters need to read. -- Rotating a credential after a security incident. - -## Notes - -- Writes overwrite any existing credential for the provider; there is no append or merge. -- The worker stores the serialised credential bytes verbatim. Token shape is the caller's responsibility; nothing is verified at write time. -- Default backend is `iii_state` (durable). Set `store: memory` in `config.yaml` or `AUTH_CREDENTIALS_STORE=memory` for ephemeral storage in tests. diff --git a/auth-credentials/skills/status.md b/auth-credentials/skills/status.md deleted file mode 100644 index a6e3dae2..00000000 --- a/auth-credentials/skills/status.md +++ /dev/null @@ -1,15 +0,0 @@ - - -# Checking whether a credential is configured - -## When to use - -- A provider adapter wants to short-circuit a request when no credential is configured, instead of getting `null` from `auth::get_token`. -- A health endpoint exposes "auth configured" without revealing the token. -- Diagnostics: confirming which source (stored or environment) is active for a given provider. - -## Notes - -- The response `source` field is `"stored"` or `"environment"` and is omitted when no credential is configured. -- The optional `label` is a redacted hint (e.g. `"api-key:sk-ant-…"`, `"oauth"`) suitable for display in a UI without leaking the full credential. -- Unlike `auth::get_token`, this function never returns the credential bytes, so it is safe to call from logging or diagnostic paths. diff --git a/auth-credentials/src/lib.rs b/auth-credentials/src/lib.rs index 60643a00..d539bb35 100644 --- a/auth-credentials/src/lib.rs +++ b/auth-credentials/src/lib.rs @@ -5,28 +5,28 @@ //! the in-memory backend is provided for tests. pub const SKILL_ID: &str = "auth-credentials"; -pub const SKILL_MD: &str = include_str!("../skill.md"); +pub const SKILL_MD: &str = include_str!("../skills/index.md"); pub const SUB_SKILLS: &[(&str, &str)] = &[ ( - "auth-credentials/set_token", - include_str!("../skills/set_token.md"), + "auth-credentials/auth/set_token", + include_str!("../skills/auth/set_token.md"), ), ( - "auth-credentials/get_token", - include_str!("../skills/get_token.md"), + "auth-credentials/auth/get_token", + include_str!("../skills/auth/get_token.md"), ), ( - "auth-credentials/delete_token", - include_str!("../skills/delete_token.md"), + "auth-credentials/auth/delete_token", + include_str!("../skills/auth/delete_token.md"), ), ( - "auth-credentials/list_providers", - include_str!("../skills/list_providers.md"), + "auth-credentials/auth/list_providers", + include_str!("../skills/auth/list_providers.md"), ), ( - "auth-credentials/status", - include_str!("../skills/status.md"), + "auth-credentials/auth/status", + include_str!("../skills/auth/status.md"), ), ]; @@ -37,35 +37,45 @@ use std::collections::HashMap; use std::sync::{Arc, RwLock}; use async_trait::async_trait; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Stored credential for a provider. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Credential { ApiKey { + /// Raw API key bytes for the provider. key: String, }, + #[serde(rename = "oauth")] OAuth { + /// Bearer access token returned by the provider OAuth flow. access_token: String, + /// Optional refresh token when the provider issues one. #[serde(default, skip_serializing_if = "Option::is_none")] refresh_token: Option, + /// Optional Unix timestamp when the access token expires. #[serde(default, skip_serializing_if = "Option::is_none")] expires_at: Option, + /// OAuth scopes granted to the token. #[serde(default)] scopes: Vec, + /// Provider-specific OAuth metadata. #[serde(default)] provider_extra: serde_json::Value, }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] pub enum CredentialType { ApiKey, + #[serde(rename = "oauth")] OAuth, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AuthSource { Stored, @@ -75,29 +85,59 @@ pub enum AuthSource { } /// Status of a provider's credential resolution. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct AuthStatus { + /// True when the worker can resolve a credential for the provider. pub configured: bool, + /// Source that satisfied resolution. Omitted when no credential exists. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, + /// Redacted display label. Never contains the full credential. #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct ConfiguredProvider { pub provider: String, pub credential_type: CredentialType, pub label: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct EnvKeyMatch { pub provider: String, pub env_var: String, pub key_prefix: String, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +pub struct ProviderInput { + /// Provider identifier, for example "anthropic" or "openai". + pub provider: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +pub struct SetTokenInput { + /// Provider identifier, for example "anthropic" or "openai". + pub provider: String, + /// Credential payload to persist for the provider. + pub credential: Credential, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct OkOutput { + pub ok: bool, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +pub struct ListProvidersInput {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct ListProvidersOutput { + pub providers: Vec, +} + /// Storage backend abstraction. Production impl writes to iii state; the /// in-memory impl is provided here for tests. #[async_trait] @@ -259,6 +299,70 @@ fn label_for(cred: &Credential) -> String { } } +fn normalize_provider(provider: &str) -> anyhow::Result { + let provider = provider.trim(); + if provider.is_empty() { + return Err(anyhow::anyhow!("provider must be non-empty")); + } + Ok(provider.to_string()) +} + +pub async fn handle_get_token( + store: &dyn CredentialStore, + input: ProviderInput, + getter: F, +) -> anyhow::Result> +where + F: Fn(&str) -> Option, +{ + let provider = normalize_provider(&input.provider)?; + Ok(resolve_credential(store, &provider, getter) + .await? + .map(|(credential, _source)| credential)) +} + +pub async fn handle_set_token( + store: &dyn CredentialStore, + input: SetTokenInput, +) -> anyhow::Result { + let provider = normalize_provider(&input.provider)?; + store.set(&provider, input.credential).await?; + Ok(OkOutput { ok: true }) +} + +pub async fn handle_delete_token( + store: &dyn CredentialStore, + input: ProviderInput, +) -> anyhow::Result { + let provider = normalize_provider(&input.provider)?; + store.clear(&provider).await?; + Ok(OkOutput { ok: true }) +} + +pub async fn handle_list_providers( + store: &dyn CredentialStore, + _input: ListProvidersInput, +) -> anyhow::Result { + let entries = store.list().await?; + let mut providers: Vec = entries.into_iter().map(|(provider, _)| provider).collect(); + providers.sort(); + providers.dedup(); + Ok(ListProvidersOutput { providers }) +} + +pub async fn handle_status( + store: &dyn CredentialStore, + input: ProviderInput, + getter: F, +) -> anyhow::Result +where + F: Fn(&str) -> Option, +{ + let provider = normalize_provider(&input.provider)?; + let resolved = resolve_credential(store, &provider, getter).await?; + Ok(status_for(resolved.as_ref())) +} + /// Register `auth::*` iii functions on the bus. /// /// Functions registered: @@ -277,124 +381,72 @@ pub async fn register_with_iii( iii: &iii_sdk::III, store: std::sync::Arc, ) -> anyhow::Result { - use iii_sdk::{IIIError, RegisterFunctionMessage}; - use serde_json::{json, Value}; + use iii_sdk::{IIIError, RegisterFunction}; let store_get = store.clone(); - let get_token = iii.register_function(( - RegisterFunctionMessage::with_id("auth::get_token".to_string()) - .with_description("Fetch the stored credential for a provider.".into()), - move |payload: Value| { + let get_token = iii.register_function( + RegisterFunction::new_async("auth::get_token", move |input: ProviderInput| { let store = store_get.clone(); async move { - let provider = payload - .get("provider") - .and_then(Value::as_str) - .ok_or_else(|| IIIError::Handler("missing required field: provider".into()))? - .to_string(); - // P5: providers call `auth::get_token` as their single - // credential entry point. Resolve stored-then-env so callers - // never re-read env directly. Returning `null` means the - // provider has neither a stored credential nor an env match. - let resolved = - resolve_credential(&*store, &provider, |var| std::env::var(var).ok()) - .await - .map_err(|e| { - IIIError::Handler(format!("resolve_credential failed: {e}")) - })?; - let cred = resolved.map(|(c, _source)| c); - serde_json::to_value(cred).map_err(|e| IIIError::Handler(e.to_string())) + handle_get_token(&*store, input, |var| std::env::var(var).ok()) + .await + .map_err(|e| IIIError::Handler(e.to_string())) } - }, - )); + }) + .description("Fetch the stored or environment credential for a provider."), + ); let store_set = store.clone(); - let set_token = iii.register_function(( - RegisterFunctionMessage::with_id("auth::set_token".to_string()) - .with_description("Persist a credential for a provider.".into()), - move |payload: Value| { + let set_token = iii.register_function( + RegisterFunction::new_async("auth::set_token", move |input: SetTokenInput| { let store = store_set.clone(); async move { - let provider = payload - .get("provider") - .and_then(Value::as_str) - .ok_or_else(|| IIIError::Handler("missing required field: provider".into()))? - .to_string(); - let cred_value = payload.get("credential").cloned().ok_or_else(|| { - IIIError::Handler("missing required field: credential".into()) - })?; - let cred: Credential = serde_json::from_value(cred_value) - .map_err(|e| IIIError::Handler(format!("invalid credential: {e}")))?; - store - .set(&provider, cred) + handle_set_token(&*store, input) .await - .map_err(|e| IIIError::Handler(format!("store.set failed: {e}")))?; - Ok(json!({ "ok": true })) + .map_err(|e| IIIError::Handler(e.to_string())) } - }, - )); + }) + .description("Persist a credential for a provider."), + ); let store_del = store.clone(); - let delete_token = iii.register_function(( - RegisterFunctionMessage::with_id("auth::delete_token".to_string()) - .with_description("Remove the stored credential for a provider.".into()), - move |payload: Value| { + let delete_token = iii.register_function( + RegisterFunction::new_async("auth::delete_token", move |input: ProviderInput| { let store = store_del.clone(); async move { - let provider = payload - .get("provider") - .and_then(Value::as_str) - .ok_or_else(|| IIIError::Handler("missing required field: provider".into()))? - .to_string(); - store - .clear(&provider) + handle_delete_token(&*store, input) .await - .map_err(|e| IIIError::Handler(format!("store.clear failed: {e}")))?; - Ok(json!({ "ok": true })) + .map_err(|e| IIIError::Handler(e.to_string())) } - }, - )); + }) + .description("Remove the stored credential for a provider."), + ); let store_list = store.clone(); - let list_providers = iii.register_function(( - RegisterFunctionMessage::with_id("auth::list_providers".to_string()) - .with_description("List every provider with a stored credential.".into()), - move |_payload: Value| { + let list_providers = iii.register_function( + RegisterFunction::new_async("auth::list_providers", move |input: ListProvidersInput| { let store = store_list.clone(); async move { - let entries = store - .list() + handle_list_providers(&*store, input) .await - .map_err(|e| IIIError::Handler(format!("store.list failed: {e}")))?; - let providers: Vec = entries.into_iter().map(|(p, _)| p).collect(); - Ok(json!({ "providers": providers })) + .map_err(|e| IIIError::Handler(e.to_string())) } - }, - )); + }) + .description("List every provider with a stored credential."), + ); let store_status = store.clone(); - let status = iii.register_function(( - RegisterFunctionMessage::with_id("auth::status".to_string()) - .with_description("Report stored vs. env credential status for a provider.".into()), - move |payload: Value| { + let status = iii.register_function( + RegisterFunction::new_async("auth::status", move |input: ProviderInput| { let store = store_status.clone(); async move { - let provider = payload - .get("provider") - .and_then(Value::as_str) - .ok_or_else(|| IIIError::Handler("missing required field: provider".into()))? - .to_string(); - let resolved = - resolve_credential(&*store, &provider, |var| std::env::var(var).ok()) - .await - .map_err(|e| { - IIIError::Handler(format!("resolve_credential failed: {e}")) - })?; - let st = status_for(resolved.as_ref()); - serde_json::to_value(st).map_err(|e| IIIError::Handler(e.to_string())) + handle_status(&*store, input, |var| std::env::var(var).ok()) + .await + .map_err(|e| IIIError::Handler(e.to_string())) } - }, - )); + }) + .description("Report stored vs. environment credential status for a provider."), + ); Ok(AuthFunctionRefs { get_token, @@ -548,4 +600,39 @@ mod tests { assert!(providers.contains(&&"openai")); assert!(providers.contains(&&"google")); } + + #[tokio::test] + async fn list_provider_handler_returns_sorted_names_only() -> anyhow::Result<()> { + let s = InMemoryStore::new(); + s.set("openai", Credential::ApiKey { key: "b".into() }) + .await?; + s.set("anthropic", Credential::ApiKey { key: "a".into() }) + .await?; + + let out = handle_list_providers(&s, ListProvidersInput {}).await?; + assert_eq!(out.providers, vec!["anthropic", "openai"]); + Ok(()) + } + + #[tokio::test] + async fn provider_handlers_reject_blank_provider() { + let s = InMemoryStore::new(); + let err = handle_get_token( + &s, + ProviderInput { + provider: " ".into(), + }, + |_| None, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("provider must be non-empty")); + } + + #[test] + fn auth_source_serializes_snake_case() -> anyhow::Result<()> { + let value = serde_json::to_value(AuthSource::Environment)?; + assert_eq!(value, serde_json::json!("environment")); + Ok(()) + } } diff --git a/auth-credentials/src/store_iii_state.rs b/auth-credentials/src/store_iii_state.rs index dc228d40..d2cc3e89 100644 --- a/auth-credentials/src/store_iii_state.rs +++ b/auth-credentials/src/store_iii_state.rs @@ -188,6 +188,27 @@ mod tests { Ok(()) } + #[tokio::test] + async fn get_returns_err_on_missing_credential_field() { + let mock = Arc::new(MockTrigger::new(vec![Ok( + json!({ "provider": "anthropic" }), + )])); + let store = IiiStateCredentialStore::new(mock); + let err = store.get("anthropic").await.unwrap_err(); + assert!(err.to_string().contains("missing `credential` field")); + } + + #[tokio::test] + async fn get_returns_err_on_invalid_credential_payload() { + let mock = Arc::new(MockTrigger::new(vec![Ok(json!({ + "provider": "anthropic", + "credential": { "type": "api_key" } + }))])); + let store = IiiStateCredentialStore::new(mock); + let err = store.get("anthropic").await.unwrap_err(); + assert!(err.to_string().contains("deserialize credential")); + } + #[tokio::test] async fn get_returns_err_on_trigger_failure() { let mock = Arc::new(MockTrigger::new(vec![Err(IIIError::Handler( @@ -286,6 +307,44 @@ mod tests { Ok(()) } + #[tokio::test] + async fn list_returns_err_on_non_array_response() { + let mock = Arc::new(MockTrigger::new(vec![Ok(json!({ "not": "an array" }))])); + let store = IiiStateCredentialStore::new(mock); + let err = store.list().await.unwrap_err(); + assert!(err.to_string().contains("state::list returned non-array")); + } + + #[tokio::test] + async fn list_returns_err_on_missing_provider_field() { + let mock = Arc::new(MockTrigger::new(vec![Ok(json!([ + { "credential": { "type": "api_key", "key": "sk-test" } } + ]))])); + let store = IiiStateCredentialStore::new(mock); + let err = store.list().await.unwrap_err(); + assert!(err.to_string().contains("missing `provider`")); + } + + #[tokio::test] + async fn list_returns_err_on_missing_credential_field() { + let mock = Arc::new(MockTrigger::new(vec![Ok(json!([ + { "provider": "anthropic" } + ]))])); + let store = IiiStateCredentialStore::new(mock); + let err = store.list().await.unwrap_err(); + assert!(err.to_string().contains("missing `credential`")); + } + + #[tokio::test] + async fn list_returns_err_on_invalid_credential_payload() { + let mock = Arc::new(MockTrigger::new(vec![Ok(json!([ + { "provider": "anthropic", "credential": { "type": "api_key" } } + ]))])); + let store = IiiStateCredentialStore::new(mock); + let err = store.list().await.unwrap_err(); + assert!(err.to_string().contains("deserialize credential")); + } + #[tokio::test] async fn list_returns_empty_on_empty_scope() -> anyhow::Result<()> { let mock = Arc::new(MockTrigger::new(vec![Ok(json!([]))])); diff --git a/auth-credentials/tests/bdd.rs b/auth-credentials/tests/bdd.rs new file mode 100644 index 00000000..39ae9772 --- /dev/null +++ b/auth-credentials/tests/bdd.rs @@ -0,0 +1,364 @@ +mod steps { + use cucumber::{given, then, when}; + use serde_json::Value; + use serde_yaml::Value as YamlValue; + + use crate::AuthWorld; + + #[given("an empty auth credential store")] + fn empty_store(world: &mut AuthWorld) { + *world = AuthWorld::new(); + } + + #[given(regex = r#"^environment variable "([^"]+)" is "([^"]*)"$"#)] + fn env_var(world: &mut AuthWorld, name: String, value: String) { + world.env.insert(name, value); + } + + #[when(regex = r"^I call (auth::[a-z_]+) with payload:$")] + async fn call_auth(world: &mut AuthWorld, function_id: String, step: &cucumber::gherkin::Step) { + let payload = parse_payload(step); + world.last_ok = None; + world.last_err = None; + match dispatch(world, &function_id, payload).await { + Ok(value) => world.last_ok = Some(value), + Err(err) => world.last_err = Some(err), + } + } + + #[then(regex = r#"^the auth credential response has api key "([^"]+)"$"#)] + fn credential_has_key(world: &mut AuthWorld, expected: String) { + let value = last_ok(world); + assert_eq!(value["type"].as_str(), Some("api_key")); + assert_eq!(value["key"].as_str(), Some(expected.as_str())); + } + + #[then(regex = r#"^the auth OAuth response has access token "([^"]+)"$"#)] + fn oauth_has_access_token(world: &mut AuthWorld, expected: String) { + let value = last_ok(world); + assert_eq!(value["type"].as_str(), Some("oauth")); + assert_eq!(value["access_token"].as_str(), Some(expected.as_str())); + } + + #[then(regex = r#"^the auth OAuth response has refresh token "([^"]+)"$"#)] + fn oauth_has_refresh_token(world: &mut AuthWorld, expected: String) { + let value = last_ok(world); + assert_eq!(value["type"].as_str(), Some("oauth")); + assert_eq!(value["refresh_token"].as_str(), Some(expected.as_str())); + } + + #[then(regex = r#"^the auth OAuth response has scopes "([^"]*)"$"#)] + fn oauth_has_scopes(world: &mut AuthWorld, csv: String) { + let expected: Vec<&str> = csv.split(',').filter(|s| !s.is_empty()).collect(); + let got: Vec<&str> = last_ok(world)["scopes"] + .as_array() + .expect("scopes is an array") + .iter() + .map(|v| v.as_str().expect("scope is a string")) + .collect(); + assert_eq!(got, expected); + } + + #[then(regex = r#"^the auth status source is "([^"]+)"$"#)] + fn status_source(world: &mut AuthWorld, expected: String) { + let value = last_ok(world); + assert_eq!(value["configured"].as_bool(), Some(true)); + assert_eq!(value["source"].as_str(), Some(expected.as_str())); + } + + #[then("the auth status is unconfigured")] + fn status_unconfigured(world: &mut AuthWorld) { + let value = last_ok(world); + assert_eq!(value["configured"].as_bool(), Some(false)); + assert!(value.get("source").is_none()); + assert!(value.get("label").is_none()); + } + + #[then(regex = r#"^the auth status label is "([^"]+)"$"#)] + fn status_label(world: &mut AuthWorld, expected: String) { + assert_eq!(last_ok(world)["label"].as_str(), Some(expected.as_str())); + } + + #[then(regex = r#"^the auth status label starts with "([^"]+)"$"#)] + fn status_label_starts_with(world: &mut AuthWorld, expected: String) { + let label = last_ok(world)["label"] + .as_str() + .expect("status label is a string"); + assert!( + label.starts_with(&expected), + "expected label {label:?} to start with {expected:?}" + ); + } + + #[then(regex = r#"^the auth provider list is "([^"]*)"$"#)] + fn provider_list(world: &mut AuthWorld, csv: String) { + let expected: Vec<&str> = csv.split(',').filter(|s| !s.is_empty()).collect(); + let got: Vec<&str> = last_ok(world)["providers"] + .as_array() + .expect("providers is an array") + .iter() + .map(|v| v.as_str().expect("provider is a string")) + .collect(); + assert_eq!(got, expected); + } + + #[then(regex = r#"^the auth response does not contain "([^"]+)"$"#)] + fn response_does_not_contain(world: &mut AuthWorld, needle: String) { + let rendered = serde_json::to_string(last_ok(world)).expect("render last response"); + assert!( + !rendered.contains(&needle), + "response leaked {needle:?}: {rendered}" + ); + } + + #[then("the auth response is null")] + fn response_is_null(world: &mut AuthWorld) { + assert!( + last_ok(world).is_null(), + "expected null, got {:?}", + last_ok(world) + ); + } + + #[then(regex = r#"^the auth call fails with a message mentioning "([^"]+)"$"#)] + fn call_fails(world: &mut AuthWorld, needle: String) { + let err = world.last_err.as_deref().unwrap_or(""); + assert!( + err.contains(&needle), + "expected error to mention {needle:?}; got {err:?}; success: {:?}", + world.last_ok + ); + } + + #[then(regex = r#"^the auth skill index has type "([^"]+)" and title "([^"]+)"$"#)] + fn skill_index_frontmatter( + _world: &mut AuthWorld, + expected_type: String, + expected_title: String, + ) { + let (frontmatter, markdown) = split_frontmatter("index", auth_credentials::SKILL_MD); + assert_eq!( + frontmatter_str("index", &frontmatter, "type"), + expected_type + ); + assert_eq!( + frontmatter_str("index", &frontmatter, "title"), + expected_title + ); + assert!( + markdown.contains("## How-tos"), + "index skill should have a How-tos section" + ); + } + + #[then("the auth skill index links to every auth how-to")] + fn skill_index_links_every_how_to(_world: &mut AuthWorld) { + let (_frontmatter, markdown) = split_frontmatter("index", auth_credentials::SKILL_MD); + for (id, _) in auth_credentials::SUB_SKILLS { + let uri = format!("iii://{id}"); + assert!(markdown.contains(&uri), "index missing URI {uri}"); + } + } + + #[then("every auth how-to path mirrors its function id")] + fn every_how_to_path_mirrors_function_id(_world: &mut AuthWorld) { + let prefix = format!("{}/", auth_credentials::SKILL_ID); + for (id, body) in auth_credentials::SUB_SKILLS { + let (frontmatter, _markdown) = split_frontmatter(id, body); + assert_eq!(frontmatter_str(id, &frontmatter, "type"), "how-to"); + let function_id = frontmatter_str(id, &frontmatter, "function_id"); + let actual_path = id.strip_prefix(&prefix).unwrap_or(id); + assert_eq!( + actual_path, + function_id.replace("::", "/"), + "{id}: path must mirror function namespace" + ); + } + } + + #[then("every auth how-to has required sections in order")] + fn every_how_to_has_ordered_sections(_world: &mut AuthWorld) { + for (id, body) in auth_credentials::SUB_SKILLS { + let (_frontmatter, markdown) = split_frontmatter(id, body); + let when = section_position(id, &markdown, "# When to use"); + let inputs = section_position(id, &markdown, "# Inputs"); + let outputs = section_position(id, &markdown, "# Outputs"); + let worked = section_position(id, &markdown, "# Worked example"); + let related = section_position(id, &markdown, "# Related"); + assert!( + when < inputs && inputs < outputs && outputs < worked && worked < related, + "{id}: sections are out of order" + ); + } + } + + #[then("every auth how-to JSON example parses")] + fn every_how_to_json_example_parses(_world: &mut AuthWorld) { + for (id, body) in auth_credentials::SUB_SKILLS { + let (_frontmatter, markdown) = split_frontmatter(id, body); + let blocks = json_blocks(&markdown); + assert!(!blocks.is_empty(), "{id}: expected at least one JSON block"); + for block in blocks { + let stripped = strip_json_comments(&block); + serde_json::from_str::(&stripped) + .unwrap_or_else(|err| panic!("{id}: invalid JSON example: {err}")); + } + } + } + + #[then("auth write how-tos document side effects")] + fn auth_write_how_tos_document_side_effects(_world: &mut AuthWorld) { + for (id, body) in auth_credentials::SUB_SKILLS { + let needs_side_effects = id.ends_with("/set_token") || id.ends_with("/delete_token"); + assert_eq!( + body.contains("# Side effects"), + needs_side_effects, + "{id}: side effects section mismatch" + ); + } + } + + fn parse_payload(step: &cucumber::gherkin::Step) -> Value { + let raw = step.docstring.as_deref().unwrap_or("{}"); + serde_json::from_str(raw) + .unwrap_or_else(|err| panic!("payload docstring is not valid JSON: {raw:?}: {err}")) + } + + fn last_ok(world: &AuthWorld) -> &Value { + world + .last_ok + .as_ref() + .unwrap_or_else(|| panic!("expected success, got error {:?}", world.last_err)) + } + + async fn dispatch( + world: &mut AuthWorld, + function_id: &str, + payload: Value, + ) -> Result { + match function_id { + "auth::get_token" => { + let input = serde_json::from_value(payload).map_err(|err| err.to_string())?; + let env = world.env.clone(); + let output = auth_credentials::handle_get_token(&world.store, input, |var| { + env.get(var).cloned() + }) + .await + .map_err(|err| err.to_string())?; + serde_json::to_value(output).map_err(|err| err.to_string()) + } + "auth::set_token" => { + let input = serde_json::from_value(payload).map_err(|err| err.to_string())?; + let output = auth_credentials::handle_set_token(&world.store, input) + .await + .map_err(|err| err.to_string())?; + serde_json::to_value(output).map_err(|err| err.to_string()) + } + "auth::delete_token" => { + let input = serde_json::from_value(payload).map_err(|err| err.to_string())?; + let output = auth_credentials::handle_delete_token(&world.store, input) + .await + .map_err(|err| err.to_string())?; + serde_json::to_value(output).map_err(|err| err.to_string()) + } + "auth::list_providers" => { + let input = serde_json::from_value(payload).map_err(|err| err.to_string())?; + let output = auth_credentials::handle_list_providers(&world.store, input) + .await + .map_err(|err| err.to_string())?; + serde_json::to_value(output).map_err(|err| err.to_string()) + } + "auth::status" => { + let input = serde_json::from_value(payload).map_err(|err| err.to_string())?; + let env = world.env.clone(); + let output = auth_credentials::handle_status(&world.store, input, |var| { + env.get(var).cloned() + }) + .await + .map_err(|err| err.to_string())?; + serde_json::to_value(output).map_err(|err| err.to_string()) + } + other => Err(format!("unknown function {other}")), + } + } + + fn split_frontmatter(label: &str, body: &str) -> (YamlValue, String) { + let rest = body + .strip_prefix("---\n") + .unwrap_or_else(|| panic!("{label}: missing frontmatter")); + let (yaml, markdown) = rest + .split_once("\n---\n") + .unwrap_or_else(|| panic!("{label}: missing closing frontmatter fence")); + let frontmatter = serde_yaml::from_str(yaml) + .unwrap_or_else(|err| panic!("{label}: invalid frontmatter: {err}")); + (frontmatter, markdown.to_string()) + } + + fn frontmatter_str<'a>(label: &str, frontmatter: &'a YamlValue, key: &str) -> &'a str { + frontmatter + .get(key) + .and_then(YamlValue::as_str) + .unwrap_or_else(|| panic!("{label}: missing frontmatter string key {key:?}")) + } + + fn section_position(label: &str, body: &str, heading: &str) -> usize { + body.find(heading) + .unwrap_or_else(|| panic!("{label}: missing required section {heading:?}")) + } + + fn json_blocks(body: &str) -> Vec { + let mut blocks = Vec::new(); + let mut rest = body; + while let Some((_, after_open)) = rest.split_once("```json\n") { + let Some((block, after_close)) = after_open.split_once("\n```") else { + break; + }; + blocks.push(block.to_string()); + rest = after_close; + } + blocks + } + + fn strip_json_comments(json: &str) -> String { + json.lines() + .map(|line| match line.find("//") { + Some(idx) => &line[..idx], + None => line, + }) + .collect::>() + .join("\n") + } +} + +use std::collections::HashMap; + +use cucumber::World; +use serde_json::Value; + +#[derive(Debug, World)] +#[world(init = Self::new)] +pub struct AuthWorld { + store: auth_credentials::InMemoryStore, + env: HashMap, + last_ok: Option, + last_err: Option, +} + +impl AuthWorld { + fn new() -> Self { + Self { + store: auth_credentials::InMemoryStore::new(), + env: HashMap::new(), + last_ok: None, + last_err: None, + } + } +} + +#[tokio::main] +async fn main() { + AuthWorld::cucumber() + .max_concurrent_scenarios(1) + .run_and_exit("tests/features") + .await; +} diff --git a/auth-credentials/tests/e2e/engine-state.yaml b/auth-credentials/tests/e2e/engine-state.yaml new file mode 100644 index 00000000..55667567 --- /dev/null +++ b/auth-credentials/tests/e2e/engine-state.yaml @@ -0,0 +1,7 @@ +workers: + - name: iii-state + config: + adapter: + name: kv + config: + store_method: in_memory diff --git a/auth-credentials/tests/engine_e2e.rs b/auth-credentials/tests/engine_e2e.rs new file mode 100644 index 00000000..a5147756 --- /dev/null +++ b/auth-credentials/tests/engine_e2e.rs @@ -0,0 +1,267 @@ +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use iii_sdk::{register_worker, InitOptions, TriggerRequest, III}; +use serde_json::{json, Value}; +use tokio::time::{sleep, timeout}; + +struct WorkerProcess { + child: Child, +} + +impl WorkerProcess { + fn spawn(engine_url: &str, nonce: &str) -> Self { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let config_path = format!("{manifest_dir}/config.yaml"); + let child = Command::new(env!("CARGO_BIN_EXE_auth-credentials")) + .args(["--url", engine_url, "--config", &config_path]) + .env("AUTH_CREDENTIALS_STORE", "iii_state") + .env("ANTHROPIC_API_KEY", format!("sk-env-anthropic-{nonce}")) + .env("OPENAI_API_KEY", format!("sk-env-openai-{nonce}")) + .env("RUST_LOG", "warn") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn auth-credentials worker"); + Self { child } + } +} + +impl Drop for WorkerProcess { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +async fn trigger(client: &III, function_id: &str, payload: Value) -> anyhow::Result { + timeout( + Duration::from_secs(8), + client.trigger(TriggerRequest { + function_id: function_id.to_string(), + payload, + action: None, + timeout_ms: Some(5_000), + }), + ) + .await + .map_err(|_| anyhow::anyhow!("{function_id} timed out"))? + .map_err(|err| anyhow::anyhow!("{function_id} failed: {err}")) +} + +async fn wait_for_auth_functions(client: &III) -> anyhow::Result<()> { + let mut last_err = None; + for _ in 0..40 { + match trigger(client, "auth::list_providers", json!({})).await { + Ok(_) => return Ok(()), + Err(err) => last_err = Some(err), + } + sleep(Duration::from_millis(250)).await; + } + Err(anyhow::anyhow!( + "auth worker did not register in time: {:?}", + last_err.map(|err| err.to_string()) + )) +} + +async fn best_effort_delete(client: &III, provider: &str) { + let _ = trigger( + client, + "auth::delete_token", + json!({ "provider": provider }), + ) + .await; +} + +#[tokio::test] +async fn auth_worker_real_engine_contract_scenarios() -> anyhow::Result<()> { + let Some(engine_url) = std::env::var("IIITEST_ENGINE_URL").ok() else { + eprintln!("skipping auth worker engine e2e: set IIITEST_ENGINE_URL to a running engine"); + return Ok(()); + }; + + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + .to_string(); + let client = register_worker(&engine_url, InitOptions::default()); + let _worker = WorkerProcess::spawn(&engine_url, &nonce); + wait_for_auth_functions(&client).await?; + + let alpha = format!("auth-e2e-alpha-{nonce}"); + let beta = format!("auth-e2e-beta-{nonce}"); + let oauth = format!("auth-e2e-oauth-{nonce}"); + let old_secret = format!("sk-old-{nonce}"); + let new_secret = format!("sk-new-{nonce}"); + + for provider in [&alpha, &beta, &oauth, "anthropic", "openai"] { + best_effort_delete(&client, provider).await; + } + + trigger( + &client, + "auth::set_token", + json!({ + "provider": format!(" {alpha} "), + "credential": { "type": "api_key", "key": format!("sk-alpha-{nonce}") } + }), + ) + .await?; + let trimmed = trigger(&client, "auth::get_token", json!({ "provider": alpha })).await?; + assert_eq!(trimmed["type"].as_str(), Some("api_key")); + assert_eq!( + trimmed["key"].as_str(), + Some(format!("sk-alpha-{nonce}").as_str()) + ); + + trigger( + &client, + "auth::set_token", + json!({ + "provider": beta, + "credential": { "type": "api_key", "key": old_secret } + }), + ) + .await?; + trigger( + &client, + "auth::set_token", + json!({ + "provider": beta, + "credential": { "type": "api_key", "key": new_secret } + }), + ) + .await?; + let rotated = trigger(&client, "auth::get_token", json!({ "provider": beta })).await?; + assert_eq!(rotated["key"].as_str(), Some(new_secret.as_str())); + assert!(!serde_json::to_string(&rotated)?.contains(&old_secret)); + + trigger( + &client, + "auth::set_token", + json!({ + "provider": oauth, + "credential": { + "type": "oauth", + "access_token": format!("oauth-access-{nonce}"), + "refresh_token": format!("oauth-refresh-{nonce}"), + "expires_at": 1893456000_i64, + "scopes": ["models:read", "messages:write"], + "provider_extra": { "workspace": "prod" } + } + }), + ) + .await?; + let oauth_value = trigger(&client, "auth::get_token", json!({ "provider": oauth })).await?; + assert_eq!(oauth_value["type"].as_str(), Some("oauth")); + assert_eq!( + oauth_value["access_token"].as_str(), + Some(format!("oauth-access-{nonce}").as_str()) + ); + assert_eq!( + oauth_value["refresh_token"].as_str(), + Some(format!("oauth-refresh-{nonce}").as_str()) + ); + assert_eq!( + oauth_value["scopes"], + json!(["models:read", "messages:write"]) + ); + + let oauth_status = trigger(&client, "auth::status", json!({ "provider": oauth })).await?; + let oauth_status_rendered = serde_json::to_string(&oauth_status)?; + assert_eq!(oauth_status["configured"].as_bool(), Some(true)); + assert_eq!(oauth_status["source"].as_str(), Some("stored")); + assert_eq!(oauth_status["label"].as_str(), Some("oauth")); + assert!(!oauth_status_rendered.contains(&format!("oauth-access-{nonce}"))); + assert!(!oauth_status_rendered.contains(&format!("oauth-refresh-{nonce}"))); + + let env_openai = trigger(&client, "auth::get_token", json!({ "provider": "openai" })).await?; + assert_eq!(env_openai["type"].as_str(), Some("api_key")); + assert_eq!( + env_openai["key"].as_str(), + Some(format!("sk-env-openai-{nonce}").as_str()) + ); + + trigger( + &client, + "auth::set_token", + json!({ + "provider": "openai", + "credential": { "type": "api_key", "key": format!("sk-stored-openai-{nonce}") } + }), + ) + .await?; + let stored_status = trigger(&client, "auth::status", json!({ "provider": "openai" })).await?; + let stored_status_rendered = serde_json::to_string(&stored_status)?; + assert_eq!(stored_status["source"].as_str(), Some("stored")); + assert!(stored_status["label"] + .as_str() + .is_some_and(|label| label.starts_with("api-key:sk-st"))); + assert!(!stored_status_rendered.contains(&format!("sk-stored-openai-{nonce}"))); + assert!(!stored_status_rendered.contains(&format!("sk-env-openai-{nonce}"))); + + trigger( + &client, + "auth::delete_token", + json!({ "provider": "openai" }), + ) + .await?; + let fallback_after_delete = + trigger(&client, "auth::get_token", json!({ "provider": "openai" })).await?; + assert_eq!( + fallback_after_delete["key"].as_str(), + Some(format!("sk-env-openai-{nonce}").as_str()) + ); + + let unknown = trigger( + &client, + "auth::get_token", + json!({ "provider": format!("unknown-{nonce}") }), + ) + .await?; + assert!(unknown.is_null()); + let unknown_status = trigger( + &client, + "auth::status", + json!({ "provider": format!("unknown-{nonce}") }), + ) + .await?; + assert_eq!(unknown_status["configured"].as_bool(), Some(false)); + assert!(unknown_status.get("source").is_none()); + assert!(unknown_status.get("label").is_none()); + + let blank_error = trigger( + &client, + "auth::set_token", + json!({ + "provider": " ", + "credential": { "type": "api_key", "key": format!("sk-invalid-{nonce}") } + }), + ) + .await + .unwrap_err() + .to_string(); + assert!(blank_error.contains("provider must be non-empty")); + + let listed = trigger(&client, "auth::list_providers", json!({})).await?; + let providers = listed["providers"] + .as_array() + .expect("providers is an array") + .iter() + .filter_map(Value::as_str) + .filter(|provider| provider.contains(&nonce)) + .collect::>(); + assert_eq!( + providers, + vec![alpha.as_str(), beta.as_str(), oauth.as_str()] + ); + let listed_rendered = serde_json::to_string(&listed)?; + assert!(!listed_rendered.contains(&new_secret)); + assert!(!listed_rendered.contains(&format!("oauth-access-{nonce}"))); + + for provider in [&alpha, &beta, &oauth, "anthropic", "openai"] { + best_effort_delete(&client, provider).await; + } + client.shutdown_async().await; + Ok(()) +} diff --git a/auth-credentials/tests/features/auth_functions.feature b/auth-credentials/tests/features/auth_functions.feature new file mode 100644 index 00000000..a12be8aa --- /dev/null +++ b/auth-credentials/tests/features/auth_functions.feature @@ -0,0 +1,237 @@ +@pure @auth @auth_functions +Feature: auth::* credential functions + Credential behavior should be reviewable from scenarios: stored credentials + win over environment fallback, status never leaks token bytes, provider lists + reveal names only, deletes are idempotent, OAuth payloads survive round trip, + blank environment values are ignored, and invalid provider ids fail. + + Background: + Given an empty auth credential store + + Scenario: stored credential wins over environment fallback + Given environment variable "ANTHROPIC_API_KEY" is "sk-env-secret" + When I call auth::set_token with payload: + """ + { + "provider": "anthropic", + "credential": { "type": "api_key", "key": "sk-stored-secret" } + } + """ + And I call auth::get_token with payload: + """ + { "provider": "anthropic" } + """ + Then the auth credential response has api key "sk-stored-secret" + + Scenario: set_token overwrites a rotated stored API key + When I call auth::set_token with payload: + """ + { + "provider": "anthropic", + "credential": { "type": "api_key", "key": "sk-old-secret" } + } + """ + And I call auth::set_token with payload: + """ + { + "provider": "anthropic", + "credential": { "type": "api_key", "key": "sk-new-secret" } + } + """ + And I call auth::get_token with payload: + """ + { "provider": "anthropic" } + """ + Then the auth credential response has api key "sk-new-secret" + And the auth response does not contain "sk-old-secret" + + Scenario: provider names are trimmed before storage and lookup + When I call auth::set_token with payload: + """ + { + "provider": " anthropic ", + "credential": { "type": "api_key", "key": "sk-trimmed" } + } + """ + And I call auth::get_token with payload: + """ + { "provider": "anthropic" } + """ + Then the auth credential response has api key "sk-trimmed" + When I call auth::list_providers with payload: + """ + {} + """ + Then the auth provider list is "anthropic" + + Scenario: OAuth credentials preserve token metadata but status redacts it + When I call auth::set_token with payload: + """ + { + "provider": "anthropic", + "credential": { + "type": "oauth", + "access_token": "oauth-access-secret", + "refresh_token": "oauth-refresh-secret", + "expires_at": 1893456000, + "scopes": ["models:read", "messages:write"], + "provider_extra": { "workspace": "prod" } + } + } + """ + And I call auth::get_token with payload: + """ + { "provider": "anthropic" } + """ + Then the auth OAuth response has access token "oauth-access-secret" + And the auth OAuth response has refresh token "oauth-refresh-secret" + And the auth OAuth response has scopes "models:read,messages:write" + When I call auth::status with payload: + """ + { "provider": "anthropic" } + """ + Then the auth status source is "stored" + And the auth status label is "oauth" + And the auth response does not contain "oauth-access-secret" + And the auth response does not contain "oauth-refresh-secret" + + Scenario: status prefers stored credential and redacts stored and environment secrets + Given environment variable "ANTHROPIC_API_KEY" is "sk-env-secret" + When I call auth::set_token with payload: + """ + { + "provider": "anthropic", + "credential": { "type": "api_key", "key": "sk-stored-secret" } + } + """ + And I call auth::status with payload: + """ + { "provider": "anthropic" } + """ + Then the auth status source is "stored" + And the auth status label starts with "api-key:sk-st" + And the auth response does not contain "sk-stored-secret" + And the auth response does not contain "sk-env-secret" + + Scenario: get_token falls back to the provider environment variable + Given environment variable "OPENAI_API_KEY" is "sk-env-openai" + When I call auth::get_token with payload: + """ + { "provider": "openai" } + """ + Then the auth credential response has api key "sk-env-openai" + + Scenario: status reports source without leaking the full credential + Given environment variable "OPENAI_API_KEY" is "sk-env-openai-secret" + When I call auth::status with payload: + """ + { "provider": "openai" } + """ + Then the auth status source is "environment" + And the auth response does not contain "sk-env-openai-secret" + + Scenario: empty provider environment variable is ignored + Given environment variable "OPENAI_API_KEY" is "" + When I call auth::get_token with payload: + """ + { "provider": "openai" } + """ + Then the auth response is null + When I call auth::status with payload: + """ + { "provider": "openai" } + """ + Then the auth status is unconfigured + + Scenario: unknown provider does not use unrelated environment fallback + Given environment variable "OPENAI_API_KEY" is "sk-env-openai" + When I call auth::get_token with payload: + """ + { "provider": "unknown-provider" } + """ + Then the auth response is null + When I call auth::status with payload: + """ + { "provider": "unknown-provider" } + """ + Then the auth status is unconfigured + + Scenario: list_providers returns sorted names only + When I call auth::set_token with payload: + """ + { + "provider": "openai", + "credential": { "type": "api_key", "key": "sk-openai" } + } + """ + And I call auth::set_token with payload: + """ + { + "provider": "anthropic", + "credential": { "type": "api_key", "key": "sk-anthropic" } + } + """ + And I call auth::list_providers with payload: + """ + {} + """ + Then the auth provider list is "anthropic,openai" + And the auth response does not contain "sk-openai" + And the auth response does not contain "sk-anthropic" + + Scenario: delete_token is idempotent and removes the stored credential + When I call auth::set_token with payload: + """ + { + "provider": "anthropic", + "credential": { "type": "api_key", "key": "sk-delete-me" } + } + """ + And I call auth::delete_token with payload: + """ + { "provider": "anthropic" } + """ + And I call auth::delete_token with payload: + """ + { "provider": "anthropic" } + """ + And I call auth::get_token with payload: + """ + { "provider": "anthropic" } + """ + Then the auth response is null + + Scenario: delete_token reveals environment fallback after removing stored credential + Given environment variable "OPENAI_API_KEY" is "sk-env-openai" + When I call auth::set_token with payload: + """ + { + "provider": "openai", + "credential": { "type": "api_key", "key": "sk-stored-openai" } + } + """ + And I call auth::delete_token with payload: + """ + { "provider": "openai" } + """ + And I call auth::get_token with payload: + """ + { "provider": "openai" } + """ + Then the auth credential response has api key "sk-env-openai" + When I call auth::status with payload: + """ + { "provider": "openai" } + """ + Then the auth status source is "environment" + And the auth response does not contain "sk-stored-openai" + + Scenario: blank provider ids fail before touching storage + When I call auth::set_token with payload: + """ + { + "provider": " ", + "credential": { "type": "api_key", "key": "sk-invalid" } + } + """ + Then the auth call fails with a message mentioning "provider must be non-empty" diff --git a/auth-credentials/tests/features/auth_skill_bundle.feature b/auth-credentials/tests/features/auth_skill_bundle.feature new file mode 100644 index 00000000..50ed51ad --- /dev/null +++ b/auth-credentials/tests/features/auth_skill_bundle.feature @@ -0,0 +1,18 @@ +@pure @auth @auth_skill_bundle +Feature: auth-credentials skill bundle + The bundled markdown is an agent-facing contract. It should follow the + worker skill bundle format: one index skill, namespace-mirrored how-to + paths, function_id frontmatter, ordered sections, parseable JSON examples, + and explicit side effects for write paths. + + Scenario: index skill identifies the worker and links every auth how-to + Then the auth skill index has type "index" and title "auth-credentials" + And the auth skill index links to every auth how-to + + Scenario: auth how-to files map paths to function ids + Then every auth how-to path mirrors its function id + And every auth how-to has required sections in order + + Scenario: auth how-to examples and side effects are reviewable + Then every auth how-to JSON example parses + And auth write how-tos document side effects diff --git a/auth-credentials/tests/integration.rs b/auth-credentials/tests/integration.rs index f38c6048..86354437 100644 --- a/auth-credentials/tests/integration.rs +++ b/auth-credentials/tests/integration.rs @@ -1,16 +1,447 @@ -//! Smoke tests that run without an iii engine connection. +//! Behavior tests that run without an iii engine connection. #[test] -fn in_memory_store_constructs() { - let _store = auth_credentials::InMemoryStore::new(); +fn api_key_credential_serializes_round_trip_with_snake_case_tag() { + let cred = auth_credentials::Credential::ApiKey { + key: "sk-test".into(), + }; + let json = serde_json::to_string(&cred).expect("serialize"); + assert!(json.contains(r#""type":"api_key""#)); + let back: auth_credentials::Credential = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(cred, back); } #[test] -fn credential_serializes_round_trip() { - let cred = auth_credentials::Credential::ApiKey { - key: "sk-test".into(), +fn oauth_credential_serializes_round_trip_with_contract_tag() { + let cred = auth_credentials::Credential::OAuth { + access_token: "oauth-access".into(), + refresh_token: Some("oauth-refresh".into()), + expires_at: Some(1_893_456_000), + scopes: vec!["models:read".into()], + provider_extra: serde_json::json!({ "workspace": "prod" }), }; let json = serde_json::to_string(&cred).expect("serialize"); + assert!(json.contains(r#""type":"oauth""#)); + assert!(!json.contains(r#""type":"o_auth""#)); let back: auth_credentials::Credential = serde_json::from_str(&json).expect("deserialize"); assert_eq!(cred, back); } + +#[test] +fn credential_type_oauth_serializes_with_contract_tag() { + let value = serde_json::to_value(auth_credentials::CredentialType::OAuth).unwrap(); + assert_eq!(value, serde_json::json!("oauth")); +} + +#[test] +fn auth_source_serializes_as_snake_case() { + let value = serde_json::to_value(auth_credentials::AuthSource::Environment).unwrap(); + assert_eq!(value, serde_json::json!("environment")); +} + +#[tokio::test] +async fn handlers_round_trip_stored_credential_and_delete() -> anyhow::Result<()> { + let store = auth_credentials::InMemoryStore::new(); + let credential = auth_credentials::Credential::ApiKey { + key: "sk-stored".into(), + }; + + let set = auth_credentials::handle_set_token( + &store, + auth_credentials::SetTokenInput { + provider: "anthropic".into(), + credential: credential.clone(), + }, + ) + .await?; + assert!(set.ok); + + let got = auth_credentials::handle_get_token( + &store, + auth_credentials::ProviderInput { + provider: "anthropic".into(), + }, + |_| Some("sk-env".into()), + ) + .await?; + assert_eq!(got, Some(credential)); + + let deleted = auth_credentials::handle_delete_token( + &store, + auth_credentials::ProviderInput { + provider: "anthropic".into(), + }, + ) + .await?; + assert!(deleted.ok); + + let after_delete = auth_credentials::handle_get_token( + &store, + auth_credentials::ProviderInput { + provider: "anthropic".into(), + }, + |_| None, + ) + .await?; + assert_eq!(after_delete, None); + Ok(()) +} + +#[tokio::test] +async fn set_token_overwrites_existing_credential() -> anyhow::Result<()> { + let store = auth_credentials::InMemoryStore::new(); + auth_credentials::handle_set_token( + &store, + auth_credentials::SetTokenInput { + provider: "anthropic".into(), + credential: auth_credentials::Credential::ApiKey { + key: "sk-old".into(), + }, + }, + ) + .await?; + auth_credentials::handle_set_token( + &store, + auth_credentials::SetTokenInput { + provider: "anthropic".into(), + credential: auth_credentials::Credential::ApiKey { + key: "sk-new".into(), + }, + }, + ) + .await?; + + let got = auth_credentials::handle_get_token( + &store, + auth_credentials::ProviderInput { + provider: "anthropic".into(), + }, + |_| None, + ) + .await?; + assert_eq!( + got, + Some(auth_credentials::Credential::ApiKey { + key: "sk-new".into() + }) + ); + Ok(()) +} + +#[tokio::test] +async fn oauth_credentials_round_trip_with_optional_fields() -> anyhow::Result<()> { + let store = auth_credentials::InMemoryStore::new(); + let credential = auth_credentials::Credential::OAuth { + access_token: "oauth-access-secret".into(), + refresh_token: Some("oauth-refresh-secret".into()), + expires_at: Some(1_893_456_000), + scopes: vec!["models:read".into(), "messages:write".into()], + provider_extra: serde_json::json!({ "workspace": "prod" }), + }; + + auth_credentials::handle_set_token( + &store, + auth_credentials::SetTokenInput { + provider: "anthropic".into(), + credential: credential.clone(), + }, + ) + .await?; + + let got = auth_credentials::handle_get_token( + &store, + auth_credentials::ProviderInput { + provider: "anthropic".into(), + }, + |_| None, + ) + .await?; + assert_eq!(got, Some(credential)); + + let status = auth_credentials::handle_status( + &store, + auth_credentials::ProviderInput { + provider: "anthropic".into(), + }, + |_| None, + ) + .await?; + assert_eq!(status.source, Some(auth_credentials::AuthSource::Stored)); + assert_eq!(status.label.as_deref(), Some("oauth")); + let rendered = serde_json::to_string(&status)?; + assert!(!rendered.contains("oauth-access-secret")); + assert!(!rendered.contains("oauth-refresh-secret")); + Ok(()) +} + +#[tokio::test] +async fn provider_is_trimmed_before_storage_lookup_and_listing() -> anyhow::Result<()> { + let store = auth_credentials::InMemoryStore::new(); + auth_credentials::handle_set_token( + &store, + auth_credentials::SetTokenInput { + provider: " anthropic ".into(), + credential: auth_credentials::Credential::ApiKey { + key: "sk-trimmed".into(), + }, + }, + ) + .await?; + + let got = auth_credentials::handle_get_token( + &store, + auth_credentials::ProviderInput { + provider: "anthropic".into(), + }, + |_| None, + ) + .await?; + assert_eq!( + got, + Some(auth_credentials::Credential::ApiKey { + key: "sk-trimmed".into() + }) + ); + + let output = + auth_credentials::handle_list_providers(&store, auth_credentials::ListProvidersInput {}) + .await?; + assert_eq!(output.providers, vec!["anthropic"]); + Ok(()) +} + +#[tokio::test] +async fn get_token_uses_environment_fallback_when_store_is_empty() -> anyhow::Result<()> { + let store = auth_credentials::InMemoryStore::new(); + let got = auth_credentials::handle_get_token( + &store, + auth_credentials::ProviderInput { + provider: "openai".into(), + }, + |var| { + if var == "OPENAI_API_KEY" { + Some("sk-env-openai".into()) + } else { + None + } + }, + ) + .await?; + assert_eq!( + got, + Some(auth_credentials::Credential::ApiKey { + key: "sk-env-openai".into() + }) + ); + Ok(()) +} + +#[tokio::test] +async fn delete_reveals_environment_fallback() -> anyhow::Result<()> { + let store = auth_credentials::InMemoryStore::new(); + auth_credentials::handle_set_token( + &store, + auth_credentials::SetTokenInput { + provider: "openai".into(), + credential: auth_credentials::Credential::ApiKey { + key: "sk-stored-openai".into(), + }, + }, + ) + .await?; + auth_credentials::handle_delete_token( + &store, + auth_credentials::ProviderInput { + provider: "openai".into(), + }, + ) + .await?; + + let got = auth_credentials::handle_get_token( + &store, + auth_credentials::ProviderInput { + provider: "openai".into(), + }, + |var| { + if var == "OPENAI_API_KEY" { + Some("sk-env-openai".into()) + } else { + None + } + }, + ) + .await?; + assert_eq!( + got, + Some(auth_credentials::Credential::ApiKey { + key: "sk-env-openai".into() + }) + ); + + let status = auth_credentials::handle_status( + &store, + auth_credentials::ProviderInput { + provider: "openai".into(), + }, + |var| { + if var == "OPENAI_API_KEY" { + Some("sk-env-openai".into()) + } else { + None + } + }, + ) + .await?; + assert_eq!( + status.source, + Some(auth_credentials::AuthSource::Environment) + ); + Ok(()) +} + +#[tokio::test] +async fn empty_environment_variable_is_ignored() -> anyhow::Result<()> { + let store = auth_credentials::InMemoryStore::new(); + let got = auth_credentials::handle_get_token( + &store, + auth_credentials::ProviderInput { + provider: "openai".into(), + }, + |var| { + if var == "OPENAI_API_KEY" { + Some(String::new()) + } else { + None + } + }, + ) + .await?; + assert_eq!(got, None); + + let status = auth_credentials::handle_status( + &store, + auth_credentials::ProviderInput { + provider: "openai".into(), + }, + |var| { + if var == "OPENAI_API_KEY" { + Some(String::new()) + } else { + None + } + }, + ) + .await?; + assert!(!status.configured); + assert_eq!(status.source, None); + assert_eq!(status.label, None); + Ok(()) +} + +#[tokio::test] +async fn unknown_provider_returns_null_and_unconfigured_status() -> anyhow::Result<()> { + let store = auth_credentials::InMemoryStore::new(); + let got = auth_credentials::handle_get_token( + &store, + auth_credentials::ProviderInput { + provider: "unknown-provider".into(), + }, + |_| Some("sk-env-openai".into()), + ) + .await?; + assert_eq!(got, None); + + let status = auth_credentials::handle_status( + &store, + auth_credentials::ProviderInput { + provider: "unknown-provider".into(), + }, + |_| Some("sk-env-openai".into()), + ) + .await?; + assert!(!status.configured); + assert_eq!(status.source, None); + assert_eq!(status.label, None); + Ok(()) +} + +#[tokio::test] +async fn status_never_serializes_full_credential_bytes() -> anyhow::Result<()> { + let store = auth_credentials::InMemoryStore::new(); + let status = auth_credentials::handle_status( + &store, + auth_credentials::ProviderInput { + provider: "openai".into(), + }, + |var| { + if var == "OPENAI_API_KEY" { + Some("sk-env-openai-secret".into()) + } else { + None + } + }, + ) + .await?; + + let rendered = serde_json::to_string(&status)?; + assert!(status.configured); + assert_eq!( + status.source, + Some(auth_credentials::AuthSource::Environment) + ); + assert!(!rendered.contains("sk-env-openai-secret")); + assert!(rendered.contains("api-key:sk-env")); + Ok(()) +} + +#[tokio::test] +async fn list_providers_sorts_names_and_omits_credentials() -> anyhow::Result<()> { + let store = auth_credentials::InMemoryStore::new(); + auth_credentials::handle_set_token( + &store, + auth_credentials::SetTokenInput { + provider: "openai".into(), + credential: auth_credentials::Credential::ApiKey { + key: "sk-openai".into(), + }, + }, + ) + .await?; + auth_credentials::handle_set_token( + &store, + auth_credentials::SetTokenInput { + provider: "anthropic".into(), + credential: auth_credentials::Credential::ApiKey { + key: "sk-anthropic".into(), + }, + }, + ) + .await?; + + let output = + auth_credentials::handle_list_providers(&store, auth_credentials::ListProvidersInput {}) + .await?; + assert_eq!(output.providers, vec!["anthropic", "openai"]); + + let rendered = serde_json::to_string(&output)?; + assert!(!rendered.contains("sk-openai")); + assert!(!rendered.contains("sk-anthropic")); + Ok(()) +} + +#[tokio::test] +async fn blank_provider_is_rejected_before_storage() { + let store = auth_credentials::InMemoryStore::new(); + let err = auth_credentials::handle_set_token( + &store, + auth_credentials::SetTokenInput { + provider: " ".into(), + credential: auth_credentials::Credential::ApiKey { + key: "sk-invalid".into(), + }, + }, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("provider must be non-empty")); +} diff --git a/auth-credentials/tests/restart_e2e.rs b/auth-credentials/tests/restart_e2e.rs index d85375c7..8bd278ae 100644 --- a/auth-credentials/tests/restart_e2e.rs +++ b/auth-credentials/tests/restart_e2e.rs @@ -13,23 +13,37 @@ use std::process::{Child, Command, Stdio}; use std::time::Duration; +use iii_sdk::TriggerRequest; +use serde_json::json; + fn spawn_worker(engine_url: &str, bin: &str) -> Child { Command::new(bin) .env("III_URL", engine_url) - // Default backend for the worker is iii-state. We force it explicitly - // here so the test is robust against future default flips. .env("AUTH_CREDENTIALS_STORE", "iii_state") - // Suppress worker output so it doesn't interleave with cargo test output. .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("spawn auth-credentials") } -async fn wait_for_ready() { - // Crude: give the worker time to register on the bus. A real harness - // would poll a status function with backoff. - tokio::time::sleep(Duration::from_secs(2)).await; +async fn wait_for_ready(iii: &iii_sdk::III) { + let mut last_err = None; + for _ in 0..40 { + match iii + .trigger(TriggerRequest { + function_id: "auth::list_providers".into(), + payload: json!({}), + action: None, + timeout_ms: Some(1_000), + }) + .await + { + Ok(_) => return, + Err(err) => last_err = Some(err.to_string()), + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + panic!("auth-credentials did not register auth::* in time: {last_err:?}"); } #[tokio::test] @@ -41,17 +55,13 @@ async fn credential_survives_worker_restart() { let provider = format!("e2e-restart-{}", std::process::id()); let api_key = format!("sk-e2e-{}", std::process::id()); - // 1. Spawn worker, wait for ready, write a credential. + let iii = iii_sdk::register_worker(&url, iii_sdk::InitOptions::default()); + let mut worker = spawn_worker(&url, &bin); - wait_for_ready().await; + wait_for_ready(&iii).await; - let iii = iii_sdk::register_worker(&url, iii_sdk::InitOptions::default()); - iii.trigger(iii_sdk::TriggerRequest { + iii.trigger(TriggerRequest { function_id: "auth::set_token".into(), - // Credential serializes per the `#[serde(tag = "type", rename_all = - // "snake_case")]` attribute on the `Credential` enum (auth-credentials - // src/lib.rs lines 17-34): `ApiKey { key }` => `{ "type": "api_key", - // "key": "..." }`. payload: serde_json::json!({ "provider": &provider, "credential": { "type": "api_key", "key": &api_key }, @@ -62,17 +72,14 @@ async fn credential_survives_worker_restart() { .await .expect("auth::set_token failed"); - // 2. Kill the worker. worker.kill().expect("kill worker"); worker.wait().expect("wait for worker exit"); - // 3. Restart and wait. let mut worker = spawn_worker(&url, &bin); - wait_for_ready().await; + wait_for_ready(&iii).await; - // 4. Read back; assert credential survived. let resp = iii - .trigger(iii_sdk::TriggerRequest { + .trigger(TriggerRequest { function_id: "auth::get_token".into(), payload: serde_json::json!({ "provider": &provider }), action: None, @@ -91,9 +98,8 @@ async fn credential_survives_worker_restart() { .expect("response missing `key` field"); assert_eq!(key_field, api_key, "credential.key must round-trip"); - // 5. Cleanup. Best-effort — the actual assertion above is what matters. let _ = iii - .trigger(iii_sdk::TriggerRequest { + .trigger(TriggerRequest { function_id: "auth::delete_token".into(), payload: serde_json::json!({ "provider": &provider }), action: None, diff --git a/auth-credentials/tests/skill.rs b/auth-credentials/tests/skill.rs index e18574bc..f0550bf6 100644 --- a/auth-credentials/tests/skill.rs +++ b/auth-credentials/tests/skill.rs @@ -1,46 +1,29 @@ -//! Compile-time and format checks for the registered skill set. -//! Runs without an iii engine connection. -//! -//! Asserts the platform contract from skills/README.md: H1 first (used as -//! the iii://skills index link title), then a non-heading paragraph (used -//! as the description, truncated at 140 chars). Workers in this repo -//! follow folder-name-equals-skill-id; if a future worker uses different -//! naming, adjust id_is_valid accordingly. - -fn well_formed(label: &str, body: &str, require_summary: bool) { - assert!(!body.trim().is_empty(), "{label}: skill is empty"); - assert!( - body.len() <= 256 * 1024, - "{label}: skill exceeds 256 KiB ({} bytes)", - body.len() - ); +//! Compile-time and format checks for the registered skill bundle. - // Skip blank lines and single-line HTML comments (e.g., the renderer's - // generated banner). The check is in-memory only; the rendered file - // itself is unchanged. - let mut lines = body.lines().filter(|l| { - let t = l.trim(); - !(t.is_empty() || t.starts_with("")) - }); - let h1 = lines.next().unwrap_or(""); - assert!( - h1.starts_with("# "), - "{label}: skill must start with an H1, got: {h1:?}" - ); - if require_summary { - let summary = lines.next().unwrap_or(""); - assert!( - !summary.starts_with('#'), - "{label}: expected a summary paragraph after the H1, got another heading: {summary:?}" - ); - } +use serde_yaml::Value; + +fn split_frontmatter(label: &str, body: &str) -> (Value, String) { + let rest = body + .strip_prefix("---\n") + .unwrap_or_else(|| panic!("{label}: missing opening frontmatter fence")); + let (yaml, markdown) = rest + .split_once("\n---\n") + .unwrap_or_else(|| panic!("{label}: missing closing frontmatter fence")); + let fm = serde_yaml::from_str(yaml) + .unwrap_or_else(|err| panic!("{label}: invalid YAML frontmatter: {err}")); + (fm, markdown.to_string()) +} + +fn frontmatter_str<'a>(label: &str, fm: &'a Value, key: &str) -> &'a str { + fm.get(key) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("{label}: missing string frontmatter key {key:?}")) } fn id_is_valid(label: &str, id: &str) { assert!(!id.is_empty(), "{label}: id is empty"); assert!(id.len() <= 1024, "{label}: id exceeds 1024 chars"); - // `fn` is the only reserved first-segment literal as of skills v0.2.0. let first_segment = id.split('/').next().unwrap_or(""); assert_ne!( first_segment, "fn", @@ -70,24 +53,163 @@ fn id_is_valid(label: &str, id: &str) { } } +fn well_formed(label: &str, body: &str) { + assert!(!body.trim().is_empty(), "{label}: skill is empty"); + assert!( + body.len() <= 256 * 1024, + "{label}: skill exceeds 256 KiB ({} bytes)", + body.len() + ); + + let (_fm, markdown) = split_frontmatter(label, body); + let h1 = markdown + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(""); + assert!( + h1.starts_with("# "), + "{label}: skill must start with an H1, got: {h1:?}" + ); +} + +fn section_position(label: &str, body: &str, heading: &str) -> usize { + body.find(heading) + .unwrap_or_else(|| panic!("{label}: missing required section {heading:?}")) +} + +fn strip_json_comments(json: &str) -> String { + json.lines() + .map(|line| match line.find("//") { + Some(idx) => &line[..idx], + None => line, + }) + .collect::>() + .join("\n") +} + +fn json_blocks(body: &str) -> Vec { + let mut blocks = Vec::new(); + let mut rest = body; + while let Some((_, after_open)) = rest.split_once("```json\n") { + let Some((block, after_close)) = after_open.split_once("\n```") else { + break; + }; + blocks.push(block.to_string()); + rest = after_close; + } + blocks +} + #[test] -fn router_well_formed() { - well_formed("router", auth_credentials::SKILL_MD, true); - id_is_valid("router", auth_credentials::SKILL_ID); +fn index_skill_has_index_frontmatter_and_links_to_every_how_to() { + well_formed("index", auth_credentials::SKILL_MD); + id_is_valid("index", auth_credentials::SKILL_ID); + + let (fm, markdown) = split_frontmatter("index", auth_credentials::SKILL_MD); + assert_eq!(frontmatter_str("index", &fm, "type"), "index"); + assert_eq!( + frontmatter_str("index", &fm, "title"), + auth_credentials::SKILL_ID + ); + assert!(markdown.contains("## How-tos")); + + for (id, _) in auth_credentials::SUB_SKILLS { + let uri = format!("iii://{id}"); + assert!(markdown.contains(&uri), "index missing URI {uri}"); + } } #[test] -fn sub_skills_well_formed() { +fn how_to_skills_use_required_frontmatter_and_path_mapping() { let prefix = format!("{}/", auth_credentials::SKILL_ID); for (id, body) in auth_credentials::SUB_SKILLS { - // Canonical leaves go directly from the topical H1 to ## When to use, - // so the summary-paragraph assertion only applies to the router skill. - well_formed(id, body, false); + well_formed(id, body); id_is_valid(id, id); assert!( id.starts_with(&prefix), "sub-skill id {id:?} must be nested under the worker id ({}/)", auth_credentials::SKILL_ID ); + + let (fm, _markdown) = split_frontmatter(id, body); + assert_eq!(frontmatter_str(id, &fm, "type"), "how-to"); + let function_id = frontmatter_str(id, &fm, "function_id"); + let expected_path = function_id.replace("::", "/"); + let actual_path = id.strip_prefix(&prefix).unwrap_or(id); + assert_eq!( + actual_path, expected_path, + "{id}: skill path must mirror function namespace" + ); + assert!( + !frontmatter_str(id, &fm, "title").is_empty(), + "{id}: title must be non-empty" + ); + } +} + +#[test] +fn how_to_skills_have_required_sections_in_order() { + for (id, body) in auth_credentials::SUB_SKILLS { + let (_fm, markdown) = split_frontmatter(id, body); + let when = section_position(id, &markdown, "# When to use"); + let inputs = section_position(id, &markdown, "# Inputs"); + let outputs = section_position(id, &markdown, "# Outputs"); + let worked = section_position(id, &markdown, "# Worked example"); + let related = section_position(id, &markdown, "# Related"); + assert!( + when < inputs && inputs < outputs && outputs < worked && worked < related, + "{id}: required sections are out of order" + ); + } +} + +#[test] +fn json_examples_are_parseable_after_field_comments_are_removed() { + for (id, body) in auth_credentials::SUB_SKILLS { + let (_fm, markdown) = split_frontmatter(id, body); + let blocks = json_blocks(&markdown); + assert!(!blocks.is_empty(), "{id}: expected at least one JSON block"); + for block in blocks { + let stripped = strip_json_comments(&block); + serde_json::from_str::(&stripped) + .unwrap_or_else(|err| panic!("{id}: invalid JSON example {stripped:?}: {err}")); + } + } +} + +#[test] +fn write_path_skills_document_side_effects() { + for (id, body) in auth_credentials::SUB_SKILLS { + let needs_side_effects = id.ends_with("/set_token") || id.ends_with("/delete_token"); + assert_eq!( + body.contains("# Side effects"), + needs_side_effects, + "{id}: side effects section mismatch" + ); + } +} + +#[test] +fn related_bullets_use_function_id_contract() { + for (id, body) in auth_credentials::SUB_SKILLS { + let (_fm, markdown) = split_frontmatter(id, body); + let related = markdown + .split("# Related") + .nth(1) + .unwrap_or_else(|| panic!("{id}: missing related section")); + for line in related.lines().filter(|line| line.starts_with("- ")) { + assert!( + line.contains(" — "), + "{id}: related bullet must use en-dash separator: {line:?}" + ); + assert!( + line.trim_end().ends_with('.'), + "{id}: related bullet must end with a period: {line:?}" + ); + assert!( + line.starts_with("- `auth::"), + "{id}: related bullet must start with a function id in backticks: {line:?}" + ); + } } } diff --git a/auth/Cargo.lock b/auth/Cargo.lock new file mode 100644 index 00000000..5dd8fad3 --- /dev/null +++ b/auth/Cargo.lock @@ -0,0 +1,3032 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[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 = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +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 = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "chrono", + "clap", + "iii-sdk", + "jsonwebtoken", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[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 = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[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 = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +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 = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[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 = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-hex" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" +dependencies = [ + "cfg-if", + "cpufeatures", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[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 = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[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 = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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 = "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 = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +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-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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-macro", + "futures-sink", + "futures-task", + "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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +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.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[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 = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "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", + "tokio", + "tower-service", + "tracing", +] + +[[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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "iii-sdk" +version = "0.11.7-next.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dfc486e53858bbde04b66ab9c8d23bef9587fb5312ce2725dd82fa69b8b970" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "schemars", + "serde", + "serde_json", + "sysinfo", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +dependencies = [ + "base64", + "const-hex", + "opentelemetry", + "opentelemetry_sdk", + "prost", + "serde", + "serde_json", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.4", + "thiserror", + "tokio", + "tokio-stream", +] + +[[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 = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[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 = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[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 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[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 = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[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 = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "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.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +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 = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +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 = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[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 = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[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 = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64", + "bytes", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[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 = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[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 = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[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 = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[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 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +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.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[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-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[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-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[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.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.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.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-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[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.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.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.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.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.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.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" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +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/auth/Cargo.toml b/auth/Cargo.toml new file mode 100644 index 00000000..bae92cf3 --- /dev/null +++ b/auth/Cargo.toml @@ -0,0 +1,112 @@ +[workspace] + +[package] +name = "auth" +version = "0.1.0" +description = "OAuth authority worker for iii RBAC, discovery, DCR, JWKS, and token validation." +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/iii-hq/workers" +authors = ["iii contributors"] +publish = false +build = "build.rs" + +[lib] +name = "iii_auth" +path = "src/lib.rs" + +[[bin]] +name = "iii-auth" +path = "src/main.rs" + +[dependencies] +iii-sdk = "0.11.7-next.1" +anyhow = "1" +async-trait = "0.1" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive", "env"] } +jsonwebtoken = "9" +rand = "0.8" +rsa = { version = "0.9", features = ["pem"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +uuid = { version = "1", features = ["v4", "serde"] } + +[dev-dependencies] +serde_json = "1" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +struct_excessive_bools = "allow" +doc_markdown = "allow" +match_same_arms = "allow" +missing_const_for_fn = "allow" +derive_partial_eq_without_eq = "allow" +unreadable_literal = "allow" +needless_pass_by_value = "allow" +single_match_else = "allow" +option_if_let_else = "allow" +redundant_closure_for_method_calls = "allow" +significant_drop_tightening = "allow" +manual_let_else = "allow" +collapsible_if = "allow" +drain_collect = "allow" +manual_map = "allow" +useless_let_if_seq = "allow" +needless_continue = "allow" +items_after_statements = "allow" +too_many_lines = "allow" +unused_self = "allow" +single_call_fn = "allow" +inefficient_to_string = "allow" +similar_names = "allow" +or_fun_call = "allow" +unnecessary_wraps = "allow" +unused_async = "allow" +needless_collect = "allow" +case_sensitive_file_extension_comparisons = "allow" +significant_drop_in_scrutinee = "allow" +redundant_pub_crate = "allow" +ignored_unit_patterns = "allow" +manual_string_new = "allow" +redundant_else = "allow" +cognitive_complexity = "allow" +branches_sharing_code = "allow" +unnecessary_lazy_evaluations = "allow" +non_std_lazy_statics = "allow" +ignore_without_reason = "allow" +struct_field_names = "allow" +naive_bytecount = "allow" +suspicious_arithmetic_impl = "allow" +return_self_not_must_use = "allow" +needless_pass_by_ref_mut = "allow" +ref_option = "allow" +mut_mut = "allow" +should_implement_trait = "allow" +default_trait_access = "allow" +cloned_instead_of_copied = "allow" +implicit_clone = "allow" +use_self = "allow" +derivable_impls = "allow" +redundant_clone = "allow" +unnecessary_map_or = "allow" +double_ended_iterator_last = "allow" +format_push_string = "allow" +uninlined_format_args = "allow" +too_long_first_doc_paragraph = "allow" +map_unwrap_or = "allow" diff --git a/auth/README.md b/auth/README.md new file mode 100644 index 00000000..c2725eb4 --- /dev/null +++ b/auth/README.md @@ -0,0 +1,146 @@ +# auth + +OAuth authority worker for iii. It gives MCP, A2A, virtual workers, and normal iii workers one shared auth surface for token issue, validation, discovery, revocation, and worker-manager RBAC. + +## Functions + +- `auth::validate` validates a Bearer token for `iii-worker-manager` RBAC and returns the session decision shape the engine expects. +- `auth::server_metadata` returns the RFC 8414 authorization server discovery document. +- `auth::resource_metadata` returns the RFC 9728 protected resource discovery document. +- `auth::register` performs RFC 7591-style dynamic client registration. +- `auth::jwks` returns active public signing keys. +- `auth::jwks_rotate` rotates the local signing key and keeps old keys through the overlap window. +- `auth::token` issues client-credentials tokens and rotates refresh tokens. +- `auth::introspect` returns token activity for authenticated clients. +- `auth::revoke` revokes access tokens or refresh tokens. + +## Install + +```bash +iii worker add auth +``` + +Then point `iii-worker-manager` RBAC at `auth::validate`: + +```yaml +workers: + - name: iii-worker-manager + config: + rbac: + auth_function_id: auth::validate + expose_functions: + - metadata: + public: true + - name: auth + config: + issuer: https://api.example.com + idp_mode: local +``` + +## Quickstart + +Register a client: + +```json +{ + "client_name": "local-mcp-client", + "grant_types": ["client_credentials", "refresh_token"], + "scope": "mcp:tools" +} +``` + +Call `auth::register` with that payload. The response includes `client_id` and, for confidential clients, a one-time `client_secret`. + +Privileged scopes are intentionally blocked for public registration. Set `III_AUTH_REGISTRATION_TOKEN` and pass it as `Authorization: Bearer ` only for internal bootstrap clients that need `function:*`, `trigger:*`, or `iii:*` scopes. + +Issue a token: + +```json +{ + "grant_type": "client_credentials", + "client_id": "", + "client_secret": "", + "scope": "mcp:tools" +} +``` + +Call `auth::token`. Use the returned Bearer token when connecting to the worker manager, MCP, or A2A bridge. + +Refresh a token: + +```json +{ + "grant_type": "refresh_token", + "client_id": "", + "client_secret": "", + "refresh_token": "" +} +``` + +The old refresh token is revoked and the response includes a new one. + +Revoke a token: + +```json +{ + "client_id": "", + "client_secret": "", + "token": "", + "token_type_hint": "access_token" +} +``` + +## Configuration + +```yaml +environment: "local" +engine_url: "ws://127.0.0.1:49134" +issuer: "https://api.example.com" +audience: "iii" +idp_mode: "local" +store: "iii_state" +access_token_ttl_seconds: 900 +refresh_token_ttl_seconds: 2592000 +rotation_overlap_seconds: 86400 +default_scopes: ["mcp:tools"] +supported_scopes: + - "mcp:tools" + - "a2a:message" +token_endpoint_auth_methods_supported: + - "client_secret_post" + - "client_secret_basic" +registration_admin_token_env: "III_AUTH_REGISTRATION_TOKEN" +state_timeout_ms: 5000 +connection_ready_attempts: 150 +connection_ready_interval_ms: 200 +``` + +Privileged scopes are opt-in. Add them only for deployments that need worker-manager bootstrap authority, and protect registration with `III_AUTH_REGISTRATION_TOKEN`: + +```yaml +supported_scopes: + - "mcp:tools" + - "a2a:message" + - "function:*" + - "iii:function_registration" + - "iii:trigger_type_registration" + - "iii:trusted_internal" +``` + +`idp_mode: local` issues and validates local RS256 JWTs. The worker fails closed if its config file cannot be loaded. The iii state store uses bounded timeouts so auth paths do not wait forever on state. + +Set `environment: "production"` or `III_AUTH_ENV=production` to reject insecure `ws://` and `http://` endpoints at startup. + +The registry default uses an HTTPS issuer placeholder. Replace it with the real HTTPS authority and certificate for any shared, remote, or production deployment. + +## IdP Matrix + +| IdP | DCR | Metadata | PKCE | Notes | +|---|---|---|---|---| +| Keycloak | yes | yes | required | Best reference bridge target. | +| Okta | yes | yes | required | Good DCR support. | +| Auth0 | yes | yes | required | Good DCR support. | +| Entra ID | no | yes | required | Pre-register clients. | +| Google | no | yes | required | Pre-register clients. | +| Ping | yes | yes | required | Good DCR support. | +| ForgeRock | yes | yes | required | Good DCR support. | diff --git a/auth/build.rs b/auth/build.rs new file mode 100644 index 00000000..6867bd0f --- /dev/null +++ b/auth/build.rs @@ -0,0 +1,4 @@ +fn main() { + let target = std::env::var("TARGET").expect("TARGET must be set by Cargo for auth/build.rs"); + println!("cargo:rustc-env=TARGET_TRIPLE={}", target); +} diff --git a/auth/config.yaml b/auth/config.yaml new file mode 100644 index 00000000..727440c3 --- /dev/null +++ b/auth/config.yaml @@ -0,0 +1,24 @@ +# Local development defaults. Use wss:// engine_url and https:// issuer when +# environment is production. +environment: "local" +engine_url: "ws://127.0.0.1:49134" +issuer: "http://127.0.0.1:3111" +audience: "iii" +idp_mode: "local" +store: "iii_state" +access_token_ttl_seconds: 900 +refresh_token_ttl_seconds: 2592000 +rotation_overlap_seconds: 86400 +rotation_cron: "0 0 3 * * *" +default_scopes: + - "mcp:tools" +supported_scopes: + - "mcp:tools" + - "a2a:message" +token_endpoint_auth_methods_supported: + - "client_secret_post" + - "client_secret_basic" +registration_admin_token_env: "III_AUTH_REGISTRATION_TOKEN" +state_timeout_ms: 5000 +connection_ready_attempts: 150 +connection_ready_interval_ms: 200 diff --git a/auth/iii.worker.yaml b/auth/iii.worker.yaml new file mode 100644 index 00000000..e525e66e --- /dev/null +++ b/auth/iii.worker.yaml @@ -0,0 +1,7 @@ +iii: v1 +name: auth +language: rust +deploy: binary +manifest: Cargo.toml +bin: iii-auth +description: OAuth authority worker for iii RBAC, discovery, DCR, JWKS, and token validation. diff --git a/auth/skills/index.md b/auth/skills/index.md new file mode 100644 index 00000000..38e6cb36 --- /dev/null +++ b/auth/skills/index.md @@ -0,0 +1,87 @@ +# auth + +Use this worker when an iii project needs one OAuth authority for worker-manager RBAC, MCP bridges, A2A bridges, or generated workers that need bearer-token access. + +It is useful when you need to: + +- let clients discover auth endpoints without hardcoding URLs +- dynamically register clients at runtime +- issue short-lived RS256 access tokens plus refresh tokens +- validate incoming worker-manager sessions into concrete RBAC decisions +- expose JWKS for local token verification +- introspect or revoke tokens from trusted resource workers + +Prefer the smallest function that answers the job: + +- `auth::server_metadata` for authorization server discovery +- `auth::resource_metadata` for protected resource discovery +- `auth::register` before a new client can request tokens +- `auth::token` to issue or refresh tokens +- `auth::validate` in worker-manager RBAC middleware +- `auth::jwks` when a verifier needs public signing keys +- `auth::jwks_rotate` for scheduled or manual signing-key rotation +- `auth::introspect` when a trusted resource needs token status +- `auth::revoke` when a client signs out or a token must stop working + +Typical flow: + +```text +auth::server_metadata -> auth::register -> auth::token -> auth::validate +``` + +For HTTP clients, the same worker exposes: + +```text +GET /.well-known/oauth-authorization-server +GET /.well-known/oauth-protected-resource +POST /register +POST /token +GET /.well-known/jwks.json +POST /introspect +POST /revoke +``` + +Example client registration: + +```json +{ + "client_name": "artifact-worker", + "scope": "mcp:tools a2a:message" +} +``` + +Example token request: + +```json +{ + "grant_type": "client_credentials", + "client_id": "client_123", + "client_secret": "secret_456" +} +``` + +Example validation request: + +```json +{ + "headers": { + "authorization": "Bearer eyJhbGciOiJSUzI1NiIs..." + }, + "ip_address": "127.0.0.1" +} +``` + +Example validation output: + +```json +{ + "allowed_functions": ["tools::search"], + "forbidden_functions": [], + "allow_function_registration": false, + "trusted_internal": false, + "context": { + "client_id": "client_123", + "subject": "client_123" + } +} +``` diff --git a/auth/skills/introspect.md b/auth/skills/introspect.md new file mode 100644 index 00000000..63bd6bbf --- /dev/null +++ b/auth/skills/introspect.md @@ -0,0 +1,47 @@ +# auth::introspect + +Use this when a trusted resource worker needs to know whether a token is active and which client/scopes it represents. + +HTTP route: `POST /introspect` + +Authenticate with one of: + +- `client_secret_post`: include `client_id` and `client_secret` in the JSON body. +- `client_secret_basic`: send `Authorization: Basic base64(client_id:client_secret)`. + +Input: + +```json +{ + "client_id": "QGxGq7m6bcqXkFY7Q0c1p2Jf", + "client_secret": "iN1PqXZJDEU6M5HsR3uHz12vQk1eQJ3TR1T1lPYU6Oc", + "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...", + "token_type_hint": "access_token" +} +``` + +Sample active output: + +```json +{ + "active": true, + "client_id": "QGxGq7m6bcqXkFY7Q0c1p2Jf", + "sub": "QGxGq7m6bcqXkFY7Q0c1p2Jf", + "aud": "iii", + "iss": "http://127.0.0.1:3111", + "exp": 1770000000, + "iat": 1769999100, + "scope": "mcp:tools", + "jti": "token-id" +} +``` + +Inactive response: + +```json +{ "active": false } +``` + +Use `token_type_hint: refresh_token` when checking a refresh token, otherwise omit the hint or use `access_token`. + +Do not expose introspection to untrusted callers. A caller with valid client credentials can learn token activity and subject data. diff --git a/auth/skills/jwks.md b/auth/skills/jwks.md new file mode 100644 index 00000000..54b1c3a8 --- /dev/null +++ b/auth/skills/jwks.md @@ -0,0 +1,30 @@ +# auth::jwks + +Use this when a verifier needs public keys for RS256 access tokens issued by this auth worker. + +HTTP route: `GET /.well-known/jwks.json` + +Input: + +```json +{} +``` + +Sample output: + +```json +{ + "keys": [ + { + "kty": "RSA", + "use": "sig", + "kid": "current-key-id", + "alg": "RS256", + "n": "base64url-modulus", + "e": "AQAB" + } + ] +} +``` + +Cache by `kid`, but refresh this document when token validation sees an unknown `kid`. After `auth::jwks_rotate`, the previous key remains available until the configured overlap window ends. diff --git a/auth/skills/jwks_rotate.md b/auth/skills/jwks_rotate.md new file mode 100644 index 00000000..d450099a --- /dev/null +++ b/auth/skills/jwks_rotate.md @@ -0,0 +1,23 @@ +# auth::jwks_rotate + +Use this for scheduled or manual signing-key rotation. + +Trigger: cron from `rotation_cron`, or direct function call when an operator needs rotation immediately. + +Input: + +```json +{} +``` + +Sample output: + +```json +{ + "ok": true, + "current_kid": "new-key-id", + "retained_keys": 2 +} +``` + +The worker keeps the previous key through `rotation_overlap_seconds` so existing access tokens can still verify. Run `auth::jwks` after rotation if a verifier needs the latest public key set. diff --git a/auth/skills/register.md b/auth/skills/register.md new file mode 100644 index 00000000..d3b40967 --- /dev/null +++ b/auth/skills/register.md @@ -0,0 +1,56 @@ +# auth::register + +Use this before a new MCP bridge, A2A bridge, generated worker, or internal client asks `auth::token` for credentials. + +HTTP route: `POST /register` + +Use public registration for normal scopes such as `mcp:tools` and `a2a:message`. Use an admin bearer token only when registering privileged scopes such as `function:*`, `trigger:*`, or `iii:*`. + +Minimal input: + +```json +{ + "client_name": "artifact-worker" +} +``` + +Input with explicit scopes and grants: + +```json +{ + "client_name": "artifact-worker", + "redirect_uris": ["http://127.0.0.1:3000/callback"], + "grant_types": ["client_credentials", "refresh_token"], + "scope": "mcp:tools a2a:message", + "token_endpoint_auth_method": "client_secret_post" +} +``` + +Privileged registration input: + +```json +{ + "headers": { + "authorization": "Bearer " + }, + "client_name": "", + "scope": "function:* iii:function_registration iii:trusted_internal" +} +``` + +Sample output: + +```json +{ + "client_id": "", + "client_secret": "", + "client_name": "", + "client_id_issued_at": 1770000000, + "grant_types": ["client_credentials", "refresh_token"], + "redirect_uris": [], + "scope": "mcp:tools a2a:message", + "token_endpoint_auth_method": "client_secret_post" +} +``` + +Do not request privileged scopes for public clients. The worker rejects those unless the configured admin token is present. diff --git a/auth/skills/resource_metadata.md b/auth/skills/resource_metadata.md new file mode 100644 index 00000000..271cce9b --- /dev/null +++ b/auth/skills/resource_metadata.md @@ -0,0 +1,23 @@ +# auth::resource_metadata + +Use this when a protected MCP or A2A resource needs to advertise which authorization server and scopes clients should use. + +HTTP route: `GET /.well-known/oauth-protected-resource` + +Input: + +```json +{} +``` + +Sample output: + +```json +{ + "resource": "iii", + "authorization_servers": ["http://127.0.0.1:3111"], + "scopes_supported": ["mcp:tools", "a2a:message"] +} +``` + +Use `auth::server_metadata` next when the client needs token, registration, JWKS, introspection, or revocation endpoint URLs. diff --git a/auth/skills/revoke.md b/auth/skills/revoke.md new file mode 100644 index 00000000..267e07d6 --- /dev/null +++ b/auth/skills/revoke.md @@ -0,0 +1,28 @@ +# auth::revoke + +Use this when a client signs out, a refresh token is rotated out of use, or an operator needs an access token or refresh token to stop working. + +HTTP route: `POST /revoke` + +Authenticate with `client_secret_post` or `client_secret_basic`. + +Input: + +```json +{ + "client_id": "QGxGq7m6bcqXkFY7Q0c1p2Jf", + "client_secret": "iN1PqXZJDEU6M5HsR3uHz12vQk1eQJ3TR1T1lPYU6Oc", + "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...", + "token_type_hint": "access_token" +} +``` + +Sample output: + +```json +{ + "ok": true +} +``` + +Use `token_type_hint: refresh_token` for refresh tokens. Unknown tokens also return success so callers cannot use revocation as a token oracle. diff --git a/auth/skills/server_metadata.md b/auth/skills/server_metadata.md new file mode 100644 index 00000000..3962f449 --- /dev/null +++ b/auth/skills/server_metadata.md @@ -0,0 +1,30 @@ +# auth::server_metadata + +Use this when a client, bridge, or generated worker needs OAuth discovery for the auth worker. + +HTTP route: `GET /.well-known/oauth-authorization-server` + +Input: + +```json +{} +``` + +Sample output: + +```json +{ + "issuer": "http://127.0.0.1:3111", + "token_endpoint": "http://127.0.0.1:3111/token", + "registration_endpoint": "http://127.0.0.1:3111/register", + "jwks_uri": "http://127.0.0.1:3111/.well-known/jwks.json", + "introspection_endpoint": "http://127.0.0.1:3111/introspect", + "revocation_endpoint": "http://127.0.0.1:3111/revoke", + "grant_types_supported": ["client_credentials", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "scopes_supported": ["mcp:tools", "a2a:message"], + "idp_mode": "local" +} +``` + +Use this instead of hardcoding URLs. The worker builds endpoint URLs from the configured issuer. diff --git a/auth/skills/token.md b/auth/skills/token.md new file mode 100644 index 00000000..6fd5d04a --- /dev/null +++ b/auth/skills/token.md @@ -0,0 +1,58 @@ +# auth::token + +Use this after `auth::register` to issue an access token or rotate a refresh token. + +HTTP route: `POST /token` + +Supported grants: + +- `client_credentials`: issue a new access token and refresh token +- `refresh_token`: revoke the used refresh token, issue a new access token, and return a new refresh token + +Client-secret-post input: + +```json +{ + "grant_type": "client_credentials", + "client_id": "QGxGq7m6bcqXkFY7Q0c1p2Jf", + "client_secret": "iN1PqXZJDEU6M5HsR3uHz12vQk1eQJ3TR1T1lPYU6Oc", + "scope": "mcp:tools" +} +``` + +Client-secret-basic input: + +```json +{ + "headers": { + "authorization": "Basic UUd4R3E3bTZiY3FYa0ZZN1EwYzFwMkpmOmlOMVBxWFpKREVVNk01SHNSM3VIejEydlFrMWVRSjNUUjFUMWxQWVU2T2M=" + }, + "grant_type": "client_credentials", + "scope": "mcp:tools" +} +``` + +Refresh input: + +```json +{ + "grant_type": "refresh_token", + "refresh_token": "old-refresh-token", + "client_id": "QGxGq7m6bcqXkFY7Q0c1p2Jf", + "client_secret": "iN1PqXZJDEU6M5HsR3uHz12vQk1eQJ3TR1T1lPYU6Oc" +} +``` + +Sample output: + +```json +{ + "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...", + "token_type": "Bearer", + "expires_in": 900, + "refresh_token": "QGKb6A0lHDxcnwhYE4V3pKL40ZVvL2r8G9E4jWoSvdA", + "scope": "mcp:tools" +} +``` + +Never ask for wildcard scopes at token time. Wildcards can be registered for policy, but concrete tokens must carry concrete scopes. diff --git a/auth/skills/validate.md b/auth/skills/validate.md new file mode 100644 index 00000000..2c3783a5 --- /dev/null +++ b/auth/skills/validate.md @@ -0,0 +1,55 @@ +# auth::validate + +Use this inside worker-manager RBAC to turn an incoming bearer token into an iii authorization decision. + +Input can provide the token through `headers.authorization` or compatible request metadata. Include `ip_address` when available so loopback/internal policies can be evaluated consistently. + +Input: + +```json +{ + "headers": { + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6..." + }, + "query_params": {}, + "ip_address": "127.0.0.1" +} +``` + +Sample output for a normal client: + +```json +{ + "allowed_functions": ["tools::search"], + "forbidden_functions": [], + "allowed_trigger_types": null, + "allow_trigger_type_registration": false, + "allow_function_registration": false, + "trusted_internal": false, + "context": { + "client_id": "QGxGq7m6bcqXkFY7Q0c1p2Jf", + "subject": "QGxGq7m6bcqXkFY7Q0c1p2Jf", + "scope": "function:tools::search" + } +} +``` + +Sample output for a privileged internal client: + +```json +{ + "allowed_functions": ["*"], + "forbidden_functions": [], + "allowed_trigger_types": ["http"], + "allow_trigger_type_registration": true, + "allow_function_registration": true, + "trusted_internal": true, + "function_registration_prefix": null, + "context": { + "client_id": "worker-manager", + "subject": "worker-manager" + } +} +``` + +Reject the session if this function errors, returns an inactive decision, or lacks the function/trigger permission required by the requested operation. diff --git a/auth/src/config.rs b/auth/src/config.rs new file mode 100644 index 00000000..2353f720 --- /dev/null +++ b/auth/src/config.rs @@ -0,0 +1,337 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum StoreBackend { + #[default] + IiiState, + Memory, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct AuthConfig { + #[serde(default = "default_environment")] + pub environment: String, + #[serde(default = "default_engine_url")] + pub engine_url: String, + #[serde(default = "default_issuer")] + pub issuer: String, + #[serde(default = "default_audience")] + pub audience: String, + #[serde(default = "default_idp_mode")] + pub idp_mode: String, + #[serde(default)] + pub store: StoreBackend, + #[serde(default = "default_access_token_ttl_seconds")] + pub access_token_ttl_seconds: i64, + #[serde(default = "default_refresh_token_ttl_seconds")] + pub refresh_token_ttl_seconds: i64, + #[serde(default = "default_rotation_overlap_seconds")] + pub rotation_overlap_seconds: i64, + #[serde(default = "default_rotation_cron")] + pub rotation_cron: String, + #[serde(default = "default_default_scopes")] + pub default_scopes: Vec, + #[serde(default = "default_supported_scopes")] + pub supported_scopes: Vec, + #[serde(default = "default_token_endpoint_auth_methods_supported")] + pub token_endpoint_auth_methods_supported: Vec, + #[serde(default = "default_registration_admin_token_env")] + pub registration_admin_token_env: String, + #[serde(default = "default_state_timeout_ms")] + pub state_timeout_ms: u64, + #[serde(default = "default_connection_ready_attempts")] + pub connection_ready_attempts: usize, + #[serde(default = "default_connection_ready_interval_ms")] + pub connection_ready_interval_ms: u64, + #[serde(default = "default_skills_timeout_ms")] + pub skills_register_timeout_ms: u64, + #[serde(default = "default_skills_timeout_ms")] + pub skills_unregister_timeout_ms: u64, +} + +fn default_engine_url() -> String { + "ws://127.0.0.1:49134".to_string() +} + +fn default_environment() -> String { + "local".to_string() +} + +fn default_issuer() -> String { + "http://127.0.0.1:3111".to_string() +} + +fn default_audience() -> String { + "iii".to_string() +} + +fn default_idp_mode() -> String { + "local".to_string() +} + +fn default_access_token_ttl_seconds() -> i64 { + 900 +} + +fn default_refresh_token_ttl_seconds() -> i64 { + 2_592_000 +} + +fn default_rotation_overlap_seconds() -> i64 { + 86_400 +} + +fn default_rotation_cron() -> String { + "0 0 3 * * *".to_string() +} + +fn default_skills_timeout_ms() -> u64 { + 5_000 +} + +fn default_default_scopes() -> Vec { + vec!["mcp:tools".to_string()] +} + +fn default_supported_scopes() -> Vec { + vec!["mcp:tools".to_string(), "a2a:message".to_string()] +} + +fn default_token_endpoint_auth_methods_supported() -> Vec { + vec![ + "client_secret_post".to_string(), + "client_secret_basic".to_string(), + ] +} + +fn default_registration_admin_token_env() -> String { + "III_AUTH_REGISTRATION_TOKEN".to_string() +} + +fn default_state_timeout_ms() -> u64 { + 5_000 +} + +fn default_connection_ready_attempts() -> usize { + 150 +} + +fn default_connection_ready_interval_ms() -> u64 { + 200 +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { + environment: default_environment(), + engine_url: default_engine_url(), + issuer: default_issuer(), + audience: default_audience(), + idp_mode: default_idp_mode(), + store: StoreBackend::default(), + access_token_ttl_seconds: default_access_token_ttl_seconds(), + refresh_token_ttl_seconds: default_refresh_token_ttl_seconds(), + rotation_overlap_seconds: default_rotation_overlap_seconds(), + rotation_cron: default_rotation_cron(), + default_scopes: default_default_scopes(), + supported_scopes: default_supported_scopes(), + token_endpoint_auth_methods_supported: default_token_endpoint_auth_methods_supported(), + registration_admin_token_env: default_registration_admin_token_env(), + state_timeout_ms: default_state_timeout_ms(), + connection_ready_attempts: default_connection_ready_attempts(), + connection_ready_interval_ms: default_connection_ready_interval_ms(), + skills_register_timeout_ms: default_skills_timeout_ms(), + skills_unregister_timeout_ms: default_skills_timeout_ms(), + } + } +} + +pub fn load_config(path: &str) -> Result { + let contents = std::fs::read_to_string(path)?; + let mut cfg: AuthConfig = serde_yaml::from_str(&contents)?; + if let Ok(environment) = std::env::var("III_AUTH_ENV") { + if !environment.is_empty() { + cfg.environment = environment; + } + } + validate_config(&cfg)?; + Ok(cfg) +} + +pub fn validate_config(cfg: &AuthConfig) -> Result<()> { + if cfg.access_token_ttl_seconds <= 0 { + anyhow::bail!("access_token_ttl_seconds must be positive"); + } + if cfg.refresh_token_ttl_seconds <= 0 { + anyhow::bail!("refresh_token_ttl_seconds must be positive"); + } + if cfg.rotation_overlap_seconds <= 0 { + anyhow::bail!("rotation_overlap_seconds must be positive"); + } + if cfg.rotation_overlap_seconds >= cfg.refresh_token_ttl_seconds { + anyhow::bail!("rotation_overlap_seconds must be less than refresh_token_ttl_seconds"); + } + if cfg.state_timeout_ms == 0 { + anyhow::bail!("state_timeout_ms must be positive"); + } + if cfg.connection_ready_attempts == 0 { + anyhow::bail!("connection_ready_attempts must be positive"); + } + if cfg.connection_ready_interval_ms == 0 { + anyhow::bail!("connection_ready_interval_ms must be positive"); + } + if cfg.skills_register_timeout_ms == 0 { + anyhow::bail!("skills_register_timeout_ms must be positive"); + } + if cfg.skills_unregister_timeout_ms == 0 { + anyhow::bail!("skills_unregister_timeout_ms must be positive"); + } + if cfg.supported_scopes.is_empty() { + anyhow::bail!("supported_scopes must not be empty"); + } + for scope in &cfg.default_scopes { + if !scope_supported_by(scope, &cfg.supported_scopes) { + anyhow::bail!("default scope {scope} must be listed in supported_scopes"); + } + } + if cfg.environment.eq_ignore_ascii_case("production") { + if !cfg.engine_url.starts_with("wss://") { + anyhow::bail!("production auth config requires wss:// engine_url"); + } + if !cfg.issuer.starts_with("https://") { + anyhow::bail!("production auth config requires https:// issuer"); + } + } + let cron_fields = cfg.rotation_cron.split_whitespace().count(); + if cron_fields != 6 { + anyhow::bail!("rotation_cron must use iii's 6-field cron format"); + } + Ok(()) +} + +fn scope_supported_by(scope: &str, supported_scopes: &[String]) -> bool { + supported_scopes.iter().any(|supported| { + supported == scope + || (supported == "function:*" && scope.starts_with("function:")) + || (supported == "trigger:*" && scope.starts_with("trigger:")) + }) +} + +pub fn resolve_store_backend(cfg: &AuthConfig) -> StoreBackend { + match std::env::var("III_AUTH_STORE").as_deref() { + Ok("memory") => StoreBackend::Memory, + Ok("iii_state") => StoreBackend::IiiState, + Ok(other) if !other.is_empty() => { + tracing::warn!(%other, "unknown III_AUTH_STORE, using configured store"); + cfg.store + } + _ => cfg.store, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_from_empty_yaml() { + let cfg: AuthConfig = serde_yaml::from_str("{}").unwrap(); + assert_eq!(cfg.engine_url, "ws://127.0.0.1:49134"); + assert_eq!(cfg.issuer, "http://127.0.0.1:3111"); + assert_eq!(cfg.environment, "local"); + assert_eq!(cfg.store, StoreBackend::IiiState); + assert!(cfg.supported_scopes.contains(&"mcp:tools".to_string())); + assert!(!cfg.supported_scopes.contains(&"function:*".to_string())); + assert_eq!( + cfg.registration_admin_token_env, + "III_AUTH_REGISTRATION_TOKEN" + ); + assert_eq!(cfg.state_timeout_ms, 5_000); + assert_eq!(cfg.connection_ready_attempts, 150); + assert_eq!(cfg.connection_ready_interval_ms, 200); + } + + #[test] + fn custom_yaml_overrides() { + let cfg: AuthConfig = serde_yaml::from_str( + r#"engine_url: "ws://example:49134" +issuer: "https://auth.example" +audience: "workers" +store: memory +default_scopes: ["function:demo::read"] +"#, + ) + .unwrap(); + assert_eq!(cfg.engine_url, "ws://example:49134"); + assert_eq!(cfg.issuer, "https://auth.example"); + assert_eq!(cfg.audience, "workers"); + assert_eq!(cfg.store, StoreBackend::Memory); + assert_eq!(cfg.default_scopes, vec!["function:demo::read"]); + } + + #[test] + fn production_rejects_insecure_urls() { + let cfg = AuthConfig { + environment: "production".to_string(), + ..AuthConfig::default() + }; + let err = validate_config(&cfg).unwrap_err(); + assert!(err.to_string().contains("wss:// engine_url")); + } + + #[test] + fn cron_must_be_six_fields() { + let cfg = AuthConfig { + rotation_cron: "0 0 3 * * * *".to_string(), + ..AuthConfig::default() + }; + let err = validate_config(&cfg).unwrap_err(); + assert!(err.to_string().contains("6-field cron")); + } + + #[test] + fn ttls_must_be_positive() { + let cfg = AuthConfig { + access_token_ttl_seconds: 0, + ..AuthConfig::default() + }; + let err = validate_config(&cfg).unwrap_err(); + assert!(err.to_string().contains("access_token_ttl_seconds")); + } + + #[test] + fn rotation_overlap_must_be_less_than_refresh_ttl() { + let cfg = AuthConfig { + refresh_token_ttl_seconds: 60, + rotation_overlap_seconds: 60, + ..AuthConfig::default() + }; + let err = validate_config(&cfg).unwrap_err(); + assert!(err + .to_string() + .contains("rotation_overlap_seconds must be less")); + } + + #[test] + fn default_scopes_must_be_supported() { + let cfg = AuthConfig { + default_scopes: vec!["function:demo::read".to_string()], + supported_scopes: vec!["mcp:tools".to_string()], + ..AuthConfig::default() + }; + let err = validate_config(&cfg).unwrap_err(); + assert!(err.to_string().contains("default scope")); + } + + #[test] + fn wildcard_supported_scopes_cover_default_scopes() { + let cfg = AuthConfig { + default_scopes: vec!["function:demo::read".to_string()], + supported_scopes: vec!["function:*".to_string()], + ..AuthConfig::default() + }; + validate_config(&cfg).unwrap(); + } +} diff --git a/auth/src/io.rs b/auth/src/io.rs new file mode 100644 index 00000000..5b8e7104 --- /dev/null +++ b/auth/src/io.rs @@ -0,0 +1,14 @@ +use iii_sdk::{IIIError, TriggerRequest}; +use serde_json::Value; + +#[async_trait::async_trait] +pub trait IIITrigger: Send + Sync + 'static { + async fn trigger(&self, request: TriggerRequest) -> Result; +} + +#[async_trait::async_trait] +impl IIITrigger for iii_sdk::III { + async fn trigger(&self, request: TriggerRequest) -> Result { + iii_sdk::III::trigger(self, request).await + } +} diff --git a/auth/src/lib.rs b/auth/src/lib.rs new file mode 100644 index 00000000..86e78a33 --- /dev/null +++ b/auth/src/lib.rs @@ -0,0 +1,1498 @@ +pub mod config; +pub mod io; +pub mod store; + +use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; + +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use base64::Engine; +use chrono::Utc; +use jsonwebtoken::{ + decode, decode_header, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation, +}; +use rand::rngs::OsRng; +use rand::RngCore; +use rsa::pkcs8::{EncodePrivateKey, LineEnding}; +use rsa::traits::PublicKeyParts; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::config::AuthConfig; +use crate::store::AuthStore; + +pub const SKILL_ID: &str = "auth"; +pub const SKILL_MD: &str = include_str!("../skills/index.md"); + +pub const SUB_SKILLS: &[(&str, &str)] = &[ + ("auth/validate", include_str!("../skills/validate.md")), + ( + "auth/server_metadata", + include_str!("../skills/server_metadata.md"), + ), + ( + "auth/resource_metadata", + include_str!("../skills/resource_metadata.md"), + ), + ("auth/register", include_str!("../skills/register.md")), + ("auth/jwks", include_str!("../skills/jwks.md")), + ("auth/jwks_rotate", include_str!("../skills/jwks_rotate.md")), + ("auth/token", include_str!("../skills/token.md")), + ("auth/introspect", include_str!("../skills/introspect.md")), + ("auth/revoke", include_str!("../skills/revoke.md")), +]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientRecord { + pub client_id: String, + pub client_name: String, + pub client_secret_sha256: Option, + pub redirect_uris: Vec, + pub grant_types: Vec, + pub scopes: Vec, + pub token_endpoint_auth_method: String, + pub created_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PublicJwk { + pub kty: String, + #[serde(rename = "use")] + pub use_: String, + pub kid: String, + pub alg: String, + pub n: String, + pub e: String, +} + +impl PublicJwk { + fn to_json(&self) -> Value { + json!({ + "kty": self.kty, + "use": self.use_, + "kid": self.kid, + "alg": self.alg, + "n": self.n, + "e": self.e, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeyRecord { + pub kid: String, + pub private_pem: String, + pub public_jwk: PublicJwk, + pub created_at: i64, + pub retire_after: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeySet { + pub current_kid: String, + pub keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RefreshTokenRecord { + pub token_id: String, + pub client_id: String, + pub subject: String, + pub scopes: Vec, + pub expires_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Claims { + iss: String, + sub: String, + aud: String, + exp: u64, + iat: u64, + nbf: u64, + scope: String, + client_id: String, + jti: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthDecision { + #[serde(default)] + pub allowed_functions: Vec, + #[serde(default)] + pub forbidden_functions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_trigger_types: Option>, + #[serde(default)] + pub allow_trigger_type_registration: bool, + #[serde(default)] + pub allow_function_registration: bool, + #[serde(default)] + pub trusted_internal: bool, + #[serde(default)] + pub context: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub function_registration_prefix: Option, +} + +fn now() -> i64 { + Utc::now().timestamp() +} + +fn random_url_token(bytes: usize) -> String { + let mut buf = vec![0_u8; bytes]; + OsRng.fill_bytes(&mut buf); + URL_SAFE_NO_PAD.encode(buf) +} + +fn sha256_url(value: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(value.as_bytes())) +} + +fn timestamp_claim(value: i64) -> anyhow::Result { + u64::try_from(value).map_err(|_| anyhow::anyhow!("timestamp out of JWT claim range: {value}")) +} + +fn constant_time_eq(left: &str, right: &str) -> bool { + let left = left.as_bytes(); + let right = right.as_bytes(); + let max_len = left.len().max(right.len()); + let mut diff = left.len() ^ right.len(); + for i in 0..max_len { + let l = left.get(i).copied().unwrap_or(0); + let r = right.get(i).copied().unwrap_or(0); + diff |= usize::from(l ^ r); + } + diff == 0 +} + +fn split_scope(value: &str) -> Vec { + value + .split_whitespace() + .filter(|scope| !scope.trim().is_empty()) + .map(str::to_string) + .collect() +} + +fn normalize_body(payload: &Value) -> Value { + payload + .get("body") + .filter(|body| body.is_object()) + .cloned() + .unwrap_or_else(|| payload.clone()) +} + +fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) +} + +fn string_array_field(value: &Value, key: &str) -> Vec { + value + .get(key) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .filter(|v| !v.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn scope_field(value: &Value, key: &str) -> Vec { + if let Some(raw) = string_field(value, key) { + split_scope(raw) + } else { + string_array_field(value, key) + } +} + +fn endpoint(issuer: &str, path: &str) -> String { + format!("{}{}", issuer.trim_end_matches('/'), path) +} + +pub fn idp_capability_matrix() -> Vec { + vec![ + json!({"idp": "keycloak", "dynamic_client_registration": true, "authorization_server_metadata": true, "pkce": "required", "notes": "reference bridge target"}), + json!({"idp": "okta", "dynamic_client_registration": true, "authorization_server_metadata": true, "pkce": "required", "notes": "bridge config straightforward"}), + json!({"idp": "auth0", "dynamic_client_registration": true, "authorization_server_metadata": true, "pkce": "required", "notes": "bridge config straightforward"}), + json!({"idp": "entra", "dynamic_client_registration": false, "authorization_server_metadata": true, "pkce": "required", "notes": "pre-register clients"}), + json!({"idp": "google", "dynamic_client_registration": false, "authorization_server_metadata": true, "pkce": "required", "notes": "pre-register clients"}), + json!({"idp": "ping", "dynamic_client_registration": true, "authorization_server_metadata": true, "pkce": "required", "notes": "bridge config straightforward"}), + json!({"idp": "forgerock", "dynamic_client_registration": true, "authorization_server_metadata": true, "pkce": "required", "notes": "bridge config straightforward"}), + ] +} + +pub fn server_metadata_document(cfg: &AuthConfig) -> Value { + json!({ + "issuer": cfg.issuer, + "token_endpoint": endpoint(&cfg.issuer, "/token"), + "registration_endpoint": endpoint(&cfg.issuer, "/register"), + "introspection_endpoint": endpoint(&cfg.issuer, "/introspect"), + "revocation_endpoint": endpoint(&cfg.issuer, "/revoke"), + "jwks_uri": endpoint(&cfg.issuer, "/.well-known/jwks.json"), + "grant_types_supported": ["client_credentials", "refresh_token"], + "token_endpoint_auth_methods_supported": cfg.token_endpoint_auth_methods_supported, + "scopes_supported": cfg.supported_scopes, + "idp_mode": cfg.idp_mode, + "idp_capabilities": idp_capability_matrix(), + }) +} + +pub fn resource_metadata_document(cfg: &AuthConfig) -> Value { + json!({ + "resource": cfg.audience, + "authorization_servers": [cfg.issuer], + "jwks_uri": endpoint(&cfg.issuer, "/.well-known/jwks.json"), + "scopes_supported": cfg.supported_scopes, + "bearer_methods_supported": ["header"], + }) +} + +fn http_json(body: Value) -> Value { + json!({ + "status_code": 200, + "headers": { "content-type": "application/json" }, + "body": body, + }) +} + +pub fn server_metadata_response(cfg: &AuthConfig) -> Value { + http_json(server_metadata_document(cfg)) +} + +pub fn resource_metadata_response(cfg: &AuthConfig) -> Value { + http_json(resource_metadata_document(cfg)) +} + +fn is_privileged_scope(scope: &str) -> bool { + scope.starts_with("function:") || scope.starts_with("trigger:") || scope.starts_with("iii:") +} + +fn scope_supported(scope: &str, cfg: &AuthConfig) -> bool { + cfg.supported_scopes.iter().any(|supported| { + supported == scope + || (supported == "function:*" && scope.starts_with("function:")) + || (supported == "trigger:*" && scope.starts_with("trigger:")) + }) +} + +fn scope_allowed_by_client(scope: &str, client: &ClientRecord) -> bool { + client.scopes.iter().any(|allowed| { + allowed == scope + || (allowed == "function:*" && scope.starts_with("function:") && scope != "function:*") + || (allowed == "trigger:*" && scope.starts_with("trigger:") && scope != "trigger:*") + }) +} + +fn requested_or_default(requested: &[String], cfg: &AuthConfig) -> Vec { + if requested.is_empty() { + cfg.default_scopes.clone() + } else { + requested.to_vec() + } +} + +fn validate_registration_scopes( + requested: &[String], + cfg: &AuthConfig, + admin_authorized: bool, +) -> anyhow::Result> { + let scopes = requested_or_default(requested, cfg); + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + for scope in scopes { + if !scope_supported(&scope, cfg) { + anyhow::bail!("unsupported scope: {scope}"); + } + if is_privileged_scope(&scope) && !admin_authorized { + anyhow::bail!("admin authorization required for privileged scope: {scope}"); + } + if seen.insert(scope.clone()) { + out.push(scope); + } + } + Ok(out) +} + +fn validate_token_scopes( + requested: &[String], + client: &ClientRecord, + cfg: &AuthConfig, +) -> anyhow::Result> { + let scopes = requested_or_default(requested, cfg); + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + for scope in scopes { + if !scope_supported(&scope, cfg) { + anyhow::bail!("unsupported scope: {scope}"); + } + if scope == "function:*" || scope == "trigger:*" { + anyhow::bail!("wildcard scopes can be registered but cannot be issued: {scope}"); + } + if !scope_allowed_by_client(&scope, client) { + anyhow::bail!("scope not allowed for client: {scope}"); + } + if seen.insert(scope.clone()) { + out.push(scope); + } + } + Ok(out) +} + +fn supported_grant_type(value: &str) -> bool { + matches!(value, "client_credentials" | "refresh_token") +} + +fn requested_grant_types(body: &Value) -> anyhow::Result> { + let values = string_array_field(body, "grant_types"); + let grants = if values.is_empty() { + vec![ + "client_credentials".to_string(), + "refresh_token".to_string(), + ] + } else { + values + }; + for grant in &grants { + if !supported_grant_type(grant) { + anyhow::bail!("unsupported grant_type for local auth worker: {grant}"); + } + } + Ok(grants) +} + +fn requested_auth_method(body: &Value, cfg: &AuthConfig) -> anyhow::Result { + let method = string_field(body, "token_endpoint_auth_method") + .unwrap_or("client_secret_post") + .to_string(); + if !cfg + .token_endpoint_auth_methods_supported + .iter() + .any(|supported| supported == &method) + { + anyhow::bail!("unsupported token_endpoint_auth_method: {method}"); + } + Ok(method) +} + +fn admin_registration_token(cfg: &AuthConfig) -> Option { + if cfg.registration_admin_token_env.is_empty() { + return None; + } + std::env::var(&cfg.registration_admin_token_env) + .ok() + .filter(|token| !token.is_empty()) +} + +fn registration_admin_authorized(payload: &Value, body: &Value, cfg: &AuthConfig) -> bool { + let Some(expected) = admin_registration_token(cfg) else { + return false; + }; + let bearer = auth_header_from_payload(payload, body) + .and_then(|raw| strip_auth_scheme(&raw, "Bearer").map(str::to_string)); + let body_token = string_field(body, "admin_token").map(str::to_string); + [bearer, body_token] + .into_iter() + .flatten() + .any(|token| constant_time_eq(&token, &expected)) +} + +fn generate_key_record() -> anyhow::Result { + let mut rng = OsRng; + let private = RsaPrivateKey::new(&mut rng, 2048)?; + let public = RsaPublicKey::from(&private); + let kid = Uuid::new_v4().to_string(); + let private_pem = private.to_pkcs8_pem(LineEnding::LF)?.to_string(); + Ok(KeyRecord { + kid: kid.clone(), + private_pem, + public_jwk: PublicJwk { + kty: "RSA".to_string(), + use_: "sig".to_string(), + kid, + alg: "RS256".to_string(), + n: URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()), + e: URL_SAFE_NO_PAD.encode(public.e().to_bytes_be()), + }, + created_at: now(), + retire_after: None, + }) +} + +async fn ensure_keyset(store: &dyn AuthStore) -> anyhow::Result { + if let Some(keyset) = store.get_keyset().await? { + return Ok(keyset); + } + let key = generate_key_record()?; + let keyset = KeySet { + current_kid: key.kid.clone(), + keys: vec![key], + }; + store.create_keyset_if_absent(keyset).await +} + +pub async fn rotate_jwks(store: &dyn AuthStore, cfg: &AuthConfig) -> anyhow::Result { + let current_time = now(); + let new_key = generate_key_record()?; + let keyset = store + .rotate_keyset(new_key, current_time, cfg.rotation_overlap_seconds) + .await?; + Ok(json!({ + "ok": true, + "current_kid": keyset.current_kid, + "active_keys": keyset.keys.len(), + })) +} + +pub async fn jwks_document(store: &dyn AuthStore) -> anyhow::Result { + let keyset = ensure_keyset(store).await?; + let current_time = now(); + let keys: Vec = keyset + .keys + .iter() + .filter(|key| key.retire_after.is_none_or(|retire| retire > current_time)) + .map(|key| key.public_jwk.to_json()) + .collect(); + Ok(json!({ "keys": keys })) +} + +pub async fn register_client( + store: &dyn AuthStore, + cfg: &AuthConfig, + payload: Value, +) -> anyhow::Result { + let body = normalize_body(&payload); + let client_id = random_url_token(18); + let client_secret = random_url_token(32); + let method = requested_auth_method(&body, cfg)?; + let requested_scopes = scope_field(&body, "scope"); + let admin_authorized = registration_admin_authorized(&payload, &body, cfg); + let scopes = validate_registration_scopes(&requested_scopes, cfg, admin_authorized)?; + let grant_types = requested_grant_types(&body)?; + if method == "none" + && grant_types + .iter() + .any(|grant| grant == "client_credentials" || grant == "refresh_token") + { + anyhow::bail!("token_endpoint_auth_method none cannot use local client_credentials or refresh_token grants"); + } + let redirect_uris = string_array_field(&body, "redirect_uris"); + let client_name = string_field(&body, "client_name") + .unwrap_or("iii client") + .to_string(); + let response_client_name = client_name.clone(); + let stored_secret = if method == "none" { + None + } else { + Some(sha256_url(&client_secret)) + }; + let record = ClientRecord { + client_id: client_id.clone(), + client_name, + client_secret_sha256: stored_secret, + redirect_uris: redirect_uris.clone(), + grant_types: grant_types.clone(), + scopes: scopes.clone(), + token_endpoint_auth_method: method.clone(), + created_at: now(), + }; + store.set_client(record).await?; + let mut out = json!({ + "client_id": client_id, + "client_id_issued_at": now(), + "client_name": response_client_name, + "redirect_uris": redirect_uris, + "grant_types": grant_types, + "scope": scopes.join(" "), + "token_endpoint_auth_method": method, + }); + if method != "none" { + out.as_object_mut() + .expect("registration response is object") + .insert("client_secret".to_string(), Value::String(client_secret)); + } + Ok(out) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CredentialSource { + Basic, + Post, + None, +} + +fn client_secret_matches( + client: &ClientRecord, + secret: Option<&str>, + source: CredentialSource, +) -> bool { + match (&client.client_secret_sha256, secret) { + (None, None) => client.token_endpoint_auth_method == "none", + (None, Some(_)) => false, + (Some(expected), Some(actual)) => { + let source_allowed = match client.token_endpoint_auth_method.as_str() { + "client_secret_basic" => source == CredentialSource::Basic, + "client_secret_post" => source == CredentialSource::Post, + _ => false, + }; + source_allowed && constant_time_eq(&sha256_url(actual), expected) + } + _ => false, + } +} + +fn issue_access_token( + cfg: &AuthConfig, + keyset: &KeySet, + client: &ClientRecord, + subject: &str, + scopes: &[String], +) -> anyhow::Result<(String, Claims)> { + let current = keyset + .keys + .iter() + .find(|key| key.kid == keyset.current_kid) + .ok_or_else(|| anyhow::anyhow!("current signing key missing"))?; + let issued_at = now(); + let expires_at = issued_at + cfg.access_token_ttl_seconds; + let claims = Claims { + iss: cfg.issuer.clone(), + sub: subject.to_string(), + aud: cfg.audience.clone(), + exp: timestamp_claim(expires_at)?, + iat: timestamp_claim(issued_at)?, + nbf: timestamp_claim(issued_at)?, + scope: scopes.join(" "), + client_id: client.client_id.clone(), + jti: Uuid::new_v4().to_string(), + }; + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(current.kid.clone()); + let token = encode( + &header, + &claims, + &EncodingKey::from_rsa_pem(current.private_pem.as_bytes())?, + )?; + Ok((token, claims)) +} + +fn decode_token(cfg: &AuthConfig, keyset: &KeySet, token: &str) -> anyhow::Result { + let header = decode_header(token)?; + let kid = header + .kid + .ok_or_else(|| anyhow::anyhow!("token missing kid"))?; + let key = keyset + .keys + .iter() + .find(|key| key.kid == kid) + .ok_or_else(|| anyhow::anyhow!("unknown kid"))?; + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(std::slice::from_ref(&cfg.audience)); + validation.set_issuer(std::slice::from_ref(&cfg.issuer)); + let decoded = decode::( + token, + &DecodingKey::from_rsa_components(&key.public_jwk.n, &key.public_jwk.e)?, + &validation, + )?; + Ok(decoded.claims) +} + +fn auth_header(headers: &Map) -> Option { + headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("authorization")) + .and_then(|(_, value)| value.as_str()) + .map(str::to_string) +} + +fn auth_header_from_payload(payload: &Value, body: &Value) -> Option { + payload + .get("headers") + .and_then(Value::as_object) + .and_then(auth_header) + .or_else(|| { + body.get("headers") + .and_then(Value::as_object) + .and_then(auth_header) + }) +} + +fn strip_auth_scheme<'a>(value: &'a str, scheme: &str) -> Option<&'a str> { + let (actual, rest) = value.split_once(' ')?; + actual.eq_ignore_ascii_case(scheme).then_some(rest) +} + +fn basic_credentials(value: &str) -> Option<(String, String)> { + let encoded = strip_auth_scheme(value, "Basic")?; + let decoded = STANDARD.decode(encoded).ok()?; + let decoded = String::from_utf8(decoded).ok()?; + let (client_id, secret) = decoded.split_once(':')?; + Some((client_id.to_string(), secret.to_string())) +} + +fn bearer_token_from_payload(payload: &Value) -> Option { + if let Some(token) = string_field(payload, "token") { + return Some(token.to_string()); + } + let headers = payload.get("headers").and_then(Value::as_object)?; + let raw = auth_header(headers)?; + strip_auth_scheme(&raw, "Bearer").map(str::to_string) +} + +fn client_credentials( + payload: &Value, + body: &Value, +) -> (Option, Option, CredentialSource) { + if let Some(raw) = auth_header_from_payload(payload, body) { + if let Some((client_id, secret)) = basic_credentials(&raw) { + return (Some(client_id), Some(secret), CredentialSource::Basic); + } + } + ( + string_field(body, "client_id").map(str::to_string), + string_field(body, "client_secret").map(str::to_string), + if string_field(body, "client_secret").is_some() { + CredentialSource::Post + } else { + CredentialSource::None + }, + ) +} + +async fn issue_for_client_credentials( + store: &dyn AuthStore, + cfg: &AuthConfig, + payload: &Value, + body: &Value, +) -> anyhow::Result { + store + .cleanup_expired_tokens(now(), cfg.refresh_token_ttl_seconds) + .await?; + let (client_id, secret, source) = client_credentials(payload, body); + let client_id = client_id.ok_or_else(|| anyhow::anyhow!("missing client_id"))?; + let client = store + .get_client(&client_id) + .await? + .ok_or_else(|| anyhow::anyhow!("unknown client_id"))?; + if !client + .grant_types + .iter() + .any(|grant| grant == "client_credentials") + { + anyhow::bail!("client_credentials grant not allowed for client"); + } + if !client_secret_matches(&client, secret.as_deref(), source) { + anyhow::bail!("invalid client_secret"); + } + let requested = scope_field(body, "scope"); + let scopes = validate_token_scopes(&requested, &client, cfg)?; + let keyset = ensure_keyset(store).await?; + let (access_token, claims) = + issue_access_token(cfg, &keyset, &client, &client.client_id, &scopes)?; + let refresh_token = random_url_token(32); + let refresh = RefreshTokenRecord { + token_id: refresh_token.clone(), + client_id: client.client_id.clone(), + subject: claims.sub, + scopes: scopes.clone(), + expires_at: now() + cfg.refresh_token_ttl_seconds, + }; + store.set_refresh_token(refresh).await?; + Ok(json!({ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": cfg.access_token_ttl_seconds, + "refresh_token": refresh_token, + "scope": scopes.join(" "), + })) +} + +async fn issue_for_refresh_token( + store: &dyn AuthStore, + cfg: &AuthConfig, + payload: &Value, + body: &Value, +) -> anyhow::Result { + store + .cleanup_expired_tokens(now(), cfg.refresh_token_ttl_seconds) + .await?; + let refresh_token = string_field(body, "refresh_token") + .ok_or_else(|| anyhow::anyhow!("missing refresh_token"))?; + let (client_id, secret, source) = client_credentials(payload, body); + let client_id = client_id.ok_or_else(|| anyhow::anyhow!("missing client_id"))?; + let refresh = store + .get_refresh_token(refresh_token) + .await? + .ok_or_else(|| anyhow::anyhow!("unknown refresh_token"))?; + if refresh.expires_at <= now() || store.is_revoked(refresh_token).await? { + anyhow::bail!("refresh_token expired or revoked"); + } + if refresh.client_id != client_id { + anyhow::bail!("refresh_token client mismatch"); + } + let client = store + .get_client(&refresh.client_id) + .await? + .ok_or_else(|| anyhow::anyhow!("refresh token client missing"))?; + if !client_secret_matches(&client, secret.as_deref(), source) { + anyhow::bail!("invalid client_secret"); + } + let keyset = ensure_keyset(store).await?; + let (access_token, _) = + issue_access_token(cfg, &keyset, &client, &refresh.subject, &refresh.scopes)?; + let new_refresh_token = random_url_token(32); + let new_refresh_record = RefreshTokenRecord { + token_id: new_refresh_token.clone(), + client_id: client.client_id.clone(), + subject: refresh.subject, + scopes: refresh.scopes.clone(), + expires_at: now() + cfg.refresh_token_ttl_seconds, + }; + store + .rotate_refresh_token(refresh_token, new_refresh_record) + .await?; + Ok(json!({ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": cfg.access_token_ttl_seconds, + "refresh_token": new_refresh_token, + "scope": refresh.scopes.join(" "), + })) +} + +pub async fn introspect_token( + store: &dyn AuthStore, + cfg: &AuthConfig, + token: &str, +) -> anyhow::Result { + let keyset = ensure_keyset(store).await?; + let claims = match decode_token(cfg, &keyset, token) { + Ok(claims) => claims, + Err(_) => return Ok(json!({ "active": false })), + }; + if store.is_revoked(&claims.jti).await? { + return Ok(json!({ "active": false })); + } + Ok(json!({ + "active": true, + "client_id": claims.client_id, + "sub": claims.sub, + "aud": claims.aud, + "iss": claims.iss, + "exp": claims.exp, + "iat": claims.iat, + "scope": claims.scope, + "jti": claims.jti, + })) +} + +pub async fn token_endpoint( + store: &dyn AuthStore, + cfg: &AuthConfig, + payload: Value, +) -> anyhow::Result { + let body = normalize_body(&payload); + let action = string_field(&body, "action"); + let grant_type = string_field(&body, "grant_type"); + if action == Some("introspect") || grant_type == Some("introspection") { + return introspect_endpoint(store, cfg, payload).await; + } + match grant_type.unwrap_or("client_credentials") { + "client_credentials" => issue_for_client_credentials(store, cfg, &payload, &body).await, + "refresh_token" => issue_for_refresh_token(store, cfg, &payload, &body).await, + other => anyhow::bail!("unsupported grant_type: {other}"), + } +} + +async fn authenticated_client( + store: &dyn AuthStore, + payload: &Value, + body: &Value, +) -> anyhow::Result { + let (client_id, secret, source) = client_credentials(payload, body); + let client_id = client_id.ok_or_else(|| anyhow::anyhow!("missing client_id"))?; + let client = store + .get_client(&client_id) + .await? + .ok_or_else(|| anyhow::anyhow!("unknown client_id"))?; + if !client_secret_matches(&client, secret.as_deref(), source) { + anyhow::bail!("invalid client_secret"); + } + Ok(client) +} + +pub async fn introspect_endpoint( + store: &dyn AuthStore, + cfg: &AuthConfig, + payload: Value, +) -> anyhow::Result { + let body = normalize_body(&payload); + let _client = authenticated_client(store, &payload, &body).await?; + let token = string_field(&body, "token").ok_or_else(|| anyhow::anyhow!("missing token"))?; + introspect_token(store, cfg, token).await +} + +pub async fn revoke_endpoint( + store: &dyn AuthStore, + cfg: &AuthConfig, + payload: Value, +) -> anyhow::Result { + let body = normalize_body(&payload); + let client = authenticated_client(store, &payload, &body).await?; + let token = string_field(&body, "token").ok_or_else(|| anyhow::anyhow!("missing token"))?; + let hint = string_field(&body, "token_type_hint"); + let refresh = store.get_refresh_token(token).await?; + + if hint == Some("refresh_token") || refresh.is_some() { + if let Some(refresh) = refresh { + if refresh.client_id == client.client_id { + store.revoke(token).await?; + } + } + return Ok(json!({ "ok": true })); + } + + let keyset = ensure_keyset(store).await?; + if let Ok(claims) = decode_token(cfg, &keyset, token) { + if claims.client_id == client.client_id { + store.revoke(&claims.jti).await?; + } + } + Ok(json!({ "ok": true })) +} + +fn scopes_to_decision(claims: &Claims) -> AuthDecision { + let scopes = split_scope(&claims.scope); + let allowed_functions: Vec = scopes + .iter() + .filter_map(|scope| scope.strip_prefix("function:").map(str::to_string)) + .filter(|function_id| function_id != "*") + .collect(); + let trigger_types: Vec = scopes + .iter() + .filter_map(|scope| scope.strip_prefix("trigger:").map(str::to_string)) + .collect(); + AuthDecision { + allowed_functions, + forbidden_functions: vec![], + allowed_trigger_types: if trigger_types.is_empty() { + None + } else { + Some(trigger_types) + }, + allow_trigger_type_registration: scopes + .iter() + .any(|scope| scope == "iii:trigger_type_registration"), + allow_function_registration: scopes + .iter() + .any(|scope| scope == "iii:function_registration"), + trusted_internal: scopes.iter().any(|scope| scope == "iii:trusted_internal"), + context: json!({ + "client_id": claims.client_id, + "subject": claims.sub, + "scopes": scopes, + "token_id": claims.jti, + }), + function_registration_prefix: None, + } +} + +pub async fn validate_session( + store: &dyn AuthStore, + cfg: &AuthConfig, + payload: Value, +) -> anyhow::Result { + let token = bearer_token_from_payload(&payload) + .ok_or_else(|| anyhow::anyhow!("missing bearer token"))?; + let keyset = ensure_keyset(store).await?; + let claims = decode_token(cfg, &keyset, &token)?; + if store.is_revoked(&claims.jti).await? { + anyhow::bail!("token revoked"); + } + Ok(scopes_to_decision(&claims)) +} + +pub async fn register_with_iii( + iii: &iii_sdk::III, + store: Arc, + cfg: Arc, +) -> anyhow::Result { + use iii_sdk::{IIIError, RegisterFunctionMessage}; + + let validate_store = store.clone(); + let validate_cfg = cfg.clone(); + let validate = iii.register_function(( + RegisterFunctionMessage::with_id("auth::validate".to_string()).with_description( + "Validate a Bearer token and return an iii RBAC session decision.".into(), + ), + move |payload: Value| { + let store = validate_store.clone(); + let cfg = validate_cfg.clone(); + async move { + validate_session(&*store, &cfg, payload) + .await + .and_then(|decision| serde_json::to_value(decision).map_err(Into::into)) + .map_err(|e: anyhow::Error| IIIError::Handler(e.to_string())) + } + }, + )); + + let server_cfg = cfg.clone(); + let server_metadata = iii.register_function(( + RegisterFunctionMessage::with_id("auth::server_metadata".to_string()) + .with_description("Return RFC 8414 authorization server metadata.".into()), + move |_payload: Value| { + let cfg = server_cfg.clone(); + async move { Ok(server_metadata_response(&cfg)) } + }, + )); + + let resource_cfg = cfg.clone(); + let resource_metadata = iii.register_function(( + RegisterFunctionMessage::with_id("auth::resource_metadata".to_string()) + .with_description("Return RFC 9728 protected resource metadata.".into()), + move |_payload: Value| { + let cfg = resource_cfg.clone(); + async move { Ok(resource_metadata_response(&cfg)) } + }, + )); + + let register_store = store.clone(); + let register_cfg = cfg.clone(); + let register = iii.register_function(( + RegisterFunctionMessage::with_id("auth::register".to_string()) + .with_description("Register an OAuth client at runtime.".into()), + move |payload: Value| { + let store = register_store.clone(); + let cfg = register_cfg.clone(); + async move { + register_client(&*store, &cfg, payload) + .await + .map(http_json) + .map_err(|e| IIIError::Handler(e.to_string())) + } + }, + )); + + let jwks_store = store.clone(); + let jwks = iii.register_function(( + RegisterFunctionMessage::with_id("auth::jwks".to_string()) + .with_description("Return the active public JSON Web Key Set.".into()), + move |_payload: Value| { + let store = jwks_store.clone(); + async move { + jwks_document(&*store) + .await + .map(http_json) + .map_err(|e| IIIError::Handler(e.to_string())) + } + }, + )); + + let rotate_store = store.clone(); + let rotate_cfg = cfg.clone(); + let jwks_rotate = iii.register_function(( + RegisterFunctionMessage::with_id("auth::jwks_rotate".to_string()) + .with_description("Rotate the active local signing key.".into()), + move |_payload: Value| { + let store = rotate_store.clone(); + let cfg = rotate_cfg.clone(); + async move { + rotate_jwks(&*store, &cfg) + .await + .map_err(|e| IIIError::Handler(e.to_string())) + } + }, + )); + + let token_store = store.clone(); + let token_cfg = cfg.clone(); + let token = iii.register_function(( + RegisterFunctionMessage::with_id("auth::token".to_string()) + .with_description("Issue or refresh OAuth tokens.".into()), + move |payload: Value| { + let store = token_store.clone(); + let cfg = token_cfg.clone(); + async move { + token_endpoint(&*store, &cfg, payload) + .await + .map(http_json) + .map_err(|e| IIIError::Handler(e.to_string())) + } + }, + )); + + let introspect_store = store.clone(); + let introspect_cfg = cfg.clone(); + let introspect = iii.register_function(( + RegisterFunctionMessage::with_id("auth::introspect".to_string()) + .with_description("Introspect an OAuth token for an authenticated client.".into()), + move |payload: Value| { + let store = introspect_store.clone(); + let cfg = introspect_cfg.clone(); + async move { + introspect_endpoint(&*store, &cfg, payload) + .await + .map(http_json) + .map_err(|e| IIIError::Handler(e.to_string())) + } + }, + )); + + let revoke_store = store; + let revoke_cfg = cfg; + let revoke = iii.register_function(( + RegisterFunctionMessage::with_id("auth::revoke".to_string()) + .with_description("Revoke an access token or refresh token.".into()), + move |payload: Value| { + let store = revoke_store.clone(); + let cfg = revoke_cfg.clone(); + async move { + revoke_endpoint(&*store, &cfg, payload) + .await + .map(http_json) + .map_err(|e| IIIError::Handler(e.to_string())) + } + }, + )); + + Ok(AuthFunctionRefs { + validate, + server_metadata, + resource_metadata, + register, + jwks, + jwks_rotate, + token, + introspect, + revoke, + }) +} + +pub struct AuthFunctionRefs { + pub validate: iii_sdk::FunctionRef, + pub server_metadata: iii_sdk::FunctionRef, + pub resource_metadata: iii_sdk::FunctionRef, + pub register: iii_sdk::FunctionRef, + pub jwks: iii_sdk::FunctionRef, + pub jwks_rotate: iii_sdk::FunctionRef, + pub token: iii_sdk::FunctionRef, + pub introspect: iii_sdk::FunctionRef, + pub revoke: iii_sdk::FunctionRef, +} + +impl AuthFunctionRefs { + pub fn unregister_all(self) { + for reference in [ + self.validate, + self.server_metadata, + self.resource_metadata, + self.register, + self.jwks, + self.jwks_rotate, + self.token, + self.introspect, + self.revoke, + ] { + reference.unregister(); + } + } +} + +pub fn extract_response_body(value: &Value) -> Option<&Value> { + value.get("body").or(Some(value)) +} + +pub fn public_token_payload(value: &Value) -> HashMap { + value + .as_object() + .map(|object| object.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::InMemoryAuthStore; + + fn cfg() -> AuthConfig { + AuthConfig { + issuer: "https://auth.test".to_string(), + audience: "iii-test".to_string(), + store: crate::config::StoreBackend::Memory, + supported_scopes: vec![ + "mcp:tools".to_string(), + "a2a:message".to_string(), + "function:demo::read".to_string(), + "function:*".to_string(), + "trigger:http".to_string(), + "iii:function_registration".to_string(), + "iii:trusted_internal".to_string(), + ], + default_scopes: vec!["mcp:tools".to_string()], + registration_admin_token_env: "III_AUTH_TEST_ADMIN_TOKEN".to_string(), + ..AuthConfig::default() + } + } + + fn cfg_with_admin_env() -> AuthConfig { + let env_name = format!("III_AUTH_TEST_ADMIN_TOKEN_{}", Uuid::new_v4().simple()); + std::env::set_var(&env_name, "admin-secret"); + AuthConfig { + registration_admin_token_env: env_name, + ..cfg() + } + } + + #[tokio::test] + async fn client_credentials_roundtrip_validates_session() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + let cfg = cfg_with_admin_env(); + let registration = register_client( + &store, + &cfg, + json!({ + "headers": { "authorization": "bearer admin-secret" }, + "client_name": "test", + "scope": "function:demo::read iii:function_registration iii:trusted_internal" + }), + ) + .await?; + let client_id = registration["client_id"].as_str().unwrap(); + let client_secret = registration["client_secret"].as_str().unwrap(); + let token = token_endpoint( + &store, + &cfg, + json!({ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": "function:demo::read iii:function_registration iii:trusted_internal" + }), + ) + .await?; + let access_token = token["access_token"].as_str().unwrap(); + let decision = validate_session( + &store, + &cfg, + json!({ "headers": { "authorization": format!("beAREr {access_token}") } }), + ) + .await?; + assert_eq!(decision.allowed_functions, vec!["demo::read"]); + assert!(decision.allow_function_registration); + assert!(decision.trusted_internal); + assert_eq!(decision.context["client_id"], client_id); + Ok(()) + } + + #[tokio::test] + async fn public_registration_rejects_privileged_scopes() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + let cfg = cfg_with_admin_env(); + let err = register_client( + &store, + &cfg, + json!({ + "client_name": "test", + "scope": "function:demo::read iii:trusted_internal" + }), + ) + .await + .unwrap_err(); + assert!(err + .to_string() + .contains("admin authorization required for privileged scope")); + Ok(()) + } + + #[tokio::test] + async fn public_registration_allows_public_scopes() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + let cfg = cfg(); + let registration = register_client( + &store, + &cfg, + json!({ + "client_name": "test", + "scope": "mcp:tools a2a:message" + }), + ) + .await?; + assert_eq!(registration["scope"], "mcp:tools a2a:message"); + Ok(()) + } + + #[tokio::test] + async fn client_cannot_escalate_scopes_at_token_time() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + let cfg = cfg_with_admin_env(); + let registration = register_client( + &store, + &cfg, + json!({ + "headers": { "authorization": "Bearer admin-secret" }, + "client_name": "test", + "scope": "function:demo::read" + }), + ) + .await?; + let err = token_endpoint( + &store, + &cfg, + json!({ + "grant_type": "client_credentials", + "client_id": registration["client_id"], + "client_secret": registration["client_secret"], + "scope": "iii:trusted_internal" + }), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("scope not allowed for client")); + Ok(()) + } + + #[tokio::test] + async fn client_secret_basic_requires_basic_auth_header() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + let cfg = cfg(); + let registration = register_client( + &store, + &cfg, + json!({ + "client_name": "basic-client", + "token_endpoint_auth_method": "client_secret_basic" + }), + ) + .await?; + let client_id = registration["client_id"].as_str().unwrap(); + let client_secret = registration["client_secret"].as_str().unwrap(); + let post_err = token_endpoint( + &store, + &cfg, + json!({ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret + }), + ) + .await + .unwrap_err(); + assert!(post_err.to_string().contains("invalid client_secret")); + let encoded = STANDARD.encode(format!("{client_id}:{client_secret}")); + let token = token_endpoint( + &store, + &cfg, + json!({ + "headers": { "authorization": format!("bAsIc {encoded}") }, + "grant_type": "client_credentials" + }), + ) + .await?; + assert_eq!(token["token_type"], "Bearer"); + Ok(()) + } + + #[tokio::test] + async fn wildcard_scope_is_not_issued_as_token_scope() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + let cfg = cfg_with_admin_env(); + let registration = register_client( + &store, + &cfg, + json!({ + "headers": { "authorization": "Bearer admin-secret" }, + "client_name": "wildcard-client", + "scope": "function:*" + }), + ) + .await?; + let err = token_endpoint( + &store, + &cfg, + json!({ + "grant_type": "client_credentials", + "client_id": registration["client_id"], + "client_secret": registration["client_secret"], + "scope": "function:*" + }), + ) + .await + .unwrap_err(); + assert!(err + .to_string() + .contains("wildcard scopes can be registered")); + Ok(()) + } + + #[tokio::test] + async fn refresh_token_issues_new_access_token() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + let cfg = cfg(); + let registration = register_client(&store, &cfg, json!({ "client_name": "test" })).await?; + let token = token_endpoint( + &store, + &cfg, + json!({ + "grant_type": "client_credentials", + "client_id": registration["client_id"], + "client_secret": registration["client_secret"], + }), + ) + .await?; + let refreshed = token_endpoint( + &store, + &cfg, + json!({ + "grant_type": "refresh_token", + "refresh_token": token["refresh_token"], + "client_id": registration["client_id"], + "client_secret": registration["client_secret"], + }), + ) + .await?; + assert_eq!(refreshed["token_type"], "Bearer"); + assert!(refreshed["access_token"].as_str().unwrap().len() > 100); + assert!(refreshed["refresh_token"].as_str().unwrap().len() > 20); + let old_refresh = token_endpoint( + &store, + &cfg, + json!({ + "grant_type": "refresh_token", + "refresh_token": token["refresh_token"], + "client_id": registration["client_id"], + "client_secret": registration["client_secret"], + }), + ) + .await + .unwrap_err(); + assert!(old_refresh.to_string().contains("expired or revoked")); + Ok(()) + } + + #[tokio::test] + async fn revoke_invalidates_access_token() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + let cfg = cfg(); + let registration = register_client(&store, &cfg, json!({ "client_name": "test" })).await?; + let token = token_endpoint( + &store, + &cfg, + json!({ + "grant_type": "client_credentials", + "client_id": registration["client_id"], + "client_secret": registration["client_secret"], + }), + ) + .await?; + let access_token = token["access_token"].as_str().unwrap(); + let active = introspect_endpoint( + &store, + &cfg, + json!({ + "client_id": registration["client_id"], + "client_secret": registration["client_secret"], + "token": access_token + }), + ) + .await?; + assert_eq!(active["active"], true); + revoke_endpoint( + &store, + &cfg, + json!({ + "client_id": registration["client_id"], + "client_secret": registration["client_secret"], + "token": access_token, + "token_type_hint": "access_token" + }), + ) + .await?; + let rejected = validate_session( + &store, + &cfg, + json!({ "headers": { "authorization": format!("Bearer {access_token}") } }), + ) + .await + .unwrap_err(); + assert!(rejected.to_string().contains("token revoked")); + let inactive = introspect_endpoint( + &store, + &cfg, + json!({ + "client_id": registration["client_id"], + "client_secret": registration["client_secret"], + "token": access_token + }), + ) + .await?; + assert_eq!(inactive["active"], false); + Ok(()) + } + + #[tokio::test] + async fn public_client_method_none_cannot_use_local_grants() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + let mut cfg = cfg(); + cfg.token_endpoint_auth_methods_supported + .push("none".to_string()); + let err = register_client( + &store, + &cfg, + json!({ + "client_name": "public", + "token_endpoint_auth_method": "none", + "grant_types": ["client_credentials"] + }), + ) + .await + .unwrap_err(); + assert!(err + .to_string() + .contains("cannot use local client_credentials")); + Ok(()) + } + + #[tokio::test] + async fn jwks_rotate_keeps_previous_key() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + let cfg = cfg(); + let first = jwks_document(&store).await?; + assert_eq!(first["keys"].as_array().unwrap().len(), 1); + rotate_jwks(&store, &cfg).await?; + let second = jwks_document(&store).await?; + assert_eq!(second["keys"].as_array().unwrap().len(), 2); + Ok(()) + } + + #[test] + fn metadata_contains_required_endpoints() { + let cfg = cfg(); + let metadata = server_metadata_document(&cfg); + assert_eq!(metadata["issuer"], "https://auth.test"); + assert_eq!( + metadata["registration_endpoint"], + "https://auth.test/register" + ); + assert_eq!( + metadata["jwks_uri"], + "https://auth.test/.well-known/jwks.json" + ); + assert_eq!(metadata["revocation_endpoint"], "https://auth.test/revoke"); + assert_eq!( + metadata["introspection_endpoint"], + "https://auth.test/introspect" + ); + assert!(metadata.get("authorization_endpoint").is_none()); + assert!(metadata.get("code_challenge_methods_supported").is_none()); + } +} diff --git a/auth/src/main.rs b/auth/src/main.rs new file mode 100644 index 00000000..4c822082 --- /dev/null +++ b/auth/src/main.rs @@ -0,0 +1,256 @@ +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use clap::Parser; +use iii_sdk::{ + register_worker, InitOptions, OtelConfig, RegisterTriggerInput, TriggerRequest, WorkerMetadata, +}; +use serde_json::json; +use tokio::task::JoinHandle; +use tracing_subscriber::EnvFilter; + +mod manifest; + +use iii_auth::config::{resolve_store_backend, validate_config, AuthConfig, StoreBackend}; +use iii_auth::store::{IiiStateAuthStore, InMemoryAuthStore}; + +#[derive(Parser, Debug)] +#[command( + name = "iii-auth", + about = "OAuth authority worker for iii RBAC, discovery, DCR, JWKS, and token validation." +)] +struct Cli { + #[arg(long, env = "III_AUTH_CONFIG", default_value = "./config.yaml")] + config: String, + + #[arg(long, env = "III_URL")] + url: Option, + + #[arg(long)] + issuer: Option, + + #[arg(long)] + idp_mode: Option, + + #[arg(long)] + rotation_cron: Option, + + #[arg(long)] + manifest: bool, +} + +const CONNECTION_READY_SETTLE: Duration = Duration::from_millis(50); +const SKILL_REGISTER_TIMEOUT: Duration = Duration::new(3 * 60, 0); +const SKILL_REGISTER_MAX_BACKOFF: Duration = Duration::new(60, 0); +const SKILL_REGISTER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + + if cli.manifest { + let m = manifest::build_manifest(); + println!("{}", serde_json::to_string_pretty(&m)?); + return Ok(()); + } + + let mut cfg = iii_auth::config::load_config(&cli.config) + .with_context(|| format!("failed to load auth config from {}", cli.config))?; + if let Some(issuer) = cli.issuer { + cfg.issuer = issuer; + } + if let Some(idp_mode) = cli.idp_mode { + cfg.idp_mode = idp_mode; + } + if let Some(rotation_cron) = cli.rotation_cron { + cfg.rotation_cron = rotation_cron; + } + validate_config(&cfg).context("invalid auth config")?; + let engine_url = cli.url.unwrap_or_else(|| cfg.engine_url.clone()); + let cfg = Arc::new(cfg); + + let iii = Arc::new(register_worker( + &engine_url, + InitOptions { + otel: Some(OtelConfig::default()), + metadata: Some(WorkerMetadata { + runtime: "rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + name: "auth".to_string(), + os: std::env::consts::OS.to_string(), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + }), + ..InitOptions::default() + }, + )); + wait_for_connection_ready(&iii, &cfg).await?; + + let store: Arc = match resolve_store_backend(&cfg) { + StoreBackend::Memory => Arc::new(InMemoryAuthStore::new()), + StoreBackend::IiiState => { + let iii_for_store: Arc = iii.clone(); + Arc::new(IiiStateAuthStore::new(iii_for_store, cfg.state_timeout_ms)) + } + }; + + let _refs = iii_auth::register_with_iii(&iii, store, cfg.clone()) + .await + .context("auth register failed")?; + + register_triggers(&iii, &cfg).context("auth trigger registration failed")?; + let skill_register_handle = spawn_skill_register(iii.clone(), cfg.clone()); + + tracing::info!("auth ready"); + wait_for_shutdown().await?; + skill_register_handle.abort(); + let _ = tokio::time::timeout(SKILL_REGISTER_SHUTDOWN_TIMEOUT, skill_register_handle).await; + unregister_skill(&iii, &cfg).await; + tracing::info!("auth shutting down"); + iii.shutdown_async().await; + Ok(()) +} + +fn register_triggers(iii: &iii_sdk::III, cfg: &AuthConfig) -> Result<()> { + let http_routes = [ + ( + "auth::server_metadata", + "GET", + ".well-known/oauth-authorization-server", + ), + ( + "auth::resource_metadata", + "GET", + ".well-known/oauth-protected-resource", + ), + ("auth::register", "POST", "register"), + ("auth::jwks", "GET", ".well-known/jwks.json"), + ("auth::token", "POST", "token"), + ("auth::introspect", "POST", "introspect"), + ("auth::revoke", "POST", "revoke"), + ]; + for (function_id, method, api_path) in http_routes { + iii.register_trigger(RegisterTriggerInput { + trigger_type: "http".to_string(), + function_id: function_id.to_string(), + config: json!({ "api_path": api_path, "http_method": method }), + metadata: None, + }) + .with_context(|| format!("failed to register {method} {api_path} for {function_id}"))?; + } + iii.register_trigger(RegisterTriggerInput { + trigger_type: "cron".to_string(), + function_id: "auth::jwks_rotate".to_string(), + config: json!({ "expression": cfg.rotation_cron }), + metadata: None, + }) + .context("failed to register JWKS rotation trigger")?; + Ok(()) +} + +async fn register_skill_with_retry(iii: &iii_sdk::III, id: &str, body: &str, timeout_ms: u64) { + let mut backoff = Duration::from_secs(5); + let started = Instant::now(); + loop { + let res = iii + .trigger(TriggerRequest { + function_id: "skills::register".into(), + payload: json!({ "id": id, "skill": body }), + action: None, + timeout_ms: Some(timeout_ms), + }) + .await; + match res { + Ok(_) => return, + Err(e) => { + if started.elapsed() > SKILL_REGISTER_TIMEOUT { + tracing::warn!(%id, error = %e, "skills handshake gave up"); + return; + } + } + } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(SKILL_REGISTER_MAX_BACKOFF); + } +} + +fn spawn_skill_register(iii: Arc, cfg: Arc) -> JoinHandle<()> { + tokio::spawn(async move { + register_skill_with_retry( + &iii, + iii_auth::SKILL_ID, + iii_auth::SKILL_MD, + cfg.skills_register_timeout_ms, + ) + .await; + for (id, body) in iii_auth::SUB_SKILLS { + register_skill_with_retry(&iii, id, body, cfg.skills_register_timeout_ms).await; + } + }) +} + +async fn unregister_skill(iii: &Arc, cfg: &Arc) { + for (id, _) in iii_auth::SUB_SKILLS { + let _ = iii + .trigger(TriggerRequest { + function_id: "skills::unregister".into(), + payload: json!({ "id": id }), + action: None, + timeout_ms: Some(cfg.skills_unregister_timeout_ms), + }) + .await; + } + let _ = iii + .trigger(TriggerRequest { + function_id: "skills::unregister".into(), + payload: json!({ "id": iii_auth::SKILL_ID }), + action: None, + timeout_ms: Some(cfg.skills_unregister_timeout_ms), + }) + .await; +} + +async fn wait_for_connection_ready(iii: &iii_sdk::III, cfg: &AuthConfig) -> Result<()> { + let interval = Duration::from_millis(cfg.connection_ready_interval_ms); + for attempt in 1..=cfg.connection_ready_attempts { + let state = iii.get_connection_state(); + if state == iii_sdk::IIIConnectionState::Connected { + tokio::time::sleep(CONNECTION_READY_SETTLE).await; + return Ok(()); + } + tracing::debug!(attempt, state = ?state, "iii engine connection not ready"); + tokio::time::sleep(interval).await; + } + anyhow::bail!( + "timed out waiting for iii engine connection after {} attempts", + cfg.connection_ready_attempts + ) +} + +async fn wait_for_shutdown() -> Result<()> { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = + signal(SignalKind::terminate()).context("failed to install SIGTERM handler")?; + tokio::select! { + r = tokio::signal::ctrl_c() => r.context("failed to await SIGINT")?, + _ = sigterm.recv() => {} + } + Ok(()) + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c() + .await + .context("failed to await SIGINT") + } +} diff --git a/auth/src/manifest.rs b/auth/src/manifest.rs new file mode 100644 index 00000000..128e4c04 --- /dev/null +++ b/auth/src/manifest.rs @@ -0,0 +1,38 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct WorkerManifest { + pub name: String, + pub version: String, + pub description: String, + pub default_config: serde_json::Value, + pub supported_targets: Vec, +} + +pub fn build_manifest() -> WorkerManifest { + WorkerManifest { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: env!("CARGO_PKG_DESCRIPTION").to_string(), + default_config: serde_json::to_value(iii_auth::config::AuthConfig::default()) + .expect("config serializes to JSON"), + supported_targets: vec![env!("TARGET_TRIPLE").to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_roundtrip_has_required_fields() { + let m = build_manifest(); + let json = serde_json::to_string_pretty(&m).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["name"], env!("CARGO_PKG_NAME")); + assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION")); + assert!(parsed["description"].is_string()); + assert!(parsed["default_config"].is_object()); + assert!(!parsed["supported_targets"].as_array().unwrap().is_empty()); + } +} diff --git a/auth/src/store.rs b/auth/src/store.rs new file mode 100644 index 00000000..a8232036 --- /dev/null +++ b/auth/src/store.rs @@ -0,0 +1,428 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::Utc; +use iii_sdk::TriggerRequest; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::sync::{Mutex, RwLock}; + +use crate::io::IIITrigger; +use crate::{ClientRecord, KeyRecord, KeySet, RefreshTokenRecord}; + +pub const CLIENTS_SCOPE: &str = "auth:clients"; +pub const JWKS_SCOPE: &str = "auth:jwks"; +pub const TOKENS_SCOPE: &str = "auth:tokens"; +const KEYSET_KEY: &str = "keyset"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RevokedTokenRecord { + token_id: String, + created_at: i64, +} + +#[async_trait::async_trait] +pub trait AuthStore: Send + Sync { + async fn get_client(&self, client_id: &str) -> anyhow::Result>; + async fn set_client(&self, client: ClientRecord) -> anyhow::Result<()>; + async fn get_keyset(&self) -> anyhow::Result>; + async fn set_keyset(&self, keyset: KeySet) -> anyhow::Result<()>; + async fn create_keyset_if_absent(&self, keyset: KeySet) -> anyhow::Result; + async fn rotate_keyset( + &self, + new_key: KeyRecord, + current_time: i64, + rotation_overlap_seconds: i64, + ) -> anyhow::Result; + async fn get_refresh_token(&self, token_id: &str) + -> anyhow::Result>; + async fn set_refresh_token(&self, record: RefreshTokenRecord) -> anyhow::Result<()>; + async fn rotate_refresh_token( + &self, + old_token_id: &str, + new_record: RefreshTokenRecord, + ) -> anyhow::Result<()>; + async fn is_revoked(&self, token_id: &str) -> anyhow::Result; + async fn revoke(&self, token_id: &str) -> anyhow::Result<()>; + async fn cleanup_expired_tokens( + &self, + current_time: i64, + revoked_retention_seconds: i64, + ) -> anyhow::Result<()>; +} + +#[derive(Debug, Clone, Default)] +pub struct InMemoryAuthStore { + clients: Arc>>, + keyset: Arc>>, + refresh_tokens: Arc>>, + revoked: Arc>>, +} + +impl InMemoryAuthStore { + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait::async_trait] +impl AuthStore for InMemoryAuthStore { + async fn get_client(&self, client_id: &str) -> anyhow::Result> { + Ok(self.clients.read().await.get(client_id).cloned()) + } + + async fn set_client(&self, client: ClientRecord) -> anyhow::Result<()> { + self.clients + .write() + .await + .insert(client.client_id.clone(), client); + Ok(()) + } + + async fn get_keyset(&self) -> anyhow::Result> { + Ok(self.keyset.read().await.clone()) + } + + async fn set_keyset(&self, keyset: KeySet) -> anyhow::Result<()> { + *self.keyset.write().await = Some(keyset); + Ok(()) + } + + async fn create_keyset_if_absent(&self, keyset: KeySet) -> anyhow::Result { + let mut current = self.keyset.write().await; + if let Some(existing) = current.clone() { + return Ok(existing); + } + *current = Some(keyset.clone()); + Ok(keyset) + } + + async fn rotate_keyset( + &self, + new_key: KeyRecord, + current_time: i64, + rotation_overlap_seconds: i64, + ) -> anyhow::Result { + let mut current = self.keyset.write().await; + let mut keyset = current.clone().unwrap_or_else(|| KeySet { + current_kid: new_key.kid.clone(), + keys: Vec::new(), + }); + if !keyset.keys.is_empty() { + for key in &mut keyset.keys { + if key.kid == keyset.current_kid && key.retire_after.is_none() { + key.retire_after = Some(current_time + rotation_overlap_seconds); + } + } + keyset + .keys + .retain(|key| key.retire_after.is_none_or(|retire| retire > current_time)); + } + keyset.current_kid.clone_from(&new_key.kid); + keyset.keys.push(new_key); + *current = Some(keyset.clone()); + Ok(keyset) + } + + async fn get_refresh_token( + &self, + token_id: &str, + ) -> anyhow::Result> { + Ok(self.refresh_tokens.read().await.get(token_id).cloned()) + } + + async fn set_refresh_token(&self, record: RefreshTokenRecord) -> anyhow::Result<()> { + self.refresh_tokens + .write() + .await + .insert(record.token_id.clone(), record); + Ok(()) + } + + async fn rotate_refresh_token( + &self, + old_token_id: &str, + new_record: RefreshTokenRecord, + ) -> anyhow::Result<()> { + let mut refresh_tokens = self.refresh_tokens.write().await; + let mut revoked = self.revoked.write().await; + refresh_tokens.insert(new_record.token_id.clone(), new_record); + revoked.insert(old_token_id.to_string(), Utc::now().timestamp()); + Ok(()) + } + + async fn is_revoked(&self, token_id: &str) -> anyhow::Result { + Ok(self.revoked.read().await.contains_key(token_id)) + } + + async fn revoke(&self, token_id: &str) -> anyhow::Result<()> { + self.revoked + .write() + .await + .insert(token_id.to_string(), Utc::now().timestamp()); + Ok(()) + } + + async fn cleanup_expired_tokens( + &self, + current_time: i64, + revoked_retention_seconds: i64, + ) -> anyhow::Result<()> { + self.refresh_tokens + .write() + .await + .retain(|_, record| record.expires_at > current_time); + let revoked_cutoff = current_time.saturating_sub(revoked_retention_seconds); + self.revoked + .write() + .await + .retain(|_, created_at| *created_at >= revoked_cutoff); + Ok(()) + } +} + +pub struct IiiStateAuthStore { + iii: Arc, + timeout_ms: u64, + lock: Arc>, +} + +impl IiiStateAuthStore { + pub fn new(iii: Arc, timeout_ms: u64) -> Self { + Self { + iii, + timeout_ms, + lock: Arc::new(Mutex::new(())), + } + } + + async fn get_value(&self, scope: &str, key: &str) -> anyhow::Result> { + let resp = self + .iii + .trigger(TriggerRequest { + function_id: "state::get".into(), + payload: json!({ "scope": scope, "key": key }), + action: None, + timeout_ms: Some(self.timeout_ms), + }) + .await + .map_err(|e| anyhow::anyhow!("state::get failed: {e}"))?; + if resp.is_null() { + Ok(None) + } else { + Ok(Some(resp)) + } + } + + async fn set_value(&self, scope: &str, key: &str, value: Value) -> anyhow::Result<()> { + self.iii + .trigger(TriggerRequest { + function_id: "state::set".into(), + payload: json!({ "scope": scope, "key": key, "value": value }), + action: None, + timeout_ms: Some(self.timeout_ms), + }) + .await + .map_err(|e| anyhow::anyhow!("state::set failed: {e}"))?; + Ok(()) + } + + async fn delete_value(&self, scope: &str, key: &str) -> anyhow::Result<()> { + self.iii + .trigger(TriggerRequest { + function_id: "state::delete".into(), + payload: json!({ "scope": scope, "key": key }), + action: None, + timeout_ms: Some(self.timeout_ms), + }) + .await + .map_err(|e| anyhow::anyhow!("state::delete failed: {e}"))?; + Ok(()) + } + + async fn list_values(&self, scope: &str) -> anyhow::Result> { + let resp = self + .iii + .trigger(TriggerRequest { + function_id: "state::list".into(), + payload: json!({ "scope": scope }), + action: None, + timeout_ms: Some(self.timeout_ms), + }) + .await + .map_err(|e| anyhow::anyhow!("state::list failed: {e}"))?; + serde_json::from_value(resp).map_err(Into::into) + } +} + +#[async_trait::async_trait] +impl AuthStore for IiiStateAuthStore { + async fn get_client(&self, client_id: &str) -> anyhow::Result> { + self.get_value(CLIENTS_SCOPE, client_id) + .await? + .map(serde_json::from_value) + .transpose() + .map_err(Into::into) + } + + async fn set_client(&self, client: ClientRecord) -> anyhow::Result<()> { + let key = client.client_id.clone(); + self.set_value(CLIENTS_SCOPE, &key, serde_json::to_value(client)?) + .await + } + + async fn get_keyset(&self) -> anyhow::Result> { + self.get_value(JWKS_SCOPE, KEYSET_KEY) + .await? + .map(serde_json::from_value) + .transpose() + .map_err(Into::into) + } + + async fn set_keyset(&self, keyset: KeySet) -> anyhow::Result<()> { + self.set_value(JWKS_SCOPE, KEYSET_KEY, serde_json::to_value(keyset)?) + .await + } + + async fn create_keyset_if_absent(&self, keyset: KeySet) -> anyhow::Result { + let _guard = self.lock.lock().await; + if let Some(existing) = self.get_keyset().await? { + return Ok(existing); + } + self.set_keyset(keyset.clone()).await?; + Ok(keyset) + } + + async fn rotate_keyset( + &self, + new_key: KeyRecord, + current_time: i64, + rotation_overlap_seconds: i64, + ) -> anyhow::Result { + let _guard = self.lock.lock().await; + let mut keyset = self.get_keyset().await?.unwrap_or_else(|| KeySet { + current_kid: new_key.kid.clone(), + keys: Vec::new(), + }); + if !keyset.keys.is_empty() { + for key in &mut keyset.keys { + if key.kid == keyset.current_kid && key.retire_after.is_none() { + key.retire_after = Some(current_time + rotation_overlap_seconds); + } + } + keyset + .keys + .retain(|key| key.retire_after.is_none_or(|retire| retire > current_time)); + } + keyset.current_kid.clone_from(&new_key.kid); + keyset.keys.push(new_key); + self.set_keyset(keyset.clone()).await?; + Ok(keyset) + } + + async fn get_refresh_token( + &self, + token_id: &str, + ) -> anyhow::Result> { + self.get_value(TOKENS_SCOPE, &format!("refresh:{token_id}")) + .await? + .map(serde_json::from_value) + .transpose() + .map_err(Into::into) + } + + async fn set_refresh_token(&self, record: RefreshTokenRecord) -> anyhow::Result<()> { + self.set_value( + TOKENS_SCOPE, + &format!("refresh:{}", record.token_id), + serde_json::to_value(record)?, + ) + .await + } + + async fn rotate_refresh_token( + &self, + old_token_id: &str, + new_record: RefreshTokenRecord, + ) -> anyhow::Result<()> { + let _guard = self.lock.lock().await; + self.set_refresh_token(new_record.clone()).await?; + if let Err(err) = self.revoke(old_token_id).await { + let _ = self + .delete_value(TOKENS_SCOPE, &format!("refresh:{}", new_record.token_id)) + .await; + return Err(err); + } + Ok(()) + } + + async fn is_revoked(&self, token_id: &str) -> anyhow::Result { + Ok(self + .get_value(TOKENS_SCOPE, &format!("revoked:{token_id}")) + .await? + .is_some()) + } + + async fn revoke(&self, token_id: &str) -> anyhow::Result<()> { + self.set_value( + TOKENS_SCOPE, + &format!("revoked:{token_id}"), + serde_json::to_value(RevokedTokenRecord { + token_id: token_id.to_string(), + created_at: Utc::now().timestamp(), + })?, + ) + .await + } + + async fn cleanup_expired_tokens( + &self, + current_time: i64, + revoked_retention_seconds: i64, + ) -> anyhow::Result<()> { + let _guard = self.lock.lock().await; + let revoked_cutoff = current_time.saturating_sub(revoked_retention_seconds); + for value in self.list_values(TOKENS_SCOPE).await? { + if let Ok(record) = serde_json::from_value::(value.clone()) { + if record.expires_at <= current_time { + self.delete_value(TOKENS_SCOPE, &format!("refresh:{}", record.token_id)) + .await?; + } + continue; + } + if let Ok(record) = serde_json::from_value::(value) { + if record.created_at < revoked_cutoff { + self.delete_value(TOKENS_SCOPE, &format!("revoked:{}", record.token_id)) + .await?; + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn memory_cleanup_prunes_expired_tokens() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + store + .set_refresh_token(RefreshTokenRecord { + token_id: "expired".to_string(), + client_id: "client".to_string(), + subject: "client".to_string(), + scopes: vec!["mcp:tools".to_string()], + expires_at: Utc::now().timestamp() - 1, + }) + .await?; + store.revoke("old-revoked").await?; + store + .cleanup_expired_tokens(Utc::now().timestamp() + 1, 0) + .await?; + assert!(store.get_refresh_token("expired").await?.is_none()); + assert!(!store.is_revoked("old-revoked").await?); + Ok(()) + } +} diff --git a/auth/tests/integration.rs b/auth/tests/integration.rs new file mode 100644 index 00000000..09bf5a4a --- /dev/null +++ b/auth/tests/integration.rs @@ -0,0 +1,64 @@ +use iii_auth::config::{AuthConfig, StoreBackend}; +use iii_auth::store::InMemoryAuthStore; +use iii_auth::{register_client, token_endpoint, validate_session}; +use serde_json::json; + +fn cfg() -> AuthConfig { + let admin_env = format!( + "III_AUTH_INTEGRATION_ADMIN_{}", + uuid::Uuid::new_v4().simple() + ); + std::env::set_var(&admin_env, "admin-secret"); + AuthConfig { + issuer: "https://auth.test".to_string(), + audience: "iii-test".to_string(), + store: StoreBackend::Memory, + supported_scopes: vec![ + "mcp:tools".to_string(), + "function:demo::read".to_string(), + "trigger:http".to_string(), + ], + default_scopes: vec!["mcp:tools".to_string()], + registration_admin_token_env: admin_env, + ..AuthConfig::default() + } +} + +#[tokio::test] +async fn dcr_token_validate_flow() -> anyhow::Result<()> { + let store = InMemoryAuthStore::new(); + let cfg = cfg(); + let registration = register_client( + &store, + &cfg, + json!({ + "headers": { "authorization": "Bearer admin-secret" }, + "client_name": "integration", + "scope": "function:demo::read trigger:http" + }), + ) + .await?; + let token = token_endpoint( + &store, + &cfg, + json!({ + "grant_type": "client_credentials", + "client_id": registration["client_id"], + "client_secret": registration["client_secret"], + "scope": "function:demo::read trigger:http" + }), + ) + .await?; + let decision = validate_session( + &store, + &cfg, + json!({ "headers": { "Authorization": format!("Bearer {}", token["access_token"].as_str().unwrap()) } }), + ) + .await?; + assert_eq!(decision.allowed_functions, vec!["demo::read"]); + assert_eq!( + decision.allowed_trigger_types, + Some(vec!["http".to_string()]) + ); + Ok(()) +} diff --git a/auth/tests/manifest.rs b/auth/tests/manifest.rs new file mode 100644 index 00000000..7f115473 --- /dev/null +++ b/auth/tests/manifest.rs @@ -0,0 +1,13 @@ +use std::process::Command; + +#[test] +fn manifest_command_outputs_json() { + let output = Command::new(env!("CARGO_BIN_EXE_iii-auth")) + .arg("--manifest") + .output() + .expect("run manifest command"); + assert!(output.status.success()); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["name"], "auth"); + assert!(json["default_config"].is_object()); +} diff --git a/auth/tests/skill.rs b/auth/tests/skill.rs new file mode 100644 index 00000000..a846923c --- /dev/null +++ b/auth/tests/skill.rs @@ -0,0 +1,14 @@ +#[test] +fn skill_starts_with_heading_and_lists_functions() { + let body = include_str!("../skills/index.md"); + assert!(body.starts_with("# auth\n")); + assert!(body.contains("auth::validate")); + assert!(body.contains("auth::server_metadata")); + assert!(body.contains("auth::resource_metadata")); + assert!(body.contains("auth::register")); + assert!(body.contains("auth::jwks")); + assert!(body.contains("auth::jwks_rotate")); + assert!(body.contains("auth::token")); + assert!(body.contains("auth::introspect")); + assert!(body.contains("auth::revoke")); +} diff --git a/registry/index.json b/registry/index.json index cdb9bd2d..a906ef42 100644 --- a/registry/index.json +++ b/registry/index.json @@ -31,6 +31,39 @@ }, "version": "0.1.0" }, + "auth": { + "type": "binary", + "description": "OAuth authority under auth::*: RBAC validation, discovery, DCR, JWKS, token issuance.", + "repo": "iii-hq/workers", + "tag_prefix": "auth", + "supported_targets": [ + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu" + ], + "has_checksum": true, + "default_config": { + "environment": "local", + "engine_url": "ws://127.0.0.1:49134", + "issuer": "https://127.0.0.1:3111", + "audience": "iii", + "idp_mode": "local", + "store": "iii_state", + "default_scopes": [ + "mcp:tools" + ], + "supported_scopes": [ + "mcp:tools", + "a2a:message" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ], + "registration_admin_token_env": "III_AUTH_REGISTRATION_TOKEN", + "state_timeout_ms": 5000 + }, + "version": "0.1.0" + }, "auth-credentials": { "type": "binary", "description": "Provider credential vault under auth::* — API keys and OAuth tokens.",