From 6028dad2f439c54a6cab8a3af8c8c21ddd97dcb6 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Sun, 2 Aug 2026 22:33:20 +0800 Subject: [PATCH] test(iam): freeze OIDC federation behavior (#5627) --- crates/iam/src/federation/oidc/claims.rs | 40 +++ crates/iam/src/federation/transaction.rs | 226 +++++++++++--- crates/iam/src/oidc.rs | 74 +++-- crates/iam/src/oidc_state.rs | 37 +++ .../src/admin/service/federated_identity.rs | 291 ++++++++++++++---- 5 files changed, 531 insertions(+), 137 deletions(-) diff --git a/crates/iam/src/federation/oidc/claims.rs b/crates/iam/src/federation/oidc/claims.rs index e54afd62b..e256052d8 100644 --- a/crates/iam/src/federation/oidc/claims.rs +++ b/crates/iam/src/federation/oidc/claims.rs @@ -62,6 +62,7 @@ pub(super) fn authorization(oidc: &OidcSys, provider_id: String, claims: OidcCla #[cfg(test)] mod tests { use super::*; + use crate::oidc::{make_test_sys, test_config}; use serde_json::json; use std::collections::HashMap; @@ -99,4 +100,43 @@ mod tests { }; assert!(string_list_claim(&ambiguous, "roles").is_empty()); } + + #[test] + fn authorization_preserves_verified_claims_and_keeps_source_groups_distinct() { + let mut config = test_config("corp"); + config.claim_prefix = "mapped-".to_string(); + config.roles_claim = "roles".to_string(); + let oidc = make_test_sys(vec![config]); + let raw = HashMap::from([ + ("iss".to_string(), json!("https://corp.example.test")), + ("department".to_string(), json!("engineering")), + ("roles".to_string(), json!(["reader", "admin"])), + ]); + let authorization = authorization( + &oidc, + "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!(authorization.provider_id, "corp"); + assert_eq!(authorization.claims.sub, " subject-123 "); + assert_eq!(authorization.claims.email, " user@example.test "); + assert_eq!(authorization.claims.username, " user "); + assert_eq!(authorization.claims.groups, ["source-ops", "source-developers", "source-ops"]); + assert_eq!(authorization.claims.raw, raw); + assert_eq!(authorization.policies, ["mapped-source-developers", "mapped-source-ops"]); + assert_eq!(authorization.groups, ["source-developers", "source-ops"]); + assert_eq!(authorization.roles_claim_key.as_deref(), Some("roles")); + assert_eq!(authorization.roles, ["reader", "admin"]); + } } diff --git a/crates/iam/src/federation/transaction.rs b/crates/iam/src/federation/transaction.rs index a0f168df5..389e233df 100644 --- a/crates/iam/src/federation/transaction.rs +++ b/crates/iam/src/federation/transaction.rs @@ -140,29 +140,51 @@ mod tests { }; use crate::oidc::{OidcProviderConfig, OidcProviderSummary}; use rustfs_credentials::Credentials; - use std::sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, - }; + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Copy, PartialEq, Eq)] + enum ProviderFailure { + None, + Exchange, + Verification, + Logout, + } struct TestProvider { with_policy: bool, + with_group: bool, + browser_provider_id: &'static str, + web_provider_id: &'static str, + failure: ProviderFailure, events: Arc>>, + expected_logout: (&'static str, &'static str), } impl TestProvider { + fn new(events: Arc>>) -> Self { + Self { + with_policy: true, + with_group: false, + browser_provider_id: "default", + web_provider_id: "default", + failure: ProviderFailure::None, + events, + expected_logout: ("default", "id-token"), + } + } + fn record(&self, event: &'static str) { self.events.lock().expect("event log should not be poisoned").push(event); } - fn authorization(&self) -> FederatedAuthorization { + fn authorization(&self, provider_id: &str) -> FederatedAuthorization { FederatedAuthorization { - provider_id: "default".to_string(), + provider_id: provider_id.to_string(), claims: FederatedClaims { sub: "subject".to_string(), email: String::new(), username: "user".to_string(), - groups: Vec::new(), + groups: vec!["source-group".to_string()], raw: Default::default(), }, policies: if self.with_policy { @@ -170,7 +192,11 @@ mod tests { } else { Vec::new() }, - groups: Vec::new(), + groups: if self.with_group { + vec!["developers".to_string()] + } else { + Vec::new() + }, roles_claim_key: None, roles: Vec::new(), } @@ -206,8 +232,11 @@ mod tests { async fn exchange_code(&self, _state: &str, _code: &str, _redirect_uri: &str) -> Result { self.record("exchange"); + if self.failure == ProviderFailure::Exchange { + return Err(FederationError::CodeExchange("exchange failed".to_string())); + } Ok(FederatedCodeExchange { - authorization: self.authorization(), + authorization: self.authorization(self.browser_provider_id), redirect_after: Some("/browser".to_string()), id_token: "id-token".to_string(), }) @@ -215,11 +244,18 @@ mod tests { async fn verify_web_identity_token(&self, _jwt: &str) -> Result { self.record("verify"); - Ok(self.authorization()) + if self.failure == ProviderFailure::Verification { + return Err(FederationError::TokenVerification("verification failed".to_string())); + } + Ok(self.authorization(self.web_provider_id)) } - async fn create_logout_token(&self, _provider_id: &str, _id_token: &str) -> Result { + async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result { self.record("logout"); + assert_eq!((provider_id, id_token), self.expected_logout); + if self.failure == ProviderFailure::Logout { + return Err(FederationError::Logout("logout failed".to_string())); + } Ok("logout-token".to_string()) } @@ -228,19 +264,37 @@ mod tests { } } - struct CountingBinding { - calls: AtomicUsize, + struct RecordingBinding { + fail: bool, events: Arc>>, + transactions: Mutex)>>, + } + + impl RecordingBinding { + fn new(events: Arc>>) -> Self { + Self { + fail: false, + events, + transactions: Mutex::new(Vec::new()), + } + } } #[async_trait::async_trait] - impl FederatedSessionBinding for CountingBinding { + impl FederatedSessionBinding for RecordingBinding { async fn bind( &self, transaction: &FederatedSessionTransaction, ) -> core::result::Result { - self.calls.fetch_add(1, Ordering::Relaxed); self.events.lock().expect("event log should not be poisoned").push("bind"); + self.transactions.lock().expect("transactions should not be poisoned").push(( + transaction.authorization.provider_id.clone(), + transaction.duration_seconds, + transaction.session_policy.clone(), + )); + if self.fail { + return Err(FederatedSessionBindingError::Internal("binding failed".to_string())); + } Ok(Credentials { access_key: transaction.authorization.claims.session_identity(), ..Default::default() @@ -249,16 +303,14 @@ mod tests { } #[tokio::test] - async fn callback_and_web_identity_share_session_binding() { + async fn callback_and_web_identity_preserve_provider_and_transaction_boundaries() { let events = Arc::new(Mutex::new(Vec::new())); - let provider = Arc::new(TestProvider { - with_policy: true, - events: events.clone(), - }); - let binding = Arc::new(CountingBinding { - calls: AtomicUsize::new(0), - events: events.clone(), - }); + let mut provider = TestProvider::new(events.clone()); + provider.browser_provider_id = "corp"; + provider.web_provider_id = "partner"; + provider.expected_logout = ("corp", "id-token"); + let provider = Arc::new(provider); + let binding = Arc::new(RecordingBinding::new(events.clone())); let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(provider)); let login = service @@ -266,6 +318,7 @@ mod tests { .await .expect("callback flow should complete"); assert_eq!(login.session.credentials.access_key, "user"); + assert_eq!(login.session.authorization.provider_id, "corp"); assert_eq!(login.redirect_after.as_deref(), Some("/browser")); assert_eq!(login.logout_token, "logout-token"); assert_eq!( @@ -275,25 +328,32 @@ mod tests { events.lock().expect("event log should not be poisoned").clear(); let web_identity = service - .assume_role_with_web_identity("jwt", 3600, None, binding.as_ref()) + .assume_role_with_web_identity("jwt", 7200, Some("session-policy".to_string()), binding.as_ref()) .await .expect("web identity flow should complete"); assert_eq!(web_identity.credentials.access_key, "user"); - assert_eq!(binding.calls.load(Ordering::Relaxed), 2); + assert_eq!(web_identity.authorization.provider_id, "partner"); assert_eq!(events.lock().expect("event log should not be poisoned").as_slice(), ["verify", "bind"]); + assert_eq!( + binding + .transactions + .lock() + .expect("transactions should not be poisoned") + .as_slice(), + [ + ("corp".to_string(), 3600, None), + ("partner".to_string(), 7200, Some("session-policy".to_string())), + ] + ); } #[tokio::test] async fn web_identity_without_policy_or_group_is_not_bound() { let events = Arc::new(Mutex::new(Vec::new())); - let provider = Arc::new(TestProvider { - with_policy: false, - events: events.clone(), - }); - let binding = Arc::new(CountingBinding { - calls: AtomicUsize::new(0), - events, - }); + let mut provider = TestProvider::new(events.clone()); + provider.with_policy = false; + let provider = Arc::new(provider); + let binding = Arc::new(RecordingBinding::new(events.clone())); let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(provider)); let error = service @@ -302,6 +362,102 @@ mod tests { .expect_err("authorization context is required"); assert!(matches!(error, FederationError::NoAuthorizationContext)); - assert_eq!(binding.calls.load(Ordering::Relaxed), 0); + assert_eq!(events.lock().expect("event log should not be poisoned").as_slice(), ["verify"]); + } + + #[tokio::test] + async fn web_identity_group_only_authorization_is_bound_once() { + let events = Arc::new(Mutex::new(Vec::new())); + let mut provider = TestProvider::new(events.clone()); + provider.with_policy = false; + provider.with_group = true; + let provider = Arc::new(provider); + let binding = Arc::new(RecordingBinding::new(events.clone())); + let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(provider)); + + let session = service + .assume_role_with_web_identity("jwt", 3600, None, binding.as_ref()) + .await + .expect("a mapped group is an authorization context"); + + assert!(session.authorization.policies.is_empty()); + assert_eq!(session.authorization.groups, ["developers"]); + assert_eq!(events.lock().expect("event log should not be poisoned").as_slice(), ["verify", "bind"]); + } + + #[tokio::test] + async fn callback_failures_preserve_existing_side_effect_order() { + for (provider_failure, binding_failure, expected_events) in [ + (ProviderFailure::Exchange, false, vec!["exchange"]), + (ProviderFailure::None, true, vec!["exchange", "bind"]), + (ProviderFailure::Logout, false, vec!["exchange", "bind", "logout"]), + ] { + let events = Arc::new(Mutex::new(Vec::new())); + let mut provider = TestProvider::new(events.clone()); + provider.failure = provider_failure; + let mut binding = RecordingBinding::new(events.clone()); + binding.fail = binding_failure; + let binding = Arc::new(binding); + let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(Arc::new(provider))); + + let error = service + .complete_authorization_code("state", "code", "https://console.example/callback", 3600, binding.as_ref()) + .await + .expect_err("the configured failure should be returned"); + + if provider_failure == ProviderFailure::Exchange { + assert!(matches!(error, FederationError::CodeExchange(ref message) if message == "exchange failed")); + } else if binding_failure { + assert!(matches!( + error, + FederationError::Binding(FederatedSessionBindingError::Internal(ref message)) + if message == "binding failed" + )); + } else { + assert!(matches!(error, FederationError::Logout(ref message) if message == "logout failed")); + } + + assert_eq!( + events.lock().expect("event log should not be poisoned").as_slice(), + expected_events, + "later callback steps must not run after a failure" + ); + } + } + + #[tokio::test] + async fn web_identity_failures_preserve_existing_side_effect_order() { + for (provider_failure, binding_failure, expected_events) in [ + (ProviderFailure::Verification, false, vec!["verify"]), + (ProviderFailure::None, true, vec!["verify", "bind"]), + ] { + let events = Arc::new(Mutex::new(Vec::new())); + let mut provider = TestProvider::new(events.clone()); + provider.failure = provider_failure; + let mut binding = RecordingBinding::new(events.clone()); + binding.fail = binding_failure; + let binding = Arc::new(binding); + let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(Arc::new(provider))); + + let error = service + .assume_role_with_web_identity("jwt", 3600, None, binding.as_ref()) + .await + .expect_err("the configured failure should be returned"); + + if provider_failure == ProviderFailure::Verification { + assert!(matches!(error, FederationError::TokenVerification(_))); + } else { + assert!(matches!( + error, + FederationError::Binding(FederatedSessionBindingError::Internal(ref message)) + if message == "binding failed" + )); + } + assert_eq!( + events.lock().expect("event log should not be poisoned").as_slice(), + expected_events, + "later web identity steps must not run after a failure" + ); + } } } diff --git a/crates/iam/src/oidc.rs b/crates/iam/src/oidc.rs index 838e3bb98..5a1f2e900 100644 --- a/crates/iam/src/oidc.rs +++ b/crates/iam/src/oidc.rs @@ -2007,6 +2007,42 @@ fn claim_value_type_for_log(value: Option<&serde_json::Value>) -> &'static str { } } +#[cfg(test)] +pub(crate) fn make_test_sys(configs: Vec) -> OidcSys { + let configs = configs.into_iter().map(|config| (config.id.clone(), config)).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(test)] +pub(crate) 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::*; @@ -2958,44 +2994,6 @@ mod tests { handle.join().expect("mock body server thread should exit"); } - /// Helper to create an OidcSys with configs only (no provider states needed). - fn make_test_sys(configs: Vec) -> OidcSys { - let mut config_map = HashMap::new(); - for c in configs { - config_map.insert(c.id.clone(), c); - } - OidcSys { - configs: config_map, - provider_states: RwLock::new(HashMap::new()), - state_store: OidcStateStore::new(), - http_client: ReqwestHttpClient::new().expect("failed to initialize OIDC HTTP clients"), - } - } - - 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: "".to_string(), - role_policy: "".to_string(), - 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, - } - } - #[test] fn test_oidc_provider_config_debug_redacts_client_secret() { let config = OidcProviderConfig { diff --git a/crates/iam/src/oidc_state.rs b/crates/iam/src/oidc_state.rs index 90b0eea47..880ee679a 100644 --- a/crates/iam/src/oidc_state.rs +++ b/crates/iam/src/oidc_state.rs @@ -138,6 +138,8 @@ impl Default for OidcStateStore { #[cfg(test)] mod tests { use super::*; + use std::sync::Arc; + use tokio::sync::Barrier; #[tokio::test] async fn test_state_store_insert_and_take() { @@ -197,6 +199,41 @@ mod tests { } } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn auth_state_is_consumed_once_under_concurrent_take() { + let store = OidcStateStore::new(); + store + .insert( + "state_once".to_string(), + OidcAuthSession { + provider_id: "corp".to_string(), + pkce_verifier: "verifier".to_string(), + nonce: "nonce".to_string(), + redirect_after: None, + }, + ) + .await; + + let first_store = store.clone(); + let second_store = store.clone(); + let barrier = Arc::new(Barrier::new(3)); + let first_barrier = Arc::clone(&barrier); + let first = tokio::spawn(async move { + first_barrier.wait().await; + first_store.take("state_once").await + }); + let second_barrier = Arc::clone(&barrier); + let second = tokio::spawn(async move { + second_barrier.wait().await; + second_store.take("state_once").await + }); + barrier.wait().await; + let first = first.await.expect("first state consumer should finish"); + let second = second.await.expect("second state consumer should finish"); + + assert_eq!(first.is_some() as usize + second.is_some() as usize, 1); + } + #[tokio::test] async fn test_logout_state_store_insert_and_take() { let store = OidcStateStore::new(); diff --git a/rustfs/src/admin/service/federated_identity.rs b/rustfs/src/admin/service/federated_identity.rs index 69914c3cd..e0f52f2f9 100644 --- a/rustfs/src/admin/service/federated_identity.rs +++ b/rustfs/src/admin/service/federated_identity.rs @@ -311,11 +311,17 @@ impl FederatedSessionBinding for DefaultFederatedSessionBinding { mod tests { use super::*; use crate::admin::runtime_sources::{AppContext, publish_test_app_context}; + use hmac::{Hmac, KeyInit, Mac}; use rustfs_iam::federation::{FederatedAuthorization, FederatedClaims}; use rustfs_iam::store::{Store, UserType, object::IAM_CONFIG_PREFIX}; use rustfs_kms::KmsServiceManager; use rustfs_madmin::{AccountStatus, AddOrUpdateUserReq}; + use rustfs_policy::policy::{ + Args, + action::{Action, S3Action}, + }; use serial_test::serial; + use sha2::Sha512; use std::sync::Arc; fn transaction() -> FederatedSessionTransaction { @@ -327,7 +333,10 @@ mod tests { email: "user@example.com".to_string(), username: "user".to_string(), groups: vec!["source-group".to_string()], - raw: HashMap::from([("iss".to_string(), serde_json::json!("https://idp.example.test"))]), + raw: HashMap::from([ + ("iss".to_string(), serde_json::json!("https://idp.example.test")), + ("raw_poison".to_string(), serde_json::json!("must-not-leak")), + ]), }, policies: vec!["readwrite".to_string()], groups: vec!["devs".to_string()], @@ -339,74 +348,14 @@ mod tests { } } - #[test] - fn token_claims_preserve_existing_oidc_shape() { - let transaction = transaction(); - - let claims = build_oidc_token_claims(&transaction); - assert_eq!(claims.get("sub"), Some(&serde_json::json!("subject"))); - assert_eq!(claims.get("iss"), Some(&serde_json::json!("rustfs-oidc"))); - assert_eq!(claims.get("oidc_provider"), Some(&serde_json::json!("default"))); - assert_eq!(claims.get("email"), Some(&serde_json::json!("user@example.com"))); - assert_eq!(claims.get("preferred_username"), Some(&serde_json::json!("user"))); - assert_eq!(claims.get("groups"), Some(&serde_json::json!(["devs"]))); - assert_eq!(claims.get("roles"), Some(&serde_json::json!(["admin", "reader"]))); - } - - #[test] - fn issued_credentials_and_replication_item_use_minio_parent_shape() { - let transaction = transaction(); - let secret = "federated-session-test-signing-secret"; - let selected_policy_names = vec!["readonly".to_string()]; - - let credentials = - issue_credentials(&transaction, &selected_policy_names, Some(secret)).expect("credential issuance should succeed"); - assert_eq!(credentials.parent_user, "TwyekekG2eMes0qk9Tgh7KXEitwGi1z2W1f2KccrXGA"); - assert_eq!(credentials.groups, Some(vec!["devs".to_string()])); - - let claims = rustfs_iam::sys::get_claims_from_token_with_secret(&credentials.session_token, secret) - .expect("issued session token should verify"); - assert_eq!(claims.get("iss"), Some(&serde_json::json!("rustfs-oidc"))); - assert_eq!(claims.get("oidc_provider"), Some(&serde_json::json!("default"))); - assert_eq!( - claims.get("parent"), - Some(&serde_json::json!("TwyekekG2eMes0qk9Tgh7KXEitwGi1z2W1f2KccrXGA")) - ); - assert_eq!( - claims.get(OIDC_VIRTUAL_PARENT_CLAIM), - Some(&serde_json::json!("TwyekekG2eMes0qk9Tgh7KXEitwGi1z2W1f2KccrXGA")) - ); - assert_eq!(claims.get("policy"), Some(&serde_json::json!("readonly"))); - assert_eq!(claims.get("groups"), Some(&serde_json::json!(["devs"]))); - assert_eq!(claims.get("roles"), Some(&serde_json::json!(["admin", "reader"]))); - assert!(!claims.contains_key("oidc_issuer")); - - let updated_at = OffsetDateTime::UNIX_EPOCH; - let item = site_replication_item(&credentials, updated_at); - assert_eq!(item.r#type, "sts-credential"); - assert_eq!(item.updated_at, Some(updated_at)); - assert_eq!(item.api_version.as_deref(), Some(SITE_REPL_API_VERSION)); - let replicated = item.sts_credential.expect("replication item should contain STS credentials"); - assert_eq!(replicated.access_key, credentials.access_key); - assert_eq!(replicated.secret_key, credentials.secret_key); - assert_eq!(replicated.session_token, credentials.session_token); - assert_eq!(replicated.parent_user, "TwyekekG2eMes0qk9Tgh7KXEitwGi1z2W1f2KccrXGA"); - assert_eq!(replicated.parent_policy_mapping, OIDC_STS_REQUIRES_VIRTUAL_PARENT_RECEIVER_POLICY); - assert!(replicated.parent_policy_mapping.trim().is_empty()); - assert!(MappedPolicy::new(&replicated.parent_policy_mapping).to_slice().is_empty()); - assert_eq!(replicated.api_version.as_deref(), Some(SITE_REPL_API_VERSION)); - } - - #[tokio::test] - #[serial] - async fn binding_uses_sts_policy_when_regular_mapping_collides() { + async fn ready_test_iam() -> Arc> { let _ = rustfs_credentials::init_global_action_credentials( Some("TESTROOTACCESSKEY".to_string()), Some("TESTROOTSECRET123".to_string()), ); if current_ready_iam_handle().is_err() { let env = rustfs_test_utils::TestECStoreEnv::builder() - .prefix("federated_binding_sts_policy") + .prefix("federated_identity") .disk_count(1) .init_bucket_metadata(false) .build() @@ -424,8 +373,146 @@ mod tests { Arc::new(KmsServiceManager::new()), ))); } + current_ready_iam_handle().expect("test IAM should be ready") + } - let iam = current_ready_iam_handle().expect("test IAM should be ready"); + // Keep this fixture independent of the production JWT encoder so writer and reader changes cannot drift together. + fn markerless_legacy_session_token(signing_key: &str) -> String { + const HEADER: &str = r#"{"alg":"HS512","typ":"JWT"}"#; + const PAYLOAD: &str = r#"{"iss":"rustfs-oidc","oidc_provider":"default","sub":"legacy-subject","parent":"legacy-markerless-oidc-parent","policy":"readonly","exp":4102444800}"#; + let header = base64_simd::URL_SAFE_NO_PAD.encode_to_string(HEADER.as_bytes()); + let payload = base64_simd::URL_SAFE_NO_PAD.encode_to_string(PAYLOAD.as_bytes()); + let signing_input = format!("{header}.{payload}"); + let mut mac = + as KeyInit>::new_from_slice(signing_key.as_bytes()).expect("HMAC-SHA512 accepts the test signing key"); + mac.update(signing_input.as_bytes()); + let signature = base64_simd::URL_SAFE_NO_PAD.encode_to_string(mac.finalize().into_bytes().as_slice()); + format!("{signing_input}.{signature}") + } + + #[test] + fn token_claims_preserve_existing_oidc_shape() { + let transaction = transaction(); + + let claims = build_oidc_token_claims(&transaction); + assert_eq!( + claims, + HashMap::from([ + ("sub".to_string(), serde_json::json!("subject")), + ("iss".to_string(), serde_json::json!("rustfs-oidc")), + ("oidc_provider".to_string(), serde_json::json!("default")), + ("email".to_string(), serde_json::json!("user@example.com")), + ("preferred_username".to_string(), serde_json::json!("user")), + ("groups".to_string(), serde_json::json!(["devs"])), + ("roles".to_string(), serde_json::json!(["admin", "reader"])), + ]) + ); + } + + #[test] + fn token_claims_omit_empty_optional_oidc_fields() { + let mut transaction = transaction(); + transaction.authorization.claims.email.clear(); + transaction.authorization.claims.username.clear(); + transaction.authorization.groups.clear(); + transaction.authorization.roles.clear(); + + assert_eq!( + build_oidc_token_claims(&transaction), + HashMap::from([ + ("sub".to_string(), serde_json::json!("subject")), + ("iss".to_string(), serde_json::json!("rustfs-oidc")), + ("oidc_provider".to_string(), serde_json::json!("default")), + ]) + ); + } + + #[test] + fn issued_credentials_and_replication_item_use_minio_parent_shape() { + let transaction = transaction(); + let secret = "federated-session-test-signing-secret"; + let selected_policy_names = vec!["readonly".to_string()]; + + let credentials = + issue_credentials(&transaction, &selected_policy_names, Some(secret)).expect("credential issuance should succeed"); + assert_eq!(credentials.parent_user, "TwyekekG2eMes0qk9Tgh7KXEitwGi1z2W1f2KccrXGA"); + assert_eq!(credentials.groups, Some(vec!["devs".to_string()])); + assert_eq!(credentials.status, "on"); + assert!(!credentials.access_key.is_empty()); + assert!(!credentials.secret_key.is_empty()); + assert!(!credentials.session_token.is_empty()); + assert!(credentials.claims.is_none()); + assert!(credentials.name.is_none()); + assert!(credentials.description.is_none()); + + let mut claims = rustfs_iam::sys::get_claims_from_token_with_secret(&credentials.session_token, secret) + .expect("issued session token should verify"); + let expires_at = claims + .remove("exp") + .and_then(|value| value.as_i64()) + .expect("issued token should contain an integer expiration"); + assert_eq!( + credentials + .expiration + .expect("issued credentials should contain an expiration") + .unix_timestamp(), + expires_at + ); + assert_eq!( + claims, + HashMap::from([ + ("sub".to_string(), serde_json::json!("subject")), + ("iss".to_string(), serde_json::json!("rustfs-oidc")), + ("oidc_provider".to_string(), serde_json::json!("default")), + ("email".to_string(), serde_json::json!("user@example.com")), + ("preferred_username".to_string(), serde_json::json!("user")), + ("groups".to_string(), serde_json::json!(["devs"])), + ("roles".to_string(), serde_json::json!(["admin", "reader"])), + ("parent".to_string(), serde_json::json!("TwyekekG2eMes0qk9Tgh7KXEitwGi1z2W1f2KccrXGA")), + ( + OIDC_VIRTUAL_PARENT_CLAIM.to_string(), + serde_json::json!("TwyekekG2eMes0qk9Tgh7KXEitwGi1z2W1f2KccrXGA"), + ), + ("policy".to_string(), serde_json::json!("readonly")), + ]) + ); + + let updated_at = OffsetDateTime::UNIX_EPOCH; + let mut item = site_replication_item(&credentials, updated_at); + let replicated = item + .sts_credential + .as_mut() + .expect("replication item should contain STS credentials"); + assert_eq!(replicated.access_key, credentials.access_key); + assert!(crate::auth::constant_time_eq(&replicated.secret_key, &credentials.secret_key)); + assert!(crate::auth::constant_time_eq(&replicated.session_token, &credentials.session_token)); + assert!(MappedPolicy::new(&replicated.parent_policy_mapping).to_slice().is_empty()); + replicated.access_key = "".to_string(); + replicated.secret_key = "".to_string(); + replicated.session_token = "".to_string(); + assert_eq!( + serde_json::to_value(item).expect("replication item should serialize"), + serde_json::json!({ + "type": "sts-credential", + "name": "", + "stsCredential": { + "accessKey": "", + "secretKey": "", + "sessionToken": "", + "parentUser": "TwyekekG2eMes0qk9Tgh7KXEitwGi1z2W1f2KccrXGA", + "parentPolicyMapping": OIDC_STS_REQUIRES_VIRTUAL_PARENT_RECEIVER_POLICY, + "apiVersion": SITE_REPL_API_VERSION, + }, + "updatedAt": "1970-01-01T00:00:00Z", + "apiVersion": SITE_REPL_API_VERSION, + }) + ); + } + + #[tokio::test] + #[serial] + async fn binding_uses_sts_policy_when_regular_mapping_collides() { + let iam = ready_test_iam().await; let mut transaction = transaction(); transaction.authorization.claims.sub = "binding-sts-policy-subject".to_string(); transaction.authorization.policies = vec!["readwrite".to_string()]; @@ -459,6 +546,82 @@ mod tests { .expect("issued session token should verify"); assert_eq!(claims.get("policy"), Some(&serde_json::json!("writeonly"))); + + let groups = credentials.groups.clone(); + let conditions = HashMap::new(); + for (action, allowed) in [(S3Action::PutObjectAction, true), (S3Action::GetObjectAction, false)] { + let args = Args { + account: &credentials.access_key, + groups: &groups, + action: Action::S3Action(action), + bucket: "federated-binding-bucket", + conditions: &conditions, + is_owner: false, + object: "object.txt", + claims: &claims, + deny_only: false, + }; + assert_eq!( + iam.is_allowed(&args).await, + allowed, + "the credential persisted by the production binding must use the selected STS policy" + ); + } + } + + #[tokio::test] + #[serial] + async fn markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries() { + let iam = ready_test_iam().await; + let signing_key = current_token_signing_key().expect("test signing key should be initialized"); + let legacy_parent = "legacy-markerless-oidc-parent"; + let legacy_claims = HashMap::from([ + ("iss".to_string(), serde_json::json!("rustfs-oidc")), + ("oidc_provider".to_string(), serde_json::json!("default")), + ("sub".to_string(), serde_json::json!("legacy-subject")), + ("parent".to_string(), serde_json::json!(legacy_parent)), + ("policy".to_string(), serde_json::json!("readonly")), + ("exp".to_string(), serde_json::json!(4_102_444_800_i64)), + ]); + let legacy = rustfs_credentials::Credentials { + access_key: "LEGACYMARKERLESS0001".to_string(), + secret_key: "legacy-markerless-secret-0001".to_string(), + session_token: markerless_legacy_session_token(&signing_key), + parent_user: legacy_parent.to_string(), + status: "on".to_string(), + expiration: Some(OffsetDateTime::from_unix_timestamp(4_102_444_800).expect("legacy expiration should be valid")), + ..Default::default() + }; + iam.set_temp_user(&legacy.access_key, &legacy, None) + .await + .expect("markerless legacy session should be stored"); + + let (authenticated, owner) = crate::auth::check_key_valid(&legacy.session_token, &legacy.access_key) + .await + .expect("markerless legacy session should pass the request authentication boundary"); + assert_eq!(authenticated.claims, Some(legacy_claims)); + assert!(!owner); + let listed = iam + .list_sts_accounts(legacy_parent) + .await + .expect("markerless legacy session should be listable"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].access_key, legacy.access_key); + + iam.delete_temp_account(&legacy.access_key, false) + .await + .expect("markerless legacy session should be deleted through IAM"); + assert!( + iam.list_sts_accounts(legacy_parent) + .await + .expect("legacy session list after deletion") + .is_empty() + ); + assert!( + crate::auth::check_key_valid(&legacy.session_token, &legacy.access_key) + .await + .is_err() + ); } #[test]