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 deduplicated, sorted group names
255    /// (exact case preserved — matching against catalog role names is
256    /// case-sensitive).
257    ///
258    /// Accepts arrays of strings, single strings, or mixed arrays (non-string
259    /// elements are filtered out). Other JSON types are treated as absent.
260    pub fn groups(&self, claim_name: &str) -> Option<Vec<String>> {
261        let value = self.unknown_claims.get(claim_name)?;
262
263        let raw_groups: Vec<String> = match value {
264            serde_json::Value::Array(arr) => arr
265                .iter()
266                .filter_map(|v| v.as_str().map(String::from))
267                .collect(),
268            serde_json::Value::String(s) => {
269                if s.is_empty() {
270                    vec![]
271                } else {
272                    vec![s.clone()]
273                }
274            }
275            _ => {
276                warn!(
277                    claim_name,
278                    "OIDC group claim has unexpected type; skipping group sync"
279                );
280                return None;
281            }
282        };
283
284        let groups: Vec<String> = raw_groups
285            .into_iter()
286            .filter(|g| !g.is_empty())
287            .collect::<BTreeSet<_>>()
288            .into_iter()
289            .collect();
290
291        Some(groups)
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        // Case is preserved; "Analytics" and "analytics" are distinct groups.
756        assert_eq!(
757            claims.groups("groups"),
758            Some(vec![
759                "Analytics".to_string(),
760                "PLATFORM_ENG".to_string(),
761                "analytics".to_string(),
762            ])
763        );
764    }
765
766    #[mz_ore::test]
767    fn test_groups_custom_claim_name() {
768        let json =
769            r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","roles":["admin","viewer"]}"#;
770        let claims: OidcClaims = serde_json::from_str(json).unwrap();
771        assert_eq!(
772            claims.groups("roles"),
773            Some(vec!["admin".to_string(), "viewer".to_string()])
774        );
775        assert_eq!(claims.groups("groups"), None);
776    }
777
778    #[mz_ore::test]
779    fn test_groups_non_string_values_in_array() {
780        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["valid",123,true,"also_valid"]}"#;
781        let claims: OidcClaims = serde_json::from_str(json).unwrap();
782        assert_eq!(
783            claims.groups("groups"),
784            Some(vec!["also_valid".to_string(), "valid".to_string()])
785        );
786    }
787
788    #[mz_ore::test]
789    fn test_groups_non_array_non_string() {
790        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":42}"#;
791        let claims: OidcClaims = serde_json::from_str(json).unwrap();
792        assert_eq!(claims.groups("groups"), None);
793    }
794
795    #[mz_ore::test]
796    fn test_groups_empty_string() {
797        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":""}"#;
798        let claims: OidcClaims = serde_json::from_str(json).unwrap();
799        assert_eq!(claims.groups("groups"), Some(vec![]));
800    }
801
802    #[mz_ore::test]
803    fn test_groups_null_claim() {
804        // Explicit null → treated as absent (not a valid group representation)
805        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":null}"#;
806        let claims: OidcClaims = serde_json::from_str(json).unwrap();
807        assert_eq!(claims.groups("groups"), None);
808    }
809
810    #[mz_ore::test]
811    fn test_groups_boolean_claim() {
812        // Boolean value → not array or string, treated as absent
813        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":true}"#;
814        let claims: OidcClaims = serde_json::from_str(json).unwrap();
815        assert_eq!(claims.groups("groups"), None);
816    }
817
818    #[mz_ore::test]
819    fn test_groups_object_claim() {
820        // JSON object → not array or string, treated as absent
821        let json =
822            r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":{"team":"eng"}}"#;
823        let claims: OidcClaims = serde_json::from_str(json).unwrap();
824        assert_eq!(claims.groups("groups"), None);
825    }
826
827    #[mz_ore::test]
828    fn test_groups_array_all_non_strings() {
829        // Array with zero valid string elements → Some(vec![]), not None
830        // (the claim *is* present, it just has no usable group names)
831        let json =
832            r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[1,2,true,null]}"#;
833        let claims: OidcClaims = serde_json::from_str(json).unwrap();
834        assert_eq!(claims.groups("groups"), Some(vec![]));
835    }
836
837    #[mz_ore::test]
838    fn test_groups_array_with_nested_arrays() {
839        // Nested arrays are not strings, so they're filtered out
840        let json =
841            r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[["nested"],"valid"]}"#;
842        let claims: OidcClaims = serde_json::from_str(json).unwrap();
843        assert_eq!(claims.groups("groups"), Some(vec!["valid".to_string()]));
844    }
845
846    #[mz_ore::test]
847    fn test_groups_array_with_empty_strings() {
848        // Empty strings are not valid role names and are filtered out,
849        // consistent with the single-string case where "" → Some(vec![]).
850        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["","eng",""]}"#;
851        let claims: OidcClaims = serde_json::from_str(json).unwrap();
852        assert_eq!(claims.groups("groups"), Some(vec!["eng".to_string()]));
853    }
854
855    #[mz_ore::test]
856    fn test_groups_whitespace_only_single_string() {
857        // Whitespace-only string is preserved as-is (exact matching, no trim).
858        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":"  "}"#;
859        let claims: OidcClaims = serde_json::from_str(json).unwrap();
860        assert_eq!(claims.groups("groups"), Some(vec!["  ".to_string()]));
861    }
862
863    #[mz_ore::test]
864    fn test_groups_whitespace_names() {
865        // Leading/trailing whitespace is preserved (exact matching, no trim).
866        let json =
867            r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["  spaces  ","eng"]}"#;
868        let claims: OidcClaims = serde_json::from_str(json).unwrap();
869        assert_eq!(
870            claims.groups("groups"),
871            Some(vec!["  spaces  ".to_string(), "eng".to_string()])
872        );
873    }
874
875    #[mz_ore::test]
876    fn test_groups_unicode_names() {
877        // Unicode group names are preserved as-is (no case folding).
878        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["Développeurs","INGÉNIEURS"]}"#;
879        let claims: OidcClaims = serde_json::from_str(json).unwrap();
880        assert_eq!(
881            claims.groups("groups"),
882            Some(vec!["Développeurs".to_string(), "INGÉNIEURS".to_string()])
883        );
884    }
885
886    #[mz_ore::test]
887    fn test_groups_special_characters() {
888        // Group names with special characters (hyphens, underscores, dots)
889        // are common in enterprise IdPs like Azure AD / Okta
890        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["team-platform.eng","org_data-science","role/admin"]}"#;
891        let claims: OidcClaims = serde_json::from_str(json).unwrap();
892        assert_eq!(
893            claims.groups("groups"),
894            Some(vec![
895                "org_data-science".to_string(),
896                "role/admin".to_string(),
897                "team-platform.eng".to_string(),
898            ])
899        );
900    }
901
902    #[mz_ore::test]
903    fn test_groups_no_case_folding() {
904        // Case is preserved; "Eng", "eng", "ENG", "eNg" are four distinct groups.
905        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["Eng","eng","ENG","eNg"]}"#;
906        let claims: OidcClaims = serde_json::from_str(json).unwrap();
907        assert_eq!(
908            claims.groups("groups"),
909            Some(vec![
910                "ENG".to_string(),
911                "Eng".to_string(),
912                "eNg".to_string(),
913                "eng".to_string(),
914            ])
915        );
916    }
917
918    #[mz_ore::test]
919    fn test_groups_large_array() {
920        // Verify we handle a reasonably large group list without issues
921        let groups: Vec<String> = (0..100).map(|i| format!("\"group_{}\"", i)).collect();
922        let json = format!(
923            r#"{{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":[{}]}}"#,
924            groups.join(",")
925        );
926        let claims: OidcClaims = serde_json::from_str(&json).unwrap();
927        let result = claims.groups("groups").unwrap();
928        assert_eq!(result.len(), 100);
929        // BTreeSet ensures sorted order
930        assert_eq!(result[0], "group_0");
931        assert_eq!(result[99], "group_99");
932    }
933
934    #[mz_ore::test]
935    fn test_groups_float_claim() {
936        // Float value → not array or string, treated as absent
937        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":3.14}"#;
938        let claims: OidcClaims = serde_json::from_str(json).unwrap();
939        assert_eq!(claims.groups("groups"), None);
940    }
941
942    #[mz_ore::test]
943    fn test_groups_array_with_null_elements() {
944        // Null elements in array are not strings, filtered out
945        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["eng",null,"ops",null]}"#;
946        let claims: OidcClaims = serde_json::from_str(json).unwrap();
947        assert_eq!(
948            claims.groups("groups"),
949            Some(vec!["eng".to_string(), "ops".to_string()])
950        );
951    }
952
953    #[mz_ore::test]
954    fn test_groups_array_with_object_elements() {
955        // Object elements in array are not strings, filtered out
956        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["eng",{"name":"ops"},"analytics"]}"#;
957        let claims: OidcClaims = serde_json::from_str(json).unwrap();
958        assert_eq!(
959            claims.groups("groups"),
960            Some(vec!["analytics".to_string(), "eng".to_string()])
961        );
962    }
963
964    #[mz_ore::test]
965    fn test_groups_sorted_output() {
966        // Verify output is sorted alphabetically regardless of input order
967        let json = r#"{"sub":"user","iss":"issuer","exp":1234,"aud":"app","groups":["zebra","alpha","mango","beta"]}"#;
968        let claims: OidcClaims = serde_json::from_str(json).unwrap();
969        assert_eq!(
970            claims.groups("groups"),
971            Some(vec![
972                "alpha".to_string(),
973                "beta".to_string(),
974                "mango".to_string(),
975                "zebra".to_string(),
976            ])
977        );
978    }
979}