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:
Jeff Poegel
2026-02-25 22:03:17 -08:00
committed by GitHub
parent 0f8bc461d6
commit c35ef84a8c
22 changed files with 2584 additions and 139 deletions
Generated
+425 -33
View File
@@ -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]]
+1
View File
@@ -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"] }
+1
View File
@@ -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;
+91
View File
@@ -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";
+4
View File
@@ -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")]
+3
View File
@@ -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)
+101
View File
@@ -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,
},
])
});
+6
View File
@@ -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
View File
@@ -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()
}
+936
View File
@@ -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);
}
}
+127
View File
@@ -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
View File
@@ -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;
}
+2
View File
@@ -55,6 +55,8 @@ pub enum UserAuthType {
Builtin,
#[serde(rename = "ldap")]
Ldap,
#[serde(rename = "oidc")]
Oidc,
}
#[derive(Debug, Serialize, Deserialize)]
+44 -3
View File
@@ -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,
}
}
+14
View File
@@ -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)
+27 -4
View File
@@ -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,
+1
View File
@@ -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;
+372
View File
@@ -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(""));
}
}
+325 -95
View File
@@ -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("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => 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>"), "&lt;script&gt;");
assert_eq!(xml_escape("a&b"), "a&amp;b");
assert_eq!(xml_escape("\"quoted\""), "&quot;quoted&quot;");
assert_eq!(xml_escape("it's"), "it&apos;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
}
}
+4 -1
View File
@@ -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)
}
+30
View File
@@ -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
View File
@@ -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);