From c35ef84a8c542d95091eb0a36527758b191196d4 Mon Sep 17 00:00:00 2001 From: Jeff Poegel <14828959+jpoegs@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:03:17 -0800 Subject: [PATCH] feat(iam): add OpenID Connect SSO with claim-based policy resolution (#1875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: houseme Co-authored-by: houseme Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: heihutu <30542132+heihutu@users.noreply.github.com> Co-authored-by: loverustfs Co-authored-by: 安正超 --- Cargo.lock | 458 ++++++++++- Cargo.toml | 1 + crates/config/src/constants/mod.rs | 1 + crates/config/src/constants/oidc.rs | 91 +++ crates/config/src/lib.rs | 4 + crates/ecstore/src/config/mod.rs | 3 + crates/ecstore/src/config/oidc.rs | 101 +++ crates/iam/Cargo.toml | 6 + crates/iam/src/lib.rs | 42 +- crates/iam/src/oidc.rs | 936 ++++++++++++++++++++++ crates/iam/src/oidc_state.rs | 127 +++ crates/iam/src/sys.rs | 24 +- crates/madmin/src/user.rs | 2 + rustfs/src/admin/console.rs | 47 +- rustfs/src/admin/handlers/account_info.rs | 14 + rustfs/src/admin/handlers/is_admin.rs | 31 +- rustfs/src/admin/handlers/mod.rs | 1 + rustfs/src/admin/handlers/oidc.rs | 372 +++++++++ rustfs/src/admin/handlers/sts.rs | 420 +++++++--- rustfs/src/admin/mod.rs | 5 +- rustfs/src/admin/router.rs | 30 + rustfs/src/main.rs | 7 +- 22 files changed, 2584 insertions(+), 139 deletions(-) create mode 100644 crates/config/src/constants/oidc.rs create mode 100644 crates/ecstore/src/config/oidc.rs create mode 100644 crates/iam/src/oidc.rs create mode 100644 crates/iam/src/oidc_state.rs create mode 100644 rustfs/src/admin/handlers/oidc.rs diff --git a/Cargo.lock b/Cargo.lock index 82711587d..e549865d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 4d2bcc2fa..d8dd651cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/config/src/constants/mod.rs b/crates/config/src/constants/mod.rs index ad245cc2c..6176f3e98 100644 --- a/crates/config/src/constants/mod.rs +++ b/crates/config/src/constants/mod.rs @@ -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; diff --git a/crates/config/src/constants/oidc.rs b/crates/config/src/constants/oidc.rs new file mode 100644 index 000000000..c4bb73a90 --- /dev/null +++ b/crates/config/src/constants/oidc.rs @@ -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"; diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index e0d28aaef..c46fcf104 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -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")] diff --git a/crates/ecstore/src/config/mod.rs b/crates/ecstore/src/config/mod.rs index 9a39df133..701050f6d 100644 --- a/crates/ecstore/src/config/mod.rs +++ b/crates/ecstore/src/config/mod.rs @@ -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) diff --git a/crates/ecstore/src/config/oidc.rs b/crates/ecstore/src/config/oidc.rs new file mode 100644 index 000000000..21de803a3 --- /dev/null +++ b/crates/ecstore/src/config/oidc.rs @@ -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 = 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, + }, + ]) +}); diff --git a/crates/iam/Cargo.toml b/crates/iam/Cargo.toml index 09770a4af..67428cd7f 100644 --- a/crates/iam/Cargo.toml +++ b/crates/iam/Cargo.toml @@ -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 diff --git a/crates/iam/src/lib.rs b/crates/iam/src/lib.rs index f217b84e6..b5262357c 100644 --- a/crates/iam/src/lib.rs +++ b/crates/iam/src/lib.rs @@ -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>> = OnceLock::new(); +static OIDC_SYS: OnceLock> = OnceLock::new(); #[instrument(skip(ecstore))] pub async fn init_iam_sys(ecstore: Arc) -> Result<()> { @@ -75,3 +79,39 @@ pub fn get() -> Result>> { pub fn get_global_iam_sys() -> Option>> { 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> { + OIDC_SYS.get().cloned() +} diff --git a/crates/iam/src/oidc.rs b/crates/iam/src/oidc.rs new file mode 100644 index 000000000..eb1a4a5ec --- /dev/null +++ b/crates/iam/src/oidc.rs @@ -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>, Self::Error>> + Send + 'c>>; + + fn call(&'c self, request: http::Request>) -> 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, + pub scopes: Vec, + pub redirect_uri: Option, + 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, + pub raw: HashMap, +} + +// ---- 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, + provider_states: HashMap, + state_store: OidcStateStore, + http_client: ReqwestHttpClient, +} + +impl OidcSys { + /// Parse environment variables and discover all configured OIDC providers. + pub async fn new() -> Result { + 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 { + 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, + ) -> Result { + 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, Vec) { + 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 { + 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 = Vec::new(); + let scan_prefixes: Vec = 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 { + 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::() + .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::() + .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 { + // 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 { + 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, 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, key: &str) -> Vec { + 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) -> 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); + } +} diff --git a/crates/iam/src/oidc_state.rs b/crates/iam/src/oidc_state.rs new file mode 100644 index 000000000..0286cb337 --- /dev/null +++ b/crates/iam/src/oidc_state.rs @@ -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, +} + +/// 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, +} + +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 { + 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()); + } + } +} diff --git a/crates/iam/src/sys.rs b/crates/iam/src/sys.rs index 5c0fa28db..68c8b51d5 100644 --- a/crates/iam/src/sys.rs +++ b/crates/iam/src/sys.rs @@ -767,11 +767,33 @@ impl IamSys { } }, }; - 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; } diff --git a/crates/madmin/src/user.rs b/crates/madmin/src/user.rs index 3f02f8683..323f3d7d0 100644 --- a/crates/madmin/src/user.rs +++ b/crates/madmin/src/user.rs @@ -55,6 +55,8 @@ pub enum UserAuthType { Builtin, #[serde(rename = "ldap")] Ldap, + #[serde(rename = "oidc")] + Oidc, } #[derive(Debug, Serialize, Deserialize)] diff --git a/rustfs/src/admin/console.rs b/rustfs/src/admin/console.rs index 8aa936273..4bb1ef321 100644 --- a/rustfs/src/admin/console.rs +++ b/rustfs/src/admin/console.rs @@ -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 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, +} + +#[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, } } diff --git a/rustfs/src/admin/handlers/account_info.rs b/rustfs/src/admin/handlers/account_info.rs index 76eaa114c..967f1b91b 100644 --- a/rustfs/src/admin/handlers/account_info.rs +++ b/rustfs/src/admin/handlers/account_info.rs @@ -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) diff --git a/rustfs/src/admin/handlers/is_admin.rs b/rustfs/src/admin/handlers/is_admin.rs index 26e10884f..dff8312a1 100644 --- a/rustfs/src/admin/handlers/is_admin.rs +++ b/rustfs/src/admin/handlers/is_admin.rs @@ -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, diff --git a/rustfs/src/admin/handlers/mod.rs b/rustfs/src/admin/handlers/mod.rs index 0d62ee65a..af310e446 100644 --- a/rustfs/src/admin/handlers/mod.rs +++ b/rustfs/src/admin/handlers/mod.rs @@ -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; diff --git a/rustfs/src/admin/handlers/oidc.rs b/rustfs/src/admin/handlers/oidc.rs new file mode 100644 index 000000000..b00ac9c9b --- /dev/null +++ b/rustfs/src/admin/handlers/oidc.rs @@ -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) -> 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, _params: Params<'_, '_>) -> S3Result> { + 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, params: Params<'_, '_>) -> S3Result> { + 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, params: Params<'_, '_>) -> S3Result> { + 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, provider_id: &str) -> S3Result { + // 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 { + 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, + access_key: &str, + secret_key: &str, + session_token: &str, + expiration: Option, + 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("")); + } +} diff --git a/rustfs/src/admin/handlers/sts.rs b/rustfs/src/admin/handlers/sts.rs index 53001b988..5f25229c4 100644 --- a/rustfs/src/admin/handlers/sts.rs +++ b/rustfs/src/admin/handlers/sts.rs @@ -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) -> 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, _params: Params<'_, '_>) -> S3Result> { 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::(&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, + uri: http::Uri, + headers: http::HeaderMap, + body: AssumeRoleRequest, +) -> S3Result> { + 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::(&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> { + 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_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 { + let mut token_claims: HashMap = 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, 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, policy: &str Ok(()) } + +/// Escape special XML characters in a string. +fn xml_escape(s: &str) -> String { + // Fast path: if there are no escapable characters, just clone the string. + if !s.chars().any(|c| matches!(c, '&' | '<' | '>' | '"' | '\'')) { + return s.to_owned(); + } + + // Slow path: build the escaped string in a single pass. + let mut escaped = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(c), + } + } + escaped +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_xml_escape() { + assert_eq!(xml_escape("hello"), "hello"); + assert_eq!(xml_escape("