From da42266bfb72d4ff040229893160a51aa4663244 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Sun, 11 Oct 2026 13:27:46 +0800 Subject: [PATCH] refactor(iam): organize OIDC runtime modules (#8445) --- .github/workflows/oidc-keycloak.yml | 4 +- crates/iam/src/federation/mod.rs | 5 +- crates/iam/src/federation/oidc/claims.rs | 68 - crates/iam/src/federation/oidc/config.rs | 68 - crates/iam/src/federation/oidc/discovery.rs | 63 - crates/iam/src/federation/oidc/flow.rs | 109 - crates/iam/src/federation/oidc/http.rs | 57 - crates/iam/src/federation/oidc/mod.rs | 22 - crates/iam/src/federation/transaction.rs | 2 +- crates/iam/src/lib.rs | 1 - crates/iam/src/oidc.rs | 4710 ----------------- crates/iam/src/oidc/config.rs | 434 ++ crates/iam/src/oidc/config_tests.rs | 307 ++ crates/iam/src/oidc/mod.rs | 37 + crates/iam/src/oidc/provider.rs | 632 +++ crates/iam/src/oidc/provider_tests.rs | 712 +++ crates/iam/src/oidc/runtime.rs | 897 ++++ crates/iam/src/oidc/runtime_tests.rs | 754 +++ .../iam/src/{oidc_state.rs => oidc/state.rs} | 34 +- crates/iam/src/oidc/test_support.rs | 526 ++ crates/iam/src/oidc/transport.rs | 660 +++ crates/iam/src/oidc/transport_tests.rs | 167 + docs/operations/oidc-console-integration.md | 4 +- docs/operations/oidc-provider-requirements.md | 20 +- docs/operations/outbound-connection-policy.md | 2 +- rustfs/src/app/context.rs | 11 +- rustfs/src/app/context/global.rs | 6 +- rustfs/src/app/context/handles.rs | 13 +- rustfs/src/app/context/interfaces.rs | 6 +- rustfs/src/runtime_sources.rs | 9 +- rustfs/src/startup_auth.rs | 7 +- 31 files changed, 5176 insertions(+), 5171 deletions(-) delete mode 100644 crates/iam/src/federation/oidc/claims.rs delete mode 100644 crates/iam/src/federation/oidc/config.rs delete mode 100644 crates/iam/src/federation/oidc/discovery.rs delete mode 100644 crates/iam/src/federation/oidc/flow.rs delete mode 100644 crates/iam/src/federation/oidc/http.rs delete mode 100644 crates/iam/src/federation/oidc/mod.rs delete mode 100644 crates/iam/src/oidc.rs create mode 100644 crates/iam/src/oidc/config.rs create mode 100644 crates/iam/src/oidc/config_tests.rs create mode 100644 crates/iam/src/oidc/mod.rs create mode 100644 crates/iam/src/oidc/provider.rs create mode 100644 crates/iam/src/oidc/provider_tests.rs create mode 100644 crates/iam/src/oidc/runtime.rs create mode 100644 crates/iam/src/oidc/runtime_tests.rs rename crates/iam/src/{oidc_state.rs => oidc/state.rs} (92%) create mode 100644 crates/iam/src/oidc/test_support.rs create mode 100644 crates/iam/src/oidc/transport.rs create mode 100644 crates/iam/src/oidc/transport_tests.rs diff --git a/.github/workflows/oidc-keycloak.yml b/.github/workflows/oidc-keycloak.yml index 16b736521..f32287fb2 100644 --- a/.github/workflows/oidc-keycloak.yml +++ b/.github/workflows/oidc-keycloak.yml @@ -20,7 +20,7 @@ on: - ".github/workflows/oidc-keycloak.yml" - "crates/config/src/constants/oidc.rs" - "crates/iam/src/federation/**" - - "crates/iam/src/oidc.rs" + - "crates/iam/src/oidc/**" - "rustfs/src/admin/handlers/oidc.rs" - "rustfs/src/admin/handlers/sts.rs" - "scripts/test/oidc_keycloak_live.sh" @@ -31,7 +31,7 @@ on: - ".github/workflows/oidc-keycloak.yml" - "crates/config/src/constants/oidc.rs" - "crates/iam/src/federation/**" - - "crates/iam/src/oidc.rs" + - "crates/iam/src/oidc/**" - "rustfs/src/admin/handlers/oidc.rs" - "rustfs/src/admin/handlers/sts.rs" - "scripts/test/oidc_keycloak_live.sh" diff --git a/crates/iam/src/federation/mod.rs b/crates/iam/src/federation/mod.rs index dc1d930f5..b7498cf0a 100644 --- a/crates/iam/src/federation/mod.rs +++ b/crates/iam/src/federation/mod.rs @@ -16,14 +16,15 @@ mod binding; mod error; mod mapper; mod model; -pub mod oidc; mod provider; mod transaction; pub use binding::FederatedSessionBinding; pub use error::{FederatedSessionBindingError, FederationError, Result}; +pub(crate) use mapper::FederatedAuthorizationRule; +#[cfg(test)] +pub(crate) use mapper::FederatedAuthorizationRuleRef; pub use mapper::{CoreFederatedAuthorizationMapper, FederatedAuthorizationRules}; -pub(crate) use mapper::{FederatedAuthorizationRule, FederatedAuthorizationRuleRef}; pub(crate) use model::OpaqueLogoutContinuation; pub use model::{ FederatedAuthorization, FederatedClaims, FederatedLoginSession, FederatedProviderRef, FederatedSession, diff --git a/crates/iam/src/federation/oidc/claims.rs b/crates/iam/src/federation/oidc/claims.rs deleted file mode 100644 index 210bef001..000000000 --- a/crates/iam/src/federation/oidc/claims.rs +++ /dev/null @@ -1,68 +0,0 @@ -// 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::{ - federation::{FederatedClaims, FederatedProviderRef, VerifiedFederatedIdentity}, - oidc::OidcClaims, -}; - -pub(super) fn verified_identity(provider: FederatedProviderRef, claims: OidcClaims) -> VerifiedFederatedIdentity { - VerifiedFederatedIdentity::from_claims( - provider, - FederatedClaims { - sub: claims.sub, - email: claims.email, - username: claims.username, - groups: claims.groups, - raw: claims.raw, - }, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::collections::HashMap; - - #[test] - fn verified_identity_preserves_provider_and_claim_values() { - let raw = HashMap::from([ - ("iss".to_string(), json!("https://corp.example.test")), - ("department".to_string(), json!("engineering")), - ("roles".to_string(), json!(["reader", "admin"])), - ]); - let identity = verified_identity( - FederatedProviderRef::new("corp".to_string()), - OidcClaims { - sub: " subject-123 ".to_string(), - email: " user@example.test ".to_string(), - username: " user ".to_string(), - groups: vec![ - "source-ops".to_string(), - "source-developers".to_string(), - "source-ops".to_string(), - ], - raw: raw.clone(), - }, - ); - - assert_eq!(identity.provider().as_str(), "corp"); - assert_eq!(identity.subject(), " subject-123 "); - assert_eq!(identity.email(), " user@example.test "); - assert_eq!(identity.username(), " user "); - assert_eq!(identity.source_groups(), ["source-ops", "source-developers", "source-ops"]); - assert_eq!(identity.attributes(), &raw); - } -} diff --git a/crates/iam/src/federation/oidc/config.rs b/crates/iam/src/federation/oidc/config.rs deleted file mode 100644 index f2374c251..000000000 --- a/crates/iam/src/federation/oidc/config.rs +++ /dev/null @@ -1,68 +0,0 @@ -// 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::{ - federation::{FederatedAuthorizationRule, FederatedAuthorizationRules, FederatedRedirectPolicy}, - oidc::{OidcConfigSnapshot, OidcSys}, -}; - -pub(crate) fn authorization_rules(oidc: &OidcSys) -> FederatedAuthorizationRules { - FederatedAuthorizationRules::new(oidc.provider_configs().map(|config| { - FederatedAuthorizationRule::new( - config.id.clone(), - config.claim_name.clone(), - config.claim_prefix.clone(), - config.role_policy.clone(), - config.groups_claim.clone(), - config.roles_claim.clone(), - ) - })) -} - -/// Read-only access to the active standard OIDC configuration. -/// -/// This interface stays separate from authentication and generic provider -/// views because its snapshots include OIDC-specific fields and secrets. -pub trait OidcConfigQuery: Send + Sync { - fn config_snapshot(&self) -> OidcConfigSnapshot; -} - -pub(super) fn redirect_policy(oidc: &OidcSys, provider_id: &str) -> Option { - oidc.get_provider_config(provider_id).map(|config| FederatedRedirectPolicy { - redirect_uri: config.redirect_uri.clone(), - allow_request_origin: config.redirect_uri_dynamic, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn redirect_policy_keeps_configured_uri_and_dynamic_setting() { - let mut config = crate::oidc::test_config("default"); - config.redirect_uri = Some("https://console.example.test/callback".to_string()); - config.redirect_uri_dynamic = false; - let oidc = crate::oidc::make_test_sys(vec![config]); - - assert_eq!( - redirect_policy(&oidc, "default"), - Some(FederatedRedirectPolicy { - redirect_uri: Some("https://console.example.test/callback".to_string()), - allow_request_origin: false, - }) - ); - assert_eq!(redirect_policy(&oidc, "missing"), None); - } -} diff --git a/crates/iam/src/federation/oidc/discovery.rs b/crates/iam/src/federation/oidc/discovery.rs deleted file mode 100644 index dd50fe3ac..000000000 --- a/crates/iam/src/federation/oidc/discovery.rs +++ /dev/null @@ -1,63 +0,0 @@ -// 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::{federation::FederatedProviderView, oidc::OidcSys}; - -fn provider_view(provider: &crate::oidc::OidcProviderConfig) -> FederatedProviderView { - FederatedProviderView { - provider_id: provider.id.clone(), - display_name: provider.display_name.clone(), - } -} - -pub(super) fn list_providers(oidc: &OidcSys) -> Vec { - oidc.provider_configs().map(provider_view).collect() -} - -pub(super) fn list_visible_providers(oidc: &OidcSys) -> Vec { - oidc.provider_configs() - .filter(|provider| !provider.hide_from_ui) - .map(provider_view) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn provider_views_preserve_names_and_visibility() { - let mut visible = crate::oidc::test_config("default"); - visible.display_name = "Company SSO".to_string(); - let mut hidden = crate::oidc::test_config("workload"); - hidden.hide_from_ui = true; - let oidc = crate::oidc::make_test_sys(vec![visible, hidden]); - - let all = list_providers(&oidc); - assert_eq!(all.len(), 2); - assert!( - all.iter() - .any(|provider| { provider.provider_id == "default" && provider.display_name == "Company SSO" }) - ); - assert!(all.iter().any(|provider| provider.provider_id == "workload")); - - assert_eq!( - list_visible_providers(&oidc), - vec![FederatedProviderView { - provider_id: "default".to_string(), - display_name: "Company SSO".to_string(), - }] - ); - } -} diff --git a/crates/iam/src/federation/oidc/flow.rs b/crates/iam/src/federation/oidc/flow.rs deleted file mode 100644 index e338590e7..000000000 --- a/crates/iam/src/federation/oidc/flow.rs +++ /dev/null @@ -1,109 +0,0 @@ -// 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::config::OidcConfigQuery; -use super::{claims, config, discovery, http}; -use crate::{ - federation::{ - CoreFederatedAuthorizationMapper, FederatedAuthorizationRules, FederatedIdentityService, FederatedProviderQuery, - FederatedProviderRef, FederatedProviderView, FederatedRedirectPolicy, FederationError, OpaqueLogoutContinuation, Result, - StandardOidcAuthentication, VerifiedFederatedCodeExchange, - }, - oidc::{OidcConfigSnapshot, OidcSys}, -}; -use std::sync::Arc; - -pub struct StandardOidcAdapter { - oidc: Arc, - authorization_rules: FederatedAuthorizationRules, -} - -impl StandardOidcAdapter { - pub fn new(oidc: Arc) -> Self { - let authorization_rules = config::authorization_rules(&oidc); - Self { - oidc, - authorization_rules, - } - } - - pub fn authorization_rules(&self) -> FederatedAuthorizationRules { - self.authorization_rules.clone() - } - - pub fn into_service(self: Arc, mapper: CoreFederatedAuthorizationMapper) -> FederatedIdentityService { - let authentication: Arc = self.clone(); - let provider_query: Arc = self; - FederatedIdentityService::from_standard_oidc_parts(provider_query, authentication, mapper) - } -} - -impl OidcConfigQuery for StandardOidcAdapter { - fn config_snapshot(&self) -> OidcConfigSnapshot { - self.oidc.config_snapshot() - } -} - -#[async_trait::async_trait] -impl StandardOidcAuthentication for StandardOidcAdapter { - async fn authorize_url(&self, provider_id: &str, redirect_uri: &str, redirect_after: Option) -> Result { - http::authorize_url(&self.oidc, provider_id, redirect_uri, redirect_after).await - } - - async fn exchange_identity(&self, state: &str, code: &str, redirect_uri: &str) -> Result { - let (oidc_claims, provider_id, session, id_token) = http::exchange_code(&self.oidc, state, code, redirect_uri).await?; - let provider = FederatedProviderRef::new(provider_id); - Ok(VerifiedFederatedCodeExchange::new( - claims::verified_identity(provider.clone(), oidc_claims), - session.redirect_after, - OpaqueLogoutContinuation::new(provider, id_token), - )) - } - - async fn verify_identity(&self, jwt: &str) -> Result { - let (oidc_claims, provider_id) = self - .oidc - .verify_web_identity_token(jwt) - .await - .map_err(FederationError::TokenVerification)?; - Ok(claims::verified_identity(FederatedProviderRef::new(provider_id), oidc_claims)) - } - - async fn create_logout_token(&self, continuation: OpaqueLogoutContinuation) -> Result { - let (provider, id_token) = continuation.into_parts(); - http::create_logout_token(&self.oidc, provider.as_str(), &id_token).await - } - - async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result> { - http::build_logout_url(&self.oidc, logout_token, post_logout_redirect_uri).await - } -} - -impl FederatedProviderQuery for StandardOidcAdapter { - fn has_providers(&self) -> bool { - self.oidc.has_providers() - } - - fn list_providers(&self) -> Vec { - discovery::list_providers(&self.oidc) - } - - fn list_visible_providers(&self) -> Vec { - discovery::list_visible_providers(&self.oidc) - } - - fn redirect_policy(&self, provider_id: &str) -> Option { - config::redirect_policy(&self.oidc, provider_id) - } -} diff --git a/crates/iam/src/federation/oidc/http.rs b/crates/iam/src/federation/oidc/http.rs deleted file mode 100644 index 3b4f0dcd0..000000000 --- a/crates/iam/src/federation/oidc/http.rs +++ /dev/null @@ -1,57 +0,0 @@ -// 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::{ - federation::{FederationError, Result}, - oidc::{OidcClaims, OidcSys}, - oidc_state::OidcAuthSession, -}; - -pub(super) async fn authorize_url( - oidc: &OidcSys, - provider_id: &str, - redirect_uri: &str, - redirect_after: Option, -) -> Result { - oidc.authorize_url(provider_id, redirect_uri, redirect_after) - .await - .map_err(FederationError::Authorization) -} - -pub(super) async fn exchange_code( - oidc: &OidcSys, - state: &str, - code: &str, - redirect_uri: &str, -) -> Result<(OidcClaims, String, OidcAuthSession, String)> { - oidc.exchange_code(state, code, redirect_uri) - .await - .map_err(FederationError::CodeExchange) -} - -pub(super) async fn create_logout_token(oidc: &OidcSys, provider_id: &str, id_token: &str) -> Result { - oidc.create_logout_token(provider_id, id_token) - .await - .map_err(FederationError::Logout) -} - -pub(super) async fn build_logout_url( - oidc: &OidcSys, - logout_token: &str, - post_logout_redirect_uri: &str, -) -> Result> { - oidc.build_logout_url(logout_token, post_logout_redirect_uri) - .await - .map_err(FederationError::Logout) -} diff --git a/crates/iam/src/federation/oidc/mod.rs b/crates/iam/src/federation/oidc/mod.rs deleted file mode 100644 index 52e6b6432..000000000 --- a/crates/iam/src/federation/oidc/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -// 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. - -mod claims; -mod config; -mod discovery; -mod flow; -mod http; - -pub use config::OidcConfigQuery; -pub use flow::StandardOidcAdapter; diff --git a/crates/iam/src/federation/transaction.rs b/crates/iam/src/federation/transaction.rs index b9bda054d..3f7ec15dc 100644 --- a/crates/iam/src/federation/transaction.rs +++ b/crates/iam/src/federation/transaction.rs @@ -61,7 +61,7 @@ impl FederatedIdentityService { self.authentication.create_logout_token(continuation).await } - pub(super) fn from_standard_oidc_parts( + pub(crate) fn from_standard_oidc_parts( provider_query: Arc, authentication: Arc, mapper: CoreFederatedAuthorizationMapper, diff --git a/crates/iam/src/lib.rs b/crates/iam/src/lib.rs index ab6f28bfd..2f139630c 100644 --- a/crates/iam/src/lib.rs +++ b/crates/iam/src/lib.rs @@ -34,7 +34,6 @@ pub mod keyring; pub mod manager; pub mod mfa; pub mod oidc; -pub mod oidc_state; mod root_credentials; mod runtime_sources; mod server_config; diff --git a/crates/iam/src/oidc.rs b/crates/iam/src/oidc.rs deleted file mode 100644 index 7a4adaa2f..000000000 --- a/crates/iam/src/oidc.rs +++ /dev/null @@ -1,4710 +0,0 @@ -// 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::{ - EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC, - federation::{CoreFederatedAuthorizationMapper, FederatedAuthorizationRuleRef}, - oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore}, -}; -use openidconnect::core::{ - CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreIdTokenVerifier, CoreJsonWebKeySet, CoreJwsSigningAlgorithm, -}; -use openidconnect::{ - AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, DiscoveryError, IssuerUrl, - JsonWebKeySetUrl, LogoutRequest, Nonce, PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, - ProviderMetadataWithLogout, RedirectUrl, RequestTokenError, Scope, TokenUrl, -}; -use reqwest::{Certificate, Client}; -use rustfs_config::oidc::*; -use rustfs_config::server_config::{Config as ServerConfig, KVS}; -use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState, MAX_OIDC_RESPONSE_SIZE}; -use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive}; -use rustfs_utils::egress::{ENV_OUTBOUND_ALLOW_ORIGINS, OutboundPolicy, find_outbound_dns_policy_rejection}; -use serde::{Deserialize, Serialize}; -use std::borrow::Cow; -use std::collections::{HashMap, VecDeque}; -use std::fmt; -use std::future::Future; -use std::net::IpAddr; -use std::pin::Pin; -use std::sync::Arc; -use std::sync::{LazyLock, Mutex, MutexGuard, RwLock}; -use std::time::{Duration as StdDuration, Instant}; -use tokio::time::sleep; -use tracing::{debug, error, warn}; -use url::Url; - -const EVENT_OIDC_HTTP: &str = "oidc_http"; -const OIDC_JWKS_REFRESH_INTERVAL: StdDuration = StdDuration::from_secs(24 * 60 * 60); -const OIDC_DISCOVERY_TRANSPORT_RETRIES: usize = 3; -const OIDC_DISCOVERY_TRANSPORT_RETRY_DELAY: StdDuration = StdDuration::from_millis(50); -const OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY: &str = "JWKS request blocked by outbound policy"; -const OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY: &str = "OIDC provider discovery blocked by outbound policy"; -const OIDC_HTTP_REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(10); -const OIDC_HTTP_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(3); -const OIDC_PLUGIN_AUTHN_WINDOW: StdDuration = StdDuration::from_secs(60); - -#[derive(Debug, Clone, Copy, Default)] -pub struct OidcPluginAuthnMetricsSnapshot { - pub failed_requests_minute: u64, - pub last_fail_seconds: u64, - pub last_succ_seconds: u64, - pub succ_avg_rtt_ms_minute: u64, - pub succ_max_rtt_ms_minute: u64, - pub total_requests_minute: u64, -} - -#[derive(Debug, Clone)] -struct OidcPluginAuthnSample { - observed_at: Instant, - succeeded: bool, - rtt_ms: u64, -} - -#[derive(Debug, Default)] -struct OidcPluginAuthnMetrics { - samples: Mutex>, - last_fail_at: Mutex>, - last_succ_at: Mutex>, -} - -fn lock_oidc_plugin_authn_metrics<'a, T>(mutex: &'a Mutex, metric: &'static str) -> MutexGuard<'a, T> { - match mutex.lock() { - Ok(guard) => guard, - Err(err) => { - warn!(metric, "Recovering poisoned OIDC authn metrics lock"); - err.into_inner() - } - } -} - -fn seconds_since(now: Instant, observed_at: Option) -> u64 { - observed_at - .map(|instant| now.duration_since(instant).as_secs()) - .unwrap_or_default() -} - -impl OidcPluginAuthnMetrics { - fn record(&self, rtt_ms: u64, succeeded: bool) { - let now = Instant::now(); - let mut samples = lock_oidc_plugin_authn_metrics(&self.samples, "samples"); - samples.push_back(OidcPluginAuthnSample { - observed_at: now, - succeeded, - rtt_ms, - }); - while samples - .front() - .is_some_and(|sample| now.duration_since(sample.observed_at) > OIDC_PLUGIN_AUTHN_WINDOW) - { - samples.pop_front(); - } - drop(samples); - - if succeeded { - *lock_oidc_plugin_authn_metrics(&self.last_succ_at, "last_succ_at") = Some(now); - } else { - *lock_oidc_plugin_authn_metrics(&self.last_fail_at, "last_fail_at") = Some(now); - } - } - - fn snapshot(&self) -> OidcPluginAuthnMetricsSnapshot { - let now = Instant::now(); - let (total_requests_minute, failed_requests_minute, succ_avg_rtt_ms_minute, succ_max_rtt_ms_minute) = { - let mut samples = lock_oidc_plugin_authn_metrics(&self.samples, "samples"); - while samples - .front() - .is_some_and(|sample| now.duration_since(sample.observed_at) > OIDC_PLUGIN_AUTHN_WINDOW) - { - samples.pop_front(); - } - - let mut failed_requests_minute = 0u64; - let mut successful_requests = 0u64; - let mut successful_rtt_sum = 0u64; - let mut succ_max_rtt_ms_minute = 0u64; - - for sample in samples.iter() { - if sample.succeeded { - successful_requests += 1; - successful_rtt_sum += sample.rtt_ms; - succ_max_rtt_ms_minute = succ_max_rtt_ms_minute.max(sample.rtt_ms); - } else { - failed_requests_minute += 1; - } - } - - let succ_avg_rtt_ms_minute = successful_rtt_sum.checked_div(successful_requests).unwrap_or_default(); - - ( - samples.len() as u64, - failed_requests_minute, - succ_avg_rtt_ms_minute, - succ_max_rtt_ms_minute, - ) - }; - - let last_fail_seconds = seconds_since(now, *lock_oidc_plugin_authn_metrics(&self.last_fail_at, "last_fail_at")); - let last_succ_seconds = seconds_since(now, *lock_oidc_plugin_authn_metrics(&self.last_succ_at, "last_succ_at")); - - OidcPluginAuthnMetricsSnapshot { - failed_requests_minute, - last_fail_seconds, - last_succ_seconds, - succ_avg_rtt_ms_minute, - succ_max_rtt_ms_minute, - total_requests_minute, - } - } -} - -static OIDC_PLUGIN_AUTHN_METRICS: LazyLock = LazyLock::new(OidcPluginAuthnMetrics::default); - -pub fn oidc_plugin_authn_metrics_snapshot() -> OidcPluginAuthnMetricsSnapshot { - OIDC_PLUGIN_AUTHN_METRICS.snapshot() -} - -/// Header names whose values may carry OIDC secrets (client credentials, cookies, -/// bearer tokens). Their values are never emitted to logs, only their byte length. -const SENSITIVE_HEADER_NAMES: [&str; 4] = ["authorization", "proxy-authorization", "cookie", "set-cookie"]; - -fn is_sensitive_header(name: &str) -> bool { - SENSITIVE_HEADER_NAMES - .iter() - .any(|candidate| name.eq_ignore_ascii_case(candidate)) -} - -fn format_http_headers(headers: &http::HeaderMap) -> String { - headers - .iter() - .map(|(name, value)| { - if is_sensitive_header(name.as_str()) { - format!("{}=", name.as_str(), value.as_bytes().len()) - } else { - let value = value.to_str().unwrap_or(""); - format!("{}={}", name.as_str(), value) - } - }) - .collect::>() - .join("; ") -} - -#[derive(Debug, Default)] -struct TokenResponseBodyShape { - json_object: bool, - json_keys: String, - has_access_token: bool, - has_id_token: bool, - has_token_type: bool, - has_expires_in: bool, - has_error: bool, - has_error_description: bool, - looks_like_html: bool, -} - -fn inspect_token_response_body(body: &[u8]) -> TokenResponseBodyShape { - let mut shape = TokenResponseBodyShape { - looks_like_html: body - .iter() - .copied() - .find(|byte| !byte.is_ascii_whitespace()) - .is_some_and(|byte| byte == b'<'), - ..Default::default() - }; - - let Ok(value) = serde_json::from_slice::(body) else { - return shape; - }; - let Some(object) = value.as_object() else { - return shape; - }; - - shape.json_object = true; - shape.has_access_token = object.contains_key("access_token"); - shape.has_id_token = object.contains_key("id_token"); - shape.has_token_type = object.contains_key("token_type"); - shape.has_expires_in = object.contains_key("expires_in"); - shape.has_error = object.contains_key("error"); - shape.has_error_description = object.contains_key("error_description"); - - let mut keys: Vec<&str> = object.keys().map(String::as_str).collect(); - keys.sort_unstable(); - keys.truncate(16); - shape.json_keys = keys.join(","); - - shape -} - -fn oidc_http_error_diagnostics(error: &OidcHttpError) -> (&'static str, String) { - match error { - OidcHttpError::Reqwest(err) if err.is_timeout() => ("timeout", String::new()), - OidcHttpError::Reqwest(err) if err.is_connect() => ("connect", String::new()), - OidcHttpError::Reqwest(err) if err.status().is_some() => { - ("http_status", err.status().map(|status| status.as_u16().to_string()).unwrap_or_default()) - } - OidcHttpError::Reqwest(_) => ("request", String::new()), - OidcHttpError::Http(_) => ("http_build", String::new()), - OidcHttpError::ExtraRootCa(_) => ("extra_root_ca", String::new()), - OidcHttpError::ForbiddenOutbound(_) => ("forbidden_outbound", String::new()), - OidcHttpError::ResponseTooLarge(limit) => ("response_too_large", limit.to_string()), - } -} - -// ---- HTTP Client Adapter ---- - -/// Error type for the OIDC HTTP client adapter. -#[derive(Debug)] -pub enum OidcHttpError { - Reqwest(reqwest::Error), - Http(http::Error), - ExtraRootCa(String), - /// The outbound destination was rejected by the shared egress policy before any - /// connection was attempted (invalid URL, loopback/link-local/metadata/private IP, - /// or a malformed allow-origins configuration). - ForbiddenOutbound(String), - /// The provider response body exceeded [`MAX_OIDC_RESPONSE_SIZE`] and was abandoned - /// instead of being buffered in full. - ResponseTooLarge(usize), -} - -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}"), - Self::ExtraRootCa(reason) => write!(f, "failed to load OIDC extra root CA bundle: {reason}"), - Self::ForbiddenOutbound(reason) => write!(f, "outbound request rejected: {reason}"), - Self::ResponseTooLarge(limit) => write!(f, "oidc response body exceeds {limit} bytes"), - } - } -} - -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), - Self::ExtraRootCa(_) | Self::ForbiddenOutbound(_) | Self::ResponseTooLarge(_) => None, - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct OidcExtraRootCaMaterial { - pub generation: u64, - pub root_ca_pem: Option>, -} - -type OidcExtraRootCaFuture = Pin> + Send>>; -type OidcExtraRootCaLoader = dyn Fn() -> OidcExtraRootCaFuture + Send + Sync; - -#[derive(Clone)] -pub struct OidcExtraRootCaProvider { - loader: Arc, -} - -impl OidcExtraRootCaProvider { - pub fn new(loader: F) -> Self - where - F: Fn() -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - { - Self { - loader: Arc::new(move || Box::pin(loader())), - } - } - - async fn load(&self) -> Result { - (self.loader)().await - } -} - -#[derive(Clone, Default)] -struct CachedOidcExtraRootCerts { - generation: u64, - initialized: bool, - certs: Vec, -} - -/// HTTP client adapter bridging reqwest 0.13 to the `openidconnect` `AsyncHttpClient` trait. -/// -/// A fresh client is built for every request so the destination is re-validated and the -/// resolved IP is re-classified at connection time. This closes the SSRF / DNS-rebinding -/// gap where a one-shot URL string check is bypassed by a hostname that resolves to an -/// internal address only at connection time, and it also covers endpoints discovered from -/// the provider metadata (JWKS, token) rather than only the operator-configured `config_url`. -pub(crate) struct ReqwestHttpClient { - /// `None` in production: the process-cached outbound policy from the environment is used. - /// `Some(..)` only in tests, to explicitly allow a loopback mock endpoint. - policy_override: Option, - extra_root_certs: Arc>, - extra_root_ca_provider: Option, - #[cfg(test)] - dns_resolver_override: Option>, -} - -fn parse_oidc_extra_root_certs(source: &str, pem: &[u8]) -> Result, String> { - if pem.iter().all(|byte| byte.is_ascii_whitespace()) { - return Ok(Vec::new()); - } - Certificate::from_pem_bundle(pem).map_err(|err| format!("failed to parse OIDC extra root CA bundle from {source}: {err}")) -} - -fn oidc_extra_root_certs(root_ca_pem: Option<&[u8]>) -> Result, String> { - match root_ca_pem { - Some(pem) => parse_oidc_extra_root_certs("RustFS outbound TLS material", pem), - None => Ok(Vec::new()), - } -} - -/// Build a reqwest client pinned to the shared outbound egress policy for a single request. -/// -/// [`OutboundPolicy::resolver_for`] validates the URL shape and rejects loopback, -/// link-local, metadata, multicast and unauthorized private addresses up front, and the -/// returned `OutboundDnsResolver` re-resolves and re-classifies the host on every new -/// connection so DNS rebinding fails closed. Redirects are not followed: a redirect target -/// would otherwise skip URL-shape re-validation. The timeouts bound how long a slow or -/// stalled provider can pin the calling task. -fn build_oidc_http_client( - uri: &str, - policy_override: Option<&OutboundPolicy>, - extra_root_certs: &[Certificate], - #[cfg(test)] dns_resolver_override: Option>, -) -> Result<(Client, Url), OidcHttpError> { - let url = Url::parse(uri).map_err(|_| OidcHttpError::ForbiddenOutbound("invalid outbound OIDC URL".to_string()))?; - let resolver = match policy_override { - Some(policy) => policy.resolver_for(&url), - None => OutboundPolicy::from_env_cached() - .map_err(|err| OidcHttpError::ForbiddenOutbound(err.to_string()))? - .resolver_for(&url), - } - .map_err(|err| { - let base = err.to_string(); - let origin = url.origin().ascii_serialization(); - let can_allow_origin = - OutboundPolicy::from_allowed_origins(&origin).is_ok_and(|allowlisted| allowlisted.validate_url(&url).is_ok()); - oidc_forbidden_outbound_error(&url, base, can_allow_origin) - })?; - let bypass_proxy = should_bypass_proxy_for_oidc_uri(uri); - #[cfg(test)] - let bypass_proxy = bypass_proxy || dns_resolver_override.is_some(); - #[cfg(test)] - let resolver: Arc = dns_resolver_override.unwrap_or_else(|| Arc::new(resolver)); - - let mut builder = reqwest::Client::builder() - .dns_resolver(resolver) - .redirect(reqwest::redirect::Policy::none()) - .timeout(OIDC_HTTP_REQUEST_TIMEOUT) - .connect_timeout(OIDC_HTTP_CONNECT_TIMEOUT); - if bypass_proxy { - builder = builder.no_proxy(); - } - if !extra_root_certs.is_empty() { - builder = builder.tls_certs_merge(extra_root_certs.iter().cloned()); - } - builder.build().map(|client| (client, url)).map_err(OidcHttpError::Reqwest) -} - -fn oidc_forbidden_outbound_error(url: &Url, base: String, can_allow_origin: bool) -> OidcHttpError { - let reason = if can_allow_origin { - let origin = url.origin().ascii_serialization(); - format!( - "{base}; add {origin} to {ENV_OUTBOUND_ALLOW_ORIGINS} (comma-separated) and restart RustFS to allow this operator-owned OIDC provider (origin only, no path)" - ) - } else { - base - }; - OidcHttpError::ForbiddenOutbound(reason) -} - -fn oidc_http_error_from_reqwest(url: &Url, error: reqwest::Error) -> OidcHttpError { - if let Some(rejection) = find_outbound_dns_policy_rejection(&error) { - let base = rejection.to_string(); - return oidc_forbidden_outbound_error(url, base, rejection.allow_origin_can_recover()); - } - OidcHttpError::Reqwest(error) -} - -/// Buffer a provider response body, failing closed once `limit` bytes have been seen. -/// -/// `Response::bytes` would buffer the whole body unconditionally, so a hostile or compromised -/// provider endpoint could stream an arbitrarily large (or endless) body into memory. -async fn read_bounded_response_body(response: reqwest::Response, limit: usize) -> Result, OidcHttpError> { - if response.content_length().is_some_and(|len| len > limit as u64) { - return Err(OidcHttpError::ResponseTooLarge(limit)); - } - - let mut response = response; - let mut body = Vec::new(); - while let Some(chunk) = response.chunk().await.map_err(OidcHttpError::Reqwest)? { - if body.len() + chunk.len() > limit { - return Err(OidcHttpError::ResponseTooLarge(limit)); - } - body.extend_from_slice(&chunk); - } - Ok(body) -} - -fn should_bypass_proxy_for_oidc_uri(uri: &str) -> bool { - let Some(host) = Url::parse(uri).ok().and_then(|url| url.host_str().map(str::to_owned)) else { - return false; - }; - let host = host.trim_matches(['[', ']']); - - host.eq_ignore_ascii_case("localhost") || host.parse::().is_ok_and(|addr| addr.is_loopback()) -} - -impl ReqwestHttpClient { - fn new() -> Result { - Self::new_with_extra_root_certs(Vec::new()) - } - - fn extra_root_cert_cache(certs: Vec) -> Arc> { - Arc::new(RwLock::new(CachedOidcExtraRootCerts { - generation: 0, - initialized: true, - certs, - })) - } - - fn new_with_extra_root_certs(extra_root_certs: Vec) -> Result { - Ok(Self { - policy_override: None, - extra_root_certs: Self::extra_root_cert_cache(extra_root_certs), - extra_root_ca_provider: None, - #[cfg(test)] - dns_resolver_override: None, - }) - } - - fn new_with_extra_root_ca_provider(extra_root_ca_provider: OidcExtraRootCaProvider) -> Result { - Ok(Self { - policy_override: None, - extra_root_certs: Arc::new(RwLock::new(CachedOidcExtraRootCerts::default())), - extra_root_ca_provider: Some(extra_root_ca_provider), - #[cfg(test)] - dns_resolver_override: None, - }) - } - - async fn current_extra_root_certs(&self) -> Result, OidcHttpError> { - let Some(provider) = self.extra_root_ca_provider.as_ref() else { - return self - .extra_root_certs - .read() - .map(|cache| cache.certs.clone()) - .map_err(|e| OidcHttpError::ExtraRootCa(format!("extra root certificate cache lock poisoned: {e}"))); - }; - - let material = provider.load().await.map_err(OidcHttpError::ExtraRootCa)?; - if let Ok(cache) = self.extra_root_certs.read() - && cache.initialized - && cache.generation == material.generation - { - return Ok(cache.certs.clone()); - } - - let certs = oidc_extra_root_certs(material.root_ca_pem.as_deref()).map_err(OidcHttpError::ExtraRootCa)?; - let mut cache = self - .extra_root_certs - .write() - .map_err(|e| OidcHttpError::ExtraRootCa(format!("extra root certificate cache lock poisoned: {e}")))?; - cache.generation = material.generation; - cache.initialized = true; - cache.certs = certs.clone(); - Ok(certs) - } - - /// Test-only constructor that pins outbound requests to an explicit policy, so a - /// loopback mock server can be reached without depending on process-wide environment. - #[cfg(test)] - fn with_policy(policy: OutboundPolicy) -> Self { - Self { - policy_override: Some(policy), - extra_root_certs: Self::extra_root_cert_cache(Vec::new()), - extra_root_ca_provider: None, - dns_resolver_override: None, - } - } - - #[cfg(test)] - fn with_policy_and_extra_root_certs(policy: OutboundPolicy, extra_root_certs: Vec) -> Self { - Self { - policy_override: Some(policy), - extra_root_certs: Self::extra_root_cert_cache(extra_root_certs), - extra_root_ca_provider: None, - dns_resolver_override: None, - } - } - - #[cfg(test)] - fn with_policy_and_extra_root_ca_provider(policy: OutboundPolicy, extra_root_ca_provider: OidcExtraRootCaProvider) -> Self { - Self { - policy_override: Some(policy), - extra_root_certs: Arc::new(RwLock::new(CachedOidcExtraRootCerts::default())), - extra_root_ca_provider: Some(extra_root_ca_provider), - dns_resolver_override: None, - } - } - - #[cfg(test)] - fn with_policy_and_dns_resolver(policy: OutboundPolicy, resolver: Arc) -> Self { - Self { - policy_override: Some(policy), - extra_root_certs: Self::extra_root_cert_cache(Vec::new()), - extra_root_ca_provider: None, - dns_resolver_override: Some(resolver), - } - } -} - -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 started_at = Instant::now(); - let (parts, body) = request.into_parts(); - let method = parts.method.clone(); - let uri = parts.uri.to_string(); - if tracing::enabled!(tracing::Level::DEBUG) { - let request_headers = format_http_headers(&parts.headers); - debug!( - event = EVENT_OIDC_HTTP, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "request", - method = %method, - uri = %uri, - request_headers = %request_headers, - request_body_len = body.len(), - "oidc outbound http" - ); - } - - let extra_root_certs = self.current_extra_root_certs().await?; - let (client, url) = build_oidc_http_client( - &uri, - self.policy_override.as_ref(), - &extra_root_certs, - #[cfg(test)] - self.dns_resolver_override.clone(), - )?; - let response = client - .request(parts.method, uri.clone()) - .headers(parts.headers) - .body(body) - .send() - .await; - - let elapsed_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; - let succeeded = response.as_ref().is_ok_and(|resp| resp.status().is_success()); - OIDC_PLUGIN_AUTHN_METRICS.record(elapsed_ms, succeeded); - - let response = response.map_err(|err| { - let error = oidc_http_error_from_reqwest(&url, err); - error!( - event = EVENT_OIDC_HTTP, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "request_failed", - method = %method, - uri = %uri, - elapsed_ms, - error = %error, - "oidc outbound http" - ); - error - })?; - - let status = response.status(); - let headers = response.headers().clone(); - let body_bytes = read_bounded_response_body(response, MAX_OIDC_RESPONSE_SIZE) - .await - .map_err(|err| { - error!( - event = EVENT_OIDC_HTTP, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "response_body_failed", - method = %method, - uri = %uri, - status = status.as_u16(), - elapsed_ms, - error = %err, - "oidc outbound http" - ); - err - })?; - if tracing::enabled!(tracing::Level::DEBUG) { - let response_headers = format_http_headers(&headers); - debug!( - event = EVENT_OIDC_HTTP, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "response", - method = %method, - uri = %uri, - status = status.as_u16(), - status_success = status.is_success(), - elapsed_ms, - response_headers = %response_headers, - response_body_len = body_bytes.len(), - "oidc outbound http" - ); - } - - let mut http_response = http::Response::builder() - .status(status) - .body(body_bytes) - .map_err(OidcHttpError::Http)?; - *http_response.headers_mut() = headers; - - Ok(http_response) - }) - } -} - -// ---- Public types ---- - -const REDACTED_SECRET: &str = "***redacted***"; - -fn redacted_optional_secret(value: Option<&str>) -> &'static str { - value.filter(|secret| !secret.is_empty()).map_or("", |_| REDACTED_SECRET) -} - -/// Parsed configuration for a single OIDC provider. -#[derive(Clone, PartialEq, Eq)] -pub struct OidcProviderConfig { - pub id: String, - pub enabled: bool, - pub config_url: String, - pub issuer: Option, - pub client_id: String, - pub client_secret: Option, - pub scopes: Vec, - pub other_audiences: 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 roles_claim: String, - pub email_claim: String, - pub username_claim: String, - pub hide_from_ui: bool, -} - -impl fmt::Debug for OidcProviderConfig { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("OidcProviderConfig") - .field("id", &self.id) - .field("enabled", &self.enabled) - .field("config_url", &self.config_url) - .field("issuer", &self.issuer) - .field("client_id", &self.client_id) - .field("client_secret", &redacted_optional_secret(self.client_secret.as_deref())) - .field("scopes", &self.scopes) - .field("other_audiences", &self.other_audiences) - .field("redirect_uri", &self.redirect_uri) - .field("redirect_uri_dynamic", &self.redirect_uri_dynamic) - .field("claim_name", &self.claim_name) - .field("claim_prefix", &self.claim_prefix) - .field("role_policy", &self.role_policy) - .field("display_name", &self.display_name) - .field("groups_claim", &self.groups_claim) - .field("roles_claim", &self.roles_claim) - .field("email_claim", &self.email_claim) - .field("username_claim", &self.username_claim) - .field("hide_from_ui", &self.hide_from_ui) - .finish() - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum OidcProviderConfigSource { - Env, - Persisted, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SourcedOidcProviderConfig { - pub config: OidcProviderConfig, - pub source: OidcProviderConfigSource, -} - -/// Immutable OIDC configuration entries returned by configuration queries. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OidcConfigSnapshot { - providers: Vec, -} - -impl OidcConfigSnapshot { - pub fn new(mut providers: Vec) -> Self { - providers.sort_by(|left, right| { - (left.config.id != "default") - .cmp(&(right.config.id != "default")) - .then_with(|| left.config.id.cmp(&right.config.id)) - }); - Self { providers } - } - - pub fn providers(&self) -> &[SourcedOidcProviderConfig] { - &self.providers - } - - pub fn into_providers(self) -> Vec { - self.providers - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct OidcProviderValidationResult { - pub issuer: String, - pub authorization_endpoint: Option, - pub token_endpoint: Option, -} - -/// Compatibility name for the provider summary returned by OIDC list APIs. -pub type OidcProviderSummary = crate::federation::FederatedProviderView; - -/// 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. -#[derive(Clone)] -struct ProviderState { - metadata: DiscoveredProviderMetadata, - discovered_at: Instant, -} - -// Workload issuers do not implement the browser authorization flow. Keep their -// verification metadata separate rather than inventing an authorization URL. -#[serde_with::serde_as] -#[derive(Clone, Deserialize)] -struct WorkloadProviderMetadata { - issuer: IssuerUrl, - jwks_uri: JsonWebKeySetUrl, - token_endpoint: Option, - #[serde_as(as = "serde_with::VecSkipError<_>")] - id_token_signing_alg_values_supported: Vec, - #[serde(skip)] - jwks: CoreJsonWebKeySet, - // Discovery is extensible; report unsupported fields without logging values. - #[serde(flatten)] - additional_fields: HashMap, -} - -#[derive(Clone)] -enum DiscoveredProviderMetadata { - Console(Box), - Workload(Box), -} - -impl DiscoveredProviderMetadata { - fn parse(body: &[u8], hide_from_ui: bool) -> Result { - let document: serde_json::Value = serde_json::from_slice(body).map_err(|err| err.to_string())?; - if hide_from_ui - && document - .as_object() - .is_some_and(|fields| !fields.contains_key("authorization_endpoint")) - { - let mut metadata: WorkloadProviderMetadata = serde_json::from_slice(body).map_err(|err| err.to_string())?; - if !metadata.additional_fields.is_empty() { - warn!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "workload_discovery_additional_fields", - field_count = metadata.additional_fields.len(), - "workload discovery contains additional fields" - ); - metadata.additional_fields.clear(); - } - Ok(Self::Workload(Box::new(metadata))) - } else { - serde_json::from_slice(body) - .map(|metadata| Self::Console(Box::new(metadata))) - .map_err(|err| err.to_string()) - } - } - - fn console(&self) -> Result<&ProviderMetadataWithLogout, String> { - match self { - Self::Console(metadata) => Ok(metadata), - Self::Workload(_) => Err("OIDC provider has no authorization endpoint; only web identity is supported".into()), - } - } - - fn issuer(&self) -> &IssuerUrl { - match self { - Self::Console(metadata) => metadata.issuer(), - Self::Workload(metadata) => &metadata.issuer, - } - } - - fn jwks_uri(&self) -> &JsonWebKeySetUrl { - match self { - Self::Console(metadata) => metadata.jwks_uri(), - Self::Workload(metadata) => &metadata.jwks_uri, - } - } - - fn set_jwks(self, jwks: CoreJsonWebKeySet) -> Self { - match self { - Self::Console(metadata) => Self::Console(Box::new(metadata.set_jwks(jwks))), - Self::Workload(mut metadata) => { - metadata.jwks = jwks; - Self::Workload(metadata) - } - } - } - - fn authorization_endpoint(&self) -> Option { - match self { - Self::Console(metadata) => Some(metadata.authorization_endpoint().to_string()), - Self::Workload(_) => None, - } - } - - fn token_endpoint(&self) -> Option<&TokenUrl> { - match self { - Self::Console(metadata) => metadata.token_endpoint(), - Self::Workload(metadata) => metadata.token_endpoint.as_ref(), - } - } - - fn verifier(&self, config: &OidcProviderConfig) -> CoreIdTokenVerifier<'static> { - let client_id = ClientId::new(config.client_id.clone()); - let secret = config.client_secret.as_ref().map(|secret| ClientSecret::new(secret.clone())); - let (issuer, jwks, algorithms) = match self { - Self::Console(metadata) => (metadata.issuer(), metadata.jwks(), metadata.id_token_signing_alg_values_supported()), - Self::Workload(metadata) => (&metadata.issuer, &metadata.jwks, &metadata.id_token_signing_alg_values_supported), - }; - let verifier = match secret { - Some(secret) => CoreIdTokenVerifier::new_confidential_client(client_id, secret, issuer.clone(), jwks.clone()), - None => CoreIdTokenVerifier::new_public_client(client_id, issuer.clone(), jwks.clone()), - }; - verifier.set_allowed_algs(algorithms.clone()) - } -} - -// This adapter is used only for discovery/JWKS fetches, never token exchange. -struct JwksAcceptClient<'a> { - inner: &'a ReqwestHttpClient, - discovery_url: Option, -} - -impl<'c> AsyncHttpClient<'c> for JwksAcceptClient<'_> { - type Error = OidcHttpError; - type Future = >::Future; - - fn call(&'c self, mut request: http::Request>) -> Self::Future { - if !self.discovery_url.as_ref().is_some_and(|url| request.uri() == url.as_str()) { - request.headers_mut().insert( - http::header::ACCEPT, - http::HeaderValue::from_static("application/json, application/jwk-set+json"), - ); - } - self.inner.call(request) - } -} - -impl ProviderState { - fn is_stale(&self) -> bool { - self.discovered_at.elapsed() >= OIDC_JWKS_REFRESH_INTERVAL - } -} - -// ---- Core OIDC system ---- - -/// Global OIDC manager for all configured providers. -pub struct OidcSys { - configs: HashMap, - provider_states: RwLock>, - state_store: OidcStateStore, - http_client: ReqwestHttpClient, -} - -fn trusted_aud(other_audiences: &[String], audience: &Audience) -> bool { - for aud in other_audiences { - if audience.as_str() == aud.as_str() { - return true; - } - } - false -} - -impl OidcSys { - /// Parse environment variables and discover all configured OIDC providers. - pub async fn new() -> Result { - Self::new_with_extra_root_ca(None).await - } - - /// Parse environment variables and discover providers with an additional outbound root CA bundle. - pub(crate) async fn new_with_extra_root_ca(root_ca_pem: Option<&[u8]>) -> Result { - let http_client = ReqwestHttpClient::new_with_extra_root_certs(oidc_extra_root_certs(root_ca_pem)?)?; - Self::new_with_http_client(http_client).await - } - - pub(crate) async fn new_with_extra_root_ca_provider(extra_root_ca_provider: OidcExtraRootCaProvider) -> Result { - let http_client = ReqwestHttpClient::new_with_extra_root_ca_provider(extra_root_ca_provider)?; - http_client.current_extra_root_certs().await.map_err(|err| err.to_string())?; - Self::new_with_http_client(http_client).await - } - - async fn new_with_http_client(http_client: ReqwestHttpClient) -> Result { - let server_config = crate::server_config::current_server_config(); - let parsed_configs = load_oidc_config_snapshot(server_config.as_ref()); - let mut configs = HashMap::new(); - let mut provider_states = HashMap::new(); - - for sourced_config in parsed_configs.into_providers() { - let config = &sourced_config.config; - if !config.enabled { - debug!(provider = %config.id, "OIDC provider disabled"); - continue; - } - - match Self::discover_provider(config, &http_client).await { - Ok(state) => { - debug!(provider = %config.id, "OIDC provider discovered"); - provider_states.insert(config.id.clone(), state); - configs.insert(config.id.clone(), sourced_config); - } - Err(e) => { - error!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "provider_discovery_failed", - provider_id = %config.id, - config_url = %config.config_url, - client_id = %config.client_id, - scopes = ?config.scopes, - redirect_uri = %config.redirect_uri.as_deref().unwrap_or(""), - redirect_uri_dynamic = config.redirect_uri_dynamic, - error = %e, - "oidc provider discovery failed" - ); - } - } - } - - Ok(Self { - configs, - provider_states: RwLock::new(provider_states), - state_store: OidcStateStore::new(), - http_client, - }) - } - - /// Create an OidcSys with no providers (useful for when OIDC is not configured). - pub fn empty() -> Result { - Ok(Self { - configs: HashMap::new(), - provider_states: RwLock::new(HashMap::new()), - state_store: OidcStateStore::new(), - http_client: ReqwestHttpClient::new()?, - }) - } - - /// Return true if any OIDC providers are configured and enabled. - pub fn has_providers(&self) -> bool { - !self.configs.is_empty() - } - - pub(crate) fn provider_configs(&self) -> impl Iterator { - self.configs.values().map(|provider| &provider.config) - } - - /// List all providers, including providers hidden from the login UI. - pub fn list_providers(&self) -> Vec { - self.provider_configs() - .map(|config| OidcProviderSummary { - provider_id: config.id.clone(), - display_name: config.display_name.clone(), - }) - .collect() - } - - /// List providers visible in the login UI. - pub fn list_visible_providers(&self) -> Vec { - self.provider_configs() - .filter(|config| !config.hide_from_ui) - .map(|config| OidcProviderSummary { - provider_id: config.id.clone(), - display_name: config.display_name.clone(), - }) - .collect() - } - - pub fn config_snapshot(&self) -> OidcConfigSnapshot { - OidcConfigSnapshot::new(self.configs.values().cloned().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 - .get_provider_config(provider_id) - .ok_or_else(|| format!("unknown OIDC provider: {provider_id}"))?; - let state = self.ensure_provider_state(provider_id, config).await?; - - 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 client = CoreClient::from_provider_metadata( - state.metadata.console()?.clone(), - ClientId::new(config.client_id.clone()), - config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())), - ) - .set_auth_type(AuthType::RequestBody); - - 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), 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 - .get_provider_config(&session.provider_id) - .ok_or_else(|| format!("unknown provider: {}", session.provider_id))?; - let provider_state = self.get_provider_state(&session.provider_id)?; - let issuer = provider_state.metadata.issuer().to_string(); - let token_endpoint = provider_state - .metadata - .token_endpoint() - .map(ToString::to_string) - .unwrap_or_default(); - - // Construct CoreClient on-the-fly with JWKS from discovery - let client = CoreClient::from_provider_metadata( - provider_state.metadata.console()?.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| { - error!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "token_endpoint_missing", - provider_id = %session.provider_id, - config_url = %config.config_url, - issuer = %issuer, - client_id = %config.client_id, - redirect_uri = %redirect_uri, - scopes = ?config.scopes, - error = %e, - "oidc token exchange failed" - ); - format!( - "token endpoint not configured: {e}: provider_id={}, config_url={}, issuer={}, redirect_uri={}, client_id={}", - session.provider_id, config.config_url, issuer, redirect_uri, config.client_id - ) - })? - .set_pkce_verifier(PkceCodeVerifier::new(session.pkce_verifier.clone())) - .set_redirect_uri(Cow::Owned(redirect)) - .request_async(&self.http_client) - .await - .map_err(|e| match &e { - RequestTokenError::ServerResponse(response) => { - error!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "token_server_response", - provider_id = %session.provider_id, - config_url = %config.config_url, - issuer = %issuer, - token_endpoint = %token_endpoint, - client_id = %config.client_id, - client_secret_configured = config.client_secret.as_deref().is_some_and(|secret| !secret.is_empty()), - redirect_uri = %redirect_uri, - scopes = ?config.scopes, - oauth_error = %response.error(), - oauth_error_description = %response.error_description().map(String::as_str).unwrap_or(""), - oauth_error_uri = %response.error_uri().map(String::as_str).unwrap_or(""), - error = %e, - "oidc token exchange failed" - ); - format!( - "token exchange failed: {e}: stage=token_server_response, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, oauth_error={}, oauth_error_description={}", - session.provider_id, - config.config_url, - issuer, - token_endpoint, - redirect_uri, - config.client_id, - response.error(), - response.error_description().map(String::as_str).unwrap_or("") - ) - } - RequestTokenError::Request(err) => { - let (request_error_kind, request_error_status) = oidc_http_error_diagnostics(err); - error!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "token_request_failed", - provider_id = %session.provider_id, - config_url = %config.config_url, - issuer = %issuer, - token_endpoint = %token_endpoint, - client_id = %config.client_id, - redirect_uri = %redirect_uri, - request_error_kind = %request_error_kind, - request_error_status = %request_error_status, - error = %err, - "oidc token exchange failed" - ); - format!( - "token exchange failed: stage=token_request_failed, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, request_error_kind={}, request_error_status={}, request_error={}", - session.provider_id, - config.config_url, - issuer, - token_endpoint, - redirect_uri, - config.client_id, - request_error_kind, - request_error_status, - err - ) - } - RequestTokenError::Parse(parse_err, body) => { - let shape = inspect_token_response_body(body); - error!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "token_response_parse_failed", - provider_id = %session.provider_id, - config_url = %config.config_url, - issuer = %issuer, - token_endpoint = %token_endpoint, - client_id = %config.client_id, - redirect_uri = %redirect_uri, - parse_error_path = %parse_err.path(), - response_body_len = body.len(), - response_json_object = shape.json_object, - response_json_keys = %shape.json_keys, - response_has_access_token = shape.has_access_token, - response_has_id_token = shape.has_id_token, - response_has_token_type = shape.has_token_type, - response_has_expires_in = shape.has_expires_in, - response_has_error = shape.has_error, - response_has_error_description = shape.has_error_description, - response_looks_like_html = shape.looks_like_html, - error = %e, - "oidc token exchange failed" - ); - format!( - "token exchange failed: {e}: stage=token_response_parse_failed, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, parse_error_path={}, response_body_len={}, response_json_keys={}, response_has_id_token={}, response_has_error={}, response_looks_like_html={}", - session.provider_id, - config.config_url, - issuer, - token_endpoint, - redirect_uri, - config.client_id, - parse_err.path(), - body.len(), - shape.json_keys, - shape.has_id_token, - shape.has_error, - shape.looks_like_html - ) - } - RequestTokenError::Other(message) => { - error!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "token_exchange_other_error", - provider_id = %session.provider_id, - config_url = %config.config_url, - issuer = %issuer, - token_endpoint = %token_endpoint, - client_id = %config.client_id, - redirect_uri = %redirect_uri, - error = %message, - "oidc token exchange failed" - ); - format!( - "token exchange failed: {e}: stage=token_exchange_other_error, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}", - session.provider_id, config.config_url, issuer, token_endpoint, redirect_uri, config.client_id - ) - } - })?; - - // Verify the ID token (signature, issuer, audience, expiry, nonce) - let id_token = token_response - .extra_fields() - .id_token() - .ok_or_else(|| { - error!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "token_response_missing_id_token", - provider_id = %session.provider_id, - config_url = %config.config_url, - issuer = %issuer, - token_endpoint = %token_endpoint, - client_id = %config.client_id, - redirect_uri = %redirect_uri, - scopes = ?config.scopes, - "oidc token exchange failed" - ); - format!( - "no id_token in token response: provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, scopes={}", - session.provider_id, - config.config_url, - issuer, - token_endpoint, - redirect_uri, - config.client_id, - config.scopes.join(",") - ) - })?; - - let verifier = client - .id_token_verifier() - .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); - let verified = id_token.claims(&verifier, &Nonce::new(session.nonce.clone())); - if let Err(e) = verified { - let verification_error = e.to_string(); - warn!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "id_token_verification_retry", - provider_id = %session.provider_id, - config_url = %config.config_url, - issuer = %issuer, - token_endpoint = %token_endpoint, - client_id = %config.client_id, - other_audiences = ?config.other_audiences, - error = %verification_error, - "oidc id token verification failed" - ); - let refreshed_state = self - .refresh_provider_state(&session.provider_id, config) - .await - .map_err(|refresh_err| { - format!( - "ID token verification failed: {verification_error}; failed to refresh provider metadata: {refresh_err}" - ) - })?; - - warn!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "jwks_metadata_refreshed", - provider_id = %session.provider_id, - config_url = %config.config_url, - issuer = %issuer, - "oidc provider metadata refreshed" - ); - - let client = CoreClient::from_provider_metadata( - refreshed_state.metadata.console()?.clone(), - ClientId::new(config.client_id.clone()), - config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())), - ) - .set_auth_type(AuthType::RequestBody); - - let verifier = client - .id_token_verifier() - .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); - id_token - .claims(&verifier, &Nonce::new(session.nonce.clone())) - .map_err(|retry_err| { - error!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "id_token_verification_failed", - provider_id = %session.provider_id, - config_url = %config.config_url, - issuer = %issuer, - token_endpoint = %token_endpoint, - client_id = %config.client_id, - other_audiences = ?config.other_audiences, - original_error = %verification_error, - retry_error = %retry_err, - "oidc id token verification failed" - ); - format!( - "ID token verification failed after JWKS refresh: {retry_err}; original_error={verification_error}; provider_id={}, config_url={}, issuer={}, token_endpoint={}, client_id={}, other_audiences={}", - session.provider_id, - config.config_url, - issuer, - token_endpoint, - config.client_id, - config.other_audiences.join(",") - ) - })?; - } - - // 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 = id_token.to_string(); - 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_canonical_group_values(&raw, &config.groups_claim, &config.roles_claim), - raw, - }; - - Ok((claims, session.provider_id.clone(), session, raw_jwt)) - } - - /// Store a one-time logout session keyed by an opaque token so the console can - /// trigger browser logout without persisting the raw ID token. - pub async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result { - if !self.configs.contains_key(provider_id) { - return Err(format!("unknown OIDC provider: {provider_id}")); - } - - let token = CsrfToken::new_random().secret().clone(); - self.state_store - .insert_logout( - token.clone(), - OidcLogoutSession { - provider_id: provider_id.to_string(), - id_token: id_token.to_string(), - }, - ) - .await; - - Ok(token) - } - - /// Build the RP-initiated logout URL for a previously issued logout token. - /// Returns `Ok(None)` when the provider does not advertise an end-session endpoint. - pub async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result, String> { - let session = self - .state_store - .take_logout(logout_token) - .await - .ok_or_else(|| "invalid or expired OIDC logout token".to_string())?; - - let config = self - .get_provider_config(&session.provider_id) - .ok_or_else(|| format!("unknown OIDC provider: {}", session.provider_id))?; - let state = self.ensure_provider_state(&session.provider_id, config).await?; - let Some(end_session_endpoint) = state.metadata.console()?.additional_metadata().end_session_endpoint.clone() else { - return Ok(None); - }; - - let id_token: CoreIdToken = session - .id_token - .parse() - .map_err(|e: serde_json::Error| format!("failed to parse ID token for logout: {e}"))?; - let post_logout_redirect_uri = PostLogoutRedirectUrl::new(post_logout_redirect_uri.to_string()) - .map_err(|e| format!("invalid post logout redirect URI: {e}"))?; - - let logout_url = LogoutRequest::from(end_session_endpoint) - .set_id_token_hint(&id_token) - .set_client_id(ClientId::new(config.client_id.clone())) - .set_post_logout_redirect_uri(post_logout_redirect_uri) - .http_get_url() - .to_string(); - - Ok(Some(logout_url)) - } - - /// Map OIDC claims to rustfs policy names. - pub fn map_claims_to_policies(&self, provider_id: &str, claims: &OidcClaims) -> (Vec, Vec) { - let Some(rule) = self.authorization_rule(provider_id) else { - return (Vec::new(), Vec::new()); - }; - CoreFederatedAuthorizationMapper::map_policies_and_groups(provider_id, rule, &claims.groups, &claims.raw, true) - } - - /// Policy names produced only by the canonical groups in claim-based mode: the groups claim - /// plus the roles claim values that `extract_canonical_group_values` merges into it. - /// - /// Identity providers emit built-in groups and roles that can never have a matching policy - /// (for example `DOMAIN\Domain Users` or Keycloak's `offline_access`), so the session binding - /// may ignore these names when no such policy exists. Names from a fixed role policy or a - /// dedicated policy claim are never included, so those configurations keep requiring every - /// policy to resolve. - pub fn group_claim_policy_names(&self, provider_id: &str, claims: &OidcClaims) -> Vec { - let Some(rule) = self.authorization_rule(provider_id) else { - return Vec::new(); - }; - CoreFederatedAuthorizationMapper::group_claim_policy_names(rule, &claims.groups, &claims.raw) - } - - fn authorization_rule(&self, provider_id: &str) -> Option> { - self.get_provider_config(provider_id).map(|config| { - FederatedAuthorizationRuleRef::new( - &config.claim_name, - &config.claim_prefix, - &config.role_policy, - &config.groups_claim, - &config.roles_claim, - ) - }) - } - - /// 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, mut state) = self - .find_provider_by_issuer(issuer) - .ok_or_else(|| format!("no OIDC provider configured for issuer: {issuer}"))?; - - state = self.ensure_provider_state_if_stale(&provider_id, &config, &state).await?; - - // 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 = state - .metadata - .verifier(&config) - .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); - if let Err(e) = id_token.claims(&verifier, |_: Option<&Nonce>| Ok(())) { - state = self - .refresh_provider_state(&provider_id, &config) - .await - .map_err(|refresh_err| { - format!("ID token verification failed: {e}; failed to refresh provider metadata: {refresh_err}") - })?; - - let verifier = state - .metadata - .verifier(&config) - .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); - id_token - .claims(&verifier, |_: Option<&Nonce>| Ok(())) - .map_err(|retry_err| format!("ID token verification failed after JWKS refresh: {retry_err}"))?; - } - - // 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_canonical_group_values(&raw_claims, &config.groups_claim, &config.roles_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<(String, OidcProviderConfig, ProviderState)> { - let (issuer_scheme, issuer_host, issuer_port, issuer_path) = normalize_issuer(issuer)?; - let map = self - .provider_states - .read() - .map_err(|e| format!("provider state lock poisoned: {e}")) - .ok()?; - for (id, state) in map.iter() { - let provider_issuer = state.metadata.issuer().as_str(); - let Some((provider_scheme, provider_host, provider_port, provider_path)) = normalize_issuer(provider_issuer) else { - continue; - }; - - if issuer_scheme == provider_scheme - && issuer_host == provider_host - && issuer_port == provider_port - && issuer_path == provider_path - && let Some(config) = self.get_provider_config(id) - { - return Some((id.clone(), config.clone(), state.clone())); - } - } - None - } - - fn get_provider_state(&self, provider_id: &str) -> Result { - self.provider_states - .read() - .map_err(|e| format!("provider state lock poisoned: {e}"))? - .get(provider_id) - .cloned() - .ok_or_else(|| format!("provider not discovered: {provider_id}")) - } - - async fn refresh_provider_state(&self, provider_id: &str, config: &OidcProviderConfig) -> Result { - let state = Self::discover_provider(config, &self.http_client).await?; - let mut map = self.provider_states.write().map_err(|e| { - let msg = e.to_string(); - format!("provider state lock poisoned: {msg}") - })?; - map.insert(provider_id.to_string(), state.clone()); - - Ok(state) - } - - async fn ensure_provider_state(&self, provider_id: &str, config: &OidcProviderConfig) -> Result { - let state = self.get_provider_state(provider_id)?; - if state.is_stale() { - self.refresh_provider_state(provider_id, config).await.or_else(|refresh_err| { - warn!( - "OIDC provider '{}' JWKS metadata refresh skipped due to transient network issue: {}", - provider_id, refresh_err - ); - Ok(state) - }) - } else { - Ok(state) - } - } - - async fn ensure_provider_state_if_stale( - &self, - provider_id: &str, - config: &OidcProviderConfig, - state: &ProviderState, - ) -> Result { - if state.is_stale() { - self.refresh_provider_state(provider_id, config).await.or_else(|refresh_err| { - warn!( - "OIDC provider '{}' JWKS metadata refresh skipped due to transient network issue: {}", - provider_id, refresh_err - ); - Ok(state.clone()) - }) - } else { - Ok(state.clone()) - } - } - - /// 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).map(|provider| &provider.config) - } - - /// 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 - } - - fn parse_persisted_configs(cfg: &ServerConfig) -> Vec { - let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else { - return Vec::new(); - }; - - let mut configs = Vec::new(); - let mut provider_ids: Vec = subsystem.keys().cloned().collect(); - provider_ids.sort(); - - for raw_id in provider_ids { - let Some(kvs) = subsystem.get(&raw_id) else { - continue; - }; - - let id = if raw_id == DEFAULT_DELIMITER { - "default" - } else { - raw_id.as_str() - }; - if let Some(config) = Self::parse_single_persisted_provider(kvs, id) { - configs.push(config); - } - } - - configs - } - - /// Parse a string as an `EnableState` boolean. - /// Returns `default_if_empty` when the input is empty, and `default_on_error` - /// when parsing fails. - fn parse_enable_state(value: &str, default_if_empty: bool, default_on_error: bool) -> bool { - if value.is_empty() { - return default_if_empty; - } - value - .parse::() - .map(|s| s.is_enabled()) - .unwrap_or(default_on_error) - } - - /// 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); - let issuer = get_env(ENV_IDENTITY_OPENID_ISSUER); - - // Skip if no config URL - if config_url.is_empty() { - return None; - } - - let enabled = Self::parse_enable_state(&enable_val, true, 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 other_audiences_str = get_env(ENV_IDENTITY_OPENID_OTHER_AUDIENCES); - let other_audiences = other_audiences_str - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); - - let redirect_uri_dynamic = Self::parse_enable_state(&get_env(ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC), true, 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 roles_claim = get_env(ENV_IDENTITY_OPENID_ROLES_CLAIM); - 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) } - }; - let hide_from_ui = Self::parse_enable_state(&get_env(ENV_IDENTITY_OPENID_HIDE_FROM_UI), false, false); - - Some(OidcProviderConfig { - id: id.to_string(), - enabled, - config_url, - issuer: if issuer.is_empty() { None } else { Some(issuer) }, - client_id: get_env(ENV_IDENTITY_OPENID_CLIENT_ID), - client_secret, - scopes, - other_audiences, - 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, - roles_claim, - email_claim, - username_claim, - hide_from_ui, - }) - } - - fn parse_single_persisted_provider(kvs: &KVS, id: &str) -> Option { - let config_url = kvs.get(OIDC_CONFIG_URL); - if config_url.is_empty() { - return None; - } - - let enabled = Self::parse_enable_state(&kvs.lookup(ENABLE_KEY).unwrap_or_default(), false, false); - - let scopes_str = kvs.get(OIDC_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 other_audiences_str = kvs.get(OIDC_OTHER_AUDIENCES); - let other_audiences = other_audiences_str - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); - - let redirect_uri_dynamic = - Self::parse_enable_state(&kvs.lookup(OIDC_REDIRECT_URI_DYNAMIC).unwrap_or_default(), true, true); - - let claim_name = kvs - .lookup(OIDC_CLAIM_NAME) - .unwrap_or_else(|| OIDC_DEFAULT_CLAIM_NAME.to_string()); - let groups_claim = kvs - .lookup(OIDC_GROUPS_CLAIM) - .unwrap_or_else(|| OIDC_DEFAULT_GROUPS_CLAIM.to_string()); - let roles_claim = kvs - .lookup(OIDC_ROLES_CLAIM) - .unwrap_or_else(|| OIDC_DEFAULT_ROLES_CLAIM.to_string()); - let email_claim = kvs - .lookup(OIDC_EMAIL_CLAIM) - .unwrap_or_else(|| OIDC_DEFAULT_EMAIL_CLAIM.to_string()); - let username_claim = kvs - .lookup(OIDC_USERNAME_CLAIM) - .unwrap_or_else(|| OIDC_DEFAULT_USERNAME_CLAIM.to_string()); - let display_name = kvs.lookup(OIDC_DISPLAY_NAME).unwrap_or_else(|| id.to_string()); - let redirect_uri = kvs.lookup(OIDC_REDIRECT_URI).filter(|v| !v.is_empty()); - let client_secret = kvs.lookup(OIDC_CLIENT_SECRET).filter(|v| !v.is_empty()); - let hide_from_ui = Self::parse_enable_state(&kvs.lookup(OIDC_HIDE_FROM_UI).unwrap_or_default(), false, false); - - Some(OidcProviderConfig { - id: id.to_string(), - enabled, - config_url, - issuer: kvs.lookup(OIDC_ISSUER).filter(|v| !v.is_empty()), - client_id: kvs.get(OIDC_CLIENT_ID), - client_secret, - scopes, - other_audiences, - redirect_uri, - redirect_uri_dynamic, - claim_name, - claim_prefix: kvs.get(OIDC_CLAIM_PREFIX), - role_policy: kvs.get(OIDC_ROLE_POLICY), - display_name, - groups_claim, - roles_claim, - email_claim, - username_claim, - hide_from_ui, - }) - } - - /// 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 { - if let Some(issuer) = config.issuer.as_deref().filter(|issuer| !issuer.trim().is_empty()) { - return Self::discover_provider_from_config_url(config, issuer, http_client).await; - } - - // The openidconnect crate expects the issuer URL (base), not the - // .well-known/openid-configuration URL. - let base_issuer = normalize_config_url(&config.config_url)?; - let candidates = issuer_candidates(&base_issuer); - let mut last_errors = Vec::new(); - - for candidate_issuer in candidates.iter() { - let issuer_url = IssuerUrl::new(candidate_issuer.clone()).map_err(|e| format!("invalid issuer URL: {e}"))?; - - for attempt in 0..OIDC_DISCOVERY_TRANSPORT_RETRIES { - let discovered = if config.hide_from_ui { - Self::discover_provider_from_config_url(config, candidate_issuer, http_client).await - } else { - let client = JwksAcceptClient { - inner: http_client, - discovery_url: Some( - issuer_url - .join(".well-known/openid-configuration") - .map_err(|err| err.to_string())?, - ), - }; - ProviderMetadataWithLogout::discover_async(issuer_url.clone(), &client) - .await - .map(|metadata| ProviderState { - metadata: DiscoveredProviderMetadata::Console(Box::new(metadata)), - discovered_at: Instant::now(), - }) - .map_err(|err| match err { - DiscoveryError::Request(OidcHttpError::ForbiddenOutbound(reason)) => { - format!("{OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY}: {reason}") - } - err => format!("discovery failed: {err}"), - }) - }; - match discovered { - Ok(state) => return Ok(state), - Err(error) - if error.starts_with(OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY) - || error.starts_with(OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY) => - { - return Err(error); - } - Err(error) => { - let is_transient_transport = error.contains("Request failed"); - let should_retry = is_transient_transport && attempt + 1 < OIDC_DISCOVERY_TRANSPORT_RETRIES; - if should_retry { - warn!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "provider_discovery_transport_retry", - provider_id = %config.id, - config_url = %config.config_url, - issuer_candidate = %candidate_issuer, - attempt = attempt + 1, - max_attempts = OIDC_DISCOVERY_TRANSPORT_RETRIES, - error = %error, - "oidc provider discovery failed" - ); - sleep(OIDC_DISCOVERY_TRANSPORT_RETRY_DELAY).await; - continue; - } - - last_errors.push(format!("issuer '{candidate_issuer}': {error}")); - warn!( - event = EVENT_OIDC_DIAGNOSTICS, - component = LOG_COMPONENT_IAM, - subsystem = LOG_SUBSYSTEM_OIDC, - result = "provider_discovery_candidate_failed", - provider_id = %config.id, - config_url = %config.config_url, - issuer_candidate = %candidate_issuer, - attempt = attempt + 1, - max_attempts = OIDC_DISCOVERY_TRANSPORT_RETRIES, - error = %error, - "oidc provider discovery failed" - ); - break; - } - } - } - } - - Err(format!( - "discovery failed for all issuer variants {:?}: {}", - candidates, - last_errors.join("; ") - )) - } - - async fn discover_provider_from_config_url( - config: &OidcProviderConfig, - issuer: &str, - http_client: &ReqwestHttpClient, - ) -> Result { - let issuer_url = IssuerUrl::new(issuer.trim().to_string()).map_err(|e| format!("invalid issuer URL: {e}"))?; - let explicit_issuer = config.issuer.as_deref().is_some_and(|issuer| !issuer.trim().is_empty()); - let discovery_url = if explicit_issuer { - discovery_url_from_config_url(&config.config_url)? - } else { - issuer_url - .join(".well-known/openid-configuration") - .map_err(|err| err.to_string())? - }; - let request = http::Request::builder() - .uri(discovery_url.to_string()) - .method(http::Method::GET) - .header(http::header::ACCEPT, "application/json") - .body(Vec::new()) - .map_err(|err| format!("failed to prepare discovery request: {err}"))?; - - let response = match http_client.call(request).await { - Ok(response) => response, - Err(OidcHttpError::ForbiddenOutbound(reason)) => { - return Err(format!("{OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY}: {reason}")); - } - Err(err) => return Err(format!("discovery request failed: Request failed: {err}")), - }; - if response.status() != http::StatusCode::OK { - return Err(format!("discovery failed: HTTP status code {} at {}", response.status(), discovery_url)); - } - - if !explicit_issuer - && let Some(content_type) = response.headers().get(http::header::CONTENT_TYPE) - && !content_type.to_str().ok().is_some_and(|value| { - value - .split(';') - .next() - .is_some_and(|essence| essence.eq_ignore_ascii_case("application/json")) - }) - { - return Err("Unexpected response Content-Type: expected application/json".into()); - } - - let provider_metadata = DiscoveredProviderMetadata::parse(response.body(), config.hide_from_ui) - .map_err(|err| format!("failed to parse discovery response: {err}"))?; - if provider_metadata.issuer() != &issuer_url { - return Err(format!( - "unexpected issuer URI `{}` (expected `{}`)", - provider_metadata.issuer().as_str(), - issuer_url.as_str() - )); - } - - let jwks_url = if explicit_issuer { - jwks_url_from_config_url(&config.config_url, &issuer_url, provider_metadata.jwks_uri())? - } else { - provider_metadata.jwks_uri().clone() - }; - let jwks = match CoreJsonWebKeySet::fetch_async( - &jwks_url, - &JwksAcceptClient { - inner: http_client, - discovery_url: None, - }, - ) - .await - { - Ok(jwks) => jwks, - Err(DiscoveryError::Request(OidcHttpError::ForbiddenOutbound(reason))) => { - return Err(format!("{OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY}: {reason}")); - } - Err(err) => return Err(format!("failed to fetch JWKS: {err}")), - }; - - Ok(ProviderState { - metadata: provider_metadata.set_jwks(jwks), - discovered_at: Instant::now(), - }) - } -} - -pub fn load_oidc_provider_configs_from_env() -> Vec { - OidcSys::parse_env_configs() -} - -pub fn load_oidc_provider_configs_from_server_config(cfg: &ServerConfig) -> Vec { - OidcSys::parse_persisted_configs(cfg) -} - -pub fn merge_oidc_provider_configs( - env_configs: Vec, - persisted_configs: Vec, -) -> Vec { - let mut effective = HashMap::new(); - - for config in persisted_configs { - effective.insert( - config.id.clone(), - SourcedOidcProviderConfig { - config, - source: OidcProviderConfigSource::Persisted, - }, - ); - } - - for config in env_configs { - effective.insert( - config.id.clone(), - SourcedOidcProviderConfig { - config, - source: OidcProviderConfigSource::Env, - }, - ); - } - - let mut configs: Vec = effective.into_values().collect(); - configs.sort_by(|lhs, rhs| lhs.config.id.cmp(&rhs.config.id)); - configs -} - -pub fn load_effective_oidc_provider_configs(server_config: Option<&ServerConfig>) -> Vec { - let env_configs = load_oidc_provider_configs_from_env(); - let persisted_configs = server_config - .map(load_oidc_provider_configs_from_server_config) - .unwrap_or_default(); - merge_oidc_provider_configs(env_configs, persisted_configs) -} - -pub fn load_oidc_config_snapshot(server_config: Option<&ServerConfig>) -> OidcConfigSnapshot { - OidcConfigSnapshot::new(load_effective_oidc_provider_configs(server_config)) -} - -pub async fn validate_oidc_provider_config(config: &OidcProviderConfig) -> Result { - validate_oidc_provider_config_with_extra_root_ca(config, None).await -} - -pub async fn validate_oidc_provider_config_with_extra_root_ca( - config: &OidcProviderConfig, - root_ca_pem: Option<&[u8]>, -) -> Result { - let http_client = ReqwestHttpClient::new_with_extra_root_certs(oidc_extra_root_certs(root_ca_pem)?)?; - let state = OidcSys::discover_provider(config, &http_client).await?; - - Ok(OidcProviderValidationResult { - issuer: state.metadata.issuer().to_string(), - authorization_endpoint: state.metadata.authorization_endpoint(), - token_endpoint: state.metadata.token_endpoint().map(ToString::to_string), - }) -} - -// --- Helper functions --- - -fn normalize_issuer(raw: &str) -> Option<(String, String, u16, String)> { - let parsed = Url::parse(raw).ok()?; - if parsed.scheme() != "http" && parsed.scheme() != "https" { - return None; - } - - let host = parsed.host_str()?.to_ascii_lowercase(); - let port = parsed.port_or_known_default()?; - let normalized_path = { - let path = parsed.path().trim_end_matches('/').to_string(); - if path.is_empty() { "/".to_string() } else { path } - }; - - Some((parsed.scheme().to_string(), host, port, normalized_path)) -} - -fn normalize_config_url(config_url: &str) -> Result { - let config_url = config_url.trim(); - let url = Url::parse(config_url).map_err(|e| format!("invalid config_url: {e}"))?; - if url.scheme() != "http" && url.scheme() != "https" { - return Err(format!("invalid config_url scheme: {}", url.scheme())); - } - let host = url.host_str().ok_or_else(|| "config_url missing host".to_string())?; - let path = url.path(); - - // Strip `/.well-known/openid-configuration` (with optional trailing slash) if present. - // Everything else is preserved exactly so the issuer URL matches the provider's discovery - // document (e.g. Authentik includes a trailing slash, Keycloak does not). - let normalized_path = path - .strip_suffix('/') - .unwrap_or(path) - .strip_suffix("/.well-known/openid-configuration") - .unwrap_or(if path == "/" { "" } else { path }); - - if normalized_path.contains("/.well-known/") { - return Err("config_url uses an unsupported .well-known discovery URL".into()); - } - - let mut issuer = format!("{}://{host}", url.scheme()); - if let Some(port) = url.port() { - issuer.push(':'); - issuer.push_str(&port.to_string()); - } - - if !normalized_path.is_empty() { - issuer.push_str(normalized_path); - } - - Ok(issuer) -} - -fn discovery_url_from_config_url(config_url: &str) -> Result { - let mut url = Url::parse(config_url.trim()).map_err(|e| format!("invalid config_url: {e}"))?; - if url.scheme() != "http" && url.scheme() != "https" { - return Err(format!("invalid config_url scheme: {}", url.scheme())); - } - if url.host_str().is_none() { - return Err("config_url missing host".to_string()); - } - - let path = url.path().to_string(); - let without_trailing_slash = path.strip_suffix('/').unwrap_or(&path); - if without_trailing_slash.ends_with("/.well-known/openid-configuration") { - url.set_path(without_trailing_slash); - return Ok(url); - } - if without_trailing_slash.contains("/.well-known/") { - return Err("config_url uses an unsupported .well-known discovery URL".into()); - } - - let discovery_path = if without_trailing_slash.is_empty() || without_trailing_slash == "/" { - "/.well-known/openid-configuration".to_string() - } else { - format!("{without_trailing_slash}/.well-known/openid-configuration") - }; - url.set_path(&discovery_path); - Ok(url) -} - -fn jwks_url_from_config_url( - config_url: &str, - issuer_url: &IssuerUrl, - jwks_url: &JsonWebKeySetUrl, -) -> Result { - let issuer = issuer_url.url(); - let jwks = jwks_url.url(); - if issuer.origin() != jwks.origin() { - return Ok(jwks_url.clone()); - } - - let issuer_path = issuer.path().trim_end_matches('/'); - let Some(suffix) = jwks.path().strip_prefix(issuer_path) else { - return Ok(jwks_url.clone()); - }; - if !suffix.is_empty() && !suffix.starts_with('/') { - return Ok(jwks_url.clone()); - } - - let mut internal_url = - Url::parse(&normalize_config_url(config_url)?).map_err(|err| format!("invalid config_url issuer base: {err}"))?; - let internal_path = internal_url.path().trim_end_matches('/'); - internal_url.set_path(&format!("{internal_path}{suffix}")); - internal_url.set_query(jwks.query()); - Ok(JsonWebKeySetUrl::from_url(internal_url)) -} - -fn issuer_candidates(base: &str) -> Vec { - let original = base.trim(); - let mut variants = Vec::with_capacity(2); - variants.push(original.to_string()); - - let toggled = if original.ends_with('/') { - original.trim_end_matches('/').to_string() - } else { - format!("{original}/") - }; - variants.push(toggled); - - variants -} - -/// 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 with case-insensitive fallback. -fn extract_string_claim(claims: &HashMap, key: &str) -> String { - match get_claim_case_insensitive(claims, key) { - ClaimLookup::Found(value) => value.as_str().unwrap_or_default().to_string(), - ClaimLookup::Missing | ClaimLookup::Ambiguous => String::new(), - } -} - -/// Extract a groups/array claim from raw claims with case-insensitive fallback. Handles both string arrays and single strings. -fn extract_groups_claim(claims: &HashMap, key: &str) -> Vec { - match get_claim_case_insensitive(claims, key) { - ClaimLookup::Found(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(), - ClaimLookup::Found(serde_json::Value::String(s)) => s.split(',').map(|s| s.trim().to_string()).collect(), - _ => vec![], - } -} - -fn extract_canonical_group_values( - claims: &HashMap, - groups_claim: &str, - roles_claim: &str, -) -> Vec { - let mut groups = extract_groups_claim(claims, groups_claim); - if !roles_claim.is_empty() && roles_claim != groups_claim { - groups.extend(extract_groups_claim(claims, roles_claim)); - } - groups.retain(|g| !g.is_empty()); - groups.sort(); - groups.dedup(); - groups -} - -#[cfg(any(test, feature = "test-util"))] -#[doc(hidden)] -pub fn make_test_sys(configs: Vec) -> OidcSys { - let configs = configs - .into_iter() - .map(test_sourced_config) - .map(|provider| (provider.config.id.clone(), provider)) - .collect(); - OidcSys { - configs, - provider_states: RwLock::new(HashMap::new()), - state_store: OidcStateStore::new(), - http_client: ReqwestHttpClient::new().expect("failed to initialize OIDC HTTP clients"), - } -} - -#[cfg(any(test, feature = "test-util"))] -fn test_sourced_config(config: OidcProviderConfig) -> SourcedOidcProviderConfig { - SourcedOidcProviderConfig { - config, - source: OidcProviderConfigSource::Persisted, - } -} - -#[cfg(any(test, feature = "test-util"))] -#[doc(hidden)] -pub fn test_config(id: &str) -> OidcProviderConfig { - OidcProviderConfig { - id: id.to_string(), - enabled: true, - config_url: format!("https://example.com/{id}/.well-known/openid-configuration"), - issuer: None, - client_id: "client-id".to_string(), - client_secret: None, - scopes: vec!["openid".to_string()], - other_audiences: vec![], - redirect_uri: None, - redirect_uri_dynamic: true, - claim_name: "groups".to_string(), - claim_prefix: String::new(), - role_policy: String::new(), - display_name: id.to_string(), - groups_claim: "groups".to_string(), - roles_claim: String::new(), - email_claim: "email".to_string(), - username_claim: "preferred_username".to_string(), - hide_from_ui: false, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use jsonwebtoken::{Algorithm, EncodingKey, Header}; - use rustfs_utils::egress::OutboundDnsPolicyRejection; - use std::sync::atomic::{AtomicUsize, Ordering}; - - #[derive(Clone)] - struct RejectingDnsResolver { - allow_origin_can_recover: bool, - calls: Option>, - } - - impl reqwest::dns::Resolve for RejectingDnsResolver { - fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { - if let Some(calls) = &self.calls { - calls.fetch_add(1, Ordering::Relaxed); - } - let host = name.as_str().to_string(); - let rejection = OutboundDnsPolicyRejection::new(host, self.allow_origin_can_recover); - Box::pin(async move { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, rejection).into()) }) - } - } - - #[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 format_http_headers_redacts_sensitive_values() { - let mut headers = http::HeaderMap::new(); - headers.insert(http::header::AUTHORIZATION, "Basic Y2xpZW50OnNlY3JldA==".parse().unwrap()); - headers.insert(http::header::CONTENT_TYPE, "application/json".parse().unwrap()); - headers.insert(http::header::COOKIE, "session=super-secret".parse().unwrap()); - - let rendered = format_http_headers(&headers); - - // Sensitive header values never appear; only their length is emitted. - assert!(!rendered.contains("Y2xpZW50OnNlY3JldA=="), "authorization value leaked: {rendered}"); - assert!(!rendered.contains("super-secret"), "cookie value leaked: {rendered}"); - assert!( - rendered.contains("authorization= OidcProviderConfig { - OidcProviderConfig { - id: id.to_string(), - enabled: true, - config_url: config_url.to_string(), - issuer: None, - client_id: "rustfs-oidc-test".to_string(), - client_secret: None, - scopes: vec!["openid".to_string()], - other_audiences: vec![], - redirect_uri: None, - redirect_uri_dynamic: false, - claim_name: "sub".to_string(), - claim_prefix: "oidc".to_string(), - role_policy: String::new(), - display_name: "mock-oidc".to_string(), - groups_claim: "groups".to_string(), - roles_claim: String::new(), - email_claim: "email".to_string(), - username_claim: "username".to_string(), - hide_from_ui: false, - } - } - - fn read_mock_oidc_request(stream: &mut impl std::io::Read) -> String { - let mut request_bytes = Vec::new(); - let mut buffer = [0u8; 4096]; - loop { - match stream.read(&mut buffer) { - Ok(0) => break, - Ok(n) => request_bytes.extend_from_slice(&buffer[..n]), - Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => break, - Err(_) => break, - } - if request_bytes.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - if request_bytes.len() >= 8192 { - break; - } - } - String::from_utf8_lossy(&request_bytes).into_owned() - } - - fn read_mock_oidc_request_path(stream: &mut impl std::io::Read) -> String { - read_mock_oidc_request(stream) - .lines() - .next() - .unwrap_or("") - .split_whitespace() - .nth(1) - .unwrap_or("") - .to_string() - } - - fn mock_oidc_response(path: &str, discovery_body: &str, expected_jwks_path: &str, jwks_body: &str) -> String { - let (status, body) = if path.ends_with("/.well-known/openid-configuration") { - (200, discovery_body) - } else if path == expected_jwks_path { - (200, jwks_body) - } else { - (404, r#"{"error":"not found"}"#) - }; - - format!( - "HTTP/1.1 {status} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - if status == 200 { "OK" } else { "Not Found" }, - body.len() - ) - } - - fn start_mock_oidc_discovery_server_with_jwks( - build_discovery_issuer: F, - max_requests: usize, - signing_alg: &'static str, - workload: bool, - jwks_response: J, - ) -> Option<(String, std::thread::JoinHandle<()>)> - where - F: Fn(&str) -> (String, String, String) + Send + 'static, - J: Fn(usize) -> String + Send + 'static, - { - use std::io::Write; - use std::net::{Shutdown, TcpListener}; - use std::sync::mpsc; - use std::time::{Duration, Instant}; - - // After the last completed response, exit if no new connection arrives within this window. - // Keep the mock server alive long enough for slower CI/macOS test environments to finish - // discovery + JWKS requests without racing the shutdown timer. - const IDLE_SHUTDOWN: Duration = Duration::from_secs(1); - const ABSOLUTE_CAP: Duration = Duration::from_secs(5); - - let listener = match TcpListener::bind("127.0.0.1:0") { - Ok(listener) => listener, - Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None, - Err(err) => panic!("test listener should bind: {err}"), - }; - let base = format!("http://{}", listener.local_addr().expect("listener local address should be available")); - let (discovery_issuer, discovery_jwks_uri, expected_jwks_path) = build_discovery_issuer(&base); - let mut discovery_document = serde_json::json!({ - "issuer": discovery_issuer, - "authorization_endpoint": format!("{base}/authorize"), - "token_endpoint": format!("{base}/token"), - "jwks_uri": discovery_jwks_uri, - "response_types_supported": ["code"], - "response_modes_supported": ["query"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": [signing_alg], - }); - if workload { - let fields = discovery_document.as_object_mut().expect("mock metadata is an object"); - fields.remove("authorization_endpoint"); - fields.remove("token_endpoint"); - fields.insert("response_types_supported".into(), serde_json::json!(["id_token"])); - } - let discovery_body = discovery_document.to_string(); - let (ready_tx, ready_rx) = mpsc::channel(); - - let handle = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("failed to set discovery mock listener non-blocking"); - let _ = ready_tx.send(()); - - let mut seen = 0usize; - let mut jwks_fetches = 0usize; - let start = Instant::now(); - let mut last_completed = Instant::now(); - - loop { - if seen > 0 && last_completed.elapsed() >= IDLE_SHUTDOWN { - break; - } - if start.elapsed() >= ABSOLUTE_CAP { - break; - } - - let mut stream = match listener.accept() { - Ok((stream, _)) => stream, - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(5)); - continue; - } - Err(_) => break, - }; - stream - .set_nonblocking(false) - .expect("failed to set discovery mock stream blocking"); - - seen += 1; - stream - .set_nonblocking(false) - .expect("failed to set discovery mock stream blocking"); - stream - .set_read_timeout(Some(Duration::from_secs(1))) - .expect("failed to set discovery mock read timeout"); - - let request = read_mock_oidc_request(&mut stream); - let path = request - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .unwrap_or(""); - let jwks_body = jwks_response(jwks_fetches); - if path == expected_jwks_path { - jwks_fetches += 1; - } - let mut response = mock_oidc_response(path, &discovery_body, &expected_jwks_path, &jwks_body); - if path.contains("/.well-known/openid-configuration") { - assert!( - request - .lines() - .filter_map(|line| line.split_once(':')) - .any(|(name, value)| { name.eq_ignore_ascii_case("accept") && value.trim() == "application/json" }), - "discovery Accept must remain unchanged" - ); - } - if path == expected_jwks_path { - let expected_type = if workload { - "application/jwk-set+json" - } else { - "application/json" - }; - let accepts_type = request.lines().filter_map(|line| line.split_once(':')).any(|(name, value)| { - name.eq_ignore_ascii_case("accept") && value.split(',').any(|item| item.trim() == expected_type) - }); - if !accepts_type { - response = "HTTP/1.1 406 Not Acceptable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into(); - } else if workload { - response = response.replace("Content-Type: application/json", "Content-Type: application/jwk-set+json"); - } - } - let _ = stream.write_all(response.as_bytes()); - let _ = stream.flush(); - let _ = stream.shutdown(Shutdown::Both); - last_completed = Instant::now(); - - if seen >= max_requests { - break; - } - } - }); - ready_rx - .recv_timeout(Duration::from_millis(100)) - .expect("mock OIDC discovery server should become ready"); - - Some((base, handle)) - } - - fn start_mock_oidc_discovery_server( - build_discovery_issuer: F, - max_requests: usize, - ) -> Option<(String, std::thread::JoinHandle<()>)> - where - F: Fn(&str) -> (String, String, String) + Send + 'static, - { - start_mock_oidc_discovery_server_with_jwks(build_discovery_issuer, max_requests, "RS256", false, |_| { - r#"{"keys":[]}"#.to_string() - }) - } - - fn oidc_es256_key_and_jwk(kid: &str) -> (EncodingKey, serde_json::Value) { - let certified = - rcgen::generate_simple_self_signed(vec![format!("{kid}.invalid")]).expect("OIDC signing key should generate"); - let encoding_key = - EncodingKey::from_ec_pem(certified.signing_key.serialize_pem().as_bytes()).expect("OIDC signing key should encode"); - let mut jwk = jsonwebtoken::jwk::Jwk::from_encoding_key(&encoding_key, Algorithm::ES256) - .expect("OIDC public JWK should derive from signing key"); - jwk.common.key_id = Some(kid.to_string()); - jwk.common.public_key_use = Some(jsonwebtoken::jwk::PublicKeyUse::Signature); - (encoding_key, serde_json::to_value(jwk).expect("OIDC JWK should serialize")) - } - - #[tokio::test] - async fn web_identity_verification_refreshes_rotated_jwks() { - let (_, initial_jwk) = oidc_es256_key_and_jwk("initial"); - let (rotated_key, rotated_jwk) = oidc_es256_key_and_jwk("rotated"); - let initial_jwks = serde_json::json!({ "keys": [initial_jwk] }).to_string(); - let rotated_jwks = serde_json::json!({ "keys": [rotated_jwk] }).to_string(); - let Some((base, handle)) = start_mock_oidc_discovery_server_with_jwks( - |base| (base.to_string(), format!("{base}/jwks"), "/jwks".to_string()), - 4, - "ES256", - false, - move |fetch| { - if fetch == 0 { - initial_jwks.clone() - } else { - rotated_jwks.clone() - } - }, - ) else { - return; - }; - - let config = build_mocked_oidc_provider_config("rotating", &base); - let policy = OutboundPolicy::from_allowed_origins(&base).expect("loopback origin should be allowed"); - let http_client = ReqwestHttpClient::with_policy(policy); - let state = OidcSys::discover_provider(&config, &http_client) - .await - .expect("initial OIDC discovery should succeed"); - let sys = OidcSys { - configs: HashMap::from([(config.id.clone(), test_sourced_config(config.clone()))]), - provider_states: RwLock::new(HashMap::from([(config.id.clone(), state)])), - state_store: OidcStateStore::new(), - http_client, - }; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after Unix epoch") - .as_secs(); - let mut header = Header::new(Algorithm::ES256); - header.kid = Some("rotated".to_string()); - let token = jsonwebtoken::encode( - &header, - &serde_json::json!({ - "iss": base, - "sub": "rotated-user", - "aud": config.client_id, - "iat": now, - "exp": now + 300, - "groups": ["readwrite"], - }), - &rotated_key, - ) - .expect("rotated OIDC token should sign"); - - let (claims, provider_id) = sys - .verify_web_identity_token(&token) - .await - .expect("verification should refresh JWKS and accept the rotated key"); - assert_eq!(provider_id, "rotating"); - assert_eq!(claims.sub, "rotated-user"); - assert_eq!(claims.groups, vec!["readwrite"]); - handle.join().expect("rotating JWKS mock server should exit cleanly"); - } - - #[tokio::test] - async fn complete_provider_console_login_preserves_hidden_and_issuer_modes() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - for hidden in [false, true] { - for explicit in [false, true] { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base = format!("http://{}", listener.local_addr().unwrap()); - let issuer = if explicit { - "https://issuer.example.com".to_string() - } else { - base.clone() - }; - let mut config = - build_mocked_oidc_provider_config("console", &format!("{base}/.well-known/openid-configuration")); - config.hide_from_ui = hidden; - config.issuer = explicit.then(|| issuer.clone()); - config.client_secret = Some(Nonce::new_random().secret().clone()); - let server_config = config.clone(); - let server_base = base.clone(); - let redirect = "https://console.example.com/oauth_callback"; - let (key, jwk) = oidc_es256_key_and_jwk("console"); - let (auth_tx, auth_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let mut auth_rx = Some(auth_rx); - for expected_path in ["/.well-known/openid-configuration", "/jwks", "/token"] { - let (mut stream, _) = tokio::time::timeout(StdDuration::from_secs(10), listener.accept()) - .await - .unwrap() - .unwrap(); - let mut bytes = Vec::new(); - let header_end = loop { - bytes.push(stream.read_u8().await.unwrap()); - assert!(bytes.len() < 8192); - if bytes.ends_with(b"\r\n\r\n") { - break bytes.len(); - } - }; - let headers = String::from_utf8(bytes).unwrap(); - let request_line = headers.lines().next().unwrap(); - assert_eq!(request_line.split_whitespace().nth(1), Some(expected_path)); - let headers_map: HashMap<_, _> = headers - .lines() - .skip(1) - .filter_map(|line| line.split_once(':')) - .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_string())) - .collect(); - let body = match expected_path { - "/.well-known/openid-configuration" => { - assert!(request_line.starts_with("GET ")); - assert_eq!(headers_map["accept"], "application/json"); - serde_json::json!({ - "issuer": issuer, "authorization_endpoint": format!("{server_base}/authorize"), - "token_endpoint": format!("{server_base}/token"), "jwks_uri": format!("{server_base}/jwks"), - "response_types_supported": ["code"], "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["ES256"] - }) - } - "/jwks" => { - assert!(request_line.starts_with("GET ")); - assert!(headers_map["accept"].contains("application/json")); - serde_json::json!({"keys": [jwk]}) - } - "/token" => { - assert!(request_line.starts_with("POST ")); - assert_eq!(headers_map["accept"], "application/json"); - assert!(headers_map["content-type"].starts_with("application/x-www-form-urlencoded")); - let length: usize = headers_map["content-length"].parse().unwrap(); - assert!(header_end + length < 16384); - let mut body = vec![0; length]; - stream.read_exact(&mut body).await.unwrap(); - let form: HashMap = url::form_urlencoded::parse(&body).into_owned().collect(); - assert_eq!(form["grant_type"], "authorization_code"); - assert_eq!(form["code"], "test-authorization-code"); - assert_eq!(form["client_id"], server_config.client_id); - assert_eq!(Some(&form["client_secret"]), server_config.client_secret.as_ref()); - assert_eq!(form["redirect_uri"], redirect); - let auth = auth_rx.take().unwrap().await.unwrap(); - let challenge = PkceCodeChallenge::from_code_verifier_sha256(&PkceCodeVerifier::new(form["code_verifier"].clone())); - assert_eq!(challenge.as_str(), auth["code_challenge"]); - let now = time::OffsetDateTime::now_utc().unix_timestamp(); - let mut header = Header::new(Algorithm::ES256); - header.kid = Some("console".into()); - let token = jsonwebtoken::encode(&header, &serde_json::json!({ - "iss": issuer, "sub": "existing-user", "aud": server_config.client_id, - "iat": now, "exp": now + 300, "nonce": auth["nonce"], - "email": "user@example.com", "groups": ["readwrite"] - }), &key).unwrap(); - serde_json::json!({"access_token": "test-access-token", "token_type": "Bearer", "id_token": token}) - } - _ => unreachable!(), - }.to_string(); - stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); - } - }); - let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).unwrap()); - let discovered = OidcSys::discover_provider(&config, &http_client).await.unwrap(); - let sys = OidcSys { - configs: HashMap::from([(config.id.clone(), test_sourced_config(config))]), - provider_states: RwLock::new(HashMap::from([("console".into(), discovered)])), - state_store: OidcStateStore::new(), - http_client, - }; - let auth_url = sys.authorize_url("console", redirect, Some("/buckets".into())).await.unwrap(); - let auth_url = Url::parse(&auth_url).unwrap(); - assert_eq!(auth_url.as_str().split('?').next(), Some(format!("{base}/authorize").as_str())); - let auth: HashMap = auth_url.query_pairs().into_owned().collect(); - assert_eq!(auth["response_type"], "code"); - assert_eq!(auth["client_id"], "rustfs-oidc-test"); - assert!(!auth["nonce"].is_empty()); - assert!(!auth["state"].is_empty()); - assert_eq!(auth["redirect_uri"], redirect); - assert_eq!(auth["code_challenge_method"], "S256"); - assert!(auth["scope"].split_whitespace().any(|scope| scope == "openid")); - let state = auth["state"].clone(); - auth_tx.send(auth).unwrap(); - let (claims, provider, session, _) = sys - .exchange_code(&state, "test-authorization-code", redirect) - .await - .unwrap_or_else(|err| panic!("hidden={hidden}, explicit={explicit}: {err}")); - assert_eq!(provider, "console"); - assert_eq!(claims.sub, "existing-user"); - assert_eq!(claims.email, "user@example.com"); - assert_eq!(claims.groups, vec!["readwrite"]); - assert_eq!(session.redirect_after.as_deref(), Some("/buckets")); - assert!(matches!(sys.exchange_code(&state, "test-authorization-code", redirect).await, - Err(error) if error == "invalid or expired OIDC state")); - server.await.unwrap(); - } - } - } - - #[test] - fn workload_metadata_requires_hidden_provider_and_valid_verification_fields() { - let document = serde_json::json!({ - "issuer": "https://issuer.example.com", - "jwks_uri": "https://issuer.example.com/jwks", - "response_types_supported": ["id_token"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["ES256"], - }); - let parse = - |value: &serde_json::Value, hidden| DiscoveredProviderMetadata::parse(&serde_json::to_vec(value).unwrap(), hidden); - let metadata = parse(&document, true).expect("hidden workload metadata should parse"); - assert!(metadata.authorization_endpoint().is_none()); - assert!(metadata.console().err().unwrap().contains("only web identity")); - assert!(parse(&document, false).err().unwrap().contains("authorization_endpoint")); - for field in ["issuer", "jwks_uri", "id_token_signing_alg_values_supported"] { - let mut invalid = document.clone(); - invalid.as_object_mut().unwrap().remove(field); - assert!(parse(&invalid, true).err().unwrap().contains(field), "missing {field}"); - } - for endpoint in [serde_json::Value::Null, serde_json::json!(""), serde_json::json!("not a URL")] { - let mut invalid = document.clone(); - invalid["authorization_endpoint"] = endpoint; - assert!(parse(&invalid, true).is_err(), "invalid endpoint must not select workload metadata"); - } - let mut complete = document; - complete["authorization_endpoint"] = serde_json::json!("https://issuer.example.com/authorize"); - for hidden in [true, false] { - let metadata = parse(&complete, hidden).expect("full providers keep the existing parser"); - assert!(metadata.console().is_ok()); - assert!(metadata.token_endpoint().is_none(), "token endpoint remains optional"); - } - } - - #[test] - fn workload_metadata_rejects_duplicate_fields() { - for hidden in [false, true] { - let document = format!( - r#"{{"issuer":"https://wrong.example.com","issuer":"https://issuer.example.com",{}"jwks_uri":"https://issuer.example.com/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256"]}}"#, - if hidden { - "" - } else { - r#""authorization_endpoint":"https://issuer.example.com/authorize","# - }, - ); - let error = DiscoveredProviderMetadata::parse(document.as_bytes(), hidden) - .err() - .expect("duplicate issuer must fail"); - assert!(error.contains("duplicate field"), "{error}"); - } - } - - #[test] - fn workload_verifier_preserves_algorithm_secret_and_audience_policy() { - let secret = Nonce::new_random().secret().to_string(); - let mut config = build_mocked_oidc_provider_config("workload", "https://issuer.example.com"); - config.client_secret = Some(secret.clone()); - config.other_audiences = vec!["additional-audience".into()]; - let now = time::OffsetDateTime::now_utc().unix_timestamp(); - let payload = serde_json::json!({ - "iss": config.config_url, "sub": "repo:example/project:ref:refs/heads/main", - "aud": [config.client_id, "additional-audience"], "iat": now, "exp": now + 300, - }); - let signed = - jsonwebtoken::encode(&Header::new(Algorithm::HS256), &payload, &EncodingKey::from_secret(secret.as_bytes())).unwrap(); - let token: CoreIdToken = signed.parse().unwrap(); - for workload in [false, true] { - for (algorithms, accepted) in [ - (serde_json::json!(["HS256", "unsupported-future-algorithm"]), true), - (serde_json::json!(["ES256"]), false), - (serde_json::json!(["unsupported-future-algorithm"]), false), - ] { - let mut document = serde_json::json!({ - "issuer": config.config_url, "jwks_uri": "https://issuer.example.com/jwks", - "response_types_supported": ["id_token"], "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": algorithms, - }); - if !workload { - document["authorization_endpoint"] = serde_json::json!("https://issuer.example.com/authorize"); - } - let metadata = DiscoveredProviderMetadata::parse(&serde_json::to_vec(&document).unwrap(), true).unwrap(); - let verifier = metadata - .verifier(&config) - .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); - assert_eq!( - token.claims(&verifier, |_: Option<&Nonce>| Ok(())).is_ok(), - accepted, - "workload={workload}, algorithms={algorithms}" - ); - if accepted { - assert!( - token.claims(&metadata.verifier(&config), |_: Option<&Nonce>| Ok(())).is_err(), - "additional audiences require explicit trust" - ); - let mut wrong_secret = config.clone(); - wrong_secret.client_secret = Some(Nonce::new_random().secret().to_string()); - let verifier = metadata - .verifier(&wrong_secret) - .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); - assert!( - token.claims(&verifier, |_: Option<&Nonce>| Ok(())).is_err(), - "incorrect client secret must fail" - ); - } - } - } - } - - #[tokio::test] - async fn workload_discovery_stops_after_forbidden_jwks() { - let requests = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let seen = Arc::clone(&requests); - let (base, handle) = start_mock_oidc_discovery_server_with_jwks( - |base| (base.to_string(), "http://192.168.65.254:8080/jwks".into(), "/jwks".into()), - 2, - "ES256", - true, - move |_| { - seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - serde_json::json!({"keys": []}).to_string() - }, - ) - .expect("workload discovery mock must bind"); - let mut config = build_mocked_oidc_provider_config("workload", &base); - config.hide_from_ui = true; - let client = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).unwrap()); - let error = OidcSys::discover_provider(&config, &client) - .await - .err() - .expect("private JWKS must be blocked"); - assert!(error.starts_with(OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY), "{error}"); - handle.join().unwrap(); - assert_eq!( - requests.load(std::sync::atomic::Ordering::SeqCst), - 1, - "a policy denial must not retry discovery" - ); - } - - #[tokio::test] - async fn workload_config_validation_reports_absent_console_endpoints() { - let (base, handle) = start_mock_oidc_discovery_server_with_jwks( - |base| (base.to_string(), format!("{base}/jwks"), "/jwks".into()), - 2, - "ES256", - true, - |_| serde_json::json!({"keys": []}).to_string(), - ) - .expect("workload discovery mock must bind"); - let mut config = build_mocked_oidc_provider_config("workload", &base); - config.hide_from_ui = true; - // Inferred issuers keep the library's discovery URL construction. - config.config_url = format!("{base}/.well-known/openid-configuration?ignored=1#ignored"); - let result = validate_mocked_oidc_provider_config(&config) - .await - .expect("hidden workload configuration should validate"); - assert_eq!(result.issuer, base); - assert!(result.authorization_endpoint.is_none()); - assert!(result.token_endpoint.is_none()); - handle.join().unwrap(); - } - - #[tokio::test] - async fn workload_discovery_verification_and_rotation() { - for explicit_issuer in [false, true] { - let (_, initial_jwk) = oidc_es256_key_and_jwk("initial"); - let (key, rotated_jwk) = oidc_es256_key_and_jwk("rotated"); - let initial_jwks = serde_json::json!({"keys": [initial_jwk]}).to_string(); - let rotated_jwks = serde_json::json!({"keys": [rotated_jwk]}).to_string(); - let (base, handle) = start_mock_oidc_discovery_server_with_jwks( - |base| (base.to_string(), format!("{base}/jwks"), "/jwks".into()), - 4, - "ES256", - true, - move |fetch| { - if fetch == 0 { - initial_jwks.clone() - } else { - rotated_jwks.clone() - } - }, - ) - .expect("workload discovery mock must bind"); - let mut config = build_mocked_oidc_provider_config("workload", &base); - config.hide_from_ui = true; - if explicit_issuer { - config.issuer = Some(base.clone()); - } - let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).unwrap()); - let state = OidcSys::discover_provider(&config, &http_client) - .await - .expect("workload discovery must succeed"); - assert!(state.metadata.authorization_endpoint().is_none()); - let sys = OidcSys { - configs: HashMap::from([(config.id.clone(), test_sourced_config(config.clone()))]), - provider_states: RwLock::new(HashMap::from([(config.id.clone(), state)])), - state_store: OidcStateStore::new(), - http_client, - }; - assert!(sys.has_providers()); - assert!(sys.provider_configs().all(|provider| provider.hide_from_ui)); - let error = sys - .authorize_url(&config.id, "https://console.example.com/callback", None) - .await - .unwrap_err(); - assert!(error.contains("only web identity")); - let now = time::OffsetDateTime::now_utc().unix_timestamp(); - let payload = serde_json::json!({ - "iss": base, "sub": "system:serviceaccount:default:reader", "aud": [config.client_id], - "iat": now, "exp": now + 300, "groups": ["readonly"], - "kubernetes.io": {"namespace": "default", "serviceaccount": {"name": "reader"}}, - }); - let mut header = Header::new(Algorithm::ES256); - header.kid = Some("rotated".into()); - let token = jsonwebtoken::encode(&header, &payload, &key).unwrap(); - let (claims, provider) = sys - .verify_web_identity_token(&token) - .await - .expect("rotation must retain workload discovery support"); - assert_eq!(provider, config.id); - assert_eq!(claims.sub, "system:serviceaccount:default:reader"); - assert_eq!(claims.groups, ["readonly"]); - // Repeat after the mock exits: the verified snapshot must be cached. - handle.join().unwrap(); - assert!(sys.verify_web_identity_token(&token).await.is_ok()); - for (field, value, expected) in [ - ("iss", serde_json::json!("https://wrong.example.com"), "issuer"), - ("aud", serde_json::json!("wrong-audience"), "audience"), - ("exp", serde_json::json!(now - 60), "expired"), - ] { - let mut invalid = payload.clone(); - invalid[field] = value; - let token = jsonwebtoken::encode(&header, &invalid, &key).unwrap(); - let error = sys - .verify_web_identity_token(&token) - .await - .expect_err("invalid workload token must fail"); - assert!(error.to_lowercase().contains(expected), "{field}: {error}"); - } - let (wrong_key, _) = oidc_es256_key_and_jwk("wrong"); - let invalid = jsonwebtoken::encode(&header, &payload, &wrong_key).unwrap(); - let error = sys - .verify_web_identity_token(&invalid) - .await - .expect_err("wrong signature must fail"); - assert!(error.to_lowercase().contains("signature"), "{error}"); - assert!( - sys.verify_web_identity_token(&token).await.is_ok(), - "failed refresh must preserve the cached keys" - ); - } - } - - fn start_mock_oidc_tls_discovery_server( - build_discovery_issuer: F, - max_requests: usize, - ) -> Option<(String, String, std::thread::JoinHandle<()>)> - where - F: Fn(&str) -> (String, String, String) + Send + 'static, - { - use std::io::Write; - use std::net::{Shutdown, TcpListener}; - use std::sync::mpsc; - use std::time::{Duration, Instant}; - - const IDLE_SHUTDOWN: Duration = Duration::from_secs(1); - const ABSOLUTE_CAP: Duration = Duration::from_secs(5); - - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let certified = - rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).expect("generate OIDC TLS test certificate"); - let cert_pem = certified.cert.pem(); - let server_config = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert( - vec![certified.cert.der().clone()], - rustls_pki_types::PrivateKeyDer::try_from(certified.signing_key.serialize_der()) - .expect("convert OIDC TLS test private key"), - ) - .expect("build OIDC TLS mock server config"); - - let listener = match TcpListener::bind("127.0.0.1:0") { - Ok(listener) => listener, - Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None, - Err(err) => panic!("test TLS listener should bind: {err}"), - }; - let base = format!("https://{}", listener.local_addr().expect("listener local address should be available")); - let (discovery_issuer, discovery_jwks_uri, expected_jwks_path) = build_discovery_issuer(&base); - let discovery_body = serde_json::json!({ - "issuer": discovery_issuer, - "authorization_endpoint": format!("{base}/authorize"), - "token_endpoint": format!("{base}/token"), - "jwks_uri": discovery_jwks_uri, - "response_types_supported": ["code"], - "response_modes_supported": ["query"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"], - }) - .to_string(); - let jwks_body = r#"{"keys":[]}"#; - let (ready_tx, ready_rx) = mpsc::channel(); - - let handle = std::thread::spawn(move || { - let server_config = Arc::new(server_config); - listener - .set_nonblocking(true) - .expect("failed to set TLS discovery mock listener non-blocking"); - let _ = ready_tx.send(()); - - let mut seen = 0usize; - let start = Instant::now(); - let mut last_completed = Instant::now(); - - loop { - if seen > 0 && last_completed.elapsed() >= IDLE_SHUTDOWN { - break; - } - if start.elapsed() >= ABSOLUTE_CAP { - break; - } - - let tcp_stream = match listener.accept() { - Ok((stream, _)) => stream, - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(5)); - continue; - } - Err(_) => break, - }; - tcp_stream - .set_nonblocking(false) - .expect("failed to set TLS discovery mock stream blocking"); - tcp_stream - .set_read_timeout(Some(Duration::from_secs(1))) - .expect("failed to set TLS discovery mock read timeout"); - - seen += 1; - let connection = match rustls::ServerConnection::new(server_config.clone()) { - Ok(connection) => connection, - Err(_) => break, - }; - let mut stream = rustls::StreamOwned::new(connection, tcp_stream); - let path = read_mock_oidc_request_path(&mut stream); - let response = mock_oidc_response(&path, &discovery_body, &expected_jwks_path, jwks_body); - let _ = stream.write_all(response.as_bytes()); - let _ = stream.flush(); - let _ = stream.sock.shutdown(Shutdown::Both); - last_completed = Instant::now(); - - if seen >= max_requests { - break; - } - } - }); - ready_rx - .recv_timeout(Duration::from_millis(100)) - .expect("mock TLS OIDC discovery server should become ready"); - - Some((base, cert_pem, handle)) - } - - fn discovery_error_contains_all_variants(err: &str, base: &str) -> bool { - err.contains(base) && err.contains(&format!("{base}/")) && err.contains("discovery failed for all issuer variants") - } - - async fn validate_mocked_oidc_provider_config(config: &OidcProviderConfig) -> Result { - // The mock discovery/JWKS/token endpoints share the loopback origin of `config_url`. - // Explicitly allow that origin so the egress policy does not reject the loopback mock. - let origin = Url::parse(&config.config_url) - .map_err(|_| "invalid mock config_url".to_string())? - .origin() - .ascii_serialization(); - let policy = OutboundPolicy::from_allowed_origins(&origin).map_err(|err| err.to_string())?; - let http_client = ReqwestHttpClient::with_policy(policy); - let state = OidcSys::discover_provider(config, &http_client).await?; - - Ok(OidcProviderValidationResult { - issuer: state.metadata.issuer().to_string(), - authorization_endpoint: state.metadata.authorization_endpoint(), - token_endpoint: state.metadata.token_endpoint().map(ToString::to_string), - }) - } - - #[tokio::test] - async fn oidc_discovery_accepts_extra_root_ca_for_https_provider() { - let Some((base, ca_pem, handle)) = start_mock_oidc_tls_discovery_server( - |base| (format!("{base}/application/o/rustfs"), format!("{base}/jwks"), "/jwks".to_string()), - 4, - ) else { - return; - }; - let config_url = format!("{base}/application/o/rustfs"); - let config = build_mocked_oidc_provider_config("default", &config_url); - let origin = Url::parse(&config.config_url) - .expect("mock config_url should parse") - .origin() - .ascii_serialization(); - let policy = OutboundPolicy::from_allowed_origins(&origin).expect("loopback TLS origin should be allowed"); - let extra_root_certs = - parse_oidc_extra_root_certs("test OIDC TLS CA", ca_pem.as_bytes()).expect("test CA bundle should parse"); - let http_client = ReqwestHttpClient::with_policy_and_extra_root_certs(policy, extra_root_certs); - - let state = OidcSys::discover_provider(&config, &http_client) - .await - .expect("OIDC discovery should trust the extra root CA"); - - assert_eq!(state.metadata.issuer().to_string(), format!("{base}/application/o/rustfs")); - assert!(handle.join().is_ok()); - } - - #[tokio::test] - async fn oidc_discovery_refreshes_extra_root_ca_when_generation_changes() { - let Some((base_a, ca_pem_a, handle_a)) = start_mock_oidc_tls_discovery_server( - |base| (format!("{base}/application/o/rustfs-a"), format!("{base}/jwks"), "/jwks".to_string()), - 4, - ) else { - return; - }; - let Some((base_b, ca_pem_b, handle_b)) = start_mock_oidc_tls_discovery_server( - |base| (format!("{base}/application/o/rustfs-b"), format!("{base}/jwks"), "/jwks".to_string()), - 4, - ) else { - assert!(handle_a.join().is_ok()); - return; - }; - - let origin_a = Url::parse(&base_a) - .expect("mock base A should parse") - .origin() - .ascii_serialization(); - let origin_b = Url::parse(&base_b) - .expect("mock base B should parse") - .origin() - .ascii_serialization(); - let allowed_origins = format!("{origin_a},{origin_b}"); - let policy = OutboundPolicy::from_allowed_origins(&allowed_origins).expect("loopback TLS origins should be allowed"); - let material = Arc::new(Mutex::new(OidcExtraRootCaMaterial { - generation: 1, - root_ca_pem: Some(ca_pem_a.into_bytes()), - })); - let provider = OidcExtraRootCaProvider::new({ - let material = material.clone(); - move || { - let material = material.clone(); - async move { - material - .lock() - .map(|material| material.clone()) - .map_err(|e| format!("test OIDC extra CA material lock poisoned: {e}")) - } - } - }); - let http_client = ReqwestHttpClient::with_policy_and_extra_root_ca_provider(policy, provider); - - let config_a = build_mocked_oidc_provider_config("a", &format!("{base_a}/application/o/rustfs-a")); - let state_a = OidcSys::discover_provider(&config_a, &http_client) - .await - .expect("OIDC discovery should trust initial extra root CA"); - assert_eq!(state_a.metadata.issuer().to_string(), format!("{base_a}/application/o/rustfs-a")); - - { - let mut material = material - .lock() - .expect("test OIDC extra CA material lock should not be poisoned"); - material.generation = 2; - material.root_ca_pem = Some(ca_pem_b.into_bytes()); - } - let config_b = build_mocked_oidc_provider_config("b", &format!("{base_b}/application/o/rustfs-b")); - let state_b = OidcSys::discover_provider(&config_b, &http_client) - .await - .expect("OIDC discovery should refresh extra root CA after generation change"); - - assert_eq!(state_b.metadata.issuer().to_string(), format!("{base_b}/application/o/rustfs-b")); - assert!(handle_a.join().is_ok()); - assert!(handle_b.join().is_ok()); - } - - #[tokio::test] - async fn test_validate_oidc_provider_config_retries_with_issuer_candidates() { - // Discovery document must advertise the canonical issuer path. The first candidate has no - // trailing slash; openidconnect rejects issuer mismatch, then the second variant succeeds. - let Some((base, handle)) = start_mock_oidc_discovery_server( - |base| (format!("{base}/application/o/rustfs/"), format!("{base}/jwks"), "/jwks".to_string()), - 8, - ) else { - return; - }; - let config_url = format!("{base}/application/o/rustfs"); - let config = build_mocked_oidc_provider_config("default", &config_url); - - let result = validate_mocked_oidc_provider_config(&config).await; - - let validation_result = result.expect("OIDC provider validation should succeed"); - assert_eq!(validation_result.issuer, format!("{base}/application/o/rustfs/")); - assert!(handle.join().is_ok()); - } - - #[tokio::test] - async fn test_validate_oidc_provider_config_fetches_issuer_relative_jwks_from_config_url() { - let Some((base, handle)) = start_mock_oidc_discovery_server( - |_| { - ( - "http://127.0.0.1:1/public/realms/app".to_string(), - "http://127.0.0.1:1/public/realms/app/jwks?version=1".to_string(), - "/internal/realms/app/jwks?version=1".to_string(), - ) - }, - 2, - ) else { - return; - }; - let mut config = - build_mocked_oidc_provider_config("default", &format!("{base}/internal/realms/app/.well-known/openid-configuration")); - config.issuer = Some("http://127.0.0.1:1/public/realms/app".to_string()); - - let validation_result = validate_mocked_oidc_provider_config(&config) - .await - .expect("OIDC provider validation should succeed"); - - assert_eq!(validation_result.issuer, "http://127.0.0.1:1/public/realms/app"); - assert!(handle.join().is_ok()); - } - - #[test] - fn test_jwks_url_from_config_url_preserves_unrelated_urls() { - let issuer = IssuerUrl::new("https://public.example.com/realms/app".to_string()).expect("issuer URL should parse"); - for raw_jwks_url in [ - "https://keys.example.com/jwks", - "http://public.example.com/realms/app/jwks", - "https://public.example.com:8443/realms/app/jwks", - "https://public.example.com/keys/jwks", - "https://public.example.com/realms/application/jwks", - ] { - let jwks_url = JsonWebKeySetUrl::new(raw_jwks_url.to_string()).expect("JWKS URL should parse"); - - let resolved = jwks_url_from_config_url( - "http://keycloak.internal/realms/app/.well-known/openid-configuration", - &issuer, - &jwks_url, - ) - .expect("JWKS URL should resolve"); - - assert_eq!(resolved.as_str(), raw_jwks_url); - } - - let issuer_root_jwks = JsonWebKeySetUrl::new(issuer.as_str().to_string()).expect("JWKS URL should parse"); - let resolved = jwks_url_from_config_url( - "http://keycloak.internal/realms/app/.well-known/openid-configuration", - &issuer, - &issuer_root_jwks, - ) - .expect("issuer-root JWKS URL should resolve"); - assert_eq!(resolved.as_str(), "http://keycloak.internal/realms/app"); - - let issuer_with_slash = - IssuerUrl::new("https://public.example.com/realms/app/".to_string()).expect("issuer URL should parse"); - let jwks_url = - JsonWebKeySetUrl::new("https://public.example.com/realms/app/jwks".to_string()).expect("JWKS URL should parse"); - let resolved = jwks_url_from_config_url( - "http://keycloak.internal/realms/app/.well-known/openid-configuration", - &issuer_with_slash, - &jwks_url, - ) - .expect("issuer-relative JWKS URL should resolve"); - assert_eq!(resolved.as_str(), "http://keycloak.internal/realms/app/jwks"); - } - - #[tokio::test] - async fn test_validate_oidc_provider_config_rejects_separate_issuer_mismatch() { - let Some((base, handle)) = start_mock_oidc_discovery_server( - |base| { - ( - "https://public.example.com/realms/other".to_string(), - format!("{base}/jwks"), - "/jwks".to_string(), - ) - }, - 1, - ) else { - return; - }; - let mut config = - build_mocked_oidc_provider_config("default", &format!("{base}/internal/realms/app/.well-known/openid-configuration")); - config.issuer = Some("https://public.example.com/realms/app".to_string()); - - let err = validate_mocked_oidc_provider_config(&config) - .await - .expect_err("OIDC provider validation should fail"); - - assert!(err.contains("unexpected issuer URI")); - assert!(err.contains("https://public.example.com/realms/app")); - assert!(handle.join().is_ok()); - } - - #[tokio::test] - async fn test_validate_oidc_provider_config_returns_detailed_errors() { - let Some((base, handle)) = start_mock_oidc_discovery_server( - |base| (format!("{base}/application/o/other"), format!("{base}/jwks"), "/jwks".to_string()), - 8, - ) else { - return; - }; - let config_url = format!("{base}/application/o/rustfs"); - let config = build_mocked_oidc_provider_config("default", &config_url); - - let err = validate_mocked_oidc_provider_config(&config) - .await - .expect_err("OIDC provider validation should fail"); - assert!(discovery_error_contains_all_variants(&err, &base)); - assert!(err.contains("issuer '")); - assert!(err.contains(&format!("issuer '{base}/application/o/rustfs'"))); - assert!(err.contains(&format!("issuer '{base}/application/o/rustfs/'"))); - assert!(handle.join().is_ok()); - } - - #[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_core_token_response_accepts_rfc3339_updated_at() { - // Signature verification happens later; this test covers token response deserialization. - let id_token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20vb2lkYyIsInN1YiI6InVzZXItMSIsImF1ZCI6InJ1c3RmcyIsImV4cCI6MTc4NDQzMjc2OSwiaWF0IjoxNzgzMjIzMTY5LCJ1cGRhdGVkX2F0IjoiMjAyNi0wNy0wM1QwNDo1MDo1MC44MTFaIn0.c2ln"; - let body = serde_json::json!({ - "scope": "openid roles profile email", - "token_type": "Bearer", - "access_token": "access-token", - "expires_in": 1209600, - "id_token": id_token, - }) - .to_string(); - - let response: openidconnect::core::CoreTokenResponse = - serde_json::from_str(&body).expect("RFC3339 updated_at should parse in token response"); - - assert!(response.extra_fields().id_token().is_some()); - } - - #[test] - fn test_map_claims_to_policies_no_provider() { - let sys = OidcSys::empty().expect("failed to initialize empty OIDC system"); - - 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_parse_single_provider_reads_issuer() { - temp_env::with_vars( - [ - ( - ENV_IDENTITY_OPENID_CONFIG_URL, - Some("http://keycloak.ns.svc.cluster.local:8080/realms/app/.well-known/openid-configuration"), - ), - (ENV_IDENTITY_OPENID_ISSUER, Some("https://app.local/realms/app")), - (ENV_IDENTITY_OPENID_CLIENT_ID, Some("console")), - ], - || { - let config = OidcSys::parse_single_provider("", "default").expect("provider config should parse"); - - assert_eq!(config.issuer.as_deref(), Some("https://app.local/realms/app")); - }, - ); - } - - #[test] - fn test_parse_persisted_provider_config() { - let mut cfg = ServerConfig::new(); - let mut kvs = KVS(vec![ - rustfs_config::server_config::KV { - key: ENABLE_KEY.to_string(), - value: EnableState::Off.to_string(), - hidden_if_empty: false, - }, - rustfs_config::server_config::KV { - key: OIDC_CONFIG_URL.to_string(), - value: String::new(), - hidden_if_empty: false, - }, - rustfs_config::server_config::KV { - key: OIDC_CLIENT_ID.to_string(), - value: String::new(), - hidden_if_empty: false, - }, - ]); - kvs.insert( - OIDC_CONFIG_URL.to_string(), - "https://example.com/.well-known/openid-configuration".to_string(), - ); - kvs.insert(OIDC_CLIENT_ID.to_string(), "console".to_string()); - kvs.insert(ENABLE_KEY.to_string(), EnableState::On.to_string()); - kvs.insert(OIDC_ISSUER.to_string(), "https://issuer.example".to_string()); - kvs.insert(OIDC_ROLES_CLAIM.to_string(), "app_roles".to_string()); - - cfg.0 - .entry(IDENTITY_OPENID_SUB_SYS.to_string()) - .or_default() - .insert(DEFAULT_DELIMITER.to_string(), kvs); - - let parsed = OidcSys::parse_persisted_configs(&cfg); - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].id, "default"); - assert_eq!(parsed[0].client_id, "console"); - assert_eq!(parsed[0].issuer.as_deref(), Some("https://issuer.example")); - assert!(parsed[0].enabled); - assert_eq!(parsed[0].roles_claim, "app_roles"); - } - - #[test] - fn test_parse_persisted_provider_config_omitted_roles_claim_is_empty() { - let mut cfg = ServerConfig::new(); - let mut kvs = KVS(vec![ - rustfs_config::server_config::KV { - key: ENABLE_KEY.to_string(), - value: EnableState::Off.to_string(), - hidden_if_empty: false, - }, - rustfs_config::server_config::KV { - key: OIDC_CONFIG_URL.to_string(), - value: String::new(), - hidden_if_empty: false, - }, - rustfs_config::server_config::KV { - key: OIDC_CLIENT_ID.to_string(), - value: String::new(), - hidden_if_empty: false, - }, - ]); - kvs.insert( - OIDC_CONFIG_URL.to_string(), - "https://example.com/.well-known/openid-configuration".to_string(), - ); - kvs.insert(OIDC_CLIENT_ID.to_string(), "console".to_string()); - kvs.insert(ENABLE_KEY.to_string(), EnableState::On.to_string()); - - cfg.0 - .entry(IDENTITY_OPENID_SUB_SYS.to_string()) - .or_default() - .insert(DEFAULT_DELIMITER.to_string(), kvs); - - let parsed = OidcSys::parse_persisted_configs(&cfg); - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].roles_claim, ""); - } - - #[test] - fn test_merge_oidc_provider_configs_prefers_env() { - let mut persisted = test_config("default"); - persisted.display_name = "Persisted".to_string(); - - let mut env = test_config("default"); - env.display_name = "Environment".to_string(); - - let merged = merge_oidc_provider_configs(vec![env], vec![persisted]); - assert_eq!(merged.len(), 1); - assert_eq!(merged[0].config.display_name, "Environment"); - assert_eq!(merged[0].source, OidcProviderConfigSource::Env); - } - - #[test] - fn test_oidc_sys_empty() { - let sys = OidcSys::empty().expect("failed to initialize empty OIDC system"); - assert!(!sys.has_providers()); - assert!(sys.config_snapshot().providers().is_empty()); - } - - #[test] - fn build_oidc_http_client_rejects_forbidden_targets_without_allowlist() { - // Cloud metadata endpoint is never allowed. - assert!( - matches!( - build_oidc_http_client("http://169.254.169.254/latest/meta-data/", None, &[], None), - Err(OidcHttpError::ForbiddenOutbound(_)) - ), - "metadata endpoint must be rejected" - ); - // Loopback is rejected by default (no allow-origins configured). - assert!( - matches!( - build_oidc_http_client("http://127.0.0.1:8080/.well-known/openid-configuration", None, &[], None), - Err(OidcHttpError::ForbiddenOutbound(_)) - ), - "loopback must be rejected by default" - ); - // A public hostname passes the up-front shape/host check; the resolved IP is still - // re-classified at connection time by the pinned resolver. - assert!( - build_oidc_http_client("https://accounts.example.com/.well-known/openid-configuration", None, &[], None).is_ok(), - "public https endpoint should build" - ); - } - - #[test] - fn test_should_bypass_proxy_for_oidc_uri_loopback_only() { - assert!(should_bypass_proxy_for_oidc_uri("http://127.0.0.1:9000/.well-known/openid-configuration")); - assert!(should_bypass_proxy_for_oidc_uri("http://localhost:9000/.well-known/openid-configuration")); - assert!(should_bypass_proxy_for_oidc_uri("http://[::1]:9000/.well-known/openid-configuration")); - assert!(!should_bypass_proxy_for_oidc_uri( - "https://idp.example.com/.well-known/openid-configuration" - )); - assert!(!should_bypass_proxy_for_oidc_uri("not-a-url")); - } - - #[test] - fn build_oidc_http_client_honors_explicit_allowlist_for_loopback() { - let policy = OutboundPolicy::from_allowed_origins("http://127.0.0.1:8080").expect("origin should parse"); - assert!( - build_oidc_http_client("http://127.0.0.1:8080/.well-known/openid-configuration", Some(&policy), &[], None).is_ok(), - "explicitly allow-listed loopback origin should build" - ); - // A metadata endpoint stays forbidden even when a loopback origin is allow-listed. - assert!( - matches!( - build_oidc_http_client("http://169.254.169.254/", Some(&policy), &[], None), - Err(OidcHttpError::ForbiddenOutbound(_)) - ), - "metadata endpoint stays forbidden despite an unrelated allow-list entry" - ); - } - - #[tokio::test] - async fn oidc_discovery_reports_forbidden_outbound_without_retrying() { - let config_url = "http://192.168.65.254:8080/realms/rustfs/.well-known/openid-configuration"; - let config = build_mocked_oidc_provider_config("default", config_url); - let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::default()); - - let error = match OidcSys::discover_provider(&config, &http_client).await { - Ok(_) => panic!("private OIDC provider should require an explicit allowlist origin"), - Err(error) => error, - }; - - assert!(error.contains("OIDC provider discovery blocked by outbound policy")); - assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); - assert!(!error.contains("discovery failed for all issuer variants")); - } - - #[tokio::test] - async fn oidc_explicit_issuer_reports_forbidden_discovery_endpoint() { - let config_url = "http://192.168.65.254:8080/realms/rustfs/.well-known/openid-configuration"; - let mut config = build_mocked_oidc_provider_config("default", config_url); - config.issuer = Some("https://idp.example.com/realms/rustfs".to_string()); - let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::default()); - - let error = match OidcSys::discover_provider(&config, &http_client).await { - Ok(_) => panic!("private OIDC provider should require an explicit allowlist origin"), - Err(error) => error, - }; - - assert!(error.contains("OIDC provider discovery blocked by outbound policy")); - assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); - } - - #[tokio::test] - async fn oidc_explicit_issuer_reports_forbidden_jwks_endpoint() { - let Some((base, handle)) = start_mock_oidc_discovery_server( - |base| { - ( - format!("{base}/realms/rustfs"), - "http://192.168.65.254:8080/realms/rustfs/protocol/openid-connect/certs".to_string(), - "/unused".to_string(), - ) - }, - 1, - ) else { - return; - }; - let mut config = - build_mocked_oidc_provider_config("default", &format!("{base}/realms/rustfs/.well-known/openid-configuration")); - config.issuer = Some(format!("{base}/realms/rustfs")); - let policy = OutboundPolicy::from_allowed_origins(&base).expect("loopback discovery origin should be allowed"); - let http_client = ReqwestHttpClient::with_policy(policy); - - let error = match OidcSys::discover_provider(&config, &http_client).await { - Ok(_) => panic!("private JWKS endpoint should require an explicit allowlist origin"), - Err(error) => error, - }; - - assert!(error.contains("JWKS request blocked by outbound policy")); - assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); - assert!(handle.join().is_ok()); - } - - #[tokio::test] - async fn oidc_token_exchange_reports_forbidden_token_endpoint() { - let provider_id = "default"; - let token_endpoint = "http://192.168.65.254:8080/realms/rustfs/protocol/openid-connect/token"; - let metadata = serde_json::from_value::(serde_json::json!({ - "issuer": "https://idp.example.com/realms/rustfs", - "authorization_endpoint": "https://idp.example.com/realms/rustfs/protocol/openid-connect/auth", - "token_endpoint": token_endpoint, - "jwks_uri": "https://idp.example.com/realms/rustfs/protocol/openid-connect/certs", - "response_types_supported": ["code"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"] - })) - .expect("provider metadata should parse"); - let config = build_mocked_oidc_provider_config( - provider_id, - "https://idp.example.com/realms/rustfs/.well-known/openid-configuration", - ); - let state_store = OidcStateStore::new(); - state_store - .insert( - "test-state".to_string(), - OidcAuthSession { - provider_id: provider_id.to_string(), - pkce_verifier: "test-pkce-verifier".to_string(), - nonce: "test-nonce".to_string(), - redirect_after: None, - }, - ) - .await; - let sys = OidcSys { - configs: HashMap::from([(provider_id.to_string(), test_sourced_config(config))]), - provider_states: RwLock::new(HashMap::from([( - provider_id.to_string(), - ProviderState { - metadata: DiscoveredProviderMetadata::Console(Box::new(metadata)), - discovered_at: Instant::now(), - }, - )])), - state_store, - http_client: ReqwestHttpClient::with_policy(OutboundPolicy::default()), - }; - - let error = match sys - .exchange_code("test-state", "test-code", "https://console.example.com/oauth_callback") - .await - { - Ok(_) => panic!("private token endpoint should require an explicit allowlist origin"), - Err(error) => error, - }; - - assert!(error.contains("request_error_kind=forbidden_outbound")); - assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); - } - - #[tokio::test] - async fn oidc_reqwest_dns_policy_rejection_stays_typed() { - let calls = Arc::new(AtomicUsize::new(0)); - let config = build_mocked_oidc_provider_config( - "default", - "http://keycloak.internal:8080/realms/rustfs/.well-known/openid-configuration", - ); - let http_client = ReqwestHttpClient::with_policy_and_dns_resolver( - OutboundPolicy::default(), - Arc::new(RejectingDnsResolver { - allow_origin_can_recover: true, - calls: Some(calls.clone()), - }), - ); - - let error = match OidcSys::discover_provider(&config, &http_client).await { - Ok(_) => panic!("private DNS answer should fail discovery"), - Err(error) => error, - }; - - assert!(error.contains("OIDC provider discovery blocked by outbound policy")); - assert!(error.contains(&format!("add http://keycloak.internal:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); - assert_eq!(calls.load(Ordering::Relaxed), 1, "policy rejection must not be retried"); - } - - #[tokio::test] - async fn oidc_explicit_issuer_preserves_dns_policy_rejection() { - let calls = Arc::new(AtomicUsize::new(0)); - let mut config = build_mocked_oidc_provider_config( - "default", - "http://keycloak.internal:8080/realms/rustfs/.well-known/openid-configuration", - ); - config.issuer = Some("https://idp.example.com/realms/rustfs".to_string()); - let http_client = ReqwestHttpClient::with_policy_and_dns_resolver( - OutboundPolicy::default(), - Arc::new(RejectingDnsResolver { - allow_origin_can_recover: true, - calls: Some(calls.clone()), - }), - ); - - let error = match OidcSys::discover_provider(&config, &http_client).await { - Ok(_) => panic!("private DNS answer should fail discovery"), - Err(error) => error, - }; - - assert!(error.contains("OIDC provider discovery blocked by outbound policy")); - assert!(error.contains(&format!("add http://keycloak.internal:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); - assert_eq!(calls.load(Ordering::Relaxed), 1, "policy rejection must not be retried"); - } - - #[tokio::test] - async fn oidc_nonrecoverable_dns_policy_rejection_omits_allowlist_hint() { - let uri = "http://metadata.internal/latest/meta-data"; - let http_client = ReqwestHttpClient::with_policy_and_dns_resolver( - OutboundPolicy::default(), - Arc::new(RejectingDnsResolver { - allow_origin_can_recover: false, - calls: None, - }), - ); - let request = http::Request::builder() - .uri(uri) - .body(Vec::new()) - .expect("request should build"); - - let error = http_client - .call(request) - .await - .expect_err("metadata DNS answer should be rejected"); - let message = error.to_string(); - - assert!(matches!(error, OidcHttpError::ForbiddenOutbound(_))); - assert!(message.contains("metadata.internal")); - assert!(!message.contains(&format!("add http://metadata.internal to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); - } - - #[test] - fn oidc_metadata_endpoint_rejection_does_not_offer_allowlist_bypass() { - let error = - build_oidc_http_client("http://169.254.169.254/latest/meta-data/", Some(&OutboundPolicy::default()), &[], None) - .expect_err("metadata endpoint must remain forbidden"); - let message = error.to_string(); - - assert!(message.contains("metadata endpoint")); - assert!(!message.contains(&format!("add http://169.254.169.254 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); - } - - /// Serve exactly `body_len` bytes with no `Content-Length`, so the body ends only at EOF - /// and the size guard cannot rely on an advertised length. - fn start_unbounded_body_server(body_len: usize) -> Option<(String, std::thread::JoinHandle<()>)> { - use std::io::{Read, Write}; - use std::net::TcpListener; - use std::sync::mpsc; - use std::time::Duration; - - let listener = match TcpListener::bind("127.0.0.1:0") { - Ok(listener) => listener, - Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None, - Err(err) => panic!("test listener should bind: {err}"), - }; - let base = format!("http://{}", listener.local_addr().expect("listener local address should be available")); - let (ready_tx, ready_rx) = mpsc::channel(); - - let handle = std::thread::spawn(move || { - let _ = ready_tx.send(()); - let Ok((mut stream, _)) = listener.accept() else { - return; - }; - let _ = stream.set_read_timeout(Some(Duration::from_secs(1))); - let mut buffer = [0u8; 4096]; - let _ = stream.read(&mut buffer); - let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n"); - - let chunk = vec![b'a'; 64 * 1024]; - let mut written = 0usize; - while written < body_len { - let take = chunk.len().min(body_len - written); - if stream.write_all(&chunk[..take]).is_err() { - break; - } - written += take; - } - let _ = stream.flush(); - }); - ready_rx - .recv_timeout(Duration::from_millis(100)) - .expect("mock body server should become ready"); - - Some((base, handle)) - } - - async fn fetch_oidc_mock_body(base: &str) -> Result, OidcHttpError> { - let policy = OutboundPolicy::from_allowed_origins(base).expect("origin should parse"); - let client = ReqwestHttpClient::with_policy(policy); - let request = http::Request::builder() - .method(http::Method::GET) - .uri(base) - .body(Vec::new()) - .expect("request should build"); - client.call(request).await.map(http::Response::into_body) - } - - #[tokio::test] - async fn oidc_response_body_at_the_limit_is_accepted() { - let Some((base, handle)) = start_unbounded_body_server(MAX_OIDC_RESPONSE_SIZE) else { - return; - }; - - let body = fetch_oidc_mock_body(&base) - .await - .expect("a body at the limit must be accepted"); - - assert_eq!(body.len(), MAX_OIDC_RESPONSE_SIZE); - handle.join().expect("mock body server thread should exit"); - } - - #[tokio::test] - async fn oidc_response_body_past_the_limit_is_rejected() { - let Some((base, handle)) = start_unbounded_body_server(MAX_OIDC_RESPONSE_SIZE + 1) else { - return; - }; - - let err = fetch_oidc_mock_body(&base) - .await - .map(|body| body.len()) - .expect_err("an oversized provider response must fail closed instead of being buffered"); - - assert!( - matches!(err, OidcHttpError::ResponseTooLarge(MAX_OIDC_RESPONSE_SIZE)), - "unexpected error: {err}" - ); - handle.join().expect("mock body server thread should exit"); - } - - #[test] - fn test_oidc_provider_config_debug_redacts_client_secret() { - let config = OidcProviderConfig { - client_secret: Some("oidc-client-secret".to_string()), - ..test_config("default") - }; - let sourced = SourcedOidcProviderConfig { - config, - source: OidcProviderConfigSource::Persisted, - }; - - let rendered = format!("{sourced:?}"); - - assert!(!rendered.contains("oidc-client-secret")); - assert!(rendered.contains(REDACTED_SECRET)); - assert!(rendered.contains("client-id")); - } - - #[test] - fn test_parse_enable_state_on() { - assert!(OidcSys::parse_enable_state("on", false, false)); - } - - #[test] - fn test_parse_enable_state_off() { - assert!(!OidcSys::parse_enable_state("off", true, true)); - } - - #[test] - fn test_parse_enable_state_empty_returns_default() { - assert!(OidcSys::parse_enable_state("", true, false)); - assert!(!OidcSys::parse_enable_state("", false, true)); - } - - #[test] - fn test_parse_enable_state_invalid_returns_error_default() { - assert!(!OidcSys::parse_enable_state("garbage", true, false)); - assert!(OidcSys::parse_enable_state("garbage", false, true)); - } - - #[test] - fn test_list_visible_providers_hides_hidden_provider() { - let visible = test_config("dex"); - let mut hidden = test_config("kubernetes"); - hidden.hide_from_ui = true; - - let sys = make_test_sys(vec![visible, hidden]); - let listed = sys.list_visible_providers(); - - assert_eq!(listed.len(), 1); - assert!(listed.iter().any(|provider| provider.provider_id == "dex")); - assert!(!listed.iter().any(|provider| provider.provider_id == "kubernetes")); - } - - #[test] - fn test_hidden_provider_still_resolvable_for_sts() { - let visible = test_config("dex"); - let mut hidden = test_config("kubernetes"); - hidden.hide_from_ui = true; - - let sys = make_test_sys(vec![visible, hidden]); - - assert!(sys.get_provider_config("kubernetes").is_some()); - assert!(sys.get_provider_config("dex").is_some()); - } - - #[test] - fn test_config_snapshot_includes_hidden_for_replication() { - let visible = test_config("dex"); - let mut hidden = test_config("kubernetes"); - hidden.hide_from_ui = true; - - let sys = make_test_sys(vec![visible, hidden]); - - assert_eq!(sys.config_snapshot().providers().len(), 2); - } - - #[test] - fn test_list_providers_includes_hidden_for_compatibility() { - let visible = test_config("dex"); - let mut hidden = test_config("kubernetes"); - hidden.hide_from_ui = true; - - let sys = make_test_sys(vec![visible, hidden]); - - assert_eq!(sys.list_providers().len(), 2); - assert_eq!(sys.list_visible_providers().len(), 1); - } - - #[test] - fn test_hide_from_ui_default_is_false() { - let config = test_config("default"); - assert!(!config.hide_from_ui); - } - - #[test] - fn test_parse_persisted_hide_from_ui_off_is_false() { - let mut cfg = ServerConfig::new(); - let mut kvs = KVS(vec![ - rustfs_config::server_config::KV { - key: ENABLE_KEY.to_string(), - value: EnableState::On.to_string(), - hidden_if_empty: false, - }, - rustfs_config::server_config::KV { - key: OIDC_CONFIG_URL.to_string(), - value: "https://example.com/.well-known/openid-configuration".to_string(), - hidden_if_empty: false, - }, - rustfs_config::server_config::KV { - key: OIDC_CLIENT_ID.to_string(), - value: "console".to_string(), - hidden_if_empty: false, - }, - ]); - kvs.insert(OIDC_HIDE_FROM_UI.to_string(), EnableState::Off.to_string()); - - cfg.0 - .entry(IDENTITY_OPENID_SUB_SYS.to_string()) - .or_default() - .insert(DEFAULT_DELIMITER.to_string(), kvs); - - let parsed = OidcSys::parse_persisted_configs(&cfg); - assert_eq!(parsed.len(), 1); - assert!(!parsed[0].hide_from_ui); - } - - #[test] - fn test_parse_persisted_hide_from_ui_missing_defaults_false() { - let mut cfg = ServerConfig::new(); - let kvs = KVS(vec![ - rustfs_config::server_config::KV { - key: ENABLE_KEY.to_string(), - value: EnableState::On.to_string(), - hidden_if_empty: false, - }, - rustfs_config::server_config::KV { - key: OIDC_CONFIG_URL.to_string(), - value: "https://example.com/.well-known/openid-configuration".to_string(), - hidden_if_empty: false, - }, - rustfs_config::server_config::KV { - key: OIDC_CLIENT_ID.to_string(), - value: "console".to_string(), - hidden_if_empty: false, - }, - ]); - - cfg.0 - .entry(IDENTITY_OPENID_SUB_SYS.to_string()) - .or_default() - .insert(DEFAULT_DELIMITER.to_string(), kvs); - - let parsed = OidcSys::parse_persisted_configs(&cfg); - assert_eq!(parsed.len(), 1); - assert!(!parsed[0].hide_from_ui); - } - - #[test] - fn test_parse_persisted_hide_from_ui() { - let mut cfg = ServerConfig::new(); - let mut kvs = KVS(vec![ - rustfs_config::server_config::KV { - key: ENABLE_KEY.to_string(), - value: EnableState::On.to_string(), - hidden_if_empty: false, - }, - rustfs_config::server_config::KV { - key: OIDC_CONFIG_URL.to_string(), - value: "https://example.com/.well-known/openid-configuration".to_string(), - hidden_if_empty: false, - }, - rustfs_config::server_config::KV { - key: OIDC_CLIENT_ID.to_string(), - value: "console".to_string(), - hidden_if_empty: false, - }, - ]); - kvs.insert(OIDC_HIDE_FROM_UI.to_string(), EnableState::On.to_string()); - - cfg.0 - .entry(IDENTITY_OPENID_SUB_SYS.to_string()) - .or_default() - .insert(DEFAULT_DELIMITER.to_string(), kvs); - - let parsed = OidcSys::parse_persisted_configs(&cfg); - assert_eq!(parsed.len(), 1); - assert!(parsed[0].hide_from_ui); - } - - #[test] - fn role_policy_does_not_map_groups_as_policies() { - let mut config = test_config("authentik"); - config.role_policy = "consoleAdmin".to_string(); - config.claim_name = "policy".to_string(); - - let sys = make_test_sys(vec![config]); - - let claims = OidcClaims { - groups: vec!["authentik Admins".to_string(), "users".to_string()], - raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly"]))]), - ..Default::default() - }; - - let (policies, groups) = sys.map_claims_to_policies("authentik", &claims); - assert_eq!(groups, vec!["authentik Admins", "users"]); - assert_eq!(policies, vec!["consoleAdmin"]); - } - - #[test] - fn group_claim_policy_names_cover_only_prefixed_group_values() { - let mut config = test_config("ad"); - config.claim_prefix = "oidc-".to_string(); - let sys = make_test_sys(vec![config]); - let claims = OidcClaims { - groups: vec![ - "EXAMPLE\\Domain Users".to_string(), - "admins".to_string(), - "admins".to_string(), - ], - ..Default::default() - }; - - assert_eq!( - sys.group_claim_policy_names("ad", &claims), - vec!["oidc-EXAMPLE\\Domain Users", "oidc-admins"] - ); - assert!(sys.group_claim_policy_names("missing-provider", &claims).is_empty()); - } - - #[test] - fn group_claim_policy_names_include_merged_roles_claim_values() { - let mut config = test_config("keycloak"); - config.roles_claim = "roles".to_string(); - let raw = HashMap::from([ - ("groups".to_string(), serde_json::json!(["readonly"])), - ("roles".to_string(), serde_json::json!(["offline_access", "default-roles-corp"])), - ]); - let claims = OidcClaims { - groups: extract_canonical_group_values(&raw, &config.groups_claim, &config.roles_claim), - raw, - ..Default::default() - }; - let sys = make_test_sys(vec![config]); - - assert_eq!( - sys.group_claim_policy_names("keycloak", &claims), - vec!["default-roles-corp", "offline_access", "readonly"] - ); - } - - #[test] - fn group_claim_policy_names_exclude_role_policy_and_dedicated_policy_claim() { - let mut role_config = test_config("role"); - role_config.role_policy = "consoleAdmin".to_string(); - let mut claim_config = test_config("claim"); - claim_config.claim_name = "policy".to_string(); - let sys = make_test_sys(vec![role_config, claim_config]); - let claims = OidcClaims { - groups: vec!["readonly".to_string(), "unmapped".to_string()], - raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly", "writeonly"]))]), - ..Default::default() - }; - - assert!(sys.group_claim_policy_names("role", &claims).is_empty()); - // `readonly` is also requested explicitly through the policy claim, so it must resolve. - assert_eq!(sys.group_claim_policy_names("claim", &claims), vec!["unmapped"]); - } - - #[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 blank_role_policy_uses_claim_mapping() { - let mut config = test_config("keycloak"); - config.role_policy = " ".to_string(); - - let sys = make_test_sys(vec![config]); - let claims = OidcClaims { - groups: vec!["readonly".to_string()], - ..Default::default() - }; - - let (policies, groups) = sys.map_claims_to_policies("keycloak", &claims); - assert_eq!(groups, vec!["readonly"]); - assert_eq!(policies, vec!["readonly"]); - } - - #[test] - fn claim_mapping_keeps_groups_with_distinct_primary_claim() { - let mut config = test_config("keycloak"); - config.claim_name = "policy".to_string(); - - let sys = make_test_sys(vec![config]); - let claims = OidcClaims { - groups: vec!["developers".to_string()], - raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly"]))]), - ..Default::default() - }; - - let (policies, groups) = sys.map_claims_to_policies("keycloak", &claims); - assert_eq!(groups, vec!["developers"]); - assert_eq!(policies, vec!["developers", "readonly"]); - } - - #[test] - fn test_config_snapshot_lists_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 snapshot = sys.config_snapshot(); - assert_eq!(snapshot.providers().len(), 1); - assert_eq!(snapshot.providers()[0].config.id, "keycloak"); - assert_eq!(snapshot.providers()[0].config.display_name, "Keycloak SSO"); - assert_eq!(snapshot.providers()[0].source, OidcProviderConfigSource::Persisted); - } - - #[test] - fn test_active_config_snapshot_puts_default_first() { - let sys = make_test_sys(vec![test_config("zeta"), test_config("default"), test_config("alpha")]); - - assert_eq!( - sys.config_snapshot() - .providers() - .iter() - .map(|provider| provider.config.id.as_str()) - .collect::>(), - ["default", "alpha", "zeta"] - ); - } - - #[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(), - issuer: None, - client_id: "my-client".to_string(), - client_secret: Some("secret".to_string()), - scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()], - other_audiences: vec![], - 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(), - roles_claim: String::new(), - email_claim: "email".to_string(), - username_claim: "preferred_username".to_string(), - hide_from_ui: false, - }; - - 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/config.rs b/crates/iam/src/oidc/config.rs new file mode 100644 index 000000000..6169ea60b --- /dev/null +++ b/crates/iam/src/oidc/config.rs @@ -0,0 +1,434 @@ +// 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 rustfs_config::oidc::*; +use rustfs_config::server_config::{Config as ServerConfig, KVS}; +use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt; + +pub(super) const REDACTED_SECRET: &str = "***redacted***"; + +fn redacted_optional_secret(value: Option<&str>) -> &'static str { + value.filter(|secret| !secret.is_empty()).map_or("", |_| REDACTED_SECRET) +} + +/// Parsed configuration for a single OIDC provider. +#[derive(Clone, PartialEq, Eq)] +pub struct OidcProviderConfig { + pub id: String, + pub enabled: bool, + pub config_url: String, + pub issuer: Option, + pub client_id: String, + pub client_secret: Option, + pub scopes: Vec, + pub other_audiences: 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 roles_claim: String, + pub email_claim: String, + pub username_claim: String, + pub hide_from_ui: bool, +} + +impl fmt::Debug for OidcProviderConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OidcProviderConfig") + .field("id", &self.id) + .field("enabled", &self.enabled) + .field("config_url", &self.config_url) + .field("issuer", &self.issuer) + .field("client_id", &self.client_id) + .field("client_secret", &redacted_optional_secret(self.client_secret.as_deref())) + .field("scopes", &self.scopes) + .field("other_audiences", &self.other_audiences) + .field("redirect_uri", &self.redirect_uri) + .field("redirect_uri_dynamic", &self.redirect_uri_dynamic) + .field("claim_name", &self.claim_name) + .field("claim_prefix", &self.claim_prefix) + .field("role_policy", &self.role_policy) + .field("display_name", &self.display_name) + .field("groups_claim", &self.groups_claim) + .field("roles_claim", &self.roles_claim) + .field("email_claim", &self.email_claim) + .field("username_claim", &self.username_claim) + .field("hide_from_ui", &self.hide_from_ui) + .finish() + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OidcProviderConfigSource { + Env, + Persisted, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourcedOidcProviderConfig { + pub config: OidcProviderConfig, + pub source: OidcProviderConfigSource, +} + +/// Immutable OIDC configuration entries returned by configuration queries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OidcConfigSnapshot { + providers: Vec, +} + +impl OidcConfigSnapshot { + pub fn new(mut providers: Vec) -> Self { + providers.sort_by(|left, right| { + (left.config.id != "default") + .cmp(&(right.config.id != "default")) + .then_with(|| left.config.id.cmp(&right.config.id)) + }); + Self { providers } + } + + pub fn providers(&self) -> &[SourcedOidcProviderConfig] { + &self.providers + } + + pub fn into_providers(self) -> Vec { + self.providers + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OidcProviderValidationResult { + pub issuer: String, + pub authorization_endpoint: Option, + pub token_endpoint: Option, +} + +/// Compatibility name for the provider summary returned by OIDC list APIs. +pub(crate) type OidcProviderSummary = crate::federation::FederatedProviderView; + +/// Read-only access to the active standard OIDC configuration. +/// +/// This interface stays separate from authentication and generic provider +/// views because its snapshots include OIDC-specific fields and secrets. +pub trait OidcConfigQuery: Send + Sync { + fn config_snapshot(&self) -> OidcConfigSnapshot; +} + +/// Parse all OIDC provider configs from environment variables. +pub(super) fn parse_env_configs() -> Vec { + let mut configs = Vec::new(); + + // Check for the default provider (no suffix) + if let Some(config) = 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) = parse_single_provider(&suffix, &id) { + configs.push(config); + } + } + + configs +} + +pub(super) fn parse_persisted_configs(cfg: &ServerConfig) -> Vec { + let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else { + return Vec::new(); + }; + + let mut configs = Vec::new(); + let mut provider_ids: Vec = subsystem.keys().cloned().collect(); + provider_ids.sort(); + + for raw_id in provider_ids { + let Some(kvs) = subsystem.get(&raw_id) else { + continue; + }; + + let id = if raw_id == DEFAULT_DELIMITER { + "default" + } else { + raw_id.as_str() + }; + if let Some(config) = parse_single_persisted_provider(kvs, id) { + configs.push(config); + } + } + + configs +} + +/// Parse a string as an `EnableState` boolean. +/// Returns `default_if_empty` when the input is empty, and `default_on_error` +/// when parsing fails. +pub(super) fn parse_enable_state(value: &str, default_if_empty: bool, default_on_error: bool) -> bool { + if value.is_empty() { + return default_if_empty; + } + value + .parse::() + .map(|s| s.is_enabled()) + .unwrap_or(default_on_error) +} + +/// Parse a single provider's config from env vars with the given suffix. +pub(super) 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); + let issuer = get_env(ENV_IDENTITY_OPENID_ISSUER); + + // Skip if no config URL + if config_url.is_empty() { + return None; + } + + let enabled = parse_enable_state(&enable_val, true, 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 other_audiences_str = get_env(ENV_IDENTITY_OPENID_OTHER_AUDIENCES); + let other_audiences = other_audiences_str + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + let redirect_uri_dynamic = parse_enable_state(&get_env(ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC), true, 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 roles_claim = get_env(ENV_IDENTITY_OPENID_ROLES_CLAIM); + 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) } + }; + let hide_from_ui = parse_enable_state(&get_env(ENV_IDENTITY_OPENID_HIDE_FROM_UI), false, false); + + Some(OidcProviderConfig { + id: id.to_string(), + enabled, + config_url, + issuer: if issuer.is_empty() { None } else { Some(issuer) }, + client_id: get_env(ENV_IDENTITY_OPENID_CLIENT_ID), + client_secret, + scopes, + other_audiences, + 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, + roles_claim, + email_claim, + username_claim, + hide_from_ui, + }) +} + +pub(super) fn parse_single_persisted_provider(kvs: &KVS, id: &str) -> Option { + let config_url = kvs.get(OIDC_CONFIG_URL); + if config_url.is_empty() { + return None; + } + + let enabled = parse_enable_state(&kvs.lookup(ENABLE_KEY).unwrap_or_default(), false, false); + + let scopes_str = kvs.get(OIDC_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 other_audiences_str = kvs.get(OIDC_OTHER_AUDIENCES); + let other_audiences = other_audiences_str + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + let redirect_uri_dynamic = parse_enable_state(&kvs.lookup(OIDC_REDIRECT_URI_DYNAMIC).unwrap_or_default(), true, true); + + let claim_name = kvs + .lookup(OIDC_CLAIM_NAME) + .unwrap_or_else(|| OIDC_DEFAULT_CLAIM_NAME.to_string()); + let groups_claim = kvs + .lookup(OIDC_GROUPS_CLAIM) + .unwrap_or_else(|| OIDC_DEFAULT_GROUPS_CLAIM.to_string()); + let roles_claim = kvs + .lookup(OIDC_ROLES_CLAIM) + .unwrap_or_else(|| OIDC_DEFAULT_ROLES_CLAIM.to_string()); + let email_claim = kvs + .lookup(OIDC_EMAIL_CLAIM) + .unwrap_or_else(|| OIDC_DEFAULT_EMAIL_CLAIM.to_string()); + let username_claim = kvs + .lookup(OIDC_USERNAME_CLAIM) + .unwrap_or_else(|| OIDC_DEFAULT_USERNAME_CLAIM.to_string()); + let display_name = kvs.lookup(OIDC_DISPLAY_NAME).unwrap_or_else(|| id.to_string()); + let redirect_uri = kvs.lookup(OIDC_REDIRECT_URI).filter(|v| !v.is_empty()); + let client_secret = kvs.lookup(OIDC_CLIENT_SECRET).filter(|v| !v.is_empty()); + let hide_from_ui = parse_enable_state(&kvs.lookup(OIDC_HIDE_FROM_UI).unwrap_or_default(), false, false); + + Some(OidcProviderConfig { + id: id.to_string(), + enabled, + config_url, + issuer: kvs.lookup(OIDC_ISSUER).filter(|v| !v.is_empty()), + client_id: kvs.get(OIDC_CLIENT_ID), + client_secret, + scopes, + other_audiences, + redirect_uri, + redirect_uri_dynamic, + claim_name, + claim_prefix: kvs.get(OIDC_CLAIM_PREFIX), + role_policy: kvs.get(OIDC_ROLE_POLICY), + display_name, + groups_claim, + roles_claim, + email_claim, + username_claim, + hide_from_ui, + }) +} + +pub fn load_oidc_provider_configs_from_env() -> Vec { + parse_env_configs() +} + +pub fn load_oidc_provider_configs_from_server_config(cfg: &ServerConfig) -> Vec { + parse_persisted_configs(cfg) +} + +pub(super) fn merge_oidc_provider_configs( + env_configs: Vec, + persisted_configs: Vec, +) -> Vec { + let mut effective = HashMap::new(); + + for config in persisted_configs { + effective.insert( + config.id.clone(), + SourcedOidcProviderConfig { + config, + source: OidcProviderConfigSource::Persisted, + }, + ); + } + + for config in env_configs { + effective.insert( + config.id.clone(), + SourcedOidcProviderConfig { + config, + source: OidcProviderConfigSource::Env, + }, + ); + } + + let mut configs: Vec = effective.into_values().collect(); + configs.sort_by(|lhs, rhs| lhs.config.id.cmp(&rhs.config.id)); + configs +} + +pub(super) fn load_effective_oidc_provider_configs(server_config: Option<&ServerConfig>) -> Vec { + let env_configs = load_oidc_provider_configs_from_env(); + let persisted_configs = server_config + .map(load_oidc_provider_configs_from_server_config) + .unwrap_or_default(); + merge_oidc_provider_configs(env_configs, persisted_configs) +} + +pub fn load_oidc_config_snapshot(server_config: Option<&ServerConfig>) -> OidcConfigSnapshot { + OidcConfigSnapshot::new(load_effective_oidc_provider_configs(server_config)) +} + +#[cfg(test)] +#[path = "config_tests.rs"] +mod tests; diff --git a/crates/iam/src/oidc/config_tests.rs b/crates/iam/src/oidc/config_tests.rs new file mode 100644 index 000000000..70936545a --- /dev/null +++ b/crates/iam/src/oidc/config_tests.rs @@ -0,0 +1,307 @@ +// 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::*; +use crate::oidc::test_support::*; +use rustfs_config::server_config::{Config as ServerConfig, KVS}; +use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY}; + +#[test] +fn test_parse_single_provider_no_config_url() { + let config = parse_single_provider("_TEST_EMPTY", "test_empty"); + assert!(config.is_none()); +} +#[test] +fn test_parse_single_provider_reads_issuer() { + temp_env::with_vars( + [ + ( + ENV_IDENTITY_OPENID_CONFIG_URL, + Some("http://keycloak.ns.svc.cluster.local:8080/realms/app/.well-known/openid-configuration"), + ), + (ENV_IDENTITY_OPENID_ISSUER, Some("https://app.local/realms/app")), + (ENV_IDENTITY_OPENID_CLIENT_ID, Some("console")), + ], + || { + let config = parse_single_provider("", "default").expect("provider config should parse"); + + assert_eq!(config.issuer.as_deref(), Some("https://app.local/realms/app")); + }, + ); +} +#[test] +fn test_parse_persisted_provider_config() { + let mut cfg = ServerConfig::new(); + let mut kvs = KVS(vec![ + rustfs_config::server_config::KV { + key: ENABLE_KEY.to_string(), + value: EnableState::Off.to_string(), + hidden_if_empty: false, + }, + rustfs_config::server_config::KV { + key: OIDC_CONFIG_URL.to_string(), + value: String::new(), + hidden_if_empty: false, + }, + rustfs_config::server_config::KV { + key: OIDC_CLIENT_ID.to_string(), + value: String::new(), + hidden_if_empty: false, + }, + ]); + kvs.insert( + OIDC_CONFIG_URL.to_string(), + "https://example.com/.well-known/openid-configuration".to_string(), + ); + kvs.insert(OIDC_CLIENT_ID.to_string(), "console".to_string()); + kvs.insert(ENABLE_KEY.to_string(), EnableState::On.to_string()); + kvs.insert(OIDC_ISSUER.to_string(), "https://issuer.example".to_string()); + kvs.insert(OIDC_ROLES_CLAIM.to_string(), "app_roles".to_string()); + + cfg.0 + .entry(IDENTITY_OPENID_SUB_SYS.to_string()) + .or_default() + .insert(DEFAULT_DELIMITER.to_string(), kvs); + + let parsed = parse_persisted_configs(&cfg); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].id, "default"); + assert_eq!(parsed[0].client_id, "console"); + assert_eq!(parsed[0].issuer.as_deref(), Some("https://issuer.example")); + assert!(parsed[0].enabled); + assert_eq!(parsed[0].roles_claim, "app_roles"); +} +#[test] +fn test_parse_persisted_provider_config_omitted_roles_claim_is_empty() { + let mut cfg = ServerConfig::new(); + let mut kvs = KVS(vec![ + rustfs_config::server_config::KV { + key: ENABLE_KEY.to_string(), + value: EnableState::Off.to_string(), + hidden_if_empty: false, + }, + rustfs_config::server_config::KV { + key: OIDC_CONFIG_URL.to_string(), + value: String::new(), + hidden_if_empty: false, + }, + rustfs_config::server_config::KV { + key: OIDC_CLIENT_ID.to_string(), + value: String::new(), + hidden_if_empty: false, + }, + ]); + kvs.insert( + OIDC_CONFIG_URL.to_string(), + "https://example.com/.well-known/openid-configuration".to_string(), + ); + kvs.insert(OIDC_CLIENT_ID.to_string(), "console".to_string()); + kvs.insert(ENABLE_KEY.to_string(), EnableState::On.to_string()); + + cfg.0 + .entry(IDENTITY_OPENID_SUB_SYS.to_string()) + .or_default() + .insert(DEFAULT_DELIMITER.to_string(), kvs); + + let parsed = parse_persisted_configs(&cfg); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].roles_claim, ""); +} +#[test] +fn test_merge_oidc_provider_configs_prefers_env() { + let mut persisted = test_config("default"); + persisted.display_name = "Persisted".to_string(); + + let mut env = test_config("default"); + env.display_name = "Environment".to_string(); + + let merged = merge_oidc_provider_configs(vec![env], vec![persisted]); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].config.display_name, "Environment"); + assert_eq!(merged[0].source, OidcProviderConfigSource::Env); +} +#[test] +fn test_oidc_provider_config_debug_redacts_client_secret() { + let config = OidcProviderConfig { + client_secret: Some("oidc-client-secret".to_string()), + ..test_config("default") + }; + let sourced = SourcedOidcProviderConfig { + config, + source: OidcProviderConfigSource::Persisted, + }; + + let rendered = format!("{sourced:?}"); + + assert!(!rendered.contains("oidc-client-secret")); + assert!(rendered.contains(REDACTED_SECRET)); + assert!(rendered.contains("client-id")); +} +#[test] +fn test_parse_enable_state_on() { + assert!(parse_enable_state("on", false, false)); +} +#[test] +fn test_parse_enable_state_off() { + assert!(!parse_enable_state("off", true, true)); +} +#[test] +fn test_parse_enable_state_empty_returns_default() { + assert!(parse_enable_state("", true, false)); + assert!(!parse_enable_state("", false, true)); +} +#[test] +fn test_parse_enable_state_invalid_returns_error_default() { + assert!(!parse_enable_state("garbage", true, false)); + assert!(parse_enable_state("garbage", false, true)); +} +#[test] +fn test_hide_from_ui_default_is_false() { + let config = test_config("default"); + assert!(!config.hide_from_ui); +} +#[test] +fn test_parse_persisted_hide_from_ui_off_is_false() { + let mut cfg = ServerConfig::new(); + let mut kvs = KVS(vec![ + rustfs_config::server_config::KV { + key: ENABLE_KEY.to_string(), + value: EnableState::On.to_string(), + hidden_if_empty: false, + }, + rustfs_config::server_config::KV { + key: OIDC_CONFIG_URL.to_string(), + value: "https://example.com/.well-known/openid-configuration".to_string(), + hidden_if_empty: false, + }, + rustfs_config::server_config::KV { + key: OIDC_CLIENT_ID.to_string(), + value: "console".to_string(), + hidden_if_empty: false, + }, + ]); + kvs.insert(OIDC_HIDE_FROM_UI.to_string(), EnableState::Off.to_string()); + + cfg.0 + .entry(IDENTITY_OPENID_SUB_SYS.to_string()) + .or_default() + .insert(DEFAULT_DELIMITER.to_string(), kvs); + + let parsed = parse_persisted_configs(&cfg); + assert_eq!(parsed.len(), 1); + assert!(!parsed[0].hide_from_ui); +} +#[test] +fn test_parse_persisted_hide_from_ui_missing_defaults_false() { + let mut cfg = ServerConfig::new(); + let kvs = KVS(vec![ + rustfs_config::server_config::KV { + key: ENABLE_KEY.to_string(), + value: EnableState::On.to_string(), + hidden_if_empty: false, + }, + rustfs_config::server_config::KV { + key: OIDC_CONFIG_URL.to_string(), + value: "https://example.com/.well-known/openid-configuration".to_string(), + hidden_if_empty: false, + }, + rustfs_config::server_config::KV { + key: OIDC_CLIENT_ID.to_string(), + value: "console".to_string(), + hidden_if_empty: false, + }, + ]); + + cfg.0 + .entry(IDENTITY_OPENID_SUB_SYS.to_string()) + .or_default() + .insert(DEFAULT_DELIMITER.to_string(), kvs); + + let parsed = parse_persisted_configs(&cfg); + assert_eq!(parsed.len(), 1); + assert!(!parsed[0].hide_from_ui); +} +#[test] +fn test_parse_persisted_hide_from_ui() { + let mut cfg = ServerConfig::new(); + let mut kvs = KVS(vec![ + rustfs_config::server_config::KV { + key: ENABLE_KEY.to_string(), + value: EnableState::On.to_string(), + hidden_if_empty: false, + }, + rustfs_config::server_config::KV { + key: OIDC_CONFIG_URL.to_string(), + value: "https://example.com/.well-known/openid-configuration".to_string(), + hidden_if_empty: false, + }, + rustfs_config::server_config::KV { + key: OIDC_CLIENT_ID.to_string(), + value: "console".to_string(), + hidden_if_empty: false, + }, + ]); + kvs.insert(OIDC_HIDE_FROM_UI.to_string(), EnableState::On.to_string()); + + cfg.0 + .entry(IDENTITY_OPENID_SUB_SYS.to_string()) + .or_default() + .insert(DEFAULT_DELIMITER.to_string(), kvs); + + let parsed = parse_persisted_configs(&cfg); + assert_eq!(parsed.len(), 1); + assert!(parsed[0].hide_from_ui); +} +#[test] +fn test_active_config_snapshot_puts_default_first() { + let sys = make_test_sys(vec![test_config("zeta"), test_config("default"), test_config("alpha")]); + + assert_eq!( + sys.config_snapshot() + .providers() + .iter() + .map(|provider| provider.config.id.as_str()) + .collect::>(), + ["default", "alpha", "zeta"] + ); +} +#[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(), + issuer: None, + client_id: "my-client".to_string(), + client_secret: Some("secret".to_string()), + scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()], + other_audiences: vec![], + 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(), + roles_claim: String::new(), + email_claim: "email".to_string(), + username_claim: "preferred_username".to_string(), + hide_from_ui: false, + }; + + 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/mod.rs b/crates/iam/src/oidc/mod.rs new file mode 100644 index 000000000..4acfb6364 --- /dev/null +++ b/crates/iam/src/oidc/mod.rs @@ -0,0 +1,37 @@ +// 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. +//! OpenID Connect configuration, provider discovery, runtime flows, and state. + +mod config; +mod provider; +mod runtime; +mod state; +mod transport; + +#[cfg(any(test, feature = "test-util"))] +mod test_support; + +pub use config::{ + OidcConfigQuery, OidcConfigSnapshot, OidcProviderConfig, OidcProviderConfigSource, OidcProviderValidationResult, + SourcedOidcProviderConfig, load_oidc_config_snapshot, load_oidc_provider_configs_from_env, + load_oidc_provider_configs_from_server_config, +}; +pub use provider::validate_oidc_provider_config_with_extra_root_ca; +pub use runtime::{OidcSys, StandardOidcAdapter}; +pub use transport::{ + OidcExtraRootCaMaterial, OidcExtraRootCaProvider, OidcPluginAuthnMetricsSnapshot, oidc_plugin_authn_metrics_snapshot, +}; + +#[cfg(any(test, feature = "test-util"))] +pub use test_support::{make_test_sys, test_config}; diff --git a/crates/iam/src/oidc/provider.rs b/crates/iam/src/oidc/provider.rs new file mode 100644 index 000000000..6f0c72a32 --- /dev/null +++ b/crates/iam/src/oidc/provider.rs @@ -0,0 +1,632 @@ +// 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::{ + config::{OidcProviderConfig, OidcProviderValidationResult, SourcedOidcProviderConfig}, + transport::{OidcHttpError, ReqwestHttpClient, oidc_extra_root_certs}, +}; +use crate::{EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC}; +use openidconnect::core::{CoreIdTokenVerifier, CoreJsonWebKeySet, CoreJwsSigningAlgorithm}; +use openidconnect::{ + AsyncHttpClient, ClientId, ClientSecret, DiscoveryError, IssuerUrl, JsonWebKeySetUrl, ProviderMetadataWithLogout, TokenUrl, +}; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::RwLock; +use std::time::{Duration as StdDuration, Instant}; +use tokio::time::sleep; +use tracing::warn; +use url::Url; + +pub(super) const OIDC_JWKS_REFRESH_INTERVAL: StdDuration = StdDuration::from_secs(24 * 60 * 60); +pub(super) const OIDC_DISCOVERY_TRANSPORT_RETRIES: usize = 3; +pub(super) const OIDC_DISCOVERY_TRANSPORT_RETRY_DELAY: StdDuration = StdDuration::from_millis(50); +pub(super) const OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY: &str = "JWKS request blocked by outbound policy"; +pub(super) const OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY: &str = "OIDC provider discovery blocked by outbound policy"; + +// ---- 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. +#[derive(Clone)] +pub(super) struct ProviderState { + pub(super) metadata: DiscoveredProviderMetadata, + pub(super) discovered_at: Instant, +} + +pub(super) struct ProviderRuntime { + states: RwLock>, + http_client: ReqwestHttpClient, +} + +// Workload issuers do not implement the browser authorization flow. Keep their +// verification metadata separate rather than inventing an authorization URL. +#[serde_with::serde_as] +#[derive(Clone, Deserialize)] +pub(super) struct WorkloadProviderMetadata { + issuer: IssuerUrl, + jwks_uri: JsonWebKeySetUrl, + token_endpoint: Option, + #[serde_as(as = "serde_with::VecSkipError<_>")] + id_token_signing_alg_values_supported: Vec, + #[serde(skip)] + jwks: CoreJsonWebKeySet, + // Discovery is extensible; report unsupported fields without logging values. + #[serde(flatten)] + additional_fields: HashMap, +} + +#[derive(Clone)] +pub(super) enum DiscoveredProviderMetadata { + Console(Box), + Workload(Box), +} + +impl DiscoveredProviderMetadata { + pub(super) fn parse(body: &[u8], hide_from_ui: bool) -> Result { + let document: serde_json::Value = serde_json::from_slice(body).map_err(|err| err.to_string())?; + if hide_from_ui + && document + .as_object() + .is_some_and(|fields| !fields.contains_key("authorization_endpoint")) + { + let mut metadata: WorkloadProviderMetadata = serde_json::from_slice(body).map_err(|err| err.to_string())?; + if !metadata.additional_fields.is_empty() { + warn!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "workload_discovery_additional_fields", + field_count = metadata.additional_fields.len(), + "workload discovery contains additional fields" + ); + metadata.additional_fields.clear(); + } + Ok(Self::Workload(Box::new(metadata))) + } else { + serde_json::from_slice(body) + .map(|metadata| Self::Console(Box::new(metadata))) + .map_err(|err| err.to_string()) + } + } + + pub(super) fn console(&self) -> Result<&ProviderMetadataWithLogout, String> { + match self { + Self::Console(metadata) => Ok(metadata), + Self::Workload(_) => Err("OIDC provider has no authorization endpoint; only web identity is supported".into()), + } + } + + pub(super) fn issuer(&self) -> &IssuerUrl { + match self { + Self::Console(metadata) => metadata.issuer(), + Self::Workload(metadata) => &metadata.issuer, + } + } + + pub(super) fn jwks_uri(&self) -> &JsonWebKeySetUrl { + match self { + Self::Console(metadata) => metadata.jwks_uri(), + Self::Workload(metadata) => &metadata.jwks_uri, + } + } + + pub(super) fn set_jwks(self, jwks: CoreJsonWebKeySet) -> Self { + match self { + Self::Console(metadata) => Self::Console(Box::new(metadata.set_jwks(jwks))), + Self::Workload(mut metadata) => { + metadata.jwks = jwks; + Self::Workload(metadata) + } + } + } + + pub(super) fn authorization_endpoint(&self) -> Option { + match self { + Self::Console(metadata) => Some(metadata.authorization_endpoint().to_string()), + Self::Workload(_) => None, + } + } + + pub(super) fn token_endpoint(&self) -> Option<&TokenUrl> { + match self { + Self::Console(metadata) => metadata.token_endpoint(), + Self::Workload(metadata) => metadata.token_endpoint.as_ref(), + } + } + + pub(super) fn verifier(&self, config: &OidcProviderConfig) -> CoreIdTokenVerifier<'static> { + let client_id = ClientId::new(config.client_id.clone()); + let secret = config.client_secret.as_ref().map(|secret| ClientSecret::new(secret.clone())); + let (issuer, jwks, algorithms) = match self { + Self::Console(metadata) => (metadata.issuer(), metadata.jwks(), metadata.id_token_signing_alg_values_supported()), + Self::Workload(metadata) => (&metadata.issuer, &metadata.jwks, &metadata.id_token_signing_alg_values_supported), + }; + let verifier = match secret { + Some(secret) => CoreIdTokenVerifier::new_confidential_client(client_id, secret, issuer.clone(), jwks.clone()), + None => CoreIdTokenVerifier::new_public_client(client_id, issuer.clone(), jwks.clone()), + }; + verifier.set_allowed_algs(algorithms.clone()) + } +} + +// This adapter is used only for discovery/JWKS fetches, never token exchange. +struct JwksAcceptClient<'a> { + inner: &'a ReqwestHttpClient, + discovery_url: Option, +} + +impl<'c> AsyncHttpClient<'c> for JwksAcceptClient<'_> { + type Error = OidcHttpError; + type Future = >::Future; + + fn call(&'c self, mut request: http::Request>) -> Self::Future { + if !self.discovery_url.as_ref().is_some_and(|url| request.uri() == url.as_str()) { + request.headers_mut().insert( + http::header::ACCEPT, + http::HeaderValue::from_static("application/json, application/jwk-set+json"), + ); + } + self.inner.call(request) + } +} + +impl ProviderState { + fn is_stale(&self) -> bool { + self.discovered_at.elapsed() >= OIDC_JWKS_REFRESH_INTERVAL + } +} + +impl ProviderRuntime { + pub(super) fn new(http_client: ReqwestHttpClient, states: HashMap) -> Self { + Self { + states: RwLock::new(states), + http_client, + } + } + + pub(super) fn http_client(&self) -> &ReqwestHttpClient { + &self.http_client + } + + /// Find a provider whose discovered issuer matches the given JWT issuer string. + pub(super) fn find_provider_by_issuer( + &self, + issuer: &str, + configs: &HashMap, + ) -> Option<(String, OidcProviderConfig, ProviderState)> { + let (issuer_scheme, issuer_host, issuer_port, issuer_path) = normalize_issuer(issuer)?; + let map = self + .states + .read() + .map_err(|e| format!("provider state lock poisoned: {e}")) + .ok()?; + for (id, state) in map.iter() { + let provider_issuer = state.metadata.issuer().as_str(); + let Some((provider_scheme, provider_host, provider_port, provider_path)) = normalize_issuer(provider_issuer) else { + continue; + }; + + if issuer_scheme == provider_scheme + && issuer_host == provider_host + && issuer_port == provider_port + && issuer_path == provider_path + && let Some(config) = configs.get(id) + { + return Some((id.clone(), config.config.clone(), state.clone())); + } + } + None + } + + pub(super) fn get_provider_state(&self, provider_id: &str) -> Result { + self.states + .read() + .map_err(|e| format!("provider state lock poisoned: {e}"))? + .get(provider_id) + .cloned() + .ok_or_else(|| format!("provider not discovered: {provider_id}")) + } + + pub(super) async fn refresh_provider_state( + &self, + provider_id: &str, + config: &OidcProviderConfig, + ) -> Result { + let state = discover_provider(config, &self.http_client).await?; + let mut map = self.states.write().map_err(|e| { + let msg = e.to_string(); + format!("provider state lock poisoned: {msg}") + })?; + map.insert(provider_id.to_string(), state.clone()); + + Ok(state) + } + + pub(super) async fn ensure_provider_state( + &self, + provider_id: &str, + config: &OidcProviderConfig, + ) -> Result { + let state = self.get_provider_state(provider_id)?; + if state.is_stale() { + self.refresh_provider_state(provider_id, config).await.or_else(|refresh_err| { + warn!( + "OIDC provider '{}' JWKS metadata refresh skipped due to transient network issue: {}", + provider_id, refresh_err + ); + Ok(state) + }) + } else { + Ok(state) + } + } + + pub(super) async fn ensure_provider_state_if_stale( + &self, + provider_id: &str, + config: &OidcProviderConfig, + state: &ProviderState, + ) -> Result { + if state.is_stale() { + self.refresh_provider_state(provider_id, config).await.or_else(|refresh_err| { + warn!( + "OIDC provider '{}' JWKS metadata refresh skipped due to transient network issue: {}", + provider_id, refresh_err + ); + Ok(state.clone()) + }) + } else { + Ok(state.clone()) + } + } +} + +/// Perform OIDC discovery for a provider. +/// `discover_async` fetches the discovery document and JWKS in one step. +pub(super) async fn discover_provider( + config: &OidcProviderConfig, + http_client: &ReqwestHttpClient, +) -> Result { + if let Some(issuer) = config.issuer.as_deref().filter(|issuer| !issuer.trim().is_empty()) { + return discover_provider_from_config_url(config, issuer, http_client).await; + } + + // The openidconnect crate expects the issuer URL (base), not the + // .well-known/openid-configuration URL. + let base_issuer = normalize_config_url(&config.config_url)?; + let candidates = issuer_candidates(&base_issuer); + let mut last_errors = Vec::new(); + + for candidate_issuer in candidates.iter() { + let issuer_url = IssuerUrl::new(candidate_issuer.clone()).map_err(|e| format!("invalid issuer URL: {e}"))?; + + for attempt in 0..OIDC_DISCOVERY_TRANSPORT_RETRIES { + let discovered = if config.hide_from_ui { + discover_provider_from_config_url(config, candidate_issuer, http_client).await + } else { + let client = JwksAcceptClient { + inner: http_client, + discovery_url: Some( + issuer_url + .join(".well-known/openid-configuration") + .map_err(|err| err.to_string())?, + ), + }; + ProviderMetadataWithLogout::discover_async(issuer_url.clone(), &client) + .await + .map(|metadata| ProviderState { + metadata: DiscoveredProviderMetadata::Console(Box::new(metadata)), + discovered_at: Instant::now(), + }) + .map_err(|err| match err { + DiscoveryError::Request(OidcHttpError::ForbiddenOutbound(reason)) => { + format!("{OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY}: {reason}") + } + err => format!("discovery failed: {err}"), + }) + }; + match discovered { + Ok(state) => return Ok(state), + Err(error) + if error.starts_with(OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY) + || error.starts_with(OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY) => + { + return Err(error); + } + Err(error) => { + let is_transient_transport = error.contains("Request failed"); + let should_retry = is_transient_transport && attempt + 1 < OIDC_DISCOVERY_TRANSPORT_RETRIES; + if should_retry { + warn!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "provider_discovery_transport_retry", + provider_id = %config.id, + config_url = %config.config_url, + issuer_candidate = %candidate_issuer, + attempt = attempt + 1, + max_attempts = OIDC_DISCOVERY_TRANSPORT_RETRIES, + error = %error, + "oidc provider discovery failed" + ); + sleep(OIDC_DISCOVERY_TRANSPORT_RETRY_DELAY).await; + continue; + } + + last_errors.push(format!("issuer '{candidate_issuer}': {error}")); + warn!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "provider_discovery_candidate_failed", + provider_id = %config.id, + config_url = %config.config_url, + issuer_candidate = %candidate_issuer, + attempt = attempt + 1, + max_attempts = OIDC_DISCOVERY_TRANSPORT_RETRIES, + error = %error, + "oidc provider discovery failed" + ); + break; + } + } + } + } + + Err(format!( + "discovery failed for all issuer variants {:?}: {}", + candidates, + last_errors.join("; ") + )) +} + +pub(super) async fn validate_oidc_provider_config(config: &OidcProviderConfig) -> Result { + let http_client = ReqwestHttpClient::new()?; + validate_oidc_provider_config_with_http_client(config, &http_client).await +} + +pub async fn validate_oidc_provider_config_with_extra_root_ca( + config: &OidcProviderConfig, + root_ca_pem: Option<&[u8]>, +) -> Result { + if root_ca_pem.is_none() { + return validate_oidc_provider_config(config).await; + } + + let http_client = ReqwestHttpClient::new_with_extra_root_certs(oidc_extra_root_certs(root_ca_pem)?)?; + validate_oidc_provider_config_with_http_client(config, &http_client).await +} + +async fn validate_oidc_provider_config_with_http_client( + config: &OidcProviderConfig, + http_client: &ReqwestHttpClient, +) -> Result { + let state = discover_provider(config, http_client).await?; + + Ok(OidcProviderValidationResult { + issuer: state.metadata.issuer().to_string(), + authorization_endpoint: state.metadata.authorization_endpoint(), + token_endpoint: state.metadata.token_endpoint().map(ToString::to_string), + }) +} + +async fn discover_provider_from_config_url( + config: &OidcProviderConfig, + issuer: &str, + http_client: &ReqwestHttpClient, +) -> Result { + let issuer_url = IssuerUrl::new(issuer.trim().to_string()).map_err(|e| format!("invalid issuer URL: {e}"))?; + let explicit_issuer = config.issuer.as_deref().is_some_and(|issuer| !issuer.trim().is_empty()); + let discovery_url = if explicit_issuer { + discovery_url_from_config_url(&config.config_url)? + } else { + issuer_url + .join(".well-known/openid-configuration") + .map_err(|err| err.to_string())? + }; + let request = http::Request::builder() + .uri(discovery_url.to_string()) + .method(http::Method::GET) + .header(http::header::ACCEPT, "application/json") + .body(Vec::new()) + .map_err(|err| format!("failed to prepare discovery request: {err}"))?; + + let response = match http_client.call(request).await { + Ok(response) => response, + Err(OidcHttpError::ForbiddenOutbound(reason)) => { + return Err(format!("{OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY}: {reason}")); + } + Err(err) => return Err(format!("discovery request failed: Request failed: {err}")), + }; + if response.status() != http::StatusCode::OK { + return Err(format!("discovery failed: HTTP status code {} at {}", response.status(), discovery_url)); + } + + if !explicit_issuer + && let Some(content_type) = response.headers().get(http::header::CONTENT_TYPE) + && !content_type.to_str().ok().is_some_and(|value| { + value + .split(';') + .next() + .is_some_and(|essence| essence.eq_ignore_ascii_case("application/json")) + }) + { + return Err("Unexpected response Content-Type: expected application/json".into()); + } + + let provider_metadata = DiscoveredProviderMetadata::parse(response.body(), config.hide_from_ui) + .map_err(|err| format!("failed to parse discovery response: {err}"))?; + if provider_metadata.issuer() != &issuer_url { + return Err(format!( + "unexpected issuer URI `{}` (expected `{}`)", + provider_metadata.issuer().as_str(), + issuer_url.as_str() + )); + } + + let jwks_url = if explicit_issuer { + jwks_url_from_config_url(&config.config_url, &issuer_url, provider_metadata.jwks_uri())? + } else { + provider_metadata.jwks_uri().clone() + }; + let jwks = match CoreJsonWebKeySet::fetch_async( + &jwks_url, + &JwksAcceptClient { + inner: http_client, + discovery_url: None, + }, + ) + .await + { + Ok(jwks) => jwks, + Err(DiscoveryError::Request(OidcHttpError::ForbiddenOutbound(reason))) => { + return Err(format!("{OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY}: {reason}")); + } + Err(err) => return Err(format!("failed to fetch JWKS: {err}")), + }; + + Ok(ProviderState { + metadata: provider_metadata.set_jwks(jwks), + discovered_at: Instant::now(), + }) +} + +pub(super) fn normalize_issuer(raw: &str) -> Option<(String, String, u16, String)> { + let parsed = Url::parse(raw).ok()?; + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return None; + } + + let host = parsed.host_str()?.to_ascii_lowercase(); + let port = parsed.port_or_known_default()?; + let normalized_path = { + let path = parsed.path().trim_end_matches('/').to_string(); + if path.is_empty() { "/".to_string() } else { path } + }; + + Some((parsed.scheme().to_string(), host, port, normalized_path)) +} + +pub(super) fn normalize_config_url(config_url: &str) -> Result { + let config_url = config_url.trim(); + let url = Url::parse(config_url).map_err(|e| format!("invalid config_url: {e}"))?; + if url.scheme() != "http" && url.scheme() != "https" { + return Err(format!("invalid config_url scheme: {}", url.scheme())); + } + let host = url.host_str().ok_or_else(|| "config_url missing host".to_string())?; + let path = url.path(); + + // Strip `/.well-known/openid-configuration` (with optional trailing slash) if present. + // Everything else is preserved exactly so the issuer URL matches the provider's discovery + // document (e.g. Authentik includes a trailing slash, Keycloak does not). + let normalized_path = path + .strip_suffix('/') + .unwrap_or(path) + .strip_suffix("/.well-known/openid-configuration") + .unwrap_or(if path == "/" { "" } else { path }); + + if normalized_path.contains("/.well-known/") { + return Err("config_url uses an unsupported .well-known discovery URL".into()); + } + + let mut issuer = format!("{}://{host}", url.scheme()); + if let Some(port) = url.port() { + issuer.push(':'); + issuer.push_str(&port.to_string()); + } + + if !normalized_path.is_empty() { + issuer.push_str(normalized_path); + } + + Ok(issuer) +} + +pub(super) fn discovery_url_from_config_url(config_url: &str) -> Result { + let mut url = Url::parse(config_url.trim()).map_err(|e| format!("invalid config_url: {e}"))?; + if url.scheme() != "http" && url.scheme() != "https" { + return Err(format!("invalid config_url scheme: {}", url.scheme())); + } + if url.host_str().is_none() { + return Err("config_url missing host".to_string()); + } + + let path = url.path().to_string(); + let without_trailing_slash = path.strip_suffix('/').unwrap_or(&path); + if without_trailing_slash.ends_with("/.well-known/openid-configuration") { + url.set_path(without_trailing_slash); + return Ok(url); + } + if without_trailing_slash.contains("/.well-known/") { + return Err("config_url uses an unsupported .well-known discovery URL".into()); + } + + let discovery_path = if without_trailing_slash.is_empty() || without_trailing_slash == "/" { + "/.well-known/openid-configuration".to_string() + } else { + format!("{without_trailing_slash}/.well-known/openid-configuration") + }; + url.set_path(&discovery_path); + Ok(url) +} + +pub(super) fn jwks_url_from_config_url( + config_url: &str, + issuer_url: &IssuerUrl, + jwks_url: &JsonWebKeySetUrl, +) -> Result { + let issuer = issuer_url.url(); + let jwks = jwks_url.url(); + if issuer.origin() != jwks.origin() { + return Ok(jwks_url.clone()); + } + + let issuer_path = issuer.path().trim_end_matches('/'); + let Some(suffix) = jwks.path().strip_prefix(issuer_path) else { + return Ok(jwks_url.clone()); + }; + if !suffix.is_empty() && !suffix.starts_with('/') { + return Ok(jwks_url.clone()); + } + + let mut internal_url = + Url::parse(&normalize_config_url(config_url)?).map_err(|err| format!("invalid config_url issuer base: {err}"))?; + let internal_path = internal_url.path().trim_end_matches('/'); + internal_url.set_path(&format!("{internal_path}{suffix}")); + internal_url.set_query(jwks.query()); + Ok(JsonWebKeySetUrl::from_url(internal_url)) +} + +pub(super) fn issuer_candidates(base: &str) -> Vec { + let original = base.trim(); + let mut variants = Vec::with_capacity(2); + variants.push(original.to_string()); + + let toggled = if original.ends_with('/') { + original.trim_end_matches('/').to_string() + } else { + format!("{original}/") + }; + variants.push(toggled); + + variants +} + +#[cfg(test)] +#[path = "provider_tests.rs"] +mod tests; diff --git a/crates/iam/src/oidc/provider_tests.rs b/crates/iam/src/oidc/provider_tests.rs new file mode 100644 index 000000000..efa6c543b --- /dev/null +++ b/crates/iam/src/oidc/provider_tests.rs @@ -0,0 +1,712 @@ +// 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::*; +use crate::oidc::{runtime::*, state::*, test_support::*, transport::*}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use openidconnect::core::CoreIdToken; +use openidconnect::{IssuerUrl, JsonWebKeySetUrl, Nonce}; +use rustfs_utils::egress::{ENV_OUTBOUND_ALLOW_ORIGINS, OutboundPolicy}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use url::Url; + +#[test] +fn test_normalize_issuer_matches() { + let lhs = normalize_issuer("https://idp.example.com/.well-known/openid-configuration/").unwrap(); + let rhs = normalize_issuer("https://idp.example.com/.well-known/openid-configuration").unwrap(); + assert_eq!(lhs, rhs); + assert_eq!( + lhs, + ( + "https".to_string(), + "idp.example.com".to_string(), + 443, + "/.well-known/openid-configuration".to_string() + ) + ); +} +#[test] +fn test_normalize_config_url() { + // --- Well-known suffix stripping --- + // Bare well-known URL → stripped to just the host + assert_eq!( + normalize_config_url("https://idp.example.com/.well-known/openid-configuration").unwrap(), + "https://idp.example.com" + ); + // Trailing slash after well-known suffix is also stripped + assert_eq!( + normalize_config_url("https://idp.example.com/.well-known/openid-configuration/").unwrap(), + "https://idp.example.com" + ); + // Well-known under a sub-path (Keycloak realms) + assert_eq!( + normalize_config_url("https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration").unwrap(), + "https://keycloak.example.com/realms/myrealm" + ); + + // --- Providers WITHOUT trailing slash (Keycloak, Auth0, Okta, Google) --- + assert_eq!( + normalize_config_url("https://keycloak.example.com/realms/myrealm").unwrap(), + "https://keycloak.example.com/realms/myrealm" + ); + assert_eq!( + normalize_config_url("https://idp.example.com/custom/realm").unwrap(), + "https://idp.example.com/custom/realm" + ); + + // --- Providers WITH trailing slash (Authentik) --- + assert_eq!( + normalize_config_url("https://auth.example.com/application/o/myapp/").unwrap(), + "https://auth.example.com/application/o/myapp/" + ); + + // --- Root-level issuer (bare host) --- + assert_eq!(normalize_config_url("https://idp.example.com").unwrap(), "https://idp.example.com"); + assert_eq!(normalize_config_url("https://idp.example.com/").unwrap(), "https://idp.example.com"); + + // --- Custom port --- + assert_eq!( + normalize_config_url("https://idp.example.com:8443/auth/realms/test").unwrap(), + "https://idp.example.com:8443/auth/realms/test" + ); + assert_eq!( + normalize_config_url("http://localhost:8080/application/o/app/").unwrap(), + "http://localhost:8080/application/o/app/" + ); + + // --- Error cases --- + assert!(normalize_config_url("https://idp.example.com/.well-known/invalid").is_err()); + assert!(normalize_config_url("gopher://idp.example.com").is_err()); + assert!(normalize_config_url("not-a-url").is_err()); +} +#[test] +fn test_discovery_url_from_config_url() { + assert_eq!( + discovery_url_from_config_url("https://idp.example.com/.well-known/openid-configuration") + .expect("config URL should parse") + .as_str(), + "https://idp.example.com/.well-known/openid-configuration" + ); + assert_eq!( + discovery_url_from_config_url("https://idp.example.com/realms/app") + .expect("issuer URL should derive discovery URL") + .as_str(), + "https://idp.example.com/realms/app/.well-known/openid-configuration" + ); + assert!(discovery_url_from_config_url("https://idp.example.com/.well-known/not-openid").is_err()); +} +#[test] +fn test_issuer_candidates() { + assert_eq!( + issuer_candidates("https://idp.example.com/realm"), + vec![ + "https://idp.example.com/realm".to_string(), + "https://idp.example.com/realm/".to_string() + ] + ); + assert_eq!( + issuer_candidates("https://idp.example.com/realm/"), + vec![ + "https://idp.example.com/realm/".to_string(), + "https://idp.example.com/realm".to_string() + ] + ); + assert_eq!( + issuer_candidates("https://idp.example.com"), + vec!["https://idp.example.com".to_string(), "https://idp.example.com/".to_string()] + ); +} +#[test] +fn workload_metadata_requires_hidden_provider_and_valid_verification_fields() { + let document = serde_json::json!({ + "issuer": "https://issuer.example.com", + "jwks_uri": "https://issuer.example.com/jwks", + "response_types_supported": ["id_token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["ES256"], + }); + let parse = + |value: &serde_json::Value, hidden| DiscoveredProviderMetadata::parse(&serde_json::to_vec(value).unwrap(), hidden); + let metadata = parse(&document, true).expect("hidden workload metadata should parse"); + assert!(metadata.authorization_endpoint().is_none()); + assert!(metadata.console().err().unwrap().contains("only web identity")); + assert!(parse(&document, false).err().unwrap().contains("authorization_endpoint")); + for field in ["issuer", "jwks_uri", "id_token_signing_alg_values_supported"] { + let mut invalid = document.clone(); + invalid.as_object_mut().unwrap().remove(field); + assert!(parse(&invalid, true).err().unwrap().contains(field), "missing {field}"); + } + for endpoint in [serde_json::Value::Null, serde_json::json!(""), serde_json::json!("not a URL")] { + let mut invalid = document.clone(); + invalid["authorization_endpoint"] = endpoint; + assert!(parse(&invalid, true).is_err(), "invalid endpoint must not select workload metadata"); + } + let mut complete = document; + complete["authorization_endpoint"] = serde_json::json!("https://issuer.example.com/authorize"); + for hidden in [true, false] { + let metadata = parse(&complete, hidden).expect("full providers keep the existing parser"); + assert!(metadata.console().is_ok()); + assert!(metadata.token_endpoint().is_none(), "token endpoint remains optional"); + } +} +#[test] +fn workload_metadata_rejects_duplicate_fields() { + for hidden in [false, true] { + let document = format!( + r#"{{"issuer":"https://wrong.example.com","issuer":"https://issuer.example.com",{}"jwks_uri":"https://issuer.example.com/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256"]}}"#, + if hidden { + "" + } else { + r#""authorization_endpoint":"https://issuer.example.com/authorize","# + }, + ); + let error = DiscoveredProviderMetadata::parse(document.as_bytes(), hidden) + .err() + .expect("duplicate issuer must fail"); + assert!(error.contains("duplicate field"), "{error}"); + } +} +#[test] +fn workload_verifier_preserves_algorithm_secret_and_audience_policy() { + let secret = Nonce::new_random().secret().to_string(); + let mut config = build_mocked_oidc_provider_config("workload", "https://issuer.example.com"); + config.client_secret = Some(secret.clone()); + config.other_audiences = vec!["additional-audience".into()]; + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let payload = serde_json::json!({ + "iss": config.config_url, "sub": "repo:example/project:ref:refs/heads/main", + "aud": [config.client_id, "additional-audience"], "iat": now, "exp": now + 300, + }); + let signed = + jsonwebtoken::encode(&Header::new(Algorithm::HS256), &payload, &EncodingKey::from_secret(secret.as_bytes())).unwrap(); + let token: CoreIdToken = signed.parse().unwrap(); + for workload in [false, true] { + for (algorithms, accepted) in [ + (serde_json::json!(["HS256", "unsupported-future-algorithm"]), true), + (serde_json::json!(["ES256"]), false), + (serde_json::json!(["unsupported-future-algorithm"]), false), + ] { + let mut document = serde_json::json!({ + "issuer": config.config_url, "jwks_uri": "https://issuer.example.com/jwks", + "response_types_supported": ["id_token"], "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": algorithms, + }); + if !workload { + document["authorization_endpoint"] = serde_json::json!("https://issuer.example.com/authorize"); + } + let metadata = DiscoveredProviderMetadata::parse(&serde_json::to_vec(&document).unwrap(), true).unwrap(); + let verifier = metadata + .verifier(&config) + .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); + assert_eq!( + token.claims(&verifier, |_: Option<&Nonce>| Ok(())).is_ok(), + accepted, + "workload={workload}, algorithms={algorithms}" + ); + if accepted { + assert!( + token.claims(&metadata.verifier(&config), |_: Option<&Nonce>| Ok(())).is_err(), + "additional audiences require explicit trust" + ); + let mut wrong_secret = config.clone(); + wrong_secret.client_secret = Some(Nonce::new_random().secret().to_string()); + let verifier = metadata + .verifier(&wrong_secret) + .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); + assert!( + token.claims(&verifier, |_: Option<&Nonce>| Ok(())).is_err(), + "incorrect client secret must fail" + ); + } + } + } +} +#[tokio::test] +async fn workload_discovery_stops_after_forbidden_jwks() { + let requests = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = Arc::clone(&requests); + let (base, handle) = start_mock_oidc_discovery_server_with_jwks( + |base| (base.to_string(), "http://192.168.65.254:8080/jwks".into(), "/jwks".into()), + 2, + "ES256", + true, + move |_| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + serde_json::json!({"keys": []}).to_string() + }, + ) + .expect("workload discovery mock must bind"); + let mut config = build_mocked_oidc_provider_config("workload", &base); + config.hide_from_ui = true; + let client = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).unwrap()); + let error = discover_provider(&config, &client) + .await + .err() + .expect("private JWKS must be blocked"); + assert!(error.starts_with(OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY), "{error}"); + handle.join().unwrap(); + assert_eq!( + requests.load(std::sync::atomic::Ordering::SeqCst), + 1, + "a policy denial must not retry discovery" + ); +} +#[tokio::test] +async fn workload_discovery_verification_and_rotation() { + for explicit_issuer in [false, true] { + let (_, initial_jwk) = oidc_es256_key_and_jwk("initial"); + let (key, rotated_jwk) = oidc_es256_key_and_jwk("rotated"); + let initial_jwks = serde_json::json!({"keys": [initial_jwk]}).to_string(); + let rotated_jwks = serde_json::json!({"keys": [rotated_jwk]}).to_string(); + let (base, handle) = start_mock_oidc_discovery_server_with_jwks( + |base| (base.to_string(), format!("{base}/jwks"), "/jwks".into()), + 4, + "ES256", + true, + move |fetch| { + if fetch == 0 { + initial_jwks.clone() + } else { + rotated_jwks.clone() + } + }, + ) + .expect("workload discovery mock must bind"); + let mut config = build_mocked_oidc_provider_config("workload", &base); + config.hide_from_ui = true; + if explicit_issuer { + config.issuer = Some(base.clone()); + } + let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).unwrap()); + let state = discover_provider(&config, &http_client) + .await + .expect("workload discovery must succeed"); + assert!(state.metadata.authorization_endpoint().is_none()); + let sys = OidcSys { + configs: HashMap::from([(config.id.clone(), test_sourced_config(config.clone()))]), + provider_runtime: ProviderRuntime::new(http_client, HashMap::from([(config.id.clone(), state)])), + state_store: OidcStateStore::new(), + }; + assert!(sys.has_providers()); + assert!(sys.provider_configs().all(|provider| provider.hide_from_ui)); + let error = sys + .authorize_url(&config.id, "https://console.example.com/callback", None) + .await + .unwrap_err(); + assert!(error.contains("only web identity")); + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let payload = serde_json::json!({ + "iss": base, "sub": "system:serviceaccount:default:reader", "aud": [config.client_id], + "iat": now, "exp": now + 300, "groups": ["readonly"], + "kubernetes.io": {"namespace": "default", "serviceaccount": {"name": "reader"}}, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some("rotated".into()); + let token = jsonwebtoken::encode(&header, &payload, &key).unwrap(); + let (claims, provider) = sys + .verify_web_identity_token(&token) + .await + .expect("rotation must retain workload discovery support"); + assert_eq!(provider, config.id); + assert_eq!(claims.sub, "system:serviceaccount:default:reader"); + assert_eq!(claims.groups, ["readonly"]); + // Repeat after the mock exits: the verified snapshot must be cached. + handle.join().unwrap(); + assert!(sys.verify_web_identity_token(&token).await.is_ok()); + for (field, value, expected) in [ + ("iss", serde_json::json!("https://wrong.example.com"), "issuer"), + ("aud", serde_json::json!("wrong-audience"), "audience"), + ("exp", serde_json::json!(now - 60), "expired"), + ] { + let mut invalid = payload.clone(); + invalid[field] = value; + let token = jsonwebtoken::encode(&header, &invalid, &key).unwrap(); + let error = sys + .verify_web_identity_token(&token) + .await + .expect_err("invalid workload token must fail"); + assert!(error.to_lowercase().contains(expected), "{field}: {error}"); + } + let (wrong_key, _) = oidc_es256_key_and_jwk("wrong"); + let invalid = jsonwebtoken::encode(&header, &payload, &wrong_key).unwrap(); + let error = sys + .verify_web_identity_token(&invalid) + .await + .expect_err("wrong signature must fail"); + assert!(error.to_lowercase().contains("signature"), "{error}"); + assert!( + sys.verify_web_identity_token(&token).await.is_ok(), + "failed refresh must preserve the cached keys" + ); + } +} +#[tokio::test] +async fn oidc_discovery_accepts_extra_root_ca_for_https_provider() { + let Some((base, ca_pem, handle)) = start_mock_oidc_tls_discovery_server( + |base| (format!("{base}/application/o/rustfs"), format!("{base}/jwks"), "/jwks".to_string()), + 4, + ) else { + return; + }; + let config_url = format!("{base}/application/o/rustfs"); + let config = build_mocked_oidc_provider_config("default", &config_url); + let origin = Url::parse(&config.config_url) + .expect("mock config_url should parse") + .origin() + .ascii_serialization(); + let policy = OutboundPolicy::from_allowed_origins(&origin).expect("loopback TLS origin should be allowed"); + let extra_root_certs = + parse_oidc_extra_root_certs("test OIDC TLS CA", ca_pem.as_bytes()).expect("test CA bundle should parse"); + let http_client = ReqwestHttpClient::with_policy_and_extra_root_certs(policy, extra_root_certs); + + let state = discover_provider(&config, &http_client) + .await + .expect("OIDC discovery should trust the extra root CA"); + + assert_eq!(state.metadata.issuer().to_string(), format!("{base}/application/o/rustfs")); + assert!(handle.join().is_ok()); +} +#[tokio::test] +async fn oidc_discovery_refreshes_extra_root_ca_when_generation_changes() { + let Some((base_a, ca_pem_a, handle_a)) = start_mock_oidc_tls_discovery_server( + |base| (format!("{base}/application/o/rustfs-a"), format!("{base}/jwks"), "/jwks".to_string()), + 4, + ) else { + return; + }; + let Some((base_b, ca_pem_b, handle_b)) = start_mock_oidc_tls_discovery_server( + |base| (format!("{base}/application/o/rustfs-b"), format!("{base}/jwks"), "/jwks".to_string()), + 4, + ) else { + assert!(handle_a.join().is_ok()); + return; + }; + + let origin_a = Url::parse(&base_a) + .expect("mock base A should parse") + .origin() + .ascii_serialization(); + let origin_b = Url::parse(&base_b) + .expect("mock base B should parse") + .origin() + .ascii_serialization(); + let allowed_origins = format!("{origin_a},{origin_b}"); + let policy = OutboundPolicy::from_allowed_origins(&allowed_origins).expect("loopback TLS origins should be allowed"); + let material = Arc::new(Mutex::new(OidcExtraRootCaMaterial { + generation: 1, + root_ca_pem: Some(ca_pem_a.into_bytes()), + })); + let provider = OidcExtraRootCaProvider::new({ + let material = material.clone(); + move || { + let material = material.clone(); + async move { + material + .lock() + .map(|material| material.clone()) + .map_err(|e| format!("test OIDC extra CA material lock poisoned: {e}")) + } + } + }); + let http_client = ReqwestHttpClient::with_policy_and_extra_root_ca_provider(policy, provider); + + let config_a = build_mocked_oidc_provider_config("a", &format!("{base_a}/application/o/rustfs-a")); + let state_a = discover_provider(&config_a, &http_client) + .await + .expect("OIDC discovery should trust initial extra root CA"); + assert_eq!(state_a.metadata.issuer().to_string(), format!("{base_a}/application/o/rustfs-a")); + + { + let mut material = material + .lock() + .expect("test OIDC extra CA material lock should not be poisoned"); + material.generation = 2; + material.root_ca_pem = Some(ca_pem_b.into_bytes()); + } + let config_b = build_mocked_oidc_provider_config("b", &format!("{base_b}/application/o/rustfs-b")); + let state_b = discover_provider(&config_b, &http_client) + .await + .expect("OIDC discovery should refresh extra root CA after generation change"); + + assert_eq!(state_b.metadata.issuer().to_string(), format!("{base_b}/application/o/rustfs-b")); + assert!(handle_a.join().is_ok()); + assert!(handle_b.join().is_ok()); +} +#[test] +fn test_jwks_url_from_config_url_preserves_unrelated_urls() { + let issuer = IssuerUrl::new("https://public.example.com/realms/app".to_string()).expect("issuer URL should parse"); + for raw_jwks_url in [ + "https://keys.example.com/jwks", + "http://public.example.com/realms/app/jwks", + "https://public.example.com:8443/realms/app/jwks", + "https://public.example.com/keys/jwks", + "https://public.example.com/realms/application/jwks", + ] { + let jwks_url = JsonWebKeySetUrl::new(raw_jwks_url.to_string()).expect("JWKS URL should parse"); + + let resolved = + jwks_url_from_config_url("http://keycloak.internal/realms/app/.well-known/openid-configuration", &issuer, &jwks_url) + .expect("JWKS URL should resolve"); + + assert_eq!(resolved.as_str(), raw_jwks_url); + } + + let issuer_root_jwks = JsonWebKeySetUrl::new(issuer.as_str().to_string()).expect("JWKS URL should parse"); + let resolved = jwks_url_from_config_url( + "http://keycloak.internal/realms/app/.well-known/openid-configuration", + &issuer, + &issuer_root_jwks, + ) + .expect("issuer-root JWKS URL should resolve"); + assert_eq!(resolved.as_str(), "http://keycloak.internal/realms/app"); + + let issuer_with_slash = + IssuerUrl::new("https://public.example.com/realms/app/".to_string()).expect("issuer URL should parse"); + let jwks_url = + JsonWebKeySetUrl::new("https://public.example.com/realms/app/jwks".to_string()).expect("JWKS URL should parse"); + let resolved = jwks_url_from_config_url( + "http://keycloak.internal/realms/app/.well-known/openid-configuration", + &issuer_with_slash, + &jwks_url, + ) + .expect("issuer-relative JWKS URL should resolve"); + assert_eq!(resolved.as_str(), "http://keycloak.internal/realms/app/jwks"); +} +#[tokio::test] +async fn oidc_discovery_reports_forbidden_outbound_without_retrying() { + let config_url = "http://192.168.65.254:8080/realms/rustfs/.well-known/openid-configuration"; + let config = build_mocked_oidc_provider_config("default", config_url); + let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::default()); + + let error = match discover_provider(&config, &http_client).await { + Ok(_) => panic!("private OIDC provider should require an explicit allowlist origin"), + Err(error) => error, + }; + + assert!(error.contains("OIDC provider discovery blocked by outbound policy")); + assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); + assert!(!error.contains("discovery failed for all issuer variants")); +} +#[tokio::test] +async fn oidc_explicit_issuer_reports_forbidden_discovery_endpoint() { + let config_url = "http://192.168.65.254:8080/realms/rustfs/.well-known/openid-configuration"; + let mut config = build_mocked_oidc_provider_config("default", config_url); + config.issuer = Some("https://idp.example.com/realms/rustfs".to_string()); + let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::default()); + + let error = match discover_provider(&config, &http_client).await { + Ok(_) => panic!("private OIDC provider should require an explicit allowlist origin"), + Err(error) => error, + }; + + assert!(error.contains("OIDC provider discovery blocked by outbound policy")); + assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); +} +#[tokio::test] +async fn oidc_explicit_issuer_reports_forbidden_jwks_endpoint() { + let Some((base, handle)) = start_mock_oidc_discovery_server( + |base| { + ( + format!("{base}/realms/rustfs"), + "http://192.168.65.254:8080/realms/rustfs/protocol/openid-connect/certs".to_string(), + "/unused".to_string(), + ) + }, + 1, + ) else { + return; + }; + let mut config = + build_mocked_oidc_provider_config("default", &format!("{base}/realms/rustfs/.well-known/openid-configuration")); + config.issuer = Some(format!("{base}/realms/rustfs")); + let policy = OutboundPolicy::from_allowed_origins(&base).expect("loopback discovery origin should be allowed"); + let http_client = ReqwestHttpClient::with_policy(policy); + + let error = match discover_provider(&config, &http_client).await { + Ok(_) => panic!("private JWKS endpoint should require an explicit allowlist origin"), + Err(error) => error, + }; + + assert!(error.contains("JWKS request blocked by outbound policy")); + assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); + assert!(handle.join().is_ok()); +} +#[tokio::test] +async fn oidc_reqwest_dns_policy_rejection_stays_typed() { + let calls = Arc::new(AtomicUsize::new(0)); + let config = build_mocked_oidc_provider_config( + "default", + "http://keycloak.internal:8080/realms/rustfs/.well-known/openid-configuration", + ); + let http_client = ReqwestHttpClient::with_policy_and_dns_resolver( + OutboundPolicy::default(), + Arc::new(RejectingDnsResolver { + allow_origin_can_recover: true, + calls: Some(calls.clone()), + }), + ); + + let error = match discover_provider(&config, &http_client).await { + Ok(_) => panic!("private DNS answer should fail discovery"), + Err(error) => error, + }; + + assert!(error.contains("OIDC provider discovery blocked by outbound policy")); + assert!(error.contains(&format!("add http://keycloak.internal:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); + assert_eq!(calls.load(Ordering::Relaxed), 1, "policy rejection must not be retried"); +} +#[tokio::test] +async fn oidc_explicit_issuer_preserves_dns_policy_rejection() { + let calls = Arc::new(AtomicUsize::new(0)); + let mut config = build_mocked_oidc_provider_config( + "default", + "http://keycloak.internal:8080/realms/rustfs/.well-known/openid-configuration", + ); + config.issuer = Some("https://idp.example.com/realms/rustfs".to_string()); + let http_client = ReqwestHttpClient::with_policy_and_dns_resolver( + OutboundPolicy::default(), + Arc::new(RejectingDnsResolver { + allow_origin_can_recover: true, + calls: Some(calls.clone()), + }), + ); + + let error = match discover_provider(&config, &http_client).await { + Ok(_) => panic!("private DNS answer should fail discovery"), + Err(error) => error, + }; + + assert!(error.contains("OIDC provider discovery blocked by outbound policy")); + assert!(error.contains(&format!("add http://keycloak.internal:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); + assert_eq!(calls.load(Ordering::Relaxed), 1, "policy rejection must not be retried"); +} + +#[tokio::test] +async fn workload_config_validation_reports_absent_console_endpoints() { + let (base, handle) = start_mock_oidc_discovery_server_with_jwks( + |base| (base.to_string(), format!("{base}/jwks"), "/jwks".into()), + 2, + "ES256", + true, + |_| serde_json::json!({"keys": []}).to_string(), + ) + .expect("workload discovery mock must bind"); + let mut config = build_mocked_oidc_provider_config("workload", &base); + config.hide_from_ui = true; + // Inferred issuers keep the library's discovery URL construction. + config.config_url = format!("{base}/.well-known/openid-configuration?ignored=1#ignored"); + let result = validate_mocked_oidc_provider_config(&config) + .await + .expect("hidden workload configuration should validate"); + assert_eq!(result.issuer, base); + assert!(result.authorization_endpoint.is_none()); + assert!(result.token_endpoint.is_none()); + handle.join().unwrap(); +} + +#[tokio::test] +async fn test_validate_oidc_provider_config_retries_with_issuer_candidates() { + // Discovery document must advertise the canonical issuer path. The first candidate has no + // trailing slash; openidconnect rejects issuer mismatch, then the second variant succeeds. + let Some((base, handle)) = start_mock_oidc_discovery_server( + |base| (format!("{base}/application/o/rustfs/"), format!("{base}/jwks"), "/jwks".to_string()), + 8, + ) else { + return; + }; + let config_url = format!("{base}/application/o/rustfs"); + let config = build_mocked_oidc_provider_config("default", &config_url); + + let result = validate_mocked_oidc_provider_config(&config).await; + + let validation_result = result.expect("OIDC provider validation should succeed"); + assert_eq!(validation_result.issuer, format!("{base}/application/o/rustfs/")); + assert!(handle.join().is_ok()); +} + +#[tokio::test] +async fn test_validate_oidc_provider_config_fetches_issuer_relative_jwks_from_config_url() { + let Some((base, handle)) = start_mock_oidc_discovery_server( + |_| { + ( + "http://127.0.0.1:1/public/realms/app".to_string(), + "http://127.0.0.1:1/public/realms/app/jwks?version=1".to_string(), + "/internal/realms/app/jwks?version=1".to_string(), + ) + }, + 2, + ) else { + return; + }; + let mut config = + build_mocked_oidc_provider_config("default", &format!("{base}/internal/realms/app/.well-known/openid-configuration")); + config.issuer = Some("http://127.0.0.1:1/public/realms/app".to_string()); + + let validation_result = validate_mocked_oidc_provider_config(&config) + .await + .expect("OIDC provider validation should succeed"); + + assert_eq!(validation_result.issuer, "http://127.0.0.1:1/public/realms/app"); + assert!(handle.join().is_ok()); +} + +#[tokio::test] +async fn test_validate_oidc_provider_config_rejects_separate_issuer_mismatch() { + let Some((base, handle)) = start_mock_oidc_discovery_server( + |base| { + ( + "https://public.example.com/realms/other".to_string(), + format!("{base}/jwks"), + "/jwks".to_string(), + ) + }, + 1, + ) else { + return; + }; + let mut config = + build_mocked_oidc_provider_config("default", &format!("{base}/internal/realms/app/.well-known/openid-configuration")); + config.issuer = Some("https://public.example.com/realms/app".to_string()); + + let err = validate_mocked_oidc_provider_config(&config) + .await + .expect_err("OIDC provider validation should fail"); + + assert!(err.contains("unexpected issuer URI")); + assert!(err.contains("https://public.example.com/realms/app")); + assert!(handle.join().is_ok()); +} + +#[tokio::test] +async fn test_validate_oidc_provider_config_returns_detailed_errors() { + let Some((base, handle)) = start_mock_oidc_discovery_server( + |base| (format!("{base}/application/o/other"), format!("{base}/jwks"), "/jwks".to_string()), + 8, + ) else { + return; + }; + let config_url = format!("{base}/application/o/rustfs"); + let config = build_mocked_oidc_provider_config("default", &config_url); + + let err = validate_mocked_oidc_provider_config(&config) + .await + .expect_err("OIDC provider validation should fail"); + assert!(discovery_error_contains_all_variants(&err, &base)); + assert!(err.contains("issuer '")); + assert!(err.contains(&format!("issuer '{base}/application/o/rustfs'"))); + assert!(err.contains(&format!("issuer '{base}/application/o/rustfs/'"))); + assert!(handle.join().is_ok()); +} diff --git a/crates/iam/src/oidc/runtime.rs b/crates/iam/src/oidc/runtime.rs new file mode 100644 index 000000000..01cff8046 --- /dev/null +++ b/crates/iam/src/oidc/runtime.rs @@ -0,0 +1,897 @@ +// 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::{ + config::{ + OidcConfigQuery, OidcConfigSnapshot, OidcProviderConfig, OidcProviderSummary, SourcedOidcProviderConfig, + load_oidc_config_snapshot, + }, + provider::{ProviderRuntime, discover_provider}, + state::{OidcAuthSession, OidcLogoutSession, OidcStateStore}, + transport::{OidcExtraRootCaProvider, ReqwestHttpClient, inspect_token_response_body, oidc_http_error_diagnostics}, +}; +#[cfg(test)] +use crate::federation::FederatedAuthorizationRuleRef; +use crate::{ + EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC, + federation::{ + CoreFederatedAuthorizationMapper, FederatedAuthorizationRule, FederatedAuthorizationRules, FederatedClaims, + FederatedIdentityService, FederatedProviderQuery, FederatedProviderRef, FederatedProviderView, FederatedRedirectPolicy, + FederationError, OpaqueLogoutContinuation, Result as FederationResult, StandardOidcAuthentication, + VerifiedFederatedCodeExchange, VerifiedFederatedIdentity, + }, +}; +use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken}; +use openidconnect::{ + Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, LogoutRequest, Nonce, PkceCodeChallenge, + PkceCodeVerifier, PostLogoutRedirectUrl, RedirectUrl, RequestTokenError, Scope, +}; +use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive}; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::{debug, error, warn}; + +/// Claims extracted from an OIDC ID token. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct OidcClaims { + pub sub: String, + pub email: String, + pub username: String, + pub groups: Vec, + pub raw: HashMap, +} + +// ---- Core OIDC system ---- + +/// Global OIDC manager for all configured providers. +pub struct OidcSys { + pub(super) configs: HashMap, + pub(super) provider_runtime: ProviderRuntime, + pub(super) state_store: OidcStateStore, +} + +pub(super) fn trusted_aud(other_audiences: &[String], audience: &Audience) -> bool { + for aud in other_audiences { + if audience.as_str() == aud.as_str() { + return true; + } + } + false +} + +impl OidcSys { + pub(crate) async fn new_with_extra_root_ca_provider(extra_root_ca_provider: OidcExtraRootCaProvider) -> Result { + let http_client = ReqwestHttpClient::new_with_extra_root_ca_provider(extra_root_ca_provider)?; + http_client.current_extra_root_certs().await.map_err(|err| err.to_string())?; + Self::new_with_http_client(http_client).await + } + + async fn new_with_http_client(http_client: ReqwestHttpClient) -> Result { + let server_config = crate::server_config::current_server_config(); + let parsed_configs = load_oidc_config_snapshot(server_config.as_ref()); + let mut configs = HashMap::new(); + let mut provider_states = HashMap::new(); + + for sourced_config in parsed_configs.into_providers() { + let config = &sourced_config.config; + if !config.enabled { + debug!(provider = %config.id, "OIDC provider disabled"); + continue; + } + + match discover_provider(config, &http_client).await { + Ok(state) => { + debug!(provider = %config.id, "OIDC provider discovered"); + provider_states.insert(config.id.clone(), state); + configs.insert(config.id.clone(), sourced_config); + } + Err(e) => { + error!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "provider_discovery_failed", + provider_id = %config.id, + config_url = %config.config_url, + client_id = %config.client_id, + scopes = ?config.scopes, + redirect_uri = %config.redirect_uri.as_deref().unwrap_or(""), + redirect_uri_dynamic = config.redirect_uri_dynamic, + error = %e, + "oidc provider discovery failed" + ); + } + } + } + + Ok(Self { + configs, + provider_runtime: ProviderRuntime::new(http_client, provider_states), + state_store: OidcStateStore::new(), + }) + } + + /// Create an OidcSys with no providers (useful for when OIDC is not configured). + pub fn empty() -> Result { + Ok(Self { + configs: HashMap::new(), + provider_runtime: ProviderRuntime::new(ReqwestHttpClient::new()?, HashMap::new()), + state_store: OidcStateStore::new(), + }) + } + + /// Return true if any OIDC providers are configured and enabled. + pub(crate) fn has_providers(&self) -> bool { + !self.configs.is_empty() + } + + pub(crate) fn provider_configs(&self) -> impl Iterator { + self.configs.values().map(|provider| &provider.config) + } + + /// List all providers, including providers hidden from the login UI. + pub(crate) fn list_providers(&self) -> Vec { + self.provider_configs() + .map(|config| OidcProviderSummary { + provider_id: config.id.clone(), + display_name: config.display_name.clone(), + }) + .collect() + } + + /// List providers visible in the login UI. + pub(crate) fn list_visible_providers(&self) -> Vec { + self.provider_configs() + .filter(|config| !config.hide_from_ui) + .map(|config| OidcProviderSummary { + provider_id: config.id.clone(), + display_name: config.display_name.clone(), + }) + .collect() + } + + pub(crate) fn config_snapshot(&self) -> OidcConfigSnapshot { + OidcConfigSnapshot::new(self.configs.values().cloned().collect()) + } + + /// Build the PKCE authorization URL for a provider, store state in the state store. + pub(crate) async fn authorize_url( + &self, + provider_id: &str, + redirect_uri: &str, + redirect_after: Option, + ) -> Result { + let config = self + .get_provider_config(provider_id) + .ok_or_else(|| format!("unknown OIDC provider: {provider_id}"))?; + let state = self.provider_runtime.ensure_provider_state(provider_id, config).await?; + + 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 client = CoreClient::from_provider_metadata( + state.metadata.console()?.clone(), + ClientId::new(config.client_id.clone()), + config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())), + ) + .set_auth_type(AuthType::RequestBody); + + 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(super) async fn exchange_code( + &self, + state: &str, + code: &str, + redirect_uri: &str, + ) -> Result<(OidcClaims, String, OidcAuthSession, String), 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 + .get_provider_config(&session.provider_id) + .ok_or_else(|| format!("unknown provider: {}", session.provider_id))?; + let provider_state = self.provider_runtime.get_provider_state(&session.provider_id)?; + let issuer = provider_state.metadata.issuer().to_string(); + let token_endpoint = provider_state + .metadata + .token_endpoint() + .map(ToString::to_string) + .unwrap_or_default(); + + // Construct CoreClient on-the-fly with JWKS from discovery + let client = CoreClient::from_provider_metadata( + provider_state.metadata.console()?.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| { + error!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "token_endpoint_missing", + provider_id = %session.provider_id, + config_url = %config.config_url, + issuer = %issuer, + client_id = %config.client_id, + redirect_uri = %redirect_uri, + scopes = ?config.scopes, + error = %e, + "oidc token exchange failed" + ); + format!( + "token endpoint not configured: {e}: provider_id={}, config_url={}, issuer={}, redirect_uri={}, client_id={}", + session.provider_id, config.config_url, issuer, redirect_uri, config.client_id + ) + })? + .set_pkce_verifier(PkceCodeVerifier::new(session.pkce_verifier.clone())) + .set_redirect_uri(Cow::Owned(redirect)) + .request_async(self.provider_runtime.http_client()) + .await + .map_err(|e| match &e { + RequestTokenError::ServerResponse(response) => { + error!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "token_server_response", + provider_id = %session.provider_id, + config_url = %config.config_url, + issuer = %issuer, + token_endpoint = %token_endpoint, + client_id = %config.client_id, + client_secret_configured = config.client_secret.as_deref().is_some_and(|secret| !secret.is_empty()), + redirect_uri = %redirect_uri, + scopes = ?config.scopes, + oauth_error = %response.error(), + oauth_error_description = %response.error_description().map(String::as_str).unwrap_or(""), + oauth_error_uri = %response.error_uri().map(String::as_str).unwrap_or(""), + error = %e, + "oidc token exchange failed" + ); + format!( + "token exchange failed: {e}: stage=token_server_response, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, oauth_error={}, oauth_error_description={}", + session.provider_id, + config.config_url, + issuer, + token_endpoint, + redirect_uri, + config.client_id, + response.error(), + response.error_description().map(String::as_str).unwrap_or("") + ) + } + RequestTokenError::Request(err) => { + let (request_error_kind, request_error_status) = oidc_http_error_diagnostics(err); + error!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "token_request_failed", + provider_id = %session.provider_id, + config_url = %config.config_url, + issuer = %issuer, + token_endpoint = %token_endpoint, + client_id = %config.client_id, + redirect_uri = %redirect_uri, + request_error_kind = %request_error_kind, + request_error_status = %request_error_status, + error = %err, + "oidc token exchange failed" + ); + format!( + "token exchange failed: stage=token_request_failed, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, request_error_kind={}, request_error_status={}, request_error={}", + session.provider_id, + config.config_url, + issuer, + token_endpoint, + redirect_uri, + config.client_id, + request_error_kind, + request_error_status, + err + ) + } + RequestTokenError::Parse(parse_err, body) => { + let shape = inspect_token_response_body(body); + error!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "token_response_parse_failed", + provider_id = %session.provider_id, + config_url = %config.config_url, + issuer = %issuer, + token_endpoint = %token_endpoint, + client_id = %config.client_id, + redirect_uri = %redirect_uri, + parse_error_path = %parse_err.path(), + response_body_len = body.len(), + response_json_object = shape.json_object, + response_json_keys = %shape.json_keys, + response_has_access_token = shape.has_access_token, + response_has_id_token = shape.has_id_token, + response_has_token_type = shape.has_token_type, + response_has_expires_in = shape.has_expires_in, + response_has_error = shape.has_error, + response_has_error_description = shape.has_error_description, + response_looks_like_html = shape.looks_like_html, + error = %e, + "oidc token exchange failed" + ); + format!( + "token exchange failed: {e}: stage=token_response_parse_failed, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, parse_error_path={}, response_body_len={}, response_json_keys={}, response_has_id_token={}, response_has_error={}, response_looks_like_html={}", + session.provider_id, + config.config_url, + issuer, + token_endpoint, + redirect_uri, + config.client_id, + parse_err.path(), + body.len(), + shape.json_keys, + shape.has_id_token, + shape.has_error, + shape.looks_like_html + ) + } + RequestTokenError::Other(message) => { + error!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "token_exchange_other_error", + provider_id = %session.provider_id, + config_url = %config.config_url, + issuer = %issuer, + token_endpoint = %token_endpoint, + client_id = %config.client_id, + redirect_uri = %redirect_uri, + error = %message, + "oidc token exchange failed" + ); + format!( + "token exchange failed: {e}: stage=token_exchange_other_error, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}", + session.provider_id, config.config_url, issuer, token_endpoint, redirect_uri, config.client_id + ) + } + })?; + + // Verify the ID token (signature, issuer, audience, expiry, nonce) + let id_token = token_response + .extra_fields() + .id_token() + .ok_or_else(|| { + error!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "token_response_missing_id_token", + provider_id = %session.provider_id, + config_url = %config.config_url, + issuer = %issuer, + token_endpoint = %token_endpoint, + client_id = %config.client_id, + redirect_uri = %redirect_uri, + scopes = ?config.scopes, + "oidc token exchange failed" + ); + format!( + "no id_token in token response: provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, scopes={}", + session.provider_id, + config.config_url, + issuer, + token_endpoint, + redirect_uri, + config.client_id, + config.scopes.join(",") + ) + })?; + + let verifier = client + .id_token_verifier() + .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); + let verified = id_token.claims(&verifier, &Nonce::new(session.nonce.clone())); + if let Err(e) = verified { + let verification_error = e.to_string(); + warn!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "id_token_verification_retry", + provider_id = %session.provider_id, + config_url = %config.config_url, + issuer = %issuer, + token_endpoint = %token_endpoint, + client_id = %config.client_id, + other_audiences = ?config.other_audiences, + error = %verification_error, + "oidc id token verification failed" + ); + let refreshed_state = self + .provider_runtime + .refresh_provider_state(&session.provider_id, config) + .await + .map_err(|refresh_err| { + format!( + "ID token verification failed: {verification_error}; failed to refresh provider metadata: {refresh_err}" + ) + })?; + + warn!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "jwks_metadata_refreshed", + provider_id = %session.provider_id, + config_url = %config.config_url, + issuer = %issuer, + "oidc provider metadata refreshed" + ); + + let client = CoreClient::from_provider_metadata( + refreshed_state.metadata.console()?.clone(), + ClientId::new(config.client_id.clone()), + config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())), + ) + .set_auth_type(AuthType::RequestBody); + + let verifier = client + .id_token_verifier() + .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); + id_token + .claims(&verifier, &Nonce::new(session.nonce.clone())) + .map_err(|retry_err| { + error!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "id_token_verification_failed", + provider_id = %session.provider_id, + config_url = %config.config_url, + issuer = %issuer, + token_endpoint = %token_endpoint, + client_id = %config.client_id, + other_audiences = ?config.other_audiences, + original_error = %verification_error, + retry_error = %retry_err, + "oidc id token verification failed" + ); + format!( + "ID token verification failed after JWKS refresh: {retry_err}; original_error={verification_error}; provider_id={}, config_url={}, issuer={}, token_endpoint={}, client_id={}, other_audiences={}", + session.provider_id, + config.config_url, + issuer, + token_endpoint, + config.client_id, + config.other_audiences.join(",") + ) + })?; + } + + // 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 = id_token.to_string(); + 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_canonical_group_values(&raw, &config.groups_claim, &config.roles_claim), + raw, + }; + + Ok((claims, session.provider_id.clone(), session, raw_jwt)) + } + + /// Store a one-time logout session keyed by an opaque token so the console can + /// trigger browser logout without persisting the raw ID token. + pub(crate) async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result { + if !self.configs.contains_key(provider_id) { + return Err(format!("unknown OIDC provider: {provider_id}")); + } + + let token = CsrfToken::new_random().secret().clone(); + self.state_store + .insert_logout( + token.clone(), + OidcLogoutSession { + provider_id: provider_id.to_string(), + id_token: id_token.to_string(), + }, + ) + .await; + + Ok(token) + } + + /// Build the RP-initiated logout URL for a previously issued logout token. + /// Returns `Ok(None)` when the provider does not advertise an end-session endpoint. + pub(crate) async fn build_logout_url( + &self, + logout_token: &str, + post_logout_redirect_uri: &str, + ) -> Result, String> { + let session = self + .state_store + .take_logout(logout_token) + .await + .ok_or_else(|| "invalid or expired OIDC logout token".to_string())?; + + let config = self + .get_provider_config(&session.provider_id) + .ok_or_else(|| format!("unknown OIDC provider: {}", session.provider_id))?; + let state = self + .provider_runtime + .ensure_provider_state(&session.provider_id, config) + .await?; + let Some(end_session_endpoint) = state.metadata.console()?.additional_metadata().end_session_endpoint.clone() else { + return Ok(None); + }; + + let id_token: CoreIdToken = session + .id_token + .parse() + .map_err(|e: serde_json::Error| format!("failed to parse ID token for logout: {e}"))?; + let post_logout_redirect_uri = PostLogoutRedirectUrl::new(post_logout_redirect_uri.to_string()) + .map_err(|e| format!("invalid post logout redirect URI: {e}"))?; + + let logout_url = LogoutRequest::from(end_session_endpoint) + .set_id_token_hint(&id_token) + .set_client_id(ClientId::new(config.client_id.clone())) + .set_post_logout_redirect_uri(post_logout_redirect_uri) + .http_get_url() + .to_string(); + + Ok(Some(logout_url)) + } + + /// Map OIDC claims to rustfs policy names. + #[cfg(test)] + pub(crate) fn map_claims_to_policies(&self, provider_id: &str, claims: &OidcClaims) -> (Vec, Vec) { + let Some(rule) = self.authorization_rule(provider_id) else { + return (Vec::new(), Vec::new()); + }; + CoreFederatedAuthorizationMapper::map_policies_and_groups(provider_id, rule, &claims.groups, &claims.raw, true) + } + + /// Policy names produced only by the canonical groups in claim-based mode: the groups claim + /// plus the roles claim values that `extract_canonical_group_values` merges into it. + /// + /// Identity providers emit built-in groups and roles that can never have a matching policy + /// (for example `DOMAIN\Domain Users` or Keycloak's `offline_access`), so the session binding + /// may ignore these names when no such policy exists. Names from a fixed role policy or a + /// dedicated policy claim are never included, so those configurations keep requiring every + /// policy to resolve. + #[cfg(test)] + pub(crate) fn group_claim_policy_names(&self, provider_id: &str, claims: &OidcClaims) -> Vec { + let Some(rule) = self.authorization_rule(provider_id) else { + return Vec::new(); + }; + CoreFederatedAuthorizationMapper::group_claim_policy_names(rule, &claims.groups, &claims.raw) + } + + #[cfg(test)] + fn authorization_rule(&self, provider_id: &str) -> Option> { + self.get_provider_config(provider_id).map(|config| { + FederatedAuthorizationRuleRef::new( + &config.claim_name, + &config.claim_prefix, + &config.role_policy, + &config.groups_claim, + &config.roles_claim, + ) + }) + } + + /// 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(crate) 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, mut state) = self + .provider_runtime + .find_provider_by_issuer(issuer, &self.configs) + .ok_or_else(|| format!("no OIDC provider configured for issuer: {issuer}"))?; + + state = self + .provider_runtime + .ensure_provider_state_if_stale(&provider_id, &config, &state) + .await?; + + // 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 = state + .metadata + .verifier(&config) + .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); + if let Err(e) = id_token.claims(&verifier, |_: Option<&Nonce>| Ok(())) { + state = self + .provider_runtime + .refresh_provider_state(&provider_id, &config) + .await + .map_err(|refresh_err| { + format!("ID token verification failed: {e}; failed to refresh provider metadata: {refresh_err}") + })?; + + let verifier = state + .metadata + .verifier(&config) + .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); + id_token + .claims(&verifier, |_: Option<&Nonce>| Ok(())) + .map_err(|retry_err| format!("ID token verification failed after JWKS refresh: {retry_err}"))?; + } + + // 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_canonical_group_values(&raw_claims, &config.groups_claim, &config.roles_claim), + raw: raw_claims, + }; + + Ok((claims, provider_id.to_string())) + } + /// Get a provider config by ID. + pub(crate) fn get_provider_config(&self, id: &str) -> Option<&OidcProviderConfig> { + self.configs.get(id).map(|provider| &provider.config) + } +} + +/// 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 with case-insensitive fallback. +fn extract_string_claim(claims: &HashMap, key: &str) -> String { + match get_claim_case_insensitive(claims, key) { + ClaimLookup::Found(value) => value.as_str().unwrap_or_default().to_string(), + ClaimLookup::Missing | ClaimLookup::Ambiguous => String::new(), + } +} + +/// Extract a groups/array claim from raw claims with case-insensitive fallback. Handles both string arrays and single strings. +fn extract_groups_claim(claims: &HashMap, key: &str) -> Vec { + match get_claim_case_insensitive(claims, key) { + ClaimLookup::Found(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(), + ClaimLookup::Found(serde_json::Value::String(s)) => s.split(',').map(|s| s.trim().to_string()).collect(), + _ => vec![], + } +} + +fn extract_canonical_group_values( + claims: &HashMap, + groups_claim: &str, + roles_claim: &str, +) -> Vec { + let mut groups = extract_groups_claim(claims, groups_claim); + if !roles_claim.is_empty() && roles_claim != groups_claim { + groups.extend(extract_groups_claim(claims, roles_claim)); + } + groups.retain(|g| !g.is_empty()); + groups.sort(); + groups.dedup(); + groups +} + +fn verified_identity(provider: FederatedProviderRef, claims: OidcClaims) -> VerifiedFederatedIdentity { + VerifiedFederatedIdentity::from_claims( + provider, + FederatedClaims { + sub: claims.sub, + email: claims.email, + username: claims.username, + groups: claims.groups, + raw: claims.raw, + }, + ) +} + +fn authorization_rules(oidc: &OidcSys) -> FederatedAuthorizationRules { + FederatedAuthorizationRules::new(oidc.provider_configs().map(|config| { + FederatedAuthorizationRule::new( + config.id.clone(), + config.claim_name.clone(), + config.claim_prefix.clone(), + config.role_policy.clone(), + config.groups_claim.clone(), + config.roles_claim.clone(), + ) + })) +} + +pub struct StandardOidcAdapter { + oidc: Arc, + authorization_rules: FederatedAuthorizationRules, +} + +impl StandardOidcAdapter { + pub fn new(oidc: Arc) -> Self { + let authorization_rules = authorization_rules(&oidc); + Self { + oidc, + authorization_rules, + } + } + + pub fn authorization_rules(&self) -> FederatedAuthorizationRules { + self.authorization_rules.clone() + } + + pub fn into_service(self: Arc, mapper: CoreFederatedAuthorizationMapper) -> FederatedIdentityService { + let authentication: Arc = self.clone(); + let provider_query: Arc = self; + FederatedIdentityService::from_standard_oidc_parts(provider_query, authentication, mapper) + } +} + +impl OidcConfigQuery for StandardOidcAdapter { + fn config_snapshot(&self) -> OidcConfigSnapshot { + self.oidc.config_snapshot() + } +} + +#[async_trait::async_trait] +impl StandardOidcAuthentication for StandardOidcAdapter { + async fn authorize_url( + &self, + provider_id: &str, + redirect_uri: &str, + redirect_after: Option, + ) -> FederationResult { + self.oidc + .authorize_url(provider_id, redirect_uri, redirect_after) + .await + .map_err(FederationError::Authorization) + } + + async fn exchange_identity( + &self, + state: &str, + code: &str, + redirect_uri: &str, + ) -> FederationResult { + let (claims, provider_id, session, id_token) = self + .oidc + .exchange_code(state, code, redirect_uri) + .await + .map_err(FederationError::CodeExchange)?; + let provider = FederatedProviderRef::new(provider_id); + Ok(VerifiedFederatedCodeExchange::new( + verified_identity(provider.clone(), claims), + session.redirect_after, + OpaqueLogoutContinuation::new(provider, id_token), + )) + } + + async fn verify_identity(&self, jwt: &str) -> FederationResult { + let (claims, provider_id) = self + .oidc + .verify_web_identity_token(jwt) + .await + .map_err(FederationError::TokenVerification)?; + Ok(verified_identity(FederatedProviderRef::new(provider_id), claims)) + } + + async fn create_logout_token(&self, continuation: OpaqueLogoutContinuation) -> FederationResult { + let (provider, id_token) = continuation.into_parts(); + self.oidc + .create_logout_token(provider.as_str(), &id_token) + .await + .map_err(FederationError::Logout) + } + + async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> FederationResult> { + self.oidc + .build_logout_url(logout_token, post_logout_redirect_uri) + .await + .map_err(FederationError::Logout) + } +} + +impl FederatedProviderQuery for StandardOidcAdapter { + fn has_providers(&self) -> bool { + self.oidc.has_providers() + } + + fn list_providers(&self) -> Vec { + self.oidc.list_providers() + } + + fn list_visible_providers(&self) -> Vec { + self.oidc.list_visible_providers() + } + + fn redirect_policy(&self, provider_id: &str) -> Option { + self.oidc + .get_provider_config(provider_id) + .map(|config| FederatedRedirectPolicy { + redirect_uri: config.redirect_uri.clone(), + allow_request_origin: config.redirect_uri_dynamic, + }) + } +} + +#[cfg(test)] +#[path = "runtime_tests.rs"] +mod tests; diff --git a/crates/iam/src/oidc/runtime_tests.rs b/crates/iam/src/oidc/runtime_tests.rs new file mode 100644 index 000000000..afca6f4c3 --- /dev/null +++ b/crates/iam/src/oidc/runtime_tests.rs @@ -0,0 +1,754 @@ +// 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::*; +use crate::oidc::{config::*, provider::*, test_support::*}; +use jsonwebtoken::{Algorithm, Header}; +use openidconnect::ProviderMetadataWithLogout; +use rustfs_utils::egress::{ENV_OUTBOUND_ALLOW_ORIGINS, OutboundPolicy}; +use std::time::{Duration as StdDuration, Instant}; +use url::Url; + +#[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_extract_canonical_group_values_merges_groups_and_roles() { + let mut claims = HashMap::new(); + claims.insert("groups".to_string(), serde_json::json!(["devs", "admins"])); + claims.insert("roles".to_string(), serde_json::json!(["admins", "consoleAdmin"])); + + let merged = extract_canonical_group_values(&claims, "groups", "roles"); + assert_eq!(merged, vec!["admins", "consoleAdmin", "devs"]); +} +#[test] +fn test_extract_canonical_group_values_skips_duplicate_claim_name() { + let mut claims = HashMap::new(); + claims.insert("roles".to_string(), serde_json::json!(["consoleAdmin"])); + + let merged = extract_canonical_group_values(&claims, "roles", "roles"); + assert_eq!(merged, vec!["consoleAdmin"]); +} +#[test] +fn test_extract_canonical_group_values_roles_only() { + let mut claims = HashMap::new(); + claims.insert("roles".to_string(), serde_json::json!(["consoleAdmin", "bucket-reader"])); + + let merged = extract_canonical_group_values(&claims, "groups", "roles"); + assert_eq!(merged, vec!["bucket-reader", "consoleAdmin"]); +} +#[test] +fn test_extract_string_claim_case_insensitive() { + let mut claims = HashMap::new(); + claims.insert("policyminio".to_string(), serde_json::json!("consoleAdmin")); + + assert_eq!(extract_string_claim(&claims, "policyMinio"), "consoleAdmin"); + assert_eq!(extract_string_claim(&claims, "POLICYMINIO"), "consoleAdmin"); + assert_eq!(extract_string_claim(&claims, "policyminio"), "consoleAdmin"); +} +#[test] +fn test_extract_groups_claim_case_insensitive() { + let mut claims = HashMap::new(); + claims.insert("policyminio".to_string(), serde_json::json!(["consoleAdmin", "readwrite"])); + + let groups = extract_groups_claim(&claims, "policyMinio"); + assert_eq!(groups, vec!["consoleAdmin", "readwrite"]); + + let groups = extract_groups_claim(&claims, "POLICYMINIO"); + assert_eq!(groups, vec!["consoleAdmin", "readwrite"]); + + let groups = extract_groups_claim(&claims, "policyminio"); + assert_eq!(groups, vec!["consoleAdmin", "readwrite"]); +} +#[test] +fn test_extract_groups_claim_exact_match_preferred() { + let mut claims = HashMap::new(); + claims.insert("Policy".to_string(), serde_json::json!(["exact_match"])); + claims.insert("policy".to_string(), serde_json::json!(["lowercase"])); + + let groups = extract_groups_claim(&claims, "Policy"); + assert_eq!(groups, vec!["exact_match"]); +} +#[test] +fn test_extract_string_claim_ambiguous_case_insensitive_match_returns_empty() { + let mut claims = HashMap::new(); + claims.insert("Policy".to_string(), serde_json::json!("exact_match")); + claims.insert("policy".to_string(), serde_json::json!("lowercase")); + + assert_eq!(extract_string_claim(&claims, "POLICY"), ""); +} +#[test] +fn test_extract_groups_claim_ambiguous_case_insensitive_match_returns_empty() { + let mut claims = HashMap::new(); + claims.insert("Policy".to_string(), serde_json::json!(["exact_match"])); + claims.insert("policy".to_string(), serde_json::json!(["lowercase"])); + + let groups = extract_groups_claim(&claims, "POLICY"); + 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")); +} +#[tokio::test] +async fn web_identity_verification_refreshes_rotated_jwks() { + let (_, initial_jwk) = oidc_es256_key_and_jwk("initial"); + let (rotated_key, rotated_jwk) = oidc_es256_key_and_jwk("rotated"); + let initial_jwks = serde_json::json!({ "keys": [initial_jwk] }).to_string(); + let rotated_jwks = serde_json::json!({ "keys": [rotated_jwk] }).to_string(); + let Some((base, handle)) = start_mock_oidc_discovery_server_with_jwks( + |base| (base.to_string(), format!("{base}/jwks"), "/jwks".to_string()), + 4, + "ES256", + false, + move |fetch| { + if fetch == 0 { + initial_jwks.clone() + } else { + rotated_jwks.clone() + } + }, + ) else { + return; + }; + + let config = build_mocked_oidc_provider_config("rotating", &base); + let policy = OutboundPolicy::from_allowed_origins(&base).expect("loopback origin should be allowed"); + let http_client = ReqwestHttpClient::with_policy(policy); + let state = discover_provider(&config, &http_client) + .await + .expect("initial OIDC discovery should succeed"); + let sys = OidcSys { + configs: HashMap::from([(config.id.clone(), test_sourced_config(config.clone()))]), + provider_runtime: ProviderRuntime::new(http_client, HashMap::from([(config.id.clone(), state)])), + state_store: OidcStateStore::new(), + }; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_secs(); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some("rotated".to_string()); + let token = jsonwebtoken::encode( + &header, + &serde_json::json!({ + "iss": base, + "sub": "rotated-user", + "aud": config.client_id, + "iat": now, + "exp": now + 300, + "groups": ["readwrite"], + }), + &rotated_key, + ) + .expect("rotated OIDC token should sign"); + + let (claims, provider_id) = sys + .verify_web_identity_token(&token) + .await + .expect("verification should refresh JWKS and accept the rotated key"); + assert_eq!(provider_id, "rotating"); + assert_eq!(claims.sub, "rotated-user"); + assert_eq!(claims.groups, vec!["readwrite"]); + handle.join().expect("rotating JWKS mock server should exit cleanly"); +} +#[tokio::test] +async fn complete_provider_console_login_preserves_hidden_and_issuer_modes() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + for hidden in [false, true] { + for explicit in [false, true] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let issuer = if explicit { + "https://issuer.example.com".to_string() + } else { + base.clone() + }; + let mut config = build_mocked_oidc_provider_config("console", &format!("{base}/.well-known/openid-configuration")); + config.hide_from_ui = hidden; + config.issuer = explicit.then(|| issuer.clone()); + config.client_secret = Some(Nonce::new_random().secret().clone()); + let server_config = config.clone(); + let server_base = base.clone(); + let redirect = "https://console.example.com/oauth_callback"; + let (key, jwk) = oidc_es256_key_and_jwk("console"); + let (auth_tx, auth_rx) = tokio::sync::oneshot::channel::>(); + let server = tokio::spawn(async move { + let mut auth_rx = Some(auth_rx); + for expected_path in ["/.well-known/openid-configuration", "/jwks", "/token"] { + let (mut stream, _) = tokio::time::timeout(StdDuration::from_secs(10), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut bytes = Vec::new(); + let header_end = loop { + bytes.push(stream.read_u8().await.unwrap()); + assert!(bytes.len() < 8192); + if bytes.ends_with(b"\r\n\r\n") { + break bytes.len(); + } + }; + let headers = String::from_utf8(bytes).unwrap(); + let request_line = headers.lines().next().unwrap(); + assert_eq!(request_line.split_whitespace().nth(1), Some(expected_path)); + let headers_map: HashMap<_, _> = headers + .lines() + .skip(1) + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_string())) + .collect(); + let body = match expected_path { + "/.well-known/openid-configuration" => { + assert!(request_line.starts_with("GET ")); + assert_eq!(headers_map["accept"], "application/json"); + serde_json::json!({ + "issuer": issuer, "authorization_endpoint": format!("{server_base}/authorize"), + "token_endpoint": format!("{server_base}/token"), "jwks_uri": format!("{server_base}/jwks"), + "response_types_supported": ["code"], "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["ES256"] + }) + } + "/jwks" => { + assert!(request_line.starts_with("GET ")); + assert!(headers_map["accept"].contains("application/json")); + serde_json::json!({"keys": [jwk]}) + } + "/token" => { + assert!(request_line.starts_with("POST ")); + assert_eq!(headers_map["accept"], "application/json"); + assert!(headers_map["content-type"].starts_with("application/x-www-form-urlencoded")); + let length: usize = headers_map["content-length"].parse().unwrap(); + assert!(header_end + length < 16384); + let mut body = vec![0; length]; + stream.read_exact(&mut body).await.unwrap(); + let form: HashMap = url::form_urlencoded::parse(&body).into_owned().collect(); + assert_eq!(form["grant_type"], "authorization_code"); + assert_eq!(form["code"], "test-authorization-code"); + assert_eq!(form["client_id"], server_config.client_id); + assert_eq!(Some(&form["client_secret"]), server_config.client_secret.as_ref()); + assert_eq!(form["redirect_uri"], redirect); + let auth = auth_rx.take().unwrap().await.unwrap(); + let challenge = PkceCodeChallenge::from_code_verifier_sha256(&PkceCodeVerifier::new( + form["code_verifier"].clone(), + )); + assert_eq!(challenge.as_str(), auth["code_challenge"]); + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some("console".into()); + let token = jsonwebtoken::encode( + &header, + &serde_json::json!({ + "iss": issuer, "sub": "existing-user", "aud": server_config.client_id, + "iat": now, "exp": now + 300, "nonce": auth["nonce"], + "email": "user@example.com", "groups": ["readwrite"] + }), + &key, + ) + .unwrap(); + serde_json::json!({"access_token": "test-access-token", "token_type": "Bearer", "id_token": token}) + } + _ => unreachable!(), + } + .to_string(); + stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } + }); + let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).unwrap()); + let discovered = discover_provider(&config, &http_client).await.unwrap(); + let sys = OidcSys { + configs: HashMap::from([(config.id.clone(), test_sourced_config(config))]), + provider_runtime: ProviderRuntime::new(http_client, HashMap::from([("console".into(), discovered)])), + state_store: OidcStateStore::new(), + }; + let auth_url = sys.authorize_url("console", redirect, Some("/buckets".into())).await.unwrap(); + let auth_url = Url::parse(&auth_url).unwrap(); + assert_eq!(auth_url.as_str().split('?').next(), Some(format!("{base}/authorize").as_str())); + let auth: HashMap = auth_url.query_pairs().into_owned().collect(); + assert_eq!(auth["response_type"], "code"); + assert_eq!(auth["client_id"], "rustfs-oidc-test"); + assert!(!auth["nonce"].is_empty()); + assert!(!auth["state"].is_empty()); + assert_eq!(auth["redirect_uri"], redirect); + assert_eq!(auth["code_challenge_method"], "S256"); + assert!(auth["scope"].split_whitespace().any(|scope| scope == "openid")); + let state = auth["state"].clone(); + auth_tx.send(auth).unwrap(); + let (claims, provider, session, _) = sys + .exchange_code(&state, "test-authorization-code", redirect) + .await + .unwrap_or_else(|err| panic!("hidden={hidden}, explicit={explicit}: {err}")); + assert_eq!(provider, "console"); + assert_eq!(claims.sub, "existing-user"); + assert_eq!(claims.email, "user@example.com"); + assert_eq!(claims.groups, vec!["readwrite"]); + assert_eq!(session.redirect_after.as_deref(), Some("/buckets")); + assert!(matches!(sys.exchange_code(&state, "test-authorization-code", redirect).await, + Err(error) if error == "invalid or expired OIDC state")); + server.await.unwrap(); + } + } +} +#[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_core_token_response_accepts_rfc3339_updated_at() { + // Signature verification happens later; this test covers token response deserialization. + let id_token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20vb2lkYyIsInN1YiI6InVzZXItMSIsImF1ZCI6InJ1c3RmcyIsImV4cCI6MTc4NDQzMjc2OSwiaWF0IjoxNzgzMjIzMTY5LCJ1cGRhdGVkX2F0IjoiMjAyNi0wNy0wM1QwNDo1MDo1MC44MTFaIn0.c2ln"; + let body = serde_json::json!({ + "scope": "openid roles profile email", + "token_type": "Bearer", + "access_token": "access-token", + "expires_in": 1209600, + "id_token": id_token, + }) + .to_string(); + + let response: openidconnect::core::CoreTokenResponse = + serde_json::from_str(&body).expect("RFC3339 updated_at should parse in token response"); + + assert!(response.extra_fields().id_token().is_some()); +} +#[test] +fn test_map_claims_to_policies_no_provider() { + let sys = OidcSys::empty().expect("failed to initialize empty OIDC system"); + + 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_oidc_sys_empty() { + let sys = OidcSys::empty().expect("failed to initialize empty OIDC system"); + assert!(!sys.has_providers()); + assert!(sys.config_snapshot().providers().is_empty()); +} +#[tokio::test] +async fn oidc_token_exchange_reports_forbidden_token_endpoint() { + let provider_id = "default"; + let token_endpoint = "http://192.168.65.254:8080/realms/rustfs/protocol/openid-connect/token"; + let metadata = serde_json::from_value::(serde_json::json!({ + "issuer": "https://idp.example.com/realms/rustfs", + "authorization_endpoint": "https://idp.example.com/realms/rustfs/protocol/openid-connect/auth", + "token_endpoint": token_endpoint, + "jwks_uri": "https://idp.example.com/realms/rustfs/protocol/openid-connect/certs", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"] + })) + .expect("provider metadata should parse"); + let config = + build_mocked_oidc_provider_config(provider_id, "https://idp.example.com/realms/rustfs/.well-known/openid-configuration"); + let state_store = OidcStateStore::new(); + state_store + .insert( + "test-state".to_string(), + OidcAuthSession { + provider_id: provider_id.to_string(), + pkce_verifier: "test-pkce-verifier".to_string(), + nonce: "test-nonce".to_string(), + redirect_after: None, + }, + ) + .await; + let sys = OidcSys { + configs: HashMap::from([(provider_id.to_string(), test_sourced_config(config))]), + provider_runtime: ProviderRuntime::new( + ReqwestHttpClient::with_policy(OutboundPolicy::default()), + HashMap::from([( + provider_id.to_string(), + ProviderState { + metadata: DiscoveredProviderMetadata::Console(Box::new(metadata)), + discovered_at: Instant::now(), + }, + )]), + ), + state_store, + }; + + let error = match sys + .exchange_code("test-state", "test-code", "https://console.example.com/oauth_callback") + .await + { + Ok(_) => panic!("private token endpoint should require an explicit allowlist origin"), + Err(error) => error, + }; + + assert!(error.contains("request_error_kind=forbidden_outbound")); + assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}"))); +} +#[test] +fn test_list_visible_providers_hides_hidden_provider() { + let visible = test_config("dex"); + let mut hidden = test_config("kubernetes"); + hidden.hide_from_ui = true; + + let sys = make_test_sys(vec![visible, hidden]); + let listed = sys.list_visible_providers(); + + assert_eq!(listed.len(), 1); + assert!(listed.iter().any(|provider| provider.provider_id == "dex")); + assert!(!listed.iter().any(|provider| provider.provider_id == "kubernetes")); +} +#[test] +fn test_hidden_provider_still_resolvable_for_sts() { + let visible = test_config("dex"); + let mut hidden = test_config("kubernetes"); + hidden.hide_from_ui = true; + + let sys = make_test_sys(vec![visible, hidden]); + + assert!(sys.get_provider_config("kubernetes").is_some()); + assert!(sys.get_provider_config("dex").is_some()); +} +#[test] +fn test_config_snapshot_includes_hidden_for_replication() { + let visible = test_config("dex"); + let mut hidden = test_config("kubernetes"); + hidden.hide_from_ui = true; + + let sys = make_test_sys(vec![visible, hidden]); + + assert_eq!(sys.config_snapshot().providers().len(), 2); +} +#[test] +fn test_list_providers_includes_hidden_for_compatibility() { + let visible = test_config("dex"); + let mut hidden = test_config("kubernetes"); + hidden.hide_from_ui = true; + + let sys = make_test_sys(vec![visible, hidden]); + + assert_eq!(sys.list_providers().len(), 2); + assert_eq!(sys.list_visible_providers().len(), 1); +} +#[test] +fn role_policy_does_not_map_groups_as_policies() { + let mut config = test_config("authentik"); + config.role_policy = "consoleAdmin".to_string(); + config.claim_name = "policy".to_string(); + + let sys = make_test_sys(vec![config]); + + let claims = OidcClaims { + groups: vec!["authentik Admins".to_string(), "users".to_string()], + raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly"]))]), + ..Default::default() + }; + + let (policies, groups) = sys.map_claims_to_policies("authentik", &claims); + assert_eq!(groups, vec!["authentik Admins", "users"]); + assert_eq!(policies, vec!["consoleAdmin"]); +} +#[test] +fn group_claim_policy_names_cover_only_prefixed_group_values() { + let mut config = test_config("ad"); + config.claim_prefix = "oidc-".to_string(); + let sys = make_test_sys(vec![config]); + let claims = OidcClaims { + groups: vec![ + "EXAMPLE\\Domain Users".to_string(), + "admins".to_string(), + "admins".to_string(), + ], + ..Default::default() + }; + + assert_eq!( + sys.group_claim_policy_names("ad", &claims), + vec!["oidc-EXAMPLE\\Domain Users", "oidc-admins"] + ); + assert!(sys.group_claim_policy_names("missing-provider", &claims).is_empty()); +} +#[test] +fn group_claim_policy_names_include_merged_roles_claim_values() { + let mut config = test_config("keycloak"); + config.roles_claim = "roles".to_string(); + let raw = HashMap::from([ + ("groups".to_string(), serde_json::json!(["readonly"])), + ("roles".to_string(), serde_json::json!(["offline_access", "default-roles-corp"])), + ]); + let claims = OidcClaims { + groups: extract_canonical_group_values(&raw, &config.groups_claim, &config.roles_claim), + raw, + ..Default::default() + }; + let sys = make_test_sys(vec![config]); + + assert_eq!( + sys.group_claim_policy_names("keycloak", &claims), + vec!["default-roles-corp", "offline_access", "readonly"] + ); +} +#[test] +fn group_claim_policy_names_exclude_role_policy_and_dedicated_policy_claim() { + let mut role_config = test_config("role"); + role_config.role_policy = "consoleAdmin".to_string(); + let mut claim_config = test_config("claim"); + claim_config.claim_name = "policy".to_string(); + let sys = make_test_sys(vec![role_config, claim_config]); + let claims = OidcClaims { + groups: vec!["readonly".to_string(), "unmapped".to_string()], + raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly", "writeonly"]))]), + ..Default::default() + }; + + assert!(sys.group_claim_policy_names("role", &claims).is_empty()); + // `readonly` is also requested explicitly through the policy claim, so it must resolve. + assert_eq!(sys.group_claim_policy_names("claim", &claims), vec!["unmapped"]); +} +#[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 blank_role_policy_uses_claim_mapping() { + let mut config = test_config("keycloak"); + config.role_policy = " ".to_string(); + + let sys = make_test_sys(vec![config]); + let claims = OidcClaims { + groups: vec!["readonly".to_string()], + ..Default::default() + }; + + let (policies, groups) = sys.map_claims_to_policies("keycloak", &claims); + assert_eq!(groups, vec!["readonly"]); + assert_eq!(policies, vec!["readonly"]); +} +#[test] +fn claim_mapping_keeps_groups_with_distinct_primary_claim() { + let mut config = test_config("keycloak"); + config.claim_name = "policy".to_string(); + + let sys = make_test_sys(vec![config]); + let claims = OidcClaims { + groups: vec!["developers".to_string()], + raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly"]))]), + ..Default::default() + }; + + let (policies, groups) = sys.map_claims_to_policies("keycloak", &claims); + assert_eq!(groups, vec!["developers"]); + assert_eq!(policies, vec!["developers", "readonly"]); +} +#[test] +fn test_config_snapshot_lists_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 snapshot = sys.config_snapshot(); + assert_eq!(snapshot.providers().len(), 1); + assert_eq!(snapshot.providers()[0].config.id, "keycloak"); + assert_eq!(snapshot.providers()[0].config.display_name, "Keycloak SSO"); + assert_eq!(snapshot.providers()[0].source, OidcProviderConfigSource::Persisted); +} +#[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 verified_identity_preserves_provider_and_claim_values() { + let raw = HashMap::from([ + ("iss".to_string(), serde_json::json!("https://corp.example.test")), + ("department".to_string(), serde_json::json!("engineering")), + ("roles".to_string(), serde_json::json!(["reader", "admin"])), + ]); + let identity = verified_identity( + FederatedProviderRef::new("corp".to_string()), + OidcClaims { + sub: " subject-123 ".to_string(), + email: " user@example.test ".to_string(), + username: " user ".to_string(), + groups: vec![ + "source-ops".to_string(), + "source-developers".to_string(), + "source-ops".to_string(), + ], + raw: raw.clone(), + }, + ); + + assert_eq!(identity.provider().as_str(), "corp"); + assert_eq!(identity.subject(), " subject-123 "); + assert_eq!(identity.email(), " user@example.test "); + assert_eq!(identity.username(), " user "); + assert_eq!(identity.source_groups(), ["source-ops", "source-developers", "source-ops"]); + assert_eq!(identity.attributes(), &raw); +} +#[test] +fn adapter_preserves_provider_views_and_redirect_policy() { + let mut visible = test_config("default"); + visible.display_name = "Company SSO".to_string(); + visible.redirect_uri = Some("https://console.example.test/callback".to_string()); + visible.redirect_uri_dynamic = false; + let mut hidden = test_config("workload"); + hidden.display_name = "Workload Identity".to_string(); + hidden.hide_from_ui = true; + let adapter = StandardOidcAdapter::new(Arc::new(make_test_sys(vec![visible, hidden]))); + + let mut providers = adapter.list_providers(); + providers.sort_by(|left, right| left.provider_id.cmp(&right.provider_id)); + assert_eq!( + providers, + vec![ + FederatedProviderView { + provider_id: "default".to_string(), + display_name: "Company SSO".to_string(), + }, + FederatedProviderView { + provider_id: "workload".to_string(), + display_name: "Workload Identity".to_string(), + }, + ] + ); + assert_eq!( + adapter.list_visible_providers(), + vec![FederatedProviderView { + provider_id: "default".to_string(), + display_name: "Company SSO".to_string(), + }] + ); + assert_eq!( + adapter.redirect_policy("default"), + Some(FederatedRedirectPolicy { + redirect_uri: Some("https://console.example.test/callback".to_string()), + allow_request_origin: false, + }) + ); + assert_eq!(adapter.redirect_policy("missing"), None); +} diff --git a/crates/iam/src/oidc_state.rs b/crates/iam/src/oidc/state.rs similarity index 92% rename from crates/iam/src/oidc_state.rs rename to crates/iam/src/oidc/state.rs index 5cdd962ee..fa9b90f3e 100644 --- a/crates/iam/src/oidc_state.rs +++ b/crates/iam/src/oidc/state.rs @@ -25,32 +25,32 @@ const OIDC_STATE_CAPACITY_LOG_INTERVAL_SECS: u64 = 60; /// 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, +pub(super) struct OidcAuthSession { + pub(super) provider_id: String, + pub(super) pkce_verifier: String, + pub(super) nonce: String, + pub(super) redirect_after: Option, } /// Stores an ID token behind a one-time opaque handle so the console can trigger /// RP-initiated logout without persisting the raw token in browser storage. #[derive(Debug, Clone)] -pub struct OidcLogoutSession { - pub provider_id: String, - pub id_token: String, +pub(super) struct OidcLogoutSession { + pub(super) provider_id: String, + pub(super) id_token: String, } /// TTL cache for OIDC auth state (PKCE verifiers + nonces) during the authorization flow. /// Entries expire after 5 minutes and are single-use (removed on retrieval). #[derive(Clone)] -pub struct OidcStateStore { +pub(super) struct OidcStateStore { cache: Cache, logout_cache: Cache, last_capacity_log_at: Arc, } impl OidcStateStore { - pub fn new() -> Self { + pub(super) fn new() -> Self { let cache = Cache::builder() .max_capacity(OIDC_STATE_CAPACITY) .time_to_live(Duration::from_secs(300)) // 5 minute TTL @@ -67,7 +67,7 @@ impl OidcStateStore { } /// Store a new auth session keyed by the OAuth2 `state` parameter. - pub async fn insert(&self, state: String, session: OidcAuthSession) { + pub(super) async fn insert(&self, state: String, session: OidcAuthSession) { self.cache.insert(state, session).await; let size = self.cache.entry_count(); @@ -104,27 +104,29 @@ impl OidcStateStore { /// 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 { + pub(super) 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 { + #[cfg(test)] + pub(super) async fn contains(&self, state: &str) -> bool { self.cache.get(state).await.is_some() } /// Store a new one-time logout session keyed by an opaque logout token. - pub async fn insert_logout(&self, token: String, session: OidcLogoutSession) { + pub(super) async fn insert_logout(&self, token: String, session: OidcLogoutSession) { self.logout_cache.insert(token, session).await; } /// Retrieve and remove a logout session (single-use). Returns None if expired or not found. - pub async fn take_logout(&self, token: &str) -> Option { + pub(super) async fn take_logout(&self, token: &str) -> Option { self.logout_cache.remove(token).await } /// Check if a logout token exists (without consuming it). - pub async fn contains_logout(&self, token: &str) -> bool { + #[cfg(test)] + pub(super) async fn contains_logout(&self, token: &str) -> bool { self.logout_cache.get(token).await.is_some() } } diff --git a/crates/iam/src/oidc/test_support.rs b/crates/iam/src/oidc/test_support.rs new file mode 100644 index 000000000..e81855537 --- /dev/null +++ b/crates/iam/src/oidc/test_support.rs @@ -0,0 +1,526 @@ +// 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::{ + config::{OidcProviderConfig, OidcProviderConfigSource, SourcedOidcProviderConfig}, + provider::ProviderRuntime, + runtime::OidcSys, + state::OidcStateStore, + transport::ReqwestHttpClient, +}; +use std::collections::HashMap; + +#[cfg(any(test, feature = "test-util"))] +#[doc(hidden)] +pub fn make_test_sys(configs: Vec) -> OidcSys { + let configs = configs + .into_iter() + .map(test_sourced_config) + .map(|provider| (provider.config.id.clone(), provider)) + .collect(); + OidcSys { + configs, + provider_runtime: ProviderRuntime::new( + ReqwestHttpClient::new().expect("failed to initialize OIDC HTTP clients"), + HashMap::new(), + ), + state_store: OidcStateStore::new(), + } +} + +#[cfg(any(test, feature = "test-util"))] +pub(super) fn test_sourced_config(config: OidcProviderConfig) -> SourcedOidcProviderConfig { + SourcedOidcProviderConfig { + config, + source: OidcProviderConfigSource::Persisted, + } +} + +#[cfg(any(test, feature = "test-util"))] +#[doc(hidden)] +pub fn test_config(id: &str) -> OidcProviderConfig { + OidcProviderConfig { + id: id.to_string(), + enabled: true, + config_url: format!("https://example.com/{id}/.well-known/openid-configuration"), + issuer: None, + client_id: "client-id".to_string(), + client_secret: None, + scopes: vec!["openid".to_string()], + other_audiences: vec![], + redirect_uri: None, + redirect_uri_dynamic: true, + claim_name: "groups".to_string(), + claim_prefix: String::new(), + role_policy: String::new(), + display_name: id.to_string(), + groups_claim: "groups".to_string(), + roles_claim: String::new(), + email_claim: "email".to_string(), + username_claim: "preferred_username".to_string(), + hide_from_ui: false, + } +} + +#[cfg(test)] +use super::{config::OidcProviderValidationResult, provider::discover_provider, transport::OidcHttpError}; +#[cfg(test)] +use jsonwebtoken::{Algorithm, EncodingKey}; +#[cfg(test)] +use openidconnect::AsyncHttpClient; +#[cfg(test)] +use rustfs_utils::egress::{OutboundDnsPolicyRejection, OutboundPolicy}; +#[cfg(test)] +use std::sync::Arc; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; +#[cfg(test)] +use url::Url; + +#[cfg(test)] +#[derive(Clone)] +pub(super) struct RejectingDnsResolver { + pub(super) allow_origin_can_recover: bool, + pub(super) calls: Option>, +} + +#[cfg(test)] +impl reqwest::dns::Resolve for RejectingDnsResolver { + fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { + if let Some(calls) = &self.calls { + calls.fetch_add(1, Ordering::Relaxed); + } + let host = name.as_str().to_string(); + let rejection = OutboundDnsPolicyRejection::new(host, self.allow_origin_can_recover); + Box::pin(async move { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, rejection).into()) }) + } +} + +#[cfg(test)] +pub(super) fn build_mocked_oidc_provider_config(id: &str, config_url: &str) -> OidcProviderConfig { + OidcProviderConfig { + id: id.to_string(), + enabled: true, + config_url: config_url.to_string(), + issuer: None, + client_id: "rustfs-oidc-test".to_string(), + client_secret: None, + scopes: vec!["openid".to_string()], + other_audiences: vec![], + redirect_uri: None, + redirect_uri_dynamic: false, + claim_name: "sub".to_string(), + claim_prefix: "oidc".to_string(), + role_policy: String::new(), + display_name: "mock-oidc".to_string(), + groups_claim: "groups".to_string(), + roles_claim: String::new(), + email_claim: "email".to_string(), + username_claim: "username".to_string(), + hide_from_ui: false, + } +} +#[cfg(test)] +pub(super) fn read_mock_oidc_request(stream: &mut impl std::io::Read) -> String { + let mut request_bytes = Vec::new(); + let mut buffer = [0u8; 4096]; + loop { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(n) => request_bytes.extend_from_slice(&buffer[..n]), + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => break, + Err(_) => break, + } + if request_bytes.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + if request_bytes.len() >= 8192 { + break; + } + } + String::from_utf8_lossy(&request_bytes).into_owned() +} +#[cfg(test)] +pub(super) fn read_mock_oidc_request_path(stream: &mut impl std::io::Read) -> String { + read_mock_oidc_request(stream) + .lines() + .next() + .unwrap_or("") + .split_whitespace() + .nth(1) + .unwrap_or("") + .to_string() +} +#[cfg(test)] +pub(super) fn mock_oidc_response(path: &str, discovery_body: &str, expected_jwks_path: &str, jwks_body: &str) -> String { + let (status, body) = if path.ends_with("/.well-known/openid-configuration") { + (200, discovery_body) + } else if path == expected_jwks_path { + (200, jwks_body) + } else { + (404, r#"{"error":"not found"}"#) + }; + + format!( + "HTTP/1.1 {status} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + if status == 200 { "OK" } else { "Not Found" }, + body.len() + ) +} +#[cfg(test)] +pub(super) fn start_mock_oidc_discovery_server_with_jwks( + build_discovery_issuer: F, + max_requests: usize, + signing_alg: &'static str, + workload: bool, + jwks_response: J, +) -> Option<(String, std::thread::JoinHandle<()>)> +where + F: Fn(&str) -> (String, String, String) + Send + 'static, + J: Fn(usize) -> String + Send + 'static, +{ + use std::io::Write; + use std::net::{Shutdown, TcpListener}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + // After the last completed response, exit if no new connection arrives within this window. + // Keep the mock server alive long enough for slower CI/macOS test environments to finish + // discovery + JWKS requests without racing the shutdown timer. + const IDLE_SHUTDOWN: Duration = Duration::from_secs(1); + const ABSOLUTE_CAP: Duration = Duration::from_secs(5); + + let listener = match TcpListener::bind("127.0.0.1:0") { + Ok(listener) => listener, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None, + Err(err) => panic!("test listener should bind: {err}"), + }; + let base = format!("http://{}", listener.local_addr().expect("listener local address should be available")); + let (discovery_issuer, discovery_jwks_uri, expected_jwks_path) = build_discovery_issuer(&base); + let mut discovery_document = serde_json::json!({ + "issuer": discovery_issuer, + "authorization_endpoint": format!("{base}/authorize"), + "token_endpoint": format!("{base}/token"), + "jwks_uri": discovery_jwks_uri, + "response_types_supported": ["code"], + "response_modes_supported": ["query"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": [signing_alg], + }); + if workload { + let fields = discovery_document.as_object_mut().expect("mock metadata is an object"); + fields.remove("authorization_endpoint"); + fields.remove("token_endpoint"); + fields.insert("response_types_supported".into(), serde_json::json!(["id_token"])); + } + let discovery_body = discovery_document.to_string(); + let (ready_tx, ready_rx) = mpsc::channel(); + + let handle = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("failed to set discovery mock listener non-blocking"); + let _ = ready_tx.send(()); + + let mut seen = 0usize; + let mut jwks_fetches = 0usize; + let start = Instant::now(); + let mut last_completed = Instant::now(); + + loop { + if seen > 0 && last_completed.elapsed() >= IDLE_SHUTDOWN { + break; + } + if start.elapsed() >= ABSOLUTE_CAP { + break; + } + + let mut stream = match listener.accept() { + Ok((stream, _)) => stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(_) => break, + }; + stream + .set_nonblocking(false) + .expect("failed to set discovery mock stream blocking"); + + seen += 1; + stream + .set_nonblocking(false) + .expect("failed to set discovery mock stream blocking"); + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .expect("failed to set discovery mock read timeout"); + + let request = read_mock_oidc_request(&mut stream); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or(""); + let jwks_body = jwks_response(jwks_fetches); + if path == expected_jwks_path { + jwks_fetches += 1; + } + let mut response = mock_oidc_response(path, &discovery_body, &expected_jwks_path, &jwks_body); + if path.contains("/.well-known/openid-configuration") { + assert!( + request + .lines() + .filter_map(|line| line.split_once(':')) + .any(|(name, value)| { name.eq_ignore_ascii_case("accept") && value.trim() == "application/json" }), + "discovery Accept must remain unchanged" + ); + } + if path == expected_jwks_path { + let expected_type = if workload { + "application/jwk-set+json" + } else { + "application/json" + }; + let accepts_type = request.lines().filter_map(|line| line.split_once(':')).any(|(name, value)| { + name.eq_ignore_ascii_case("accept") && value.split(',').any(|item| item.trim() == expected_type) + }); + if !accepts_type { + response = "HTTP/1.1 406 Not Acceptable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into(); + } else if workload { + response = response.replace("Content-Type: application/json", "Content-Type: application/jwk-set+json"); + } + } + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + let _ = stream.shutdown(Shutdown::Both); + last_completed = Instant::now(); + + if seen >= max_requests { + break; + } + } + }); + ready_rx + .recv_timeout(Duration::from_millis(100)) + .expect("mock OIDC discovery server should become ready"); + + Some((base, handle)) +} +#[cfg(test)] +pub(super) fn start_mock_oidc_discovery_server( + build_discovery_issuer: F, + max_requests: usize, +) -> Option<(String, std::thread::JoinHandle<()>)> +where + F: Fn(&str) -> (String, String, String) + Send + 'static, +{ + start_mock_oidc_discovery_server_with_jwks(build_discovery_issuer, max_requests, "RS256", false, |_| { + r#"{"keys":[]}"#.to_string() + }) +} +#[cfg(test)] +pub(super) fn oidc_es256_key_and_jwk(kid: &str) -> (EncodingKey, serde_json::Value) { + let certified = rcgen::generate_simple_self_signed(vec![format!("{kid}.invalid")]).expect("OIDC signing key should generate"); + let encoding_key = + EncodingKey::from_ec_pem(certified.signing_key.serialize_pem().as_bytes()).expect("OIDC signing key should encode"); + let mut jwk = jsonwebtoken::jwk::Jwk::from_encoding_key(&encoding_key, Algorithm::ES256) + .expect("OIDC public JWK should derive from signing key"); + jwk.common.key_id = Some(kid.to_string()); + jwk.common.public_key_use = Some(jsonwebtoken::jwk::PublicKeyUse::Signature); + (encoding_key, serde_json::to_value(jwk).expect("OIDC JWK should serialize")) +} +#[cfg(test)] +pub(super) fn start_mock_oidc_tls_discovery_server( + build_discovery_issuer: F, + max_requests: usize, +) -> Option<(String, String, std::thread::JoinHandle<()>)> +where + F: Fn(&str) -> (String, String, String) + Send + 'static, +{ + use std::io::Write; + use std::net::{Shutdown, TcpListener}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + const IDLE_SHUTDOWN: Duration = Duration::from_secs(1); + const ABSOLUTE_CAP: Duration = Duration::from_secs(5); + + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let certified = + rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).expect("generate OIDC TLS test certificate"); + let cert_pem = certified.cert.pem(); + let server_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![certified.cert.der().clone()], + rustls_pki_types::PrivateKeyDer::try_from(certified.signing_key.serialize_der()) + .expect("convert OIDC TLS test private key"), + ) + .expect("build OIDC TLS mock server config"); + + let listener = match TcpListener::bind("127.0.0.1:0") { + Ok(listener) => listener, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None, + Err(err) => panic!("test TLS listener should bind: {err}"), + }; + let base = format!("https://{}", listener.local_addr().expect("listener local address should be available")); + let (discovery_issuer, discovery_jwks_uri, expected_jwks_path) = build_discovery_issuer(&base); + let discovery_body = serde_json::json!({ + "issuer": discovery_issuer, + "authorization_endpoint": format!("{base}/authorize"), + "token_endpoint": format!("{base}/token"), + "jwks_uri": discovery_jwks_uri, + "response_types_supported": ["code"], + "response_modes_supported": ["query"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + }) + .to_string(); + let jwks_body = r#"{"keys":[]}"#; + let (ready_tx, ready_rx) = mpsc::channel(); + + let handle = std::thread::spawn(move || { + let server_config = Arc::new(server_config); + listener + .set_nonblocking(true) + .expect("failed to set TLS discovery mock listener non-blocking"); + let _ = ready_tx.send(()); + + let mut seen = 0usize; + let start = Instant::now(); + let mut last_completed = Instant::now(); + + loop { + if seen > 0 && last_completed.elapsed() >= IDLE_SHUTDOWN { + break; + } + if start.elapsed() >= ABSOLUTE_CAP { + break; + } + + let tcp_stream = match listener.accept() { + Ok((stream, _)) => stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(_) => break, + }; + tcp_stream + .set_nonblocking(false) + .expect("failed to set TLS discovery mock stream blocking"); + tcp_stream + .set_read_timeout(Some(Duration::from_secs(1))) + .expect("failed to set TLS discovery mock read timeout"); + + seen += 1; + let connection = match rustls::ServerConnection::new(server_config.clone()) { + Ok(connection) => connection, + Err(_) => break, + }; + let mut stream = rustls::StreamOwned::new(connection, tcp_stream); + let path = read_mock_oidc_request_path(&mut stream); + let response = mock_oidc_response(&path, &discovery_body, &expected_jwks_path, jwks_body); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + let _ = stream.sock.shutdown(Shutdown::Both); + last_completed = Instant::now(); + + if seen >= max_requests { + break; + } + } + }); + ready_rx + .recv_timeout(Duration::from_millis(100)) + .expect("mock TLS OIDC discovery server should become ready"); + + Some((base, cert_pem, handle)) +} +#[cfg(test)] +pub(super) fn discovery_error_contains_all_variants(err: &str, base: &str) -> bool { + err.contains(base) && err.contains(&format!("{base}/")) && err.contains("discovery failed for all issuer variants") +} +#[cfg(test)] +pub(super) async fn validate_mocked_oidc_provider_config( + config: &OidcProviderConfig, +) -> Result { + // The mock discovery/JWKS/token endpoints share the loopback origin of `config_url`. + // Explicitly allow that origin so the egress policy does not reject the loopback mock. + let origin = Url::parse(&config.config_url) + .map_err(|_| "invalid mock config_url".to_string())? + .origin() + .ascii_serialization(); + let policy = OutboundPolicy::from_allowed_origins(&origin).map_err(|err| err.to_string())?; + let http_client = ReqwestHttpClient::with_policy(policy); + let state = discover_provider(config, &http_client).await?; + + Ok(OidcProviderValidationResult { + issuer: state.metadata.issuer().to_string(), + authorization_endpoint: state.metadata.authorization_endpoint(), + token_endpoint: state.metadata.token_endpoint().map(ToString::to_string), + }) +} +#[cfg(test)] +pub(super) fn start_unbounded_body_server(body_len: usize) -> Option<(String, std::thread::JoinHandle<()>)> { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::time::Duration; + + let listener = match TcpListener::bind("127.0.0.1:0") { + Ok(listener) => listener, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None, + Err(err) => panic!("test listener should bind: {err}"), + }; + let base = format!("http://{}", listener.local_addr().expect("listener local address should be available")); + let (ready_tx, ready_rx) = mpsc::channel(); + + let handle = std::thread::spawn(move || { + let _ = ready_tx.send(()); + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let _ = stream.set_read_timeout(Some(Duration::from_secs(1))); + let mut buffer = [0u8; 4096]; + let _ = stream.read(&mut buffer); + let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n"); + + let chunk = vec![b'a'; 64 * 1024]; + let mut written = 0usize; + while written < body_len { + let take = chunk.len().min(body_len - written); + if stream.write_all(&chunk[..take]).is_err() { + break; + } + written += take; + } + let _ = stream.flush(); + }); + ready_rx + .recv_timeout(Duration::from_millis(100)) + .expect("mock body server should become ready"); + + Some((base, handle)) +} +#[cfg(test)] +pub(super) async fn fetch_oidc_mock_body(base: &str) -> Result, OidcHttpError> { + let policy = OutboundPolicy::from_allowed_origins(base).expect("origin should parse"); + let client = ReqwestHttpClient::with_policy(policy); + let request = http::Request::builder() + .method(http::Method::GET) + .uri(base) + .body(Vec::new()) + .expect("request should build"); + client.call(request).await.map(http::Response::into_body) +} diff --git a/crates/iam/src/oidc/transport.rs b/crates/iam/src/oidc/transport.rs new file mode 100644 index 000000000..1fd8706f0 --- /dev/null +++ b/crates/iam/src/oidc/transport.rs @@ -0,0 +1,660 @@ +// 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::{LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC}; +use openidconnect::AsyncHttpClient; +use reqwest::{Certificate, Client}; +use rustfs_config::MAX_OIDC_RESPONSE_SIZE; +use rustfs_utils::egress::{ENV_OUTBOUND_ALLOW_ORIGINS, OutboundPolicy, find_outbound_dns_policy_rejection}; +use std::collections::VecDeque; +use std::future::Future; +use std::net::IpAddr; +use std::pin::Pin; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard, RwLock}; +use std::time::{Duration as StdDuration, Instant}; +use tracing::{debug, error, warn}; +use url::Url; + +pub(super) const EVENT_OIDC_HTTP: &str = "oidc_http"; +const OIDC_HTTP_REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(10); +const OIDC_HTTP_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(3); +const OIDC_PLUGIN_AUTHN_WINDOW: StdDuration = StdDuration::from_secs(60); + +#[derive(Debug, Clone, Copy, Default)] +pub struct OidcPluginAuthnMetricsSnapshot { + pub failed_requests_minute: u64, + pub last_fail_seconds: u64, + pub last_succ_seconds: u64, + pub succ_avg_rtt_ms_minute: u64, + pub succ_max_rtt_ms_minute: u64, + pub total_requests_minute: u64, +} + +#[derive(Debug, Clone)] +struct OidcPluginAuthnSample { + observed_at: Instant, + succeeded: bool, + rtt_ms: u64, +} + +#[derive(Debug, Default)] +struct OidcPluginAuthnMetrics { + samples: Mutex>, + last_fail_at: Mutex>, + last_succ_at: Mutex>, +} + +fn lock_oidc_plugin_authn_metrics<'a, T>(mutex: &'a Mutex, metric: &'static str) -> MutexGuard<'a, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(err) => { + warn!(metric, "Recovering poisoned OIDC authn metrics lock"); + err.into_inner() + } + } +} + +fn seconds_since(now: Instant, observed_at: Option) -> u64 { + observed_at + .map(|instant| now.duration_since(instant).as_secs()) + .unwrap_or_default() +} + +impl OidcPluginAuthnMetrics { + fn record(&self, rtt_ms: u64, succeeded: bool) { + let now = Instant::now(); + let mut samples = lock_oidc_plugin_authn_metrics(&self.samples, "samples"); + samples.push_back(OidcPluginAuthnSample { + observed_at: now, + succeeded, + rtt_ms, + }); + while samples + .front() + .is_some_and(|sample| now.duration_since(sample.observed_at) > OIDC_PLUGIN_AUTHN_WINDOW) + { + samples.pop_front(); + } + drop(samples); + + if succeeded { + *lock_oidc_plugin_authn_metrics(&self.last_succ_at, "last_succ_at") = Some(now); + } else { + *lock_oidc_plugin_authn_metrics(&self.last_fail_at, "last_fail_at") = Some(now); + } + } + + fn snapshot(&self) -> OidcPluginAuthnMetricsSnapshot { + let now = Instant::now(); + let (total_requests_minute, failed_requests_minute, succ_avg_rtt_ms_minute, succ_max_rtt_ms_minute) = { + let mut samples = lock_oidc_plugin_authn_metrics(&self.samples, "samples"); + while samples + .front() + .is_some_and(|sample| now.duration_since(sample.observed_at) > OIDC_PLUGIN_AUTHN_WINDOW) + { + samples.pop_front(); + } + + let mut failed_requests_minute = 0u64; + let mut successful_requests = 0u64; + let mut successful_rtt_sum = 0u64; + let mut succ_max_rtt_ms_minute = 0u64; + + for sample in samples.iter() { + if sample.succeeded { + successful_requests += 1; + successful_rtt_sum += sample.rtt_ms; + succ_max_rtt_ms_minute = succ_max_rtt_ms_minute.max(sample.rtt_ms); + } else { + failed_requests_minute += 1; + } + } + + let succ_avg_rtt_ms_minute = successful_rtt_sum.checked_div(successful_requests).unwrap_or_default(); + + ( + samples.len() as u64, + failed_requests_minute, + succ_avg_rtt_ms_minute, + succ_max_rtt_ms_minute, + ) + }; + + let last_fail_seconds = seconds_since(now, *lock_oidc_plugin_authn_metrics(&self.last_fail_at, "last_fail_at")); + let last_succ_seconds = seconds_since(now, *lock_oidc_plugin_authn_metrics(&self.last_succ_at, "last_succ_at")); + + OidcPluginAuthnMetricsSnapshot { + failed_requests_minute, + last_fail_seconds, + last_succ_seconds, + succ_avg_rtt_ms_minute, + succ_max_rtt_ms_minute, + total_requests_minute, + } + } +} + +static OIDC_PLUGIN_AUTHN_METRICS: LazyLock = LazyLock::new(OidcPluginAuthnMetrics::default); + +pub fn oidc_plugin_authn_metrics_snapshot() -> OidcPluginAuthnMetricsSnapshot { + OIDC_PLUGIN_AUTHN_METRICS.snapshot() +} + +/// Header names whose values may carry OIDC secrets (client credentials, cookies, +/// bearer tokens). Their values are never emitted to logs, only their byte length. +const SENSITIVE_HEADER_NAMES: [&str; 4] = ["authorization", "proxy-authorization", "cookie", "set-cookie"]; + +pub(super) fn is_sensitive_header(name: &str) -> bool { + SENSITIVE_HEADER_NAMES + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) +} + +pub(super) fn format_http_headers(headers: &http::HeaderMap) -> String { + headers + .iter() + .map(|(name, value)| { + if is_sensitive_header(name.as_str()) { + format!("{}=", name.as_str(), value.as_bytes().len()) + } else { + let value = value.to_str().unwrap_or(""); + format!("{}={}", name.as_str(), value) + } + }) + .collect::>() + .join("; ") +} + +#[derive(Debug, Default)] +pub(super) struct TokenResponseBodyShape { + pub(super) json_object: bool, + pub(super) json_keys: String, + pub(super) has_access_token: bool, + pub(super) has_id_token: bool, + pub(super) has_token_type: bool, + pub(super) has_expires_in: bool, + pub(super) has_error: bool, + pub(super) has_error_description: bool, + pub(super) looks_like_html: bool, +} + +pub(super) fn inspect_token_response_body(body: &[u8]) -> TokenResponseBodyShape { + let mut shape = TokenResponseBodyShape { + looks_like_html: body + .iter() + .copied() + .find(|byte| !byte.is_ascii_whitespace()) + .is_some_and(|byte| byte == b'<'), + ..Default::default() + }; + + let Ok(value) = serde_json::from_slice::(body) else { + return shape; + }; + let Some(object) = value.as_object() else { + return shape; + }; + + shape.json_object = true; + shape.has_access_token = object.contains_key("access_token"); + shape.has_id_token = object.contains_key("id_token"); + shape.has_token_type = object.contains_key("token_type"); + shape.has_expires_in = object.contains_key("expires_in"); + shape.has_error = object.contains_key("error"); + shape.has_error_description = object.contains_key("error_description"); + + let mut keys: Vec<&str> = object.keys().map(String::as_str).collect(); + keys.sort_unstable(); + keys.truncate(16); + shape.json_keys = keys.join(","); + + shape +} + +pub(super) fn oidc_http_error_diagnostics(error: &OidcHttpError) -> (&'static str, String) { + match error { + OidcHttpError::Reqwest(err) if err.is_timeout() => ("timeout", String::new()), + OidcHttpError::Reqwest(err) if err.is_connect() => ("connect", String::new()), + OidcHttpError::Reqwest(err) if err.status().is_some() => { + ("http_status", err.status().map(|status| status.as_u16().to_string()).unwrap_or_default()) + } + OidcHttpError::Reqwest(_) => ("request", String::new()), + OidcHttpError::Http(_) => ("http_build", String::new()), + OidcHttpError::ExtraRootCa(_) => ("extra_root_ca", String::new()), + OidcHttpError::ForbiddenOutbound(_) => ("forbidden_outbound", String::new()), + OidcHttpError::ResponseTooLarge(limit) => ("response_too_large", limit.to_string()), + } +} + +// ---- HTTP Client Adapter ---- + +/// Error type for the OIDC HTTP client adapter. +#[derive(Debug)] +pub(super) enum OidcHttpError { + Reqwest(reqwest::Error), + Http(http::Error), + ExtraRootCa(String), + /// The outbound destination was rejected by the shared egress policy before any + /// connection was attempted (invalid URL, loopback/link-local/metadata/private IP, + /// or a malformed allow-origins configuration). + ForbiddenOutbound(String), + /// The provider response body exceeded [`MAX_OIDC_RESPONSE_SIZE`] and was abandoned + /// instead of being buffered in full. + ResponseTooLarge(usize), +} + +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}"), + Self::ExtraRootCa(reason) => write!(f, "failed to load OIDC extra root CA bundle: {reason}"), + Self::ForbiddenOutbound(reason) => write!(f, "outbound request rejected: {reason}"), + Self::ResponseTooLarge(limit) => write!(f, "oidc response body exceeds {limit} bytes"), + } + } +} + +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), + Self::ExtraRootCa(_) | Self::ForbiddenOutbound(_) | Self::ResponseTooLarge(_) => None, + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct OidcExtraRootCaMaterial { + pub generation: u64, + pub root_ca_pem: Option>, +} + +type OidcExtraRootCaFuture = Pin> + Send>>; +type OidcExtraRootCaLoader = dyn Fn() -> OidcExtraRootCaFuture + Send + Sync; + +#[derive(Clone)] +pub struct OidcExtraRootCaProvider { + loader: Arc, +} + +impl OidcExtraRootCaProvider { + pub fn new(loader: F) -> Self + where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + Self { + loader: Arc::new(move || Box::pin(loader())), + } + } + + async fn load(&self) -> Result { + (self.loader)().await + } +} + +#[derive(Clone, Default)] +struct CachedOidcExtraRootCerts { + generation: u64, + initialized: bool, + certs: Vec, +} + +/// HTTP client adapter bridging reqwest 0.13 to the `openidconnect` `AsyncHttpClient` trait. +/// +/// A fresh client is built for every request so the destination is re-validated and the +/// resolved IP is re-classified at connection time. This closes the SSRF / DNS-rebinding +/// gap where a one-shot URL string check is bypassed by a hostname that resolves to an +/// internal address only at connection time, and it also covers endpoints discovered from +/// the provider metadata (JWKS, token) rather than only the operator-configured `config_url`. +pub(super) struct ReqwestHttpClient { + /// `None` in production: the process-cached outbound policy from the environment is used. + /// `Some(..)` only in tests, to explicitly allow a loopback mock endpoint. + policy_override: Option, + extra_root_certs: Arc>, + extra_root_ca_provider: Option, + #[cfg(test)] + dns_resolver_override: Option>, +} + +pub(super) fn parse_oidc_extra_root_certs(source: &str, pem: &[u8]) -> Result, String> { + if pem.iter().all(|byte| byte.is_ascii_whitespace()) { + return Ok(Vec::new()); + } + Certificate::from_pem_bundle(pem).map_err(|err| format!("failed to parse OIDC extra root CA bundle from {source}: {err}")) +} + +pub(super) fn oidc_extra_root_certs(root_ca_pem: Option<&[u8]>) -> Result, String> { + match root_ca_pem { + Some(pem) => parse_oidc_extra_root_certs("RustFS outbound TLS material", pem), + None => Ok(Vec::new()), + } +} + +/// Build a reqwest client pinned to the shared outbound egress policy for a single request. +/// +/// [`OutboundPolicy::resolver_for`] validates the URL shape and rejects loopback, +/// link-local, metadata, multicast and unauthorized private addresses up front, and the +/// returned `OutboundDnsResolver` re-resolves and re-classifies the host on every new +/// connection so DNS rebinding fails closed. Redirects are not followed: a redirect target +/// would otherwise skip URL-shape re-validation. The timeouts bound how long a slow or +/// stalled provider can pin the calling task. +pub(super) fn build_oidc_http_client( + uri: &str, + policy_override: Option<&OutboundPolicy>, + extra_root_certs: &[Certificate], + #[cfg(test)] dns_resolver_override: Option>, +) -> Result<(Client, Url), OidcHttpError> { + let url = Url::parse(uri).map_err(|_| OidcHttpError::ForbiddenOutbound("invalid outbound OIDC URL".to_string()))?; + let resolver = match policy_override { + Some(policy) => policy.resolver_for(&url), + None => OutboundPolicy::from_env_cached() + .map_err(|err| OidcHttpError::ForbiddenOutbound(err.to_string()))? + .resolver_for(&url), + } + .map_err(|err| { + let base = err.to_string(); + let origin = url.origin().ascii_serialization(); + let can_allow_origin = + OutboundPolicy::from_allowed_origins(&origin).is_ok_and(|allowlisted| allowlisted.validate_url(&url).is_ok()); + oidc_forbidden_outbound_error(&url, base, can_allow_origin) + })?; + let bypass_proxy = should_bypass_proxy_for_oidc_uri(uri); + #[cfg(test)] + let bypass_proxy = bypass_proxy || dns_resolver_override.is_some(); + #[cfg(test)] + let resolver: Arc = dns_resolver_override.unwrap_or_else(|| Arc::new(resolver)); + + let mut builder = reqwest::Client::builder() + .dns_resolver(resolver) + .redirect(reqwest::redirect::Policy::none()) + .timeout(OIDC_HTTP_REQUEST_TIMEOUT) + .connect_timeout(OIDC_HTTP_CONNECT_TIMEOUT); + if bypass_proxy { + builder = builder.no_proxy(); + } + if !extra_root_certs.is_empty() { + builder = builder.tls_certs_merge(extra_root_certs.iter().cloned()); + } + builder.build().map(|client| (client, url)).map_err(OidcHttpError::Reqwest) +} + +fn oidc_forbidden_outbound_error(url: &Url, base: String, can_allow_origin: bool) -> OidcHttpError { + let reason = if can_allow_origin { + let origin = url.origin().ascii_serialization(); + format!( + "{base}; add {origin} to {ENV_OUTBOUND_ALLOW_ORIGINS} (comma-separated) and restart RustFS to allow this operator-owned OIDC provider (origin only, no path)" + ) + } else { + base + }; + OidcHttpError::ForbiddenOutbound(reason) +} + +fn oidc_http_error_from_reqwest(url: &Url, error: reqwest::Error) -> OidcHttpError { + if let Some(rejection) = find_outbound_dns_policy_rejection(&error) { + let base = rejection.to_string(); + return oidc_forbidden_outbound_error(url, base, rejection.allow_origin_can_recover()); + } + OidcHttpError::Reqwest(error) +} + +/// Buffer a provider response body, failing closed once `limit` bytes have been seen. +/// +/// `Response::bytes` would buffer the whole body unconditionally, so a hostile or compromised +/// provider endpoint could stream an arbitrarily large (or endless) body into memory. +async fn read_bounded_response_body(response: reqwest::Response, limit: usize) -> Result, OidcHttpError> { + if response.content_length().is_some_and(|len| len > limit as u64) { + return Err(OidcHttpError::ResponseTooLarge(limit)); + } + + let mut response = response; + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(OidcHttpError::Reqwest)? { + if body.len() + chunk.len() > limit { + return Err(OidcHttpError::ResponseTooLarge(limit)); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +pub(super) fn should_bypass_proxy_for_oidc_uri(uri: &str) -> bool { + let Some(host) = Url::parse(uri).ok().and_then(|url| url.host_str().map(str::to_owned)) else { + return false; + }; + let host = host.trim_matches(['[', ']']); + + host.eq_ignore_ascii_case("localhost") || host.parse::().is_ok_and(|addr| addr.is_loopback()) +} + +impl ReqwestHttpClient { + pub(super) fn new() -> Result { + Self::new_with_extra_root_certs(Vec::new()) + } + + fn extra_root_cert_cache(certs: Vec) -> Arc> { + Arc::new(RwLock::new(CachedOidcExtraRootCerts { + generation: 0, + initialized: true, + certs, + })) + } + + pub(super) fn new_with_extra_root_certs(extra_root_certs: Vec) -> Result { + Ok(Self { + policy_override: None, + extra_root_certs: Self::extra_root_cert_cache(extra_root_certs), + extra_root_ca_provider: None, + #[cfg(test)] + dns_resolver_override: None, + }) + } + + pub(super) fn new_with_extra_root_ca_provider(extra_root_ca_provider: OidcExtraRootCaProvider) -> Result { + Ok(Self { + policy_override: None, + extra_root_certs: Arc::new(RwLock::new(CachedOidcExtraRootCerts::default())), + extra_root_ca_provider: Some(extra_root_ca_provider), + #[cfg(test)] + dns_resolver_override: None, + }) + } + + pub(super) async fn current_extra_root_certs(&self) -> Result, OidcHttpError> { + let Some(provider) = self.extra_root_ca_provider.as_ref() else { + return self + .extra_root_certs + .read() + .map(|cache| cache.certs.clone()) + .map_err(|e| OidcHttpError::ExtraRootCa(format!("extra root certificate cache lock poisoned: {e}"))); + }; + + let material = provider.load().await.map_err(OidcHttpError::ExtraRootCa)?; + if let Ok(cache) = self.extra_root_certs.read() + && cache.initialized + && cache.generation == material.generation + { + return Ok(cache.certs.clone()); + } + + let certs = oidc_extra_root_certs(material.root_ca_pem.as_deref()).map_err(OidcHttpError::ExtraRootCa)?; + let mut cache = self + .extra_root_certs + .write() + .map_err(|e| OidcHttpError::ExtraRootCa(format!("extra root certificate cache lock poisoned: {e}")))?; + cache.generation = material.generation; + cache.initialized = true; + cache.certs = certs.clone(); + Ok(certs) + } + + /// Test-only constructor that pins outbound requests to an explicit policy, so a + /// loopback mock server can be reached without depending on process-wide environment. + #[cfg(test)] + pub(super) fn with_policy(policy: OutboundPolicy) -> Self { + Self { + policy_override: Some(policy), + extra_root_certs: Self::extra_root_cert_cache(Vec::new()), + extra_root_ca_provider: None, + dns_resolver_override: None, + } + } + + #[cfg(test)] + pub(super) fn with_policy_and_extra_root_certs(policy: OutboundPolicy, extra_root_certs: Vec) -> Self { + Self { + policy_override: Some(policy), + extra_root_certs: Self::extra_root_cert_cache(extra_root_certs), + extra_root_ca_provider: None, + dns_resolver_override: None, + } + } + + #[cfg(test)] + pub(super) fn with_policy_and_extra_root_ca_provider( + policy: OutboundPolicy, + extra_root_ca_provider: OidcExtraRootCaProvider, + ) -> Self { + Self { + policy_override: Some(policy), + extra_root_certs: Arc::new(RwLock::new(CachedOidcExtraRootCerts::default())), + extra_root_ca_provider: Some(extra_root_ca_provider), + dns_resolver_override: None, + } + } + + #[cfg(test)] + pub(super) fn with_policy_and_dns_resolver(policy: OutboundPolicy, resolver: Arc) -> Self { + Self { + policy_override: Some(policy), + extra_root_certs: Self::extra_root_cert_cache(Vec::new()), + extra_root_ca_provider: None, + dns_resolver_override: Some(resolver), + } + } +} + +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 started_at = Instant::now(); + let (parts, body) = request.into_parts(); + let method = parts.method.clone(); + let uri = parts.uri.to_string(); + if tracing::enabled!(tracing::Level::DEBUG) { + let request_headers = format_http_headers(&parts.headers); + debug!( + event = EVENT_OIDC_HTTP, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "request", + method = %method, + uri = %uri, + request_headers = %request_headers, + request_body_len = body.len(), + "oidc outbound http" + ); + } + + let extra_root_certs = self.current_extra_root_certs().await?; + let (client, url) = build_oidc_http_client( + &uri, + self.policy_override.as_ref(), + &extra_root_certs, + #[cfg(test)] + self.dns_resolver_override.clone(), + )?; + let response = client + .request(parts.method, uri.clone()) + .headers(parts.headers) + .body(body) + .send() + .await; + + let elapsed_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; + let succeeded = response.as_ref().is_ok_and(|resp| resp.status().is_success()); + OIDC_PLUGIN_AUTHN_METRICS.record(elapsed_ms, succeeded); + + let response = response.map_err(|err| { + let error = oidc_http_error_from_reqwest(&url, err); + error!( + event = EVENT_OIDC_HTTP, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "request_failed", + method = %method, + uri = %uri, + elapsed_ms, + error = %error, + "oidc outbound http" + ); + error + })?; + + let status = response.status(); + let headers = response.headers().clone(); + let body_bytes = read_bounded_response_body(response, MAX_OIDC_RESPONSE_SIZE) + .await + .map_err(|err| { + error!( + event = EVENT_OIDC_HTTP, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "response_body_failed", + method = %method, + uri = %uri, + status = status.as_u16(), + elapsed_ms, + error = %err, + "oidc outbound http" + ); + err + })?; + if tracing::enabled!(tracing::Level::DEBUG) { + let response_headers = format_http_headers(&headers); + debug!( + event = EVENT_OIDC_HTTP, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "response", + method = %method, + uri = %uri, + status = status.as_u16(), + status_success = status.is_success(), + elapsed_ms, + response_headers = %response_headers, + response_body_len = body_bytes.len(), + "oidc outbound http" + ); + } + + let mut http_response = http::Response::builder() + .status(status) + .body(body_bytes) + .map_err(OidcHttpError::Http)?; + *http_response.headers_mut() = headers; + + Ok(http_response) + }) + } +} + +#[cfg(test)] +#[path = "transport_tests.rs"] +mod tests; diff --git a/crates/iam/src/oidc/transport_tests.rs b/crates/iam/src/oidc/transport_tests.rs new file mode 100644 index 000000000..e77fe561a --- /dev/null +++ b/crates/iam/src/oidc/transport_tests.rs @@ -0,0 +1,167 @@ +// 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::*; +use crate::oidc::test_support::*; +use openidconnect::AsyncHttpClient; +use rustfs_config::MAX_OIDC_RESPONSE_SIZE; +use rustfs_utils::egress::{ENV_OUTBOUND_ALLOW_ORIGINS, OutboundPolicy}; +use std::sync::Arc; + +#[test] +fn format_http_headers_redacts_sensitive_values() { + let mut headers = http::HeaderMap::new(); + headers.insert(http::header::AUTHORIZATION, "Basic Y2xpZW50OnNlY3JldA==".parse().unwrap()); + headers.insert(http::header::CONTENT_TYPE, "application/json".parse().unwrap()); + headers.insert(http::header::COOKIE, "session=super-secret".parse().unwrap()); + + let rendered = format_http_headers(&headers); + + // Sensitive header values never appear; only their length is emitted. + assert!(!rendered.contains("Y2xpZW50OnNlY3JldA=="), "authorization value leaked: {rendered}"); + assert!(!rendered.contains("super-secret"), "cookie value leaked: {rendered}"); + assert!( + rendered.contains("authorization=`, suffix every provider env var with `_` (for example `RUSTFS_IDENTITY_OPENID_CLIENT_ID_keycloak`) and register the callback `/rustfs/admin/v3/oidc/callback/`. Suffix scanning is `parse_single_provider` in `crates/iam/src/oidc.rs`. +Named providers: to use provider id ``, suffix every provider env var with `_` (for example `RUSTFS_IDENTITY_OPENID_CLIENT_ID_keycloak`) and register the callback `/rustfs/admin/v3/oidc/callback/`. Suffix scanning is `parse_single_provider` in `crates/iam/src/oidc/config.rs`. Restart RustFS after changing any of these settings. diff --git a/docs/operations/oidc-provider-requirements.md b/docs/operations/oidc-provider-requirements.md index 4bc4c6c14..d2dc487b3 100644 --- a/docs/operations/oidc-provider-requirements.md +++ b/docs/operations/oidc-provider-requirements.md @@ -1,7 +1,7 @@ # OIDC provider requirements **Use this when:** evaluating whether an identity provider (or a vendor's "OIDC-like" OAuth product) can back RustFS console SSO, or debugging why a provider fails discovery, login, or policy mapping. -**Source of truth:** `crates/iam/src/oidc.rs` (`OidcProviderConfig`, `discover_provider`, `trusted_aud`, `map_claims_to_policies`); `crates/config/src/constants/oidc.rs` (`OIDC_DEFAULT_*`, `ENV_IDENTITY_OPENID_*`); `rustfs/src/admin/handlers/oidc.rs` (`derive_callback_uri_with_provider_config`, `browser_redirect_url`). +**Source of truth:** `crates/iam/src/oidc/config.rs` (`OidcProviderConfig`), `crates/iam/src/oidc/provider.rs` (`discover_provider`), `crates/iam/src/oidc/runtime.rs` (`trusted_aud`, `map_claims_to_policies`); `crates/config/src/constants/oidc.rs` (`OIDC_DEFAULT_*`, `ENV_IDENTITY_OPENID_*`); `rustfs/src/admin/handlers/oidc.rs` (`derive_callback_uri_with_provider_config`, `browser_redirect_url`). RustFS is a standard OpenID Connect relying party using the authorization-code flow with PKCE. It reads every claim it uses from the ID token; it never calls the UserInfo endpoint. Provider-side setup steps for Keycloak and Authing, and the RustFS-side configuration keys, are in [oidc-console-integration.md](oidc-console-integration.md). @@ -11,16 +11,16 @@ The table below describes Console login. For endpoint-free STS issuers, use the | # | Requirement | Details | Code anchor | | --- | --- | --- | --- | -| 1 | Discovery document | `RUSTFS_IDENTITY_OPENID_CONFIG_URL` names the provider (issuer base or full discovery URL); RustFS fetches `{issuer}/.well-known/openid-configuration` and needs `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`, and the standard `*_supported` arrays. When `RUSTFS_IDENTITY_OPENID_ISSUER` is set, the document's `issuer` must equal it exactly; otherwise RustFS tries the issuer candidates derived from the config URL. | `crates/iam/src/oidc.rs` `discover_provider`, `discover_provider_from_config_url` | -| 2 | Outbound reachability | Discovery, JWKS, and token requests go through the shared egress policy. A provider on a private or loopback address needs its exact origin in `RUSTFS_OUTBOUND_ALLOW_ORIGINS`; otherwise startup logs `OIDC provider discovery blocked by outbound policy`. | `crates/iam/src/oidc.rs` `OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY`; [Outbound connection policy](outbound-connection-policy.md) | -| 3 | Signed ID token, verifiable via JWKS | The ID token signature is verified against `jwks_uri`; the key set is refreshed after `OIDC_JWKS_REFRESH_INTERVAL` and once more on a verification failure. A token response without `id_token` fails login — an OAuth-only access token is not sufficient. | `crates/iam/src/oidc.rs` `OIDC_JWKS_REFRESH_INTERVAL`, the `no id_token in token response` error | -| 4 | Authorization-code flow with PKCE (S256) | The authorization request carries `response_type=code`, `scope`, `redirect_uri`, `state`, `nonce`, `code_challenge`, `code_challenge_method=S256`; the token request carries the `code_verifier`. Providers that ignore or reject PKCE, `state`, or `nonce` are not supported. | `crates/iam/src/oidc.rs` `PkceCodeChallenge::new_random_sha256`; `crates/iam/src/oidc_state.rs` `pkce_verifier` | -| 5 | Scopes | Default `openid,profile,email` (`OIDC_DEFAULT_SCOPES`); override with `RUSTFS_IDENTITY_OPENID_SCOPES` (comma-separated). The provider must accept every configured scope. | `crates/config/src/constants/oidc.rs` `OIDC_DEFAULT_SCOPES`; `crates/iam/src/oidc.rs` `OidcProviderConfig::scopes` | -| 6 | Callback returns `state` | The callback must carry both `code` and the original `state`; `state` locates the in-flight session that holds the nonce and PKCE verifier. A callback with only `code` fails. | `rustfs/src/admin/handlers/oidc.rs` `OIDC_CALLBACK_SUFFIX`; `crates/iam/src/oidc_state.rs` | -| 7 | ID token claims | `iss`, `aud`, `exp`, and `nonce` are verified; `sub` identifies the user. `aud` must contain the client id or one of `RUSTFS_IDENTITY_OPENID_OTHER_AUDIENCES`. | `crates/iam/src/oidc.rs` `trusted_aud`, `OidcClaims` | -| 8 | Authorization claims in the ID token | Policy mapping reads the claim named by `RUSTFS_IDENTITY_OPENID_CLAIM_NAME` (default `groups`, `OIDC_DEFAULT_CLAIM_NAME`) together with `RUSTFS_IDENTITY_OPENID_GROUPS_CLAIM` and the optional `RUSTFS_IDENTITY_OPENID_ROLES_CLAIM`, as a string or array of strings, optionally prefixed by `RUSTFS_IDENTITY_OPENID_CLAIM_PREFIX`. Values must equal RustFS policy names (for example `consoleAdmin`, `readwrite`, `readonly`) made only of `[A-Za-z0-9_:.-]`. Groups-claim and roles-claim values with no policy of that name, such as the Active Directory group `DOMAIN\Domain Users` or Keycloak's `offline_access` role, are ignored once storage confirms that no such policy exists. Login is rejected when a policy lookup fails, when no value resolves, when a value names an existing policy whose name is outside that character set (dropping it could remove a Deny), or when a name from `RUSTFS_IDENTITY_OPENID_ROLE_POLICY`, a separate `RUSTFS_IDENTITY_OPENID_CLAIM_NAME` claim, or an explicit IAM policy mapping does not exist. Email and username come from `RUSTFS_IDENTITY_OPENID_EMAIL_CLAIM` (default `email`) and `RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM` (default `preferred_username`). A provider that returns only a user id can authenticate but cannot express RustFS authorization. | `crates/iam/src/oidc.rs` `map_claims_to_policies`, `extract_canonical_group_values`; `OidcSys::group_claim_policy_names`; `rustfs/src/admin/service/federated_identity.rs` `select_oidc_policies` | +| 1 | Discovery document | `RUSTFS_IDENTITY_OPENID_CONFIG_URL` names the provider (issuer base or full discovery URL); RustFS fetches `{issuer}/.well-known/openid-configuration` and needs `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`, and the standard `*_supported` arrays. When `RUSTFS_IDENTITY_OPENID_ISSUER` is set, the document's `issuer` must equal it exactly; otherwise RustFS tries the issuer candidates derived from the config URL. | `crates/iam/src/oidc/provider.rs` `discover_provider`, `discover_provider_from_config_url` | +| 2 | Outbound reachability | Discovery, JWKS, and token requests go through the shared egress policy. A provider on a private or loopback address needs its exact origin in `RUSTFS_OUTBOUND_ALLOW_ORIGINS`; otherwise startup logs `OIDC provider discovery blocked by outbound policy`. | `crates/iam/src/oidc/provider.rs` `OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY`; [Outbound connection policy](outbound-connection-policy.md) | +| 3 | Signed ID token, verifiable via JWKS | The ID token signature is verified against `jwks_uri`; the key set is refreshed after `OIDC_JWKS_REFRESH_INTERVAL` and once more on a verification failure. A token response without `id_token` fails login — an OAuth-only access token is not sufficient. | `crates/iam/src/oidc/provider.rs` `OIDC_JWKS_REFRESH_INTERVAL`; `crates/iam/src/oidc/runtime.rs` `no id_token in token response` | +| 4 | Authorization-code flow with PKCE (S256) | The authorization request carries `response_type=code`, `scope`, `redirect_uri`, `state`, `nonce`, `code_challenge`, `code_challenge_method=S256`; the token request carries the `code_verifier`. Providers that ignore or reject PKCE, `state`, or `nonce` are not supported. | `crates/iam/src/oidc/runtime.rs` `PkceCodeChallenge::new_random_sha256`; `crates/iam/src/oidc/state.rs` `pkce_verifier` | +| 5 | Scopes | Default `openid,profile,email` (`OIDC_DEFAULT_SCOPES`); override with `RUSTFS_IDENTITY_OPENID_SCOPES` (comma-separated). The provider must accept every configured scope. | `crates/config/src/constants/oidc.rs` `OIDC_DEFAULT_SCOPES`; `crates/iam/src/oidc/config.rs` `OidcProviderConfig::scopes` | +| 6 | Callback returns `state` | The callback must carry both `code` and the original `state`; `state` locates the in-flight session that holds the nonce and PKCE verifier. A callback with only `code` fails. | `rustfs/src/admin/handlers/oidc.rs` `OIDC_CALLBACK_SUFFIX`; `crates/iam/src/oidc/state.rs` | +| 7 | ID token claims | `iss`, `aud`, `exp`, and `nonce` are verified; `sub` identifies the user. `aud` must contain the client id or one of `RUSTFS_IDENTITY_OPENID_OTHER_AUDIENCES`. | `crates/iam/src/oidc/runtime.rs` `trusted_aud`, `OidcClaims` | +| 8 | Authorization claims in the ID token | Policy mapping reads the claim named by `RUSTFS_IDENTITY_OPENID_CLAIM_NAME` (default `groups`, `OIDC_DEFAULT_CLAIM_NAME`) together with `RUSTFS_IDENTITY_OPENID_GROUPS_CLAIM` and the optional `RUSTFS_IDENTITY_OPENID_ROLES_CLAIM`, as a string or array of strings, optionally prefixed by `RUSTFS_IDENTITY_OPENID_CLAIM_PREFIX`. Values must equal RustFS policy names (for example `consoleAdmin`, `readwrite`, `readonly`) made only of `[A-Za-z0-9_:.-]`. Groups-claim and roles-claim values with no policy of that name, such as the Active Directory group `DOMAIN\Domain Users` or Keycloak's `offline_access` role, are ignored once storage confirms that no such policy exists. Login is rejected when a policy lookup fails, when no value resolves, when a value names an existing policy whose name is outside that character set (dropping it could remove a Deny), or when a name from `RUSTFS_IDENTITY_OPENID_ROLE_POLICY`, a separate `RUSTFS_IDENTITY_OPENID_CLAIM_NAME` claim, or an explicit IAM policy mapping does not exist. Email and username come from `RUSTFS_IDENTITY_OPENID_EMAIL_CLAIM` (default `email`) and `RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM` (default `preferred_username`). A provider that returns only a user id can authenticate but cannot express RustFS authorization. | `crates/iam/src/oidc/runtime.rs` `map_claims_to_policies`, `extract_canonical_group_values`, `OidcSys::group_claim_policy_names`; `rustfs/src/admin/service/federated_identity.rs` `select_oidc_policies` | | 9 | Registered redirect URI | The provider must accept the callback `{public-origin}/rustfs/admin/v3/oidc/callback/{provider_id}`. RustFS picks the origin in this order: the provider's `redirect_uri` (`RUSTFS_IDENTITY_OPENID_REDIRECT_URI`), then `RUSTFS_BROWSER_REDIRECT_URL`, then the request's own scheme and host — the last only when `RUSTFS_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC` is enabled. | `rustfs/src/admin/handlers/oidc.rs` `derive_callback_uri_with_provider_config`, `browser_redirect_url` | -| 10 | Logout endpoint (optional) | When discovery advertises `end_session_endpoint`, RustFS builds an RP-initiated logout URL with `id_token_hint`, `client_id`, and `post_logout_redirect_uri`. Without it, logout falls back to the console login page. | `crates/iam/src/oidc.rs` `build_logout_url` (reads `end_session_endpoint` from `ProviderMetadataWithLogout`) | +| 10 | Logout endpoint (optional) | When discovery advertises `end_session_endpoint`, RustFS builds an RP-initiated logout URL with `id_token_hint`, `client_id`, and `post_logout_redirect_uri`. Without it, logout falls back to the console login page. | `crates/iam/src/oidc/runtime.rs` `build_logout_url` (reads `end_session_endpoint` from `ProviderMetadataWithLogout`) | ## STS workload providers diff --git a/docs/operations/outbound-connection-policy.md b/docs/operations/outbound-connection-policy.md index f729a2915..b0a094cbe 100644 --- a/docs/operations/outbound-connection-policy.md +++ b/docs/operations/outbound-connection-policy.md @@ -16,7 +16,7 @@ RustFS validates every operator-configured outbound destination to close a serve | --- | --- | --- | | Event-notification webhooks (`RUSTFS_NOTIFY_WEBHOOK_*`) and audit webhooks (`RUSTFS_AUDIT_WEBHOOK_*`) | Full policy | Proxies disabled and redirects not followed, so the endpoint must be reachable directly (`crates/targets/src/target/webhook.rs`) | | Target configuration validation (startup and admin API) | Full policy | `crates/targets/src/config/common.rs` `validate_outbound_http_url`; `rustfs/src/admin/handlers/target_descriptor.rs` | -| OIDC discovery, JWKS, and token requests | Full policy | A blocked provider logs `OIDC provider discovery blocked by outbound policy` naming the origin to allowlist (`crates/iam/src/oidc.rs`) | +| OIDC discovery, JWKS, and token requests | Full policy | A blocked provider logs `OIDC provider discovery blocked by outbound policy` naming the origin to allowlist (`crates/iam/src/oidc/provider.rs`, `crates/iam/src/oidc/transport.rs`) | | Object Lambda targets | Full policy | `rustfs/src/admin/router.rs` `outbound_policy` | | Bucket replication targets | Literal check, relaxed | Private addresses are always allowed; loopback only with `RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET=true` (`crates/ecstore/src/bucket/remote_s3_client.rs` `validate_remote_endpoint`, shared with on-demand migration sources) | | On-demand migration sources | Literal check, relaxed | Same guard and same escape hatch as replication targets (see below) | diff --git a/rustfs/src/app/context.rs b/rustfs/src/app/context.rs index f1373cb46..a9bdcd025 100644 --- a/rustfs/src/app/context.rs +++ b/rustfs/src/app/context.rs @@ -39,9 +39,7 @@ use crate::config::RustFSBufferConfig; use rustfs_config::server_config::Config; use rustfs_credentials::Credentials; use rustfs_iam::{ - error::Error as IamError, - federation::{FederatedIdentityService, oidc::OidcConfigQuery}, - store::object::ObjectStore, + error::Error as IamError, federation::FederatedIdentityService, oidc::OidcConfigQuery, store::object::ObjectStore, sys::IamSys, }; use rustfs_io_metrics::{PerformanceMetrics, internode_metrics::InternodeMetrics}; @@ -544,11 +542,8 @@ mod tests { use crate::config::{RustFSBufferConfig, WorkloadProfile}; use async_trait::async_trait; use rustfs_iam::{ - federation::{ - CoreFederatedAuthorizationMapper, FederatedIdentityService, - oidc::{OidcConfigQuery, StandardOidcAdapter}, - }, - oidc::OidcSys, + federation::{CoreFederatedAuthorizationMapper, FederatedIdentityService}, + oidc::{OidcConfigQuery, OidcSys, StandardOidcAdapter}, store::object::ObjectStore, sys::IamSys, }; diff --git a/rustfs/src/app/context/global.rs b/rustfs/src/app/context/global.rs index fe52e40db..c4ea0edc6 100644 --- a/rustfs/src/app/context/global.rs +++ b/rustfs/src/app/context/global.rs @@ -35,11 +35,7 @@ use super::interfaces::{ }; use crate::app::object_data_cache::ObjectDataCacheAdapter; use crate::app::object_traffic_health::ObjectTrafficHealth; -use rustfs_iam::{ - federation::{FederatedIdentityService, oidc::OidcConfigQuery}, - store::object::ObjectStore, - sys::IamSys, -}; +use rustfs_iam::{federation::FederatedIdentityService, oidc::OidcConfigQuery, store::object::ObjectStore, sys::IamSys}; use rustfs_kms::KmsServiceManager; use std::sync::{Arc, OnceLock}; diff --git a/rustfs/src/app/context/handles.rs b/rustfs/src/app/context/handles.rs index 6fbbddc50..2968af533 100644 --- a/rustfs/src/app/context/handles.rs +++ b/rustfs/src/app/context/handles.rs @@ -32,11 +32,7 @@ use crate::config::RustFSBufferConfig; use async_trait::async_trait; use rustfs_config::server_config::Config; use rustfs_credentials::Credentials; -use rustfs_iam::{ - federation::{FederatedIdentityService, oidc::OidcConfigQuery}, - store::object::ObjectStore, - sys::IamSys, -}; +use rustfs_iam::{federation::FederatedIdentityService, oidc::OidcConfigQuery, store::object::ObjectStore, sys::IamSys}; use rustfs_io_metrics::{PerformanceMetrics, internode_metrics::InternodeMetrics}; use rustfs_kms::KmsServiceManager; use rustfs_lock::LockClient; @@ -654,11 +650,8 @@ mod tests { use crate::app::context::interfaces::{KmsRuntimeInterface, ServerConfigInterface}; use rustfs_config::server_config::Config; use rustfs_iam::{ - federation::{ - CoreFederatedAuthorizationMapper, FederatedIdentityService, - oidc::{OidcConfigQuery, StandardOidcAdapter}, - }, - oidc::OidcSys, + federation::{CoreFederatedAuthorizationMapper, FederatedIdentityService}, + oidc::{OidcConfigQuery, OidcSys, StandardOidcAdapter}, }; use std::collections::HashMap; use std::sync::Arc; diff --git a/rustfs/src/app/context/interfaces.rs b/rustfs/src/app/context/interfaces.rs index 8fd9eb7bf..43052f5ad 100644 --- a/rustfs/src/app/context/interfaces.rs +++ b/rustfs/src/app/context/interfaces.rs @@ -22,11 +22,7 @@ use crate::config::RustFSBufferConfig; use async_trait::async_trait; use rustfs_config::server_config::Config; use rustfs_credentials::Credentials; -use rustfs_iam::{ - federation::{FederatedIdentityService, oidc::OidcConfigQuery}, - store::object::ObjectStore, - sys::IamSys, -}; +use rustfs_iam::{federation::FederatedIdentityService, oidc::OidcConfigQuery, store::object::ObjectStore, sys::IamSys}; use rustfs_io_metrics::{PerformanceMetrics, internode_metrics::InternodeMetrics}; use rustfs_kms::KmsServiceManager; use rustfs_lock::LockClient; diff --git a/rustfs/src/runtime_sources.rs b/rustfs/src/runtime_sources.rs index fc211890b..5fdf451ab 100644 --- a/rustfs/src/runtime_sources.rs +++ b/rustfs/src/runtime_sources.rs @@ -14,7 +14,7 @@ use crate::app::context; use rustfs_config::ENV_RUSTFS_BROWSER_REDIRECT_URL; -use rustfs_iam::federation::{FederatedIdentityService, oidc::OidcConfigQuery}; +use rustfs_iam::{federation::FederatedIdentityService, oidc::OidcConfigQuery}; use std::sync::Arc; use tracing::warn; @@ -104,11 +104,8 @@ pub(crate) fn current_app_context() -> Option> { mod tests { use super::*; use rustfs_iam::{ - federation::{ - CoreFederatedAuthorizationMapper, - oidc::{OidcConfigQuery, StandardOidcAdapter}, - }, - oidc::{OidcSys, make_test_sys, test_config}, + federation::CoreFederatedAuthorizationMapper, + oidc::{OidcConfigQuery, OidcSys, StandardOidcAdapter, make_test_sys, test_config}, }; use std::{ io::{self, Write}, diff --git a/rustfs/src/startup_auth.rs b/rustfs/src/startup_auth.rs index c47afb3b1..ec2cbe6f4 100644 --- a/rustfs/src/startup_auth.rs +++ b/rustfs/src/startup_auth.rs @@ -14,11 +14,8 @@ use rustfs_iam::{ build_oidc_sys_with_extra_root_ca_provider, - federation::{ - CoreFederatedAuthorizationMapper, FederatedIdentityService, - oidc::{OidcConfigQuery, StandardOidcAdapter}, - }, - oidc::{OidcExtraRootCaMaterial, OidcExtraRootCaProvider, OidcSys}, + federation::{CoreFederatedAuthorizationMapper, FederatedIdentityService}, + oidc::{OidcConfigQuery, OidcExtraRootCaMaterial, OidcExtraRootCaProvider, OidcSys, StandardOidcAdapter}, }; use std::{ collections::hash_map::DefaultHasher,