1use std::collections::{BTreeMap, BTreeSet};
16use std::sync::{Arc, Mutex};
17use std::time::Duration;
18
19use jsonwebtoken::jwk::JwkSet;
20use mz_adapter::{AdapterError, AuthenticationError, Client as AdapterClient};
21use mz_adapter_types::dyncfgs::{
22 OIDC_AUDIENCE, OIDC_AUTHENTICATION_CLAIM, OIDC_GROUP_CLAIM, OIDC_ISSUER,
23};
24use mz_auth::Authenticated;
25use mz_ore::secure::{Zeroize, ZeroizeOnDrop};
26use mz_ore::soft_panic_or_log;
27use mz_pgwire_common::{ErrorResponse, Severity};
28use reqwest::Client as HttpClient;
29use serde::{Deserialize, Deserializer, Serialize};
30use tokio_postgres::error::SqlState;
31
32use tracing::{debug, warn};
33use url::Url;
34#[derive(Debug)]
36pub enum OidcError {
37 MissingIssuer,
38 InvalidIssuerUrl(String),
40 AudienceParseError,
41 FetchFromProviderFailed {
43 url: String,
44 error_message: String,
45 },
46 MissingKid,
48 NoMatchingKey {
50 key_id: String,
52 },
53 NoMatchingAuthenticationClaim {
55 authentication_claim: String,
56 },
57 Jwt,
59 WrongUser,
60 InvalidAudience {
61 expected_audiences: Vec<String>,
62 },
63 InvalidIssuer {
64 expected_issuer: String,
65 },
66 ExpiredSignature,
67 NonLogin,
69 LoginCheckError,
70}
71
72impl std::fmt::Display for OidcError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 OidcError::MissingIssuer => write!(f, "OIDC issuer is not configured"),
76 OidcError::InvalidIssuerUrl(_) => write!(f, "invalid OIDC issuer URL"),
77 OidcError::AudienceParseError => {
78 write!(f, "failed to parse OIDC_AUDIENCE system variable")
79 }
80 OidcError::FetchFromProviderFailed { .. } => {
81 write!(f, "failed to fetch OIDC provider configuration")
82 }
83 OidcError::MissingKid => write!(f, "missing key ID in JWT header"),
84 OidcError::NoMatchingKey { .. } => write!(f, "no matching key found in the JWKS"),
85 OidcError::NoMatchingAuthenticationClaim { .. } => {
86 write!(f, "no matching authentication claim found in the JWT")
87 }
88 OidcError::Jwt => write!(f, "failed to validate JWT"),
89 OidcError::WrongUser => write!(f, "wrong user"),
90 OidcError::InvalidAudience { .. } => write!(f, "invalid audience"),
91 OidcError::InvalidIssuer { .. } => write!(f, "invalid issuer"),
92 OidcError::ExpiredSignature => write!(f, "authentication credentials have expired"),
93 OidcError::NonLogin => write!(f, "role is not allowed to login"),
94 OidcError::LoginCheckError => write!(f, "unexpected error checking if role can login"),
95 }
96 }
97}
98
99impl std::error::Error for OidcError {}
100
101impl OidcError {
102 pub fn code(&self) -> SqlState {
103 SqlState::INVALID_AUTHORIZATION_SPECIFICATION
104 }
105
106 pub fn detail(&self) -> Option<String> {
107 match self {
108 OidcError::InvalidIssuerUrl(issuer) => {
109 Some(format!("Could not parse \"{issuer}\" as a URL."))
110 }
111 OidcError::FetchFromProviderFailed { url, error_message } => {
112 Some(format!("Fetching \"{url}\" failed. {error_message}"))
113 }
114 OidcError::NoMatchingKey { key_id } => {
115 Some(format!("JWT key ID \"{key_id}\" was not found."))
116 }
117 OidcError::InvalidAudience { expected_audiences } => Some(format!(
118 "Expected one of audiences {:?} in the JWT.",
119 expected_audiences,
120 )),
121 OidcError::InvalidIssuer { expected_issuer } => {
122 Some(format!("Expected issuer \"{expected_issuer}\" in the JWT.",))
123 }
124 OidcError::NoMatchingAuthenticationClaim {
125 authentication_claim,
126 } => Some(format!(
127 "Expected authentication claim \"{authentication_claim}\" in the JWT.",
128 )),
129 OidcError::NonLogin => Some("The role does not have the LOGIN attribute.".into()),
130 _ => None,
131 }
132 }
133
134 pub fn hint(&self) -> Option<String> {
135 match self {
136 OidcError::MissingIssuer => {
137 Some("Configure the OIDC issuer using the oidc_issuer system variable.".into())
138 }
139 _ => None,
140 }
141 }
142
143 pub fn into_response(self) -> ErrorResponse {
144 ErrorResponse {
145 severity: Severity::Fatal,
146 code: self.code(),
147 message: self.to_string(),
148 detail: self.detail(),
149 hint: self.hint(),
150 position: None,
151 }
152 }
153}
154
155fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
156where
157 D: Deserializer<'de>,
158{
159 #[derive(Deserialize)]
160 #[serde(untagged)]
161 enum StringOrVec {
162 String(String),
163 Vec(Vec<String>),
164 }
165
166 match StringOrVec::deserialize(deserializer)? {
167 StringOrVec::String(s) => Ok(vec![s]),
168 StringOrVec::Vec(v) => Ok(v),
169 }
170}
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct OidcClaims {
174 pub iss: String,
176 pub exp: i64,
178 #[serde(default)]
180 pub iat: Option<i64>,
181 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
183 pub aud: Vec<String>,
184 #[serde(flatten)]
186 pub unknown_claims: BTreeMap<String, serde_json::Value>,
187}
188
189impl Zeroize for OidcClaims {
190 fn zeroize(&mut self) {
191 self.iss.zeroize();
192 self.exp.zeroize();
193 self.iat.zeroize();
194 for s in &mut self.aud {
195 s.zeroize();
196 }
197 self.aud.clear();
198 while let Some((mut k, mut v)) = self.unknown_claims.pop_first() {
201 k.zeroize();
202 zeroize_json_value(&mut v);
203 }
204 }
205}
206
207impl Drop for OidcClaims {
208 fn drop(&mut self) {
209 self.zeroize();
210 }
211}
212
213impl ZeroizeOnDrop for OidcClaims {}
216
217fn zeroize_json_value(v: &mut serde_json::Value) {
218 use serde_json::Value;
219 match v {
220 Value::String(s) => s.zeroize(),
221 Value::Array(a) => {
222 for item in a.iter_mut() {
223 zeroize_json_value(item);
224 }
225 a.clear();
226 }
227 Value::Object(map) => {
228 let taken = std::mem::take(map);
229 for (mut k, mut nested) in taken {
230 k.zeroize();
231 zeroize_json_value(&mut nested);
232 }
233 }
234 Value::Number(_) => {
235 *v = Value::Number(serde_json::Number::from(0u8));
236 }
237 Value::Bool(b) => *b = false,
238 Value::Null => {}
239 }
240}
241
242impl OidcClaims {
243 fn user(&self, authentication_claim: &str) -> Option<&str> {
245 self.unknown_claims
246 .get(authentication_claim)
247 .and_then(|value| value.as_str())
248 }
249
250 pub fn groups(&self, claim_name: &str) -> Option<Vec<String>> {
260 let value = self.unknown_claims.get(claim_name)?;
261
262 let raw_groups: Vec<String> = match value {
263 serde_json::Value::Array(arr) => arr
264 .iter()
265 .filter_map(|v| v.as_str().map(String::from))
266 .collect(),
267 serde_json::Value::String(s) => {
268 if s.is_empty() {
269 vec![]
270 } else {
271 vec![s.clone()]
272 }
273 }
274 _ => {
275 warn!(
276 claim_name,
277 "OIDC group claim has unexpected type; skipping group sync"
278 );
279 return None;
280 }
281 };
282
283 let normalized: Vec<String> = raw_groups
284 .into_iter()
285 .map(|g| g.trim().to_lowercase())
286 .filter(|g| !g.is_empty())
287 .collect::<BTreeSet<_>>()
288 .into_iter()
289 .collect();
290
291 Some(normalized)
292 }
293}
294
295#[derive(Zeroize, ZeroizeOnDrop)]
296pub struct ValidatedClaims {
297 pub user: String,
298 pub groups: Option<Vec<String>>,
300 _private: (),
302}
303
304#[derive(Clone)]
306struct OidcDecodingKey(jsonwebtoken::DecodingKey);
307
308impl std::fmt::Debug for OidcDecodingKey {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 f.debug_struct("OidcDecodingKey")
311 .field("key", &"<redacted>")
312 .finish()
313 }
314}
315
316#[derive(Clone, Debug)]
321pub struct GenericOidcAuthenticator {
322 inner: Arc<GenericOidcAuthenticatorInner>,
323}
324
325#[derive(Debug, Deserialize)]
328struct OpenIdConfiguration {
329 jwks_uri: String,
331}
332
333#[derive(Debug)]
334pub struct GenericOidcAuthenticatorInner {
335 adapter_client: AdapterClient,
336 decoding_keys: Mutex<BTreeMap<String, OidcDecodingKey>>,
337 http_client: HttpClient,
338}
339
340impl GenericOidcAuthenticator {
341 pub fn new(adapter_client: AdapterClient) -> Self {
346 let http_client = HttpClient::new();
347
348 Self {
349 inner: Arc::new(GenericOidcAuthenticatorInner {
350 adapter_client,
351 decoding_keys: Mutex::new(BTreeMap::new()),
352 http_client,
353 }),
354 }
355 }
356}
357
358impl GenericOidcAuthenticatorInner {
359 async fn fetch_jwks_uri(&self, issuer: &str) -> Result<String, OidcError> {
360 let openid_config_url = build_openid_config_url(issuer)?;
361
362 let openid_config_url_str = openid_config_url.to_string();
363
364 let response = self
366 .http_client
367 .get(openid_config_url)
368 .timeout(Duration::from_secs(10))
369 .send()
370 .await
371 .map_err(|e| OidcError::FetchFromProviderFailed {
372 url: openid_config_url_str.clone(),
373 error_message: e.to_string(),
374 })?;
375
376 if !response.status().is_success() {
377 return Err(OidcError::FetchFromProviderFailed {
378 url: openid_config_url_str.clone(),
379 error_message: response
380 .error_for_status()
381 .err()
382 .map(|e| e.to_string())
383 .unwrap_or_else(|| "Unknown error".to_string()),
384 });
385 }
386
387 let openid_config: OpenIdConfiguration =
388 response
389 .json()
390 .await
391 .map_err(|e| OidcError::FetchFromProviderFailed {
392 url: openid_config_url_str,
393 error_message: e.to_string(),
394 })?;
395
396 Ok(openid_config.jwks_uri)
397 }
398
399 async fn fetch_jwks(
401 &self,
402 issuer: &str,
403 ) -> Result<BTreeMap<String, OidcDecodingKey>, OidcError> {
404 let jwks_uri = self.fetch_jwks_uri(issuer).await?;
405 let response = self
406 .http_client
407 .get(&jwks_uri)
408 .timeout(Duration::from_secs(10))
409 .send()
410 .await
411 .map_err(|e| OidcError::FetchFromProviderFailed {
412 url: jwks_uri.clone(),
413 error_message: e.to_string(),
414 })?;
415
416 if !response.status().is_success() {
417 return Err(OidcError::FetchFromProviderFailed {
418 url: jwks_uri.clone(),
419 error_message: response
420 .error_for_status()
421 .err()
422 .map(|e| e.to_string())
423 .unwrap_or_else(|| "Unknown error".to_string()),
424 });
425 }
426
427 let jwks: JwkSet =
428 response
429 .json()
430 .await
431 .map_err(|e| OidcError::FetchFromProviderFailed {
432 url: jwks_uri.clone(),
433 error_message: e.to_string(),
434 })?;
435
436 let mut keys = BTreeMap::new();
437
438 for jwk in jwks.keys {
439 match jsonwebtoken::DecodingKey::from_jwk(&jwk) {
440 Ok(key) => {
441 if let Some(kid) = jwk.common.key_id {
442 keys.insert(kid, OidcDecodingKey(key));
443 }
444 }
445 Err(e) => {
446 warn!("Failed to parse JWK: {}", e);
447 }
448 }
449 }
450
451 Ok(keys)
452 }
453
454 async fn find_key(&self, kid: &str, issuer: &str) -> Result<OidcDecodingKey, OidcError> {
457 {
459 let decoding_keys = self.decoding_keys.lock().expect("lock poisoned");
460
461 if let Some(key) = decoding_keys.get(kid) {
462 return Ok(key.clone());
463 }
464 }
465
466 let new_decoding_keys = self.fetch_jwks(issuer).await?;
468
469 let decoding_key = new_decoding_keys.get(kid).cloned();
470
471 {
472 let mut decoding_keys = self.decoding_keys.lock().expect("lock poisoned");
473 *decoding_keys = new_decoding_keys;
474 }
475
476 if let Some(key) = decoding_key {
477 return Ok(key);
478 }
479
480 {
481 let decoding_keys = self.decoding_keys.lock().expect("lock poisoned");
482 debug!(
483 "No matching key found in JWKS for key ID: {kid}. Available keys: {decoding_keys:?}."
484 );
485 Err(OidcError::NoMatchingKey {
486 key_id: kid.to_string(),
487 })
488 }
489 }
490
491 pub async fn validate_token(
492 &self,
493 token: &str,
494 expected_user: Option<&str>,
495 ) -> Result<ValidatedClaims, OidcError> {
496 let system_vars = self.adapter_client.get_system_vars().await;
498 let Some(issuer) = OIDC_ISSUER.get(system_vars.dyncfgs()) else {
499 return Err(OidcError::MissingIssuer);
500 };
501
502 let authentication_claim = OIDC_AUTHENTICATION_CLAIM.get(system_vars.dyncfgs());
503
504 let expected_audiences: Vec<String> = {
505 let audiences: Vec<String> =
506 serde_json::from_value(OIDC_AUDIENCE.get(system_vars.dyncfgs()))
507 .map_err(|_| OidcError::AudienceParseError)?;
508
509 if audiences.is_empty() {
510 warn!(
511 "Audience validation skipped. It is discouraged \
512 to skip audience validation since it allows anyone \
513 with a JWT issued by the same issuer to authenticate."
514 );
515 }
516 audiences
517 };
518
519 let header = jsonwebtoken::decode_header(token).map_err(|e| {
522 debug!("Failed to decode JWT header: {:?}", e);
523 OidcError::Jwt
524 })?;
525
526 let kid = header.kid.ok_or(OidcError::MissingKid)?;
527 let decoding_key = self.find_key(&kid, &issuer).await?;
530
531 let mut validation = jsonwebtoken::Validation::new(header.alg);
533 validation.set_issuer(&[&issuer]);
534 if !expected_audiences.is_empty() {
535 validation.set_audience(&expected_audiences);
536 } else {
537 validation.validate_aud = false;
538 }
539
540 let token_data = jsonwebtoken::decode::<OidcClaims>(token, &(decoding_key.0), &validation)
542 .map_err(|e| match e.kind() {
543 jsonwebtoken::errors::ErrorKind::InvalidAudience => {
544 if !expected_audiences.is_empty() {
545 OidcError::InvalidAudience {
546 expected_audiences
547 }
548 } else {
549 soft_panic_or_log!(
550 "received an audience validation error when audience validation is disabled"
551 );
552 OidcError::Jwt
553 }
554 }
555 jsonwebtoken::errors::ErrorKind::InvalidIssuer => OidcError::InvalidIssuer {
556 expected_issuer: issuer.clone(),
557 },
558 jsonwebtoken::errors::ErrorKind::ExpiredSignature => OidcError::ExpiredSignature,
559 _ => OidcError::Jwt,
560 })?;
561
562 let user = token_data.claims.user(&authentication_claim).ok_or(
563 OidcError::NoMatchingAuthenticationClaim {
564 authentication_claim,
565 },
566 )?;
567
568 if let Some(expected) = expected_user {
570 if user != expected {
571 return Err(OidcError::WrongUser);
572 }
573 }
574
575 let group_claim = OIDC_GROUP_CLAIM.get(system_vars.dyncfgs());
577 let groups = token_data.claims.groups(&group_claim);
578
579 Ok(ValidatedClaims {
580 user: user.to_string(),
581 groups,
582 _private: (),
583 })
584 }
585
586 async fn check_role_login(&self, role_name: &str) -> Result<(), OidcError> {
590 match self.adapter_client.role_can_login(role_name).await {
591 Ok(()) => Ok(()),
592 Err(AdapterError::AuthenticationError(AuthenticationError::RoleNotFound)) => {
593 Ok(())
595 }
596 Err(AdapterError::AuthenticationError(AuthenticationError::NonLogin)) => {
597 Err(OidcError::NonLogin)
598 }
599 Err(e) => {
600 warn!(?e, "unexpected error checking OIDC role login");
601 Err(OidcError::LoginCheckError)
602 }
603 }
604 }
605}
606
607impl GenericOidcAuthenticator {
608 pub async fn authenticate(
609 &self,
610 token: &str,
611 expected_user: Option<&str>,
612 ) -> Result<(ValidatedClaims, Authenticated), OidcError> {
613 let validated_claims = self.inner.validate_token(token, expected_user).await?;
614 self.inner.check_role_login(&validated_claims.user).await?;
615 Ok((validated_claims, Authenticated))
616 }
617}
618
619fn build_openid_config_url(issuer: &str) -> Result<Url, OidcError> {
620 let mut openid_config_url =
621 Url::parse(issuer).map_err(|_| OidcError::InvalidIssuerUrl(issuer.to_string()))?;
622 {
623 let mut segments = openid_config_url
624 .path_segments_mut()
625 .map_err(|_| OidcError::InvalidIssuerUrl(issuer.to_string()))?;
626 segments.pop_if_empty();
628 segments.push(".well-known");
629 segments.push("openid-configuration");
630 }
631 Ok(openid_config_url)
632}
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 #[mz_ore::test]
638 fn test_aud_single_string() {
639 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"my-app"}"#;
640 let claims: OidcClaims = serde_json::from_str(json).unwrap();
641 assert_eq!(claims.aud, vec!["my-app"]);
642 }
643
644 #[mz_ore::test]
645 fn test_aud_array() {
646 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":["app1","app2"]}"#;
647 let claims: OidcClaims = serde_json::from_str(json).unwrap();
648 assert_eq!(claims.aud, vec!["app1", "app2"]);
649 }
650
651 #[mz_ore::test]
652 fn test_user() {
653 let json = r#"{"sub":"user-123","iss":"issuer","exp":1234,"aud":["app"],"email":"alice@example.com"}"#;
654 let claims: OidcClaims = serde_json::from_str(json).unwrap();
655 assert_eq!(claims.user("sub"), Some("user-123"));
656 assert_eq!(claims.user("email"), Some("alice@example.com"));
657 assert_eq!(claims.user("missing"), None);
658 }
659
660 #[mz_ore::test]
661 fn test_build_openid_config_url() {
662 let issuer = "https://dev-123456.okta.com/oauth2/default";
663 let url = build_openid_config_url(issuer).unwrap();
664 assert_eq!(
665 url.to_string(),
666 "https://dev-123456.okta.com/oauth2/default/.well-known/openid-configuration"
667 );
668 }
669
670 #[mz_ore::test]
671 fn test_build_openid_config_url_trailing_slash() {
672 let issuer = "https://dev-123456.okta.com/oauth2/default/";
673 let url = build_openid_config_url(issuer).unwrap();
674 assert_eq!(
675 url.to_string(),
676 "https://dev-123456.okta.com/oauth2/default/.well-known/openid-configuration"
677 );
678 }
679
680 #[mz_ore::test]
681 fn zeroize_clears_validated_claims() {
682 use mz_ore::secure::Zeroize;
683 let mut claims = ValidatedClaims {
684 user: "alice@example.com".to_string(),
685 groups: Some(vec!["eng".to_string()]),
686 _private: (),
687 };
688 claims.zeroize();
689 assert!(claims.user.is_empty());
690 }
691
692 #[mz_ore::test]
693 fn oidc_claims_implements_zeroize_on_drop() {
694 fn assert_zod<T: ZeroizeOnDrop>() {}
695 assert_zod::<OidcClaims>();
696 assert_zod::<ValidatedClaims>();
697 }
698
699 #[mz_ore::test]
700 fn zeroize_clears_oidc_claims() {
701 use mz_ore::secure::Zeroize;
702 let mut claims = OidcClaims {
703 iss: "https://issuer.example.com".to_string(),
704 exp: 1234567890,
705 iat: Some(1234567800),
706 aud: vec!["app1".to_string(), "app2".to_string()],
707 unknown_claims: BTreeMap::from([(
708 "email".to_string(),
709 serde_json::Value::String("alice@example.com".to_string()),
710 )]),
711 };
712 claims.zeroize();
713 assert!(claims.iss.is_empty());
714 assert_eq!(claims.exp, 0);
715 assert!(claims.iat.is_none());
716 assert!(claims.aud.is_empty());
717 assert!(claims.unknown_claims.is_empty());
718 }
719
720 #[mz_ore::test]
721 fn test_groups_array() {
722 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["analytics","platform_eng"]}"#;
723 let claims: OidcClaims = serde_json::from_str(json).unwrap();
724 assert_eq!(
725 claims.groups("groups"),
726 Some(vec!["analytics".to_string(), "platform_eng".to_string()])
727 );
728 }
729
730 #[mz_ore::test]
731 fn test_groups_single_string() {
732 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":"analytics"}"#;
733 let claims: OidcClaims = serde_json::from_str(json).unwrap();
734 assert_eq!(claims.groups("groups"), Some(vec!["analytics".to_string()]));
735 }
736
737 #[mz_ore::test]
738 fn test_groups_missing() {
739 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app"}"#;
740 let claims: OidcClaims = serde_json::from_str(json).unwrap();
741 assert_eq!(claims.groups("groups"), None);
742 }
743
744 #[mz_ore::test]
745 fn test_groups_empty_array() {
746 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[]}"#;
747 let claims: OidcClaims = serde_json::from_str(json).unwrap();
748 assert_eq!(claims.groups("groups"), Some(vec![]));
749 }
750
751 #[mz_ore::test]
752 fn test_groups_mixed_case() {
753 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["Analytics","PLATFORM_ENG","analytics"]}"#;
754 let claims: OidcClaims = serde_json::from_str(json).unwrap();
755 assert_eq!(
756 claims.groups("groups"),
757 Some(vec!["analytics".to_string(), "platform_eng".to_string()])
758 );
759 }
760
761 #[mz_ore::test]
762 fn test_groups_custom_claim_name() {
763 let json =
764 r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","roles":["admin","viewer"]}"#;
765 let claims: OidcClaims = serde_json::from_str(json).unwrap();
766 assert_eq!(
767 claims.groups("roles"),
768 Some(vec!["admin".to_string(), "viewer".to_string()])
769 );
770 assert_eq!(claims.groups("groups"), None);
771 }
772
773 #[mz_ore::test]
774 fn test_groups_non_string_values_in_array() {
775 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["valid",123,true,"also_valid"]}"#;
776 let claims: OidcClaims = serde_json::from_str(json).unwrap();
777 assert_eq!(
778 claims.groups("groups"),
779 Some(vec!["also_valid".to_string(), "valid".to_string()])
780 );
781 }
782
783 #[mz_ore::test]
784 fn test_groups_non_array_non_string() {
785 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":42}"#;
786 let claims: OidcClaims = serde_json::from_str(json).unwrap();
787 assert_eq!(claims.groups("groups"), None);
788 }
789
790 #[mz_ore::test]
791 fn test_groups_empty_string() {
792 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":""}"#;
793 let claims: OidcClaims = serde_json::from_str(json).unwrap();
794 assert_eq!(claims.groups("groups"), Some(vec![]));
795 }
796
797 #[mz_ore::test]
798 fn test_groups_null_claim() {
799 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":null}"#;
801 let claims: OidcClaims = serde_json::from_str(json).unwrap();
802 assert_eq!(claims.groups("groups"), None);
803 }
804
805 #[mz_ore::test]
806 fn test_groups_boolean_claim() {
807 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":true}"#;
809 let claims: OidcClaims = serde_json::from_str(json).unwrap();
810 assert_eq!(claims.groups("groups"), None);
811 }
812
813 #[mz_ore::test]
814 fn test_groups_object_claim() {
815 let json =
817 r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":{"team":"eng"}}"#;
818 let claims: OidcClaims = serde_json::from_str(json).unwrap();
819 assert_eq!(claims.groups("groups"), None);
820 }
821
822 #[mz_ore::test]
823 fn test_groups_array_all_non_strings() {
824 let json =
827 r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[1,2,true,null]}"#;
828 let claims: OidcClaims = serde_json::from_str(json).unwrap();
829 assert_eq!(claims.groups("groups"), Some(vec![]));
830 }
831
832 #[mz_ore::test]
833 fn test_groups_array_with_nested_arrays() {
834 let json =
836 r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[["nested"],"valid"]}"#;
837 let claims: OidcClaims = serde_json::from_str(json).unwrap();
838 assert_eq!(claims.groups("groups"), Some(vec!["valid".to_string()]));
839 }
840
841 #[mz_ore::test]
842 fn test_groups_array_with_empty_strings() {
843 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["","eng",""]}"#;
846 let claims: OidcClaims = serde_json::from_str(json).unwrap();
847 assert_eq!(claims.groups("groups"), Some(vec!["eng".to_string()]));
848 }
849
850 #[mz_ore::test]
851 fn test_groups_whitespace_only_single_string() {
852 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":" "}"#;
854 let claims: OidcClaims = serde_json::from_str(json).unwrap();
855 assert_eq!(claims.groups("groups"), Some(vec![]));
856 }
857
858 #[mz_ore::test]
859 fn test_groups_whitespace_names() {
860 let json =
862 r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[" spaces ","eng"]}"#;
863 let claims: OidcClaims = serde_json::from_str(json).unwrap();
864 assert_eq!(
865 claims.groups("groups"),
866 Some(vec!["eng".to_string(), "spaces".to_string()])
867 );
868 }
869
870 #[mz_ore::test]
871 fn test_groups_unicode_names() {
872 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["Développeurs","INGÉNIEURS"]}"#;
874 let claims: OidcClaims = serde_json::from_str(json).unwrap();
875 assert_eq!(
876 claims.groups("groups"),
877 Some(vec!["développeurs".to_string(), "ingénieurs".to_string()])
878 );
879 }
880
881 #[mz_ore::test]
882 fn test_groups_special_characters() {
883 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["team-platform.eng","org_data-science","role/admin"]}"#;
886 let claims: OidcClaims = serde_json::from_str(json).unwrap();
887 assert_eq!(
888 claims.groups("groups"),
889 Some(vec![
890 "org_data-science".to_string(),
891 "role/admin".to_string(),
892 "team-platform.eng".to_string(),
893 ])
894 );
895 }
896
897 #[mz_ore::test]
898 fn test_groups_case_insensitive_dedup() {
899 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["Eng","eng","ENG","eNg"]}"#;
901 let claims: OidcClaims = serde_json::from_str(json).unwrap();
902 assert_eq!(claims.groups("groups"), Some(vec!["eng".to_string()]));
903 }
904
905 #[mz_ore::test]
906 fn test_groups_large_array() {
907 let groups: Vec<String> = (0..100).map(|i| format!("\"group_{}\"", i)).collect();
909 let json = format!(
910 r#"{{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[{}]}}"#,
911 groups.join(",")
912 );
913 let claims: OidcClaims = serde_json::from_str(&json).unwrap();
914 let result = claims.groups("groups").unwrap();
915 assert_eq!(result.len(), 100);
916 assert_eq!(result[0], "group_0");
918 assert_eq!(result[99], "group_99");
919 }
920
921 #[mz_ore::test]
922 fn test_groups_float_claim() {
923 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":3.14}"#;
925 let claims: OidcClaims = serde_json::from_str(json).unwrap();
926 assert_eq!(claims.groups("groups"), None);
927 }
928
929 #[mz_ore::test]
930 fn test_groups_array_with_null_elements() {
931 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["eng",null,"ops",null]}"#;
933 let claims: OidcClaims = serde_json::from_str(json).unwrap();
934 assert_eq!(
935 claims.groups("groups"),
936 Some(vec!["eng".to_string(), "ops".to_string()])
937 );
938 }
939
940 #[mz_ore::test]
941 fn test_groups_array_with_object_elements() {
942 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["eng",{"name":"ops"},"analytics"]}"#;
944 let claims: OidcClaims = serde_json::from_str(json).unwrap();
945 assert_eq!(
946 claims.groups("groups"),
947 Some(vec!["analytics".to_string(), "eng".to_string()])
948 );
949 }
950
951 #[mz_ore::test]
952 fn test_groups_sorted_output() {
953 let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["zebra","alpha","mango","beta"]}"#;
955 let claims: OidcClaims = serde_json::from_str(json).unwrap();
956 assert_eq!(
957 claims.groups("groups"),
958 Some(vec![
959 "alpha".to_string(),
960 "beta".to_string(),
961 "mango".to_string(),
962 "zebra".to_string(),
963 ])
964 );
965 }
966}