mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-29 01:42:19 +00:00
feat(rust): add gateway UI login and sessions (#43469)
Co-authored-by: Yujong Lee <yujong@berri.ai>
This commit is contained in:
parent
ed43556e92
commit
6e0926edde
22 changed files with 2327 additions and 25 deletions
577
litellm-rust/Cargo.lock
generated
577
litellm-rust/Cargo.lock
generated
|
|
@ -379,7 +379,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"fastrand",
|
||||
"hex",
|
||||
"hmac",
|
||||
"hmac 0.13.0",
|
||||
"http 0.2.12",
|
||||
"http 1.4.2",
|
||||
"http-body 1.1.0",
|
||||
|
|
@ -458,7 +458,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"form_urlencoded",
|
||||
"hex",
|
||||
"hmac",
|
||||
"hmac 0.13.0",
|
||||
"http 0.2.12",
|
||||
"http 1.4.2",
|
||||
"percent-encoding",
|
||||
|
|
@ -772,6 +772,25 @@ dependencies = [
|
|||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-login"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "964ea6eb764a227baa8c3368e45c94d23b6863cc7b880c6c9e341c143c5a5ff7"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"form_urlencoded",
|
||||
"serde",
|
||||
"subtle",
|
||||
"thiserror 2.0.19",
|
||||
"tower-cookies",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tower-sessions",
|
||||
"tracing",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "azure_core"
|
||||
version = "1.1.0"
|
||||
|
|
@ -855,6 +874,12 @@ dependencies = [
|
|||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.13.1"
|
||||
|
|
@ -883,6 +908,12 @@ dependencies = [
|
|||
"vsimd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
|
|
@ -1241,12 +1272,29 @@ version = "0.4.33"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
|
|
@ -1414,6 +1462,18 @@ version = "0.2.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
|
|
@ -1442,6 +1502,33 @@ dependencies = [
|
|||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.10.7",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "daachorse"
|
||||
version = "3.0.3"
|
||||
|
|
@ -1588,6 +1675,17 @@ dependencies = [
|
|||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
|
|
@ -1660,7 +1758,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1670,7 +1770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
|
@ -1715,6 +1815,44 @@ version = "1.0.20"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest 0.10.7",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
|
|
@ -1724,6 +1862,27 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"hkdf 0.12.4",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
|
|
@ -1742,6 +1901,15 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "envy"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
|
|
@ -1845,6 +2013,22 @@ dependencies = [
|
|||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
|
|
@ -2071,6 +2255,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2131,7 +2316,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"google-cloud-gax",
|
||||
"hex",
|
||||
"hmac",
|
||||
"hmac 0.13.0",
|
||||
"http 1.4.2",
|
||||
"jiff",
|
||||
"reqwest 0.13.5",
|
||||
|
|
@ -2338,6 +2523,36 @@ dependencies = [
|
|||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-sink",
|
||||
"futures-timer",
|
||||
"futures-util",
|
||||
"hashbrown 0.16.1",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.3.27"
|
||||
|
|
@ -2442,13 +2657,31 @@ version = "0.4.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"hmac 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2524,6 +2757,12 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
|
|
@ -3040,11 +3279,36 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"hmac 0.12.1",
|
||||
"js-sys",
|
||||
"p256",
|
||||
"p384",
|
||||
"rand 0.8.7",
|
||||
"rsa",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"signature",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin 0.9.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
|
|
@ -3052,6 +3316,12 @@ version = "0.2.186"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.37.0"
|
||||
|
|
@ -3476,20 +3746,27 @@ name = "litellm-gateway"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"envy",
|
||||
"futures-util",
|
||||
"http-body-util",
|
||||
"litellm-auth-types",
|
||||
"litellm-config",
|
||||
"litellm-core",
|
||||
"litellm-gateway-auth",
|
||||
"litellm-gateway-inference",
|
||||
"litellm-gateway-ui",
|
||||
"litellm-http",
|
||||
"litellm-llms",
|
||||
"litellm-secrets",
|
||||
"litellm-tracing",
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-sessions-moka-store",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
|
@ -3499,6 +3776,7 @@ name = "litellm-gateway-auth"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-login",
|
||||
"futures-util",
|
||||
"litellm-auth-types",
|
||||
"litellm-config",
|
||||
|
|
@ -3510,6 +3788,8 @@ dependencies = [
|
|||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-sessions",
|
||||
"veil",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3550,6 +3830,31 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-gateway-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-login",
|
||||
"base64 0.22.1",
|
||||
"governor",
|
||||
"jsonwebtoken",
|
||||
"litellm-auth-types",
|
||||
"litellm-gateway-auth",
|
||||
"rand 0.8.7",
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-cookies",
|
||||
"tower-http",
|
||||
"tower-sessions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-host"
|
||||
version = "0.1.0"
|
||||
|
|
@ -4014,6 +4319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4193,6 +4499,12 @@ dependencies = [
|
|||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
|
|
@ -4227,6 +4539,22 @@ dependencies = [
|
|||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.7",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-cmp"
|
||||
version = "0.1.0"
|
||||
|
|
@ -4285,6 +4613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4388,6 +4717,30 @@ version = "0.5.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p384"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
|
|
@ -4474,6 +4827,15 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
@ -4554,6 +4916,27 @@ version = "0.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||
dependencies = [
|
||||
"der",
|
||||
"pkcs8",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
|
|
@ -4627,6 +5010,15 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
|
|
@ -5260,6 +5652,16 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac 0.12.1",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
|
|
@ -5274,6 +5676,26 @@ dependencies = [
|
|||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"digest 0.10.7",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
|
|
@ -5613,6 +6035,20 @@ dependencies = [
|
|||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
|
|
@ -5872,6 +6308,16 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
|
|
@ -5944,6 +6390,25 @@ version = "0.10.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
|
||||
|
||||
[[package]]
|
||||
name = "spinning_top"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spm_precompiled"
|
||||
version = "0.1.4"
|
||||
|
|
@ -6102,8 +6567,8 @@ dependencies = [
|
|||
"futures-core",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"hkdf 0.13.0",
|
||||
"hmac 0.13.0",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
|
|
@ -6737,6 +7202,22 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-cookies"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"cookie",
|
||||
"futures-util",
|
||||
"http 1.4.2",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.11"
|
||||
|
|
@ -6751,6 +7232,11 @@ dependencies = [
|
|||
"http 1.4.2",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
|
@ -6772,6 +7258,69 @@ version = "0.3.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tower-sessions"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"http 1.4.2",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower-cookies",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tower-sessions-core",
|
||||
"tower-sessions-memory-store",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-sessions-core"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"base64 0.22.1",
|
||||
"futures",
|
||||
"http 1.4.2",
|
||||
"parking_lot",
|
||||
"rand 0.8.7",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-sessions-memory-store"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower-sessions-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-sessions-moka-store"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a5e622001aa59953f422ade78a0fa0d1f4d2566c9bf697bffe6aa89f1438f08"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"moka",
|
||||
"time",
|
||||
"tower-sessions-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
|
|
@ -7672,6 +8221,20 @@ name = "zeroize"
|
|||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ litellm-gateway = { path = "crates/gateway" }
|
|||
litellm-gateway-inference = { path = "crates/gateway-inference" }
|
||||
litellm-gateway-auth = { path = "crates/gateway-auth" }
|
||||
litellm-gateway-management = { path = "crates/gateway-management" }
|
||||
litellm-gateway-ui = { path = "crates/gateway-ui" }
|
||||
litellm-coroutine = { path = "crates/coroutine" }
|
||||
litellm-host = { path = "crates/host" }
|
||||
litellm-host-http = { path = "crates/host-http" }
|
||||
|
|
@ -60,6 +61,8 @@ litellm-python-compat = { path = "crates/python-compat" }
|
|||
|
||||
tracing = "0.1"
|
||||
axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "multipart"] }
|
||||
axum-login = "0.18.0"
|
||||
tower-sessions = { version = "0.14.0", features = ["memory-store"] }
|
||||
bytes = "1"
|
||||
http = "1"
|
||||
google-cloud-auth = { version = "1.16.0", default-features = false }
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ Inbound authentication uses a verifier, identity resolver, and authorizer inject
|
|||
|
||||
The Axum `authenticate` middleware currently accepts one Authorization header using the existing Bearer format. Missing, duplicate, empty, or malformed credentials fail. Authentication replaces any preexisting caller extension and checks the method and matched route before dispatch. Handlers extract `AuthenticatedRequest` and authorize their parsed operation before calling a provider. Missing authenticated context fails closed
|
||||
|
||||
Local UI login continues using axum-login and tower-sessions. Only after session and CSRF validation does `UiSession` expose an authenticated caller. Its credentials are restricted to session-info and logout operations, so the UI CSRF bearer cannot authorize inference. Future UI management routes must extend that explicit scope
|
||||
|
||||
Authentication evidence and principals contain no raw token, password, request body, or mutable accounting state. Session ownership is scoped by principal authority, subject, verifier, and credential ID, so separate credentials do not silently share an MCP session. Credential rotation may retain ownership when the verifier preserves a stable credential ID. Scope and expiry checks still run for each operation
|
||||
|
||||
Failures distinguish invalid or expired credentials, forbidden operations, unavailable authentication services, and missing server configuration/context. HTTP adapters map those outcomes to status codes; inference keeps its API-specific error envelopes
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ repository.workspace = true
|
|||
|
||||
[dependencies]
|
||||
axum = { workspace = true, features = ["matched-path"] }
|
||||
axum-login.workspace = true
|
||||
litellm-auth-types.workspace = true
|
||||
litellm-config.workspace = true
|
||||
litellm-secrets.workspace = true
|
||||
|
|
@ -14,6 +15,8 @@ sha2.workspace = true
|
|||
subtle.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
tower-sessions.workspace = true
|
||||
veil.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
futures-util.workspace = true
|
||||
|
|
|
|||
|
|
@ -49,3 +49,25 @@ impl IntoResponse for Error {
|
|||
(self.status(), self.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UiAuthError {
|
||||
#[error("UI_USERNAME and UI_PASSWORD must be nonempty")]
|
||||
Unconfigured,
|
||||
#[error("invalid UI credentials or session")]
|
||||
Unauthorized,
|
||||
#[error("UI authentication unavailable")]
|
||||
Unavailable,
|
||||
#[error("UI session unavailable")]
|
||||
Session(#[from] tower_sessions::session::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for UiAuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match self {
|
||||
Self::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, self.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ mod error;
|
|||
mod http;
|
||||
mod identity;
|
||||
pub mod keys;
|
||||
mod ui;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
|
|
@ -15,12 +16,13 @@ pub use authorization::{
|
|||
AccessRequest, AuthenticatedRequest, AuthorizedOperation, Authorizer, McpAction,
|
||||
NoAdditionalPolicy, Permissions, UiAction,
|
||||
};
|
||||
pub use error::{Error, KeyError};
|
||||
pub use error::{Error, KeyError, UiAuthError};
|
||||
pub use http::{Bearer, CredentialExtractor, RequireMasterKey, authenticate};
|
||||
pub use identity::{
|
||||
AuthenticatedCaller, Authentication, AuthenticationMethod, Principal, PrincipalKind,
|
||||
ResolvedIdentity, SharedCaller, VerifiedIdentity,
|
||||
};
|
||||
pub use ui::{UI_CSRF_KEY, UiAuthSession, UiBackend, UiCredentials, UiSession, UiUser};
|
||||
|
||||
pub fn hash_token(token: &str) -> String {
|
||||
format!("{:x}", Sha256::digest(token.as_bytes()))
|
||||
|
|
|
|||
186
litellm-rust/crates/gateway-auth/src/ui.rs
Normal file
186
litellm-rust/crates/gateway-auth/src/ui.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
use std::{convert::Infallible, sync::Arc};
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::{header::AUTHORIZATION, request::Parts};
|
||||
use axum_login::{AuthUser, AuthnBackend, UserId};
|
||||
use litellm_auth_types::SecretValue;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tower_sessions::Session;
|
||||
use veil::Redact;
|
||||
|
||||
use crate::{
|
||||
AccessRequest, AuthenticatedRequest, Authentication, AuthenticationMethod, LocalAdministrator,
|
||||
NoAdditionalPolicy, Permissions, Principal, PrincipalKind, SystemClock, UiAction, UiAuthError,
|
||||
VerifiedIdentity,
|
||||
};
|
||||
|
||||
pub const UI_CSRF_KEY: &str = "litellm.ui.csrf";
|
||||
pub type UiAuthSession = axum_login::AuthSession<UiBackend>;
|
||||
|
||||
#[derive(Clone, Redact)]
|
||||
pub struct UiUser {
|
||||
pub username: String,
|
||||
#[redact]
|
||||
password_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl AuthUser for UiUser {
|
||||
type Id = String;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.username.clone()
|
||||
}
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
&self.password_hash
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UiCredentials {
|
||||
pub username: String,
|
||||
pub password: SecretValue,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UiBackend {
|
||||
user: UiUser,
|
||||
}
|
||||
|
||||
impl UiBackend {
|
||||
pub fn new(username: String, password: SecretValue) -> Result<Self, UiAuthError> {
|
||||
if username.trim().is_empty() || password.expose().trim().is_empty() {
|
||||
return Err(UiAuthError::Unconfigured);
|
||||
}
|
||||
Ok(Self {
|
||||
user: UiUser {
|
||||
username,
|
||||
password_hash: Sha256::digest(password.expose()).into(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthnBackend for UiBackend {
|
||||
type User = UiUser;
|
||||
type Credentials = UiCredentials;
|
||||
type Error = Infallible;
|
||||
|
||||
async fn authenticate(&self, credentials: UiCredentials) -> Result<Option<UiUser>, Infallible> {
|
||||
let password_matches = self
|
||||
.user
|
||||
.password_hash
|
||||
.ct_eq(&Sha256::digest(credentials.password.expose()));
|
||||
let username_matches =
|
||||
Sha256::digest(&self.user.username).ct_eq(&Sha256::digest(credentials.username));
|
||||
Ok(bool::from(password_matches & username_matches).then(|| self.user.clone()))
|
||||
}
|
||||
|
||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<UiUser>, Infallible> {
|
||||
Ok((user_id == &self.user.username).then(|| self.user.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UiSession {
|
||||
pub user: UiUser,
|
||||
pub identity: AuthenticatedRequest,
|
||||
}
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for UiSession {
|
||||
type Rejection = UiAuthError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let auth = UiAuthSession::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| UiAuthError::Unavailable)?;
|
||||
let user = auth.user.ok_or(UiAuthError::Unauthorized)?;
|
||||
let session = Session::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| UiAuthError::Unavailable)?;
|
||||
let expected = session
|
||||
.get::<String>(UI_CSRF_KEY)
|
||||
.await?
|
||||
.ok_or(UiAuthError::Unauthorized)?;
|
||||
let provided = parts
|
||||
.headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.ok_or(UiAuthError::Unauthorized)?;
|
||||
if !bool::from(Sha256::digest(expected).ct_eq(&Sha256::digest(provided))) {
|
||||
return Err(UiAuthError::Unauthorized);
|
||||
}
|
||||
let identity = session_identity(&user).await?;
|
||||
parts.extensions.insert(identity.clone());
|
||||
Ok(Self { user, identity })
|
||||
}
|
||||
}
|
||||
|
||||
async fn session_identity(user: &UiUser) -> Result<AuthenticatedRequest, UiAuthError> {
|
||||
crate::authentication::resolve(
|
||||
VerifiedIdentity {
|
||||
principal: Principal::new(
|
||||
"litellm:local-ui".into(),
|
||||
user.username.clone(),
|
||||
PrincipalKind::Human,
|
||||
),
|
||||
authentication: Authentication {
|
||||
method: AuthenticationMethod::Session,
|
||||
verifier: "litellm:local-ui".into(),
|
||||
credential_id: user.username.clone(),
|
||||
expires_at: None,
|
||||
},
|
||||
restrictions: Permissions::Only(Arc::from([
|
||||
AccessRequest::Ui(UiAction::SessionInfo),
|
||||
AccessRequest::Ui(UiAction::Logout),
|
||||
])),
|
||||
},
|
||||
&LocalAdministrator,
|
||||
Arc::new(NoAdditionalPolicy),
|
||||
Arc::new(SystemClock),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| UiAuthError::Unauthorized)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn ui_identity_is_scoped_to_session_operations() {
|
||||
let backend = UiBackend::new("admin".into(), SecretValue::new("password")).unwrap();
|
||||
let user = backend.get_user(&"admin".into()).await.unwrap().unwrap();
|
||||
let identity = session_identity(&user).await.unwrap();
|
||||
assert_eq!(identity.caller().principal().subject(), user.username);
|
||||
assert_eq!(
|
||||
identity.caller().authentication().method,
|
||||
AuthenticationMethod::Session
|
||||
);
|
||||
assert!(
|
||||
identity
|
||||
.authorize(AccessRequest::Ui(UiAction::SessionInfo))
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
identity
|
||||
.authorize(AccessRequest::Ui(UiAction::Logout))
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert!(matches!(
|
||||
identity
|
||||
.authorize(AccessRequest::Model {
|
||||
name: "model".into(),
|
||||
deployment: "provider/model".into(),
|
||||
})
|
||||
.await,
|
||||
Err(crate::Error::Forbidden)
|
||||
));
|
||||
}
|
||||
}
|
||||
69
litellm-rust/crates/gateway-auth/tests/ui.rs
Normal file
69
litellm-rust/crates/gateway-auth/tests/ui.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use axum_login::{AuthUser, AuthnBackend};
|
||||
use litellm_auth_types::SecretValue;
|
||||
use litellm_gateway_auth::{UiBackend, UiCredentials};
|
||||
use rstest::{fixture, rstest};
|
||||
|
||||
#[fixture]
|
||||
fn backend() -> UiBackend {
|
||||
UiBackend::new("admin".into(), SecretValue::new("test-password")).unwrap()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::valid("admin", "test-password", true)]
|
||||
#[case::wrong_password("admin", "wrong", false)]
|
||||
#[case::wrong_username("other", "test-password", false)]
|
||||
#[case::blank_password("admin", "", false)]
|
||||
#[tokio::test]
|
||||
async fn authenticates_both_credentials(
|
||||
backend: UiBackend,
|
||||
#[case] username: &str,
|
||||
#[case] password: &str,
|
||||
#[case] succeeds: bool,
|
||||
) {
|
||||
let user = backend
|
||||
.authenticate(UiCredentials {
|
||||
username: username.into(),
|
||||
password: SecretValue::new(password),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(user.is_some(), succeeds);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::empty_username("", "password")]
|
||||
#[case::empty_password("admin", "")]
|
||||
#[case::whitespace_username(" ", "password")]
|
||||
#[case::whitespace_password("admin", " ")]
|
||||
fn refuses_empty_configuration(#[case] username: &str, #[case] password: &str) {
|
||||
assert!(UiBackend::new(username.into(), SecretValue::new(password)).is_err());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn restores_only_configured_users(backend: UiBackend) {
|
||||
assert_eq!(
|
||||
backend
|
||||
.get_user(&"admin".into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.username,
|
||||
"admin"
|
||||
);
|
||||
assert!(backend.get_user(&"other".into()).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn password_changes_invalidate_session_auth_hash(backend: UiBackend) {
|
||||
let original = backend.get_user(&"admin".into()).await.unwrap().unwrap();
|
||||
let replaced = UiBackend::new("admin".into(), SecretValue::new("new-password"))
|
||||
.unwrap()
|
||||
.get_user(&"admin".into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(original.id(), replaced.id());
|
||||
assert_ne!(original.session_auth_hash(), replaced.session_auth_hash());
|
||||
}
|
||||
29
litellm-rust/crates/gateway-ui/Cargo.toml
Normal file
29
litellm-rust/crates/gateway-ui/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "litellm-gateway-ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
axum = { workspace = true, features = ["json", "original-uri"] }
|
||||
axum-login.workspace = true
|
||||
base64.workspace = true
|
||||
governor = { version = "0.10.4", default-features = false, features = ["std"] }
|
||||
jsonwebtoken = { workspace = true, features = ["rust_crypto"] }
|
||||
litellm-gateway-auth.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
tower-cookies = "0.11.0"
|
||||
tower-http = { version = "0.6.11", features = ["fs", "set-header"] }
|
||||
tower-sessions.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
litellm-auth-types.workspace = true
|
||||
rstest.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio.workspace = true
|
||||
53
litellm-rust/crates/gateway-ui/README.md
Normal file
53
litellm-rust/crates/gateway-ui/README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Gateway UI
|
||||
|
||||
Provides UI login and sessions independently of the frontend build layout
|
||||
|
||||
`router(backend, store, secure_cookies)` serves login, session, and discovery endpoints without reading files. `static_assets(directory)` mounts any static export under `/ui/`, preserving its file layout and directory redirects. The export must use relative asset URLs or URLs rooted at `/ui/`; missing paths return 404. No framework or asset directory name is assumed
|
||||
|
||||
Compose them with Axum:
|
||||
|
||||
```rust
|
||||
let ui = litellm_gateway_ui::router(backend, store, true)
|
||||
.merge(litellm_gateway_ui::static_assets(directory));
|
||||
```
|
||||
|
||||
`dashboard_assets(directory)` wraps the generic mount with the existing dashboard's `_next` aliases and branding routes. The `gateway` executable explicitly selects that adapter to preserve existing dashboard URLs. A different frontend can use `static_assets` or supply its own Axum router
|
||||
|
||||
`gateway` owns environment configuration and server startup. `gateway-auth` implements the local administrator backend and the `UiSession` extractor. The login response and discovery schema still follow the current dashboard's HTTP contract
|
||||
|
||||
Run from `litellm-rust` with an existing dashboard export and a gateway config:
|
||||
|
||||
```sh
|
||||
export LITELLM_CONFIG=/path/to/config.yaml
|
||||
export LITELLM_UI_PATH=/path/to/litellm/proxy/_experimental/out
|
||||
export UI_USERNAME=admin
|
||||
read -rs UI_PASSWORD
|
||||
export UI_PASSWORD
|
||||
cargo run -p litellm-gateway
|
||||
```
|
||||
|
||||
Open `/ui/login/` to sign in. HTTPS cookies are enabled by default. For local HTTP development only, set `LITELLM_UI_SECURE_COOKIES=false`. Omitting `LITELLM_UI_PATH` leaves UI routes disabled. `UI_PASSWORD` is required when the UI is enabled, and never falls back to the inference master key
|
||||
|
||||
`POST /v2/login` accepts JSON credentials and returns the dashboard's token and redirect URL. `axum-login` handles authenticated sessions through `tower-sessions`. Login rotates the session, sets an absolute 24-hour expiry, and issues an HttpOnly `litellm_session` cookie. The JavaScript-readable `token` cookie contains display claims and a CSRF token in its `key` field. It contains neither the master key nor the session ID
|
||||
|
||||
`GET /session/info` and `POST /session/logout` require both the session cookie and `Authorization: Bearer <key>` from the dashboard token. The session's stored user determines authorization; client-supplied JWT claims do not. Logout deletes the server-side session and clears both cookies. JSON-only login prevents cross-origin form submissions, and login attempts use a shared token bucket with a burst of ten and a refill rate of ten per minute per gateway process
|
||||
|
||||
The gateway uses a process-local Moka session store with expiry and a capacity of 10,000 sessions. Restarts invalidate sessions, and capacity eviction can sign users out early. Multi-process deployments need a shared `SessionStore` supplied to the router
|
||||
|
||||
The current scope is local administrator login, sessions, discovery, and static assets. SSO, database-backed users, cross-origin workers, custom URL prefixes, and management APIs are not implemented. Dashboard screens that call management APIs still require those routes. UI request and response bodies are excluded from the inference gateway's debug body logger
|
||||
|
||||
Run the regression suite from `litellm-rust` without a browser, dashboard build, running proxy, or provider credentials:
|
||||
|
||||
```sh
|
||||
cargo test -p litellm-gateway-ui -p litellm-gateway-auth -p litellm-gateway --locked -- --test-threads=1
|
||||
```
|
||||
|
||||
`gateway-ui/tests/routes.rs` tests discovery, login, cookies, validation, rate limits, CSRF, expiry, password changes, session rotation, and logout through the real Axum router with an injected session store and no asset files
|
||||
|
||||
`gateway-ui/tests/assets.rs` tests a plain HTML/JavaScript/CSS export without a Next.js directory, including redirects and path traversal rejection. Separate compatibility cases cover the existing dashboard aliases and branding
|
||||
|
||||
`gateway-auth/tests/ui.rs` checks credential matching, empty configuration, user lookup, and password-dependent session hashes. `gateway/tests/server.rs` checks optional mounting, the production Moka store, credential isolation from inference, local HTTP cookies, and exclusion of login secrets from logs
|
||||
|
||||
The command runs serially because the existing gateway logging test can lose tracing events during concurrent tests
|
||||
|
||||
These tests cover the gateway's HTTP contract. They do not execute the dashboard's JavaScript or verify visual rendering
|
||||
39
litellm-rust/crates/gateway-ui/src/assets.rs
Normal file
39
litellm-rust/crates/gateway-ui/src/assets.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use std::path::Path;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{OriginalUri, Request},
|
||||
http::Uri,
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
routing::get,
|
||||
};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
pub fn static_assets(directory: impl AsRef<Path>) -> Router {
|
||||
let pages = Router::new()
|
||||
.fallback_service(ServeDir::new(directory))
|
||||
.layer(middleware::from_fn(restore_redirect));
|
||||
Router::new()
|
||||
.route(
|
||||
"/ui",
|
||||
get(|OriginalUri(uri): OriginalUri| async move { append_slash(&uri) }),
|
||||
)
|
||||
.nest("/ui/", pages)
|
||||
}
|
||||
|
||||
async fn restore_redirect(OriginalUri(uri): OriginalUri, request: Request, next: Next) -> Response {
|
||||
let response = next.run(request).await;
|
||||
if response.status().is_redirection() {
|
||||
return append_slash(&uri).into_response();
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn append_slash(uri: &Uri) -> Redirect {
|
||||
let location = match uri.query() {
|
||||
Some(query) => format!("{}/?{query}", uri.path()),
|
||||
None => format!("{}/", uri.path()),
|
||||
};
|
||||
Redirect::permanent(&location)
|
||||
}
|
||||
35
litellm-rust/crates/gateway-ui/src/dashboard.rs
Normal file
35
litellm-rust/crates/gateway-ui/src/dashboard.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use std::path::Path;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use serde::Serialize;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Logo {
|
||||
logo_url: &'static str,
|
||||
}
|
||||
|
||||
pub fn dashboard_assets(directory: impl AsRef<Path>) -> Router {
|
||||
let directory = directory.as_ref();
|
||||
let assets = ServeDir::new(directory.join("_next")).append_index_html_on_directories(false);
|
||||
|
||||
crate::static_assets(directory)
|
||||
.route(
|
||||
"/get_logo_url",
|
||||
get(|| async {
|
||||
axum::Json(Logo {
|
||||
logo_url: "/get_image",
|
||||
})
|
||||
}),
|
||||
)
|
||||
.route_service(
|
||||
"/get_image",
|
||||
ServeFile::new(directory.join("assets/logos/litellm_logo.jpg")),
|
||||
)
|
||||
.route_service(
|
||||
"/get_favicon",
|
||||
ServeFile::new(directory.join("favicon.ico")),
|
||||
)
|
||||
.nest_service("/_next", assets.clone())
|
||||
.nest_service("/litellm-asset-prefix/_next", assets)
|
||||
}
|
||||
59
litellm-rust/crates/gateway-ui/src/error.rs
Normal file
59
litellm-rust/crates/gateway-ui/src/error.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
use axum::{
|
||||
Json,
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use litellm_gateway_auth::UiAuthError;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ErrorBody {
|
||||
error: ErrorMessage,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ErrorMessage {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Invalid username or password")]
|
||||
InvalidCredentials,
|
||||
#[error("Too many login attempts, try again in one minute")]
|
||||
RateLimited,
|
||||
#[error("UI authentication unavailable")]
|
||||
Auth(#[from] axum_login::Error<litellm_gateway_auth::UiBackend>),
|
||||
#[error("UI session unavailable")]
|
||||
Session(#[from] tower_sessions::session::Error),
|
||||
#[error("UI token unavailable")]
|
||||
Token(#[from] jsonwebtoken::errors::Error),
|
||||
#[error("Invalid UI session")]
|
||||
Unauthorized(#[from] UiAuthError),
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match &self {
|
||||
Self::InvalidCredentials | Self::Unauthorized(UiAuthError::Unauthorized) => {
|
||||
StatusCode::UNAUTHORIZED
|
||||
}
|
||||
Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
let response = (
|
||||
status,
|
||||
Json(ErrorBody {
|
||||
error: ErrorMessage {
|
||||
message: self.to_string(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
|
||||
if matches!(self, Self::RateLimited) {
|
||||
return ([(header::RETRY_AFTER, "60")], response).into_response();
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
57
litellm-rust/crates/gateway-ui/src/lib.rs
Normal file
57
litellm-rust/crates/gateway-ui/src/lib.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
mod assets;
|
||||
mod dashboard;
|
||||
mod error;
|
||||
mod session;
|
||||
|
||||
use std::{num::NonZeroU32, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
http::{HeaderValue, header},
|
||||
routing::{get, post},
|
||||
};
|
||||
use axum_login::AuthManagerLayerBuilder;
|
||||
use governor::{Quota, RateLimiter};
|
||||
use jsonwebtoken::EncodingKey;
|
||||
use litellm_gateway_auth::UiBackend;
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
use tower_sessions::{SessionManagerLayer, SessionStore, cookie::SameSite};
|
||||
|
||||
pub use assets::static_assets;
|
||||
pub use dashboard::dashboard_assets;
|
||||
pub use error::Error;
|
||||
|
||||
pub fn router(
|
||||
backend: UiBackend,
|
||||
store: impl SessionStore + Clone,
|
||||
secure_cookies: bool,
|
||||
) -> Router {
|
||||
let sessions = SessionManagerLayer::new(store)
|
||||
.with_name("litellm_session")
|
||||
.with_http_only(true)
|
||||
.with_secure(secure_cookies)
|
||||
.with_same_site(SameSite::Strict);
|
||||
let auth = AuthManagerLayerBuilder::new(backend, sessions).build();
|
||||
let state = Arc::new(session::State {
|
||||
signing_key: EncodingKey::from_secret(&rand::random::<[u8; 32]>()),
|
||||
login_limit: RateLimiter::direct(Quota::per_minute(NonZeroU32::new(10).unwrap())),
|
||||
secure_cookies,
|
||||
});
|
||||
|
||||
Router::new()
|
||||
.route("/v2/login", post(session::login))
|
||||
.route("/session/info", get(session::info))
|
||||
.route("/session/logout", post(session::logout))
|
||||
.layer(auth)
|
||||
.route("/.well-known/litellm-ui-config", get(session::discovery))
|
||||
.route(
|
||||
"/litellm/.well-known/litellm-ui-config",
|
||||
get(session::discovery),
|
||||
)
|
||||
.layer(axum::extract::DefaultBodyLimit::max(16 * 1024))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-store"),
|
||||
))
|
||||
.with_state(state)
|
||||
}
|
||||
156
litellm-rust/crates/gateway-ui/src/session.rs
Normal file
156
litellm-rust/crates/gateway-ui/src/session.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use jsonwebtoken::{EncodingKey, Header, encode};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
use axum::{Json, extract::State as ExtractState, response::IntoResponse};
|
||||
use governor::DefaultDirectRateLimiter;
|
||||
use tower_cookies::{Cookie, Cookies};
|
||||
use tower_sessions::{Expiry, Session, cookie::SameSite};
|
||||
|
||||
use crate::Error;
|
||||
use litellm_gateway_auth::{
|
||||
AccessRequest, UI_CSRF_KEY, UiAction, UiAuthError, UiAuthSession, UiCredentials, UiSession,
|
||||
};
|
||||
|
||||
pub struct State {
|
||||
pub signing_key: EncodingKey,
|
||||
pub login_limit: DefaultDirectRateLimiter,
|
||||
pub secure_cookies: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Claims<'a> {
|
||||
key: &'a str,
|
||||
user_id: &'a str,
|
||||
user_role: &'static str,
|
||||
login_method: &'static str,
|
||||
premium_user: bool,
|
||||
auth_header_name: &'static str,
|
||||
server_root_path: &'static str,
|
||||
exp: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoginResponse {
|
||||
redirect_url: &'static str,
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionInfo {
|
||||
user_id: String,
|
||||
user_role: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LogoutResponse {
|
||||
message: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Discovery {
|
||||
server_root_path: &'static str,
|
||||
proxy_base_url: Option<&'static str>,
|
||||
auto_redirect_to_sso: bool,
|
||||
admin_ui_disabled: bool,
|
||||
sso_configured: bool,
|
||||
hide_default_credentials_hint: bool,
|
||||
is_control_plane: bool,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
ExtractState(state): ExtractState<Arc<State>>,
|
||||
mut auth: UiAuthSession,
|
||||
session: Session,
|
||||
cookies: Cookies,
|
||||
Json(credentials): Json<UiCredentials>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
state.login_limit.check().map_err(|_| Error::RateLimited)?;
|
||||
let user = auth
|
||||
.authenticate(credentials)
|
||||
.await?
|
||||
.ok_or(Error::InvalidCredentials)?;
|
||||
let expires = OffsetDateTime::now_utc() + Duration::hours(24);
|
||||
let csrf = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>());
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&Claims {
|
||||
key: &csrf,
|
||||
user_id: &user.username,
|
||||
user_role: "proxy_admin",
|
||||
login_method: "username_password",
|
||||
premium_user: false,
|
||||
auth_header_name: "Authorization",
|
||||
server_root_path: "",
|
||||
exp: expires.unix_timestamp(),
|
||||
},
|
||||
&state.signing_key,
|
||||
)?;
|
||||
auth.logout().await?;
|
||||
auth.login(&user).await?;
|
||||
session.cycle_id().await?;
|
||||
session.insert(UI_CSRF_KEY, csrf).await?;
|
||||
session.set_expiry(Some(Expiry::AtDateTime(expires)));
|
||||
cookies.add(
|
||||
Cookie::build(("token", token.clone()))
|
||||
.path("/ui")
|
||||
.same_site(SameSite::Strict)
|
||||
.secure(state.secure_cookies)
|
||||
.expires(expires)
|
||||
.build(),
|
||||
);
|
||||
Ok(Json(LoginResponse {
|
||||
redirect_url: "/ui/?login=success",
|
||||
token,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn info(session: Result<UiSession, UiAuthError>) -> Result<impl IntoResponse, Error> {
|
||||
let session = session?;
|
||||
session
|
||||
.identity
|
||||
.authorize(AccessRequest::Ui(UiAction::SessionInfo))
|
||||
.await
|
||||
.map_err(|_| UiAuthError::Unauthorized)?;
|
||||
Ok(Json(SessionInfo {
|
||||
user_id: session.user.username,
|
||||
user_role: "proxy_admin",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
validated: Result<UiSession, UiAuthError>,
|
||||
mut auth: UiAuthSession,
|
||||
cookies: Cookies,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
let validated = validated?;
|
||||
validated
|
||||
.identity
|
||||
.authorize(AccessRequest::Ui(UiAction::Logout))
|
||||
.await
|
||||
.map_err(|_| UiAuthError::Unauthorized)?;
|
||||
auth.logout().await?;
|
||||
cookies.add(
|
||||
Cookie::build(("token", ""))
|
||||
.path("/ui")
|
||||
.max_age(Duration::ZERO)
|
||||
.build(),
|
||||
);
|
||||
Ok(Json(LogoutResponse {
|
||||
message: "Session revoked.",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn discovery() -> impl IntoResponse {
|
||||
Json(Discovery {
|
||||
server_root_path: "",
|
||||
proxy_base_url: None,
|
||||
auto_redirect_to_sso: false,
|
||||
admin_ui_disabled: false,
|
||||
sso_configured: false,
|
||||
hide_default_credentials_hint: true,
|
||||
is_control_plane: false,
|
||||
})
|
||||
}
|
||||
186
litellm-rust/crates/gateway-ui/tests/assets.rs
Normal file
186
litellm-rust/crates/gateway-ui/tests/assets.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use rstest::{fixture, rstest};
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct App {
|
||||
router: Router,
|
||||
_directory: TempDir,
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn directory() -> TempDir {
|
||||
let directory = TempDir::new().unwrap();
|
||||
let export = directory.path().join("public");
|
||||
std::fs::create_dir_all(export.join("login")).unwrap();
|
||||
std::fs::create_dir_all(export.join("assets")).unwrap();
|
||||
std::fs::write(directory.path().join("outside.txt"), "private file").unwrap();
|
||||
std::fs::write(export.join("index.html"), "dashboard").unwrap();
|
||||
std::fs::write(export.join("login/index.html"), "login page").unwrap();
|
||||
std::fs::write(export.join("assets/app.js"), "window.app = true;").unwrap();
|
||||
std::fs::write(export.join("styles.css"), "body { color: black; }").unwrap();
|
||||
directory
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn app(directory: TempDir) -> App {
|
||||
App {
|
||||
router: litellm_gateway_ui::static_assets(directory.path().join("public")),
|
||||
_directory: directory,
|
||||
}
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn dashboard(directory: TempDir) -> App {
|
||||
let export = directory.path().join("public");
|
||||
std::fs::create_dir_all(export.join("_next/static")).unwrap();
|
||||
std::fs::create_dir_all(export.join("assets/logos")).unwrap();
|
||||
std::fs::write(export.join("assets/logos/litellm_logo.jpg"), "logo bytes").unwrap();
|
||||
std::fs::write(export.join("favicon.ico"), "icon bytes").unwrap();
|
||||
std::fs::write(export.join("_next/static/app.js"), "window.app = true;").unwrap();
|
||||
App {
|
||||
router: litellm_gateway_ui::dashboard_assets(export),
|
||||
_directory: directory,
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::dashboard("/ui/", "dashboard", "text/html")]
|
||||
#[case::login("/ui/login/", "login page", "text/html")]
|
||||
#[case::javascript("/ui/assets/app.js", "window.app = true;", "text/javascript")]
|
||||
#[case::stylesheet("/ui/styles.css", "body { color: black; }", "text/css")]
|
||||
#[tokio::test]
|
||||
async fn serves_export_without_auth(
|
||||
app: App,
|
||||
#[case] path: &str,
|
||||
#[case] expected: &str,
|
||||
#[case] mime: &str,
|
||||
) {
|
||||
let response = app
|
||||
.router
|
||||
.oneshot(Request::get(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response.headers()[header::CONTENT_TYPE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with(mime)
|
||||
);
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), 65536).await.unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::root("/ui?login=success", "/ui/?login=success")]
|
||||
#[case::nested("/ui/login?redirect_to=%2Fui", "/ui/login/?redirect_to=%2Fui")]
|
||||
#[tokio::test]
|
||||
async fn directory_redirects_preserve_prefix_and_query(
|
||||
app: App,
|
||||
#[case] path: &str,
|
||||
#[case] location: &str,
|
||||
) {
|
||||
let response = app
|
||||
.router
|
||||
.oneshot(Request::get(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(response.status().is_redirection());
|
||||
assert_eq!(response.headers()[header::LOCATION], location);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::missing("/ui/no-such-page")]
|
||||
#[case::outside_ui("/v1/models")]
|
||||
#[case::traversal("/ui/%2e%2e/outside.txt")]
|
||||
#[case::encoded_separator("/ui/..%2foutside.txt")]
|
||||
#[case::no_implicit_root_assets("/assets/app.js")]
|
||||
#[case::no_implicit_dashboard_branding("/get_logo_url")]
|
||||
#[tokio::test]
|
||||
async fn missing_paths_never_fall_back_to_dashboard(app: App, #[case] path: &str) {
|
||||
let response = app
|
||||
.router
|
||||
.oneshot(Request::get(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert!(
|
||||
!to_bytes(response.into_body(), 65536)
|
||||
.await
|
||||
.unwrap()
|
||||
.windows(9)
|
||||
.any(|part| part == b"dashboard")
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::dashboard("/ui/", "dashboard", "text/html")]
|
||||
#[case::asset_alias(
|
||||
"/litellm-asset-prefix/_next/static/app.js",
|
||||
"window.app = true;",
|
||||
"text/javascript"
|
||||
)]
|
||||
#[case::root_assets("/_next/static/app.js", "window.app = true;", "text/javascript")]
|
||||
#[case::nested_assets("/ui/_next/static/app.js", "window.app = true;", "text/javascript")]
|
||||
#[case::logo("/get_image", "logo bytes", "image/jpeg")]
|
||||
#[case::favicon("/get_favicon", "icon bytes", "image/x-icon")]
|
||||
#[tokio::test]
|
||||
async fn dashboard_adapter_preserves_existing_urls(
|
||||
dashboard: App,
|
||||
#[case] path: &str,
|
||||
#[case] expected: &str,
|
||||
#[case] mime: &str,
|
||||
) {
|
||||
let response = dashboard
|
||||
.router
|
||||
.oneshot(Request::get(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response.headers()[header::CONTENT_TYPE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with(mime)
|
||||
);
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), 65536).await.unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn logo_discovery_points_to_served_image(dashboard: App) {
|
||||
let response = dashboard
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(Request::get("/get_logo_url").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let config: Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()).unwrap();
|
||||
let response = dashboard
|
||||
.router
|
||||
.oneshot(
|
||||
Request::get(config["logo_url"].as_str().unwrap())
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), 65536).await.unwrap(),
|
||||
"logo bytes"
|
||||
);
|
||||
}
|
||||
498
litellm-rust/crates/gateway-ui/tests/routes.rs
Normal file
498
litellm-rust/crates/gateway-ui/tests/routes.rs
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use serde_json::{Value, json};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use rstest::{fixture, rstest};
|
||||
use tower::ServiceExt;
|
||||
use tower_cookies::Cookie;
|
||||
use tower_sessions::{
|
||||
MemoryStore, SessionStore,
|
||||
session::{Id, Record},
|
||||
};
|
||||
|
||||
use litellm_auth_types::SecretValue;
|
||||
use litellm_gateway_auth::UiBackend;
|
||||
|
||||
struct App {
|
||||
router: Router,
|
||||
store: MemoryStore,
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn app() -> App {
|
||||
let store = MemoryStore::default();
|
||||
let backend = UiBackend::new("admin".into(), SecretValue::new("test-password")).unwrap();
|
||||
let router = litellm_gateway_ui::router(backend, store.clone(), true);
|
||||
App { router, store }
|
||||
}
|
||||
|
||||
async fn body(response: Response) -> Value {
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()).unwrap()
|
||||
}
|
||||
|
||||
async fn login(app: &App, cookie: Option<&str>) -> Response {
|
||||
app.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/v2/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::COOKIE, cookie.unwrap_or(""))
|
||||
.body(Body::from(
|
||||
json!({"username": "admin", "password": "test-password"}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn session_cookie(response: &Response) -> Cookie<'static> {
|
||||
response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.map(|value| Cookie::parse(value.to_str().unwrap().to_owned()).unwrap())
|
||||
.find(|cookie| cookie.name() == "litellm_session")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn claims(response: Response) -> Value {
|
||||
let json = body(response).await;
|
||||
let token = json["token"].as_str().unwrap();
|
||||
serde_json::from_slice(
|
||||
&URL_SAFE_NO_PAD
|
||||
.decode(token.split('.').nth(1).unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn protected(app: &App, path: &str, method: &str, cookie: &str, csrf: &str) -> Response {
|
||||
app.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(path)
|
||||
.method(method)
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {csrf}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::root("/.well-known/litellm-ui-config")]
|
||||
#[case::compatibility("/litellm/.well-known/litellm-ui-config")]
|
||||
#[tokio::test]
|
||||
async fn discovery_describes_local_login(app: App, #[case] path: &str) {
|
||||
let response = app
|
||||
.router
|
||||
.oneshot(Request::get(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
let config = body(response).await;
|
||||
assert_eq!(config["sso_configured"], false);
|
||||
assert_eq!(config["hide_default_credentials_hint"], true);
|
||||
assert_eq!(config["server_root_path"], "");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn login_sets_bounded_session_and_requires_cookie_with_csrf(app: App) {
|
||||
let started = OffsetDateTime::now_utc();
|
||||
let response = login(&app, None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
let cookie = session_cookie(&response);
|
||||
assert_eq!(cookie.http_only(), Some(true));
|
||||
assert_eq!(cookie.secure(), Some(true));
|
||||
assert_eq!(
|
||||
cookie.same_site(),
|
||||
Some(tower_cookies::cookie::SameSite::Strict)
|
||||
);
|
||||
assert_eq!(cookie.path(), Some("/"));
|
||||
let display_cookie = response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.map(|value| Cookie::parse(value.to_str().unwrap().to_owned()).unwrap())
|
||||
.find(|cookie| cookie.name() == "token")
|
||||
.unwrap();
|
||||
assert_eq!(display_cookie.path(), Some("/ui"));
|
||||
assert_eq!(display_cookie.secure(), Some(true));
|
||||
assert_ne!(display_cookie.http_only(), Some(true));
|
||||
assert_eq!(
|
||||
display_cookie.same_site(),
|
||||
Some(tower_cookies::cookie::SameSite::Strict)
|
||||
);
|
||||
let json = body(response).await;
|
||||
assert_eq!(json["redirect_url"], "/ui/?login=success");
|
||||
assert_eq!(json["token"], display_cookie.value());
|
||||
let token: Value = serde_json::from_slice(
|
||||
&URL_SAFE_NO_PAD
|
||||
.decode(display_cookie.value().split('.').nth(1).unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let csrf = token["key"].as_str().unwrap();
|
||||
assert_eq!(token["user_id"], "admin");
|
||||
assert_eq!(token["user_role"], "proxy_admin");
|
||||
assert_eq!(token["auth_header_name"], "Authorization");
|
||||
assert_ne!(csrf, cookie.value());
|
||||
assert_ne!(csrf, "test-password");
|
||||
let record = app
|
||||
.store
|
||||
.load(&cookie.value().parse::<Id>().unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(token["exp"], record.expiry_date.unix_timestamp());
|
||||
assert_eq!(
|
||||
display_cookie.expires_datetime().unwrap().unix_timestamp(),
|
||||
record.expiry_date.unix_timestamp()
|
||||
);
|
||||
assert!(record.expiry_date >= started + Duration::hours(24));
|
||||
assert!(record.expiry_date <= OffsetDateTime::now_utc() + Duration::hours(24));
|
||||
let response = protected(&app, "/session/info", "GET", &cookie.to_string(), csrf).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
body(response).await,
|
||||
json!({"user_id": "admin", "user_role": "proxy_admin"})
|
||||
);
|
||||
assert_eq!(
|
||||
app.store
|
||||
.load(&record.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.expiry_date,
|
||||
record.expiry_date
|
||||
);
|
||||
assert_eq!(
|
||||
protected(&app, "/session/info", "GET", "", csrf)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
protected(&app, "/session/info", "GET", &cookie.to_string(), "wrong")
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn logout_revokes_only_the_presented_session(app: App) {
|
||||
let first = login(&app, None).await;
|
||||
let first_cookie = session_cookie(&first);
|
||||
let first_claims = claims(first).await;
|
||||
let first_csrf = first_claims["key"].as_str().unwrap();
|
||||
let second = login(&app, None).await;
|
||||
let second_cookie = session_cookie(&second);
|
||||
let second_claims = claims(second).await;
|
||||
let second_csrf = second_claims["key"].as_str().unwrap();
|
||||
let rejected = protected(
|
||||
&app,
|
||||
"/session/logout",
|
||||
"POST",
|
||||
&first_cookie.to_string(),
|
||||
second_csrf,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
|
||||
let response = protected(
|
||||
&app,
|
||||
"/session/logout",
|
||||
"POST",
|
||||
&first_cookie.to_string(),
|
||||
first_csrf,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let removed = response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.map(|value| Cookie::parse(value.to_str().unwrap().to_owned()).unwrap())
|
||||
.find(|cookie| cookie.name() == "token")
|
||||
.unwrap();
|
||||
assert_eq!(removed.value(), "");
|
||||
assert_eq!(removed.path(), Some("/ui"));
|
||||
assert_eq!(removed.max_age(), Some(Duration::ZERO));
|
||||
assert_eq!(session_cookie(&response).max_age(), Some(Duration::ZERO));
|
||||
assert_eq!(
|
||||
protected(
|
||||
&app,
|
||||
"/session/info",
|
||||
"GET",
|
||||
&first_cookie.to_string(),
|
||||
first_csrf
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
protected(
|
||||
&app,
|
||||
"/session/info",
|
||||
"GET",
|
||||
&second_cookie.to_string(),
|
||||
second_csrf
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn expired_sessions_are_rejected(app: App) {
|
||||
let response = login(&app, None).await;
|
||||
let cookie = session_cookie(&response);
|
||||
let token = claims(response).await;
|
||||
let record = app
|
||||
.store
|
||||
.load(&cookie.value().parse().unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
app.store
|
||||
.save(&Record {
|
||||
expiry_date: OffsetDateTime::now_utc() - Duration::seconds(1),
|
||||
..record
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
protected(
|
||||
&app,
|
||||
"/session/info",
|
||||
"GET",
|
||||
&cookie.to_string(),
|
||||
token["key"].as_str().unwrap()
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn reauthentication_rotates_and_revokes_the_previous_session(app: App) {
|
||||
let first = login(&app, None).await;
|
||||
let first_cookie = session_cookie(&first);
|
||||
let first_claims = claims(first).await;
|
||||
let second = login(&app, Some(&first_cookie.to_string())).await;
|
||||
assert_eq!(second.status(), StatusCode::OK);
|
||||
let second_cookie = session_cookie(&second);
|
||||
assert_ne!(first_cookie.value(), second_cookie.value());
|
||||
let second_claims = claims(second).await;
|
||||
assert_ne!(first_claims["key"], second_claims["key"]);
|
||||
assert_eq!(
|
||||
protected(
|
||||
&app,
|
||||
"/session/info",
|
||||
"GET",
|
||||
&second_cookie.to_string(),
|
||||
second_claims["key"].as_str().unwrap()
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
protected(
|
||||
&app,
|
||||
"/session/info",
|
||||
"GET",
|
||||
&first_cookie.to_string(),
|
||||
first_claims["key"].as_str().unwrap()
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn failed_logins_are_generic_and_rate_limited(app: App) {
|
||||
for attempt in 0..11 {
|
||||
let response = app.router.clone().oneshot(Request::post("/v2/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(json!({"username": format!("unknown-{attempt}"), "password": "wrong-password"}).to_string())).unwrap()).await.unwrap();
|
||||
if attempt < 10 {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
body(response).await["error"]["message"],
|
||||
"Invalid username or password"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert!(response.headers().contains_key(header::RETRY_AFTER));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn login_rejects_browser_form_posts(app: App) {
|
||||
let response = app
|
||||
.router
|
||||
.oneshot(
|
||||
Request::post("/v2/login")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from("username=admin&password=test-password"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||||
assert!(!response.headers().contains_key(header::SET_COOKIE));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::missing(None)]
|
||||
#[case::empty(Some(""))]
|
||||
#[case::wrong_scheme(Some("Basic invalid"))]
|
||||
#[case::empty_bearer(Some("Bearer "))]
|
||||
#[case::wrong_bearer(Some("Bearer invalid"))]
|
||||
#[tokio::test]
|
||||
async fn session_requires_csrf_header(app: App, #[case] authorization: Option<&str>) {
|
||||
let response = login(&app, None).await;
|
||||
let cookie = session_cookie(&response);
|
||||
let request = Request::get("/session/info").header(header::COOKIE, cookie.to_string());
|
||||
let request = match authorization {
|
||||
Some(value) => request.header(header::AUTHORIZATION, value),
|
||||
None => request,
|
||||
};
|
||||
let response = app
|
||||
.router
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::missing("")]
|
||||
#[case::malformed("litellm_session=invalid")]
|
||||
#[case::unknown("litellm_session=AAAAAAAAAAAAAAAAAAAAAA")]
|
||||
#[tokio::test]
|
||||
async fn logout_requires_authenticated_session(app: App, #[case] cookie: &str) {
|
||||
let response = protected(&app, "/session/logout", "POST", cookie, "invalid").await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::wrong_password("admin", "wrong")]
|
||||
#[case::unknown_user("unknown", "test-password")]
|
||||
#[tokio::test]
|
||||
async fn failed_login_preserves_existing_session(
|
||||
app: App,
|
||||
#[case] username: &str,
|
||||
#[case] password: &str,
|
||||
) {
|
||||
let original = login(&app, None).await;
|
||||
let cookie = session_cookie(&original);
|
||||
let token = claims(original).await;
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/v2/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::COOKIE, cookie.to_string())
|
||||
.body(Body::from(
|
||||
json!({"username": username, "password": password}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
body(response).await["error"]["message"],
|
||||
"Invalid username or password"
|
||||
);
|
||||
assert_eq!(
|
||||
protected(
|
||||
&app,
|
||||
"/session/info",
|
||||
"GET",
|
||||
&cookie.to_string(),
|
||||
token["key"].as_str().unwrap()
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::malformed("{".into(), StatusCode::BAD_REQUEST)]
|
||||
#[case::missing_password(r#"{"username":"admin"}"#.into(), StatusCode::UNPROCESSABLE_ENTITY)]
|
||||
#[case::oversized(json!({"username": "admin", "password": "x".repeat(16 * 1024)}).to_string(), StatusCode::PAYLOAD_TOO_LARGE)]
|
||||
#[tokio::test]
|
||||
async fn invalid_login_payloads_do_not_create_sessions(
|
||||
app: App,
|
||||
#[case] payload: String,
|
||||
#[case] status: StatusCode,
|
||||
) {
|
||||
let response = app
|
||||
.router
|
||||
.oneshot(
|
||||
Request::post("/v2/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(payload))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), status);
|
||||
assert!(!response.headers().contains_key(header::SET_COOKIE));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn changed_password_revokes_sessions_in_shared_store(app: App) {
|
||||
let original = login(&app, None).await;
|
||||
let cookie = session_cookie(&original);
|
||||
let token = claims(original).await;
|
||||
let replaced = litellm_gateway_ui::router(
|
||||
UiBackend::new("admin".into(), SecretValue::new("new-password")).unwrap(),
|
||||
app.store,
|
||||
true,
|
||||
);
|
||||
let response = replaced
|
||||
.oneshot(
|
||||
Request::get("/session/info")
|
||||
.header(header::COOKIE, cookie.to_string())
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", token["key"].as_str().unwrap()),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
|
@ -7,9 +7,12 @@ repository.workspace = true
|
|||
|
||||
[dependencies]
|
||||
axum.workspace = true
|
||||
envy = "0.4.2"
|
||||
http-body-util = "0.1"
|
||||
litellm-core.workspace = true
|
||||
litellm-gateway-inference.workspace = true
|
||||
litellm-gateway-ui.workspace = true
|
||||
litellm-auth-types.workspace = true
|
||||
litellm-gateway-auth.workspace = true
|
||||
litellm-config.workspace = true
|
||||
litellm-http.workspace = true
|
||||
|
|
@ -17,12 +20,16 @@ litellm-llms.workspace = true
|
|||
litellm-secrets.workspace = true
|
||||
litellm-tracing.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
tower-sessions-moka-store = "0.15.0"
|
||||
|
||||
[dev-dependencies]
|
||||
futures-util.workspace = true
|
||||
rstest.workspace = true
|
||||
base64.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
|
|
|||
|
|
@ -34,14 +34,18 @@ pub fn build_inference(config: &Config) -> Result<Arc<Gateway>, litellm_http::Er
|
|||
)?))
|
||||
}
|
||||
|
||||
pub fn router(inference: Arc<Gateway>, config: &Config) -> Router {
|
||||
pub fn router(inference: Arc<Gateway>, config: &Config, ui: Option<Router>) -> Router {
|
||||
let auth = Auth::from_config(config, inference.secrets.clone());
|
||||
litellm_gateway_inference::router(inference)
|
||||
let inference = litellm_gateway_inference::router(inference)
|
||||
.route_layer(axum::middleware::from_fn_with_state(
|
||||
auth,
|
||||
litellm_gateway_auth::authenticate,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(log_request))
|
||||
.layer(axum::middleware::from_fn(log_request));
|
||||
match ui {
|
||||
Some(ui) => inference.merge(ui),
|
||||
None => inference,
|
||||
}
|
||||
}
|
||||
|
||||
async fn log_request(request: Request, next: Next) -> Response {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@ use std::{
|
|||
};
|
||||
|
||||
use litellm_config::Config;
|
||||
use litellm_gateway_auth::UiBackend;
|
||||
use litellm_tracing::{Level, Logger, Metadata, Record, Sink};
|
||||
use serde_json::json;
|
||||
|
||||
mod settings;
|
||||
|
||||
use settings::{Settings, UiSettings};
|
||||
|
||||
struct StderrSink {
|
||||
level: Level,
|
||||
}
|
||||
|
|
@ -36,22 +41,32 @@ impl Sink for StderrSink {
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let level = std::env::var("RUST_LOG")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(Level::INFO);
|
||||
let settings: Settings = settings::from_iter(std::env::vars_os())?;
|
||||
let level = settings.log_level();
|
||||
Logger::new(StderrSink { level }).install_global()?;
|
||||
let config_path = std::env::var("LITELLM_CONFIG").unwrap_or_else(|_| "config.yaml".into());
|
||||
let config = Config::load(config_path)?;
|
||||
let config = Config::load(settings.litellm_config)?;
|
||||
let inference = litellm_gateway::build_inference(&config)?;
|
||||
let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into());
|
||||
let port = std::env::var("PORT")
|
||||
.unwrap_or_else(|_| "4000".into())
|
||||
.parse::<u16>()?;
|
||||
let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?;
|
||||
let ui = match std::env::var_os("LITELLM_UI_PATH") {
|
||||
Some(directory) => {
|
||||
let ui_settings: UiSettings = settings::from_iter(std::env::vars_os())?;
|
||||
let backend = UiBackend::new(ui_settings.ui_username, ui_settings.ui_password)?;
|
||||
Some(
|
||||
litellm_gateway_ui::router(
|
||||
backend,
|
||||
tower_sessions_moka_store::MokaStore::new(Some(10_000)),
|
||||
ui_settings.litellm_ui_secure_cookies,
|
||||
)
|
||||
.merge(litellm_gateway_ui::dashboard_assets(
|
||||
std::path::PathBuf::from(directory),
|
||||
)),
|
||||
)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let listener = tokio::net::TcpListener::bind((settings.host.as_str(), settings.port)).await?;
|
||||
|
||||
tracing::info!(address = %listener.local_addr()?, models = config.model_list.len(), log_level = %level, "gateway listening");
|
||||
|
||||
axum::serve(listener, litellm_gateway::router(inference, &config)).await?;
|
||||
axum::serve(listener, litellm_gateway::router(inference, &config, ui)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
171
litellm-rust/crates/gateway/src/settings.rs
Normal file
171
litellm-rust/crates/gateway/src/settings.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
use std::ffi::OsString;
|
||||
|
||||
use litellm_auth_types::SecretValue;
|
||||
use litellm_tracing::Level;
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(default)]
|
||||
pub(super) struct Settings {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub litellm_config: String,
|
||||
rust_log: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: "0.0.0.0".into(),
|
||||
port: 4000,
|
||||
litellm_config: "config.yaml".into(),
|
||||
rust_log: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn log_level(&self) -> Level {
|
||||
self.rust_log
|
||||
.as_deref()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(Level::INFO)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct UiSettings {
|
||||
#[serde(default = "default_username")]
|
||||
pub ui_username: String,
|
||||
pub ui_password: SecretValue,
|
||||
#[serde(default = "default_secure_cookies")]
|
||||
pub litellm_ui_secure_cookies: bool,
|
||||
}
|
||||
|
||||
fn default_username() -> String {
|
||||
"admin".into()
|
||||
}
|
||||
|
||||
fn default_secure_cookies() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn from_iter<T: DeserializeOwned>(
|
||||
variables: impl IntoIterator<Item = (OsString, OsString)>,
|
||||
) -> envy::Result<T> {
|
||||
envy::from_iter(
|
||||
variables
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn parse<T: DeserializeOwned>(variables: &[(&str, &str)]) -> envy::Result<T> {
|
||||
from_iter(
|
||||
variables
|
||||
.iter()
|
||||
.map(|(key, value)| (OsString::from(key), OsString::from(value))),
|
||||
)
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn defaults_allow_startup_without_ui_credentials() {
|
||||
let settings: Settings = parse(&[]).unwrap();
|
||||
|
||||
assert_eq!(settings.host, "0.0.0.0");
|
||||
assert_eq!(settings.port, 4000);
|
||||
assert_eq!(settings.litellm_config, "config.yaml");
|
||||
assert_eq!(settings.log_level(), Level::INFO);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn environment_overrides_startup_settings() {
|
||||
let settings: Settings = parse(&[
|
||||
("HOST", "127.0.0.1"),
|
||||
("PORT", "8080"),
|
||||
("LITELLM_CONFIG", "/tmp/custom.yaml"),
|
||||
("RUST_LOG", "debug"),
|
||||
("LITELLM_UI_SECURE_COOKIES", "invalid-but-ui-disabled"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.host, "127.0.0.1");
|
||||
assert_eq!(settings.port, 8080);
|
||||
assert_eq!(settings.litellm_config, "/tmp/custom.yaml");
|
||||
assert_eq!(settings.log_level(), Level::DEBUG);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::empty("")]
|
||||
#[case::filter("litellm=debug")]
|
||||
#[case::invalid("invalid")]
|
||||
fn unrecognized_log_levels_fall_back_to_info(#[case] value: &str) {
|
||||
let settings: Settings = parse(&[("RUST_LOG", value)]).unwrap();
|
||||
|
||||
assert_eq!(settings.log_level(), Level::INFO);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::empty("")]
|
||||
#[case::negative("-1")]
|
||||
#[case::overflow("65536")]
|
||||
#[case::invalid("http")]
|
||||
fn invalid_ports_fail_startup(#[case] value: &str) {
|
||||
assert!(parse::<Settings>(&[("PORT", value)]).is_err());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn ui_defaults_keep_secure_cookies_and_preserve_password() {
|
||||
let settings: UiSettings = parse(&[("UI_PASSWORD", " secret,with spaces ")]).unwrap();
|
||||
|
||||
assert_eq!(settings.ui_username, "admin");
|
||||
assert_eq!(settings.ui_password.expose(), " secret,with spaces ");
|
||||
assert!(settings.litellm_ui_secure_cookies);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::secure("true", true)]
|
||||
#[case::insecure("false", false)]
|
||||
fn ui_environment_overrides(#[case] value: &str, #[case] expected: bool) {
|
||||
let settings: UiSettings = parse(&[
|
||||
("UI_USERNAME", "operator"),
|
||||
("UI_PASSWORD", "password"),
|
||||
("LITELLM_UI_SECURE_COOKIES", value),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.ui_username, "operator");
|
||||
assert_eq!(settings.litellm_ui_secure_cookies, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::missing_password(&[])]
|
||||
#[case::invalid_cookie_flag(&[("UI_PASSWORD", "password"), ("LITELLM_UI_SECURE_COOKIES", "invalid")])]
|
||||
fn invalid_ui_settings_fail(#[case] variables: &[(&str, &str)]) {
|
||||
assert!(parse::<UiSettings>(variables).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[rstest]
|
||||
#[case::unrelated_variable("UNRELATED")]
|
||||
#[case::host_fallback("HOST")]
|
||||
fn non_unicode_environment_values_are_ignored(#[case] key: &str) {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let settings: Settings = from_iter([
|
||||
(OsString::from(key), OsString::from_vec(vec![0xff])),
|
||||
(OsString::from_vec(vec![0xff]), OsString::from("value")),
|
||||
(OsString::from("PORT"), OsString::from("8080")),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.host, "0.0.0.0");
|
||||
assert_eq!(settings.port, 8080);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ use std::{
|
|||
};
|
||||
|
||||
use axum::{body::Body, http::Request};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use litellm_config::Config;
|
||||
use litellm_gateway_inference::{Error, Gateway};
|
||||
use litellm_http::ClientVariant;
|
||||
|
|
@ -71,7 +72,7 @@ async fn authenticates_before_serving_mounted_inference_routes(
|
|||
let address = listener.local_addr().unwrap();
|
||||
let (shutdown, stopped) = oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, litellm_gateway::router(inference, &config))
|
||||
axum::serve(listener, litellm_gateway::router(inference, &config, None))
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = stopped.await;
|
||||
})
|
||||
|
|
@ -125,7 +126,7 @@ async fn logs_request_outcome_without_credentials_or_query(inference: Arc<Gatewa
|
|||
let logger = Logger::new(LogSink(sender));
|
||||
|
||||
let response = logger
|
||||
.instrument(litellm_gateway::router(inference, &config).oneshot(request))
|
||||
.instrument(litellm_gateway::router(inference, &config, None).oneshot(request))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -141,3 +142,145 @@ async fn logs_request_outcome_without_credentials_or_query(inference: Arc<Gatewa
|
|||
assert!(!record.to_string().contains("header-secret"));
|
||||
assert!(!record.to_string().contains("query-secret"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn mounts_ui_without_exposing_credentials_or_authorizing_inference(inference: Arc<Gateway>) {
|
||||
let config =
|
||||
Config::from_yaml("model_list: []\ngeneral_settings:\n master_key: inference-secret\n")
|
||||
.unwrap();
|
||||
let assets = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(assets.path().join("index.html"), "dashboard").unwrap();
|
||||
let backend = litellm_gateway_auth::UiBackend::new(
|
||||
"admin".into(),
|
||||
litellm_auth_types::SecretValue::new("ui-password"),
|
||||
)
|
||||
.unwrap();
|
||||
let ui = litellm_gateway_ui::router(
|
||||
backend,
|
||||
tower_sessions_moka_store::MokaStore::new(Some(10_000)),
|
||||
false,
|
||||
)
|
||||
.merge(litellm_gateway_ui::dashboard_assets(assets.path()));
|
||||
let app = litellm_gateway::router(inference, &config, Some(ui));
|
||||
let page = app
|
||||
.clone()
|
||||
.oneshot(Request::get("/ui/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page.status().as_u16(), 200);
|
||||
assert_eq!(
|
||||
axum::body::to_bytes(page.into_body(), 65536).await.unwrap(),
|
||||
"dashboard"
|
||||
);
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let logger = Logger::new(LogSink(sender));
|
||||
let response = logger
|
||||
.instrument(
|
||||
app.clone().oneshot(
|
||||
Request::post("/v2/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({"username": "admin", "password": "ui-password"}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.all(|cookie| !cookie.to_str().unwrap().contains("Secure"))
|
||||
);
|
||||
let cookie = response
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().unwrap().split(';').next())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
let login: Value = serde_json::from_slice(
|
||||
&logger
|
||||
.instrument(axum::body::to_bytes(response.into_body(), 65536))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let jwt = login["token"].as_str().unwrap();
|
||||
let token: Value = serde_json::from_slice(
|
||||
&URL_SAFE_NO_PAD
|
||||
.decode(jwt.split('.').nth(1).unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let csrf = token["key"].as_str().unwrap();
|
||||
let logs = receiver.try_iter().collect::<Vec<_>>();
|
||||
let logged = serde_json::to_string(&logs).unwrap();
|
||||
assert!(!logged.contains("ui-password"));
|
||||
assert!(!logged.contains(jwt));
|
||||
let ui_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::get("/session/info")
|
||||
.header("cookie", &cookie)
|
||||
.header("authorization", format!("Bearer {csrf}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ui_response.status().as_u16(), 200);
|
||||
let inference_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/v1/messages")
|
||||
.header("cookie", cookie)
|
||||
.header("authorization", format!("Bearer {csrf}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(inference_response.status().as_u16(), 401);
|
||||
let master_response = app
|
||||
.oneshot(
|
||||
Request::get("/session/info")
|
||||
.header("authorization", "Bearer inference-secret")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(master_response.status().as_u16(), 401);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::assets("/ui/", "GET")]
|
||||
#[case::login("/v2/login", "POST")]
|
||||
#[case::session("/session/info", "GET")]
|
||||
#[case::discovery("/.well-known/litellm-ui-config", "GET")]
|
||||
#[tokio::test]
|
||||
async fn ui_routes_are_absent_when_not_mounted(
|
||||
inference: Arc<Gateway>,
|
||||
#[case] path: &str,
|
||||
#[case] method: &str,
|
||||
) {
|
||||
let config =
|
||||
Config::from_yaml("model_list: []\ngeneral_settings:\n master_key: gateway-key\n")
|
||||
.unwrap();
|
||||
let response = litellm_gateway::router(inference, &config, None)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status().as_u16(), 404);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue