Skip to main content

mz_authenticator/
oidc.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! OIDC Authentication for pgwire connections.
11//!
12//! This module provides JWT-based authentication using OpenID Connect (OIDC).
13//! JWTs are validated locally using JWKS fetched from the configured provider.
14
15use 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/// Errors that can occur during OIDC authentication.
35#[derive(Debug)]
36pub enum OidcError {
37    MissingIssuer,
38    /// Failed to parse OIDC configuration URL.
39    InvalidIssuerUrl(String),
40    AudienceParseError,
41    /// Failed to fetch from the identity provider.
42    FetchFromProviderFailed {
43        url: String,
44        error_message: String,
45    },
46    /// The key ID is missing in the token header.
47    MissingKid,
48    /// No matching key found in JWKS.
49    NoMatchingKey {
50        /// Key ID that was found in the JWT header.
51        key_id: String,
52    },
53    /// Configured authentication claim is not found in the JWT.
54    NoMatchingAuthenticationClaim {
55        authentication_claim: String,
56    },
57    /// JWT validation error
58    Jwt,
59    WrongUser,
60    InvalidAudience {
61        expected_audiences: Vec<String>,
62    },
63    InvalidIssuer {
64        expected_issuer: String,
65    },
66    ExpiredSignature,
67    /// The role exists but does not have the LOGIN attribute.
68    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/// Claims extracted from a validated JWT.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct OidcClaims {
174    /// Issuer.
175    pub iss: String,
176    /// Expiration time (Unix timestamp).
177    pub exp: i64,
178    /// Issued at time (Unix timestamp).
179    #[serde(default)]
180    pub iat: Option<i64>,
181    /// Audience claim (can be single string or array in JWT).
182    #[serde(default, deserialize_with = "deserialize_string_or_vec")]
183    pub aud: Vec<String>,
184    /// Additional claims from the JWT, captured for flexible username extraction.
185    #[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        // serde_json::Value doesn't implement Zeroize; drain entries and
199        // zeroize keys/values before the backing allocations are freed.
200        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
213/// `OidcClaims` implements both `Zeroize` and `Drop` (which calls `zeroize()`),
214/// satisfying the `ZeroizeOnDrop` contract.
215impl 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    /// Extract the username from the OIDC claims.
244    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    /// Extracts group names from the specified JWT claim for group-to-role sync.
251    ///
252    /// Returns `None` if the claim is absent (skip sync, preserve current state),
253    /// `Some(vec![])` if the claim is present but empty (revoke all sync-granted
254    /// roles), or `Some(vec![...])` with normalized (lowercased, deduplicated,
255    /// sorted) group names.
256    ///
257    /// Accepts arrays of strings, single strings, or mixed arrays (non-string
258    /// elements are filtered out). Other JSON types are treated as absent.
259    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    /// Groups extracted from the JWT group claim. None if claim absent.
299    pub groups: Option<Vec<String>>,
300    // Prevent construction outside of `GenericOidcAuthenticator::validate_token`.
301    _private: (),
302}
303
304/// Wrapper around `jsonwebtoken::DecodingKey` with a redacted `Debug` impl.
305#[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/// OIDC Authenticator that validates JWTs using JWKS.
317///
318/// This implementation pre-fetches JWKS at construction time for synchronous
319/// token validation.
320#[derive(Clone, Debug)]
321pub struct GenericOidcAuthenticator {
322    inner: Arc<GenericOidcAuthenticatorInner>,
323}
324
325/// OpenID Connect Discovery document.
326/// See: <https://openid.net/specs/openid-connect-discovery-1_0.html>
327#[derive(Debug, Deserialize)]
328struct OpenIdConfiguration {
329    /// URL of the JWKS endpoint.
330    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    /// Create a new [`GenericOidcAuthenticator`] with an [`AdapterClient`].
342    ///
343    /// The OIDC issuer and audience are fetched from system variables on each
344    /// authentication attempt.
345    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        // Fetch OpenID configuration to get the JWKS URI
365        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    /// Fetch JWKS from the provider and parse into a map of key IDs to decoding keys.
400    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    /// Find a decoding key matching the given key ID.
455    /// If the key is not found, fetch the JWKS and cache the keys.
456    async fn find_key(&self, kid: &str, issuer: &str) -> Result<OidcDecodingKey, OidcError> {
457        // Get the cached decoding key.
458        {
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        // If not found, fetch the JWKS and cache the keys.
467        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        // Fetch current OIDC configuration from system variables
497        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        // Decode header to get key ID (kid) and the
520        // decoding algorithm
521        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        // Find the matching key from our set of cached keys. If not found,
528        // fetch the JWKS from the provider and cache the keys
529        let decoding_key = self.find_key(&kid, &issuer).await?;
530
531        // Set up audience and issuer validation
532        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        // Decode and validate the token
541        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        // Optionally validate the expected user
569        if let Some(expected) = expected_user {
570            if user != expected {
571                return Err(OidcError::WrongUser);
572            }
573        }
574
575        // Extract groups from the configured claim name for group-to-role sync.
576        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    /// Checks whether the role has the LOGIN attribute. This is needed otherwise
587    /// a user can authenticate with an OIDC token to a role that isn't recognized
588    /// as a user.
589    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                // Role will be auto-provisioned during startup; allow login.
594                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        // Remove trailing slash if it exists
627        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        // Explicit null → treated as absent (not a valid group representation)
800        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        // Boolean value → not array or string, treated as absent
808        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        // JSON object → not array or string, treated as absent
816        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        // Array with zero valid string elements → Some(vec![]), not None
825        // (the claim *is* present, it just has no usable group names)
826        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        // Nested arrays are not strings, so they're filtered out
835        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        // Empty strings are not valid role names and are filtered out,
844        // consistent with the single-string case where "" → Some(vec![]).
845        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        // Whitespace-only string trims to empty and is filtered out.
853        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        // Leading/trailing whitespace is trimmed from group names.
861        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        // Unicode group names should be lowercased correctly
873        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        // Group names with special characters (hyphens, underscores, dots)
884        // are common in enterprise IdPs like Azure AD / Okta
885        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        // "Eng" and "eng" and "ENG" should all collapse to one "eng"
900        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        // Verify we handle a reasonably large group list without issues
908        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        // BTreeSet ensures sorted order
917        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        // Float value → not array or string, treated as absent
924        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        // Null elements in array are not strings, filtered out
932        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        // Object elements in array are not strings, filtered out
943        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        // Verify output is sorted alphabetically regardless of input order
954        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}