1use 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
26type 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, EndpointNotSet, EndpointNotSet, EndpointNotSet, EndpointMaybeSet, EndpointMaybeSet, >;
46
47#[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#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct UserInfo {
78 pub subject: String,
80 pub email: Option<String>,
82 pub preferred_username: Option<String>,
84 pub groups: Vec<String>,
86 pub is_admin: bool,
88 pub is_read_only: bool,
90}
91
92#[derive(Debug)]
94pub struct AuthRequest {
95 pub auth_url: Url,
97 pub state: String,
99 pub pkce_verifier: String,
101 pub nonce: String,
103}
104
105#[derive(Debug)]
107pub struct TokenResult {
108 pub claims: CoreIdTokenClaims,
110 pub raw_payload: serde_json::Value,
112}
113
114pub struct OAuth2Handler {
116 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 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 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 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 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 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 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 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 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 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 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 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 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 *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 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 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 let groups = self.extract_groups(&result.raw_payload);
357
358 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 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 fn extract_groups(&self, payload: &serde_json::Value) -> Vec<String> {
386 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 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 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 #[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 if groups.iter().any(|g| g == claim_value) {
426 return true;
427 }
428
429 if let Some(values) = get_string_array_from_json(payload, claim_name) {
431 return values.iter().any(|v| v == claim_value);
432 }
433
434 if let Some(value) = payload.get(claim_name)
436 && let Some(b) = value.as_bool()
437 {
438 return b && claim_value.eq_ignore_ascii_case("true");
440 }
441
442 false
443 }
444
445 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 pub fn issuer_url(&self) -> &str {
470 self.issuer_url.as_str()
471 }
472
473 pub fn settings(&self) -> &OAuth2Settings {
475 &self.settings
476 }
477
478 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 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
498fn 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
518fn get_string_array_from_json(payload: &serde_json::Value, name: &str) -> Option<Vec<String>> {
520 let value = payload.get(name)?;
521
522 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 if let Some(s) = value.as_str() {
536 return Some(vec![s.to_string()]);
537 }
538
539 None
540}
541
542fn sanitize_username(input: &str) -> String {
548 sanitize_username_impl(input, false)
549}
550
551fn 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 if result
570 .chars()
571 .next()
572 .is_none_or(|c| !c.is_ascii_alphabetic())
573 {
574 result = format!("u_{result}");
575 }
576
577 if result.len() > 64 {
579 result.truncate(64);
580 }
581
582 result
583}
584
585pub 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 if is_available(base.clone()).await {
597 return base;
598 }
599
600 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 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 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 let username = generate_unique_username(&user_info, |_| async { true }).await;
722 assert_eq!(username, "johndoe");
723
724 let username =
726 generate_unique_username(&user_info, |name| async move { name != "johndoe" }).await;
727 assert_eq!(username, "johndoe_2");
728
729 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 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#[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 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 struct MockProvider {
861 issuer: String,
862 key_a: CoreRsaPrivateSigningKey,
863 key_b: CoreRsaPrivateSigningKey,
864 rotated: AtomicBool,
866 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 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 #[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 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 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 provider.rotated.store(true, Ordering::SeqCst);
999
1000 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 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}