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_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 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 let username = generate_unique_username(&user_info, |_| async { true }).await;
721 assert_eq!(username, "johndoe");
722
723 let username =
725 generate_unique_username(&user_info, |name| async move { name != "johndoe" }).await;
726 assert_eq!(username, "johndoe_2");
727
728 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 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#[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 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 struct MockProvider {
860 issuer: String,
861 key_a: CoreRsaPrivateSigningKey,
862 key_b: CoreRsaPrivateSigningKey,
863 rotated: AtomicBool,
865 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 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 #[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 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 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 provider.rotated.store(true, Ordering::SeqCst);
998
999 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 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}