Skip to main content

kellnr_auth/
oauth2.rs

1//! OAuth2/OpenID Connect authentication handler
2//!
3//! This module provides OAuth2/OIDC authentication support using the authorization
4//! code flow with PKCE.
5
6use std::future::Future;
7use std::sync::{Arc, Mutex};
8
9use kellnr_settings::OAuth2 as OAuth2Settings;
10use openidconnect::core::{
11    CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType,
12    CoreGenderClaim, CoreIdTokenClaims, CoreIdTokenVerifier, CoreJsonWebKey,
13    CoreJweContentEncryptionAlgorithm, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse,
14    CoreTokenResponse,
15};
16use openidconnect::{
17    AuthorizationCode, ClaimsVerificationError, ClientId, ClientSecret, CsrfToken,
18    EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce,
19    PkceCodeChallenge, PkceCodeVerifier, ProviderMetadataWithLogout, RedirectUrl, Scope,
20    TokenResponse,
21};
22use serde::{Deserialize, Serialize};
23use thiserror::Error;
24use tracing::{info, trace, warn};
25use url::Url;
26
27use crate::oidc_http::{OidcHttpClient, error_chain};
28
29/// Type alias for the configured OIDC client after provider discovery
30type ConfiguredCoreClient = openidconnect::Client<
31    EmptyAdditionalClaims,
32    CoreAuthDisplay,
33    CoreGenderClaim,
34    CoreJweContentEncryptionAlgorithm,
35    CoreJsonWebKey,
36    CoreAuthPrompt,
37    openidconnect::StandardErrorResponse<CoreErrorResponseType>,
38    CoreTokenResponse,
39    CoreTokenIntrospectionResponse,
40    openidconnect::core::CoreRevocableToken,
41    CoreRevocationErrorResponse,
42    EndpointSet,      // HasAuthUrl - set by from_provider_metadata
43    EndpointNotSet,   // HasDeviceAuthUrl
44    EndpointNotSet,   // HasIntrospectionUrl
45    EndpointNotSet,   // HasRevocationUrl
46    EndpointMaybeSet, // HasTokenUrl - maybe set by provider
47    EndpointMaybeSet, // HasUserInfoUrl - maybe set by provider
48>;
49
50/// Errors that can occur during OAuth2/OIDC operations
51#[derive(Debug, Error)]
52pub enum OAuth2Error {
53    #[error("OAuth2 is not enabled")]
54    NotEnabled,
55
56    #[error("OAuth2 configuration is invalid: {0}")]
57    ConfigurationError(String),
58
59    #[error("Failed to discover OIDC provider: {0}")]
60    DiscoveryError(String),
61
62    #[error("Failed to parse URL: {0}")]
63    UrlParseError(#[from] url::ParseError),
64
65    #[error("Failed to exchange authorization code: {0}")]
66    TokenExchangeError(String),
67
68    #[error("Failed to verify ID token: {0}")]
69    TokenVerificationError(String),
70
71    #[error("Missing required claim: {0}")]
72    MissingClaim(String),
73
74    #[error("HTTP request failed: {0}")]
75    HttpError(String),
76}
77
78/// Information extracted from the OIDC ID token
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct UserInfo {
81    /// The subject claim (unique user identifier from the provider)
82    pub subject: String,
83    /// Email address (if available)
84    pub email: Option<String>,
85    /// Preferred username (if available)
86    pub preferred_username: Option<String>,
87    /// Groups the user belongs to (from the configured claim)
88    pub groups: Vec<String>,
89    /// Whether the user should be an admin (derived from group claims)
90    pub is_admin: bool,
91    /// Whether the user should be read-only (derived from group claims)
92    pub is_read_only: bool,
93}
94
95/// Request data for initiating `OAuth2` authentication
96#[derive(Debug)]
97pub struct AuthRequest {
98    /// The URL to redirect the user to for authentication
99    pub auth_url: Url,
100    /// CSRF protection state (to be stored in database)
101    pub state: String,
102    /// PKCE verifier (to be stored in database for code exchange)
103    pub pkce_verifier: String,
104    /// Nonce for ID token verification (to be stored in database)
105    pub nonce: String,
106}
107
108/// Token response from the `OAuth2` provider
109#[derive(Debug)]
110pub struct TokenResult {
111    /// The standard ID token claims
112    pub claims: CoreIdTokenClaims,
113    /// Raw JWT payload for extracting additional claims
114    pub raw_payload: serde_json::Value,
115    /// Raw ID token JWT, used as `id_token_hint` for RP-initiated logout
116    pub id_token: String,
117}
118
119/// OAuth2/OIDC authentication handler
120pub struct OAuth2Handler {
121    /// The configured OIDC client, wrapped so it can be rebuilt when the
122    /// provider rotates its signing keys (see [`Self::rediscover`]).
123    client: Mutex<Arc<ConfiguredCoreClient>>,
124    client_id: ClientId,
125    client_secret: Option<ClientSecret>,
126    redirect_url: RedirectUrl,
127    settings: Arc<OAuth2Settings>,
128    issuer_url: IssuerUrl,
129    http_client: OidcHttpClient,
130    end_session_endpoint: Mutex<Arc<Option<String>>>,
131}
132
133impl OAuth2Handler {
134    /// Create a new `OAuth2Handler` using OIDC discovery
135    ///
136    /// This performs automatic discovery of the OIDC provider's configuration
137    /// using the well-known endpoint.
138    pub async fn from_discovery(
139        settings: &OAuth2Settings,
140        redirect_url: &str,
141    ) -> Result<Self, OAuth2Error> {
142        if !settings.enabled {
143            return Err(OAuth2Error::NotEnabled);
144        }
145
146        // Validate configuration
147        settings
148            .validate()
149            .map_err(OAuth2Error::ConfigurationError)?;
150
151        let issuer_url_str = settings
152            .issuer_url
153            .as_ref()
154            .ok_or_else(|| OAuth2Error::ConfigurationError("Missing issuer_url".to_string()))?;
155
156        let issuer_url = IssuerUrl::new(issuer_url_str.clone())
157            .map_err(|e| OAuth2Error::ConfigurationError(format!("Invalid issuer URL: {e}")))?;
158
159        info!("Discovering OIDC provider at: {}", issuer_url_str);
160
161        let http_client =
162            OidcHttpClient::new().map_err(|e| OAuth2Error::HttpError(e.to_string()))?;
163
164        let client_id = ClientId::new(
165            settings
166                .client_id
167                .clone()
168                .ok_or_else(|| OAuth2Error::ConfigurationError("Missing client_id".to_string()))?,
169        );
170
171        let client_secret = settings.client_secret.clone().map(ClientSecret::new);
172
173        let redirect_url = RedirectUrl::new(redirect_url.to_string())?;
174
175        // Perform OIDC discovery and build the client from provider metadata.
176        // The same discovery fetch also yields the optional RP-initiated logout
177        // endpoint, which the openidconnect client does not expose.
178        let (client, end_session_endpoint) = Self::discover_client(
179            &issuer_url,
180            &client_id,
181            client_secret.as_ref(),
182            &redirect_url,
183            &http_client,
184        )
185        .await?;
186
187        Ok(Self {
188            client: Mutex::new(Arc::new(client)),
189            client_id,
190            client_secret,
191            redirect_url,
192            settings: Arc::new(settings.clone()),
193            issuer_url,
194            http_client,
195            end_session_endpoint: Mutex::new(Arc::new(end_session_endpoint)),
196        })
197    }
198
199    /// Perform OIDC discovery and build a configured client from the fetched
200    /// provider metadata (which includes the current JWKS signing keys). Also
201    /// returns the optional `end_session_endpoint` (RP-Initiated Logout 1.0)
202    /// read from the same discovery document.
203    async fn discover_client(
204        issuer_url: &IssuerUrl,
205        client_id: &ClientId,
206        client_secret: Option<&ClientSecret>,
207        redirect_url: &RedirectUrl,
208        http_client: &OidcHttpClient,
209    ) -> Result<(ConfiguredCoreClient, Option<String>), OAuth2Error> {
210        let provider_metadata =
211            ProviderMetadataWithLogout::discover_async(issuer_url.clone(), http_client)
212                .await
213                .map_err(|e| OAuth2Error::DiscoveryError(error_chain(&e)))?;
214
215        let end_session_endpoint = provider_metadata
216            .additional_metadata()
217            .end_session_endpoint
218            .as_ref()
219            .map(ToString::to_string);
220
221        let client = CoreClient::from_provider_metadata(
222            provider_metadata,
223            client_id.clone(),
224            client_secret.cloned(),
225        )
226        .set_redirect_uri(redirect_url.clone());
227
228        Ok((client, end_session_endpoint))
229    }
230
231    /// Build the RP-initiated logout URL to end the provider session, or `None`
232    /// if the provider does not advertise an `end_session_endpoint`.
233    pub fn end_session_url(
234        &self,
235        id_token_hint: &str,
236        post_logout_redirect_uri: &str,
237    ) -> Option<String> {
238        let endpoint = self
239            .end_session_endpoint
240            .lock()
241            .expect("OAuth2 end session endpoint mutex poisoned");
242        let mut url = Url::parse(endpoint.as_ref().as_ref()?).ok()?;
243        url.query_pairs_mut()
244            .append_pair("id_token_hint", id_token_hint)
245            .append_pair("client_id", self.client_id.as_str())
246            .append_pair("post_logout_redirect_uri", post_logout_redirect_uri);
247        Some(url.into())
248    }
249
250    fn current_client(&self) -> Arc<ConfiguredCoreClient> {
251        self.client
252            .lock()
253            .expect("OAuth2 client mutex poisoned")
254            .clone()
255    }
256
257    /// Re-run OIDC discovery to obtain a client with a fresh JWKS.
258    ///
259    /// Used to recover from provider signing-key rotation, after which the
260    /// keys cached at startup can no longer verify newly issued tokens.
261    async fn rediscover(
262        &self,
263    ) -> Result<(Arc<ConfiguredCoreClient>, Arc<Option<String>>), OAuth2Error> {
264        Self::discover_client(
265            &self.issuer_url,
266            &self.client_id,
267            self.client_secret.as_ref(),
268            &self.redirect_url,
269            &self.http_client,
270        )
271        .await
272        .map(|(client, end_session_endpoint)| (Arc::new(client), Arc::new(end_session_endpoint)))
273    }
274
275    /// Build the ID token verifier for `client`.
276    ///
277    /// An ID token may contain multiple audiences. The configured client ID is
278    /// always required; any further audience is accepted only when explicitly
279    /// allowlisted via `oauth2.additional_audiences`.
280    fn id_token_verifier<'a>(&self, client: &'a ConfiguredCoreClient) -> CoreIdTokenVerifier<'a> {
281        let additional_audiences = self.settings.additional_audiences.clone();
282        client
283            .id_token_verifier()
284            .set_other_audience_verifier_fn(move |aud| {
285                additional_audiences
286                    .iter()
287                    .any(|allowed| allowed == aud.as_str())
288            })
289    }
290
291    /// Generate an authorization URL for the `OAuth2` flow
292    ///
293    /// Returns an `AuthRequest` containing the URL to redirect the user to,
294    /// along with the state, PKCE verifier, and nonce that must be stored
295    /// for later verification.
296    pub fn generate_auth_url(&self) -> AuthRequest {
297        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
298
299        let client = self.current_client();
300
301        // Build the authorization request with configured scopes
302        let mut auth_request = client
303            .authorize_url(
304                CoreAuthenticationFlow::AuthorizationCode,
305                CsrfToken::new_random,
306                Nonce::new_random,
307            )
308            .set_pkce_challenge(pkce_challenge);
309
310        // Add configured scopes
311        for scope in &self.settings.scopes {
312            auth_request = auth_request.add_scope(Scope::new(scope.clone()));
313        }
314
315        let (auth_url, csrf_state, nonce) = auth_request.url();
316
317        AuthRequest {
318            auth_url,
319            state: csrf_state.secret().clone(),
320            pkce_verifier: pkce_verifier.secret().clone(),
321            nonce: nonce.secret().clone(),
322        }
323    }
324
325    /// Exchange an authorization code for tokens and validate the ID token
326    ///
327    /// # Arguments
328    /// * `code` - The authorization code received from the provider
329    /// * `pkce_verifier` - The PKCE verifier stored during `generate_auth_url`
330    /// * `nonce` - The nonce stored during `generate_auth_url`
331    ///
332    /// # Returns
333    /// The validated ID token claims
334    pub async fn exchange_and_validate(
335        &self,
336        code: &str,
337        pkce_verifier: &str,
338        nonce: &str,
339    ) -> Result<TokenResult, OAuth2Error> {
340        let code = AuthorizationCode::new(code.to_string());
341        let pkce_verifier = PkceCodeVerifier::new(pkce_verifier.to_string());
342
343        let client = self.current_client();
344
345        let token_request = client
346            .exchange_code(code)
347            .map_err(|e| OAuth2Error::TokenExchangeError(e.to_string()))?;
348
349        let token_response: CoreTokenResponse = token_request
350            .set_pkce_verifier(pkce_verifier)
351            .request_async(&self.http_client)
352            .await
353            .map_err(|e| OAuth2Error::TokenExchangeError(error_chain(&e)))?;
354
355        // Get and validate the ID token
356        let id_token = token_response
357            .id_token()
358            .ok_or_else(|| OAuth2Error::MissingClaim("id_token".to_string()))?;
359
360        let nonce = Nonce::new(nonce.to_string());
361
362        let verifier = self.id_token_verifier(&client);
363
364        let claims = match id_token.claims(&verifier, &nonce) {
365            Ok(claims) => claims.clone(),
366            // A signature failure typically means the provider rotated its
367            // signing keys since discovery, so the JWKS cached at startup is
368            // stale. Re-fetch it once and retry before giving up.
369            Err(ClaimsVerificationError::SignatureVerification(_)) => {
370                trace!(
371                    "ID token signature verification failed; refreshing OIDC JWKS and retrying (provider may have rotated signing keys)"
372                );
373                let (refreshed_client, refreshed_end_session_endpoint) = self.rediscover().await?;
374                *self
375                    .end_session_endpoint
376                    .lock()
377                    .expect("OAuth2 end session endpoint mutex poisoned") =
378                    refreshed_end_session_endpoint;
379                // Scoped so the verifier's borrow of `refreshed` ends before
380                // it is moved into the cached client below.
381                let claims = {
382                    let verifier = self.id_token_verifier(&refreshed_client);
383                    id_token
384                        .claims(&verifier, &nonce)
385                        .map_err(|e| OAuth2Error::TokenVerificationError(e.to_string()))?
386                        .clone()
387                };
388                // Persist the refreshed client so subsequent logins reuse the
389                // new keys instead of re-fetching on every request.
390                *self.client.lock().expect("OAuth2 client mutex poisoned") = refreshed_client;
391                claims
392            }
393            Err(e) => return Err(OAuth2Error::TokenVerificationError(e.to_string())),
394        };
395
396        // Also extract raw payload for additional claims
397        let raw_payload = extract_jwt_payload(id_token.to_string().as_str())
398            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
399
400        Ok(TokenResult {
401            claims,
402            raw_payload,
403            id_token: id_token.to_string(),
404        })
405    }
406
407    /// Extract user information from ID token claims
408    ///
409    /// This extracts the subject, email, `preferred_username`, and group membership
410    /// based on the `OAuth2` settings configuration.
411    pub fn extract_user_info(&self, result: &TokenResult) -> UserInfo {
412        let subject = result.claims.subject().as_str().to_string();
413
414        let email = result.claims.email().map(|e| e.as_str().to_string());
415
416        let preferred_username = result
417            .claims
418            .preferred_username()
419            .map(|u| u.as_str().to_string());
420
421        // Extract groups from raw JWT payload
422        let groups = self.extract_groups(&result.raw_payload);
423
424        // Privileges are only derived from the configured claims, never from
425        // the fallback group claims above.
426        let is_admin = claim_contains(
427            &result.raw_payload,
428            self.settings.admin_group_claim.as_deref(),
429            self.settings.admin_group_value.as_deref(),
430        );
431        let is_read_only = claim_contains(
432            &result.raw_payload,
433            self.settings.read_only_group_claim.as_deref(),
434            self.settings.read_only_group_value.as_deref(),
435        );
436
437        UserInfo {
438            subject,
439            email,
440            preferred_username,
441            groups,
442            is_admin,
443            is_read_only,
444        }
445    }
446
447    /// Extract groups from the raw JWT payload
448    fn extract_groups(&self, payload: &serde_json::Value) -> Vec<String> {
449        // First try the configured admin group claim if it exists
450        if let Some(claim_name) = &self.settings.admin_group_claim
451            && let Some(groups) = get_string_array_from_json(payload, claim_name)
452        {
453            return groups;
454        }
455
456        // Then try the configured read-only group claim if different
457        if let Some(claim_name) = &self.settings.read_only_group_claim
458            && self.settings.admin_group_claim.as_ref() != Some(claim_name)
459            && let Some(groups) = get_string_array_from_json(payload, claim_name)
460        {
461            return groups;
462        }
463
464        // Try common group claim names
465        for claim_name in &["groups", "roles", "group"] {
466            if let Some(groups) = get_string_array_from_json(payload, claim_name) {
467                return groups;
468            }
469        }
470
471        Vec::new()
472    }
473
474    /// Generate a unique username for auto-provisioning
475    ///
476    /// Priority:
477    /// 1. `preferred_username` claim
478    /// 2. Local part of email (before @)
479    /// 3. Subject claim
480    pub fn generate_username(user_info: &UserInfo) -> String {
481        if let Some(username) = &user_info.preferred_username
482            && !username.is_empty()
483        {
484            return sanitize_username_with_dots(username);
485        }
486
487        if let Some(email) = &user_info.email
488            && let Some(local_part) = email.split('@').next()
489            && !local_part.is_empty()
490        {
491            return sanitize_username_with_dots(local_part);
492        }
493
494        sanitize_username(&user_info.subject)
495    }
496
497    /// Get the issuer URL string
498    pub fn issuer_url(&self) -> &str {
499        self.issuer_url.as_str()
500    }
501
502    /// Get a reference to the settings
503    pub fn settings(&self) -> &OAuth2Settings {
504        &self.settings
505    }
506
507    /// Whether admin status is governed by an `IdP` group claim.
508    ///
509    /// Only when both the claim name and value are configured does the `IdP`
510    /// act as the source of truth for a user's admin state. Otherwise the
511    /// extracted `is_admin` is always `false` and must not be used to
512    /// overwrite an existing user's state.
513    pub fn admin_claim_configured(&self) -> bool {
514        self.settings.admin_group_claim.is_some() && self.settings.admin_group_value.is_some()
515    }
516
517    /// Whether read-only status is governed by an `IdP` group claim.
518    ///
519    /// See [`Self::admin_claim_configured`]; the same source-of-truth rule
520    /// applies to the read-only flag.
521    pub fn read_only_claim_configured(&self) -> bool {
522        self.settings.read_only_group_claim.is_some()
523            && self.settings.read_only_group_value.is_some()
524    }
525}
526
527/// Extract and decode the payload from a JWT
528fn extract_jwt_payload(jwt: &str) -> Result<serde_json::Value, OAuth2Error> {
529    use base64::Engine;
530
531    let parts: Vec<&str> = jwt.split('.').collect();
532    if parts.len() != 3 {
533        return Err(OAuth2Error::TokenVerificationError(
534            "Invalid JWT format".to_string(),
535        ));
536    }
537
538    let payload_b64 = parts[1];
539    let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
540        .decode(payload_b64)
541        .map_err(|e| OAuth2Error::TokenVerificationError(format!("Base64 decode error: {e}")))?;
542
543    serde_json::from_slice(&payload_bytes)
544        .map_err(|e| OAuth2Error::TokenVerificationError(format!("JSON parse error: {e}")))
545}
546
547/// Get a string array from a JSON value
548/// Whether the claim `claim_name` grants `claim_value`. The claim may be a
549/// string, a string array or a boolean (matched against `"true"`). Only the
550/// named claim is consulted: falling back to other claims such as `groups`
551/// would let any group of that name grant the privilege.
552fn claim_contains(
553    payload: &serde_json::Value,
554    claim_name: Option<&str>,
555    claim_value: Option<&str>,
556) -> bool {
557    let (Some(claim_name), Some(claim_value)) = (claim_name, claim_value) else {
558        return false;
559    };
560
561    if let Some(values) = get_string_array_from_json(payload, claim_name) {
562        return values.iter().any(|v| v == claim_value);
563    }
564
565    payload
566        .get(claim_name)
567        .and_then(serde_json::Value::as_bool)
568        .is_some_and(|b| b && claim_value.eq_ignore_ascii_case("true"))
569}
570
571fn get_string_array_from_json(payload: &serde_json::Value, name: &str) -> Option<Vec<String>> {
572    let value = payload.get(name)?;
573
574    // Try to parse as array of strings
575    if let Some(arr) = value.as_array() {
576        let strings: Vec<String> = arr
577            .iter()
578            .filter_map(serde_json::Value::as_str)
579            .map(String::from)
580            .collect();
581        if !strings.is_empty() {
582            return Some(strings);
583        }
584    }
585
586    // Try to parse as single string (some providers return single group as string)
587    if let Some(s) = value.as_str() {
588        return Some(vec![s.to_string()]);
589    }
590
591    None
592}
593
594/// Sanitize a username to be valid for Kellnr
595///
596/// - Converts to lowercase
597/// - Replaces invalid characters with underscores
598/// - Ensures it starts with a letter
599fn sanitize_username(input: &str) -> String {
600    sanitize_username_impl(input, false)
601}
602
603/// Same as `sanitize_username`, but preserves dots.
604fn sanitize_username_with_dots(input: &str) -> String {
605    sanitize_username_impl(input, true)
606}
607
608fn sanitize_username_impl(input: &str, allow_dot: bool) -> String {
609    let mut result: String = input
610        .chars()
611        .map(|c| {
612            if c.is_ascii_alphanumeric() || c == '_' || c == '-' || (allow_dot && c == '.') {
613                c.to_ascii_lowercase()
614            } else {
615                '_'
616            }
617        })
618        .collect();
619
620    // Ensure it starts with a letter
621    if result
622        .chars()
623        .next()
624        .is_none_or(|c| !c.is_ascii_alphabetic())
625    {
626        result = format!("u_{result}");
627    }
628
629    // Truncate if too long (max 64 chars is reasonable)
630    if result.len() > 64 {
631        result.truncate(64);
632    }
633
634    result
635}
636
637/// Generate a unique username with collision handling
638///
639/// If the base username is taken, appends _2, _3, etc.
640pub async fn generate_unique_username<F, Fut>(user_info: &UserInfo, is_available: F) -> String
641where
642    F: Fn(String) -> Fut,
643    Fut: Future<Output = bool>,
644{
645    let base = OAuth2Handler::generate_username(user_info);
646
647    // Try the base username first
648    if is_available(base.clone()).await {
649        return base;
650    }
651
652    // Try with numeric suffixes
653    for i in 2..=100 {
654        let candidate = format!("{base}_{i}");
655        if is_available(candidate.clone()).await {
656            return candidate;
657        }
658    }
659
660    // Fallback: use subject with timestamp (very unlikely to reach here)
661    warn!("Could not find unique username after 100 attempts, using fallback");
662    format!(
663        "{}_{:x}",
664        sanitize_username(&user_info.subject),
665        std::time::SystemTime::now()
666            .duration_since(std::time::UNIX_EPOCH)
667            .map_or(0, |d| d.as_secs())
668    )
669}
670
671#[cfg(test)]
672mod tests {
673    use serde_json::json;
674
675    use super::*;
676
677    #[test]
678    fn claim_contains_matches_configured_array_claim() {
679        let payload = json!({ "kellnr_roles": ["dev", "admin"] });
680
681        assert!(claim_contains(
682            &payload,
683            Some("kellnr_roles"),
684            Some("admin")
685        ));
686        assert!(!claim_contains(&payload, Some("kellnr_roles"), Some("ops")));
687    }
688
689    #[test]
690    fn claim_contains_matches_string_and_bool_claims() {
691        let payload = json!({ "role": "admin", "is_admin": true, "is_ro": false });
692
693        assert!(claim_contains(&payload, Some("role"), Some("admin")));
694        assert!(claim_contains(&payload, Some("is_admin"), Some("true")));
695        assert!(!claim_contains(&payload, Some("is_ro"), Some("true")));
696    }
697
698    /// The privilege must come from the configured claim only. A group of the
699    /// same name in `groups`, `roles` or `group` must not grant it.
700    #[test]
701    fn claim_contains_ignores_fallback_group_claims() {
702        let payload = json!({
703            "groups": ["kellnr-admins"],
704            "roles": ["kellnr-admins"],
705            "group": "kellnr-admins",
706        });
707
708        assert!(!claim_contains(
709            &payload,
710            Some("kellnr_roles"),
711            Some("kellnr-admins")
712        ));
713    }
714
715    /// With admin and read-only on different claims, the read-only claim
716    /// must not grant admin.
717    #[test]
718    fn claim_contains_does_not_mix_claims() {
719        let payload = json!({ "ro_groups": ["kellnr-admins"] });
720
721        assert!(!claim_contains(
722            &payload,
723            Some("admin_groups"),
724            Some("kellnr-admins")
725        ));
726    }
727
728    #[test]
729    fn claim_contains_requires_claim_and_value() {
730        let payload = json!({ "groups": ["admin"] });
731
732        assert!(!claim_contains(&payload, None, Some("admin")));
733        assert!(!claim_contains(&payload, Some("groups"), None));
734        assert!(claim_contains(&payload, Some("groups"), Some("admin")));
735    }
736
737    #[test]
738    fn test_sanitize_username() {
739        assert_eq!(sanitize_username("JohnDoe"), "johndoe");
740        assert_eq!(sanitize_username("john.doe"), "john_doe");
741        assert_eq!(sanitize_username("john@example.com"), "john_example_com");
742        assert_eq!(sanitize_username("123user"), "u_123user");
743        assert_eq!(sanitize_username("_user"), "u__user");
744        assert_eq!(sanitize_username("user-name"), "user-name");
745    }
746
747    #[test]
748    fn test_sanitize_username_with_dots() {
749        assert_eq!(sanitize_username_with_dots("john.doe"), "john.doe");
750        assert_eq!(
751            sanitize_username_with_dots("john@example.com"),
752            "john_example.com"
753        );
754    }
755
756    #[test]
757    fn test_generate_username_preferred() {
758        let user_info = UserInfo {
759            subject: "sub123".to_string(),
760            email: Some("john@example.com".to_string()),
761            preferred_username: Some("johndoe".to_string()),
762            groups: vec![],
763            is_admin: false,
764            is_read_only: false,
765        };
766        assert_eq!(OAuth2Handler::generate_username(&user_info), "johndoe");
767    }
768
769    #[test]
770    fn test_generate_username_preferred_preserves_dot() {
771        let user_info = UserInfo {
772            subject: "sub123".to_string(),
773            email: Some("john@example.com".to_string()),
774            preferred_username: Some("john.doe".to_string()),
775            groups: vec![],
776            is_admin: false,
777            is_read_only: false,
778        };
779        assert_eq!(OAuth2Handler::generate_username(&user_info), "john.doe");
780    }
781
782    #[test]
783    fn test_generate_username_email() {
784        let user_info = UserInfo {
785            subject: "sub123".to_string(),
786            email: Some("john@example.com".to_string()),
787            preferred_username: None,
788            groups: vec![],
789            is_admin: false,
790            is_read_only: false,
791        };
792        assert_eq!(OAuth2Handler::generate_username(&user_info), "john");
793    }
794
795    #[test]
796    fn test_generate_username_email_preserves_dot_in_local_part() {
797        let user_info = UserInfo {
798            subject: "sub123".to_string(),
799            email: Some("john.doe@example.com".to_string()),
800            preferred_username: None,
801            groups: vec![],
802            is_admin: false,
803            is_read_only: false,
804        };
805        assert_eq!(OAuth2Handler::generate_username(&user_info), "john.doe");
806    }
807
808    #[test]
809    fn test_generate_username_subject() {
810        let user_info = UserInfo {
811            subject: "sub123".to_string(),
812            email: None,
813            preferred_username: None,
814            groups: vec![],
815            is_admin: false,
816            is_read_only: false,
817        };
818        // "sub123" starts with 's' which is a letter, so no prefix needed
819        assert_eq!(OAuth2Handler::generate_username(&user_info), "sub123");
820    }
821
822    #[tokio::test]
823    async fn test_generate_unique_username() {
824        let user_info = UserInfo {
825            subject: "sub123".to_string(),
826            email: Some("john@example.com".to_string()),
827            preferred_username: Some("johndoe".to_string()),
828            groups: vec![],
829            is_admin: false,
830            is_read_only: false,
831        };
832
833        // First username is available
834        let username = generate_unique_username(&user_info, |_| async { true }).await;
835        assert_eq!(username, "johndoe");
836
837        // First username is taken, second is available
838        let username =
839            generate_unique_username(&user_info, |name| async move { name != "johndoe" }).await;
840        assert_eq!(username, "johndoe_2");
841
842        // First two are taken
843        let username = generate_unique_username(&user_info, |name| async move {
844            name != "johndoe" && name != "johndoe_2"
845        })
846        .await;
847        assert_eq!(username, "johndoe_3");
848    }
849
850    #[test]
851    fn test_extract_jwt_payload() {
852        // A test JWT with payload: {"sub":"1234567890","name":"John Doe","iat":1516239022}
853        let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
854        let payload = extract_jwt_payload(jwt).unwrap();
855        assert_eq!(
856            payload.get("sub").and_then(|v| v.as_str()),
857            Some("1234567890")
858        );
859        assert_eq!(
860            payload.get("name").and_then(|v| v.as_str()),
861            Some("John Doe")
862        );
863    }
864
865    #[test]
866    fn test_get_string_array_from_json() {
867        let payload = serde_json::json!({
868            "groups": ["admin", "users"],
869            "single_group": "single",
870            "number": 42
871        });
872
873        assert_eq!(
874            get_string_array_from_json(&payload, "groups"),
875            Some(vec!["admin".to_string(), "users".to_string()])
876        );
877        assert_eq!(
878            get_string_array_from_json(&payload, "single_group"),
879            Some(vec!["single".to_string()])
880        );
881        assert_eq!(get_string_array_from_json(&payload, "number"), None);
882        assert_eq!(get_string_array_from_json(&payload, "missing"), None);
883    }
884}
885
886/// End-to-end tests against a mock OIDC provider.
887///
888/// These spin up a minimal in-process OIDC provider (discovery, JWKS and token
889/// endpoints) whose active signing key can be swapped at runtime, mirroring a
890/// provider like Dex rotating its keys, and whose ID token audiences can be
891/// varied to cover multi-audience providers such as Zitadel.
892#[cfg(test)]
893mod provider_tests {
894    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
895
896    use axum::extract::State;
897    use axum::routing::{get, post};
898    use axum::{Json, Router};
899    use chrono::{Duration, Utc};
900    use openidconnect::core::{
901        CoreIdToken, CoreJsonWebKeySet, CoreJwsSigningAlgorithm, CoreRsaPrivateSigningKey,
902    };
903    use openidconnect::{
904        Audience, EmptyAdditionalClaims, EndUserEmail, JsonWebKeyId, PrivateSigningKey,
905        StandardClaims, SubjectIdentifier,
906    };
907    use serde_json::{Value, json};
908    use tokio::net::TcpListener;
909    use tokio::task::JoinHandle;
910
911    use super::*;
912
913    // Two distinct 2048-bit RSA keys (PKCS#1 PEM) used to simulate key rotation.
914    const KEY_A_PEM: &str = "-----BEGIN RSA PRIVATE KEY-----
915MIIEpAIBAAKCAQEAtZ/uvzCiQirPAQf0w4piStS+zUlL6A3/ZTpTOJCpnRHVtnfh
916EPbqtQ+ohvkB8txKg2nlgDZ5+zS62jtvb2BYzwkH5qU4jlpDEOnwKVtTTjOrCWZx
917a8DVihgvcVkrYREOMRPzlXbF2aACwQAaveM8sbfu0cZ49SmQ34RQNqA8Gs+v/Nmr
918jYwwZ9rQ4XIBYE689TiBjtZJVvQa7I8Waj8euYxWP5rB1tpZVtgz2AARTn5iSgNX
919KM40IaMWLcvVUajPgYdcHE75+GLCzQAeEY4tboYo8ehFuMuehizj5sHz8ghz6reU
920D83gE5wlLndJj/c/9apvvyBYmvWyar5HIPcvEQIDAQABAoIBAAEXqMi+82H6h4JM
921duFvLowyMMlgFQr7vldpSPuSYxBEhN2YoBtJOr34T5VXrK0rPAELDzNduEmepRoK
922KHpiqSd1EJsAPVGLeWkxecPD29eoKFRjcdLT/l30ZdQVJZOa5lP+0ByGpUmhTw1y
923J98/24TXcbRjGtUsQ+4F1rnYIRVVY+RsrtWs2IaLHR8RnI6M3rFjY7y5dKX9x3Wn
924ERC1Nx7blrUMvzK1052qUTC6sCYku2nJXUKBylj095GVZpK9oje62J5c9+SSvuOQ
925MFEX0JSXfpCvoReP+9Ywa+vf+jpywja7hAIbH+bAqEnWNdrsuicTbUaRM39D1MB1
926XbKoemkCgYEA4wSokLEgQggsQ+HEjr5cTXE/dDjokp3CuuXAJg0mAhLWZro3PZnL
927dOElavpRwP4ISt3Lj47CJWcQAPGzl+BuGKPyQkRPhFkpBsUCdxgBi1eA4Suq7xa+
928ARhMEN9Ayfszh2xLD/dRgOh/cVi/x7N3IfihJ9Jd9L3p4TTCjngUC5UCgYEAzM+9
929DtPCdsNfiWtqtK/YolHUMZo3mb8Vu0e3fecIgUH3neOm/XhtxJY2wHehemyFJkYE
930f93Vp50GXEmmmPVnjXuYmrIk3jEjk3j4hn7/gi37EzNzgbSf1RN09swU31FJ5N+R
931/XZDH5zA+ZRIKU+CW8ap4cDTa1hBJNjVLqW2Fo0CgYEA1AFetjlkCaZ+SCqIGFI3
932+u5+trgKohmIaGf1CNQQobEb3rWarwF4Wr+D5SK9xIC4F8qHtpo4Pxu/e1I9SOGD
933j6lTrYUDyXJGeRb01Wlqz8k5B49zQ3K2oGkjaEJFzBq2pYqBkviBeeQmWCDsgL/d
934yrDZN0ojClNtHi7aXphPB/0CgYEAr5nVYOcSrjzopqvgezbhqJo8MqMk1L9O5Jmi
935q2HwmtJyeX78aApfItQf8XkgjBSLPLt/lBog22r4TxweqLqPpHC58LiYf6Dl/cUU
936YEx2yaiewmG0wRqah1f9SrTDmIzbrE47n3NMLch6dAI8tJ6lCAcXFKX9HuY2RF9c
937uHf/3OkCgYA2KePOq4vGOGD+zKDFrI7PSJcEa62oczZidgOylc/Lr9qx2Oiz4HM9
938Di85C5B3y/3ZnYf8+1nTPdvfqTPt4aMjJKBVVWvkEA5yPYmY5FY+Z3sAA4AzGZ8Z
939Xx2ZJaH0xsmAtrhSK8StLDiXayT8ygyZnqXfbZS0nG2HX3tKksYi0Q==
940-----END RSA PRIVATE KEY-----";
941
942    const KEY_B_PEM: &str = "-----BEGIN RSA PRIVATE KEY-----
943MIIEowIBAAKCAQEArOXZ5D7VxWmRkijS8CXQznmFL5bf3XDJ/Vll1imhJjU/sOJP
944aob5U40xIV+b0LbNhxP8C3lu+TKF3xldvv7lYTjXYfVZ3fwjfBMe6Vm5PYr31FiR
945KQLNrTrBE2cTCDoCn51NMERw/fdt0zJmfDcA77o1T6us2jxnDlrddiWYyBs26Y8p
946dYFacxObDsJwqmE60ijP92SR37NjMdwUNDg1Bzh6xw3puAP4KeFwWNNVPHwQpJEd
947pKX3T+CL2Ybfboyh9XCo2FmawfWjoIgUVoecgdpDMgNqtPjQ2HUvsH5JsWCezduE
948MW7sPloZr9doGnYVycuBCKOldwriCFJqjw2VlwIDAQABAoIBAD9lhohFLABXbcuw
949kWwCCbbz4wyon0xko4P0qD0nhZHre3+h8+nFNR3YSzgIBSu6I9GQV95jN/hC+Mht
9501iyG7VfBTmR6YOnfHqnLnw2EW0KANtBTa2KkxwLqZMp3BIkDMFwTgy6cIexVshz7
951QY3xYzQDzLF6awaYmFcwpTzBm2xfxmsjDODnMeeq4YraC/qrLn6yxFC3jH4H5fCu
952mlzYrGHAEN7mdIOQEktJXwyEzi0rVKHDfJEhStGS7gMILw7BZfFSQxnzXyHJEOt7
9533ZJY40N23H2iKaqN3s1Af56PbOcd9xmv3KM0lcmrmp2/cpoZTGSQgHNLZXB8bQ76
954L+u/WyECgYEA8hfrlpn1O66Jh7JKNl8iLFoiBGBozsyZrx0KP0yyFJ4apk7U6GzE
9557dnZJQ6sXklyE1gHSjArtICDnF8I26Jovl7EfijbQr+E4zuQhcZG3vuBzF0/8hy/
956D//QLKXvokqevEvYnt7V81Io17K80R11U5Elxhl1YwxKOMd468MUr3cCgYEAttRh
9576baK/NgktXkDhOf5Mmcz1MtUO+gop8lpOQifcPslE//6M1DE3G+6oRSZuTApSP5o
958NjwhHbu0ko/zbWWvJ760bdcv98R6z60AHFq3TZrG5R4f68o4aD+Llu1mLt26QppP
959vnby8CcCbcvCBXM4xzrORp7LOFs7fx8IsJL/EuECgYAepEZuVmhCuJGxujBId3wU
960zwe1NBqv6hedoXhVkLiNgYFwAVRTYsj+Le+dECFjAbrNlfu+OSCfBREjbqfpXMIT
961Ll2CdltiNGl2dWiSdgksWfsQydC5LUhFlyRbMlmFWhqDTLpLSXsdBA4lVvstIKRk
962AmAclcZU1g5i52R3usZYewKBgGPCn2qXYF920RD0ZycuLJuEFJQYHm5Rz10+WVv1
963iIptf13aXvuBJunhTUR3qSmTTfO2Xca6KJfAxFb0lS8sPNbDCFCnClV3sEuBan4O
964QdbmjYCXX3OZdA3uHozMHOWVtvyAluKmpQjFQF4IwGWY6XJMdCG0o86seVpz5Jn0
965m5oBAoGBAL/uJIrh8ZiL1eQrhJTitkW7BgV6iTN518Pf/I9DzOMnq/kG3W7zhQ2m
966lJZsY5m/jNCs0KrTBBEoVQzkPVlzoQ/dDYZyA2cj4LydXyuM76yth8F+DMr3ckN6
967Y4dOyrc/PytM2BLxs06WhIWeneUpz64RtUlvZUrSDgnO5AQX0ba2
968-----END RSA PRIVATE KEY-----";
969
970    const CLIENT_ID: &str = "test-client";
971    const OTHER_AUDIENCE: &str = "other-audience";
972    const SUBJECT: &str = "user-subject-123";
973    const NONCE: &str = "test-nonce";
974    const REDIRECT_URI: &str = "http://localhost/cb";
975
976    const END_SESSION_ENDPOINT_A: &str = "http://logout-a";
977    const END_SESSION_ENDPOINT_B: &str = "http://logout-b";
978
979    /// State shared with the mock provider's request handlers.
980    struct MockProvider {
981        issuer: String,
982        key_a: CoreRsaPrivateSigningKey,
983        key_b: CoreRsaPrivateSigningKey,
984        end_session_endpoint_a: String,
985        end_session_endpoint_b: String,
986        /// When `true`, the provider signs tokens with (and advertises) `key_b` and
987        /// advertises `end_session_endpoint_b` as the end session endpoint.
988        rotated: AtomicBool,
989        /// Number of times the JWKS endpoint was fetched, i.e. how often the
990        /// handler ran discovery.
991        jwks_fetches: AtomicUsize,
992        /// Whether the mock token includes the configured client ID in `aud`.
993        include_client_audience: AtomicBool,
994        /// Whether the mock token includes the additional audience in `aud`.
995        include_other_audience: AtomicBool,
996        /// When `true`, the token endpoint answers without an `access_token`.
997        malformed_token_response: AtomicBool,
998    }
999
1000    impl MockProvider {
1001        fn active_key(&self) -> &CoreRsaPrivateSigningKey {
1002            if self.rotated.load(Ordering::SeqCst) {
1003                &self.key_b
1004            } else {
1005                &self.key_a
1006            }
1007        }
1008
1009        fn active_end_session_endpoint(&self) -> &str {
1010            if self.rotated.load(Ordering::SeqCst) {
1011                &self.end_session_endpoint_b
1012            } else {
1013                &self.end_session_endpoint_a
1014            }
1015        }
1016
1017        /// Mint an ID token signed with the currently active key.
1018        fn sign_id_token(&self) -> String {
1019            let mut audiences = Vec::new();
1020            if self.include_client_audience.load(Ordering::SeqCst) {
1021                audiences.push(Audience::new(CLIENT_ID.to_string()));
1022            }
1023            if self.include_other_audience.load(Ordering::SeqCst) {
1024                audiences.push(Audience::new(OTHER_AUDIENCE.to_string()));
1025            }
1026
1027            let claims = CoreIdTokenClaims::new(
1028                IssuerUrl::new(self.issuer.clone()).unwrap(),
1029                audiences,
1030                Utc::now() + Duration::minutes(5),
1031                Utc::now(),
1032                StandardClaims::new(SubjectIdentifier::new(SUBJECT.to_string()))
1033                    .set_email(Some(EndUserEmail::new("user@example.com".to_string()))),
1034                EmptyAdditionalClaims {},
1035            )
1036            .set_nonce(Some(Nonce::new(NONCE.to_string())));
1037
1038            let id_token = CoreIdToken::new(
1039                claims,
1040                self.active_key(),
1041                CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256,
1042                None,
1043                None,
1044            )
1045            .expect("signing ID token");
1046
1047            serde_json::to_value(&id_token)
1048                .expect("serialize ID token")
1049                .as_str()
1050                .expect("ID token is a JWT string")
1051                .to_string()
1052        }
1053    }
1054
1055    async fn discovery(State(state): State<Arc<MockProvider>>) -> Json<Value> {
1056        Json(json!({
1057            "issuer": state.issuer,
1058            "authorization_endpoint": format!("{}/authorize", state.issuer),
1059            "token_endpoint": format!("{}/token", state.issuer),
1060            "end_session_endpoint": state.active_end_session_endpoint(),
1061            "jwks_uri": format!("{}/jwks", state.issuer),
1062            "response_types_supported": ["code"],
1063            "subject_types_supported": ["public"],
1064            "id_token_signing_alg_values_supported": ["RS256"],
1065        }))
1066    }
1067
1068    async fn jwks(State(state): State<Arc<MockProvider>>) -> Json<Value> {
1069        state.jwks_fetches.fetch_add(1, Ordering::SeqCst);
1070        let jwks = CoreJsonWebKeySet::new(vec![state.active_key().as_verification_key()]);
1071        Json(serde_json::to_value(&jwks).expect("serialize JWKS"))
1072    }
1073
1074    async fn token(State(state): State<Arc<MockProvider>>) -> Json<Value> {
1075        if state.malformed_token_response.load(Ordering::SeqCst) {
1076            return Json(json!({}));
1077        }
1078        Json(json!({
1079            "access_token": "access-token",
1080            "token_type": "Bearer",
1081            "expires_in": 3600,
1082            "id_token": state.sign_id_token(),
1083        }))
1084    }
1085
1086    fn test_settings(issuer: &str, additional_audiences: Vec<String>) -> OAuth2Settings {
1087        OAuth2Settings {
1088            enabled: true,
1089            issuer_url: Some(issuer.to_string()),
1090            client_id: Some(CLIENT_ID.to_string()),
1091            client_secret: Some("test-secret".to_string()),
1092            additional_audiences,
1093            ..Default::default()
1094        }
1095    }
1096
1097    /// Serve the mock provider on an ephemeral port.
1098    ///
1099    /// Returns the shared provider state, its issuer URL and the server task,
1100    /// which the caller must abort once done.
1101    async fn start_mock_provider() -> (Arc<MockProvider>, String, JoinHandle<()>) {
1102        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1103        let issuer = format!("http://{}", listener.local_addr().unwrap());
1104
1105        let provider = Arc::new(MockProvider {
1106            issuer: issuer.clone(),
1107            key_a: CoreRsaPrivateSigningKey::from_pem(
1108                KEY_A_PEM,
1109                Some(JsonWebKeyId::new("key-a".to_string())),
1110            )
1111            .unwrap(),
1112            key_b: CoreRsaPrivateSigningKey::from_pem(
1113                KEY_B_PEM,
1114                Some(JsonWebKeyId::new("key-b".to_string())),
1115            )
1116            .unwrap(),
1117            end_session_endpoint_a: END_SESSION_ENDPOINT_A.to_string(),
1118            end_session_endpoint_b: END_SESSION_ENDPOINT_B.to_string(),
1119            rotated: AtomicBool::new(false),
1120            jwks_fetches: AtomicUsize::new(0),
1121            include_client_audience: AtomicBool::new(true),
1122            include_other_audience: AtomicBool::new(false),
1123            malformed_token_response: AtomicBool::new(false),
1124        });
1125
1126        let app = Router::new()
1127            .route("/.well-known/openid-configuration", get(discovery))
1128            .route("/jwks", get(jwks))
1129            .route("/token", post(token))
1130            .with_state(provider.clone());
1131        let server = tokio::spawn(async move {
1132            axum::serve(listener, app.into_make_service())
1133                .await
1134                .unwrap();
1135        });
1136
1137        (provider, issuer, server)
1138    }
1139
1140    /// Assert that verification failed because of the `aud` claim rather than
1141    /// for some unrelated reason such as an expired token or a nonce mismatch.
1142    fn assert_audience_error(error: &OAuth2Error) {
1143        match error {
1144            OAuth2Error::TokenVerificationError(message) => assert!(
1145                message.to_ascii_lowercase().contains("audience"),
1146                "expected an audience verification error, got: {message}"
1147            ),
1148            error => panic!("expected an audience verification error, got {error:?}"),
1149        }
1150    }
1151
1152    /// A signing-key rotation makes the JWKS cached at discovery stale; the
1153    /// handler must re-fetch it and still verify the freshly signed token.
1154    #[tokio::test]
1155    async fn exchange_recovers_from_signing_key_rotation() {
1156        let (provider, issuer, server) = start_mock_provider().await;
1157
1158        // Discovery caches key A (first JWKS fetch).
1159        let handler = OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI)
1160            .await
1161            .expect("discovery should succeed");
1162        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 1);
1163
1164        // Baseline: a token signed with key A verifies against the cached JWKS
1165        // without triggering another discovery.
1166        let result = handler
1167            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1168            .await
1169            .expect("baseline exchange should verify");
1170        assert_eq!(result.claims.subject().as_str(), SUBJECT);
1171        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 1);
1172
1173        // The provider rotates keys: tokens are now signed with key B and the
1174        // JWKS endpoint serves only key B. The key A cached at startup is stale.
1175        provider.rotated.store(true, Ordering::SeqCst);
1176
1177        // Before the fix this failed with "Signature verification failed"; now
1178        // the handler re-discovers the JWKS (second fetch) and verifies.
1179        let result = handler
1180            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1181            .await
1182            .expect("exchange should succeed after JWKS refresh");
1183        assert_eq!(result.claims.subject().as_str(), SUBJECT);
1184        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 2);
1185
1186        // The refreshed client is cached, so a subsequent login reuses key B
1187        // instead of re-fetching the JWKS again.
1188        let result = handler
1189            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1190            .await
1191            .expect("exchange should reuse the refreshed JWKS");
1192        assert_eq!(result.claims.subject().as_str(), SUBJECT);
1193        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 2);
1194
1195        server.abort();
1196    }
1197
1198    /// When the handler rediscovers the client, it should also update the end session endpoint.
1199    #[tokio::test]
1200    async fn rediscovery_updates_end_session_endpoint() {
1201        let (provider, issuer, server) = start_mock_provider().await;
1202
1203        // Discovery obtains the initial end session URL.
1204        let handler = OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI)
1205            .await
1206            .expect("discovery should succeed");
1207        let url_before = handler
1208            .end_session_url("id-token", "http://kellnr/")
1209            .expect("discovery should pick up the end session endpoint");
1210        assert!(
1211            url_before.starts_with(END_SESSION_ENDPOINT_A),
1212            "expected logout URL built from endpoint A, got {url_before}"
1213        );
1214
1215        // The provider rotates (keys and) the end session endpoint.
1216        provider.rotated.store(true, Ordering::SeqCst);
1217
1218        // Token validation should trigger rediscovery, which should update the end session endpoint.
1219        let _: TokenResult = handler
1220            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1221            .await
1222            .expect("exchange should succeed after rotation");
1223
1224        let url_after = handler
1225            .end_session_url("id-token", "http://kellnr/")
1226            .expect("rediscovery should keep an end session endpoint");
1227        assert!(
1228            url_after.starts_with(END_SESSION_ENDPOINT_B),
1229            "expected logout URL built from endpoint B after rotation, got {url_after}"
1230        );
1231
1232        server.abort();
1233    }
1234
1235    /// The logout URL carries the hint, client ID and post-logout redirect the
1236    /// provider needs for RP-initiated logout.
1237    #[tokio::test]
1238    async fn end_session_url_contains_logout_parameters() {
1239        let (_provider, issuer, server) = start_mock_provider().await;
1240
1241        let handler = OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI)
1242            .await
1243            .expect("discovery should succeed");
1244
1245        let url = handler
1246            .end_session_url("the-id-token", "http://kellnr/")
1247            .expect("provider advertises an end session endpoint");
1248        let parsed = Url::parse(&url).expect("logout URL should be a valid URL");
1249        let params: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
1250
1251        assert_eq!(
1252            params.get("id_token_hint").map(String::as_str),
1253            Some("the-id-token")
1254        );
1255        assert_eq!(params.get("client_id").map(String::as_str), Some(CLIENT_ID));
1256        assert_eq!(
1257            params.get("post_logout_redirect_uri").map(String::as_str),
1258            Some("http://kellnr/")
1259        );
1260
1261        server.abort();
1262    }
1263
1264    /// A failed discovery reports the underlying cause, not only the bare
1265    /// "Request failed" from `openidconnect`, so TLS trust problems can be
1266    /// told apart from DNS or connection errors.
1267    #[tokio::test]
1268    async fn discovery_error_includes_underlying_cause() {
1269        // Bind and drop a listener to get a port nothing is listening on.
1270        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1271        let issuer = format!("http://{}", listener.local_addr().unwrap());
1272        drop(listener);
1273
1274        let Err(OAuth2Error::DiscoveryError(message)) =
1275            OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI).await
1276        else {
1277            panic!("discovery against a closed port should fail");
1278        };
1279
1280        assert!(
1281            message.starts_with("Request failed: "),
1282            "unexpected message: {message}"
1283        );
1284    }
1285
1286    /// A failed token exchange reports the underlying cause, not only the
1287    /// top-level message from `openidconnect`.
1288    #[tokio::test]
1289    async fn token_exchange_error_includes_underlying_cause() {
1290        let (provider, issuer, server) = start_mock_provider().await;
1291
1292        let handler = OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI)
1293            .await
1294            .expect("discovery should succeed");
1295        provider
1296            .malformed_token_response
1297            .store(true, Ordering::SeqCst);
1298
1299        let Err(OAuth2Error::TokenExchangeError(message)) = handler
1300            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1301            .await
1302        else {
1303            panic!("exchange with a malformed token response should fail");
1304        };
1305
1306        assert!(
1307            message.starts_with("Failed to parse server response: "),
1308            "unexpected message: {message}"
1309        );
1310
1311        server.abort();
1312    }
1313
1314    /// Providers such as Zitadel mint ID tokens listing several audiences. The
1315    /// configured client ID is always required, while any further audience is
1316    /// accepted only when allowlisted via `oauth2.additional_audiences`.
1317    #[tokio::test]
1318    async fn additional_audiences_are_accepted_only_when_allowlisted() {
1319        let (provider, issuer, server) = start_mock_provider().await;
1320
1321        let strict_handler =
1322            OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI)
1323                .await
1324                .expect("strict discovery should succeed");
1325        let allowlisted_handler = OAuth2Handler::from_discovery(
1326            &test_settings(&issuer, vec![OTHER_AUDIENCE.to_string()]),
1327            REDIRECT_URI,
1328        )
1329        .await
1330        .expect("allowlisted discovery should succeed");
1331
1332        // The common case: `aud` holds the client ID alone and needs no
1333        // allowlist at all.
1334        let result = strict_handler
1335            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1336            .await
1337            .expect("a single-audience token should verify");
1338        assert_eq!(result.claims.subject().as_str(), SUBJECT);
1339
1340        // An additional audience that is not allowlisted is rejected.
1341        provider
1342            .include_other_audience
1343            .store(true, Ordering::SeqCst);
1344        let error = strict_handler
1345            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1346            .await
1347            .expect_err("an untrusted additional audience must be rejected");
1348        assert_audience_error(&error);
1349
1350        // The very same token is accepted once that audience is allowlisted.
1351        let result = allowlisted_handler
1352            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1353            .await
1354            .expect("an allowlisted additional audience should be accepted");
1355        assert_eq!(result.claims.subject().as_str(), SUBJECT);
1356
1357        // The client ID stays mandatory even for a handler that trusts the
1358        // additional audience.
1359        provider
1360            .include_client_audience
1361            .store(false, Ordering::SeqCst);
1362        let error = allowlisted_handler
1363            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1364            .await
1365            .expect_err("a token without the client audience must be rejected");
1366        assert_audience_error(&error);
1367
1368        server.abort();
1369    }
1370}