mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-06 21:33:14 +00:00
feat(iam): add OpenID Connect SSO with claim-based policy resolution (#1875)
Signed-off-by: houseme <housemecn@gmail.com> Co-authored-by: houseme <housemecn@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: heihutu <30542132+heihutu@users.noreply.github.com> Co-authored-by: loverustfs <hello@rustfs.com> Co-authored-by: 安正超 <anzhengchao@gmail.com>
This commit is contained in:
Generated
+425
-33
@@ -317,7 +317,7 @@ dependencies = [
|
||||
"arrow-schema",
|
||||
"arrow-select",
|
||||
"atoi",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"comfy-table",
|
||||
"half",
|
||||
@@ -824,7 +824,7 @@ dependencies = [
|
||||
"hmac 0.12.1",
|
||||
"http 0.2.12",
|
||||
"http 1.4.0",
|
||||
"p256",
|
||||
"p256 0.11.1",
|
||||
"percent-encoding",
|
||||
"ring",
|
||||
"sha2 0.10.9",
|
||||
@@ -1115,12 +1115,24 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -1903,8 +1915,10 @@ 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]]
|
||||
@@ -1992,6 +2006,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.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.14.4"
|
||||
@@ -2494,7 +2535,7 @@ checksum = "379b01418ab95ca947014066248c22139fe9af9289354de10b445bd000d5d276"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"arrow-buffer",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"blake2 0.10.6",
|
||||
"blake3",
|
||||
"chrono",
|
||||
@@ -2829,6 +2870,17 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[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 0.7.0",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.8.0"
|
||||
@@ -2836,7 +2888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
|
||||
dependencies = [
|
||||
"const-oid 0.10.2",
|
||||
"pem-rfc7468",
|
||||
"pem-rfc7468 1.0.0",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2962,6 +3014,7 @@ 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",
|
||||
]
|
||||
@@ -3036,7 +3089,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"flatbuffers",
|
||||
@@ -3074,11 +3127,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c"
|
||||
dependencies = [
|
||||
"der 0.6.1",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"elliptic-curve 0.12.3",
|
||||
"rfc6979 0.3.1",
|
||||
"signature 1.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der 0.7.10",
|
||||
"digest 0.10.7",
|
||||
"elliptic-curve 0.13.8",
|
||||
"rfc6979 0.4.0",
|
||||
"signature 2.2.0",
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8 0.10.2",
|
||||
"signature 2.2.0",
|
||||
]
|
||||
|
||||
[[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.15.0"
|
||||
@@ -3095,12 +3186,33 @@ dependencies = [
|
||||
"crypto-bigint 0.4.9",
|
||||
"der 0.6.1",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"ff 0.12.1",
|
||||
"generic-array",
|
||||
"group",
|
||||
"group 0.12.1",
|
||||
"pkcs8 0.9.0",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"sec1 0.3.0",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct 0.2.0",
|
||||
"crypto-bigint 0.5.5",
|
||||
"digest 0.10.7",
|
||||
"ff 0.13.1",
|
||||
"generic-array",
|
||||
"group 0.13.0",
|
||||
"hkdf",
|
||||
"pem-rfc7468 0.7.0",
|
||||
"pkcs8 0.10.2",
|
||||
"rand_core 0.6.4",
|
||||
"sec1 0.7.3",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -3257,6 +3369,22 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[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.27"
|
||||
@@ -3499,6 +3627,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3585,7 +3714,7 @@ checksum = "36ad774d41426ab205eeec577540f209a5485c366814dd5c89a7e3018fe84e7c"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-lc-rs",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"google-cloud-gax",
|
||||
"http 1.4.0",
|
||||
@@ -3607,7 +3736,7 @@ version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2973715fe664ecb0d883926c8b5f66cb9d52a44add1d0be1cad1907d832bf0af"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures",
|
||||
"google-cloud-rpc",
|
||||
@@ -3727,7 +3856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ce225eccfece751251e07ac787199cfc53bc2b5d60ac0570f58404507ceb309"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc32c",
|
||||
@@ -3782,7 +3911,7 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0ade65b0e4fa9cb4b6f147c8e726803bff453e3190910a53cbd3b0c019f5c2a"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3798,7 +3927,18 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"ff 0.12.1",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff 0.13.1",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
@@ -3922,6 +4062,15 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9040319a6910b901d5d49cbada4a99db52836a1b63228a05f7e2b7f8feef89b1"
|
||||
|
||||
[[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 = "hmac"
|
||||
version = "0.12.1"
|
||||
@@ -4092,7 +4241,7 @@ version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
@@ -4404,6 +4553,15 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -4535,7 +4693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.2.17",
|
||||
"js-sys",
|
||||
"pem",
|
||||
@@ -4593,6 +4751,9 @@ name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin 0.9.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
@@ -5224,6 +5385,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.5",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
@@ -5338,6 +5515,25 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauth2"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"getrandom 0.2.17",
|
||||
"http 1.4.0",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.1"
|
||||
@@ -5417,6 +5613,37 @@ version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "openidconnect"
|
||||
version = "4.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ed25519-dalek",
|
||||
"hmac 0.12.1",
|
||||
"http 1.4.0",
|
||||
"itertools 0.10.5",
|
||||
"log",
|
||||
"oauth2",
|
||||
"p256 0.13.2",
|
||||
"p384",
|
||||
"rand 0.8.5",
|
||||
"rsa 0.9.10",
|
||||
"serde",
|
||||
"serde-value",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_plain",
|
||||
"serde_with",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
@@ -5556,8 +5783,32 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"ecdsa 0.14.8",
|
||||
"elliptic-curve 0.12.3",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa 0.16.9",
|
||||
"elliptic-curve 0.13.8",
|
||||
"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 0.16.9",
|
||||
"elliptic-curve 0.13.8",
|
||||
"primeorder",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
@@ -5614,7 +5865,7 @@ dependencies = [
|
||||
"arrow-ipc",
|
||||
"arrow-schema",
|
||||
"arrow-select",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -5709,10 +5960,19 @@ version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"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 = "pem-rfc7468"
|
||||
version = "1.0.0"
|
||||
@@ -5801,6 +6061,17 @@ 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 0.7.10",
|
||||
"pkcs8 0.10.2",
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.8.0-rc.4"
|
||||
@@ -5821,6 +6092,16 @@ dependencies = [
|
||||
"spki 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der 0.7.10",
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.11.0-rc.11"
|
||||
@@ -5996,6 +6277,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve 0.13.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr2"
|
||||
version = "2.0.0"
|
||||
@@ -6295,13 +6585,24 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
@@ -6317,6 +6618,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
@@ -6524,7 +6835,7 @@ version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -6565,7 +6876,7 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
@@ -6616,6 +6927,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[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 = "rgb"
|
||||
version = "0.8.52"
|
||||
@@ -6646,7 +6967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures",
|
||||
"pastey",
|
||||
@@ -6693,6 +7014,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[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 0.7.5",
|
||||
"pkcs8 0.10.2",
|
||||
"rand_core 0.6.4",
|
||||
"signature 2.2.0",
|
||||
"spki 0.7.3",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.10.0-rc.15"
|
||||
@@ -6703,7 +7044,7 @@ dependencies = [
|
||||
"crypto-bigint 0.7.0-rc.27",
|
||||
"crypto-primes",
|
||||
"digest 0.11.0",
|
||||
"pkcs1",
|
||||
"pkcs1 0.8.0-rc.4",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"rand_core 0.10.0",
|
||||
"signature 3.0.0-rc.10",
|
||||
@@ -6802,7 +7143,7 @@ dependencies = [
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
"backtrace",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
"clap",
|
||||
@@ -6898,7 +7239,7 @@ version = "0.0.5"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"rand 0.10.0",
|
||||
"rsa",
|
||||
"rsa 0.10.0-rc.15",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7005,7 +7346,7 @@ dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sdk-s3",
|
||||
"aws-smithy-types",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"base64-simd",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -7137,9 +7478,14 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64-simd",
|
||||
"futures",
|
||||
"http 1.4.0",
|
||||
"jsonwebtoken",
|
||||
"moka",
|
||||
"openidconnect",
|
||||
"pollster",
|
||||
"rand 0.10.0",
|
||||
"reqwest 0.13.2",
|
||||
"rustfs-config",
|
||||
"rustfs-credentials",
|
||||
"rustfs-crypto",
|
||||
"rustfs-ecstore",
|
||||
@@ -7153,6 +7499,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7162,7 +7509,7 @@ dependencies = [
|
||||
"aes-gcm",
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chacha20poly1305",
|
||||
"jiff",
|
||||
"md5",
|
||||
@@ -7370,7 +7717,7 @@ name = "rustfs-rio"
|
||||
version = "0.0.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"crc-fast",
|
||||
"faster-hex",
|
||||
@@ -7911,6 +8258,20 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct 0.2.0",
|
||||
"der 0.7.10",
|
||||
"generic-array",
|
||||
"pkcs8 0.10.2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -7960,6 +8321,16 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-value"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
|
||||
dependencies = [
|
||||
"ordered-float",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
@@ -8024,6 +8395,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_plain"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
@@ -8051,7 +8431,7 @@ version = "3.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
@@ -8220,6 +8600,7 @@ 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",
|
||||
]
|
||||
|
||||
@@ -8410,6 +8791,16 @@ dependencies = [
|
||||
"der 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der 0.7.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.8.0-rc.4"
|
||||
@@ -9098,7 +9489,7 @@ checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"flate2",
|
||||
"h2",
|
||||
@@ -9474,6 +9865,7 @@ dependencies = [
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -156,6 +156,7 @@ chacha20poly1305 = { version = "0.11.0-rc.3" }
|
||||
crc-fast = "1.9.0"
|
||||
hmac = { version = "0.13.0-rc.5" }
|
||||
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
|
||||
openidconnect = { version = "4.0", default-features = false }
|
||||
pbkdf2 = "0.13.0-rc.9"
|
||||
rsa = { version = "0.10.0-rc.15" }
|
||||
rustls = { version = "0.23.37", default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) mod console;
|
||||
pub(crate) mod env;
|
||||
pub(crate) mod heal;
|
||||
pub(crate) mod object;
|
||||
pub(crate) mod oidc;
|
||||
pub(crate) mod profiler;
|
||||
pub(crate) mod protocols;
|
||||
pub(crate) mod proxy;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// OIDC configuration field keys (used in KVS)
|
||||
pub const OIDC_CONFIG_URL: &str = "config_url";
|
||||
pub const OIDC_CLIENT_ID: &str = "client_id";
|
||||
pub const OIDC_CLIENT_SECRET: &str = "client_secret";
|
||||
pub const OIDC_SCOPES: &str = "scopes";
|
||||
pub const OIDC_REDIRECT_URI: &str = "redirect_uri";
|
||||
pub const OIDC_REDIRECT_URI_DYNAMIC: &str = "redirect_uri_dynamic";
|
||||
pub const OIDC_CLAIM_NAME: &str = "claim_name";
|
||||
pub const OIDC_CLAIM_PREFIX: &str = "claim_prefix";
|
||||
pub const OIDC_ROLE_POLICY: &str = "role_policy";
|
||||
pub const OIDC_DISPLAY_NAME: &str = "display_name";
|
||||
pub const OIDC_GROUPS_CLAIM: &str = "groups_claim";
|
||||
pub const OIDC_EMAIL_CLAIM: &str = "email_claim";
|
||||
pub const OIDC_USERNAME_CLAIM: &str = "username_claim";
|
||||
|
||||
// Environment variable names for OIDC
|
||||
pub const ENV_IDENTITY_OPENID_ENABLE: &str = "RUSTFS_IDENTITY_OPENID_ENABLE";
|
||||
pub const ENV_IDENTITY_OPENID_CONFIG_URL: &str = "RUSTFS_IDENTITY_OPENID_CONFIG_URL";
|
||||
pub const ENV_IDENTITY_OPENID_CLIENT_ID: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_ID";
|
||||
pub const ENV_IDENTITY_OPENID_CLIENT_SECRET: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_SECRET";
|
||||
pub const ENV_IDENTITY_OPENID_SCOPES: &str = "RUSTFS_IDENTITY_OPENID_SCOPES";
|
||||
pub const ENV_IDENTITY_OPENID_REDIRECT_URI: &str = "RUSTFS_IDENTITY_OPENID_REDIRECT_URI";
|
||||
pub const ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC: &str = "RUSTFS_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC";
|
||||
pub const ENV_IDENTITY_OPENID_CLAIM_NAME: &str = "RUSTFS_IDENTITY_OPENID_CLAIM_NAME";
|
||||
pub const ENV_IDENTITY_OPENID_CLAIM_PREFIX: &str = "RUSTFS_IDENTITY_OPENID_CLAIM_PREFIX";
|
||||
pub const ENV_IDENTITY_OPENID_ROLE_POLICY: &str = "RUSTFS_IDENTITY_OPENID_ROLE_POLICY";
|
||||
pub const ENV_IDENTITY_OPENID_DISPLAY_NAME: &str = "RUSTFS_IDENTITY_OPENID_DISPLAY_NAME";
|
||||
pub const ENV_IDENTITY_OPENID_GROUPS_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_GROUPS_CLAIM";
|
||||
pub const ENV_IDENTITY_OPENID_EMAIL_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_EMAIL_CLAIM";
|
||||
pub const ENV_IDENTITY_OPENID_USERNAME_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM";
|
||||
|
||||
/// List of all environment variable keys for an OIDC provider.
|
||||
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 14] = &[
|
||||
ENV_IDENTITY_OPENID_ENABLE,
|
||||
ENV_IDENTITY_OPENID_CONFIG_URL,
|
||||
ENV_IDENTITY_OPENID_CLIENT_ID,
|
||||
ENV_IDENTITY_OPENID_CLIENT_SECRET,
|
||||
ENV_IDENTITY_OPENID_SCOPES,
|
||||
ENV_IDENTITY_OPENID_REDIRECT_URI,
|
||||
ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC,
|
||||
ENV_IDENTITY_OPENID_CLAIM_NAME,
|
||||
ENV_IDENTITY_OPENID_CLAIM_PREFIX,
|
||||
ENV_IDENTITY_OPENID_ROLE_POLICY,
|
||||
ENV_IDENTITY_OPENID_DISPLAY_NAME,
|
||||
ENV_IDENTITY_OPENID_GROUPS_CLAIM,
|
||||
ENV_IDENTITY_OPENID_EMAIL_CLAIM,
|
||||
ENV_IDENTITY_OPENID_USERNAME_CLAIM,
|
||||
];
|
||||
|
||||
/// A list of all valid configuration keys for an OIDC provider.
|
||||
pub const IDENTITY_OPENID_KEYS: &[&str] = &[
|
||||
crate::ENABLE_KEY,
|
||||
OIDC_CONFIG_URL,
|
||||
OIDC_CLIENT_ID,
|
||||
OIDC_CLIENT_SECRET,
|
||||
OIDC_SCOPES,
|
||||
OIDC_REDIRECT_URI,
|
||||
OIDC_REDIRECT_URI_DYNAMIC,
|
||||
OIDC_CLAIM_NAME,
|
||||
OIDC_CLAIM_PREFIX,
|
||||
OIDC_ROLE_POLICY,
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_GROUPS_CLAIM,
|
||||
OIDC_EMAIL_CLAIM,
|
||||
OIDC_USERNAME_CLAIM,
|
||||
crate::COMMENT_KEY,
|
||||
];
|
||||
|
||||
// Default values
|
||||
pub const OIDC_DEFAULT_SCOPES: &str = "openid,profile,email";
|
||||
pub const OIDC_DEFAULT_CLAIM_NAME: &str = "groups";
|
||||
pub const OIDC_DEFAULT_GROUPS_CLAIM: &str = "groups";
|
||||
pub const OIDC_DEFAULT_EMAIL_CLAIM: &str = "email";
|
||||
pub const OIDC_DEFAULT_USERNAME_CLAIM: &str = "preferred_username";
|
||||
|
||||
// Subsystem identifier
|
||||
pub const IDENTITY_OPENID_SUB_SYS: &str = "identity_openid";
|
||||
@@ -44,6 +44,10 @@ pub use constants::scanner::*;
|
||||
pub use constants::targets::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::tls::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub mod oidc {
|
||||
pub use super::constants::oidc::*;
|
||||
}
|
||||
#[cfg(feature = "audit")]
|
||||
pub mod audit;
|
||||
#[cfg(feature = "notify")]
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod com;
|
||||
#[allow(dead_code)]
|
||||
pub mod heal;
|
||||
mod notify;
|
||||
mod oidc;
|
||||
pub mod storageclass;
|
||||
|
||||
use crate::error::Result;
|
||||
@@ -26,6 +27,7 @@ use rustfs_config::COMMENT_KEY;
|
||||
use rustfs_config::DEFAULT_DELIMITER;
|
||||
use rustfs_config::audit::{AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS};
|
||||
use rustfs_config::notify::{NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS};
|
||||
use rustfs_config::oidc::IDENTITY_OPENID_SUB_SYS;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
@@ -226,6 +228,7 @@ pub fn init() {
|
||||
kvs.insert(AUDIT_WEBHOOK_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_WEBHOOK_KVS.clone());
|
||||
kvs.insert(NOTIFY_MQTT_SUB_SYS.to_owned(), notify::DEFAULT_NOTIFY_MQTT_KVS.clone());
|
||||
kvs.insert(AUDIT_MQTT_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_MQTT_KVS.clone());
|
||||
kvs.insert(IDENTITY_OPENID_SUB_SYS.to_owned(), oidc::DEFAULT_IDENTITY_OPENID_KVS.clone());
|
||||
|
||||
// Register all default configurations
|
||||
register_default_kvs(kvs)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::config::{KV, KVS};
|
||||
use rustfs_config::{
|
||||
ENABLE_KEY, EnableState,
|
||||
oidc::{
|
||||
OIDC_CLAIM_NAME, OIDC_CLAIM_PREFIX, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_CONFIG_URL, OIDC_DEFAULT_CLAIM_NAME,
|
||||
OIDC_DEFAULT_EMAIL_CLAIM, OIDC_DEFAULT_GROUPS_CLAIM, OIDC_DEFAULT_SCOPES, OIDC_DEFAULT_USERNAME_CLAIM, OIDC_DISPLAY_NAME,
|
||||
OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_REDIRECT_URI, OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_SCOPES,
|
||||
OIDC_USERNAME_CLAIM,
|
||||
},
|
||||
};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Default KVS for OIDC identity provider settings.
|
||||
pub static DEFAULT_IDENTITY_OPENID_KVS: LazyLock<KVS> = LazyLock::new(|| {
|
||||
KVS(vec![
|
||||
KV {
|
||||
key: ENABLE_KEY.to_owned(),
|
||||
value: EnableState::Off.to_string(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_CONFIG_URL.to_owned(),
|
||||
value: "".to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_CLIENT_ID.to_owned(),
|
||||
value: "".to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_CLIENT_SECRET.to_owned(),
|
||||
value: "".to_owned(),
|
||||
hidden_if_empty: true,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_SCOPES.to_owned(),
|
||||
value: OIDC_DEFAULT_SCOPES.to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_REDIRECT_URI.to_owned(),
|
||||
value: "".to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_REDIRECT_URI_DYNAMIC.to_owned(),
|
||||
value: EnableState::On.to_string(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_CLAIM_NAME.to_owned(),
|
||||
value: OIDC_DEFAULT_CLAIM_NAME.to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_CLAIM_PREFIX.to_owned(),
|
||||
value: "".to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_ROLE_POLICY.to_owned(),
|
||||
value: "".to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_DISPLAY_NAME.to_owned(),
|
||||
value: "".to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_GROUPS_CLAIM.to_owned(),
|
||||
value: OIDC_DEFAULT_GROUPS_CLAIM.to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_EMAIL_CLAIM.to_owned(),
|
||||
value: OIDC_DEFAULT_EMAIL_CLAIM.to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_USERNAME_CLAIM.to_owned(),
|
||||
value: OIDC_DEFAULT_USERNAME_CLAIM.to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
])
|
||||
});
|
||||
@@ -30,6 +30,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
rustfs-credentials = { workspace = true }
|
||||
rustfs-config = { workspace = true, features = ["constants"] }
|
||||
tokio.workspace = true
|
||||
time = { workspace = true, features = ["serde-human-readable"] }
|
||||
serde = { workspace = true, features = ["derive", "rc"] }
|
||||
@@ -49,6 +50,11 @@ rustfs-madmin.workspace = true
|
||||
rustfs-utils = { workspace = true, features = ["path"] }
|
||||
tokio-util.workspace = true
|
||||
pollster.workspace = true
|
||||
reqwest = { workspace = true }
|
||||
url = { workspace = true }
|
||||
moka = { workspace = true }
|
||||
openidconnect = { workspace = true }
|
||||
http = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pollster.workspace = true
|
||||
|
||||
+41
-1
@@ -14,20 +14,24 @@
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use manager::IamCache;
|
||||
use oidc::OidcSys;
|
||||
use rustfs_ecstore::store::ECStore;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use store::object::ObjectStore;
|
||||
use sys::IamSys;
|
||||
use tracing::{error, info, instrument};
|
||||
use tracing::{error, info, instrument, warn};
|
||||
|
||||
pub mod cache;
|
||||
pub mod error;
|
||||
pub mod manager;
|
||||
pub mod oidc;
|
||||
pub mod oidc_state;
|
||||
pub mod store;
|
||||
pub mod sys;
|
||||
pub mod utils;
|
||||
|
||||
static IAM_SYS: OnceLock<Arc<IamSys<ObjectStore>>> = OnceLock::new();
|
||||
static OIDC_SYS: OnceLock<Arc<OidcSys>> = OnceLock::new();
|
||||
|
||||
#[instrument(skip(ecstore))]
|
||||
pub async fn init_iam_sys(ecstore: Arc<ECStore>) -> Result<()> {
|
||||
@@ -75,3 +79,39 @@ pub fn get() -> Result<Arc<IamSys<ObjectStore>>> {
|
||||
pub fn get_global_iam_sys() -> Option<Arc<IamSys<ObjectStore>>> {
|
||||
IAM_SYS.get().cloned()
|
||||
}
|
||||
|
||||
/// Initialize the global OIDC system. Non-fatal if no OIDC providers are configured.
|
||||
pub async fn init_oidc_sys() -> Result<()> {
|
||||
if OIDC_SYS.get().is_some() {
|
||||
info!("OIDC system already initialized, skipping.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Starting OIDC system initialization...");
|
||||
|
||||
let oidc_sys = match OidcSys::new().await {
|
||||
Ok(sys) => {
|
||||
if sys.has_providers() {
|
||||
info!("OIDC system initialized with {} provider(s)", sys.list_providers().len());
|
||||
} else {
|
||||
info!("No OIDC providers configured");
|
||||
}
|
||||
sys
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("OIDC initialization failed (non-fatal): {}", e);
|
||||
OidcSys::empty()
|
||||
}
|
||||
};
|
||||
|
||||
if OIDC_SYS.set(Arc::new(oidc_sys)).is_err() {
|
||||
warn!("Race condition during OIDC initialization (non-fatal)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the global OIDC system.
|
||||
pub fn get_oidc() -> Option<Arc<OidcSys>> {
|
||||
OIDC_SYS.get().cloned()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! OIDC Provider Manager
|
||||
//!
|
||||
//! Implements the OpenID Connect Authorization Code Flow with PKCE using the
|
||||
//! `openidconnect` crate for standards-compliant discovery, token exchange,
|
||||
//! and ID token verification.
|
||||
|
||||
use crate::oidc_state::{OidcAuthSession, OidcStateStore};
|
||||
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreProviderMetadata};
|
||||
use openidconnect::{
|
||||
AsyncHttpClient, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge,
|
||||
PkceCodeVerifier, RedirectUrl, Scope,
|
||||
};
|
||||
use rustfs_config::oidc::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use tracing::{error, info};
|
||||
|
||||
// ---- HTTP Client Adapter ----
|
||||
|
||||
/// Error type for the OIDC HTTP client adapter.
|
||||
#[derive(Debug)]
|
||||
pub enum OidcHttpError {
|
||||
Reqwest(reqwest::Error),
|
||||
Http(http::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OidcHttpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Reqwest(e) => write!(f, "{e}"),
|
||||
Self::Http(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OidcHttpError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Reqwest(e) => Some(e),
|
||||
Self::Http(e) => Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client adapter bridging reqwest 0.13 to the `openidconnect` `AsyncHttpClient` trait.
|
||||
pub(crate) struct ReqwestHttpClient(reqwest::Client);
|
||||
|
||||
impl<'c> AsyncHttpClient<'c> for ReqwestHttpClient {
|
||||
type Error = OidcHttpError;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<http::Response<Vec<u8>>, Self::Error>> + Send + 'c>>;
|
||||
|
||||
fn call(&'c self, request: http::Request<Vec<u8>>) -> Self::Future {
|
||||
Box::pin(async move {
|
||||
let (parts, body) = request.into_parts();
|
||||
let response = self
|
||||
.0
|
||||
.request(parts.method, parts.uri.to_string())
|
||||
.headers(parts.headers)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(OidcHttpError::Reqwest)?;
|
||||
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let body_bytes = response.bytes().await.map_err(OidcHttpError::Reqwest)?;
|
||||
|
||||
let mut http_response = http::Response::builder()
|
||||
.status(status)
|
||||
.body(body_bytes.to_vec())
|
||||
.map_err(OidcHttpError::Http)?;
|
||||
*http_response.headers_mut() = headers;
|
||||
|
||||
Ok(http_response)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Public types (unchanged API) ----
|
||||
|
||||
/// Parsed configuration for a single OIDC provider.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OidcProviderConfig {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
pub config_url: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub scopes: Vec<String>,
|
||||
pub redirect_uri: Option<String>,
|
||||
pub redirect_uri_dynamic: bool,
|
||||
pub claim_name: String,
|
||||
pub claim_prefix: String,
|
||||
pub role_policy: String,
|
||||
pub display_name: String,
|
||||
pub groups_claim: String,
|
||||
pub email_claim: String,
|
||||
pub username_claim: String,
|
||||
}
|
||||
|
||||
/// Summary info about a provider, returned to the console.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OidcProviderSummary {
|
||||
pub provider_id: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// Claims extracted from an OIDC ID token.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct OidcClaims {
|
||||
pub sub: String,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub groups: Vec<String>,
|
||||
pub raw: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
// ---- Internal provider state ----
|
||||
|
||||
/// Discovered OIDC provider metadata.
|
||||
/// We store metadata (which includes JWKS after discovery) separately rather than
|
||||
/// a `CoreClient` because the crate uses type-state generics that make storing
|
||||
/// the configured client in a HashMap impractical. The client is reconstructed
|
||||
/// on-the-fly from metadata when needed.
|
||||
struct ProviderState {
|
||||
metadata: CoreProviderMetadata,
|
||||
}
|
||||
|
||||
// ---- Core OIDC system ----
|
||||
|
||||
/// Global OIDC manager for all configured providers.
|
||||
pub struct OidcSys {
|
||||
configs: HashMap<String, OidcProviderConfig>,
|
||||
provider_states: HashMap<String, ProviderState>,
|
||||
state_store: OidcStateStore,
|
||||
http_client: ReqwestHttpClient,
|
||||
}
|
||||
|
||||
impl OidcSys {
|
||||
/// Parse environment variables and discover all configured OIDC providers.
|
||||
pub async fn new() -> Result<Self, String> {
|
||||
let http_client = ReqwestHttpClient(reqwest::Client::new());
|
||||
let parsed_configs = Self::parse_env_configs();
|
||||
let mut configs = HashMap::new();
|
||||
let mut provider_states = HashMap::new();
|
||||
|
||||
for config in parsed_configs {
|
||||
if !config.enabled {
|
||||
info!("OIDC provider '{}' is disabled, skipping", config.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
match Self::discover_provider(&config, &http_client).await {
|
||||
Ok(state) => {
|
||||
info!("OIDC provider '{}' discovered successfully", config.id);
|
||||
provider_states.insert(config.id.clone(), state);
|
||||
configs.insert(config.id.clone(), config);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to discover OIDC provider '{}': {}", config.id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
configs,
|
||||
provider_states,
|
||||
state_store: OidcStateStore::new(),
|
||||
http_client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an OidcSys with no providers (useful for when OIDC is not configured).
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
configs: HashMap::new(),
|
||||
provider_states: HashMap::new(),
|
||||
state_store: OidcStateStore::new(),
|
||||
http_client: ReqwestHttpClient(reqwest::Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true if any OIDC providers are configured and enabled.
|
||||
pub fn has_providers(&self) -> bool {
|
||||
!self.configs.is_empty()
|
||||
}
|
||||
|
||||
/// Return provider summaries for the console UI.
|
||||
pub fn list_providers(&self) -> Vec<OidcProviderSummary> {
|
||||
self.configs
|
||||
.values()
|
||||
.map(|c| OidcProviderSummary {
|
||||
provider_id: c.id.clone(),
|
||||
display_name: c.display_name.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the PKCE authorization URL for a provider, store state in the state store.
|
||||
pub async fn authorize_url(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
redirect_uri: &str,
|
||||
redirect_after: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let config = self
|
||||
.configs
|
||||
.get(provider_id)
|
||||
.ok_or_else(|| format!("unknown OIDC provider: {provider_id}"))?;
|
||||
let state = self
|
||||
.provider_states
|
||||
.get(provider_id)
|
||||
.ok_or_else(|| format!("provider not discovered: {provider_id}"))?;
|
||||
|
||||
// Construct CoreClient on-the-fly (avoids type-state storage issues)
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
state.metadata.clone(),
|
||||
ClientId::new(config.client_id.clone()),
|
||||
config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())),
|
||||
)
|
||||
.set_auth_type(AuthType::RequestBody);
|
||||
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let redirect = RedirectUrl::new(redirect_uri.to_string()).map_err(|e| format!("invalid redirect URI: {e}"))?;
|
||||
|
||||
let mut auth_req =
|
||||
client.authorize_url(CoreAuthenticationFlow::AuthorizationCode, CsrfToken::new_random, Nonce::new_random);
|
||||
auth_req = auth_req.set_redirect_uri(Cow::Owned(redirect));
|
||||
|
||||
for scope in &config.scopes {
|
||||
auth_req = auth_req.add_scope(Scope::new(scope.clone()));
|
||||
}
|
||||
|
||||
auth_req = auth_req.set_pkce_challenge(pkce_challenge);
|
||||
|
||||
let (auth_url, csrf_token, nonce) = auth_req.url();
|
||||
|
||||
// Store the state for callback validation
|
||||
self.state_store
|
||||
.insert(
|
||||
csrf_token.secret().clone(),
|
||||
OidcAuthSession {
|
||||
provider_id: provider_id.to_string(),
|
||||
pkce_verifier: pkce_verifier.secret().clone(),
|
||||
nonce: nonce.secret().clone(),
|
||||
redirect_after,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(auth_url.to_string())
|
||||
}
|
||||
|
||||
/// Exchange an authorization code for tokens and extract claims.
|
||||
pub async fn exchange_code(
|
||||
&self,
|
||||
state: &str,
|
||||
code: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<(OidcClaims, String, OidcAuthSession), String> {
|
||||
// Retrieve and consume the state (single-use)
|
||||
let session = self
|
||||
.state_store
|
||||
.take(state)
|
||||
.await
|
||||
.ok_or_else(|| "invalid or expired OIDC state".to_string())?;
|
||||
|
||||
let config = self
|
||||
.configs
|
||||
.get(&session.provider_id)
|
||||
.ok_or_else(|| format!("unknown provider: {}", session.provider_id))?;
|
||||
let provider_state = self
|
||||
.provider_states
|
||||
.get(&session.provider_id)
|
||||
.ok_or_else(|| format!("provider not discovered: {}", session.provider_id))?;
|
||||
|
||||
// Construct CoreClient on-the-fly with JWKS from discovery
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
provider_state.metadata.clone(),
|
||||
ClientId::new(config.client_id.clone()),
|
||||
config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())),
|
||||
)
|
||||
.set_auth_type(AuthType::RequestBody);
|
||||
|
||||
let redirect = RedirectUrl::new(redirect_uri.to_string()).map_err(|e| format!("invalid redirect URI: {e}"))?;
|
||||
|
||||
// Exchange code for tokens
|
||||
let token_response = client
|
||||
.exchange_code(AuthorizationCode::new(code.to_string()))
|
||||
.map_err(|e| format!("token endpoint not configured: {e}"))?
|
||||
.set_pkce_verifier(PkceCodeVerifier::new(session.pkce_verifier.clone()))
|
||||
.set_redirect_uri(Cow::Owned(redirect))
|
||||
.request_async(&self.http_client)
|
||||
.await
|
||||
.map_err(|e| format!("token exchange failed: {e}"))?;
|
||||
|
||||
// Verify the ID token (signature, issuer, audience, expiry, nonce)
|
||||
let id_token = token_response
|
||||
.extra_fields()
|
||||
.id_token()
|
||||
.ok_or_else(|| "no id_token in token response".to_string())?;
|
||||
|
||||
let verifier = client.id_token_verifier();
|
||||
let _verified_claims = id_token
|
||||
.claims(&verifier, &Nonce::new(session.nonce.clone()))
|
||||
.map_err(|e| format!("ID token verification failed: {e}"))?;
|
||||
|
||||
// Extract raw claims from the verified JWT for custom claim support
|
||||
// (the crate verifies signature/expiry/nonce; we decode payload for non-standard claims)
|
||||
let raw_jwt = serde_json::to_value(id_token)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default();
|
||||
let raw = decode_jwt_payload(&raw_jwt);
|
||||
|
||||
let claims = OidcClaims {
|
||||
sub: extract_string_claim(&raw, "sub"),
|
||||
email: extract_string_claim(&raw, &config.email_claim),
|
||||
username: extract_string_claim(&raw, &config.username_claim),
|
||||
groups: extract_groups_claim(&raw, &config.groups_claim),
|
||||
raw,
|
||||
};
|
||||
|
||||
Ok((claims, session.provider_id.clone(), session))
|
||||
}
|
||||
|
||||
/// Map OIDC claims to rustfs policy names.
|
||||
pub fn map_claims_to_policies(&self, provider_id: &str, claims: &OidcClaims) -> (Vec<String>, Vec<String>) {
|
||||
let config = match self.configs.get(provider_id) {
|
||||
Some(c) => c,
|
||||
None => return (vec![], vec![]),
|
||||
};
|
||||
|
||||
let mut policies = Vec::new();
|
||||
let mut groups = Vec::new();
|
||||
|
||||
// Add default role policy if configured
|
||||
if !config.role_policy.is_empty() {
|
||||
for policy in config.role_policy.split(',') {
|
||||
let policy = policy.trim();
|
||||
if !policy.is_empty() {
|
||||
policies.push(policy.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map groups claim to policies
|
||||
for group in &claims.groups {
|
||||
groups.push(group.clone());
|
||||
let policy_name = if config.claim_prefix.is_empty() {
|
||||
group.clone()
|
||||
} else {
|
||||
format!("{}{}", config.claim_prefix, group)
|
||||
};
|
||||
policies.push(policy_name);
|
||||
}
|
||||
|
||||
// Map primary claim (if different from groups)
|
||||
if config.claim_name != config.groups_claim {
|
||||
let claim_values = extract_groups_claim(&claims.raw, &config.claim_name);
|
||||
for val in claim_values {
|
||||
let policy_name = if config.claim_prefix.is_empty() {
|
||||
val
|
||||
} else {
|
||||
format!("{}{}", config.claim_prefix, val)
|
||||
};
|
||||
policies.push(policy_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
policies.sort();
|
||||
policies.dedup();
|
||||
groups.sort();
|
||||
groups.dedup();
|
||||
|
||||
(policies, groups)
|
||||
}
|
||||
|
||||
/// Verify a raw JWT (id_token) for the AssumeRoleWithWebIdentity flow.
|
||||
///
|
||||
/// Unlike the authorization code flow, ARWWI receives a raw JWT directly
|
||||
/// (not via code exchange). This method:
|
||||
/// 1. Decodes the JWT payload to extract the `iss` claim
|
||||
/// 2. Finds the OIDC provider whose issuer matches
|
||||
/// 3. Verifies signature, issuer, audience, and expiry (nonce is skipped)
|
||||
/// 4. Extracts claims using the provider's claim configuration
|
||||
pub async fn verify_web_identity_token(&self, jwt: &str) -> Result<(OidcClaims, String /* provider_id */), String> {
|
||||
// Decode JWT payload without verification to get the issuer claim
|
||||
let raw_claims = decode_jwt_payload(jwt);
|
||||
let issuer = raw_claims
|
||||
.get("iss")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "JWT missing 'iss' claim".to_string())?;
|
||||
|
||||
// Find matching provider by issuer
|
||||
let (provider_id, config, state) = self
|
||||
.find_provider_by_issuer(issuer)
|
||||
.ok_or_else(|| format!("no OIDC provider configured for issuer: {issuer}"))?;
|
||||
|
||||
// Reconstruct CoreClient from provider metadata
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
state.metadata.clone(),
|
||||
ClientId::new(config.client_id.clone()),
|
||||
config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())),
|
||||
)
|
||||
.set_auth_type(AuthType::RequestBody);
|
||||
|
||||
// Parse raw JWT string into CoreIdToken
|
||||
let id_token: CoreIdToken = jwt
|
||||
.parse()
|
||||
.map_err(|e: serde_json::Error| format!("failed to parse JWT as ID token: {e}"))?;
|
||||
|
||||
// Verify the token (signature, issuer, audience, expiry) — skip nonce
|
||||
// (nonce is only required for the authorization code flow)
|
||||
let verifier = client.id_token_verifier();
|
||||
let _verified = id_token
|
||||
.claims(&verifier, |_: Option<&Nonce>| Ok(()))
|
||||
.map_err(|e| format!("ID token verification failed: {e}"))?;
|
||||
|
||||
// Extract claims using the provider's claim configuration
|
||||
let claims = OidcClaims {
|
||||
sub: extract_string_claim(&raw_claims, "sub"),
|
||||
email: extract_string_claim(&raw_claims, &config.email_claim),
|
||||
username: extract_string_claim(&raw_claims, &config.username_claim),
|
||||
groups: extract_groups_claim(&raw_claims, &config.groups_claim),
|
||||
raw: raw_claims,
|
||||
};
|
||||
|
||||
Ok((claims, provider_id.to_string()))
|
||||
}
|
||||
|
||||
/// Find a provider whose discovered issuer matches the given JWT issuer string.
|
||||
fn find_provider_by_issuer(&self, issuer: &str) -> Option<(&str, &OidcProviderConfig, &ProviderState)> {
|
||||
let issuer_normalized = issuer.trim_end_matches('/');
|
||||
for (id, state) in &self.provider_states {
|
||||
let provider_issuer = state.metadata.issuer().as_str();
|
||||
let provider_normalized = provider_issuer.trim_end_matches('/');
|
||||
if issuer_normalized == provider_normalized
|
||||
&& let Some(config) = self.configs.get(id)
|
||||
{
|
||||
return Some((id, config, state));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the state store (used by HTTP handlers).
|
||||
pub fn state_store(&self) -> &OidcStateStore {
|
||||
&self.state_store
|
||||
}
|
||||
|
||||
/// Get a provider config by ID.
|
||||
pub fn get_provider_config(&self, id: &str) -> Option<&OidcProviderConfig> {
|
||||
self.configs.get(id)
|
||||
}
|
||||
|
||||
/// Parse all OIDC provider configs from environment variables.
|
||||
fn parse_env_configs() -> Vec<OidcProviderConfig> {
|
||||
let mut configs = Vec::new();
|
||||
|
||||
// Check for the default provider (no suffix)
|
||||
if let Some(config) = Self::parse_single_provider("", "default") {
|
||||
configs.push(config);
|
||||
}
|
||||
|
||||
// Scan for suffixed providers by checking all OIDC env var prefixes.
|
||||
// This allows providers to be discovered without requiring a separate ENABLE_ key.
|
||||
let mut provider_ids: Vec<String> = Vec::new();
|
||||
let scan_prefixes: Vec<String> = ENV_IDENTITY_OPENID_KEYS.iter().map(|k| format!("{k}_")).collect();
|
||||
for (key, _) in std::env::vars() {
|
||||
for prefix in &scan_prefixes {
|
||||
if let Some(suffix) = key.strip_prefix(prefix.as_str())
|
||||
&& !suffix.is_empty()
|
||||
&& suffix != "default"
|
||||
{
|
||||
provider_ids.push(suffix.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider_ids.sort();
|
||||
provider_ids.dedup();
|
||||
|
||||
for id in provider_ids {
|
||||
let suffix = format!("_{id}");
|
||||
if let Some(config) = Self::parse_single_provider(&suffix, &id) {
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
|
||||
configs
|
||||
}
|
||||
|
||||
/// Parse a single provider's config from env vars with the given suffix.
|
||||
fn parse_single_provider(env_suffix: &str, id: &str) -> Option<OidcProviderConfig> {
|
||||
let get_env = |base: &str| -> String { std::env::var(format!("{base}{env_suffix}")).unwrap_or_default() };
|
||||
|
||||
let enable_val = get_env(ENV_IDENTITY_OPENID_ENABLE);
|
||||
let config_url = get_env(ENV_IDENTITY_OPENID_CONFIG_URL);
|
||||
|
||||
// Skip if no config URL
|
||||
if config_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let enabled = enable_val.is_empty()
|
||||
|| enable_val
|
||||
.parse::<rustfs_config::EnableState>()
|
||||
.map(|s| s.is_enabled())
|
||||
.unwrap_or(false);
|
||||
|
||||
let scopes_str = get_env(ENV_IDENTITY_OPENID_SCOPES);
|
||||
let scopes = if scopes_str.is_empty() {
|
||||
OIDC_DEFAULT_SCOPES.split(',').map(String::from).collect()
|
||||
} else {
|
||||
scopes_str.split(',').map(|s| s.trim().to_string()).collect()
|
||||
};
|
||||
|
||||
let redirect_uri_dynamic_str = get_env(ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC);
|
||||
let redirect_uri_dynamic = redirect_uri_dynamic_str.is_empty()
|
||||
|| redirect_uri_dynamic_str
|
||||
.parse::<rustfs_config::EnableState>()
|
||||
.map(|s| s.is_enabled())
|
||||
.unwrap_or(true);
|
||||
|
||||
let claim_name = {
|
||||
let v = get_env(ENV_IDENTITY_OPENID_CLAIM_NAME);
|
||||
if v.is_empty() {
|
||||
OIDC_DEFAULT_CLAIM_NAME.to_string()
|
||||
} else {
|
||||
v
|
||||
}
|
||||
};
|
||||
let groups_claim = {
|
||||
let v = get_env(ENV_IDENTITY_OPENID_GROUPS_CLAIM);
|
||||
if v.is_empty() {
|
||||
OIDC_DEFAULT_GROUPS_CLAIM.to_string()
|
||||
} else {
|
||||
v
|
||||
}
|
||||
};
|
||||
let email_claim = {
|
||||
let v = get_env(ENV_IDENTITY_OPENID_EMAIL_CLAIM);
|
||||
if v.is_empty() {
|
||||
OIDC_DEFAULT_EMAIL_CLAIM.to_string()
|
||||
} else {
|
||||
v
|
||||
}
|
||||
};
|
||||
let username_claim = {
|
||||
let v = get_env(ENV_IDENTITY_OPENID_USERNAME_CLAIM);
|
||||
if v.is_empty() {
|
||||
OIDC_DEFAULT_USERNAME_CLAIM.to_string()
|
||||
} else {
|
||||
v
|
||||
}
|
||||
};
|
||||
let display_name = {
|
||||
let v = get_env(ENV_IDENTITY_OPENID_DISPLAY_NAME);
|
||||
if v.is_empty() { id.to_string() } else { v }
|
||||
};
|
||||
let redirect_uri = {
|
||||
let v = get_env(ENV_IDENTITY_OPENID_REDIRECT_URI);
|
||||
if v.is_empty() { None } else { Some(v) }
|
||||
};
|
||||
let client_secret = {
|
||||
let v = get_env(ENV_IDENTITY_OPENID_CLIENT_SECRET);
|
||||
if v.is_empty() { None } else { Some(v) }
|
||||
};
|
||||
|
||||
Some(OidcProviderConfig {
|
||||
id: id.to_string(),
|
||||
enabled,
|
||||
config_url,
|
||||
client_id: get_env(ENV_IDENTITY_OPENID_CLIENT_ID),
|
||||
client_secret,
|
||||
scopes,
|
||||
redirect_uri,
|
||||
redirect_uri_dynamic,
|
||||
claim_name,
|
||||
claim_prefix: get_env(ENV_IDENTITY_OPENID_CLAIM_PREFIX),
|
||||
role_policy: get_env(ENV_IDENTITY_OPENID_ROLE_POLICY),
|
||||
display_name,
|
||||
groups_claim,
|
||||
email_claim,
|
||||
username_claim,
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform OIDC discovery for a provider.
|
||||
/// `discover_async` fetches the discovery document and JWKS in one step.
|
||||
async fn discover_provider(config: &OidcProviderConfig, http_client: &ReqwestHttpClient) -> Result<ProviderState, String> {
|
||||
// The openidconnect crate expects the issuer URL (base), not the
|
||||
// .well-known/openid-configuration URL. Strip the suffix if present.
|
||||
let issuer_str = config
|
||||
.config_url
|
||||
.strip_suffix("/.well-known/openid-configuration")
|
||||
.unwrap_or(&config.config_url);
|
||||
|
||||
// Ensure trailing slash for correct URL joining in the crate
|
||||
let issuer_str = if issuer_str.ends_with('/') {
|
||||
issuer_str.to_string()
|
||||
} else {
|
||||
format!("{issuer_str}/")
|
||||
};
|
||||
|
||||
let issuer_url = IssuerUrl::new(issuer_str).map_err(|e| format!("invalid issuer URL: {e}"))?;
|
||||
|
||||
let metadata = CoreProviderMetadata::discover_async(issuer_url, http_client)
|
||||
.await
|
||||
.map_err(|e| format!("discovery failed: {e}"))?;
|
||||
|
||||
Ok(ProviderState { metadata })
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
/// Decode the payload section of a JWT without validation (token must already be verified).
|
||||
pub(crate) fn decode_jwt_payload(token: &str) -> HashMap<String, serde_json::Value> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() < 2 {
|
||||
return HashMap::new();
|
||||
}
|
||||
let payload_bytes = base64_simd::URL_SAFE_NO_PAD.decode_to_vec(parts[1]);
|
||||
match payload_bytes {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a string claim from raw claims.
|
||||
fn extract_string_claim(claims: &HashMap<String, serde_json::Value>, key: &str) -> String {
|
||||
claims.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// Extract a groups/array claim from raw claims. Handles both string arrays and single strings.
|
||||
fn extract_groups_claim(claims: &HashMap<String, serde_json::Value>, key: &str) -> Vec<String> {
|
||||
match claims.get(key) {
|
||||
Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(),
|
||||
Some(serde_json::Value::String(s)) => s.split(',').map(|s| s.trim().to_string()).collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_string_claim() {
|
||||
let mut claims = HashMap::new();
|
||||
claims.insert("email".to_string(), serde_json::json!("user@example.com"));
|
||||
claims.insert("sub".to_string(), serde_json::json!("12345"));
|
||||
|
||||
assert_eq!(extract_string_claim(&claims, "email"), "user@example.com");
|
||||
assert_eq!(extract_string_claim(&claims, "sub"), "12345");
|
||||
assert_eq!(extract_string_claim(&claims, "missing"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_groups_claim_array() {
|
||||
let mut claims = HashMap::new();
|
||||
claims.insert("groups".to_string(), serde_json::json!(["admin", "developers", "readonly"]));
|
||||
|
||||
let groups = extract_groups_claim(&claims, "groups");
|
||||
assert_eq!(groups, vec!["admin", "developers", "readonly"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_groups_claim_string() {
|
||||
let mut claims = HashMap::new();
|
||||
claims.insert("groups".to_string(), serde_json::json!("admin,developers"));
|
||||
|
||||
let groups = extract_groups_claim(&claims, "groups");
|
||||
assert_eq!(groups, vec!["admin", "developers"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_groups_claim_missing() {
|
||||
let claims = HashMap::new();
|
||||
let groups = extract_groups_claim(&claims, "groups");
|
||||
assert!(groups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_groups_claim_number() {
|
||||
let mut claims = HashMap::new();
|
||||
claims.insert("groups".to_string(), serde_json::json!(42));
|
||||
let groups = extract_groups_claim(&claims, "groups");
|
||||
assert!(groups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_jwt_payload() {
|
||||
let payload = r#"{"sub":"user123","email":"user@example.com"}"#;
|
||||
let payload_b64 = base64_simd::URL_SAFE_NO_PAD.encode_to_string(payload.as_bytes());
|
||||
let token = format!("eyJhbGciOiJSUzI1NiJ9.{payload_b64}.signature");
|
||||
|
||||
let claims = decode_jwt_payload(&token);
|
||||
assert_eq!(claims.get("sub").and_then(|v| v.as_str()), Some("user123"));
|
||||
assert_eq!(claims.get("email").and_then(|v| v.as_str()), Some("user@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_jwt_payload_invalid() {
|
||||
assert!(decode_jwt_payload("not-a-jwt").is_empty());
|
||||
assert!(decode_jwt_payload("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_claims_to_policies_no_provider() {
|
||||
let sys = OidcSys::empty();
|
||||
|
||||
let claims = OidcClaims {
|
||||
sub: "user123".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
groups: vec!["admin".to_string(), "devs".to_string()],
|
||||
raw: HashMap::new(),
|
||||
};
|
||||
|
||||
let (policies, groups) = sys.map_claims_to_policies("nonexistent", &claims);
|
||||
assert!(policies.is_empty());
|
||||
assert!(groups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_claims_default() {
|
||||
let claims = OidcClaims::default();
|
||||
assert!(claims.sub.is_empty());
|
||||
assert!(claims.email.is_empty());
|
||||
assert!(claims.username.is_empty());
|
||||
assert!(claims.groups.is_empty());
|
||||
assert!(claims.raw.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_claims_serde_roundtrip() {
|
||||
let claims = OidcClaims {
|
||||
sub: "user123".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
username: "testuser".to_string(),
|
||||
groups: vec!["admin".to_string(), "devs".to_string()],
|
||||
raw: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("custom".to_string(), serde_json::json!("value"));
|
||||
m
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&claims).unwrap();
|
||||
let deserialized: OidcClaims = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.sub, "user123");
|
||||
assert_eq!(deserialized.email, "user@example.com");
|
||||
assert_eq!(deserialized.groups.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_provider_summary_serde() {
|
||||
let summary = OidcProviderSummary {
|
||||
provider_id: "okta".to_string(),
|
||||
display_name: "Okta SSO".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&summary).unwrap();
|
||||
let deserialized: OidcProviderSummary = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.provider_id, "okta");
|
||||
assert_eq!(deserialized.display_name, "Okta SSO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_provider_no_config_url() {
|
||||
let config = OidcSys::parse_single_provider("_TEST_EMPTY", "test_empty");
|
||||
assert!(config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_sys_empty() {
|
||||
let sys = OidcSys::empty();
|
||||
assert!(!sys.has_providers());
|
||||
assert!(sys.list_providers().is_empty());
|
||||
}
|
||||
|
||||
/// Helper to create an OidcSys with configs only (no provider states needed).
|
||||
fn make_test_sys(configs: Vec<OidcProviderConfig>) -> OidcSys {
|
||||
let mut config_map = HashMap::new();
|
||||
for c in configs {
|
||||
config_map.insert(c.id.clone(), c);
|
||||
}
|
||||
OidcSys {
|
||||
configs: config_map,
|
||||
provider_states: HashMap::new(),
|
||||
state_store: OidcStateStore::new(),
|
||||
http_client: ReqwestHttpClient(reqwest::Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_config(id: &str) -> OidcProviderConfig {
|
||||
OidcProviderConfig {
|
||||
id: id.to_string(),
|
||||
enabled: true,
|
||||
config_url: format!("https://example.com/{id}/.well-known/openid-configuration"),
|
||||
client_id: "client-id".to_string(),
|
||||
client_secret: None,
|
||||
scopes: vec!["openid".to_string()],
|
||||
redirect_uri: None,
|
||||
redirect_uri_dynamic: true,
|
||||
claim_name: "groups".to_string(),
|
||||
claim_prefix: "".to_string(),
|
||||
role_policy: "".to_string(),
|
||||
display_name: id.to_string(),
|
||||
groups_claim: "groups".to_string(),
|
||||
email_claim: "email".to_string(),
|
||||
username_claim: "preferred_username".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_claims_to_policies_with_provider() {
|
||||
let mut config = test_config("okta");
|
||||
config.role_policy = "readwrite".to_string();
|
||||
config.display_name = "Okta".to_string();
|
||||
|
||||
let sys = make_test_sys(vec![config]);
|
||||
|
||||
let claims = OidcClaims {
|
||||
sub: "user123".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
groups: vec!["admin".to_string(), "devs".to_string()],
|
||||
raw: HashMap::new(),
|
||||
};
|
||||
|
||||
let (policies, groups) = sys.map_claims_to_policies("okta", &claims);
|
||||
assert_eq!(groups, vec!["admin", "devs"]);
|
||||
assert!(policies.contains(&"readwrite".to_string()));
|
||||
assert!(policies.contains(&"admin".to_string()));
|
||||
assert!(policies.contains(&"devs".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_claims_to_policies_with_prefix() {
|
||||
let mut config = test_config("azure");
|
||||
config.claim_prefix = "oidc-".to_string();
|
||||
config.display_name = "Azure AD".to_string();
|
||||
|
||||
let sys = make_test_sys(vec![config]);
|
||||
|
||||
let claims = OidcClaims {
|
||||
sub: "user456".to_string(),
|
||||
email: "user@corp.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
groups: vec!["engineers".to_string()],
|
||||
raw: HashMap::new(),
|
||||
};
|
||||
|
||||
let (policies, groups) = sys.map_claims_to_policies("azure", &claims);
|
||||
assert_eq!(groups, vec!["engineers"]);
|
||||
assert!(policies.contains(&"oidc-engineers".to_string()));
|
||||
assert_eq!(policies.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_providers() {
|
||||
let mut config = test_config("keycloak");
|
||||
config.display_name = "Keycloak SSO".to_string();
|
||||
|
||||
let sys = make_test_sys(vec![config]);
|
||||
|
||||
assert!(sys.has_providers());
|
||||
let summaries = sys.list_providers();
|
||||
assert_eq!(summaries.len(), 1);
|
||||
assert_eq!(summaries[0].provider_id, "keycloak");
|
||||
assert_eq!(summaries[0].display_name, "Keycloak SSO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_provider_config() {
|
||||
let mut config = test_config("test");
|
||||
config.client_id = "my-client".to_string();
|
||||
config.client_secret = Some("secret".to_string());
|
||||
|
||||
let sys = make_test_sys(vec![config]);
|
||||
|
||||
assert!(sys.get_provider_config("test").is_some());
|
||||
assert_eq!(sys.get_provider_config("test").unwrap().client_id, "my-client");
|
||||
assert!(sys.get_provider_config("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oidc_provider_config_defaults() {
|
||||
let config = OidcProviderConfig {
|
||||
id: "test".to_string(),
|
||||
enabled: true,
|
||||
config_url: "https://example.com/.well-known/openid-configuration".to_string(),
|
||||
client_id: "my-client".to_string(),
|
||||
client_secret: Some("secret".to_string()),
|
||||
scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()],
|
||||
redirect_uri: None,
|
||||
redirect_uri_dynamic: true,
|
||||
claim_name: "groups".to_string(),
|
||||
claim_prefix: "".to_string(),
|
||||
role_policy: "readwrite".to_string(),
|
||||
display_name: "Test Provider".to_string(),
|
||||
groups_claim: "groups".to_string(),
|
||||
email_claim: "email".to_string(),
|
||||
username_claim: "preferred_username".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(config.id, "test");
|
||||
assert!(config.enabled);
|
||||
assert_eq!(config.scopes.len(), 3);
|
||||
assert!(config.redirect_uri_dynamic);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use moka::future::Cache;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Stores the PKCE verifier and nonce for an in-flight OIDC authorization flow.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OidcAuthSession {
|
||||
pub provider_id: String,
|
||||
pub pkce_verifier: String,
|
||||
pub nonce: String,
|
||||
pub redirect_after: Option<String>,
|
||||
}
|
||||
|
||||
/// TTL cache for OIDC auth state (PKCE verifiers + nonces) during the authorization flow.
|
||||
/// Entries expire after 5 minutes and are single-use (removed on retrieval).
|
||||
#[derive(Clone)]
|
||||
pub struct OidcStateStore {
|
||||
cache: Cache<String, OidcAuthSession>,
|
||||
}
|
||||
|
||||
impl OidcStateStore {
|
||||
pub fn new() -> Self {
|
||||
let cache = Cache::builder()
|
||||
.max_capacity(10_000)
|
||||
.time_to_live(Duration::from_secs(300)) // 5 minute TTL
|
||||
.build();
|
||||
Self { cache }
|
||||
}
|
||||
|
||||
/// Store a new auth session keyed by the OAuth2 `state` parameter.
|
||||
pub async fn insert(&self, state: String, session: OidcAuthSession) {
|
||||
self.cache.insert(state, session).await;
|
||||
}
|
||||
|
||||
/// Retrieve and remove an auth session (single-use). Returns None if expired or not found.
|
||||
/// Uses `remove` which returns the value and removes it in a single operation.
|
||||
pub async fn take(&self, state: &str) -> Option<OidcAuthSession> {
|
||||
self.cache.remove(state).await
|
||||
}
|
||||
|
||||
/// Check if a state key exists (without consuming it).
|
||||
pub async fn contains(&self, state: &str) -> bool {
|
||||
self.cache.get(state).await.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OidcStateStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_store_insert_and_take() {
|
||||
let store = OidcStateStore::new();
|
||||
let session = OidcAuthSession {
|
||||
provider_id: "okta".to_string(),
|
||||
pkce_verifier: "verifier123".to_string(),
|
||||
nonce: "nonce456".to_string(),
|
||||
redirect_after: Some("/dashboard".to_string()),
|
||||
};
|
||||
|
||||
store.insert("state_abc".to_string(), session.clone()).await;
|
||||
assert!(store.contains("state_abc").await);
|
||||
|
||||
let retrieved = store.take("state_abc").await;
|
||||
assert!(retrieved.is_some());
|
||||
let retrieved = retrieved.unwrap();
|
||||
assert_eq!(retrieved.provider_id, "okta");
|
||||
assert_eq!(retrieved.pkce_verifier, "verifier123");
|
||||
assert_eq!(retrieved.nonce, "nonce456");
|
||||
assert_eq!(retrieved.redirect_after, Some("/dashboard".to_string()));
|
||||
|
||||
// Single-use: second take should return None
|
||||
assert!(store.take("state_abc").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_store_missing_key() {
|
||||
let store = OidcStateStore::new();
|
||||
assert!(store.take("nonexistent").await.is_none());
|
||||
assert!(!store.contains("nonexistent").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_store_multiple_entries() {
|
||||
let store = OidcStateStore::new();
|
||||
|
||||
for i in 0..5 {
|
||||
let session = OidcAuthSession {
|
||||
provider_id: format!("provider_{i}"),
|
||||
pkce_verifier: format!("verifier_{i}"),
|
||||
nonce: format!("nonce_{i}"),
|
||||
redirect_after: None,
|
||||
};
|
||||
store.insert(format!("state_{i}"), session).await;
|
||||
}
|
||||
|
||||
for i in 0..5 {
|
||||
let retrieved = store.take(&format!("state_{i}")).await;
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().provider_id, format!("provider_{i}"));
|
||||
}
|
||||
|
||||
// All consumed
|
||||
for i in 0..5 {
|
||||
assert!(store.take(&format!("state_{i}")).await.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-1
@@ -767,11 +767,33 @@ impl<T: Store> IamSys<T> {
|
||||
}
|
||||
},
|
||||
};
|
||||
let Ok(p) = self.policy_db_get(parent_user, &effective_groups).await else { return false };
|
||||
let p = self.policy_db_get(parent_user, &effective_groups).await.unwrap_or_default();
|
||||
(effective_groups, groups_source, p)
|
||||
};
|
||||
|
||||
if !is_owner && policies.is_empty() {
|
||||
// For OIDC/STS users, policies may be specified in JWT claims rather than IAM DB.
|
||||
// Resolve claim-based policy names against built-in default policies.
|
||||
if let Some(claim_policies) = args.claims.get("policy").and_then(|v| v.as_str()) {
|
||||
use rustfs_policy::policy::default::DEFAULT_POLICIES;
|
||||
let mut resolved = Vec::new();
|
||||
for policy_name in claim_policies.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||
for (name, p) in DEFAULT_POLICIES.iter() {
|
||||
if *name == policy_name {
|
||||
resolved.push(p.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !resolved.is_empty() {
|
||||
let combined = Policy::merge_policies(resolved);
|
||||
let (has_session_policy, is_allowed_sp) = is_allowed_by_session_policy(args);
|
||||
if has_session_policy {
|
||||
return is_allowed_sp && combined.is_allowed(args).await;
|
||||
}
|
||||
return combined.is_allowed(args).await;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ pub enum UserAuthType {
|
||||
Builtin,
|
||||
#[serde(rename = "ldap")]
|
||||
Ldap,
|
||||
#[serde(rename = "oidc")]
|
||||
Oidc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -63,14 +63,32 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
if path.is_empty() {
|
||||
path = "index.html"
|
||||
}
|
||||
|
||||
// Try the exact path first
|
||||
if let Some(file) = StaticFiles::get(path) {
|
||||
let mime_type = from_path(path).first_or_octet_stream();
|
||||
Response::builder()
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type.to_string())
|
||||
.body(Body::from(file.data))
|
||||
.unwrap()
|
||||
} else if let Some(file) = StaticFiles::get("index.html") {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// For directory paths (trailing slash), try <path>index.html
|
||||
if path.ends_with('/') {
|
||||
let index_path = format!("{path}index.html");
|
||||
if let Some(file) = StaticFiles::get(&index_path) {
|
||||
let mime_type = from_path(&index_path).first_or_octet_stream();
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type.to_string())
|
||||
.body(Body::from(file.data))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// SPA fallback: serve root index.html for client-side routing
|
||||
if let Some(file) = StaticFiles::get("index.html") {
|
||||
let mime_type = from_path("index.html").first_or_octet_stream();
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
@@ -94,11 +112,33 @@ pub(crate) struct Config {
|
||||
release: Release,
|
||||
license: License,
|
||||
doc: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
oidc: Vec<OidcProviderInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
struct OidcProviderInfo {
|
||||
provider_id: String,
|
||||
display_name: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn new(local_ip: IpAddr, port: u16, version: &str, date: &str) -> Self {
|
||||
let http_prefix = rustfs_config::RUSTFS_HTTP_PREFIX;
|
||||
|
||||
// Collect OIDC provider info if available
|
||||
let oidc = rustfs_iam::get_oidc()
|
||||
.map(|sys| {
|
||||
sys.list_providers()
|
||||
.into_iter()
|
||||
.map(|p| OidcProviderInfo {
|
||||
provider_id: p.provider_id,
|
||||
display_name: p.display_name,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Config {
|
||||
port,
|
||||
api: Api {
|
||||
@@ -117,6 +157,7 @@ impl Config {
|
||||
url: rustfs_config::RUSTFS_LICENSE_URL.to_string(),
|
||||
},
|
||||
doc: rustfs_config::RUSTFS_DOCS_URL.to_string(),
|
||||
oidc,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +186,20 @@ impl Operation for AccountInfoHandler {
|
||||
|
||||
let policies = MappedPolicy::new(&policy_name).to_slice();
|
||||
effective_policy = iam_store.get_combined_policy(&policies).await;
|
||||
} else if let Some(claim_policies) = claims.get("policy").and_then(|v| v.as_str()) {
|
||||
// STS/OIDC users: resolve policy names from JWT claims against built-in policies
|
||||
let mut resolved = Vec::new();
|
||||
for policy_name in claim_policies.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||
for (name, p) in DEFAULT_POLICIES.iter() {
|
||||
if *name == policy_name {
|
||||
resolved.push(p.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !resolved.is_empty() {
|
||||
effective_policy = rustfs_policy::policy::Policy::merge_policies(resolved);
|
||||
}
|
||||
} else {
|
||||
let policies = iam_store
|
||||
.policy_db_get(&account_name, &cred.groups)
|
||||
|
||||
@@ -13,14 +13,17 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::router::Operation;
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::auth::{check_key_valid, constant_time_eq, get_condition_values, get_session_token};
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use hyper::StatusCode;
|
||||
use matchit::Params;
|
||||
use rustfs_credentials::get_global_action_cred;
|
||||
use rustfs_policy::policy::Args;
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
use s3s::header::CONTENT_TYPE;
|
||||
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IsAdminResponse {
|
||||
@@ -43,14 +46,34 @@ impl Operation for IsAdminHandler {
|
||||
|
||||
let access_key_to_check = input_cred.access_key.clone();
|
||||
|
||||
// Check if the user is admin by comparing with global credentials
|
||||
// Check if the user is admin: root user check, then evaluate through the policy engine
|
||||
let is_admin = if let Some(sys_cred) = get_global_action_cred() {
|
||||
crate::auth::constant_time_eq(&access_key_to_check, &sys_cred.access_key)
|
||||
|| crate::auth::constant_time_eq(&cred.parent_user, &sys_cred.access_key)
|
||||
constant_time_eq(&access_key_to_check, &sys_cred.access_key)
|
||||
|| constant_time_eq(&cred.parent_user, &sys_cred.access_key)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let is_admin = if is_admin {
|
||||
true
|
||||
} else {
|
||||
let iam_store = rustfs_iam::get().map_err(|_| s3_error!(InternalError, "iam not init"))?;
|
||||
let conditions = get_condition_values(&req.headers, &cred, None, None, None);
|
||||
iam_store
|
||||
.is_allowed(&Args {
|
||||
account: &cred.access_key,
|
||||
groups: &cred.groups,
|
||||
action: Action::AdminAction(AdminAction::AllAdminActions),
|
||||
conditions: &conditions,
|
||||
is_owner: false,
|
||||
claims: cred.claims.as_ref().unwrap_or(&HashMap::new()),
|
||||
deny_only: false,
|
||||
bucket: "",
|
||||
object: "",
|
||||
})
|
||||
.await
|
||||
};
|
||||
|
||||
let response = IsAdminResponse {
|
||||
is_admin,
|
||||
access_key: access_key_to_check,
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod kms_dynamic;
|
||||
pub mod kms_keys;
|
||||
pub mod kms_management;
|
||||
pub mod metrics;
|
||||
pub mod oidc;
|
||||
pub mod policies;
|
||||
pub mod pools;
|
||||
pub mod profile;
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::sts::create_oidc_sts_credentials;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use http::StatusCode;
|
||||
use hyper::Method;
|
||||
use matchit::Params;
|
||||
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const OIDC_PATH_PREFIX: &str = "/rustfs/admin/v3/oidc";
|
||||
|
||||
/// Validate that a provider ID contains only safe characters (alphanumeric, underscore, hyphen).
|
||||
fn is_valid_provider_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// Validate that a redirect_after path is a safe relative path (starts with `/`, no scheme).
|
||||
fn is_safe_redirect_path(path: &str) -> bool {
|
||||
path.starts_with('/') && !path.starts_with("//") && !path.contains("://")
|
||||
}
|
||||
|
||||
/// Validate that a scheme is either "http" or "https".
|
||||
fn is_valid_scheme(scheme: &str) -> bool {
|
||||
scheme == "http" || scheme == "https"
|
||||
}
|
||||
|
||||
/// Register OIDC routes on the admin router.
|
||||
pub fn register_oidc_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
||||
r.insert(
|
||||
Method::GET,
|
||||
&format!("{ADMIN_PREFIX}/v3/oidc/providers"),
|
||||
AdminOperation(&ListOidcProvidersHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::GET,
|
||||
&format!("{ADMIN_PREFIX}/v3/oidc/authorize/{{provider_id}}"),
|
||||
AdminOperation(&OidcAuthorizeHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::GET,
|
||||
&format!("{ADMIN_PREFIX}/v3/oidc/callback/{{provider_id}}"),
|
||||
AdminOperation(&OidcCallbackHandler {}),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if the given path is an OIDC endpoint (requires unauthenticated access).
|
||||
pub fn is_oidc_path(path: &str) -> bool {
|
||||
path.starts_with(OIDC_PATH_PREFIX)
|
||||
}
|
||||
|
||||
/// Handler: GET /rustfs/admin/v3/oidc/providers
|
||||
/// Returns list of configured OIDC providers for the login page.
|
||||
pub struct ListOidcProvidersHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for ListOidcProvidersHandler {
|
||||
async fn call(&self, _req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let oidc_sys = rustfs_iam::get_oidc().ok_or_else(|| s3_error!(InternalError, "OIDC not initialized"))?;
|
||||
|
||||
let providers = oidc_sys.list_providers();
|
||||
let json_body = serde_json::to_vec(&providers)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize error: {e}")))?;
|
||||
|
||||
Ok(S3Response::new((StatusCode::OK, Body::from(json_body))))
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler: GET /rustfs/admin/v3/oidc/authorize/:provider_id
|
||||
/// Generates PKCE challenge, stores state, and returns 302 redirect to IdP.
|
||||
pub struct OidcAuthorizeHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for OidcAuthorizeHandler {
|
||||
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let provider_id = params
|
||||
.get("provider_id")
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "missing provider_id"))?;
|
||||
|
||||
if !is_valid_provider_id(provider_id) {
|
||||
return Err(s3_error!(InvalidRequest, "invalid provider_id"));
|
||||
}
|
||||
|
||||
let oidc_sys = rustfs_iam::get_oidc().ok_or_else(|| s3_error!(InternalError, "OIDC not initialized"))?;
|
||||
|
||||
// Derive the callback redirect URI from the request
|
||||
let redirect_uri = derive_callback_uri(&req, provider_id)?;
|
||||
|
||||
// Optional: redirect_after query parameter (must be a safe relative path)
|
||||
let redirect_after = extract_query_param(&req.uri, "redirect_after").filter(|p| is_safe_redirect_path(p));
|
||||
|
||||
let auth_url = oidc_sys
|
||||
.authorize_url(provider_id, &redirect_uri, redirect_after)
|
||||
.await
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("authorize failed: {e}")))?;
|
||||
|
||||
info!("OIDC authorize redirect for provider '{}' to IdP", provider_id);
|
||||
|
||||
// Return 302 redirect
|
||||
let mut resp = S3Response::new((StatusCode::FOUND, Body::empty()));
|
||||
resp.headers.insert(
|
||||
http::header::LOCATION,
|
||||
auth_url
|
||||
.parse()
|
||||
.map_err(|_| s3_error!(InternalError, "failed to construct authorization URL"))?,
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler: GET /rustfs/admin/v3/oidc/callback/:provider_id?code=...&state=...
|
||||
/// Exchanges authorization code for tokens, maps claims, issues STS credentials.
|
||||
pub struct OidcCallbackHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for OidcCallbackHandler {
|
||||
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let provider_id = params
|
||||
.get("provider_id")
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "missing provider_id"))?;
|
||||
|
||||
if !is_valid_provider_id(provider_id) {
|
||||
return Err(s3_error!(InvalidRequest, "invalid provider_id"));
|
||||
}
|
||||
|
||||
// Extract code and state from query parameters
|
||||
let code =
|
||||
extract_query_param(&req.uri, "code").ok_or_else(|| s3_error!(InvalidRequest, "missing 'code' query parameter"))?;
|
||||
let state =
|
||||
extract_query_param(&req.uri, "state").ok_or_else(|| s3_error!(InvalidRequest, "missing 'state' query parameter"))?;
|
||||
|
||||
// Check for error response from IdP
|
||||
if let Some(error) = extract_query_param(&req.uri, "error") {
|
||||
let desc = extract_query_param(&req.uri, "error_description").unwrap_or_default();
|
||||
warn!("OIDC callback received error from IdP: {} - {}", error, desc);
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::AccessDenied,
|
||||
format!("OIDC authentication failed: {error} - {desc}"),
|
||||
));
|
||||
}
|
||||
|
||||
let oidc_sys = rustfs_iam::get_oidc().ok_or_else(|| s3_error!(InternalError, "OIDC not initialized"))?;
|
||||
|
||||
let redirect_uri = derive_callback_uri(&req, provider_id)?;
|
||||
|
||||
// Exchange authorization code for tokens and extract claims
|
||||
let (claims, actual_provider_id, session) = oidc_sys.exchange_code(&state, &code, &redirect_uri).await.map_err(|e| {
|
||||
error!("OIDC code exchange failed: {}", e);
|
||||
S3Error::with_message(S3ErrorCode::AccessDenied, format!("code exchange failed: {e}"))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"OIDC login successful: username='{}', email='{}', sub='{}' (provider: {})",
|
||||
claims.username, claims.email, claims.sub, actual_provider_id
|
||||
);
|
||||
|
||||
// Map claims to policies and groups
|
||||
let (policies, groups) = oidc_sys.map_claims_to_policies(&actual_provider_id, &claims);
|
||||
|
||||
info!(
|
||||
"OIDC claim mapping: user='{}', policies={:?}, groups={:?}",
|
||||
claims.username, policies, groups
|
||||
);
|
||||
|
||||
// Generate STS credentials using the shared helper.
|
||||
// Console/OIDC sessions use a fixed 1-hour duration as a security/UX choice.
|
||||
// Longer-lived credentials (15 min to 12 hours) can be requested via CLI/SDK
|
||||
// through AssumeRoleWithWebIdentity.
|
||||
let new_cred = create_oidc_sts_credentials(&claims, &actual_provider_id, &policies, &groups, 3600, None).await?;
|
||||
|
||||
// Build redirect URL to console with credentials in the fragment
|
||||
let console_redirect = build_console_redirect(
|
||||
&req,
|
||||
&new_cred.access_key,
|
||||
&new_cred.secret_key,
|
||||
&new_cred.session_token,
|
||||
new_cred.expiration,
|
||||
session.redirect_after.as_deref(),
|
||||
);
|
||||
|
||||
let mut resp = S3Response::new((StatusCode::FOUND, Body::empty()));
|
||||
resp.headers.insert(
|
||||
http::header::LOCATION,
|
||||
console_redirect
|
||||
.parse()
|
||||
.map_err(|_| s3_error!(InternalError, "failed to construct console redirect URL"))?,
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the OIDC callback URI.
|
||||
/// Uses the provider's configured redirect_uri if set, otherwise derives dynamically
|
||||
/// from request headers. For production deployments behind a reverse proxy, configuring
|
||||
/// an explicit redirect_uri is recommended to prevent header manipulation.
|
||||
fn derive_callback_uri(req: &S3Request<Body>, provider_id: &str) -> S3Result<String> {
|
||||
// Use explicitly configured redirect_uri if available
|
||||
if let Some(oidc_sys) = rustfs_iam::get_oidc()
|
||||
&& let Some(config) = oidc_sys.get_provider_config(provider_id)
|
||||
&& let Some(ref uri) = config.redirect_uri
|
||||
{
|
||||
return Ok(uri.clone());
|
||||
}
|
||||
let scheme = req
|
||||
.headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_else(|| req.uri.scheme_str().unwrap_or("http"));
|
||||
|
||||
if !is_valid_scheme(scheme) {
|
||||
return Err(s3_error!(InvalidRequest, "invalid scheme in request"));
|
||||
}
|
||||
|
||||
let host = req
|
||||
.headers
|
||||
.get(http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.or_else(|| req.uri.host())
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "cannot determine host for redirect URI"))?;
|
||||
|
||||
// Validate host doesn't contain path separators or other injection characters
|
||||
if host.contains('/') || host.contains('\\') {
|
||||
return Err(s3_error!(InvalidRequest, "invalid host header"));
|
||||
}
|
||||
|
||||
Ok(format!("{scheme}://{host}/rustfs/admin/v3/oidc/callback/{provider_id}"))
|
||||
}
|
||||
|
||||
/// Extract a query parameter from the URI.
|
||||
fn extract_query_param(uri: &http::Uri, key: &str) -> Option<String> {
|
||||
uri.query().and_then(|q| {
|
||||
// Parse query string manually without external dependency
|
||||
q.split('&')
|
||||
.filter_map(|pair| {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
let k = parts.next()?;
|
||||
let v = parts.next().unwrap_or("");
|
||||
if k == key {
|
||||
Some(urlencoding::decode(v).unwrap_or_default().into_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next()
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the console redirect URL with STS credentials in the hash fragment.
|
||||
fn build_console_redirect(
|
||||
req: &S3Request<Body>,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
session_token: &str,
|
||||
expiration: Option<OffsetDateTime>,
|
||||
redirect_after: Option<&str>,
|
||||
) -> String {
|
||||
let scheme = req
|
||||
.headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|s| is_valid_scheme(s))
|
||||
.unwrap_or_else(|| req.uri.scheme_str().unwrap_or("http"));
|
||||
|
||||
let host = req
|
||||
.headers
|
||||
.get(http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|h| !h.contains('/') && !h.contains('\\'))
|
||||
.unwrap_or("localhost");
|
||||
|
||||
let console_prefix = "/rustfs/console";
|
||||
let page = redirect_after.filter(|p| is_safe_redirect_path(p)).unwrap_or("/");
|
||||
|
||||
let exp_str = expiration
|
||||
.map(|e| e.format(&time::format_description::well_known::Rfc3339).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let fragment = format!(
|
||||
"accessKey={}&secretKey={}&sessionToken={}&expiration={}&redirect={}",
|
||||
urlencoding::encode(access_key),
|
||||
urlencoding::encode(secret_key),
|
||||
urlencoding::encode(session_token),
|
||||
urlencoding::encode(&exp_str),
|
||||
urlencoding::encode(page),
|
||||
);
|
||||
|
||||
format!("{scheme}://{host}{console_prefix}/auth/oidc-callback/#{fragment}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_oidc_path() {
|
||||
assert!(is_oidc_path("/rustfs/admin/v3/oidc/providers"));
|
||||
assert!(is_oidc_path("/rustfs/admin/v3/oidc/authorize/okta"));
|
||||
assert!(is_oidc_path("/rustfs/admin/v3/oidc/callback/okta"));
|
||||
assert!(!is_oidc_path("/rustfs/admin/v3/users"));
|
||||
assert!(!is_oidc_path("/health"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_query_param() {
|
||||
let uri: http::Uri = "http://localhost/callback?code=abc123&state=xyz789".parse().unwrap();
|
||||
assert_eq!(extract_query_param(&uri, "code"), Some("abc123".to_string()));
|
||||
assert_eq!(extract_query_param(&uri, "state"), Some("xyz789".to_string()));
|
||||
assert_eq!(extract_query_param(&uri, "missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_query_param_empty() {
|
||||
let uri: http::Uri = "http://localhost/callback".parse().unwrap();
|
||||
assert_eq!(extract_query_param(&uri, "code"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_query_param_encoded() {
|
||||
let uri: http::Uri = "http://localhost/callback?redirect_after=%2Fdashboard".parse().unwrap();
|
||||
assert_eq!(extract_query_param(&uri, "redirect_after"), Some("/dashboard".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_provider_id() {
|
||||
assert!(is_valid_provider_id("AUTHENTIK"));
|
||||
assert!(is_valid_provider_id("my-provider"));
|
||||
assert!(is_valid_provider_id("okta_prod"));
|
||||
assert!(is_valid_provider_id("Azure123"));
|
||||
assert!(!is_valid_provider_id(""));
|
||||
assert!(!is_valid_provider_id("../evil"));
|
||||
assert!(!is_valid_provider_id("foo bar"));
|
||||
assert!(!is_valid_provider_id("foo/bar"));
|
||||
assert!(!is_valid_provider_id("provider;drop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_safe_redirect_path() {
|
||||
assert!(is_safe_redirect_path("/"));
|
||||
assert!(is_safe_redirect_path("/dashboard"));
|
||||
assert!(is_safe_redirect_path("/buckets/my-bucket"));
|
||||
assert!(!is_safe_redirect_path("https://evil.com"));
|
||||
assert!(!is_safe_redirect_path("javascript:alert(1)"));
|
||||
assert!(!is_safe_redirect_path("//evil.com/path"));
|
||||
assert!(!is_safe_redirect_path("relative/path"));
|
||||
assert!(!is_safe_redirect_path(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_scheme() {
|
||||
assert!(is_valid_scheme("http"));
|
||||
assert!(is_valid_scheme("https"));
|
||||
assert!(!is_valid_scheme("ftp"));
|
||||
assert!(!is_valid_scheme("javascript"));
|
||||
assert!(!is_valid_scheme(""));
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ use hyper::Method;
|
||||
use matchit::Params;
|
||||
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
|
||||
use rustfs_ecstore::bucket::utils::serialize;
|
||||
use rustfs_iam::{manager::get_token_signing_key, sys::SESSION_POLICY_NAME};
|
||||
use rustfs_iam::{manager::get_token_signing_key, oidc::OidcClaims, sys::SESSION_POLICY_NAME};
|
||||
use rustfs_policy::{auth::get_new_credentials_with_metadata, policy::Policy};
|
||||
use s3s::{
|
||||
Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result,
|
||||
@@ -35,9 +35,10 @@ use serde_json::Value;
|
||||
use serde_urlencoded::from_bytes;
|
||||
use std::collections::HashMap;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
const ASSUME_ROLE_ACTION: &str = "AssumeRole";
|
||||
const ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION: &str = "AssumeRoleWithWebIdentity";
|
||||
const ASSUME_ROLE_VERSION: &str = "2011-06-15";
|
||||
|
||||
pub fn register_admin_auth_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
||||
@@ -62,6 +63,7 @@ pub struct AssumeRoleRequest {
|
||||
pub role_session_name: String,
|
||||
pub policy: String,
|
||||
pub external_id: String,
|
||||
pub web_identity_token: String,
|
||||
}
|
||||
|
||||
pub struct AssumeRoleHandle {}
|
||||
@@ -70,21 +72,6 @@ impl Operation for AssumeRoleHandle {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
warn!("handle AssumeRoleHandle");
|
||||
|
||||
let Some(user) = req.credentials else { return Err(s3_error!(InvalidRequest, "get cred failed")) };
|
||||
|
||||
let session_token = get_session_token(&req.uri, &req.headers);
|
||||
if session_token.is_some() {
|
||||
return Err(s3_error!(InvalidRequest, "AccessDenied1"));
|
||||
}
|
||||
|
||||
let (cred, _owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &user.access_key).await?;
|
||||
|
||||
// TODO: Check permissions, do not allow STS access
|
||||
if cred.is_temp() || cred.is_service_account() {
|
||||
return Err(s3_error!(InvalidRequest, "AccessDenied"));
|
||||
}
|
||||
|
||||
let mut input = req.input;
|
||||
|
||||
let bytes = match input.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE).await {
|
||||
@@ -97,88 +84,282 @@ impl Operation for AssumeRoleHandle {
|
||||
|
||||
let body: AssumeRoleRequest = from_bytes(&bytes).map_err(|_e| s3_error!(InvalidRequest, "invalid STS request format"))?;
|
||||
|
||||
if body.action.as_str() != ASSUME_ROLE_ACTION {
|
||||
return Err(s3_error!(InvalidArgument, "not support action"));
|
||||
match body.action.as_str() {
|
||||
ASSUME_ROLE_ACTION => handle_assume_role(req.credentials, req.uri, req.headers, body).await,
|
||||
ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION => handle_assume_role_with_web_identity(body).await,
|
||||
_ => Err(s3_error!(InvalidArgument, "unsupported Action")),
|
||||
}
|
||||
|
||||
if body.version.as_str() != ASSUME_ROLE_VERSION {
|
||||
return Err(s3_error!(InvalidArgument, "not support version"));
|
||||
}
|
||||
|
||||
let mut claims = cred.claims.unwrap_or_default();
|
||||
|
||||
populate_session_policy(&mut claims, &body.policy)?;
|
||||
|
||||
let exp = {
|
||||
if body.duration_seconds > 0 {
|
||||
body.duration_seconds
|
||||
} else {
|
||||
3600
|
||||
}
|
||||
};
|
||||
|
||||
claims.insert(
|
||||
"exp".to_string(),
|
||||
Value::Number(serde_json::Number::from(OffsetDateTime::now_utc().unix_timestamp() + exp as i64)),
|
||||
);
|
||||
|
||||
claims.insert("parent".to_string(), Value::String(cred.access_key.clone()));
|
||||
|
||||
// warn!("AssumeRole get cred {:?}", &user);
|
||||
// warn!("AssumeRole get body {:?}", &body);
|
||||
|
||||
let Ok(iam_store) = rustfs_iam::get() else {
|
||||
return Err(s3_error!(InvalidRequest, "iam not init"));
|
||||
};
|
||||
|
||||
if let Err(_err) = iam_store.policy_db_get(&cred.access_key, &cred.groups).await {
|
||||
error!(
|
||||
"AssumeRole get policy failed, err: {:?}, access_key: {:?}, groups: {:?}",
|
||||
_err, cred.access_key, cred.groups
|
||||
);
|
||||
return Err(s3_error!(InvalidArgument, "invalid policy arg"));
|
||||
}
|
||||
|
||||
let Some(secret) = get_token_signing_key() else {
|
||||
return Err(s3_error!(InvalidArgument, "global active sk not init"));
|
||||
};
|
||||
|
||||
info!("AssumeRole get claims {:?}", &claims);
|
||||
|
||||
let mut new_cred = get_new_credentials_with_metadata(&claims, &secret)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("get new cred failed {e}")))?;
|
||||
|
||||
new_cred.parent_user = cred.access_key.clone();
|
||||
|
||||
info!("AssumeRole get new_cred {:?}", &new_cred);
|
||||
|
||||
if let Err(_err) = iam_store.set_temp_user(&new_cred.access_key, &new_cred, None).await {
|
||||
return Err(s3_error!(InternalError, "set_temp_user failed"));
|
||||
}
|
||||
|
||||
// TODO: globalSiteReplicationSys
|
||||
|
||||
let resp = AssumeRoleOutput {
|
||||
credentials: Some(Credentials {
|
||||
access_key_id: new_cred.access_key,
|
||||
expiration: Timestamp::from(
|
||||
new_cred
|
||||
.expiration
|
||||
.unwrap_or(OffsetDateTime::now_utc().saturating_add(Duration::seconds(3600))),
|
||||
),
|
||||
secret_access_key: new_cred.secret_key,
|
||||
session_token: new_cred.session_token,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// getAssumeRoleCredentials
|
||||
let output = serialize::<AssumeRoleOutput>(&resp).unwrap();
|
||||
|
||||
Ok(S3Response::new((StatusCode::OK, Body::from(output))))
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the standard AssumeRole action (requires SigV4 credentials).
|
||||
async fn handle_assume_role(
|
||||
credentials: Option<s3s::auth::Credentials>,
|
||||
uri: http::Uri,
|
||||
headers: http::HeaderMap,
|
||||
body: AssumeRoleRequest,
|
||||
) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let Some(user) = credentials else {
|
||||
return Err(s3_error!(InvalidRequest, "get cred failed"));
|
||||
};
|
||||
|
||||
let session_token = get_session_token(&uri, &headers);
|
||||
if session_token.is_some() {
|
||||
return Err(s3_error!(InvalidRequest, "AccessDenied1"));
|
||||
}
|
||||
|
||||
let (cred, _owner) = check_key_valid(get_session_token(&uri, &headers).unwrap_or_default(), &user.access_key).await?;
|
||||
|
||||
// TODO: Check permissions, do not allow STS access
|
||||
if cred.is_temp() || cred.is_service_account() {
|
||||
return Err(s3_error!(InvalidRequest, "AccessDenied"));
|
||||
}
|
||||
|
||||
if body.version.as_str() != ASSUME_ROLE_VERSION {
|
||||
return Err(s3_error!(InvalidArgument, "not support version"));
|
||||
}
|
||||
|
||||
let mut claims = cred.claims.unwrap_or_default();
|
||||
|
||||
populate_session_policy(&mut claims, &body.policy)?;
|
||||
|
||||
let exp = {
|
||||
if body.duration_seconds > 0 {
|
||||
body.duration_seconds
|
||||
} else {
|
||||
3600
|
||||
}
|
||||
};
|
||||
|
||||
claims.insert(
|
||||
"exp".to_string(),
|
||||
Value::Number(serde_json::Number::from(OffsetDateTime::now_utc().unix_timestamp() + exp as i64)),
|
||||
);
|
||||
|
||||
claims.insert("parent".to_string(), Value::String(cred.access_key.clone()));
|
||||
|
||||
let Ok(iam_store) = rustfs_iam::get() else {
|
||||
return Err(s3_error!(InvalidRequest, "iam not init"));
|
||||
};
|
||||
|
||||
if let Err(_err) = iam_store.policy_db_get(&cred.access_key, &cred.groups).await {
|
||||
error!(
|
||||
"AssumeRole get policy failed, err: {:?}, access_key: {:?}, groups: {:?}",
|
||||
_err, cred.access_key, cred.groups
|
||||
);
|
||||
return Err(s3_error!(InvalidArgument, "invalid policy arg"));
|
||||
}
|
||||
|
||||
let Some(secret) = get_token_signing_key() else {
|
||||
return Err(s3_error!(InvalidArgument, "global active sk not init"));
|
||||
};
|
||||
|
||||
info!("AssumeRole get claims {:?}", &claims);
|
||||
|
||||
let mut new_cred = get_new_credentials_with_metadata(&claims, &secret)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("get new cred failed {e}")))?;
|
||||
|
||||
new_cred.parent_user = cred.access_key.clone();
|
||||
|
||||
debug!("AssumeRole get new_cred {:?}", &new_cred);
|
||||
|
||||
if let Err(_err) = iam_store.set_temp_user(&new_cred.access_key, &new_cred, None).await {
|
||||
return Err(s3_error!(InternalError, "set_temp_user failed"));
|
||||
}
|
||||
|
||||
// TODO: globalSiteReplicationSys
|
||||
|
||||
let resp = AssumeRoleOutput {
|
||||
credentials: Some(Credentials {
|
||||
access_key_id: new_cred.access_key,
|
||||
expiration: Timestamp::from(
|
||||
new_cred
|
||||
.expiration
|
||||
.unwrap_or(OffsetDateTime::now_utc().saturating_add(Duration::seconds(3600))),
|
||||
),
|
||||
secret_access_key: new_cred.secret_key,
|
||||
session_token: new_cred.session_token,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// getAssumeRoleCredentials
|
||||
let output = serialize::<AssumeRoleOutput>(&resp).unwrap();
|
||||
|
||||
Ok(S3Response::new((StatusCode::OK, Body::from(output))))
|
||||
}
|
||||
|
||||
/// Handle the AssumeRoleWithWebIdentity action.
|
||||
/// The JWT (id_token) in the request is the authentication — no SigV4 needed.
|
||||
async fn handle_assume_role_with_web_identity(body: AssumeRoleRequest) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
if body.web_identity_token.is_empty() {
|
||||
return Err(s3_error!(InvalidArgument, "WebIdentityToken is required"));
|
||||
}
|
||||
|
||||
if body.version.as_str() != ASSUME_ROLE_VERSION {
|
||||
return Err(s3_error!(InvalidArgument, "not support version"));
|
||||
}
|
||||
|
||||
// Verify the JWT and extract claims
|
||||
let oidc_sys = rustfs_iam::get_oidc().ok_or_else(|| s3_error!(InternalError, "OIDC not initialized"))?;
|
||||
|
||||
let (claims, provider_id) = oidc_sys
|
||||
.verify_web_identity_token(&body.web_identity_token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("AssumeRoleWithWebIdentity JWT verification failed: {}", e);
|
||||
S3Error::with_message(S3ErrorCode::AccessDenied, format!("token verification failed: {e}"))
|
||||
})?;
|
||||
|
||||
// Map claims to policies and groups
|
||||
let (policies, groups) = oidc_sys.map_claims_to_policies(&provider_id, &claims);
|
||||
|
||||
info!(
|
||||
"AssumeRoleWithWebIdentity: user='{}', provider='{}', policies={:?}, groups={:?}",
|
||||
claims.username, provider_id, policies, groups
|
||||
);
|
||||
|
||||
let mut duration = if body.duration_seconds > 0 {
|
||||
body.duration_seconds
|
||||
} else {
|
||||
3600
|
||||
};
|
||||
|
||||
// Enforce reasonable bounds for STS credentials duration (similar to AWS STS)
|
||||
duration = duration.clamp(900, 43200);
|
||||
// Generate STS credentials using the shared helper
|
||||
let new_cred = create_oidc_sts_credentials(
|
||||
&claims,
|
||||
&provider_id,
|
||||
&policies,
|
||||
&groups,
|
||||
duration,
|
||||
if body.policy.is_empty() { None } else { Some(&body.policy) },
|
||||
)
|
||||
.await?;
|
||||
|
||||
let subject = if !claims.email.is_empty() {
|
||||
claims.email.clone()
|
||||
} else if !claims.username.is_empty() {
|
||||
claims.username.clone()
|
||||
} else if !claims.sub.is_empty() {
|
||||
claims.sub.clone()
|
||||
} else {
|
||||
"oidc-user-unknown".to_string()
|
||||
};
|
||||
|
||||
// Build XML response (AssumeRoleWithWebIdentityResponse)
|
||||
let expiration = new_cred
|
||||
.expiration
|
||||
.unwrap_or(OffsetDateTime::now_utc().saturating_add(Duration::seconds(3600)));
|
||||
let exp_str = expiration
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_default();
|
||||
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<AssumeRoleWithWebIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
|
||||
<AssumeRoleWithWebIdentityResult>
|
||||
<Credentials>
|
||||
<AccessKeyId>{}</AccessKeyId>
|
||||
<SecretAccessKey>{}</SecretAccessKey>
|
||||
<SessionToken>{}</SessionToken>
|
||||
<Expiration>{}</Expiration>
|
||||
</Credentials>
|
||||
<SubjectFromWebIdentityToken>{}</SubjectFromWebIdentityToken>
|
||||
</AssumeRoleWithWebIdentityResult>
|
||||
</AssumeRoleWithWebIdentityResponse>"#,
|
||||
xml_escape(&new_cred.access_key),
|
||||
xml_escape(&new_cred.secret_key),
|
||||
xml_escape(&new_cred.session_token),
|
||||
xml_escape(&exp_str),
|
||||
xml_escape(&subject),
|
||||
);
|
||||
|
||||
let mut resp = S3Response::new((StatusCode::OK, Body::from(xml.into_bytes())));
|
||||
resp.headers
|
||||
.insert(http::header::CONTENT_TYPE, "application/xml".parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Shared helper to generate STS credentials from OIDC claims.
|
||||
/// Used by both the OIDC callback handler and AssumeRoleWithWebIdentity.
|
||||
pub async fn create_oidc_sts_credentials(
|
||||
claims: &OidcClaims,
|
||||
provider_id: &str,
|
||||
policies: &[String],
|
||||
groups: &[String],
|
||||
duration_seconds: usize,
|
||||
session_policy: Option<&str>,
|
||||
) -> S3Result<rustfs_credentials::Credentials> {
|
||||
let mut token_claims: HashMap<String, Value> = HashMap::new();
|
||||
token_claims.insert("sub".to_string(), Value::String(claims.sub.clone()));
|
||||
token_claims.insert("iss".to_string(), Value::String("rustfs-oidc".to_string()));
|
||||
token_claims.insert("oidc_provider".to_string(), Value::String(provider_id.to_string()));
|
||||
|
||||
if !claims.email.is_empty() {
|
||||
token_claims.insert("email".to_string(), Value::String(claims.email.clone()));
|
||||
}
|
||||
if !claims.username.is_empty() {
|
||||
token_claims.insert("preferred_username".to_string(), Value::String(claims.username.clone()));
|
||||
}
|
||||
if !groups.is_empty() {
|
||||
token_claims.insert(
|
||||
"groups".to_string(),
|
||||
Value::Array(groups.iter().map(|g| Value::String(g.clone())).collect()),
|
||||
);
|
||||
}
|
||||
|
||||
// Set expiration
|
||||
let exp = OffsetDateTime::now_utc().saturating_add(Duration::seconds(duration_seconds as i64));
|
||||
token_claims.insert("exp".to_string(), Value::Number(serde_json::Number::from(exp.unix_timestamp())));
|
||||
|
||||
// Set the parent user: prefer email, then username, then sub
|
||||
let parent_user = if !claims.email.is_empty() {
|
||||
claims.email.clone()
|
||||
} else if !claims.username.is_empty() {
|
||||
claims.username.clone()
|
||||
} else if !claims.sub.is_empty() {
|
||||
claims.sub.clone()
|
||||
} else {
|
||||
"oidc-user-unknown".to_string()
|
||||
};
|
||||
info!(
|
||||
"OIDC STS credential: parent_user='{}' (email='{}', username='{}', sub='{}')",
|
||||
parent_user, claims.email, claims.username, claims.sub
|
||||
);
|
||||
token_claims.insert("parent".to_string(), Value::String(parent_user.clone()));
|
||||
|
||||
// Set policies as a comma-separated string
|
||||
if !policies.is_empty() {
|
||||
token_claims.insert("policy".to_string(), Value::String(policies.join(",")));
|
||||
}
|
||||
|
||||
// Optionally apply session policy
|
||||
if let Some(policy_str) = session_policy {
|
||||
populate_session_policy(&mut token_claims, policy_str)?;
|
||||
}
|
||||
|
||||
// Generate STS temp credentials
|
||||
let secret = get_token_signing_key().ok_or_else(|| s3_error!(InternalError, "token signing key not initialized"))?;
|
||||
|
||||
let mut new_cred = get_new_credentials_with_metadata(&token_claims, &secret)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("credential generation failed: {e}")))?;
|
||||
|
||||
new_cred.parent_user = parent_user;
|
||||
new_cred.groups = Some(groups.to_vec());
|
||||
|
||||
// Store temp user in IAM
|
||||
let iam_store = rustfs_iam::get().map_err(|_| s3_error!(InternalError, "IAM not initialized"))?;
|
||||
|
||||
iam_store
|
||||
.set_temp_user(&new_cred.access_key, &new_cred, None)
|
||||
.await
|
||||
.map_err(|_| s3_error!(InternalError, "failed to store temp user"))?;
|
||||
|
||||
Ok(new_cred)
|
||||
}
|
||||
|
||||
pub fn populate_session_policy(claims: &mut HashMap<String, Value>, policy: &str) -> S3Result<()> {
|
||||
if !policy.is_empty() {
|
||||
let session_policy = Policy::parse_config(policy.as_bytes())
|
||||
@@ -205,3 +386,52 @@ pub fn populate_session_policy(claims: &mut HashMap<String, Value>, policy: &str
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Escape special XML characters in a string.
|
||||
fn xml_escape(s: &str) -> String {
|
||||
// Fast path: if there are no escapable characters, just clone the string.
|
||||
if !s.chars().any(|c| matches!(c, '&' | '<' | '>' | '"' | '\'')) {
|
||||
return s.to_owned();
|
||||
}
|
||||
|
||||
// Slow path: build the escaped string in a single pass.
|
||||
let mut escaped = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(c),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_xml_escape() {
|
||||
assert_eq!(xml_escape("hello"), "hello");
|
||||
assert_eq!(xml_escape("<script>"), "<script>");
|
||||
assert_eq!(xml_escape("a&b"), "a&b");
|
||||
assert_eq!(xml_escape("\"quoted\""), ""quoted"");
|
||||
assert_eq!(xml_escape("it's"), "it's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duration_clamping() {
|
||||
// Simulates the clamping logic from handle_assume_role_with_web_identity
|
||||
let clamp = |d: usize| if d > 0 { d.clamp(900, 43200) } else { 3600 };
|
||||
|
||||
assert_eq!(clamp(0), 3600); // default
|
||||
assert_eq!(clamp(100), 900); // clamped to min
|
||||
assert_eq!(clamp(900), 900); // exact min
|
||||
assert_eq!(clamp(3600), 3600); // normal
|
||||
assert_eq!(clamp(43200), 43200); // exact max
|
||||
assert_eq!(clamp(999999), 43200); // clamped to max
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ mod console_test;
|
||||
#[cfg(test)]
|
||||
mod route_registration_test;
|
||||
|
||||
use handlers::{bucket_meta, heal, health, kms, pools, profile_admin, quota, rebalance, replication, sts, system, tier, user};
|
||||
use handlers::{
|
||||
bucket_meta, heal, health, kms, oidc, pools, profile_admin, quota, rebalance, replication, sts, system, tier, user,
|
||||
};
|
||||
use router::{AdminOperation, S3Router};
|
||||
use rpc::register_rpc_route;
|
||||
use s3s::route::S3Route;
|
||||
@@ -58,6 +60,7 @@ pub fn make_admin_route(console_enabled: bool) -> std::io::Result<impl S3Route>
|
||||
replication::register_replication_route(&mut r)?;
|
||||
profile_admin::register_profiling_route(&mut r)?;
|
||||
kms::register_kms_route(&mut r)?;
|
||||
oidc::register_oidc_route(&mut r)?;
|
||||
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::console::{is_console_path, make_console_server};
|
||||
use crate::admin::handlers::oidc::is_oidc_path;
|
||||
use crate::server::{ADMIN_PREFIX, HEALTH_PREFIX, HEALTH_READY_PATH, PROFILE_CPU_PATH, PROFILE_MEMORY_PATH, RPC_PREFIX};
|
||||
use hyper::HeaderMap;
|
||||
use hyper::Method;
|
||||
@@ -142,6 +143,11 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Allow unauthenticated access to OIDC endpoints (user not yet authenticated)
|
||||
if is_oidc_path(path) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check RPC signature verification
|
||||
if req.uri.path().starts_with(RPC_PREFIX) {
|
||||
// Skip signature verification for HEAD requests (health checks)
|
||||
@@ -154,6 +160,30 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Allow unauthenticated STS requests to POST / (AssumeRoleWithWebIdentity
|
||||
// doesn't use SigV4 — the JWT token in the request body is the authentication).
|
||||
// The handler dispatches on the Action parameter: AssumeRole will reject if
|
||||
// credentials are missing, AssumeRoleWithWebIdentity will validate the JWT.
|
||||
// Require application/x-www-form-urlencoded Content-Type to narrow the bypass.
|
||||
if req.method == Method::POST
|
||||
&& path == "/"
|
||||
&& req.credentials.is_none()
|
||||
&& req
|
||||
.headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|ct| {
|
||||
ct.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("application/x-www-form-urlencoded")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// For non-RPC admin requests, check credentials
|
||||
match req.credentials {
|
||||
Some(_) => Ok(()),
|
||||
|
||||
+6
-1
@@ -62,7 +62,7 @@ use rustfs_ecstore::{
|
||||
use rustfs_heal::{
|
||||
create_ahm_services_cancel_token, heal::storage::ECStoreHealStorage, init_heal_manager, shutdown_ahm_services,
|
||||
};
|
||||
use rustfs_iam::init_iam_sys;
|
||||
use rustfs_iam::{init_iam_sys, init_oidc_sys};
|
||||
use rustfs_metrics::init_metrics_system;
|
||||
use rustfs_obs::{init_obs, set_global_guard};
|
||||
use rustfs_scanner::init_data_scanner;
|
||||
@@ -366,6 +366,11 @@ async fn run(config: config::Config) -> Result<()> {
|
||||
init_iam_sys(store.clone()).await.map_err(Error::other)?;
|
||||
readiness.mark_stage(SystemStage::IamReady);
|
||||
|
||||
// 3b. Initialize OIDC System (non-fatal if no providers configured)
|
||||
if let Err(e) = init_oidc_sys().await {
|
||||
warn!("OIDC initialization failed (non-fatal): {}", e);
|
||||
}
|
||||
|
||||
let iam_interface =
|
||||
rustfs_iam::get().map_err(|e| Error::other(format!("initialize app context IAM dependency failed: {e}")))?;
|
||||
let kms_interface = rustfs_kms::get_global_kms_service_manager().unwrap_or_else(rustfs_kms::init_global_kms_service_manager);
|
||||
|
||||
Reference in New Issue
Block a user