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(|d| d.as_secs())
616            .unwrap_or(0)
617    )
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn test_sanitize_username() {
626        assert_eq!(sanitize_username("JohnDoe"), "johndoe");
627        assert_eq!(sanitize_username("john.doe"), "john_doe");
628        assert_eq!(sanitize_username("john@example.com"), "john_example_com");
629        assert_eq!(sanitize_username("123user"), "u_123user");
630        assert_eq!(sanitize_username("_user"), "u__user");
631        assert_eq!(sanitize_username("user-name"), "user-name");
632    }
633
634    #[test]
635    fn test_sanitize_username_with_dots() {
636        assert_eq!(sanitize_username_with_dots("john.doe"), "john.doe");
637        assert_eq!(
638            sanitize_username_with_dots("john@example.com"),
639            "john_example.com"
640        );
641    }
642
643    #[test]
644    fn test_generate_username_preferred() {
645        let user_info = UserInfo {
646            subject: "sub123".to_string(),
647            email: Some("john@example.com".to_string()),
648            preferred_username: Some("johndoe".to_string()),
649            groups: vec![],
650            is_admin: false,
651            is_read_only: false,
652        };
653        assert_eq!(OAuth2Handler::generate_username(&user_info), "johndoe");
654    }
655
656    #[test]
657    fn test_generate_username_preferred_preserves_dot() {
658        let user_info = UserInfo {
659            subject: "sub123".to_string(),
660            email: Some("john@example.com".to_string()),
661            preferred_username: Some("john.doe".to_string()),
662            groups: vec![],
663            is_admin: false,
664            is_read_only: false,
665        };
666        assert_eq!(OAuth2Handler::generate_username(&user_info), "john.doe");
667    }
668
669    #[test]
670    fn test_generate_username_email() {
671        let user_info = UserInfo {
672            subject: "sub123".to_string(),
673            email: Some("john@example.com".to_string()),
674            preferred_username: None,
675            groups: vec![],
676            is_admin: false,
677            is_read_only: false,
678        };
679        assert_eq!(OAuth2Handler::generate_username(&user_info), "john");
680    }
681
682    #[test]
683    fn test_generate_username_email_preserves_dot_in_local_part() {
684        let user_info = UserInfo {
685            subject: "sub123".to_string(),
686            email: Some("john.doe@example.com".to_string()),
687            preferred_username: None,
688            groups: vec![],
689            is_admin: false,
690            is_read_only: false,
691        };
692        assert_eq!(OAuth2Handler::generate_username(&user_info), "john.doe");
693    }
694
695    #[test]
696    fn test_generate_username_subject() {
697        let user_info = UserInfo {
698            subject: "sub123".to_string(),
699            email: None,
700            preferred_username: None,
701            groups: vec![],
702            is_admin: false,
703            is_read_only: false,
704        };
705        // "sub123" starts with 's' which is a letter, so no prefix needed
706        assert_eq!(OAuth2Handler::generate_username(&user_info), "sub123");
707    }
708
709    #[tokio::test]
710    async fn test_generate_unique_username() {
711        let user_info = UserInfo {
712            subject: "sub123".to_string(),
713            email: Some("john@example.com".to_string()),
714            preferred_username: Some("johndoe".to_string()),
715            groups: vec![],
716            is_admin: false,
717            is_read_only: false,
718        };
719
720        // First username is available
721        let username = generate_unique_username(&user_info, |_| async { true }).await;
722        assert_eq!(username, "johndoe");
723
724        // First username is taken, second is available
725        let username =
726            generate_unique_username(&user_info, |name| async move { name != "johndoe" }).await;
727        assert_eq!(username, "johndoe_2");
728
729        // First two are taken
730        let username = generate_unique_username(&user_info, |name| async move {
731            name != "johndoe" && name != "johndoe_2"
732        })
733        .await;
734        assert_eq!(username, "johndoe_3");
735    }
736
737    #[test]
738    fn test_extract_jwt_payload() {
739        // A test JWT with payload: {"sub":"1234567890","name":"John Doe","iat":1516239022}
740        let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
741        let payload = extract_jwt_payload(jwt).unwrap();
742        assert_eq!(
743            payload.get("sub").and_then(|v| v.as_str()),
744            Some("1234567890")
745        );
746        assert_eq!(
747            payload.get("name").and_then(|v| v.as_str()),
748            Some("John Doe")
749        );
750    }
751
752    #[test]
753    fn test_get_string_array_from_json() {
754        let payload = serde_json::json!({
755            "groups": ["admin", "users"],
756            "single_group": "single",
757            "number": 42
758        });
759
760        assert_eq!(
761            get_string_array_from_json(&payload, "groups"),
762            Some(vec!["admin".to_string(), "users".to_string()])
763        );
764        assert_eq!(
765            get_string_array_from_json(&payload, "single_group"),
766            Some(vec!["single".to_string()])
767        );
768        assert_eq!(get_string_array_from_json(&payload, "number"), None);
769        assert_eq!(get_string_array_from_json(&payload, "missing"), None);
770    }
771}
772
773/// End-to-end tests for recovering from OIDC provider signing-key rotation.
774///
775/// These spin up a minimal in-process OIDC provider (discovery, JWKS and token
776/// endpoints) whose active signing key can be swapped at runtime, mirroring a
777/// provider like Dex rotating its keys.
778#[cfg(test)]
779mod rotation_tests {
780    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
781
782    use axum::extract::State;
783    use axum::routing::{get, post};
784    use axum::{Json, Router};
785    use chrono::{Duration, Utc};
786    use openidconnect::core::{
787        CoreIdToken, CoreJsonWebKeySet, CoreJwsSigningAlgorithm, CoreRsaPrivateSigningKey,
788    };
789    use openidconnect::{
790        Audience, EmptyAdditionalClaims, EndUserEmail, JsonWebKeyId, PrivateSigningKey,
791        StandardClaims, SubjectIdentifier,
792    };
793    use serde_json::{Value, json};
794    use tokio::net::TcpListener;
795
796    use super::*;
797
798    // Two distinct 2048-bit RSA keys (PKCS#1 PEM) used to simulate key rotation.
799    const KEY_A_PEM: &str = "-----BEGIN RSA PRIVATE KEY-----
800MIIEpAIBAAKCAQEAtZ/uvzCiQirPAQf0w4piStS+zUlL6A3/ZTpTOJCpnRHVtnfh
801EPbqtQ+ohvkB8txKg2nlgDZ5+zS62jtvb2BYzwkH5qU4jlpDEOnwKVtTTjOrCWZx
802a8DVihgvcVkrYREOMRPzlXbF2aACwQAaveM8sbfu0cZ49SmQ34RQNqA8Gs+v/Nmr
803jYwwZ9rQ4XIBYE689TiBjtZJVvQa7I8Waj8euYxWP5rB1tpZVtgz2AARTn5iSgNX
804KM40IaMWLcvVUajPgYdcHE75+GLCzQAeEY4tboYo8ehFuMuehizj5sHz8ghz6reU
805D83gE5wlLndJj/c/9apvvyBYmvWyar5HIPcvEQIDAQABAoIBAAEXqMi+82H6h4JM
806duFvLowyMMlgFQr7vldpSPuSYxBEhN2YoBtJOr34T5VXrK0rPAELDzNduEmepRoK
807KHpiqSd1EJsAPVGLeWkxecPD29eoKFRjcdLT/l30ZdQVJZOa5lP+0ByGpUmhTw1y
808J98/24TXcbRjGtUsQ+4F1rnYIRVVY+RsrtWs2IaLHR8RnI6M3rFjY7y5dKX9x3Wn
809ERC1Nx7blrUMvzK1052qUTC6sCYku2nJXUKBylj095GVZpK9oje62J5c9+SSvuOQ
810MFEX0JSXfpCvoReP+9Ywa+vf+jpywja7hAIbH+bAqEnWNdrsuicTbUaRM39D1MB1
811XbKoemkCgYEA4wSokLEgQggsQ+HEjr5cTXE/dDjokp3CuuXAJg0mAhLWZro3PZnL
812dOElavpRwP4ISt3Lj47CJWcQAPGzl+BuGKPyQkRPhFkpBsUCdxgBi1eA4Suq7xa+
813ARhMEN9Ayfszh2xLD/dRgOh/cVi/x7N3IfihJ9Jd9L3p4TTCjngUC5UCgYEAzM+9
814DtPCdsNfiWtqtK/YolHUMZo3mb8Vu0e3fecIgUH3neOm/XhtxJY2wHehemyFJkYE
815f93Vp50GXEmmmPVnjXuYmrIk3jEjk3j4hn7/gi37EzNzgbSf1RN09swU31FJ5N+R
816/XZDH5zA+ZRIKU+CW8ap4cDTa1hBJNjVLqW2Fo0CgYEA1AFetjlkCaZ+SCqIGFI3
817+u5+trgKohmIaGf1CNQQobEb3rWarwF4Wr+D5SK9xIC4F8qHtpo4Pxu/e1I9SOGD
818j6lTrYUDyXJGeRb01Wlqz8k5B49zQ3K2oGkjaEJFzBq2pYqBkviBeeQmWCDsgL/d
819yrDZN0ojClNtHi7aXphPB/0CgYEAr5nVYOcSrjzopqvgezbhqJo8MqMk1L9O5Jmi
820q2HwmtJyeX78aApfItQf8XkgjBSLPLt/lBog22r4TxweqLqPpHC58LiYf6Dl/cUU
821YEx2yaiewmG0wRqah1f9SrTDmIzbrE47n3NMLch6dAI8tJ6lCAcXFKX9HuY2RF9c
822uHf/3OkCgYA2KePOq4vGOGD+zKDFrI7PSJcEa62oczZidgOylc/Lr9qx2Oiz4HM9
823Di85C5B3y/3ZnYf8+1nTPdvfqTPt4aMjJKBVVWvkEA5yPYmY5FY+Z3sAA4AzGZ8Z
824Xx2ZJaH0xsmAtrhSK8StLDiXayT8ygyZnqXfbZS0nG2HX3tKksYi0Q==
825-----END RSA PRIVATE KEY-----";
826
827    const KEY_B_PEM: &str = "-----BEGIN RSA PRIVATE KEY-----
828MIIEowIBAAKCAQEArOXZ5D7VxWmRkijS8CXQznmFL5bf3XDJ/Vll1imhJjU/sOJP
829aob5U40xIV+b0LbNhxP8C3lu+TKF3xldvv7lYTjXYfVZ3fwjfBMe6Vm5PYr31FiR
830KQLNrTrBE2cTCDoCn51NMERw/fdt0zJmfDcA77o1T6us2jxnDlrddiWYyBs26Y8p
831dYFacxObDsJwqmE60ijP92SR37NjMdwUNDg1Bzh6xw3puAP4KeFwWNNVPHwQpJEd
832pKX3T+CL2Ybfboyh9XCo2FmawfWjoIgUVoecgdpDMgNqtPjQ2HUvsH5JsWCezduE
833MW7sPloZr9doGnYVycuBCKOldwriCFJqjw2VlwIDAQABAoIBAD9lhohFLABXbcuw
834kWwCCbbz4wyon0xko4P0qD0nhZHre3+h8+nFNR3YSzgIBSu6I9GQV95jN/hC+Mht
8351iyG7VfBTmR6YOnfHqnLnw2EW0KANtBTa2KkxwLqZMp3BIkDMFwTgy6cIexVshz7
836QY3xYzQDzLF6awaYmFcwpTzBm2xfxmsjDODnMeeq4YraC/qrLn6yxFC3jH4H5fCu
837mlzYrGHAEN7mdIOQEktJXwyEzi0rVKHDfJEhStGS7gMILw7BZfFSQxnzXyHJEOt7
8383ZJY40N23H2iKaqN3s1Af56PbOcd9xmv3KM0lcmrmp2/cpoZTGSQgHNLZXB8bQ76
839L+u/WyECgYEA8hfrlpn1O66Jh7JKNl8iLFoiBGBozsyZrx0KP0yyFJ4apk7U6GzE
8407dnZJQ6sXklyE1gHSjArtICDnF8I26Jovl7EfijbQr+E4zuQhcZG3vuBzF0/8hy/
841D//QLKXvokqevEvYnt7V81Io17K80R11U5Elxhl1YwxKOMd468MUr3cCgYEAttRh
8426baK/NgktXkDhOf5Mmcz1MtUO+gop8lpOQifcPslE//6M1DE3G+6oRSZuTApSP5o
843NjwhHbu0ko/zbWWvJ760bdcv98R6z60AHFq3TZrG5R4f68o4aD+Llu1mLt26QppP
844vnby8CcCbcvCBXM4xzrORp7LOFs7fx8IsJL/EuECgYAepEZuVmhCuJGxujBId3wU
845zwe1NBqv6hedoXhVkLiNgYFwAVRTYsj+Le+dECFjAbrNlfu+OSCfBREjbqfpXMIT
846Ll2CdltiNGl2dWiSdgksWfsQydC5LUhFlyRbMlmFWhqDTLpLSXsdBA4lVvstIKRk
847AmAclcZU1g5i52R3usZYewKBgGPCn2qXYF920RD0ZycuLJuEFJQYHm5Rz10+WVv1
848iIptf13aXvuBJunhTUR3qSmTTfO2Xca6KJfAxFb0lS8sPNbDCFCnClV3sEuBan4O
849QdbmjYCXX3OZdA3uHozMHOWVtvyAluKmpQjFQF4IwGWY6XJMdCG0o86seVpz5Jn0
850m5oBAoGBAL/uJIrh8ZiL1eQrhJTitkW7BgV6iTN518Pf/I9DzOMnq/kG3W7zhQ2m
851lJZsY5m/jNCs0KrTBBEoVQzkPVlzoQ/dDYZyA2cj4LydXyuM76yth8F+DMr3ckN6
852Y4dOyrc/PytM2BLxs06WhIWeneUpz64RtUlvZUrSDgnO5AQX0ba2
853-----END RSA PRIVATE KEY-----";
854
855    const CLIENT_ID: &str = "test-client";
856    const SUBJECT: &str = "user-subject-123";
857    const NONCE: &str = "test-nonce";
858
859    /// State shared with the mock provider's request handlers.
860    struct MockProvider {
861        issuer: String,
862        key_a: CoreRsaPrivateSigningKey,
863        key_b: CoreRsaPrivateSigningKey,
864        /// When `true`, the provider signs tokens with (and advertises) `key_b`.
865        rotated: AtomicBool,
866        /// Number of times the JWKS endpoint was fetched, i.e. how often the
867        /// handler ran discovery.
868        jwks_fetches: AtomicUsize,
869    }
870
871    impl MockProvider {
872        fn active_key(&self) -> &CoreRsaPrivateSigningKey {
873            if self.rotated.load(Ordering::SeqCst) {
874                &self.key_b
875            } else {
876                &self.key_a
877            }
878        }
879
880        /// Mint an ID token signed with the currently active key.
881        fn sign_id_token(&self) -> String {
882            let claims = CoreIdTokenClaims::new(
883                IssuerUrl::new(self.issuer.clone()).unwrap(),
884                vec![Audience::new(CLIENT_ID.to_string())],
885                Utc::now() + Duration::minutes(5),
886                Utc::now(),
887                StandardClaims::new(SubjectIdentifier::new(SUBJECT.to_string()))
888                    .set_email(Some(EndUserEmail::new("user@example.com".to_string()))),
889                EmptyAdditionalClaims {},
890            )
891            .set_nonce(Some(Nonce::new(NONCE.to_string())));
892
893            let id_token = CoreIdToken::new(
894                claims,
895                self.active_key(),
896                CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256,
897                None,
898                None,
899            )
900            .expect("signing ID token");
901
902            serde_json::to_value(&id_token)
903                .expect("serialize ID token")
904                .as_str()
905                .expect("ID token is a JWT string")
906                .to_string()
907        }
908    }
909
910    async fn discovery(State(state): State<Arc<MockProvider>>) -> Json<Value> {
911        Json(json!({
912            "issuer": state.issuer,
913            "authorization_endpoint": format!("{}/authorize", state.issuer),
914            "token_endpoint": format!("{}/token", state.issuer),
915            "jwks_uri": format!("{}/jwks", state.issuer),
916            "response_types_supported": ["code"],
917            "subject_types_supported": ["public"],
918            "id_token_signing_alg_values_supported": ["RS256"],
919        }))
920    }
921
922    async fn jwks(State(state): State<Arc<MockProvider>>) -> Json<Value> {
923        state.jwks_fetches.fetch_add(1, Ordering::SeqCst);
924        let jwks = CoreJsonWebKeySet::new(vec![state.active_key().as_verification_key()]);
925        Json(serde_json::to_value(&jwks).expect("serialize JWKS"))
926    }
927
928    async fn token(State(state): State<Arc<MockProvider>>) -> Json<Value> {
929        Json(json!({
930            "access_token": "access-token",
931            "token_type": "Bearer",
932            "expires_in": 3600,
933            "id_token": state.sign_id_token(),
934        }))
935    }
936
937    fn test_settings(issuer: &str) -> OAuth2Settings {
938        OAuth2Settings {
939            enabled: true,
940            issuer_url: Some(issuer.to_string()),
941            client_id: Some(CLIENT_ID.to_string()),
942            client_secret: Some("test-secret".to_string()),
943            ..Default::default()
944        }
945    }
946
947    /// A signing-key rotation makes the JWKS cached at discovery stale; the
948    /// handler must re-fetch it and still verify the freshly signed token.
949    #[tokio::test]
950    async fn exchange_recovers_from_signing_key_rotation() {
951        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
952        let issuer = format!("http://{}", listener.local_addr().unwrap());
953
954        let provider = Arc::new(MockProvider {
955            issuer: issuer.clone(),
956            key_a: CoreRsaPrivateSigningKey::from_pem(
957                KEY_A_PEM,
958                Some(JsonWebKeyId::new("key-a".to_string())),
959            )
960            .unwrap(),
961            key_b: CoreRsaPrivateSigningKey::from_pem(
962                KEY_B_PEM,
963                Some(JsonWebKeyId::new("key-b".to_string())),
964            )
965            .unwrap(),
966            rotated: AtomicBool::new(false),
967            jwks_fetches: AtomicUsize::new(0),
968        });
969
970        let app = Router::new()
971            .route("/.well-known/openid-configuration", get(discovery))
972            .route("/jwks", get(jwks))
973            .route("/token", post(token))
974            .with_state(provider.clone());
975        let server = tokio::spawn(async move {
976            axum::serve(listener, app.into_make_service())
977                .await
978                .unwrap();
979        });
980
981        // Discovery caches key A (first JWKS fetch).
982        let handler = OAuth2Handler::from_discovery(&test_settings(&issuer), "http://localhost/cb")
983            .await
984            .expect("discovery should succeed");
985        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 1);
986
987        // Baseline: a token signed with key A verifies against the cached JWKS
988        // without triggering another discovery.
989        let result = handler
990            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
991            .await
992            .expect("baseline exchange should verify");
993        assert_eq!(result.claims.subject().as_str(), SUBJECT);
994        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 1);
995
996        // The provider rotates keys: tokens are now signed with key B and the
997        // JWKS endpoint serves only key B. The key A cached at startup is stale.
998        provider.rotated.store(true, Ordering::SeqCst);
999
1000        // Before the fix this failed with "Signature verification failed"; now
1001        // the handler re-discovers the JWKS (second fetch) and verifies.
1002        let result = handler
1003            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1004            .await
1005            .expect("exchange should succeed after JWKS refresh");
1006        assert_eq!(result.claims.subject().as_str(), SUBJECT);
1007        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 2);
1008
1009        // The refreshed client is cached, so a subsequent login reuses key B
1010        // instead of re-fetching the JWKS again.
1011        let result = handler
1012            .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1013            .await
1014            .expect("exchange should reuse the refreshed JWKS");
1015        assert_eq!(result.claims.subject().as_str(), SUBJECT);
1016        assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 2);
1017
1018        server.abort();
1019    }
1020}