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, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm,
13    CoreProviderMetadata, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse,
14    CoreTokenResponse,
15};
16use openidconnect::{
17    AuthorizationCode, ClaimsVerificationError, ClientId, ClientSecret, CsrfToken,
18    EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce,
19    PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, reqwest,
20};
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23use tracing::{info, trace, warn};
24use url::Url;
25
26/// Type alias for the configured OIDC client after provider discovery
27type ConfiguredCoreClient = openidconnect::Client<
28    EmptyAdditionalClaims,
29    CoreAuthDisplay,
30    CoreGenderClaim,
31    CoreJweContentEncryptionAlgorithm,
32    CoreJsonWebKey,
33    CoreAuthPrompt,
34    openidconnect::StandardErrorResponse<CoreErrorResponseType>,
35    CoreTokenResponse,
36    CoreTokenIntrospectionResponse,
37    openidconnect::core::CoreRevocableToken,
38    CoreRevocationErrorResponse,
39    EndpointSet,      // HasAuthUrl - set by from_provider_metadata
40    EndpointNotSet,   // HasDeviceAuthUrl
41    EndpointNotSet,   // HasIntrospectionUrl
42    EndpointNotSet,   // HasRevocationUrl
43    EndpointMaybeSet, // HasTokenUrl - maybe set by provider
44    EndpointMaybeSet, // HasUserInfoUrl - maybe set by provider
45>;
46
47/// Errors that can occur during OAuth2/OIDC operations
48#[derive(Debug, Error)]
49pub enum OAuth2Error {
50    #[error("OAuth2 is not enabled")]
51    NotEnabled,
52
53    #[error("OAuth2 configuration is invalid: {0}")]
54    ConfigurationError(String),
55
56    #[error("Failed to discover OIDC provider: {0}")]
57    DiscoveryError(String),
58
59    #[error("Failed to parse URL: {0}")]
60    UrlParseError(#[from] url::ParseError),
61
62    #[error("Failed to exchange authorization code: {0}")]
63    TokenExchangeError(String),
64
65    #[error("Failed to verify ID token: {0}")]
66    TokenVerificationError(String),
67
68    #[error("Missing required claim: {0}")]
69    MissingClaim(String),
70
71    #[error("HTTP request failed: {0}")]
72    HttpError(String),
73}
74
75/// Information extracted from the OIDC ID token
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct UserInfo {
78    /// The subject claim (unique user identifier from the provider)
79    pub subject: String,
80    /// Email address (if available)
81    pub email: Option<String>,
82    /// Preferred username (if available)
83    pub preferred_username: Option<String>,
84    /// Groups the user belongs to (from the configured claim)
85    pub groups: Vec<String>,
86    /// Whether the user should be an admin (derived from group claims)
87    pub is_admin: bool,
88    /// Whether the user should be read-only (derived from group claims)
89    pub is_read_only: bool,
90}
91
92/// Request data for initiating `OAuth2` authentication
93#[derive(Debug)]
94pub struct AuthRequest {
95    /// The URL to redirect the user to for authentication
96    pub auth_url: Url,
97    /// CSRF protection state (to be stored in database)
98    pub state: String,
99    /// PKCE verifier (to be stored in database for code exchange)
100    pub pkce_verifier: String,
101    /// Nonce for ID token verification (to be stored in database)
102    pub nonce: String,
103}
104
105/// Token response from the `OAuth2` provider
106#[derive(Debug)]
107pub struct TokenResult {
108    /// The standard ID token claims
109    pub claims: CoreIdTokenClaims,
110    /// Raw JWT payload for extracting additional claims
111    pub raw_payload: serde_json::Value,
112}
113
114/// OAuth2/OIDC authentication handler
115pub struct OAuth2Handler {
116    /// The configured OIDC client, wrapped so it can be rebuilt when the
117    /// provider rotates its signing keys (see [`Self::rediscover`]).
118    client: Mutex<Arc<ConfiguredCoreClient>>,
119    client_id: ClientId,
120    client_secret: Option<ClientSecret>,
121    redirect_url: RedirectUrl,
122    settings: Arc<OAuth2Settings>,
123    issuer_url: IssuerUrl,
124    http_client: reqwest::Client,
125}
126
127impl OAuth2Handler {
128    /// Create a new `OAuth2Handler` using OIDC discovery
129    ///
130    /// This performs automatic discovery of the OIDC provider's configuration
131    /// using the well-known endpoint.
132    pub async fn from_discovery(
133        settings: &OAuth2Settings,
134        redirect_url: &str,
135    ) -> Result<Self, OAuth2Error> {
136        if !settings.enabled {
137            return Err(OAuth2Error::NotEnabled);
138        }
139
140        // Validate configuration
141        settings
142            .validate()
143            .map_err(OAuth2Error::ConfigurationError)?;
144
145        let issuer_url_str = settings
146            .issuer_url
147            .as_ref()
148            .ok_or_else(|| OAuth2Error::ConfigurationError("Missing issuer_url".to_string()))?;
149
150        let issuer_url = IssuerUrl::new(issuer_url_str.clone())
151            .map_err(|e| OAuth2Error::ConfigurationError(format!("Invalid issuer URL: {e}")))?;
152
153        info!("Discovering OIDC provider at: {}", issuer_url_str);
154
155        // Create HTTP client
156        let http_client = reqwest::ClientBuilder::new()
157            .redirect(reqwest::redirect::Policy::none())
158            .build()
159            .map_err(|e| OAuth2Error::HttpError(e.to_string()))?;
160
161        let client_id = ClientId::new(
162            settings
163                .client_id
164                .clone()
165                .ok_or_else(|| OAuth2Error::ConfigurationError("Missing client_id".to_string()))?,
166        );
167
168        let client_secret = settings.client_secret.clone().map(ClientSecret::new);
169
170        let redirect_url = RedirectUrl::new(redirect_url.to_string())?;
171
172        // Perform OIDC discovery and build the client from provider metadata
173        let client = Self::discover_client(
174            &issuer_url,
175            &client_id,
176            client_secret.as_ref(),
177            &redirect_url,
178            &http_client,
179        )
180        .await?;
181
182        Ok(Self {
183            client: Mutex::new(Arc::new(client)),
184            client_id,
185            client_secret,
186            redirect_url,
187            settings: Arc::new(settings.clone()),
188            issuer_url,
189            http_client,
190        })
191    }
192
193    /// Perform OIDC discovery and build a configured client from the fetched
194    /// provider metadata (which includes the current JWKS signing keys).
195    async fn discover_client(
196        issuer_url: &IssuerUrl,
197        client_id: &ClientId,
198        client_secret: Option<&ClientSecret>,
199        redirect_url: &RedirectUrl,
200        http_client: &reqwest::Client,
201    ) -> Result<ConfiguredCoreClient, OAuth2Error> {
202        let provider_metadata =
203            CoreProviderMetadata::discover_async(issuer_url.clone(), http_client)
204                .await
205                .map_err(|e| OAuth2Error::DiscoveryError(e.to_string()))?;
206
207        Ok(CoreClient::from_provider_metadata(
208            provider_metadata,
209            client_id.clone(),
210            client_secret.cloned(),
211        )
212        .set_redirect_uri(redirect_url.clone()))
213    }
214
215    fn current_client(&self) -> Arc<ConfiguredCoreClient> {
216        self.client
217            .lock()
218            .expect("OAuth2 client mutex poisoned")
219            .clone()
220    }
221
222    /// Re-run OIDC discovery to obtain a client with a fresh JWKS.
223    ///
224    /// Used to recover from provider signing-key rotation, after which the
225    /// keys cached at startup can no longer verify newly issued tokens.
226    async fn rediscover(&self) -> Result<Arc<ConfiguredCoreClient>, OAuth2Error> {
227        Self::discover_client(
228            &self.issuer_url,
229            &self.client_id,
230            self.client_secret.as_ref(),
231            &self.redirect_url,
232            &self.http_client,
233        )
234        .await
235        .map(Arc::new)
236    }
237
238    /// Generate an authorization URL for the `OAuth2` flow
239    ///
240    /// Returns an `AuthRequest` containing the URL to redirect the user to,
241    /// along with the state, PKCE verifier, and nonce that must be stored
242    /// for later verification.
243    pub fn generate_auth_url(&self) -> AuthRequest {
244        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
245
246        let client = self.current_client();
247
248        // Build the authorization request with configured scopes
249        let mut auth_request = client
250            .authorize_url(
251                CoreAuthenticationFlow::AuthorizationCode,
252                CsrfToken::new_random,
253                Nonce::new_random,
254            )
255            .set_pkce_challenge(pkce_challenge);
256
257        // Add configured scopes
258        for scope in &self.settings.scopes {
259            auth_request = auth_request.add_scope(Scope::new(scope.clone()));
260        }
261
262        let (auth_url, csrf_state, nonce) = auth_request.url();
263
264        AuthRequest {
265            auth_url,
266            state: csrf_state.secret().clone(),
267            pkce_verifier: pkce_verifier.secret().clone(),
268            nonce: nonce.secret().clone(),
269        }
270    }
271
272    /// Exchange an authorization code for tokens and validate the ID token
273    ///
274    /// # Arguments
275    /// * `code` - The authorization code received from the provider
276    /// * `pkce_verifier` - The PKCE verifier stored during `generate_auth_url`
277    /// * `nonce` - The nonce stored during `generate_auth_url`
278    ///
279    /// # Returns
280    /// The validated ID token claims
281    pub async fn exchange_and_validate(
282        &self,
283        code: &str,
284        pkce_verifier: &str,
285        nonce: &str,
286    ) -> Result<TokenResult, OAuth2Error> {
287        let code = AuthorizationCode::new(code.to_string());
288        let pkce_verifier = PkceCodeVerifier::new(pkce_verifier.to_string());
289
290        let client = self.current_client();
291
292        let token_request = client
293            .exchange_code(code)
294            .map_err(|e| OAuth2Error::TokenExchangeError(e.to_string()))?;
295
296        let token_response: CoreTokenResponse = token_request
297            .set_pkce_verifier(pkce_verifier)
298            .request_async(&self.http_client)
299            .await
300            .map_err(|e| OAuth2Error::TokenExchangeError(e.to_string()))?;
301
302        // Get and validate the ID token
303        let id_token = token_response
304            .id_token()
305            .ok_or_else(|| OAuth2Error::MissingClaim("id_token".to_string()))?;
306
307        let nonce = Nonce::new(nonce.to_string());
308
309        let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
310            Ok(claims) => claims.clone(),
311            // A signature failure typically means the provider rotated its
312            // signing keys since discovery, so the JWKS cached at startup is
313            // stale. Re-fetch it once and retry before giving up.
314            Err(ClaimsVerificationError::SignatureVerification(_)) => {
315                trace!(
316                    "ID token signature verification failed; refreshing OIDC JWKS and retrying (provider may have rotated signing keys)"
317                );
318                let refreshed = self.rediscover().await?;
319                let claims = id_token
320                    .claims(&refreshed.id_token_verifier(), &nonce)
321                    .map_err(|e| OAuth2Error::TokenVerificationError(e.to_string()))?
322                    .clone();
323                // Persist the refreshed client so subsequent logins reuse the
324                // new keys instead of re-fetching on every request.
325                *self.client.lock().expect("OAuth2 client mutex poisoned") = refreshed;
326                claims
327            }
328            Err(e) => return Err(OAuth2Error::TokenVerificationError(e.to_string())),
329        };
330
331        // Also extract raw payload for additional claims
332        let raw_payload = extract_jwt_payload(id_token.to_string().as_str())
333            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
334
335        Ok(TokenResult {
336            claims,
337            raw_payload,
338        })
339    }
340
341    /// Extract user information from ID token claims
342    ///
343    /// This extracts the subject, email, `preferred_username`, and group membership
344    /// based on the `OAuth2` settings configuration.
345    pub fn extract_user_info(&self, result: &TokenResult) -> UserInfo {
346        let subject = result.claims.subject().as_str().to_string();
347
348        let email = result.claims.email().map(|e| e.as_str().to_string());
349
350        let preferred_username = result
351            .claims
352            .preferred_username()
353            .map(|u| u.as_str().to_string());
354
355        // Extract groups from raw JWT payload
356        let groups = self.extract_groups(&result.raw_payload);
357
358        // Determine admin status from group claims
359        let is_admin = self.check_group_membership(
360            &groups,
361            &result.raw_payload,
362            self.settings.admin_group_claim.as_deref(),
363            self.settings.admin_group_value.as_deref(),
364        );
365
366        // Determine read-only status from group claims
367        let is_read_only = self.check_group_membership(
368            &groups,
369            &result.raw_payload,
370            self.settings.read_only_group_claim.as_deref(),
371            self.settings.read_only_group_value.as_deref(),
372        );
373
374        UserInfo {
375            subject,
376            email,
377            preferred_username,
378            groups,
379            is_admin,
380            is_read_only,
381        }
382    }
383
384    /// Extract groups from the raw JWT payload
385    fn extract_groups(&self, payload: &serde_json::Value) -> Vec<String> {
386        // First try the configured admin group claim if it exists
387        if let Some(claim_name) = &self.settings.admin_group_claim
388            && let Some(groups) = get_string_array_from_json(payload, claim_name)
389        {
390            return groups;
391        }
392
393        // Then try the configured read-only group claim if different
394        if let Some(claim_name) = &self.settings.read_only_group_claim
395            && self.settings.admin_group_claim.as_ref() != Some(claim_name)
396            && let Some(groups) = get_string_array_from_json(payload, claim_name)
397        {
398            return groups;
399        }
400
401        // Try common group claim names
402        for claim_name in &["groups", "roles", "group"] {
403            if let Some(groups) = get_string_array_from_json(payload, claim_name) {
404                return groups;
405            }
406        }
407
408        Vec::new()
409    }
410
411    /// Check if the user belongs to a specific group based on claims
412    #[allow(clippy::unused_self)]
413    fn check_group_membership(
414        &self,
415        groups: &[String],
416        payload: &serde_json::Value,
417        claim_name: Option<&str>,
418        claim_value: Option<&str>,
419    ) -> bool {
420        let (Some(claim_name), Some(claim_value)) = (claim_name, claim_value) else {
421            return false;
422        };
423
424        // First check in the extracted groups
425        if groups.iter().any(|g| g == claim_value) {
426            return true;
427        }
428
429        // Also check directly in the claim (in case it's a different claim than groups)
430        if let Some(values) = get_string_array_from_json(payload, claim_name) {
431            return values.iter().any(|v| v == claim_value);
432        }
433
434        // Check if it's a boolean claim
435        if let Some(value) = payload.get(claim_name)
436            && let Some(b) = value.as_bool()
437        {
438            // If the claim is a boolean and we're checking for "true"
439            return b && claim_value.eq_ignore_ascii_case("true");
440        }
441
442        false
443    }
444
445    /// Generate a unique username for auto-provisioning
446    ///
447    /// Priority:
448    /// 1. `preferred_username` claim
449    /// 2. Local part of email (before @)
450    /// 3. Subject claim
451    pub fn generate_username(user_info: &UserInfo) -> String {
452        if let Some(username) = &user_info.preferred_username
453            && !username.is_empty()
454        {
455            return sanitize_username_with_dots(username);
456        }
457
458        if let Some(email) = &user_info.email
459            && let Some(local_part) = email.split('@').next()
460            && !local_part.is_empty()
461        {
462            return sanitize_username_with_dots(local_part);
463        }
464
465        sanitize_username(&user_info.subject)
466    }
467
468    /// Get the issuer URL string
469    pub fn issuer_url(&self) -> &str {
470        self.issuer_url.as_str()
471    }
472
473    /// Get a reference to the settings
474    pub fn settings(&self) -> &OAuth2Settings {
475        &self.settings
476    }
477
478    /// Whether admin status is governed by an `IdP` group claim.
479    ///
480    /// Only when both the claim name and value are configured does the `IdP`
481    /// act as the source of truth for a user's admin state. Otherwise the
482    /// extracted `is_admin` is always `false` and must not be used to
483    /// overwrite an existing user's state.
484    pub fn admin_claim_configured(&self) -> bool {
485        self.settings.admin_group_claim.is_some() && self.settings.admin_group_value.is_some()
486    }
487
488    /// Whether read-only status is governed by an `IdP` group claim.
489    ///
490    /// See [`Self::admin_claim_configured`]; the same source-of-truth rule
491    /// applies to the read-only flag.
492    pub fn read_only_claim_configured(&self) -> bool {
493        self.settings.read_only_group_claim.is_some()
494            && self.settings.read_only_group_value.is_some()
495    }
496}
497
498/// Extract and decode the payload from a JWT
499fn extract_jwt_payload(jwt: &str) -> Result<serde_json::Value, OAuth2Error> {
500    use base64::Engine;
501
502    let parts: Vec<&str> = jwt.split('.').collect();
503    if parts.len() != 3 {
504        return Err(OAuth2Error::TokenVerificationError(
505            "Invalid JWT format".to_string(),
506        ));
507    }
508
509    let payload_b64 = parts[1];
510    let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
511        .decode(payload_b64)
512        .map_err(|e| OAuth2Error::TokenVerificationError(format!("Base64 decode error: {e}")))?;
513
514    serde_json::from_slice(&payload_bytes)
515        .map_err(|e| OAuth2Error::TokenVerificationError(format!("JSON parse error: {e}")))
516}
517
518/// Get a string array from a JSON value
519fn get_string_array_from_json(payload: &serde_json::Value, name: &str) -> Option<Vec<String>> {
520    let value = payload.get(name)?;
521
522    // Try to parse as array of strings
523    if let Some(arr) = value.as_array() {
524        let strings: Vec<String> = arr
525            .iter()
526            .filter_map(serde_json::Value::as_str)
527            .map(String::from)
528            .collect();
529        if !strings.is_empty() {
530            return Some(strings);
531        }
532    }
533
534    // Try to parse as single string (some providers return single group as string)
535    if let Some(s) = value.as_str() {
536        return Some(vec![s.to_string()]);
537    }
538
539    None
540}
541
542/// Sanitize a username to be valid for Kellnr
543///
544/// - Converts to lowercase
545/// - Replaces invalid characters with underscores
546/// - Ensures it starts with a letter
547fn sanitize_username(input: &str) -> String {
548    sanitize_username_impl(input, false)
549}
550
551/// Same as `sanitize_username`, but preserves dots.
552fn sanitize_username_with_dots(input: &str) -> String {
553    sanitize_username_impl(input, true)
554}
555
556fn sanitize_username_impl(input: &str, allow_dot: bool) -> String {
557    let mut result: String = input
558        .chars()
559        .map(|c| {
560            if c.is_ascii_alphanumeric() || c == '_' || c == '-' || (allow_dot && c == '.') {
561                c.to_ascii_lowercase()
562            } else {
563                '_'
564            }
565        })
566        .collect();
567
568    // Ensure it starts with a letter
569    if result
570        .chars()
571        .next()
572        .is_none_or(|c| !c.is_ascii_alphabetic())
573    {
574        result = format!("u_{result}");
575    }
576
577    // Truncate if too long (max 64 chars is reasonable)
578    if result.len() > 64 {
579        result.truncate(64);
580    }
581
582    result
583}
584
585/// Generate a unique username with collision handling
586///
587/// If the base username is taken, appends _2, _3, etc.
588pub async fn generate_unique_username<F, Fut>(user_info: &UserInfo, is_available: F) -> String
589where
590    F: Fn(String) -> Fut,
591    Fut: Future<Output = bool>,
592{
593    let base = OAuth2Handler::generate_username(user_info);
594
595    // Try the base username first
596    if is_available(base.clone()).await {
597        return base;
598    }
599
600    // Try with numeric suffixes
601    for i in 2..=100 {
602        let candidate = format!("{base}_{i}");
603        if is_available(candidate.clone()).await {
604            return candidate;
605        }
606    }
607
608    // Fallback: use subject with timestamp (very unlikely to reach here)
609    warn!("Could not find unique username after 100 attempts, using fallback");
610    format!(
611        "{}_{:x}",
612        sanitize_username(&user_info.subject),
613        std::time::SystemTime::now()
614            .duration_since(std::time::UNIX_EPOCH)
615            .map_or(0, |d| d.as_secs())
616    )
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn test_sanitize_username() {
625        assert_eq!(sanitize_username("JohnDoe"), "johndoe");
626        assert_eq!(sanitize_username("john.doe"), "john_doe");
627        assert_eq!(sanitize_username("john@example.com"), "john_example_com");
628        assert_eq!(sanitize_username("123user"), "u_123user");
629        assert_eq!(sanitize_username("_user"), "u__user");
630        assert_eq!(sanitize_username("user-name"), "user-name");
631    }
632
633    #[test]
634    fn test_sanitize_username_with_dots() {
635        assert_eq!(sanitize_username_with_dots("john.doe"), "john.doe");
636        assert_eq!(
637            sanitize_username_with_dots("john@example.com"),
638            "john_example.com"
639        );
640    }
641
642    #[test]
643    fn test_generate_username_preferred() {
644        let user_info = UserInfo {
645            subject: "sub123".to_string(),
646            email: Some("john@example.com".to_string()),
647            preferred_username: Some("johndoe".to_string()),
648            groups: vec![],
649            is_admin: false,
650            is_read_only: false,
651        };
652        assert_eq!(OAuth2Handler::generate_username(&user_info), "johndoe");
653    }
654
655    #[test]
656    fn test_generate_username_preferred_preserves_dot() {
657        let user_info = UserInfo {
658            subject: "sub123".to_string(),
659            email: Some("john@example.com".to_string()),
660            preferred_username: Some("john.doe".to_string()),
661            groups: vec![],
662            is_admin: false,
663            is_read_only: false,
664        };
665        assert_eq!(OAuth2Handler::generate_username(&user_info), "john.doe");
666    }
667
668    #[test]
669    fn test_generate_username_email() {
670        let user_info = UserInfo {
671            subject: "sub123".to_string(),
672            email: Some("john@example.com".to_string()),
673            preferred_username: None,
674            groups: vec![],
675            is_admin: false,
676            is_read_only: false,
677        };
678        assert_eq!(OAuth2Handler::generate_username(&user_info), "john");
679    }
680
681    #[test]
682    fn test_generate_username_email_preserves_dot_in_local_part() {
683        let user_info = UserInfo {
684            subject: "sub123".to_string(),
685            email: Some("john.doe@example.com".to_string()),
686            preferred_username: None,
687            groups: vec![],
688            is_admin: false,
689            is_read_only: false,
690        };
691        assert_eq!(OAuth2Handler::generate_username(&user_info), "john.doe");
692    }
693
694    #[test]
695    fn test_generate_username_subject() {
696        let user_info = UserInfo {
697            subject: "sub123".to_string(),
698            email: None,
699            preferred_username: None,
700            groups: vec![],
701            is_admin: false,
702            is_read_only: false,
703        };
704        // "sub123" starts with 's' which is a letter, so no prefix needed
705        assert_eq!(OAuth2Handler::generate_username(&user_info), "sub123");
706    }
707
708    #[tokio::test]
709    async fn test_generate_unique_username() {
710        let user_info = UserInfo {
711            subject: "sub123".to_string(),
712            email: Some("john@example.com".to_string()),
713            preferred_username: Some("johndoe".to_string()),
714            groups: vec![],
715            is_admin: false,
716            is_read_only: false,
717        };
718
719        // First username is available
720        let username = generate_unique_username(&user_info, |_| async { true }).await;
721        assert_eq!(username, "johndoe");
722
723        // First username is taken, second is available
724        let username =
725            generate_unique_username(&user_info, |name| async move { name != "johndoe" }).await;
726        assert_eq!(username, "johndoe_2");
727
728        // First two are taken
729        let username = generate_unique_username(&user_info, |name| async move {
730            name != "johndoe" && name != "johndoe_2"
731        })
732        .await;
733        assert_eq!(username, "johndoe_3");
734    }
735
736    #[test]
737    fn test_extract_jwt_payload() {
738        // A test JWT with payload: {"sub":"1234567890","name":"John Doe","iat":1516239022}
739        let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
740        let payload = extract_jwt_payload(jwt).unwrap();
741        assert_eq!(
742            payload.get("sub").and_then(|v| v.as_str()),
743            Some("1234567890")
744        );
745        assert_eq!(
746            payload.get("name").and_then(|v| v.as_str()),
747            Some("John Doe")
748        );
749    }
750
751    #[test]
752    fn test_get_string_array_from_json() {
753        let payload = serde_json::json!({
754            "groups": ["admin", "users"],
755            "single_group": "single",
756            "number": 42
757        });
758
759        assert_eq!(
760            get_string_array_from_json(&payload, "groups"),
761            Some(vec!["admin".to_string(), "users".to_string()])
762        );
763        assert_eq!(
764            get_string_array_from_json(&payload, "single_group"),
765            Some(vec!["single".to_string()])
766        );
767        assert_eq!(get_string_array_from_json(&payload, "number"), None);
768        assert_eq!(get_string_array_from_json(&payload, "missing"), None);
769    }
770}
771
772/// End-to-end tests for recovering from OIDC provider signing-key rotation.
773///
774/// These spin up a minimal in-process OIDC provider (discovery, JWKS and token
775/// endpoints) whose active signing key can be swapped at runtime, mirroring a
776/// provider like Dex rotating its keys.
777#[cfg(test)]
778mod rotation_tests {
779    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
780
781    use axum::extract::State;
782    use axum::routing::{get, post};
783    use axum::{Json, Router};
784    use chrono::{Duration, Utc};
785    use openidconnect::core::{
786        CoreIdToken, CoreJsonWebKeySet, CoreJwsSigningAlgorithm, CoreRsaPrivateSigningKey,
787    };
788    use openidconnect::{
789        Audience, EmptyAdditionalClaims, EndUserEmail, JsonWebKeyId, PrivateSigningKey,
790        StandardClaims, SubjectIdentifier,
791    };
792    use serde_json::{Value, json};
793    use tokio::net::TcpListener;
794
795    use super::*;
796
797    // Two distinct 2048-bit RSA keys (PKCS#1 PEM) used to simulate key rotation.
798    const KEY_A_PEM: &str = "-----BEGIN RSA PRIVATE KEY-----
799MIIEpAIBAAKCAQEAtZ/uvzCiQirPAQf0w4piStS+zUlL6A3/ZTpTOJCpnRHVtnfh
800EPbqtQ+ohvkB8txKg2nlgDZ5+zS62jtvb2BYzwkH5qU4jlpDEOnwKVtTTjOrCWZx
801a8DVihgvcVkrYREOMRPzlXbF2aACwQAaveM8sbfu0cZ49SmQ34RQNqA8Gs+v/Nmr
802jYwwZ9rQ4XIBYE689TiBjtZJVvQa7I8Waj8euYxWP5rB1tpZVtgz2AARTn5iSgNX
803KM40IaMWLcvVUajPgYdcHE75+GLCzQAeEY4tboYo8ehFuMuehizj5sHz8ghz6reU
804D83gE5wlLndJj/c/9apvvyBYmvWyar5HIPcvEQIDAQABAoIBAAEXqMi+82H6h4JM
805duFvLowyMMlgFQr7vldpSPuSYxBEhN2YoBtJOr34T5VXrK0rPAELDzNduEmepRoK
806KHpiqSd1EJsAPVGLeWkxecPD29eoKFRjcdLT/l30ZdQVJZOa5lP+0ByGpUmhTw1y
807J98/24TXcbRjGtUsQ+4F1rnYIRVVY+RsrtWs2IaLHR8RnI6M3rFjY7y5dKX9x3Wn
808ERC1Nx7blrUMvzK1052qUTC6sCYku2nJXUKBylj095GVZpK9oje62J5c9+SSvuOQ
809MFEX0JSXfpCvoReP+9Ywa+vf+jpywja7hAIbH+bAqEnWNdrsuicTbUaRM39D1MB1
810XbKoemkCgYEA4wSokLEgQggsQ+HEjr5cTXE/dDjokp3CuuXAJg0mAhLWZro3PZnL
811dOElavpRwP4ISt3Lj47CJWcQAPGzl+BuGKPyQkRPhFkpBsUCdxgBi1eA4Suq7xa+
812ARhMEN9Ayfszh2xLD/dRgOh/cVi/x7N3IfihJ9Jd9L3p4TTCjngUC5UCgYEAzM+9
813DtPCdsNfiWtqtK/YolHUMZo3mb8Vu0e3fecIgUH3neOm/XhtxJY2wHehemyFJkYE
814f93Vp50GXEmmmPVnjXuYmrIk3jEjk3j4hn7/gi37EzNzgbSf1RN09swU31FJ5N+R
815/XZDH5zA+ZRIKU+CW8ap4cDTa1hBJNjVLqW2Fo0CgYEA1AFetjlkCaZ+SCqIGFI3
816+u5+trgKohmIaGf1CNQQobEb3rWarwF4Wr+D5SK9xIC4F8qHtpo4Pxu/e1I9SOGD
817j6lTrYUDyXJGeRb01Wlqz8k5B49zQ3K2oGkjaEJFzBq2pYqBkviBeeQmWCDsgL/d
818yrDZN0ojClNtHi7aXphPB/0CgYEAr5nVYOcSrjzopqvgezbhqJo8MqMk1L9O5Jmi
819q2HwmtJyeX78aApfItQf8XkgjBSLPLt/lBog22r4TxweqLqPpHC58LiYf6Dl/cUU
820YEx2yaiewmG0wRqah1f9SrTDmIzbrE47n3NMLch6dAI8tJ6lCAcXFKX9HuY2RF9c
821uHf/3OkCgYA2KePOq4vGOGD+zKDFrI7PSJcEa62oczZidgOylc/Lr9qx2Oiz4HM9
822Di85C5B3y/3ZnYf8+1nTPdvfqTPt4aMjJKBVVWvkEA5yPYmY5FY+Z3sAA4AzGZ8Z
823Xx2ZJaH0xsmAtrhSK8StLDiXayT8ygyZnqXfbZS0nG2HX3tKksYi0Q==
824-----END RSA PRIVATE KEY-----";
825
826    const KEY_B_PEM: &str = "-----BEGIN RSA PRIVATE KEY-----
827MIIEowIBAAKCAQEArOXZ5D7VxWmRkijS8CXQznmFL5bf3XDJ/Vll1imhJjU/sOJP
828aob5U40xIV+b0LbNhxP8C3lu+TKF3xldvv7lYTjXYfVZ3fwjfBMe6Vm5PYr31FiR
829KQLNrTrBE2cTCDoCn51NMERw/fdt0zJmfDcA77o1T6us2jxnDlrddiWYyBs26Y8p
830dYFacxObDsJwqmE60ijP92SR37NjMdwUNDg1Bzh6xw3puAP4KeFwWNNVPHwQpJEd
831pKX3T+CL2Ybfboyh9XCo2FmawfWjoIgUVoecgdpDMgNqtPjQ2HUvsH5JsWCezduE
832MW7sPloZr9doGnYVycuBCKOldwriCFJqjw2VlwIDAQABAoIBAD9lhohFLABXbcuw
833kWwCCbbz4wyon0xko4P0qD0nhZHre3+h8+nFNR3YSzgIBSu6I9GQV95jN/hC+Mht
8341iyG7VfBTmR6YOnfHqnLnw2EW0KANtBTa2KkxwLqZMp3BIkDMFwTgy6cIexVshz7
835QY3xYzQDzLF6awaYmFcwpTzBm2xfxmsjDODnMeeq4YraC/qrLn6yxFC3jH4H5fCu
836mlzYrGHAEN7mdIOQEktJXwyEzi0rVKHDfJEhStGS7gMILw7BZfFSQxnzXyHJEOt7
8373ZJY40N23H2iKaqN3s1Af56PbOcd9xmv3KM0lcmrmp2/cpoZTGSQgHNLZXB8bQ76
838L+u/WyECgYEA8hfrlpn1O66Jh7JKNl8iLFoiBGBozsyZrx0KP0yyFJ4apk7U6GzE
8397dnZJQ6sXklyE1gHSjArtICDnF8I26Jovl7EfijbQr+E4zuQhcZG3vuBzF0/8hy/
840D//QLKXvokqevEvYnt7V81Io17K80R11U5Elxhl1YwxKOMd468MUr3cCgYEAttRh
8416baK/NgktXkDhOf5Mmcz1MtUO+gop8lpOQifcPslE//6M1DE3G+6oRSZuTApSP5o
842NjwhHbu0ko/zbWWvJ760bdcv98R6z60AHFq3TZrG5R4f68o4aD+Llu1mLt26QppP
843vnby8CcCbcvCBXM4xzrORp7LOFs7fx8IsJL/EuECgYAepEZuVmhCuJGxujBId3wU
844zwe1NBqv6hedoXhVkLiNgYFwAVRTYsj+Le+dECFjAbrNlfu+OSCfBREjbqfpXMIT
845Ll2CdltiNGl2dWiSdgksWfsQydC5LUhFlyRbMlmFWhqDTLpLSXsdBA4lVvstIKRk
846AmAclcZU1g5i52R3usZYewKBgGPCn2qXYF920RD0ZycuLJuEFJQYHm5Rz10+WVv1
847iIptf13aXvuBJunhTUR3qSmTTfO2Xca6KJfAxFb0lS8sPNbDCFCnClV3sEuBan4O
848QdbmjYCXX3OZdA3uHozMHOWVtvyAluKmpQjFQF4IwGWY6XJMdCG0o86seVpz5Jn0
849m5oBAoGBAL/uJIrh8ZiL1eQrhJTitkW7BgV6iTN518Pf/I9DzOMnq/kG3W7zhQ2m
850lJZsY5m/jNCs0KrTBBEoVQzkPVlzoQ/dDYZyA2cj4LydXyuM76yth8F+DMr3ckN6
851Y4dOyrc/PytM2BLxs06WhIWeneUpz64RtUlvZUrSDgnO5AQX0ba2
852-----END RSA PRIVATE KEY-----";
853
854    const CLIENT_ID: &str = "test-client";
855    const SUBJECT: &str = "user-subject-123";
856    const NONCE: &str = "test-nonce";
857
858    /// State shared with the mock provider's request handlers.
859    struct MockProvider {
860        issuer: String,
861        key_a: CoreRsaPrivateSigningKey,
862        key_b: CoreRsaPrivateSigningKey,
863        /// When `true`, the provider signs tokens with (and advertises) `key_b`.
864        rotated: AtomicBool,
865        /// Number of times the JWKS endpoint was fetched, i.e. how often the
866        /// handler ran discovery.
867        jwks_fetches: AtomicUsize,
868    }
869
870    impl MockProvider {
871        fn active_key(&self) -> &CoreRsaPrivateSigningKey {
872            if self.rotated.load(Ordering::SeqCst) {
873                &self.key_b
874            } else {
875                &self.key_a
876            }
877        }
878
879        /// Mint an ID token signed with the currently active key.
880        fn sign_id_token(&self) -> String {
881            let claims = CoreIdTokenClaims::new(
882                IssuerUrl::new(self.issuer.clone()).unwrap(),
883                vec![Audience::new(CLIENT_ID.to_string())],
884                Utc::now() + Duration::minutes(5),
885                Utc::now(),
886                StandardClaims::new(SubjectIdentifier::new(SUBJECT.to_string()))
887                    .set_email(Some(EndUserEmail::new("user@example.com".to_string()))),
888                EmptyAdditionalClaims {},
889            )
890            .set_nonce(Some(Nonce::new(NONCE.to_string())));
891
892            let id_token = CoreIdToken::new(
893                claims,
894                self.active_key(),
895                CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256,
896                None,
897                None,
898            )
899            .expect("signing ID token");
900
901            serde_json::to_value(&id_token)
902                .expect("serialize ID token")
903                .as_str()
904                .expect("ID token is a JWT string")
905                .to_string()
906        }
907    }
908
909    async fn discovery(State(state): State<Arc<MockProvider>>) -> Json<Value> {
910        Json(json!({
911            "issuer": state.issuer,
912            "authorization_endpoint": format!("{}/authorize", state.issuer),
913            "token_endpoint": format!("{}/token", state.issuer),
914            "jwks_uri": format!("{}/jwks", state.issuer),
915            "response_types_supported": ["code"],
916            "subject_types_supported": ["public"],
917            "id_token_signing_alg_values_supported": ["RS256"],
918        }))
919    }
920
921    async fn jwks(State(state): State<Arc<MockProvider>>) -> Json<Value> {
922        state.jwks_fetches.fetch_add(1, Ordering::SeqCst);
923        let jwks = CoreJsonWebKeySet::new(vec![state.active_key().as_verification_key()]);
924        Json(serde_json::to_value(&jwks).expect("serialize JWKS"))
925    }
926
927    async fn token(State(state): State<Arc<MockProvider>>) -> Json<Value> {
928        Json(json!({
929            "access_token": "access-token",
930            "token_type": "Bearer",
931            "expires_in": 3600,
932            "id_token": state.sign_id_token(),
933        }))
934    }
935
936    fn test_settings(issuer: &str) -> OAuth2Settings {
937        OAuth2Settings {
938            enabled: true,
939            issuer_url: Some(issuer.to_string()),
940            client_id: Some(CLIENT_ID.to_string()),
941            client_secret: Some("test-secret".to_string()),
942            ..Default::default()
943        }
944    }
945
946    /// A signing-key rotation makes the JWKS cached at discovery stale; the
947    /// handler must re-fetch it and still verify the freshly signed token.
948    #[tokio::test]
949    async fn exchange_recovers_from_signing_key_rotation() {
950        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
951        let issuer = format!("http://{}", listener.local_addr().unwrap());
952
953        let provider = Arc::new(MockProvider {
954            issuer: issuer.clone(),
955            key_a: CoreRsaPrivateSigningKey::from_pem(
956                KEY_A_PEM,
957                Some(JsonWebKeyId::new("key-a".to_string())),
958            )
959            .unwrap(),
960            key_b: CoreRsaPrivateSigningKey::from_pem(
961                KEY_B_PEM,
962                Some(JsonWebKeyId::new("key-b".to_string())),
963            )
964            .unwrap(),
965            rotated: AtomicBool::new(false),
966            jwks_fetches: AtomicUsize::new(0),
967        });
968
969        let app = Router::new()
970            .route("/.well-known/openid-configuration", get(discovery))
971            .route("/jwks", get(jwks))
972            .route("/token", post(token))
973            .with_state(provider.clone());
974        let server = tokio::spawn(async move {
975            axum::serve(listener, app.into_make_service())
976                .await
977                .unwrap();
978        });
979
980        // Discovery caches key A (first JWKS fetch).
981        let handler = OAuth2Handler::from_discovery(&test_settings(&issuer), "http://localhost/cb")
982            .await
983            .expect("discovery should succeed");
984        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 1);
985
986        // Baseline: a token signed with key A verifies against the cached JWKS
987        // without triggering another discovery.
988        let result = handler
989            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
990            .await
991            .expect("baseline exchange should verify");
992        assert_eq!(result.claims.subject().as_str(), SUBJECT);
993        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 1);
994
995        // The provider rotates keys: tokens are now signed with key B and the
996        // JWKS endpoint serves only key B. The key A cached at startup is stale.
997        provider.rotated.store(true, Ordering::SeqCst);
998
999        // Before the fix this failed with "Signature verification failed"; now
1000        // the handler re-discovers the JWKS (second fetch) and verifies.
1001        let result = handler
1002            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1003            .await
1004            .expect("exchange should succeed after JWKS refresh");
1005        assert_eq!(result.claims.subject().as_str(), SUBJECT);
1006        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 2);
1007
1008        // The refreshed client is cached, so a subsequent login reuses key B
1009        // instead of re-fetching the JWKS again.
1010        let result = handler
1011            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1012            .await
1013            .expect("exchange should reuse the refreshed JWKS");
1014        assert_eq!(result.claims.subject().as_str(), SUBJECT);
1015        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 2);
1016
1017        server.abort();
1018    }
1019}