diff --git a/.cargo/config.toml b/.cargo/config.toml index 44d2b923f..1f703c889 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,6 +5,7 @@ dev = "run --package qp-dev-cli" subgraphs = "run --package subgraphs" test_all = "test --workspace" test_qp = "test --package hive-router-query-planner -- --nocapture" +test_e2e = "test --package e2e -- --nocapture" test_qpe = "test --package hive-router-plan-executor -- --nocapture" "clippy:fix" = "clippy --all --fix --allow-dirty --allow-staged" "router-config" = "run --release -p hive-router-config router-config.schema.json" diff --git a/Cargo.lock b/Cargo.lock index fb2ab35a1..7cdc8282e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,9 +14,9 @@ dependencies = [ [[package]] name = "addr2line" -version = "0.25.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] @@ -125,9 +125,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anstyle-parse" @@ -352,9 +352,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.6" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core", "base64 0.22.1", @@ -372,7 +372,8 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "serde_core", + "rustversion", + "serde", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -388,9 +389,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.5" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" dependencies = [ "bytes", "futures-core", @@ -399,6 +400,7 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", + "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -407,9 +409,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.76" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", "cfg-if", @@ -417,7 +419,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-link 0.2.0", + "windows-targets 0.52.6", ] [[package]] @@ -546,9 +548,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.40" +version = "1.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb" +checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" dependencies = [ "find-msvc-tools", "jobserver", @@ -779,9 +781,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.18" +version = "0.15.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180e549344080374f9b32ed41bf3b6b57885ff6a289367b3dbc10eea8acc1918" +checksum = "cef036f0ecf99baef11555578630e2cca559909b4c50822dbba828c252d21c49" dependencies = [ "async-trait", "convert_case 0.6.0", @@ -857,6 +859,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1065,7 +1077,7 @@ checksum = "881c5d0a13b2f1498e2306e82cbada78390e152d4b1378fb28a84f4dcd0dc4f3" dependencies = [ "dispatch", "nix", - "windows-sys 0.61.1", + "windows-sys 0.61.0", ] [[package]] @@ -1149,7 +1161,7 @@ dependencies = [ "hashbrown 0.14.5", "lock_api", "once_cell", - "parking_lot_core 0.9.12", + "parking_lot_core 0.9.11", ] [[package]] @@ -1169,12 +1181,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.4" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" dependencies = [ "powerfmt", - "serde_core", + "serde", ] [[package]] @@ -1282,6 +1294,23 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "e2e" +version = "0.0.1" +dependencies = [ + "hive-router", + "hive-router-config", + "insta", + "jsonwebtoken", + "lazy_static", + "ntex", + "sonic-rs", + "subgraphs", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "either" version = "1.15.0" @@ -1364,7 +1393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.1", + "windows-sys 0.61.0", ] [[package]] @@ -1440,9 +1469,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.3" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" [[package]] name = "fixedbitset" @@ -1613,6 +1642,20 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1653,9 +1696,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.32.3" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" @@ -1760,6 +1803,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1807,6 +1852,7 @@ name = "hive-router" version = "0.0.11" dependencies = [ "async-trait", + "cookie", "futures", "graphql-parser", "graphql-tools", @@ -1816,16 +1862,19 @@ dependencies = [ "http", "http-body-util", "hyper", + "jsonwebtoken", "lazy_static", "mimalloc", "moka", "ntex", "rand 0.9.2", "regex-automata", + "reqwest", "serde", "sonic-rs", - "thiserror 2.0.17", + "thiserror 2.0.16", "tokio", + "tokio-util", "tracing", "tracing-subscriber", "tracing-tree", @@ -1840,10 +1889,11 @@ dependencies = [ "config", "http", "humantime-serde", + "jsonwebtoken", "schemars 1.0.4", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.16", ] [[package]] @@ -1875,7 +1925,7 @@ dependencies = [ "serde", "sonic-rs", "subgraphs", - "thiserror 2.0.17", + "thiserror 2.0.16", "tokio", "tracing", "vrl", @@ -1893,11 +1943,11 @@ dependencies = [ "insta", "lazy-init", "lazy_static", - "petgraph 0.8.3", + "petgraph 0.8.2", "rustc-hash", "serde", "sonic-rs", - "thiserror 2.0.17", + "thiserror 2.0.16", "tokio-util", "tracing", "tracing-subscriber", @@ -2061,9 +2111,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.0", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2078,7 +2130,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.0", ] [[package]] @@ -2356,9 +2408,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ "once_cell", "wasm-bindgen", @@ -2401,6 +2453,21 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "keccak" version = "0.1.5" @@ -2455,9 +2522,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.176" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libmimalloc-sys" @@ -2492,10 +2559,11 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" -version = "0.4.14" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ + "autocfg", "scopeguard", ] @@ -2505,6 +2573,19 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2547,9 +2628,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "mimalloc" @@ -2605,22 +2686,23 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.11" +version = "0.12.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" dependencies = [ "async-lock", "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", - "equivalent", "event-listener", "futures-util", - "parking_lot 0.12.5", + "loom", + "parking_lot 0.12.4", "portable-atomic", "rustc_version", "smallvec", "tagptr", + "thiserror 1.0.69", "uuid", ] @@ -2643,18 +2725,18 @@ dependencies = [ [[package]] name = "munge" -version = "0.4.7" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +checksum = "d7feb0b48aa0a25f9fe0899482c6e1379ee7a11b24a53073eacdecb9adb6dc60" dependencies = [ "munge_macro", ] [[package]] name = "munge_macro" -version = "0.4.7" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +checksum = "f2e3795a5d2da581a8b252fec6022eee01aea10161a4d1bf237d4cbe47f7e988" dependencies = [ "proc-macro2", "quote", @@ -2771,7 +2853,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sha1", - "thiserror 2.0.17", + "thiserror 2.0.16", "variadics_please", ] @@ -2814,7 +2896,7 @@ dependencies = [ "ntex-service", "ntex-util", "pin-project-lite", - "thiserror 2.0.17", + "thiserror 2.0.16", ] [[package]] @@ -2830,7 +2912,7 @@ dependencies = [ "log", "ntex-bytes", "serde", - "thiserror 2.0.17", + "thiserror 2.0.16", ] [[package]] @@ -2861,9 +2943,9 @@ dependencies = [ [[package]] name = "ntex-net" -version = "2.8.1" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a53c65509e4e3abf6490514e98a570d4cbcdbf18628e9569a02cc1d47c1e29b9" +checksum = "8e25de68e90b2f1f15a765366e170b0d5b2d2fe0f81db03673505998a009f991" dependencies = [ "bitflags 2.9.4", "cfg-if", @@ -2876,7 +2958,7 @@ dependencies = [ "ntex-service", "ntex-tokio", "ntex-util", - "thiserror 2.0.17", + "thiserror 2.0.16", ] [[package]] @@ -2980,7 +3062,7 @@ dependencies = [ "ntex-service", "pin-project-lite", "slab", - "thiserror 2.0.17", + "thiserror 2.0.16", ] [[package]] @@ -3089,9 +3171,9 @@ dependencies = [ [[package]] name = "object" -version = "0.37.3" +version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "memchr", ] @@ -3202,9 +3284,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.5.3+3.5.4" +version = "300.5.2+3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6bad8cd0233b63971e232cc9c5e83039375b8586d2312f31fda85db8f888c2" +checksum = "d270b79e2926f5150189d475bc7e9d2c69f9c4697b185fa917d5a32b792d21b4" dependencies = [ "cc", ] @@ -3266,12 +3348,12 @@ dependencies = [ [[package]] name = "parking_lot" -version = "0.12.5" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", - "parking_lot_core 0.9.12", + "parking_lot_core 0.9.11", ] [[package]] @@ -3290,15 +3372,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.12" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall 0.5.17", "smallvec", - "windows-link 0.2.0", + "windows-targets 0.52.6", ] [[package]] @@ -3319,6 +3401,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9ed2178b0575fff8e1b83b58ba6f75e727aafac2e1b6c795169ad3b17eb518" +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64 0.22.1", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3327,19 +3419,20 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.3" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +checksum = "21e0a3a33733faeaf8651dfee72dd0f388f0c8e5ad496a3478fa5a922f49cfa8" dependencies = [ "memchr", + "thiserror 2.0.16", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.3" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +checksum = "bc58706f770acb1dbd0973e6530a3cff4746fb721207feb3a8a6064cd0b6c663" dependencies = [ "pest", "pest_generator", @@ -3347,9 +3440,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.3" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +checksum = "6d4f36811dfe07f7b8573462465d5cb8965fffc2e71ae377a33aecf14c2c9a2f" dependencies = [ "pest", "pest_meta", @@ -3360,9 +3453,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.3" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +checksum = "42919b05089acbd0a5dcd5405fb304d17d1053847b81163d09c4ad18ce8e8420" dependencies = [ "pest", "sha2", @@ -3380,9 +3473,9 @@ dependencies = [ [[package]] name = "petgraph" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +checksum = "54acf3a685220b533e437e264e4d932cfbdc4cc7ec0cd232ed73c08d03b8a7ca" dependencies = [ "fixedbitset", "hashbrown 0.15.5", @@ -3474,7 +3567,7 @@ dependencies = [ "hermit-abi", "pin-project-lite", "rustix", - "windows-sys 0.61.1", + "windows-sys 0.61.0", ] [[package]] @@ -3559,7 +3652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.106", @@ -3587,9 +3680,9 @@ dependencies = [ [[package]] name = "psl" -version = "2.1.147" +version = "2.1.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b0efc2f09945ea5ef176dc4f1cb703b0efa41b112d5eb27489afc880db860a7" +checksum = "53297a72c400b31c5facd8e50894d08d20b74ee74925b28a20d51fe48c863583" dependencies = [ "psl-types", ] @@ -3602,18 +3695,18 @@ checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" [[package]] name = "ptr_meta" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +checksum = "fe9e76f66d3f9606f44e45598d155cb13ecf09f4a28199e48daf8c8fc937ea90" dependencies = [ "ptr_meta_derive", ] [[package]] name = "ptr_meta_derive" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +checksum = "ca414edb151b4c8d125c12566ab0d74dc9cdba36fb80eb7b848c15f495fd32d1" dependencies = [ "proc-macro2", "quote", @@ -3666,7 +3759,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.0", - "thiserror 2.0.17", + "thiserror 2.0.16", "tokio", "tracing", "web-time", @@ -3687,7 +3780,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.16", "tinyvec", "tracing", "web-time", @@ -3704,14 +3797,14 @@ dependencies = [ "once_cell", "socket2 0.6.0", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] name = "quote" -version = "1.0.41" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -3730,9 +3823,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rancor" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +checksum = "caf5f7161924b9d1cea0e4cabc97c372cea92b5f927fc13c6bca67157a0ad947" dependencies = [ "ptr_meta", ] @@ -3827,27 +3920,27 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.18" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags 2.9.4", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", @@ -3863,16 +3956,16 @@ dependencies = [ "ahash", "fluent-uri", "once_cell", - "parking_lot 0.12.5", + "parking_lot 0.12.4", "percent-encoding", "serde_json", ] [[package]] name = "regex" -version = "1.11.3" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -3882,9 +3975,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", @@ -3913,9 +4006,9 @@ checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "rend" -version = "0.5.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +checksum = "a35e8a6bf28cd121053a66aa2e6a2e3eaffad4a60012179f0e864aa5ffeff215" [[package]] name = "reqwest" @@ -3925,6 +4018,7 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "h2", "http", @@ -3932,9 +4026,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3946,6 +4043,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -4017,9 +4115,9 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.12" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a640b26f007713818e9a9b65d34da1cf58538207b052916a83d80e43f3ffa4" +checksum = "19f5c3e5da784cd8c69d32cdc84673f3204536ca56e1fa01be31a74b92c932ac" dependencies = [ "bytes", "hashbrown 0.15.5", @@ -4035,9 +4133,9 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.12" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd83f5f173ff41e00337d97f6572e416d022ef8a19f371817259ae960324c482" +checksum = "4270433626cffc9c4c1d3707dd681f2a2718d3d7b09ad754bec137acecda8d22" dependencies = [ "proc-macro2", "quote", @@ -4113,7 +4211,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.1", + "windows-sys 0.61.0", ] [[package]] @@ -4154,9 +4252,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.7" +version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ "ring", "rustls-pki-types", @@ -4199,7 +4297,7 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.61.1", + "windows-sys 0.61.0", ] [[package]] @@ -4239,6 +4337,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4295,9 +4399,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" dependencies = [ "serde_core", "serde_derive", @@ -4317,18 +4421,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", @@ -4393,9 +4497,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.15.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6093cd8c01b25262b84927e0f7151692158fab02d961e04c979d3903eba7ecc5" +checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" dependencies = [ "base64 0.22.1", "chrono", @@ -4404,7 +4508,8 @@ dependencies = [ "indexmap 2.11.4", "schemars 0.9.0", "schemars 1.0.4", - "serde_core", + "serde", + "serde_derive", "serde_json", "serde_with_macros", "time", @@ -4412,9 +4517,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.15.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e6c180db0816026a61afa1cff5344fb7ebded7e4d3062772179f2501481c27" +checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" dependencies = [ "darling 0.21.3", "proc-macro2", @@ -4530,6 +4635,18 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.16", + "time", +] + [[package]] name = "siphasher" version = "1.0.1" @@ -4628,9 +4745,9 @@ dependencies = [ [[package]] name = "sonic-rs" -version = "0.5.5" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22540d56ba14521e4878ad436d498518c59698c39a89d5905c694932f0bf7134" +checksum = "b0e0c5c1c429b500b4583e860ed48f9e34968cb1ba49dd16c1e2743678e3fd18" dependencies = [ "ahash", "bumpalo", @@ -4644,7 +4761,7 @@ dependencies = [ "simdutf8", "sonic-number", "sonic-simd", - "thiserror 2.0.17", + "thiserror 2.0.16", ] [[package]] @@ -4681,7 +4798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", - "parking_lot 0.12.5", + "parking_lot 0.12.4", "phf_shared 0.11.3", "precomputed-hash", ] @@ -4732,6 +4849,7 @@ dependencies = [ "axum", "lazy_static", "rand 0.9.2", + "sonic-rs", "tokio", ] @@ -4793,6 +4911,27 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -4801,15 +4940,15 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.61.1", + "windows-sys 0.61.0", ] [[package]] @@ -4818,7 +4957,7 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2111ef44dae28680ae9752bb89409e7310ca33a8c621ebe7b106cf5c928b3ac0" dependencies = [ - "windows-sys 0.61.1", + "windows-sys 0.61.0", ] [[package]] @@ -4841,11 +4980,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.16", ] [[package]] @@ -4861,9 +5000,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -4965,7 +5104,7 @@ dependencies = [ "io-uring", "libc", "mio", - "parking_lot 0.12.5", + "parking_lot 0.12.4", "pin-project-lite", "signal-hook-registry", "slab", @@ -4997,9 +5136,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" dependencies = [ "rustls", "tokio", @@ -5018,9 +5157,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.28.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", @@ -5209,9 +5348,9 @@ dependencies = [ [[package]] name = "tracing-tree" -version = "0.4.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac87aa03b6a4d5a7e4810d1a80c19601dbe0f8a837e9177f23af721c7ba7beec" +checksum = "f459ca79f1b0d5f71c54ddfde6debfc59c8b6eeb46808ae492077f739dc7b49c" dependencies = [ "nu-ansi-term", "tracing-core", @@ -5227,9 +5366,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", @@ -5238,7 +5377,7 @@ dependencies = [ "log", "rand 0.9.2", "sha1", - "thiserror 2.0.17", + "thiserror 2.0.16", "utf-8", ] @@ -5256,9 +5395,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "ua-parser" @@ -5301,9 +5440,9 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -5508,7 +5647,7 @@ dependencies = [ "strip-ansi-escapes", "syslog_loose", "termcolor", - "thiserror 2.0.17", + "thiserror 2.0.16", "tokio", "tracing", "ua-parser", @@ -5581,9 +5720,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", @@ -5594,9 +5733,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.104" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", @@ -5608,9 +5747,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" dependencies = [ "cfg-if", "js-sys", @@ -5621,9 +5760,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5631,9 +5770,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", @@ -5644,18 +5783,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.54" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e381134e148c1062f965a42ed1f5ee933eef2927c3f70d1812158f711d39865" +checksum = "aee0a0f5343de9221a0d233b04520ed8dc2e6728dce180b1dcd9288ec9d9fa3c" dependencies = [ "js-sys", "minicov", @@ -5666,9 +5805,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.54" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b673bca3298fe582aeef8352330ecbad91849f85090805582400850f8270a2e8" +checksum = "a369369e4360c2884c3168d22bded735c43cccae97bbc147586d4b480edd138d" dependencies = [ "proc-macro2", "quote", @@ -5692,9 +5831,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" dependencies = [ "js-sys", "wasm-bindgen", @@ -5732,7 +5871,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.1", + "windows-sys 0.61.0", ] [[package]] @@ -5741,24 +5880,70 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + [[package]] name = "windows-core" -version = "0.62.1" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" dependencies = [ "windows-implement", "windows-interface", "windows-link 0.2.0", - "windows-result", - "windows-strings", + "windows-result 0.4.0", + "windows-strings 0.5.0", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] name = "windows-implement" -version = "0.60.1" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", @@ -5767,9 +5952,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.2" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", @@ -5788,6 +5973,36 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.0" @@ -5797,6 +6012,15 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-strings" version = "0.5.0" @@ -5835,9 +6059,9 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.61.1" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" dependencies = [ "windows-link 0.2.0", ] @@ -5865,14 +6089,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" dependencies = [ "windows-link 0.2.0", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -5883,9 +6116,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -5895,9 +6128,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -5907,9 +6140,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -5919,9 +6152,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -5931,9 +6164,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -5943,9 +6176,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -5955,9 +6188,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -5967,9 +6200,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" @@ -6086,9 +6319,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 23d11c651..0e25d8670 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "lib/executor", "bin/dev-cli", "bin/router", + "e2e", "bench/subgraphs", ] resolver = "2" @@ -51,3 +52,5 @@ xxhash-rust = { version = "0.8.15", features = ["xxh3"] } tokio = { version = "1.47.1", features = ["full"] } tokio-util = { version = "0.7.16" } rand = "0.9.2" +jsonwebtoken = "9.3.1" +ntex = { version = "2", features = ["tokio"] } diff --git a/bench/subgraphs/Cargo.toml b/bench/subgraphs/Cargo.toml index e37d4e0af..7d1f940bf 100644 --- a/bench/subgraphs/Cargo.toml +++ b/bench/subgraphs/Cargo.toml @@ -16,6 +16,7 @@ path = "lib.rs" lazy_static = { workspace = true } rand = { workspace = true } tokio = { workspace = true } +sonic-rs = { workspace = true } async-graphql = "7.0.17" async-graphql-axum = "7.0.17" diff --git a/bench/subgraphs/lib.rs b/bench/subgraphs/lib.rs index 9bfc53ea4..c8304c02f 100644 --- a/bench/subgraphs/lib.rs +++ b/bench/subgraphs/lib.rs @@ -2,3 +2,155 @@ pub mod accounts; pub mod inventory; pub mod products; pub mod reviews; + +use async_graphql_axum::GraphQL; +use axum::{ + body::{to_bytes, Bytes}, + extract::{Request, State}, + http::{self, request::Parts, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post_service}, + Router, +}; +use sonic_rs::Value; +use std::{collections::HashMap, env::var, sync::Arc}; +use tokio::{ + net::TcpListener, + sync::{ + oneshot::{self, Sender}, + Mutex, + }, + task::JoinHandle, +}; + +extern crate lazy_static; + +async fn delay_middleware(req: Request, next: Next) -> Response { + let delay_ms: Option = std::env::var("SUBGRAPH_DELAY_MS") + .ok() + .and_then(|s| s.parse().ok()) + .filter(|d| *d != 0); + + if let Some(delay_ms) = delay_ms { + tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; + } + + next.run(req).await +} + +async fn add_subgraph_header(req: Request, next: Next) -> Response { + let path = req.uri().path(); + let subgraph_name = path.trim_start_matches('/').to_string(); + + let mut response = next.run(req).await; + + if !subgraph_name.is_empty() && subgraph_name != "health" { + if let Ok(header_value) = subgraph_name.parse() { + response.headers_mut().insert("x-subgraph", header_value); + } + } + + response +} + +async fn track_requests( + State(state): State, + request: Request, + next: Next, +) -> impl IntoResponse { + let path = request.uri().path().to_string(); + let (parts, body) = request.into_parts(); + let body_bytes = to_bytes(body, usize::MAX).await.unwrap(); + let record = extract_record(&parts, body_bytes.clone()); + let mut log = state.request_log.lock().await; + + log.entry(path).or_default().push(record); + let new_body = axum::body::Body::from(body_bytes); + let request = Request::from_parts(parts, new_body); + + next.run(request).await +} + +fn extract_record(request_parts: &Parts, request_body: Bytes) -> RequestLog { + let header_map = request_parts.headers.clone(); + let body_value: Value = sonic_rs::from_slice(&request_body).unwrap(); + + RequestLog { + headers: header_map, + request_body: body_value, + } +} + +async fn health_check_handler() -> impl IntoResponse { + StatusCode::OK +} + +#[derive(Debug, Clone, Default)] +pub struct RequestLog { + pub headers: http::HeaderMap, + pub request_body: Value, +} + +#[derive(Clone)] +pub struct SubgraphsServiceState { + pub request_log: Arc>>>, +} + +pub fn start_subgraphs_server( + port: Option, +) -> (JoinHandle<()>, Sender<()>, SubgraphsServiceState) { + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let host = var("HOST").unwrap_or("0.0.0.0".to_owned()); + let port = port + .map(|v| v.to_string()) + .unwrap_or(var("PORT").unwrap_or("4200".to_owned())); + + let shared_state = SubgraphsServiceState { + request_log: Arc::new(Mutex::new(HashMap::new())), + }; + + let app = Router::new() + .route( + "/accounts", + post_service(GraphQL::new(accounts::get_subgraph())), + ) + .route( + "/inventory", + post_service(GraphQL::new(inventory::get_subgraph())), + ) + .route( + "/products", + post_service(GraphQL::new(products::get_subgraph())), + ) + .route( + "/reviews", + post_service(GraphQL::new(reviews::get_subgraph())), + ) + .layer(middleware::from_fn_with_state( + shared_state.clone(), + track_requests, + )) + .route("/health", get(health_check_handler)) + .route_layer(middleware::from_fn(add_subgraph_header)) + .route_layer(middleware::from_fn(delay_middleware)); + + println!("Starting server on http://{}:{}", host, port); + + let server_handle = tokio::spawn(async move { + axum::serve( + TcpListener::bind(&format!("{}:{}", host, port)) + .await + .unwrap(), + app, + ) + .with_graceful_shutdown(async { + shutdown_rx.await.ok(); + println!("Graceful shutdown signal received."); + }) + .await + .expect("failed to start subgraphs server"); + }); + + (server_handle, shutdown_tx, shared_state) +} diff --git a/bench/subgraphs/main.rs b/bench/subgraphs/main.rs index 496cf5149..08c645c43 100644 --- a/bench/subgraphs/main.rs +++ b/bench/subgraphs/main.rs @@ -1,88 +1,10 @@ -use async_graphql_axum::GraphQL; -use axum::{ - extract::Request, - http::StatusCode, - middleware::{self, Next}, - response::{IntoResponse, Response}, - routing::{get, post_service}, - Router, -}; -use std::env::var; -use tokio::net::TcpListener; - -mod accounts; -mod inventory; -mod products; -mod reviews; - -extern crate lazy_static; - -async fn delay_middleware(req: Request, next: Next) -> Response { - let delay_ms: Option = std::env::var("SUBGRAPH_DELAY_MS") - .ok() - .and_then(|s| s.parse().ok()) - .filter(|d| *d != 0); - - if let Some(delay_ms) = delay_ms { - tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; - } - - next.run(req).await -} - -async fn add_subgraph_header(req: Request, next: Next) -> Response { - let path = req.uri().path(); - let subgraph_name = path.trim_start_matches('/').to_string(); - - let mut response = next.run(req).await; - - if !subgraph_name.is_empty() && subgraph_name != "health" { - if let Ok(header_value) = subgraph_name.parse() { - response.headers_mut().insert("x-subgraph", header_value); - } - } - - response -} - -async fn health_check_handler() -> impl IntoResponse { - StatusCode::OK -} +use subgraphs::start_subgraphs_server; #[tokio::main] async fn main() { - let host = var("HOST").unwrap_or("0.0.0.0".to_owned()); - let port = var("PORT").unwrap_or("4200".to_owned()); - - let app = Router::new() - .route( - "/accounts", - post_service(GraphQL::new(accounts::get_subgraph())), - ) - .route( - "/inventory", - post_service(GraphQL::new(inventory::get_subgraph())), - ) - .route( - "/products", - post_service(GraphQL::new(products::get_subgraph())), - ) - .route( - "/reviews", - post_service(GraphQL::new(reviews::get_subgraph())), - ) - .route("/health", get(health_check_handler)) - .route_layer(middleware::from_fn(add_subgraph_header)) - .route_layer(middleware::from_fn(delay_middleware)); - - println!("Starting server on http://localhost:4200"); + let (server_handle, _shutdown_tx, _shared_state) = start_subgraphs_server(None); - axum::serve( - TcpListener::bind(&format!("{}:{}", host, port)) - .await - .unwrap(), - app, - ) - .await - .unwrap(); + server_handle + .await + .expect("subgraph server failed to start"); } diff --git a/bin/router/Cargo.toml b/bin/router/Cargo.toml index aa5bde31b..22ca55029 100644 --- a/bin/router/Cargo.toml +++ b/bin/router/Cargo.toml @@ -38,8 +38,12 @@ async-trait = { workspace = true } xxhash-rust = { workspace = true } rand = { workspace = true } regex-automata = "0.4.10" +jsonwebtoken = { workspace = true } mimalloc = { version = "0.1.47", features = ["override"] } moka = { version = "0.12.10", features = ["future"] } ulid = "1.2.1" -ntex = { version = "2", features = ["tokio"] } +ntex = { workspace = true } +tokio-util = "0.7.16" +reqwest = "0.12.23" +cookie = "0.18.1" diff --git a/bin/router/src/background_tasks/mod.rs b/bin/router/src/background_tasks/mod.rs new file mode 100644 index 000000000..c67210467 --- /dev/null +++ b/bin/router/src/background_tasks/mod.rs @@ -0,0 +1,56 @@ +use async_trait::async_trait; +use futures::future::join_all; +use std::sync::Arc; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info}; + +#[async_trait] +pub trait BackgroundTask: Send + Sync { + fn id(&self) -> &str; + async fn run(&self, token: CancellationToken); +} + +pub struct BackgroundTasksManager { + cancellation_token: CancellationToken, + task_handles: Vec>, +} + +impl Default for BackgroundTasksManager { + fn default() -> Self { + Self::new() + } +} + +impl BackgroundTasksManager { + pub fn new() -> Self { + Self { + cancellation_token: CancellationToken::new(), + task_handles: Vec::new(), + } + } + + pub fn register_task(&mut self, task: Arc) + where + T: BackgroundTask + 'static, + { + info!("registering background task: {}", task.id()); + let child_token = self.cancellation_token.clone(); + + let handle = tokio::spawn(async move { + task.run(child_token).await; + }); + + self.task_handles.push(handle); + } + + pub async fn shutdown(self) { + info!("shutdown triggered, stopping all background tasks..."); + self.cancellation_token.cancel(); + + debug!("waiting for background tasks to finish..."); + join_all(self.task_handles).await; + + println!("all background tasks have been shut down gracefully."); + } +} diff --git a/bin/router/src/jwt/context.rs b/bin/router/src/jwt/context.rs new file mode 100644 index 000000000..dd8b3397c --- /dev/null +++ b/bin/router/src/jwt/context.rs @@ -0,0 +1,66 @@ +use std::collections::HashMap; + +use hive_router_plan_executor::execution::jwt_forward::JwtAuthForwardingPlan; +use jsonwebtoken::TokenData; +use serde::{Deserialize, Serialize}; +use sonic_rs::Value; + +use crate::jwt::errors::JwtForwardingError; + +pub type JwtTokenPayload = TokenData; + +#[derive(Debug, Clone)] +pub struct JwtRequestContext { + // The payload extracted from the JWT token, and the extensions key to inject it into the request + pub token_payload: Option<(String, JwtTokenPayload)>, +} + +impl TryInto> for JwtRequestContext { + type Error = JwtForwardingError; + + fn try_into(self) -> Result, Self::Error> { + if let Some((extension_field_name, payload)) = &self.token_payload { + return Ok(Some(JwtAuthForwardingPlan { + extension_field_name: extension_field_name.clone(), + extension_field_value: sonic_rs::to_value(&payload.claims)?, + })); + } + + Ok(None) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(untagged)] +pub enum Audience { + Single(String), + Multiple(Vec), +} + +// Based on https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JwtClaims { + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub nbf: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub iat: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub jti: Option, + + #[serde(flatten)] + pub additional_claims: HashMap, +} diff --git a/bin/router/src/jwt/errors.rs b/bin/router/src/jwt/errors.rs new file mode 100644 index 000000000..4e7da0438 --- /dev/null +++ b/bin/router/src/jwt/errors.rs @@ -0,0 +1,107 @@ +use crate::pipeline::error::FailedExecutionResult; +use hive_router_plan_executor::response::graphql_error::{GraphQLError, GraphQLErrorExtensions}; +use http::StatusCode; +use ntex::{ + http::{ + header::{InvalidHeaderValue, ToStrError}, + ResponseBuilder, + }, + web, +}; + +#[derive(Debug, thiserror::Error)] +pub enum LookupError { + #[error("failed to locate the value in the incoming request")] + LookupFailed, + #[error("prefix does not match the found value")] + MismatchedPrefix, + #[error("failed to convert header to string")] + FailedToStringifyHeader(ToStrError), + #[error("failed to parse header value")] + FailedToParseHeader(InvalidHeaderValue), +} + +#[derive(Debug, thiserror::Error)] +pub enum JwtError { + #[error("jwt header lookup failed: {0}")] + LookupFailed(LookupError), + #[error("failed to parse JWT header: {0}")] + InvalidJwtHeader(jsonwebtoken::errors::Error), + #[error("failed to decode JWK: {0}")] + InvalidDecodingKey(jsonwebtoken::errors::Error), + #[error("token is not supported by any of the configured providers")] + FailedToLocateProvider, + #[error("failed to locate algorithm in jwk")] + JwkMissingAlgorithm, + #[error("jwk algorithm is not supported: {0}")] + JwkAlgorithmNotSupported(jsonwebtoken::errors::Error), + #[error("failed to decode token: {0}")] + FailedToDecodeToken(jsonwebtoken::errors::Error), + #[error("all jwk failed to decode token: {0:?}")] + AllProvidersFailedToDecode(Vec), + #[error("http request parsing error: {0:?}")] + HTTPRequestParsingError(String), +} + +impl JwtError { + pub fn make_response(&self) -> web::HttpResponse { + let validation_error_result = FailedExecutionResult { + errors: Some(vec![self.into()]), + }; + + ResponseBuilder::new(self.into()).json(&validation_error_result) + } + + pub fn error_code(&self) -> &'static str { + match self { + JwtError::AllProvidersFailedToDecode(_) => "MISSING_JWT", + JwtError::FailedToDecodeToken(_) => "INVALID_JWT", + JwtError::FailedToLocateProvider => "JWT_NOT_SUPPORTED", + JwtError::HTTPRequestParsingError(_) => "FAILED_TO_PARSE_REQUEST", + JwtError::InvalidJwtHeader(_) => "INVALID_JWT_HEADER", + JwtError::InvalidDecodingKey(_) => "INTERNAL_SERVER_ERROR", + JwtError::JwkAlgorithmNotSupported(_) => "JWK_ALGORITHM_NOT_SUPPORTED", + JwtError::JwkMissingAlgorithm => "JWK_MISSING_ALGORITHM", + JwtError::LookupFailed(_) => "JWT_LOOKUP_FAILED", + } + } +} + +impl From<&JwtError> for StatusCode { + fn from(val: &JwtError) -> Self { + match val { + JwtError::LookupFailed(_) => StatusCode::UNAUTHORIZED, + JwtError::JwkAlgorithmNotSupported(_) | JwtError::HTTPRequestParsingError(_) => { + StatusCode::BAD_REQUEST + } + JwtError::AllProvidersFailedToDecode(_) + | JwtError::InvalidJwtHeader(_) + | JwtError::JwkMissingAlgorithm + | JwtError::InvalidDecodingKey(_) + | JwtError::FailedToLocateProvider + | JwtError::FailedToDecodeToken(_) => StatusCode::FORBIDDEN, + } + } +} + +impl From<&JwtError> for GraphQLError { + fn from(val: &JwtError) -> Self { + GraphQLError { + extensions: GraphQLErrorExtensions { + code: Some(val.error_code().to_string()), + ..Default::default() + }, + message: val.to_string(), + locations: None, + path: None, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum JwtForwardingError { + #[error("failed to serialized jwt claims")] + ClaimsSerializeError(#[from] sonic_rs::Error), + #[error("failed to parse as valid header value")] + ValueIsNotValidHeader(#[from] http::header::InvalidHeaderValue), +} diff --git a/bin/router/src/jwt/jwks_manager.rs b/bin/router/src/jwt/jwks_manager.rs new file mode 100644 index 000000000..7011a4501 --- /dev/null +++ b/bin/router/src/jwt/jwks_manager.rs @@ -0,0 +1,184 @@ +use hive_router_config::jwt_auth::{JwksProviderSourceConfig, JwtAuthConfig}; +use sonic_rs::from_str; +use std::sync::{Arc, RwLock}; +use tokio::fs::read_to_string; +use tokio_util::sync::CancellationToken; +use tracing::debug; + +use jsonwebtoken::jwk::JwkSet; + +use crate::background_tasks::{BackgroundTask, BackgroundTasksManager}; + +pub struct JwksManager { + sources: Vec>, +} + +impl JwksManager { + pub fn from_config(config: &JwtAuthConfig) -> Self { + let sources = config + .jwks_providers + .iter() + .map(|config| Arc::new(JwksSource::new(config.clone()))) + .collect(); + + JwksManager { sources } + } + + pub fn all(&self) -> Vec> { + self.sources + .iter() + .filter_map(|v| match v.get_jwk_set() { + Ok(set) => Some(set), + Err(err) => { + tracing::error!("Failed to use JWK set: {}, ignoring", err); + + None + } + }) + .collect() + } + + pub async fn prefetch_sources(&self) -> Result<(), JwksSourceError> { + for source in &self.sources { + if source.should_prefetch() { + match source.load_and_store_jwks().await { + Ok(_) => {} + Err(err) => return Err(err), + } + } + } + + Ok(()) + } + + pub fn register_background_tasks(&self, background_tasks_mgr: &mut BackgroundTasksManager) { + for source in &self.sources { + if source.should_poll_in_background() { + background_tasks_mgr.register_task(source.clone()); + } + } + } +} + +#[derive(Debug)] +pub struct JwksSource { + config: JwksProviderSourceConfig, + jwk: RwLock>>, +} + +#[async_trait::async_trait] +impl BackgroundTask for JwksSource { + fn id(&self) -> &str { + "jwt_auth_jwks" + } + + async fn run(&self, token: CancellationToken) { + if let JwksProviderSourceConfig::Remote { + polling_interval: Some(interval), + .. + } = &self.config + { + debug!("Starting remote jwks polling for source: {:?}", self.config); + let mut tokio_interval = tokio::time::interval(*interval); + + loop { + tokio::select! { + _ = tokio_interval.tick() => { match self.load_and_store_jwks().await { + Ok(_) => {} + Err(err) => { + tracing::error!("Failed to load remote jwks: {}", err); + } + } } + _ = token.cancelled() => { println!("Shutting down."); return; } + } + } + } + } +} + +#[derive(thiserror::Error, Debug)] +pub enum JwksSourceError { + #[error("failed to load remote jwks: {0}")] + RemoteJwksNetworkError(reqwest::Error), + #[error("failed to load file jwks: {0}")] + FileJwksNetworkError(std::io::Error), + #[error("failed to parse jwks json file: {0}")] + JwksContentInvalidStructure(sonic_rs::Error), + #[error("failed to acquire jwks handle")] + FailedToAcquireJwk, +} + +impl JwksSource { + async fn load_and_store_jwks(&self) -> Result<&Self, JwksSourceError> { + let jwks_str = match &self.config { + JwksProviderSourceConfig::Remote { url, .. } => { + let client = reqwest::Client::new(); + tracing::debug!("loading jwks from a remote source: {}", url); + + let response_text = client + .get(url) + .send() + .await + .map_err(JwksSourceError::RemoteJwksNetworkError)? + .text() + .await + .map_err(JwksSourceError::RemoteJwksNetworkError)?; + + response_text + } + JwksProviderSourceConfig::File { file, .. } => { + tracing::debug!("loading jwks from a file source: {}", file.absolute); + + let file_contents = read_to_string(&file.absolute) + .await + .map_err(JwksSourceError::FileJwksNetworkError)?; + + file_contents + } + }; + + let new_jwk = Arc::new( + from_str::(&jwks_str).map_err(JwksSourceError::JwksContentInvalidStructure)?, + ); + + if let Ok(mut w_jwk) = self.jwk.write() { + *w_jwk = Some(new_jwk); + } + + Ok(self) + } + + pub fn new(config: JwksProviderSourceConfig) -> Self { + Self { + config, + jwk: RwLock::new(None), + } + } + + pub fn should_poll_in_background(&self) -> bool { + match &self.config { + JwksProviderSourceConfig::Remote { .. } => true, + JwksProviderSourceConfig::File { .. } => false, + } + } + + pub fn should_prefetch(&self) -> bool { + match &self.config { + JwksProviderSourceConfig::Remote { prefetch, .. } => match prefetch { + Some(prefetch) => *prefetch, + None => false, + }, + JwksProviderSourceConfig::File { .. } => true, + } + } + + pub fn get_jwk_set(&self) -> Result, JwksSourceError> { + if let Ok(jwk) = self.jwk.try_read() { + if let Some(jwk) = jwk.as_ref() { + return Ok(jwk.clone()); + } + } + + Err(JwksSourceError::FailedToAcquireJwk) + } +} diff --git a/bin/router/src/jwt/mod.rs b/bin/router/src/jwt/mod.rs new file mode 100644 index 000000000..99f4e434e --- /dev/null +++ b/bin/router/src/jwt/mod.rs @@ -0,0 +1,297 @@ +pub mod context; +pub mod errors; +pub mod jwks_manager; + +use std::{str::FromStr, sync::Arc}; + +use cookie::Cookie; +use hive_router_config::jwt_auth::{JwtAuthConfig, JwtAuthPluginLookupLocation}; +use http::header::COOKIE; +use jsonwebtoken::{ + decode, decode_header, + jwk::{Jwk, JwkSet}, + Algorithm, DecodingKey, Header, Validation, +}; +use ntex::{http::header::HeaderValue, web::HttpRequest}; +use tracing::warn; + +use crate::{ + background_tasks::BackgroundTasksManager, + jwt::{ + context::{Audience, JwtClaims, JwtRequestContext, JwtTokenPayload}, + errors::{JwtError, LookupError}, + jwks_manager::{JwksManager, JwksSourceError}, + }, +}; + +pub struct JwtAuthRuntime { + config: JwtAuthConfig, + jwks: JwksManager, +} + +impl JwtAuthRuntime { + pub async fn init( + background_tasks_mgr: &mut BackgroundTasksManager, + config: &JwtAuthConfig, + ) -> Result { + let jwks = JwksManager::from_config(config); + + // If any of the sources needs to be prefetched (loaded when the server starts), then we'll + // try to load it now, and fail if it fails. + jwks.prefetch_sources().await?; + + // Register background tasks for refreshing JWKS keys + jwks.register_background_tasks(background_tasks_mgr); + + let instance = JwtAuthRuntime { + config: config.clone(), + jwks, + }; + + Ok(instance) + } + + fn lookup(&self, req: &HttpRequest) -> Result { + for lookup_config in &self.config.lookup_locations { + match lookup_config { + JwtAuthPluginLookupLocation::Header { name, prefix } => { + if let Some(header_value) = req.headers().get(name.get_header_ref()) { + let header_str = match header_value.to_str() { + Ok(s) => s, + Err(e) => return Err(LookupError::FailedToStringifyHeader(e)), + }; + + let header_value: HeaderValue = match header_str.parse() { + Ok(v) => v, + Err(e) => return Err(LookupError::FailedToParseHeader(e)), + }; + + match prefix { + Some(prefix) => match header_value + .to_str() + .ok() + .and_then(|s| s.strip_prefix(prefix)) + { + Some(stripped_value) => { + return Ok(stripped_value.trim().to_string()); + } + None => { + return Err(LookupError::MismatchedPrefix); + } + }, + None => { + return Ok(header_value.to_str().unwrap_or("").to_string()); + } + } + } + } + JwtAuthPluginLookupLocation::Cookie { name } => { + if let Some(cookie_raw) = req.headers().get(COOKIE) { + let raw_cookies = match cookie_raw.to_str() { + Ok(cookies) => cookies.split(';'), + Err(e) => { + warn!("jwt auth failed to convert cookie header to string, ignoring cookie. error: {}", e); + continue; + } + }; + + for item in raw_cookies { + match Cookie::parse(item) { + Ok(v) => { + let (cookie_name, cookie_value) = v.name_value_trimmed(); + + if cookie_name == name { + return Ok(cookie_value.to_string()); + } + } + Err(e) => { + // Should we reject the entire request in case of invalid cookies? + // I think it's better to consider this as a user error? maybe return 400? + warn!( + "jwt auth failed to parse cookie value, ignoring cookie. error: {}", + e + ); + } + } + } + } + } + } + } + + Err(LookupError::LookupFailed) + } + + pub(crate) fn find_matching_jwks<'a>( + &'a self, + jwt_header: &Header, + jwks: &'a Vec>, + ) -> Result<&'a JwkSet, JwtError> { + // If `kid` is vailable on the header, we can try to match it to the `kid` on the available JWKs. + if let Some(jwt_kid) = &jwt_header.kid { + for jwk in jwks { + for key in &jwk.keys { + if key.common.key_id.as_ref().is_some_and(|v| v == jwt_kid) { + return Ok(jwk); + } + } + } + } + + // If we don't have `kid` on the token, we should try to match the `alg` field. + for jwk in jwks { + for key in &jwk.keys { + if let Some(key_alg) = key.common.key_algorithm { + let key_alg_cmp = Algorithm::from_str(&key_alg.to_string()) + .map_err(JwtError::JwkAlgorithmNotSupported)?; + if key_alg_cmp == jwt_header.alg { + return Ok(jwk); + } + } + } + } + + Err(JwtError::FailedToLocateProvider) + } + + fn authenticate( + &self, + jwks: &Vec>, + req: &HttpRequest, + ) -> Result<(JwtTokenPayload, String), JwtError> { + match self.lookup(req) { + Ok(token) => { + // First, we need to decode the header to determine which provider to use. + let header = decode_header(&token).map_err(JwtError::InvalidJwtHeader)?; + let jwk = self.find_matching_jwks(&header, jwks)?; + + self.decode_and_validate_token(&token, &jwk.keys) + .map(|token_data| (token_data, token)) + } + Err(e) => { + warn!("jwt plugin failed to lookup token. error: {}", e); + + Err(JwtError::LookupFailed(e)) + } + } + } + + fn decode_and_validate_token( + &self, + token: &str, + jwks: &[Jwk], + ) -> Result { + let decode_attempts = jwks.iter().map(|jwk| self.try_decode_from_jwk(token, jwk)); + + if let Some(success) = decode_attempts.clone().find(|result| result.is_ok()) { + return success; + } + + Err(JwtError::AllProvidersFailedToDecode( + decode_attempts + .into_iter() + .map(|result: Result| result.unwrap_err()) + .collect::>(), + )) + } + + fn try_decode_from_jwk(&self, token: &str, jwk: &Jwk) -> Result { + let decoding_key = DecodingKey::from_jwk(jwk).map_err(JwtError::InvalidDecodingKey)?; + let key_alg = jwk + .common + .key_algorithm + .ok_or(JwtError::JwkMissingAlgorithm)?; + + let alg = Algorithm::from_str(&key_alg.to_string()) + .map_err(JwtError::JwkAlgorithmNotSupported)?; + + let mut validation = Validation::new(alg); + + // This only validates the existence of the claim, it does not validate the values, we'll do it after decoding. + if let Some(iss) = &self.config.issuers { + validation.set_issuer(iss); + } + + // This only validates the existence of the claim, it does not validate the values, we'll do it after decoding. + if let Some(aud) = &self.config.audiences { + validation.set_audience(aud); + } + + let token_data = match decode::(token, &decoding_key, &validation) { + Ok(data) => data, + Err(e) => return Err(JwtError::FailedToDecodeToken(e)), + }; + + match (&self.config.issuers, &token_data.claims.iss) { + (Some(issuers), Some(token_iss)) => { + if !issuers.contains(token_iss) { + return Err(JwtError::FailedToDecodeToken( + jsonwebtoken::errors::ErrorKind::InvalidIssuer.into(), + )); + } + } + (Some(_), None) => { + return Err(JwtError::FailedToDecodeToken( + jsonwebtoken::errors::ErrorKind::InvalidIssuer.into(), + )); + } + _ => {} + }; + + match (&self.config.audiences, &token_data.claims.aud) { + (Some(audiences), Some(token_aud)) => { + let all_valid = match token_aud { + Audience::Single(s) => audiences.contains(s), + Audience::Multiple(s) => s.iter().all(|v| audiences.contains(v)), + }; + + if !all_valid { + return Err(JwtError::FailedToDecodeToken( + jsonwebtoken::errors::ErrorKind::InvalidAudience.into(), + )); + } + } + (Some(_), None) => { + return Err(JwtError::FailedToDecodeToken( + jsonwebtoken::errors::ErrorKind::InvalidAudience.into(), + )); + } + _ => {} + }; + + Ok(token_data) + } + + pub fn validate_request(&self, request: &mut HttpRequest) -> Result<(), JwtError> { + let valid_jwks = self.jwks.all(); + + match self.authenticate(&valid_jwks, request) { + Ok((token_data, _token)) => { + let mut jwt_ctx = JwtRequestContext { + token_payload: None, + }; + + if self.config.forward_claims_to_upstream_extensions.enabled { + jwt_ctx.token_payload = Some(( + self.config + .forward_claims_to_upstream_extensions + .field_name + .clone(), + token_data, + )); + } + + request.extensions_mut().insert(jwt_ctx); + } + Err(e) => { + warn!("jwt token error: {}", e); + + if self.config.require_authentication.is_some_and(|v| v) { + return Err(e); + } + } + } + + Ok(()) + } +} diff --git a/bin/router/src/lib.rs b/bin/router/src/lib.rs index 5e7379c8e..6e2585b41 100644 --- a/bin/router/src/lib.rs +++ b/bin/router/src/lib.rs @@ -1,4 +1,6 @@ +pub mod background_tasks; mod http_utils; +mod jwt; mod logger; mod pipeline; mod shared_state; @@ -6,19 +8,22 @@ mod shared_state; use std::sync::Arc; use crate::{ + background_tasks::BackgroundTasksManager, http_utils::{health::health_check_handler, landing_page::landing_page_handler}, + jwt::JwtAuthRuntime, logger::configure_logging, pipeline::graphql_request_handler, shared_state::RouterSharedState, }; -use hive_router_config::load_config; +use hive_router_config::{load_config, HiveRouterConfig}; use ntex::{ util::Bytes, web::{self, HttpRequest}, }; use hive_router_query_planner::utils::parsing::parse_schema; +use tracing::info; async fn graphql_endpoint_handler( mut request: HttpRequest, @@ -48,21 +53,45 @@ pub async fn router_entrypoint() -> Result<(), Box> { let config_path = std::env::var("ROUTER_CONFIG_FILE_PATH").ok(); let router_config = load_config(config_path)?; configure_logging(&router_config.log); - - let supergraph_sdl = router_config.supergraph.load().await?; - let parsed_schema = parse_schema(&supergraph_sdl); let addr = router_config.http.address(); - let shared_state = RouterSharedState::new(parsed_schema, router_config)?; + let mut bg_tasks_manager = BackgroundTasksManager::new(); + let shared_state = configure_app_from_config(router_config, &mut bg_tasks_manager).await?; - web::HttpServer::new(move || { + let maybe_error = web::HttpServer::new(move || { web::App::new() .state(shared_state.clone()) - .route("/graphql", web::to(graphql_endpoint_handler)) - .route("/health", web::to(health_check_handler)) + .configure(configure_ntex_app) .default_service(web::to(landing_page_handler)) }) .bind(addr)? .run() .await - .map_err(|err| err.into()) + .map_err(|err| err.into()); + + info!("server stopped, clearning background tasks"); + bg_tasks_manager.shutdown().await; + + maybe_error +} + +pub async fn configure_app_from_config( + router_config: HiveRouterConfig, + bg_tasks_manager: &mut BackgroundTasksManager, +) -> Result, Box> { + let supergraph_sdl = router_config.supergraph.load().await?; + let parsed_schema = parse_schema(&supergraph_sdl); + + let jwt_runtime = match &router_config.jwt { + Some(jwt_config) => Some(JwtAuthRuntime::init(bg_tasks_manager, jwt_config).await?), + None => None, + }; + + let shared_state = RouterSharedState::new(parsed_schema, router_config, jwt_runtime)?; + + Ok(shared_state) +} + +pub fn configure_ntex_app(cfg: &mut web::ServiceConfig) { + cfg.route("/graphql", web::to(graphql_endpoint_handler)) + .route("/health", web::to(health_check_handler)); } diff --git a/bin/router/src/pipeline/error.rs b/bin/router/src/pipeline/error.rs index 2ce4569ff..49a6ee8a0 100644 --- a/bin/router/src/pipeline/error.rs +++ b/bin/router/src/pipeline/error.rs @@ -15,7 +15,10 @@ use ntex::{ }; use serde::{Deserialize, Serialize}; -use crate::pipeline::header::{RequestAccepts, APPLICATION_GRAPHQL_RESPONSE_JSON_STR}; +use crate::{ + jwt::errors::JwtForwardingError, + pipeline::header::{RequestAccepts, APPLICATION_GRAPHQL_RESPONSE_JSON_STR}, +}; #[derive(Debug)] pub struct PipelineError { @@ -80,6 +83,10 @@ pub enum PipelineErrorVariant { // HTTP Security-related errors #[error("Required CSRF header(s) not present")] CsrfPreventionFailed, + + // JWT-auth plugin errors + #[error("Failed to forward jwt: {0}")] + JwtForwardingError(JwtForwardingError), } impl PipelineErrorVariant { @@ -115,6 +122,7 @@ impl PipelineErrorVariant { match (self, prefer_ok) { (Self::PlannerError(_), _) => StatusCode::INTERNAL_SERVER_ERROR, (Self::PlanExecutionError(_), _) => StatusCode::INTERNAL_SERVER_ERROR, + (Self::JwtForwardingError(_), _) => StatusCode::INTERNAL_SERVER_ERROR, (Self::UnsupportedHttpMethod(_), _) => StatusCode::METHOD_NOT_ALLOWED, (Self::InvalidHeaderValue(_), _) => StatusCode::BAD_REQUEST, (Self::GetUnprocessableQueryParams(_), _) => StatusCode::BAD_REQUEST, diff --git a/bin/router/src/pipeline/execution.rs b/bin/router/src/pipeline/execution.rs index 1c902097c..2fec6165d 100644 --- a/bin/router/src/pipeline/execution.rs +++ b/bin/router/src/pipeline/execution.rs @@ -2,11 +2,13 @@ use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; +use crate::jwt::context::JwtRequestContext; use crate::pipeline::coerce_variables::CoerceVariablesPayload; use crate::pipeline::error::{PipelineError, PipelineErrorFromAcceptHeader, PipelineErrorVariant}; use crate::pipeline::normalize::GraphQLNormalizationPayload; use crate::shared_state::RouterSharedState; use hive_router_plan_executor::execute_query_plan; +use hive_router_plan_executor::execution::jwt_forward::JwtAuthForwardingPlan; use hive_router_plan_executor::execution::plan::{ ClientRequestDetails, OperationDetails, PlanExecutionOutput, QueryPlanExecutionContext, }; @@ -65,6 +67,17 @@ pub async fn execute_plan<'a>( metadata: &app_state.schema_metadata, }; + let jwt_context = { + let req_extensions = req.extensions(); + req_extensions.get::().cloned() + }; + let jwt_forward_plan: Option = match jwt_context { + Some(jwt_context) => jwt_context + .try_into() + .map_err(|e| req.new_pipeline_error(PipelineErrorVariant::JwtForwardingError(e)))?, + None => None, + }; + execute_query_plan(QueryPlanExecutionContext { query_plan: query_plan_payload, projection_plan: &normalized_payload.projection_plan, @@ -89,6 +102,7 @@ pub async fn execute_plan<'a>( introspection_context: &introspection_context, operation_type_name: normalized_payload.root_type_name, executors: &app_state.subgraph_executor_map, + jwt_auth_forwarding: &jwt_forward_plan, }) .await .map_err(|err| { diff --git a/bin/router/src/pipeline/mod.rs b/bin/router/src/pipeline/mod.rs index f82fd3354..183184ef7 100644 --- a/bin/router/src/pipeline/mod.rs +++ b/bin/router/src/pipeline/mod.rs @@ -55,6 +55,13 @@ pub async fn graphql_request_handler( .body(GRAPHIQL_HTML); } + if let Some(jwt) = &state.jwt_auth_runtime { + match jwt.validate_request(req) { + Ok(_) => (), + Err(err) => return err.make_response(), + } + } + match execute_pipeline(req, body_bytes, state).await { Ok(response) => { let response_bytes = Bytes::from(response.body); diff --git a/bin/router/src/shared_state.rs b/bin/router/src/shared_state.rs index ee00da34d..3dc2ac387 100644 --- a/bin/router/src/shared_state.rs +++ b/bin/router/src/shared_state.rs @@ -16,6 +16,7 @@ use hive_router_query_planner::{ }; use moka::future::Cache; +use crate::jwt::JwtAuthRuntime; use crate::pipeline::{ cors::{CORSConfigError, Cors}, normalize::GraphQLNormalizationPayload, @@ -33,12 +34,14 @@ pub struct RouterSharedState { pub router_config: HiveRouterConfig, pub headers_plan: HeaderRulesPlan, pub cors: Option, + pub jwt_auth_runtime: Option, } impl RouterSharedState { pub fn new( parsed_supergraph_sdl: Document<'static, String>, router_config: HiveRouterConfig, + jwt_auth_runtime: Option, ) -> Result, SharedStateError> { let supergraph_state = SupergraphState::new(&parsed_supergraph_sdl); let planner = @@ -63,6 +66,7 @@ impl RouterSharedState { normalize_cache: moka::future::Cache::new(1000), cors: Cors::from_config(&router_config.cors).map_err(Box::new)?, router_config, + jwt_auth_runtime, })) } } diff --git a/docs/README.md b/docs/README.md index abf6278df..4d62f3fbd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,9 +8,10 @@ |[**csrf**](#csrf)|`object`|Configuration for CSRF prevention.
Default: `{"required_headers":[]}`
|| |[**headers**](#headers)|`object`|Configuration for the headers.
Default: `{}`
|| |[**http**](#http)|`object`|Configuration for the HTTP server/listener.
Default: `{"host":"0.0.0.0","port":4000}`
|| +|[**jwt**](#jwt)|`object`, `null`|Configuration for JWT authentication plugin.
|yes| |[**log**](#log)|`object`|The router logger configuration.
Default: `{"filter":null,"format":"json","level":"info"}`
|| |[**query\_planner**](#query_planner)|`object`|Query planning configuration.
Default: `{"allow_expose":false,"timeout":"10s"}`
|| -|[**supergraph**](#supergraph)|`object`|Configuration for the Federation supergraph source. By default, the router will use a local file-based supergraph source (`./supergraph.graphql`).
Default: `{"path":"supergraph.graphql","source":"file"}`
|| +|[**supergraph**](#supergraph)|`object`|Configuration for the Federation supergraph source. By default, the router will use a local file-based supergraph source (`./supergraph.graphql`).
|| |[**traffic\_shaping**](#traffic_shaping)|`object`|Configuration for the traffic-shaper executor. Use these configurations to control how requests are being executed to subgraphs.
Default: `{"dedupe_enabled":true,"max_connections_per_host":100,"pool_idle_timeout_seconds":50}`
|| **Additional Properties:** not allowed @@ -60,9 +61,7 @@ log: query_planner: allow_expose: false timeout: 10s -supergraph: - path: supergraph.graphql - source: file +supergraph: {} traffic_shaping: dedupe_enabled: true max_connections_per_host: 100 @@ -1273,6 +1272,7 @@ Configuration for the HTTP server/listener. |**host**|`string`|The host address to bind the HTTP server to.
Default: `"0.0.0.0"`
|| |**port**|`integer`|The port to bind the HTTP server to.

If you are running the router inside a Docker container, please ensure that the port is exposed correctly using `-p :` flag.
Default: `4000`
Format: `"uint16"`
Minimum: `0`
Maximum: `65535`
|| +**Additional Properties:** not allowed **Example** ```yaml @@ -1281,6 +1281,177 @@ port: 4000 ``` + +## jwt: object,null + +Configuration for JWT authentication plugin. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|[**allowed\_algorithms**](#jwtallowed_algorithms)|`string[]`|List of allowed algorithms for verifying the JWT signature.
Default: `"HS256"`, `"HS384"`, `"HS512"`, `"RS256"`, `"RS384"`, `"RS512"`, `"ES256"`, `"ES384"`, `"PS256"`, `"PS384"`, `"PS512"`, `"EdDSA"`
|no| +|[**audiences**](#jwtaudiences)|`string[]`|The list of [JWT audiences](https://tools.ietf.org/html/rfc7519#section-4.1.3) are allowed to access.
|no| +|[**forward\_claims\_to\_upstream\_extensions**](#jwtforward_claims_to_upstream_extensions)|`object`|Forward the JWT claims to the upstream service using GraphQL's `.extensions`.
Default: `{"enabled":false,"field_name":"jwt"}`
|yes| +|[**issuers**](#jwtissuers)|`string[]`|Specify the [principal](https://tools.ietf.org/html/rfc7519#section-4.1.1) that issued the JWT, usually a URL or an email address.
|no| +|[**jwks\_providers**](#jwtjwks_providers)|`array`|A list of JWKS providers to use for verifying the JWT signature.
|yes| +|[**lookup\_locations**](#jwtlookup_locations)|`array`|A list of locations to look up for the JWT token in the incoming HTTP request.
Default: `{"name":"authorization","prefix":"Bearer","source":"header"}`
|no| +|**require\_authentication**|`boolean`, `null`|If set to `true`, the entire request will be rejected if the JWT token is not present in the request.
|no| + +**Additional Properties:** not allowed + +### jwt\.allowed\_algorithms\[\]: array,null + +List of allowed algorithms for verifying the JWT signature. +If not specified, the default list of all supported algorithms in [`jsonwebtoken` crate](https://crates.io/crates/jsonwebtoken) are used. + + +**Items** + +**Item Type:** `string` +**Example** + +```yaml +- HS256 +- HS384 +- HS512 +- RS256 +- RS384 +- RS512 +- ES256 +- ES384 +- PS256 +- PS384 +- PS512 +- EdDSA + +``` + + +### jwt\.audiences\[\]: array,null + +The list of [JWT audiences](https://tools.ietf.org/html/rfc7519#section-4.1.3) are allowed to access. +If this field is set, the token's `aud` field must be one of the values in this list, otherwise the token's `aud` field is not checked. + + +**Items** + +**Item Type:** `string` + +### jwt\.forward\_claims\_to\_upstream\_extensions: object + +Forward the JWT claims to the upstream service using GraphQL's `.extensions`. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**enabled**|`boolean`||yes| +|**field\_name**|`string`||yes| + +**Example** + +```yaml +enabled: false +field_name: jwt + +``` + + +### jwt\.issuers\[\]: array,null + +Specify the [principal](https://tools.ietf.org/html/rfc7519#section-4.1.1) that issued the JWT, usually a URL or an email address. +If specified, it has to match the `iss` field in JWT, otherwise the token's `iss` field is not checked. + + +**Items** + +**Item Type:** `string` + +### jwt\.jwks\_providers\[\]: array + +A list of JWKS providers to use for verifying the JWT signature. +Can be either a path to a local JSON of the file-system, or a URL to a remote JWKS provider. + + +**Items** + +  +**Option 1 (alternative):** +A local file on the file-system. This file will be read once on startup and cached. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**path**|`string`|A path to a local file on the file-system. Relative to the location of the root configuration file.
Format: `"path"`
|yes| +|**source**|`string`|Constant Value: `"file"`
|yes| + + +  +**Option 2 (alternative):** +A remote JWKS provider. The JWKS will be fetched via HTTP/HTTPS and cached. + + +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**polling\_interval**|`string`|How often the JWKS should be polled for updates.
Default: `"10m"`
|no| +|**prefetch**|`boolean`, `null`|If set to `true`, the JWKS will be fetched on startup and cached. In case of invalid JWKS, the error will be ignored and the plugin will try to fetch again when server receives the first request.
If set to `false`, the JWKS will be fetched on-demand, when the first request comes in.
|no| +|**source**|`string`|Constant Value: `"remote"`
|yes| +|**url**|`string`|The URL to fetch the JWKS key set from, via HTTP/HTTPS.
|yes| + +**Example** + +```yaml +polling_interval: 10m + +``` + + + +### jwt\.lookup\_locations\[\]: array + +A list of locations to look up for the JWT token in the incoming HTTP request. +The first one that is found will be used. + + +**Items** + +  +**Option 1 (alternative):** +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**name**|`string`|A valid HTTP header name, according to RFC 7230.
Pattern: `^[A-Za-z0-9!#$%&'*+\-.^_\`\|~]+$`
|yes| +|**prefix**|`string`, `null`||no| +|**source**|`string`|Constant Value: `"header"`
|yes| + + +  +**Option 2 (alternative):** +**Properties** + +|Name|Type|Description|Required| +|----|----|-----------|--------| +|**name**|`string`||yes| +|**source**|`string`|Constant Value: `"cookies"`
|yes| + + +**Example** + +```yaml +- name: authorization + prefix: Bearer + source: header + +``` + ## log: object @@ -1297,6 +1468,7 @@ The router is configured to be mostly silent (`info`) level, and will print only |**format**|`string`|Default: `"json"`
Enum: `"pretty-tree"`, `"pretty-compact"`, `"json"`
|| |**level**|`string`|Default: `"info"`
Enum: `"trace"`, `"debug"`, `"info"`, `"warn"`, `"error"`
|| +**Additional Properties:** not allowed **Example** ```yaml @@ -1319,6 +1491,7 @@ Query planning configuration. |**allow\_expose**|`boolean`|A flag to allow exposing the query plan in the response.
When set to `true` and an incoming request has a `hive-expose-query-plan: true` header, the query plan will be exposed in the response, as part of `extensions`.
Default: `false`
|| |**timeout**|`string`|The maximum time for the query planner to create an execution plan.
This acts as a safeguard against overly complex or malicious queries that could degrade server performance.
When the timeout is reached, the planning process is cancelled.

Default: 10s.
Default: `"10s"`
|| +**Additional Properties:** not allowed **Example** ```yaml @@ -1347,14 +1520,7 @@ The path can be either absolute or relative to the router's working directory. |**path**|`string`|Format: `"path"`
|yes| |**source**|`string`|Constant Value: `"file"`
|yes| - -**Example** - -```yaml -path: supergraph.graphql -source: file - -``` +**Additional Properties:** not allowed ## traffic\_shaping: object @@ -1370,6 +1536,7 @@ Configuration for the traffic-shaper executor. Use these configurations to contr |**max\_connections\_per\_host**|`integer`|Limits the concurrent amount of requests/connections per host/subgraph.
Default: `100`
Format: `"uint"`
Minimum: `0`
|| |**pool\_idle\_timeout\_seconds**|`integer`|Timeout for idle sockets being kept-alive.
Default: `50`
Format: `"uint64"`
Minimum: `0`
|| +**Additional Properties:** not allowed **Example** ```yaml diff --git a/e2e/Cargo.toml b/e2e/Cargo.toml new file mode 100644 index 000000000..58c43c108 --- /dev/null +++ b/e2e/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "e2e" +version = "0.0.1" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" + +[dependencies] +tokio = { workspace = true } +ntex = { workspace = true } +tracing-subscriber = { workspace = true } +tracing = { workspace = true } +sonic-rs = { workspace = true } +lazy_static = { workspace = true } +jsonwebtoken = { workspace = true } +insta = { workspace = true } + +hive-router = { path = "../bin/router" } +hive-router-config = { path = "../lib/router-config" } +subgraphs = { path = "../bench/subgraphs" } diff --git a/e2e/configs/jwt_auth.router.yaml b/e2e/configs/jwt_auth.router.yaml new file mode 100644 index 000000000..09cd74e32 --- /dev/null +++ b/e2e/configs/jwt_auth.router.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../router-config.schema.json +supergraph: + source: file + path: ../supergraph.graphql +jwt: + require_authentication: true + jwks_providers: + - source: file + path: ../jwks.rsa512.json diff --git a/e2e/configs/jwt_auth_audience.router.yaml b/e2e/configs/jwt_auth_audience.router.yaml new file mode 100644 index 000000000..00047481a --- /dev/null +++ b/e2e/configs/jwt_auth_audience.router.yaml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=../../router-config.schema.json +supergraph: + source: file + path: ../supergraph.graphql +jwt: + require_authentication: true + audiences: + - "my-app-audience" + jwks_providers: + - source: file + path: ../jwks.rsa512.json diff --git a/e2e/configs/jwt_auth_forward.router.yaml b/e2e/configs/jwt_auth_forward.router.yaml new file mode 100644 index 000000000..78807518b --- /dev/null +++ b/e2e/configs/jwt_auth_forward.router.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=../../router-config.schema.json +supergraph: + source: file + path: ../supergraph.graphql +jwt: + require_authentication: true + forward_claims_to_upstream_extensions: + enabled: true + field_name: jwt + jwks_providers: + - source: file + path: ../jwks.rsa512.json diff --git a/e2e/configs/jwt_auth_issuer.router.yaml b/e2e/configs/jwt_auth_issuer.router.yaml new file mode 100644 index 000000000..2b9883710 --- /dev/null +++ b/e2e/configs/jwt_auth_issuer.router.yaml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=../../router-config.schema.json +supergraph: + source: file + path: ../supergraph.graphql +jwt: + require_authentication: true + issuers: + - "my-app-issuer" + jwks_providers: + - source: file + path: ../jwks.rsa512.json diff --git a/e2e/jwks.rsa512.json b/e2e/jwks.rsa512.json new file mode 100644 index 000000000..0b8f25844 --- /dev/null +++ b/e2e/jwks.rsa512.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "kid": "test_id", + "alg": "RS512", + "n": "jllePzkDl9e0O9Vuy1_qpSPUL8RQbuHOCQknWysfHlm6QGNqiyDY46AMfpaSb45bMYQjgOoL7nboe8Q1Qaz4M33PyV-_cYm9lY2cdxE72Vd7LlLFI-4q5uPbnX0ofb1kiD47I7KOKshbq2UzLnV6CDXBr5-LMZyXKOCNCEtvEytHwdXDosveJ0BzBaEY6tdJdmitGaXrqHj365Rms14x8uU6uSXZm3ZQYB_j5oiGu-JoGIIPGPyEQ4R0lThMCxXmmplcVUkfFrsth3WhQdzlRwXMPT1myvEA8Cro4nWTPKnL_W4e1r4CT1NjMDYPbDU0zJhBei8FCeCAW8DSjd7faw" + } + ] +} diff --git a/e2e/jwks.rsa512.pem b/e2e/jwks.rsa512.pem new file mode 100644 index 000000000..c81556666 --- /dev/null +++ b/e2e/jwks.rsa512.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEAjllePzkDl9e0O9Vuy1/qpSPUL8RQbuHOCQknWysfHlm6QGNq + iyDY46AMfpaSb45bMYQjgOoL7nboe8Q1Qaz4M33PyV+/cYm9lY2cdxE72Vd7LlLF + I+4q5uPbnX0ofb1kiD47I7KOKshbq2UzLnV6CDXBr5+LMZyXKOCNCEtvEytHwdXD + osveJ0BzBaEY6tdJdmitGaXrqHj365Rms14x8uU6uSXZm3ZQYB/j5oiGu+JoGIIP + GPyEQ4R0lThMCxXmmplcVUkfFrsth3WhQdzlRwXMPT1myvEA8Cro4nWTPKnL/W4e + 1r4CT1NjMDYPbDU0zJhBei8FCeCAW8DSjd7fawIDAQABAoIBACk8eFHmSUUuZnbC + 0HK32Xh3VZt0yjwky5PQhAckCcK4CX1nj1C4djwSfCwboFYSrhY9Ci/pHQW6ioR4 + BVl+KvR3qL7ULthMJ5BwUngnlOfUMMntjlBnSSRTs6X+wMEUIVBafrVLn2WDXxLa + oSX/QBeqwu4GUMNRcnSUACb7+zRY7d7gn4xSHL4lbkQRZ+ZuWabjrmj1pDM7rGrZ + 3qMfLVsTvBaqYMlX01clZtd8IcZxA04b1eTSwok1ut6kIvLc/+DXIM0+J7GdCU5h + DCMPk3hTIwjtj3U0ad4dGP2WiAYbR/L8Hozjvr50NgiSTB0ZEka5PfAmpu86QIq/ + +GzDN7kCgYEA+GP8GrBL9R50yADVLZ/idrBnLmPnFLAYU469jUYnIUqbNRUP1yia + V0mnxrJS47H+uHClavIyapz6s8pBE8AHWgqjj41kihB3hmRk49/nsIbPG4fewXPw + KLxConzoqsOdZhHHF0UaJTgq9FMpi2okoLC3BfD1j2X5OVAn/wNeq08CgYEAkrW/ + d7d7urLe/79ew46Ca9E/TZdkPIJIkFFfqxFO8+6tFtP9UEwmp0rOK9YCIv9Hs+24 + 6F+TnmCQd5u+VcUarMrD5jUQB4zNEqDiBenUbpYiZDl3uLTelHNMMUVeqX4PfDvG + gh1HosErQhkysayVyQK87/N5F0dN1DZ07b6i0yUCgYA/wMHzQ66rQl7s+rG8nR3u + IsbI9GFaQPxtbeSe/xOKCvEdRcOkEMrUfpYufJSj1oqvYlJCydlA3fvG67GaVR5N + 8Q8cCEl22lUjTF9M0apQ97juswfslUpd2jwsIm1BbyXWDdgQ0+6rAOidfz7ZhqvS + BqljP/53CNBX8ofhf0bsJwKBgBerJKmeu2JiayGdcR9hhV75khnle7FbX3OQ/Tsu + /qrR7bDKIIrsziudIOfnjc6xmpLHnlY23Szm7Ueuo6VYuDX6PGKOWvis2YTQ2cYU + dEYnCINc1hjBbUtL0pX8WApGIR9s0Vi6eo0iVuVCBXCupDearnqTsAx2X3MGGhUk + 9UXVAoGBAMDzIS2XjvzO1sIDbjbb4mIa6iQU5s/E9hV0H4sHq+yb8EWMBajwV1tZ + TQYHV7TjRUSrEkmcinVIXi/oQCGz9og/MHGGBD0Idoww5PqjB9jTcCIoAd8PTZCp + I3OrgFkoqk03cpX4AL2GYC2ejytAqboL6pFTfmTgg2UtvKIeaTyF + -----END RSA PRIVATE KEY----- diff --git a/e2e/src/jwt.rs b/e2e/src/jwt.rs new file mode 100644 index 000000000..90db56467 --- /dev/null +++ b/e2e/src/jwt.rs @@ -0,0 +1,256 @@ +#[cfg(test)] +mod jwt_e2e_tests { + use jsonwebtoken::{encode, EncodingKey}; + use ntex::http::header; + use ntex::web::test; + use sonic_rs::{from_slice, json, JsonValueTrait, Value}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use crate::testkit::{init_graphql_request, init_router_from_config_file, SubgraphsServer}; + + fn generate_jwt(payload: &Value) -> String { + let pem = include_str!("../jwks.rsa512.pem"); + + encode::( + &jsonwebtoken::Header { + alg: jsonwebtoken::Algorithm::RS512, + ..Default::default() + }, + payload, + &EncodingKey::from_rsa_pem(pem.as_bytes()).expect("failed to read pem"), + ) + .expect("failed to create token") + } + + #[ntex::test] + async fn should_forward_claims_to_subgraph_via_extensions() { + let subgraphs_server = SubgraphsServer::start(); + let app = init_router_from_config_file("configs/jwt_auth_forward.router.yaml") + .await + .unwrap(); + + let exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600; + let claims = json!({ + "sub": "user1", + "iat": 1516239022, + "exp": exp, + }); + let token = generate_jwt(&claims); + + let req = init_graphql_request("{ users { id } }", None).header( + header::AUTHORIZATION, + header::HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + ); + let resp = test::call_service(&app, req.to_request()).await; + + assert!(resp.status().is_success(), "Expected 200 OK"); + + let subgraph_requests = subgraphs_server + .get_subgraph_requests_log("accounts") + .await + .expect("expected requests sent to accounts subgraph"); + assert_eq!( + subgraph_requests.len(), + 1, + "expected 1 request to accounts subgraph" + ); + + let extensions = subgraph_requests[0].request_body.get("extensions").unwrap(); + + assert_eq!(extensions.get("jwt").unwrap(), &claims); + } + + #[ntex::test] + async fn rejects_request_without_token_when_auth_is_required() { + let app = init_router_from_config_file("configs/jwt_auth.router.yaml") + .await + .unwrap(); + + let req = init_graphql_request("{ __typename }", None).to_request(); + let resp = test::call_service(&app, req).await; + + assert_eq!( + resp.status(), + ntex::http::StatusCode::UNAUTHORIZED, + "Expected 401 Unauthorized" + ); + let body = test::read_body(resp).await; + let json_body: Value = from_slice(&body).unwrap(); + + assert_eq!( + json_body["errors"][0]["message"], + "jwt header lookup failed: failed to locate the value in the incoming request" + ); + assert_eq!( + json_body["errors"][0]["extensions"]["code"], + "JWT_LOOKUP_FAILED" + ); + } + + #[ntex::test] + async fn rejects_request_with_malformed_token() { + let app = init_router_from_config_file("configs/jwt_auth.router.yaml") + .await + .unwrap(); + + let req = init_graphql_request("{ __typename }", None).header( + header::AUTHORIZATION, + header::HeaderValue::from_static("Bearer not-a-valid-jwt"), + ); + + let resp = test::call_service(&app, req.to_request()).await; + + assert_eq!( + resp.status(), + ntex::http::StatusCode::FORBIDDEN, + "Expected 403 Forbidden" + ); + let body = test::read_body(resp).await; + let json_body: Value = from_slice(&body).unwrap(); + + assert_eq!( + json_body["errors"][0]["message"], + "failed to parse JWT header: InvalidToken" + ); + assert_eq!( + json_body["errors"][0]["extensions"]["code"], + "INVALID_JWT_HEADER" + ); + } + + #[ntex::test] + async fn rejects_request_with_invalid_signature() { + let app = init_router_from_config_file("configs/jwt_auth.router.yaml") + .await + .unwrap(); + + // This token is valid but signed with a different, unknown key. + let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + let req = init_graphql_request("{ __typename }", None).header( + header::AUTHORIZATION, + header::HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + ); + + let resp = test::call_service(&app, req.to_request()).await; + + assert_eq!( + resp.status(), + ntex::http::StatusCode::FORBIDDEN, + "Expected 403 Forbidden" + ); + } + + #[ntex::test] + async fn accepts_request_with_valid_token() { + let app = init_router_from_config_file("configs/jwt_auth.router.yaml") + .await + .unwrap(); + + let exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600; + let claims = json!({ + "sub": "user1", + "iat": 1516239022, + "exp": exp, + }); + let token = generate_jwt(&claims); + + let req = init_graphql_request("{ __typename }", None).header( + header::AUTHORIZATION, + header::HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + ); + + let resp = test::call_service(&app, req.to_request()).await; + + assert!( + resp.status().is_success(), + "Expected 2xx status for valid token" + ); + let body = test::read_body(resp).await; + let json_body: Value = from_slice(&body).unwrap(); + assert_eq!(json_body["data"]["__typename"], "Query"); + } + + #[ntex::test] + async fn rejects_request_with_expired_token() { + let app = init_router_from_config_file("configs/jwt_auth.router.yaml") + .await + .unwrap(); + + let exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + - 3600; + let claims = json!({ + "sub": "user1", + "iat": 1516239022, + "exp": exp, + }); + let token = generate_jwt(&claims); + + let req = init_graphql_request("{ __typename }", None).header( + header::AUTHORIZATION, + header::HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + ); + + let resp = test::call_service(&app, req.to_request()).await; + assert_eq!( + resp.status(), + ntex::http::StatusCode::FORBIDDEN, + "Expected 403 for expired token" + ); + } + + #[ntex::test] + async fn rejects_request_with_wrong_issuer() { + let app = init_router_from_config_file("configs/jwt_auth_issuer.router.yaml") + .await + .unwrap(); + + let claims = json!({ "iss": "wrong-issuer", "exp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600 }); + let token = generate_jwt(&claims); + + let req = init_graphql_request("{ __typename }", None).header( + header::AUTHORIZATION, + header::HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + ); + + let resp = test::call_service(&app, req.to_request()).await; + assert_eq!( + resp.status(), + ntex::http::StatusCode::FORBIDDEN, + "Expected 403 for wrong issuer" + ); + } + + #[ntex::test] + async fn rejects_request_with_wrong_audience() { + let app = init_router_from_config_file("configs/jwt_auth_audience.router.yaml") + .await + .unwrap(); + + let claims = json!({ "aud": "wrong-audience", "exp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600 }); + let token = generate_jwt(&claims); + + let req = init_graphql_request("{ __typename }", None).header( + header::AUTHORIZATION, + header::HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + ); + + let resp = test::call_service(&app, req.to_request()).await; + assert_eq!( + resp.status(), + ntex::http::StatusCode::FORBIDDEN, + "Expected 403 for wrong audience" + ); + } +} diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs new file mode 100644 index 000000000..7729e6d4e --- /dev/null +++ b/e2e/src/lib.rs @@ -0,0 +1,4 @@ +#[cfg(test)] +mod jwt; +#[cfg(test)] +mod testkit; diff --git a/e2e/src/testkit.rs b/e2e/src/testkit.rs new file mode 100644 index 000000000..a7441c524 --- /dev/null +++ b/e2e/src/testkit.rs @@ -0,0 +1,101 @@ +use std::{path::PathBuf, sync::Once}; + +use hive_router::{ + background_tasks::BackgroundTasksManager, configure_app_from_config, configure_ntex_app, +}; +use hive_router_config::load_config; +use lazy_static::lazy_static; +use ntex::{ + web::{self, test, test::TestRequest, WebResponse}, + Pipeline, +}; +use sonic_rs::json; +use subgraphs::{start_subgraphs_server, RequestLog, SubgraphsServiceState}; +use tracing::subscriber::set_global_default; +use tracing_subscriber::{ + fmt::{self}, + layer::SubscriberExt, + EnvFilter, +}; + +pub fn init_graphql_request(op: &str, variables: Option) -> TestRequest { + let body = json!({ + "query": op, + "variables": variables + }); + + test::TestRequest::post() + .uri("/graphql") + .header("content-type", "application/json") + .set_payload(body.to_string()) +} + +lazy_static! { + static ref TRACING_INIT: Once = Once::new(); +} + +#[allow(dead_code)] // call this at the beginning of the test if you wish to see gw logs +pub fn init_logger() { + TRACING_INIT.call_once(|| { + let subscriber = tracing_subscriber::registry() + .with(fmt::layer().with_test_writer()) + .with(EnvFilter::from_default_env()); + + let _ = set_global_default(subscriber); + }); +} + +pub struct SubgraphsServer { + shutdown_tx: Option>, + subgraph_shared_state: SubgraphsServiceState, +} + +impl Drop for SubgraphsServer { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } +} + +impl SubgraphsServer { + pub fn start() -> Self { + let (_server_handle, shutdown_tx, subgraph_shared_state) = start_subgraphs_server(None); + + Self { + shutdown_tx: Some(shutdown_tx), + subgraph_shared_state, + } + } + + pub async fn get_subgraph_requests_log(&self, subgraph_name: &str) -> Option> { + let log = self.subgraph_shared_state.request_log.lock().await; + + log.get(&format!("/{}", subgraph_name)).cloned() + } +} + +pub async fn init_router_from_config_file( + config_path: &str, +) -> Result< + Pipeline< + impl ntex::Service, + >, + Box, +> { + // init_logger(); + + let supergraph_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(config_path); + let router_config = load_config(Some(supergraph_path.to_str().unwrap().to_string()))?; + let mut bg_tasks_manager = BackgroundTasksManager::new(); + let shared_state = configure_app_from_config(router_config, &mut bg_tasks_manager).await?; + + let ntex_app = test::init_service( + web::App::new() + .state(shared_state.clone()) + .configure(configure_ntex_app), + ) + .await; + + Ok(ntex_app) +} diff --git a/e2e/supergraph.graphql b/e2e/supergraph.graphql new file mode 100644 index 000000000..1fe16b12b --- /dev/null +++ b/e2e/supergraph.graphql @@ -0,0 +1,115 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__FieldSet + +enum join__Graph { + ACCOUNTS @join__graph(name: "accounts", url: "http://0.0.0.0:4200/accounts") + INVENTORY + @join__graph(name: "inventory", url: "http://0.0.0.0:4200/inventory") + PRODUCTS @join__graph(name: "products", url: "http://0.0.0.0:4200/products") + REVIEWS @join__graph(name: "reviews", url: "http://0.0.0.0:4200/reviews") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Product + @join__type(graph: INVENTORY, key: "upc") + @join__type(graph: PRODUCTS, key: "upc") + @join__type(graph: REVIEWS, key: "upc") { + upc: String! + weight: Int + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + price: Int + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + inStock: Boolean @join__field(graph: INVENTORY) + shippingEstimate: Int @join__field(graph: INVENTORY, requires: "price weight") + name: String @join__field(graph: PRODUCTS) + reviews: [Review] @join__field(graph: REVIEWS) +} + +type Query + @join__type(graph: ACCOUNTS) + @join__type(graph: INVENTORY) + @join__type(graph: PRODUCTS) + @join__type(graph: REVIEWS) { + me: User @join__field(graph: ACCOUNTS) + user(id: ID!): User @join__field(graph: ACCOUNTS) + users: [User] @join__field(graph: ACCOUNTS) + topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS) +} + +type Review @join__type(graph: REVIEWS, key: "id") { + id: ID! + body: String + product: Product + author: User @join__field(graph: REVIEWS, provides: "username") +} + +type User + @join__type(graph: ACCOUNTS, key: "id") + @join__type(graph: REVIEWS, key: "id") { + id: ID! + name: String @join__field(graph: ACCOUNTS) + username: String + @join__field(graph: ACCOUNTS) + @join__field(graph: REVIEWS, external: true) + birthday: Int @join__field(graph: ACCOUNTS) + reviews: [Review] @join__field(graph: REVIEWS) +} diff --git a/lib/executor/src/execution/jwt_forward.rs b/lib/executor/src/execution/jwt_forward.rs new file mode 100644 index 000000000..29f72d6f7 --- /dev/null +++ b/lib/executor/src/execution/jwt_forward.rs @@ -0,0 +1,7 @@ +use sonic_rs::Value; + +#[derive(Default)] +pub struct JwtAuthForwardingPlan { + pub extension_field_name: String, + pub extension_field_value: Value, +} diff --git a/lib/executor/src/execution/mod.rs b/lib/executor/src/execution/mod.rs index 2dc209802..5a7a6d0fc 100644 --- a/lib/executor/src/execution/mod.rs +++ b/lib/executor/src/execution/mod.rs @@ -1,3 +1,4 @@ pub mod error; +pub mod jwt_forward; pub mod plan; pub mod rewrites; diff --git a/lib/executor/src/execution/plan.rs b/lib/executor/src/execution/plan.rs index cb1edde28..a15650952 100644 --- a/lib/executor/src/execution/plan.rs +++ b/lib/executor/src/execution/plan.rs @@ -16,7 +16,9 @@ use sonic_rs::ValueRef; use crate::{ context::ExecutionContext, - execution::{error::PlanExecutionError, rewrites::FetchRewriteExt}, + execution::{ + error::PlanExecutionError, jwt_forward::JwtAuthForwardingPlan, rewrites::FetchRewriteExt, + }, executors::{ common::{HttpExecutionRequest, HttpExecutionResponse}, map::SubgraphExecutorMap, @@ -70,6 +72,7 @@ pub struct QueryPlanExecutionContext<'exec> { pub introspection_context: &'exec IntrospectionContext<'exec, 'static>, pub operation_type_name: &'exec str, pub executors: &'exec SubgraphExecutorMap, + pub jwt_auth_forwarding: &'exec Option, } pub struct PlanExecutionOutput { @@ -93,6 +96,7 @@ pub async fn execute_query_plan<'exec>( ctx.introspection_context.metadata, &ctx.client_request, ctx.headers_plan, + ctx.jwt_auth_forwarding, // Deduplicate subgraph requests only if the operation type is a query ctx.operation_type_name == "Query", ); @@ -129,6 +133,7 @@ pub struct Executor<'exec> { executors: &'exec SubgraphExecutorMap, client_request: &'exec ClientRequestDetails<'exec>, headers_plan: &'exec HeaderRulesPlan, + jwt_forwarding_plan: &'exec Option, dedupe_subgraph_requests: bool, } @@ -223,6 +228,7 @@ impl<'exec> Executor<'exec> { schema_metadata: &'exec SchemaMetadata, client_request: &'exec ClientRequestDetails<'exec>, headers_plan: &'exec HeaderRulesPlan, + jwt_forwarding_plan: &'exec Option, dedupe_subgraph_requests: bool, ) -> Self { Executor { @@ -232,6 +238,7 @@ impl<'exec> Executor<'exec> { client_request, headers_plan, dedupe_subgraph_requests, + jwt_forwarding_plan, } } @@ -712,22 +719,29 @@ impl<'exec> Executor<'exec> { let variable_refs = select_fetch_variables(self.variable_values, node.variable_usages.as_ref()); + let mut subgraph_request = HttpExecutionRequest { + query: node.operation.document_str.as_str(), + dedupe: self.dedupe_subgraph_requests, + operation_name: node.operation_name.as_deref(), + variables: variable_refs, + representations, + headers: headers_map, + extensions: None, + }; + + if let Some(jwt_forwarding_plan) = &self.jwt_forwarding_plan { + subgraph_request.add_request_extensions_field( + jwt_forwarding_plan.extension_field_name.clone(), + jwt_forwarding_plan.extension_field_value.clone(), + ); + } + Ok(ExecutionJob::Fetch(FetchJob { fetch_node_id: node.id, subgraph_name: node.service_name.clone(), response: self .executors - .execute( - &node.service_name, - HttpExecutionRequest { - query: node.operation.document_str.as_str(), - dedupe: self.dedupe_subgraph_requests, - operation_name: node.operation_name.as_deref(), - variables: variable_refs, - representations, - headers: headers_map, - }, - ) + .execute(&node.service_name, subgraph_request) .await .into(), })) diff --git a/lib/executor/src/executors/common.rs b/lib/executor/src/executors/common.rs index 6a053bdd5..22bcd580d 100644 --- a/lib/executor/src/executors/common.rs +++ b/lib/executor/src/executors/common.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use bytes::Bytes; use http::HeaderMap; +use sonic_rs::Value; #[async_trait] pub trait SubgraphExecutor { @@ -22,6 +23,8 @@ pub type SubgraphExecutorType = dyn crate::executors::common::SubgraphExecutor + pub type SubgraphExecutorBoxedArc = Arc>; +pub type SubgraphRequestExtensions = HashMap; + pub struct HttpExecutionRequest<'a> { pub query: &'a str, pub dedupe: bool, @@ -30,6 +33,15 @@ pub struct HttpExecutionRequest<'a> { pub variables: Option>, pub headers: HeaderMap, pub representations: Option>, + pub extensions: Option, +} + +impl HttpExecutionRequest<'_> { + pub fn add_request_extensions_field(&mut self, key: String, value: Value) { + self.extensions + .get_or_insert_with(HashMap::new) + .insert(key, value); + } } pub struct HttpExecutionResponse { diff --git a/lib/executor/src/executors/http.rs b/lib/executor/src/executors/http.rs index e1a285430..2df9b90ea 100644 --- a/lib/executor/src/executors/http.rs +++ b/lib/executor/src/executors/http.rs @@ -111,6 +111,17 @@ impl HTTPSubgraphExecutor { if !first_variable { body.put(CLOSE_BRACE); } + + if let Some(extensions) = &execution_request.extensions { + if !extensions.is_empty() { + let as_value = sonic_rs::to_value(extensions).unwrap(); + + body.put(COMMA); + body.put("\"extensions\":".as_bytes()); + body.extend_from_slice(as_value.to_string().as_bytes()); + } + } + body.put(CLOSE_BRACE); Ok(body) diff --git a/lib/router-config/Cargo.toml b/lib/router-config/Cargo.toml index 3d574f79c..8c06f7405 100644 --- a/lib/router-config/Cargo.toml +++ b/lib/router-config/Cargo.toml @@ -20,6 +20,8 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } http = { workspace = true } +jsonwebtoken = { workspace = true } + schemars = "1.0.4" humantime-serde = "1.1.1" config = { version = "0.15.14", features = ["yaml", "json", "json5"] } diff --git a/lib/router-config/src/http_server.rs b/lib/router-config/src/http_server.rs index 325c7a5bb..c03696826 100644 --- a/lib/router-config/src/http_server.rs +++ b/lib/router-config/src/http_server.rs @@ -1,7 +1,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct HttpServerConfig { /// The host address to bind the HTTP server to. #[serde(default = "http_server_host_default")] diff --git a/lib/router-config/src/jwt_auth.rs b/lib/router-config/src/jwt_auth.rs new file mode 100644 index 000000000..8ae15ee9f --- /dev/null +++ b/lib/router-config/src/jwt_auth.rs @@ -0,0 +1,133 @@ +use std::time::Duration; + +use jsonwebtoken::Algorithm; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::primitives::{file_path::FilePath, http_header::HttpHeaderName}; + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] +pub struct JwtAuthConfig { + /// A list of JWKS providers to use for verifying the JWT signature. + /// Can be either a path to a local JSON of the file-system, or a URL to a remote JWKS provider. + pub jwks_providers: Vec, + /// Specify the [principal](https://tools.ietf.org/html/rfc7519#section-4.1.1) that issued the JWT, usually a URL or an email address. + /// If specified, it has to match the `iss` field in JWT, otherwise the token's `iss` field is not checked. + #[serde(skip_serializing_if = "Option::is_none")] + pub issuers: Option>, + /// The list of [JWT audiences](https://tools.ietf.org/html/rfc7519#section-4.1.3) are allowed to access. + /// If this field is set, the token's `aud` field must be one of the values in this list, otherwise the token's `aud` field is not checked. + #[serde(skip_serializing_if = "Option::is_none")] + pub audiences: Option>, + /// A list of locations to look up for the JWT token in the incoming HTTP request. + /// The first one that is found will be used. + #[serde( + default = "default_lookup_location", + skip_serializing_if = "Vec::is_empty" + )] + pub lookup_locations: Vec, + /// If set to `true`, the entire request will be rejected if the JWT token is not present in the request. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_authentication: Option, + /// List of allowed algorithms for verifying the JWT signature. + /// If not specified, the default list of all supported algorithms in [`jsonwebtoken` crate](https://crates.io/crates/jsonwebtoken) are used. + #[serde( + skip_serializing_if = "Option::is_none", + default = "default_allowed_algorithms" + )] + #[schemars(with = "Option>")] + pub allowed_algorithms: Option>, + #[serde(default = "default_forward_claims_to_upstream_extensions")] + /// Forward the JWT claims to the upstream service using GraphQL's `.extensions`. + pub forward_claims_to_upstream_extensions: JwtClaimsForwardingConfig, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub struct JwtClaimsForwardingConfig { + pub enabled: bool, + pub field_name: String, +} + +fn default_forward_claims_to_upstream_extensions() -> JwtClaimsForwardingConfig { + JwtClaimsForwardingConfig { + enabled: false, + field_name: "jwt".to_string(), + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, JsonSchema)] +#[serde(tag = "source")] +pub enum JwksProviderSourceConfig { + /// A local file on the file-system. This file will be read once on startup and cached. + #[serde(rename = "file")] + #[schemars(title = "file")] + File { + #[serde(rename = "path")] + /// A path to a local file on the file-system. Relative to the location of the root configuration file. + file: FilePath, + }, + /// A remote JWKS provider. The JWKS will be fetched via HTTP/HTTPS and cached. + #[serde(rename = "remote")] + #[schemars(title = "remote")] + Remote { + /// The URL to fetch the JWKS key set from, via HTTP/HTTPS. + url: String, + #[serde( + deserialize_with = "humantime_serde::deserialize", + serialize_with = "humantime_serde::serialize", + default = "default_polling_interval" + )] + #[schemars(with = "String")] + /// How often the JWKS should be polled for updates. + polling_interval: Option, + /// If set to `true`, the JWKS will be fetched on startup and cached. In case of invalid JWKS, the error will be ignored and the plugin will try to fetch again when server receives the first request. + /// If set to `false`, the JWKS will be fetched on-demand, when the first request comes in. + prefetch: Option, + }, +} + +fn default_polling_interval() -> Option { + // Some providers like MS Azure have rate limit configured. So let's use 10 minutes, like Envoy does. + // and allow users to adjust it if needed. + // See https://community.auth0.com/t/caching-jwks-signing-key/17654/2 + Some(Duration::from_secs(10 * 60)) +} + +pub fn default_lookup_location() -> Vec { + vec![JwtAuthPluginLookupLocation::Header { + name: "Authorization".into(), + prefix: Some("Bearer".to_string()), + }] +} + +pub fn default_allowed_algorithms() -> Option> { + Some(vec![ + Algorithm::HS256, + Algorithm::HS384, + Algorithm::HS512, + Algorithm::RS256, + Algorithm::RS384, + Algorithm::RS512, + Algorithm::ES256, + Algorithm::ES384, + Algorithm::PS256, + Algorithm::PS384, + Algorithm::PS512, + Algorithm::EdDSA, + ]) +} + +#[derive(Deserialize, Serialize, Debug, Clone, JsonSchema)] +#[serde(tag = "source")] +pub enum JwtAuthPluginLookupLocation { + #[serde(rename = "header")] + #[schemars(title = "header")] + Header { + name: HttpHeaderName, + prefix: Option, + }, + #[serde(rename = "cookies")] + #[schemars(title = "cookies")] + Cookie { name: String }, +} diff --git a/lib/router-config/src/lib.rs b/lib/router-config/src/lib.rs index 38eefdfdc..d393641e5 100644 --- a/lib/router-config/src/lib.rs +++ b/lib/router-config/src/lib.rs @@ -2,6 +2,7 @@ pub mod cors; pub mod csrf; pub mod headers; pub mod http_server; +pub mod jwt_auth; pub mod log; pub mod primitives; pub mod query_planner; @@ -11,15 +12,20 @@ pub mod traffic_shaping; use config::{Config, Environment, File, FileFormat, FileSourceFile}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use crate::{ - http_server::HttpServerConfig, log::LoggingConfig, query_planner::QueryPlannerConfig, + http_server::HttpServerConfig, jwt_auth::JwtAuthConfig, log::LoggingConfig, + primitives::file_path::with_start_path, query_planner::QueryPlannerConfig, supergraph::SupergraphSource, traffic_shaping::TrafficShapingExecutorConfig, }; -#[derive(Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct HiveRouterConfig { + #[serde(skip)] + root_directory: PathBuf, + /// The router logger configuration. /// /// The router is configured to be mostly silent (`info`) level, and will print only important messages, warnings, and errors. @@ -52,8 +58,13 @@ pub struct HiveRouterConfig { #[serde(default)] pub csrf: csrf::CSRFPreventionConfig, /// Configuration for CORS (Cross-Origin Resource Sharing). + #[serde(default)] pub cors: cors::CORSConfig, + /// Configuration for JWT authentication plugin. + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jwt: Option, } #[derive(Debug, thiserror::Error)] @@ -62,34 +73,49 @@ pub enum RouterConfigError { ConfigLoadError(config::ConfigError), } +static DEFAULT_FILE_NAMES: &[&str] = &[ + "hive-router.config.yaml", + "hive-router.config.yml", + "hive-router.config.json", + "hive-router.config.json5", +]; + pub fn load_config( overide_config_path: Option, ) -> Result { let mut config = Config::builder(); + let mut config_root_path = std::env::current_dir().expect("failed to get current directory"); if let Some(path_str) = overide_config_path { let path_buf = path_str .parse::() .expect("failed to parse config file path"); + let path_dupe = path_buf.clone(); + let parent_dir = path_dupe.parent().unwrap(); let as_file: File = path_buf.into(); config = config.add_source(as_file.required(true)); + config_root_path = config_root_path.clone().join(parent_dir); } else { - config = config - .add_source(File::with_name("hive-router.config.yaml").required(false)) - .add_source(File::with_name("hive-router.config.yml").required(false)) - .add_source(File::with_name("hive-router.config.json").required(false)) - .add_source(File::with_name("hive-router.config.json5").required(false)) + for name in DEFAULT_FILE_NAMES { + config = config.add_source(File::with_name(name).required(false)); + } } - config - .add_source( - Environment::with_prefix("HIVE") - .separator("__") - .prefix_separator("__"), - ) - .build()? - .try_deserialize::() + let mut base_cfg = with_start_path(&config_root_path, || { + config + .add_source( + Environment::with_prefix("HIVE") + .separator("__") + .prefix_separator("__"), + ) + .build()? + .try_deserialize::() + })?; + + base_cfg.root_directory = config_root_path; + + Ok(base_cfg) } pub fn parse_yaml_config(config_raw: String) -> Result { diff --git a/lib/router-config/src/log.rs b/lib/router-config/src/log.rs index 54f5b4099..0d617c36d 100644 --- a/lib/router-config/src/log.rs +++ b/lib/router-config/src/log.rs @@ -1,7 +1,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Deserialize, Serialize, JsonSchema, Default)] +#[derive(Debug, Deserialize, Serialize, JsonSchema, Default)] +#[serde(deny_unknown_fields)] pub struct LoggingConfig { #[serde(default)] pub level: LogLevel, @@ -17,7 +18,7 @@ impl LoggingConfig { } } -#[derive(Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum LogLevel { Trace, @@ -51,7 +52,7 @@ impl LogLevel { } } -#[derive(Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema)] pub enum LogFormat { #[serde(rename = "pretty-tree")] PrettyTree, diff --git a/lib/router-config/src/primitives/file_path.rs b/lib/router-config/src/primitives/file_path.rs index 7c0a8098c..f8140562a 100644 --- a/lib/router-config/src/primitives/file_path.rs +++ b/lib/router-config/src/primitives/file_path.rs @@ -1,8 +1,42 @@ +use std::{ + cell::RefCell, + env, fmt, fs, io, + path::{Path, PathBuf}, +}; + use schemars::{json_schema, JsonSchema}; -use serde::{Deserialize, Serialize}; +use serde::{ + de::{self, Visitor}, + Deserialize, Deserializer, Serialize, +}; + +#[derive(Debug, Clone, Serialize)] +pub struct FilePath { + #[serde(flatten)] + pub relative: String, + #[serde(skip)] + pub absolute: String, +} + +// This is a workaround/solution to pass some kind of "context" to the deserialization process. +thread_local!(static CONTEXT_START_PATH: RefCell> = const { RefCell::new(None) }); + +pub fn with_start_path(start_path: &Path, f: F) -> T +where + F: FnOnce() -> T, +{ + CONTEXT_START_PATH.with(|ctx| { + *ctx.borrow_mut() = Some(start_path.to_path_buf()); + }); -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FilePath(pub String); + let result = f(); + + CONTEXT_START_PATH.with(|ctx| { + *ctx.borrow_mut() = None; + }); + + result +} impl JsonSchema for FilePath { fn schema_name() -> std::borrow::Cow<'static, str> { @@ -21,14 +55,63 @@ impl JsonSchema for FilePath { } } -impl From for FilePath { - fn from(value: String) -> Self { - FilePath(value) +struct FilePathVisitor; + +impl<'de> Visitor<'de> for FilePathVisitor { + type Value = FilePath; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string representing a relative file path") + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + CONTEXT_START_PATH.with(|ctx| { + if let Some(start_path) = ctx.borrow().as_ref() { + match FilePath::resolve_relative(start_path, v, true) { + Ok(file_path) => Ok(file_path), + Err(err) => Err(E::custom(format!("Failed to canonicalize path: {}", err))), + } + } else { + Err(E::custom( + "FilePath deserialization context (start_path) is not set", + )) + } + }) + } +} + +impl<'de> Deserialize<'de> for FilePath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(FilePathVisitor) } } -impl From<&str> for FilePath { - fn from(value: &str) -> Self { - FilePath(value.to_string()) +impl FilePath { + pub fn new_from_relative(relative_path: &str) -> io::Result { + Self::resolve_relative(&env::current_dir()?, relative_path, false) + } + + fn resolve_relative>( + base_path: &RootPath, + relative_path: &str, + canonicalize: bool, + ) -> io::Result { + let absolute_path = base_path.as_ref().join(relative_path); + let canonical_path = if canonicalize { + fs::canonicalize(absolute_path)? + } else { + absolute_path + }; + + Ok(FilePath { + relative: relative_path.to_string(), + absolute: canonical_path.to_string_lossy().to_string(), + }) } } diff --git a/lib/router-config/src/primitives/http_header.rs b/lib/router-config/src/primitives/http_header.rs new file mode 100644 index 000000000..391794159 --- /dev/null +++ b/lib/router-config/src/primitives/http_header.rs @@ -0,0 +1,101 @@ +use std::{fmt, str::FromStr}; + +use http::HeaderName; +use schemars::{json_schema, JsonSchema}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HttpHeaderName(HeaderName); + +impl From for HttpHeaderName { + fn from(header_name: HeaderName) -> Self { + HttpHeaderName(header_name) + } +} + +impl HttpHeaderName { + pub fn get_header_ref(&self) -> &HeaderName { + &self.0 + } +} + +impl From<&str> for HttpHeaderName { + fn from(header_name: &str) -> Self { + HttpHeaderName(HeaderName::from_str(header_name).unwrap()) + } +} + +impl From for HttpHeaderName { + fn from(header_name: String) -> Self { + HttpHeaderName(HeaderName::from_str(&header_name).unwrap()) + } +} + +impl Serialize for HttpHeaderName { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.0.as_str()) + } +} + +struct HeaderNameVisitor; + +impl JsonSchema for HttpHeaderName { + fn schema_name() -> std::borrow::Cow<'static, str> { + "HttpHeaderName".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "type": "string", + "description": "A valid HTTP header name, according to RFC 7230.", + "pattern": "^[A-Za-z0-9!#$%&'*+\\-.^_`|~]+$" + }) + } + + fn inline_schema() -> bool { + true + } +} + +impl<'de> serde::de::Visitor<'de> for HeaderNameVisitor { + type Value = HttpHeaderName; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an HTTP header name string (e.g., \"Content-Type\")") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + HeaderName::from_str(value) + .map(HttpHeaderName) + .map_err(serde::de::Error::custom) + } + + fn visit_borrowed_str(self, value: &'de str) -> Result + where + E: serde::de::Error, + { + self.visit_str(value) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error, + { + self.visit_str(&value) + } +} + +impl<'de> Deserialize<'de> for HttpHeaderName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(HeaderNameVisitor) + } +} diff --git a/lib/router-config/src/primitives/mod.rs b/lib/router-config/src/primitives/mod.rs index 54867ebb8..97a3e3155 100644 --- a/lib/router-config/src/primitives/mod.rs +++ b/lib/router-config/src/primitives/mod.rs @@ -1 +1,2 @@ pub mod file_path; +pub mod http_header; diff --git a/lib/router-config/src/query_planner.rs b/lib/router-config/src/query_planner.rs index aa42c5320..3233ee05b 100644 --- a/lib/router-config/src/query_planner.rs +++ b/lib/router-config/src/query_planner.rs @@ -4,6 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] pub struct QueryPlannerConfig { /// A flag to allow exposing the query plan in the response. /// When set to `true` and an incoming request has a `hive-expose-query-plan: true` header, the query plan will be exposed in the response, as part of `extensions`. diff --git a/lib/router-config/src/supergraph.rs b/lib/router-config/src/supergraph.rs index 6b36778c3..ffc826954 100644 --- a/lib/router-config/src/supergraph.rs +++ b/lib/router-config/src/supergraph.rs @@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize}; use crate::primitives::file_path::FilePath; -#[derive(Deserialize, Serialize, JsonSchema)] -#[serde(tag = "source")] +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields, tag = "source")] pub enum SupergraphSource { /// Loads a supergraph from the filesystem. /// The path can be either absolute or relative to the router's working directory. @@ -15,7 +15,8 @@ pub enum SupergraphSource { impl Default for SupergraphSource { fn default() -> Self { SupergraphSource::File { - path: "supergraph.graphql".into(), + path: FilePath::new_from_relative("supergraph.graphql") + .expect("failed to resolve local path for supergraph file source"), } } } @@ -24,10 +25,10 @@ impl SupergraphSource { pub async fn load(&self) -> Result> { match self { SupergraphSource::File { path } => { - let supergraph_sdl = std::fs::read_to_string(&path.0).map_err(|e| { + let supergraph_sdl = std::fs::read_to_string(&path.absolute).map_err(|e| { std::io::Error::new( e.kind(), - format!("Failed to read supergraph file '{}': {}", path.0, e), + format!("Failed to read supergraph file '{}': {}", path.absolute, e), ) })?; Ok(supergraph_sdl) diff --git a/lib/router-config/src/traffic_shaping.rs b/lib/router-config/src/traffic_shaping.rs index 595112e37..02ed5ecdd 100644 --- a/lib/router-config/src/traffic_shaping.rs +++ b/lib/router-config/src/traffic_shaping.rs @@ -2,6 +2,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[serde(deny_unknown_fields)] pub struct TrafficShapingExecutorConfig { /// Limits the concurrent amount of requests/connections per host/subgraph. #[serde(default = "default_max_connections_per_host")]