From 2d91e2f580eb600962dbb7202f0f31279d0f201c Mon Sep 17 00:00:00 2001 From: Logan Ye Date: Fri, 3 Apr 2026 21:45:56 +0800 Subject: [PATCH] fix(oidc): support case-insensitive claim name matching (#2362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: loverustfs Co-authored-by: 安正超 --- crates/iam/src/oidc.rs | 58 +++++++++++++++++++++++++++--- crates/policy/src/policy/policy.rs | 53 ++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/crates/iam/src/oidc.rs b/crates/iam/src/oidc.rs index f28f3b9ff..cac4c7bb6 100644 --- a/crates/iam/src/oidc.rs +++ b/crates/iam/src/oidc.rs @@ -1007,14 +1007,27 @@ pub(crate) fn decode_jwt_payload(token: &str) -> HashMap, key: &str) -> String { - claims.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string() +/// Get a claim value from raw claims with case-insensitive fallback. +/// First tries exact match, then falls back to case-insensitive match if not found. +fn get_claim_case_insensitive<'a>(claims: &'a HashMap, key: &str) -> Option<&'a serde_json::Value> { + if let Some(v) = claims.get(key) { + return Some(v); + } + let key_lower = key.to_lowercase(); + claims.iter().find(|(k, _)| k.to_lowercase() == key_lower).map(|(_, v)| v) } -/// Extract a groups/array claim from raw claims. Handles both string arrays and single strings. +/// Extract a string claim from raw claims with case-insensitive fallback. +fn extract_string_claim(claims: &HashMap, key: &str) -> String { + get_claim_case_insensitive(claims, key) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string() +} + +/// 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 claims.get(key) { + match get_claim_case_insensitive(claims, key) { Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(), Some(serde_json::Value::String(s)) => s.split(',').map(|s| s.trim().to_string()).collect(), _ => vec![], @@ -1069,6 +1082,41 @@ mod tests { assert!(groups.is_empty()); } + #[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_decode_jwt_payload() { let payload = r#"{"sub":"user123","email":"user@example.com"}"#; diff --git a/crates/policy/src/policy/policy.rs b/crates/policy/src/policy/policy.rs index 6464fcfd3..88201e66b 100644 --- a/crates/policy/src/policy/policy.rs +++ b/crates/policy/src/policy/policy.rs @@ -239,9 +239,20 @@ impl Validator for BucketPolicy { } } +fn get_claim_case_insensitive<'a>(claims: &'a HashMap, claim_name: &str) -> Option<&'a Value> { + if let Some(v) = claims.get(claim_name) { + return Some(v); + } + let claim_name_lower = claim_name.to_lowercase(); + claims + .iter() + .find(|(k, _)| k.to_lowercase() == claim_name_lower) + .map(|(_, v)| v) +} + fn get_values_from_claims(claims: &HashMap, claim_name: &str) -> (HashSet, bool) { let mut s = HashSet::new(); - if let Some(pname) = claims.get(claim_name) { + if let Some(pname) = get_claim_case_insensitive(claims, claim_name) { if let Some(pnames) = pname.as_array() { for pname in pnames { if let Some(pname_str) = pname.as_str() { @@ -1693,4 +1704,44 @@ mod test { "principal and resource match should keep ExistingObjectTag fetch hint" ); } + + #[test] + fn test_get_values_from_claims_case_insensitive() { + let mut claims = HashMap::new(); + claims.insert("policyminio".to_string(), Value::Array(vec![Value::String("consoleAdmin".to_string())])); + + let (policies, found) = get_values_from_claims(&claims, "policyMinio"); + assert!(found); + assert!(policies.contains("consoleAdmin")); + + let (policies, found) = get_values_from_claims(&claims, "POLICYMINIO"); + assert!(found); + assert!(policies.contains("consoleAdmin")); + + let (policies, found) = get_values_from_claims(&claims, "policyminio"); + assert!(found); + assert!(policies.contains("consoleAdmin")); + } + + #[test] + fn test_get_values_from_claims_exact_match_preferred() { + let mut claims = HashMap::new(); + claims.insert("Policy".to_string(), Value::Array(vec![Value::String("exact_match".to_string())])); + claims.insert("policy".to_string(), Value::Array(vec![Value::String("lowercase".to_string())])); + + let (policies, _) = get_values_from_claims(&claims, "Policy"); + assert!(policies.contains("exact_match")); + assert!(!policies.contains("lowercase")); + } + + #[test] + fn test_get_policies_from_claims_case_insensitive_string() { + let mut claims = HashMap::new(); + claims.insert("policyminio".to_string(), Value::String("consoleAdmin,readwrite".to_string())); + + let (policies, found) = get_policies_from_claims(&claims, "policyMinio"); + assert!(found); + assert!(policies.contains("consoleAdmin")); + assert!(policies.contains("readwrite")); + } }