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, CoreIdTokenVerifier, CoreJsonWebKey,
13 CoreJweContentEncryptionAlgorithm, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse,
14 CoreTokenResponse,
15};
16use openidconnect::{
17 AuthorizationCode, ClaimsVerificationError, ClientId, ClientSecret, CsrfToken,
18 EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce,
19 PkceCodeChallenge, PkceCodeVerifier, ProviderMetadataWithLogout, RedirectUrl, Scope,
20 TokenResponse,
21};
22use serde::{Deserialize, Serialize};
23use thiserror::Error;
24use tracing::{info, trace, warn};
25use url::Url;
26
27use crate::oidc_http::{OidcHttpClient, error_chain};
28
29type ConfiguredCoreClient = openidconnect::Client<
31 EmptyAdditionalClaims,
32 CoreAuthDisplay,
33 CoreGenderClaim,
34 CoreJweContentEncryptionAlgorithm,
35 CoreJsonWebKey,
36 CoreAuthPrompt,
37 openidconnect::StandardErrorResponse<CoreErrorResponseType>,
38 CoreTokenResponse,
39 CoreTokenIntrospectionResponse,
40 openidconnect::core::CoreRevocableToken,
41 CoreRevocationErrorResponse,
42 EndpointSet, EndpointNotSet, EndpointNotSet, EndpointNotSet, EndpointMaybeSet, EndpointMaybeSet, >;
49
50#[derive(Debug, Error)]
52pub enum OAuth2Error {
53 #[error("OAuth2 is not enabled")]
54 NotEnabled,
55
56 #[error("OAuth2 configuration is invalid: {0}")]
57 ConfigurationError(String),
58
59 #[error("Failed to discover OIDC provider: {0}")]
60 DiscoveryError(String),
61
62 #[error("Failed to parse URL: {0}")]
63 UrlParseError(#[from] url::ParseError),
64
65 #[error("Failed to exchange authorization code: {0}")]
66 TokenExchangeError(String),
67
68 #[error("Failed to verify ID token: {0}")]
69 TokenVerificationError(String),
70
71 #[error("Missing required claim: {0}")]
72 MissingClaim(String),
73
74 #[error("HTTP request failed: {0}")]
75 HttpError(String),
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct UserInfo {
81 pub subject: String,
83 pub email: Option<String>,
85 pub preferred_username: Option<String>,
87 pub groups: Vec<String>,
89 pub is_admin: bool,
91 pub is_read_only: bool,
93}
94
95#[derive(Debug)]
97pub struct AuthRequest {
98 pub auth_url: Url,
100 pub state: String,
102 pub pkce_verifier: String,
104 pub nonce: String,
106}
107
108#[derive(Debug)]
110pub struct TokenResult {
111 pub claims: CoreIdTokenClaims,
113 pub raw_payload: serde_json::Value,
115 pub id_token: String,
117}
118
119pub struct OAuth2Handler {
121 client: Mutex<Arc<ConfiguredCoreClient>>,
124 client_id: ClientId,
125 client_secret: Option<ClientSecret>,
126 redirect_url: RedirectUrl,
127 settings: Arc<OAuth2Settings>,
128 issuer_url: IssuerUrl,
129 http_client: OidcHttpClient,
130 end_session_endpoint: Mutex<Arc<Option<String>>>,
131}
132
133impl OAuth2Handler {
134 pub async fn from_discovery(
139 settings: &OAuth2Settings,
140 redirect_url: &str,
141 ) -> Result<Self, OAuth2Error> {
142 if !settings.enabled {
143 return Err(OAuth2Error::NotEnabled);
144 }
145
146 settings
148 .validate()
149 .map_err(OAuth2Error::ConfigurationError)?;
150
151 let issuer_url_str = settings
152 .issuer_url
153 .as_ref()
154 .ok_or_else(|| OAuth2Error::ConfigurationError("Missing issuer_url".to_string()))?;
155
156 let issuer_url = IssuerUrl::new(issuer_url_str.clone())
157 .map_err(|e| OAuth2Error::ConfigurationError(format!("Invalid issuer URL: {e}")))?;
158
159 info!("Discovering OIDC provider at: {}", issuer_url_str);
160
161 let http_client =
162 OidcHttpClient::new().map_err(|e| OAuth2Error::HttpError(e.to_string()))?;
163
164 let client_id = ClientId::new(
165 settings
166 .client_id
167 .clone()
168 .ok_or_else(|| OAuth2Error::ConfigurationError("Missing client_id".to_string()))?,
169 );
170
171 let client_secret = settings.client_secret.clone().map(ClientSecret::new);
172
173 let redirect_url = RedirectUrl::new(redirect_url.to_string())?;
174
175 let (client, end_session_endpoint) = Self::discover_client(
179 &issuer_url,
180 &client_id,
181 client_secret.as_ref(),
182 &redirect_url,
183 &http_client,
184 )
185 .await?;
186
187 Ok(Self {
188 client: Mutex::new(Arc::new(client)),
189 client_id,
190 client_secret,
191 redirect_url,
192 settings: Arc::new(settings.clone()),
193 issuer_url,
194 http_client,
195 end_session_endpoint: Mutex::new(Arc::new(end_session_endpoint)),
196 })
197 }
198
199 async fn discover_client(
204 issuer_url: &IssuerUrl,
205 client_id: &ClientId,
206 client_secret: Option<&ClientSecret>,
207 redirect_url: &RedirectUrl,
208 http_client: &OidcHttpClient,
209 ) -> Result<(ConfiguredCoreClient, Option<String>), OAuth2Error> {
210 let provider_metadata =
211 ProviderMetadataWithLogout::discover_async(issuer_url.clone(), http_client)
212 .await
213 .map_err(|e| OAuth2Error::DiscoveryError(error_chain(&e)))?;
214
215 let end_session_endpoint = provider_metadata
216 .additional_metadata()
217 .end_session_endpoint
218 .as_ref()
219 .map(ToString::to_string);
220
221 let client = CoreClient::from_provider_metadata(
222 provider_metadata,
223 client_id.clone(),
224 client_secret.cloned(),
225 )
226 .set_redirect_uri(redirect_url.clone());
227
228 Ok((client, end_session_endpoint))
229 }
230
231 pub fn end_session_url(
234 &self,
235 id_token_hint: &str,
236 post_logout_redirect_uri: &str,
237 ) -> Option<String> {
238 let endpoint = self
239 .end_session_endpoint
240 .lock()
241 .expect("OAuth2 end session endpoint mutex poisoned");
242 let mut url = Url::parse(endpoint.as_ref().as_ref()?).ok()?;
243 url.query_pairs_mut()
244 .append_pair("id_token_hint", id_token_hint)
245 .append_pair("client_id", self.client_id.as_str())
246 .append_pair("post_logout_redirect_uri", post_logout_redirect_uri);
247 Some(url.into())
248 }
249
250 fn current_client(&self) -> Arc<ConfiguredCoreClient> {
251 self.client
252 .lock()
253 .expect("OAuth2 client mutex poisoned")
254 .clone()
255 }
256
257 async fn rediscover(
262 &self,
263 ) -> Result<(Arc<ConfiguredCoreClient>, Arc<Option<String>>), OAuth2Error> {
264 Self::discover_client(
265 &self.issuer_url,
266 &self.client_id,
267 self.client_secret.as_ref(),
268 &self.redirect_url,
269 &self.http_client,
270 )
271 .await
272 .map(|(client, end_session_endpoint)| (Arc::new(client), Arc::new(end_session_endpoint)))
273 }
274
275 fn id_token_verifier<'a>(&self, client: &'a ConfiguredCoreClient) -> CoreIdTokenVerifier<'a> {
281 let additional_audiences = self.settings.additional_audiences.clone();
282 client
283 .id_token_verifier()
284 .set_other_audience_verifier_fn(move |aud| {
285 additional_audiences
286 .iter()
287 .any(|allowed| allowed == aud.as_str())
288 })
289 }
290
291 pub fn generate_auth_url(&self) -> AuthRequest {
297 let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
298
299 let client = self.current_client();
300
301 let mut auth_request = client
303 .authorize_url(
304 CoreAuthenticationFlow::AuthorizationCode,
305 CsrfToken::new_random,
306 Nonce::new_random,
307 )
308 .set_pkce_challenge(pkce_challenge);
309
310 for scope in &self.settings.scopes {
312 auth_request = auth_request.add_scope(Scope::new(scope.clone()));
313 }
314
315 let (auth_url, csrf_state, nonce) = auth_request.url();
316
317 AuthRequest {
318 auth_url,
319 state: csrf_state.secret().clone(),
320 pkce_verifier: pkce_verifier.secret().clone(),
321 nonce: nonce.secret().clone(),
322 }
323 }
324
325 pub async fn exchange_and_validate(
335 &self,
336 code: &str,
337 pkce_verifier: &str,
338 nonce: &str,
339 ) -> Result<TokenResult, OAuth2Error> {
340 let code = AuthorizationCode::new(code.to_string());
341 let pkce_verifier = PkceCodeVerifier::new(pkce_verifier.to_string());
342
343 let client = self.current_client();
344
345 let token_request = client
346 .exchange_code(code)
347 .map_err(|e| OAuth2Error::TokenExchangeError(e.to_string()))?;
348
349 let token_response: CoreTokenResponse = token_request
350 .set_pkce_verifier(pkce_verifier)
351 .request_async(&self.http_client)
352 .await
353 .map_err(|e| OAuth2Error::TokenExchangeError(error_chain(&e)))?;
354
355 let id_token = token_response
357 .id_token()
358 .ok_or_else(|| OAuth2Error::MissingClaim("id_token".to_string()))?;
359
360 let nonce = Nonce::new(nonce.to_string());
361
362 let verifier = self.id_token_verifier(&client);
363
364 let claims = match id_token.claims(&verifier, &nonce) {
365 Ok(claims) => claims.clone(),
366 Err(ClaimsVerificationError::SignatureVerification(_)) => {
370 trace!(
371 "ID token signature verification failed; refreshing OIDC JWKS and retrying (provider may have rotated signing keys)"
372 );
373 let (refreshed_client, refreshed_end_session_endpoint) = self.rediscover().await?;
374 *self
375 .end_session_endpoint
376 .lock()
377 .expect("OAuth2 end session endpoint mutex poisoned") =
378 refreshed_end_session_endpoint;
379 let claims = {
382 let verifier = self.id_token_verifier(&refreshed_client);
383 id_token
384 .claims(&verifier, &nonce)
385 .map_err(|e| OAuth2Error::TokenVerificationError(e.to_string()))?
386 .clone()
387 };
388 *self.client.lock().expect("OAuth2 client mutex poisoned") = refreshed_client;
391 claims
392 }
393 Err(e) => return Err(OAuth2Error::TokenVerificationError(e.to_string())),
394 };
395
396 let raw_payload = extract_jwt_payload(id_token.to_string().as_str())
398 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
399
400 Ok(TokenResult {
401 claims,
402 raw_payload,
403 id_token: id_token.to_string(),
404 })
405 }
406
407 pub fn extract_user_info(&self, result: &TokenResult) -> UserInfo {
412 let subject = result.claims.subject().as_str().to_string();
413
414 let email = result.claims.email().map(|e| e.as_str().to_string());
415
416 let preferred_username = result
417 .claims
418 .preferred_username()
419 .map(|u| u.as_str().to_string());
420
421 let groups = self.extract_groups(&result.raw_payload);
423
424 let is_admin = claim_contains(
427 &result.raw_payload,
428 self.settings.admin_group_claim.as_deref(),
429 self.settings.admin_group_value.as_deref(),
430 );
431 let is_read_only = claim_contains(
432 &result.raw_payload,
433 self.settings.read_only_group_claim.as_deref(),
434 self.settings.read_only_group_value.as_deref(),
435 );
436
437 UserInfo {
438 subject,
439 email,
440 preferred_username,
441 groups,
442 is_admin,
443 is_read_only,
444 }
445 }
446
447 fn extract_groups(&self, payload: &serde_json::Value) -> Vec<String> {
449 if let Some(claim_name) = &self.settings.admin_group_claim
451 && let Some(groups) = get_string_array_from_json(payload, claim_name)
452 {
453 return groups;
454 }
455
456 if let Some(claim_name) = &self.settings.read_only_group_claim
458 && self.settings.admin_group_claim.as_ref() != Some(claim_name)
459 && let Some(groups) = get_string_array_from_json(payload, claim_name)
460 {
461 return groups;
462 }
463
464 for claim_name in &["groups", "roles", "group"] {
466 if let Some(groups) = get_string_array_from_json(payload, claim_name) {
467 return groups;
468 }
469 }
470
471 Vec::new()
472 }
473
474 pub fn generate_username(user_info: &UserInfo) -> String {
481 if let Some(username) = &user_info.preferred_username
482 && !username.is_empty()
483 {
484 return sanitize_username_with_dots(username);
485 }
486
487 if let Some(email) = &user_info.email
488 && let Some(local_part) = email.split('@').next()
489 && !local_part.is_empty()
490 {
491 return sanitize_username_with_dots(local_part);
492 }
493
494 sanitize_username(&user_info.subject)
495 }
496
497 pub fn issuer_url(&self) -> &str {
499 self.issuer_url.as_str()
500 }
501
502 pub fn settings(&self) -> &OAuth2Settings {
504 &self.settings
505 }
506
507 pub fn admin_claim_configured(&self) -> bool {
514 self.settings.admin_group_claim.is_some() && self.settings.admin_group_value.is_some()
515 }
516
517 pub fn read_only_claim_configured(&self) -> bool {
522 self.settings.read_only_group_claim.is_some()
523 && self.settings.read_only_group_value.is_some()
524 }
525}
526
527fn extract_jwt_payload(jwt: &str) -> Result<serde_json::Value, OAuth2Error> {
529 use base64::Engine;
530
531 let parts: Vec<&str> = jwt.split('.').collect();
532 if parts.len() != 3 {
533 return Err(OAuth2Error::TokenVerificationError(
534 "Invalid JWT format".to_string(),
535 ));
536 }
537
538 let payload_b64 = parts[1];
539 let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
540 .decode(payload_b64)
541 .map_err(|e| OAuth2Error::TokenVerificationError(format!("Base64 decode error: {e}")))?;
542
543 serde_json::from_slice(&payload_bytes)
544 .map_err(|e| OAuth2Error::TokenVerificationError(format!("JSON parse error: {e}")))
545}
546
547fn claim_contains(
553 payload: &serde_json::Value,
554 claim_name: Option<&str>,
555 claim_value: Option<&str>,
556) -> bool {
557 let (Some(claim_name), Some(claim_value)) = (claim_name, claim_value) else {
558 return false;
559 };
560
561 if let Some(values) = get_string_array_from_json(payload, claim_name) {
562 return values.iter().any(|v| v == claim_value);
563 }
564
565 payload
566 .get(claim_name)
567 .and_then(serde_json::Value::as_bool)
568 .is_some_and(|b| b && claim_value.eq_ignore_ascii_case("true"))
569}
570
571fn get_string_array_from_json(payload: &serde_json::Value, name: &str) -> Option<Vec<String>> {
572 let value = payload.get(name)?;
573
574 if let Some(arr) = value.as_array() {
576 let strings: Vec<String> = arr
577 .iter()
578 .filter_map(serde_json::Value::as_str)
579 .map(String::from)
580 .collect();
581 if !strings.is_empty() {
582 return Some(strings);
583 }
584 }
585
586 if let Some(s) = value.as_str() {
588 return Some(vec![s.to_string()]);
589 }
590
591 None
592}
593
594fn sanitize_username(input: &str) -> String {
600 sanitize_username_impl(input, false)
601}
602
603fn sanitize_username_with_dots(input: &str) -> String {
605 sanitize_username_impl(input, true)
606}
607
608fn sanitize_username_impl(input: &str, allow_dot: bool) -> String {
609 let mut result: String = input
610 .chars()
611 .map(|c| {
612 if c.is_ascii_alphanumeric() || c == '_' || c == '-' || (allow_dot && c == '.') {
613 c.to_ascii_lowercase()
614 } else {
615 '_'
616 }
617 })
618 .collect();
619
620 if result
622 .chars()
623 .next()
624 .is_none_or(|c| !c.is_ascii_alphabetic())
625 {
626 result = format!("u_{result}");
627 }
628
629 if result.len() > 64 {
631 result.truncate(64);
632 }
633
634 result
635}
636
637pub async fn generate_unique_username<F, Fut>(user_info: &UserInfo, is_available: F) -> String
641where
642 F: Fn(String) -> Fut,
643 Fut: Future<Output = bool>,
644{
645 let base = OAuth2Handler::generate_username(user_info);
646
647 if is_available(base.clone()).await {
649 return base;
650 }
651
652 for i in 2..=100 {
654 let candidate = format!("{base}_{i}");
655 if is_available(candidate.clone()).await {
656 return candidate;
657 }
658 }
659
660 warn!("Could not find unique username after 100 attempts, using fallback");
662 format!(
663 "{}_{:x}",
664 sanitize_username(&user_info.subject),
665 std::time::SystemTime::now()
666 .duration_since(std::time::UNIX_EPOCH)
667 .map_or(0, |d| d.as_secs())
668 )
669}
670
671#[cfg(test)]
672mod tests {
673 use serde_json::json;
674
675 use super::*;
676
677 #[test]
678 fn claim_contains_matches_configured_array_claim() {
679 let payload = json!({ "kellnr_roles": ["dev", "admin"] });
680
681 assert!(claim_contains(
682 &payload,
683 Some("kellnr_roles"),
684 Some("admin")
685 ));
686 assert!(!claim_contains(&payload, Some("kellnr_roles"), Some("ops")));
687 }
688
689 #[test]
690 fn claim_contains_matches_string_and_bool_claims() {
691 let payload = json!({ "role": "admin", "is_admin": true, "is_ro": false });
692
693 assert!(claim_contains(&payload, Some("role"), Some("admin")));
694 assert!(claim_contains(&payload, Some("is_admin"), Some("true")));
695 assert!(!claim_contains(&payload, Some("is_ro"), Some("true")));
696 }
697
698 #[test]
701 fn claim_contains_ignores_fallback_group_claims() {
702 let payload = json!({
703 "groups": ["kellnr-admins"],
704 "roles": ["kellnr-admins"],
705 "group": "kellnr-admins",
706 });
707
708 assert!(!claim_contains(
709 &payload,
710 Some("kellnr_roles"),
711 Some("kellnr-admins")
712 ));
713 }
714
715 #[test]
718 fn claim_contains_does_not_mix_claims() {
719 let payload = json!({ "ro_groups": ["kellnr-admins"] });
720
721 assert!(!claim_contains(
722 &payload,
723 Some("admin_groups"),
724 Some("kellnr-admins")
725 ));
726 }
727
728 #[test]
729 fn claim_contains_requires_claim_and_value() {
730 let payload = json!({ "groups": ["admin"] });
731
732 assert!(!claim_contains(&payload, None, Some("admin")));
733 assert!(!claim_contains(&payload, Some("groups"), None));
734 assert!(claim_contains(&payload, Some("groups"), Some("admin")));
735 }
736
737 #[test]
738 fn test_sanitize_username() {
739 assert_eq!(sanitize_username("JohnDoe"), "johndoe");
740 assert_eq!(sanitize_username("john.doe"), "john_doe");
741 assert_eq!(sanitize_username("john@example.com"), "john_example_com");
742 assert_eq!(sanitize_username("123user"), "u_123user");
743 assert_eq!(sanitize_username("_user"), "u__user");
744 assert_eq!(sanitize_username("user-name"), "user-name");
745 }
746
747 #[test]
748 fn test_sanitize_username_with_dots() {
749 assert_eq!(sanitize_username_with_dots("john.doe"), "john.doe");
750 assert_eq!(
751 sanitize_username_with_dots("john@example.com"),
752 "john_example.com"
753 );
754 }
755
756 #[test]
757 fn test_generate_username_preferred() {
758 let user_info = UserInfo {
759 subject: "sub123".to_string(),
760 email: Some("john@example.com".to_string()),
761 preferred_username: Some("johndoe".to_string()),
762 groups: vec![],
763 is_admin: false,
764 is_read_only: false,
765 };
766 assert_eq!(OAuth2Handler::generate_username(&user_info), "johndoe");
767 }
768
769 #[test]
770 fn test_generate_username_preferred_preserves_dot() {
771 let user_info = UserInfo {
772 subject: "sub123".to_string(),
773 email: Some("john@example.com".to_string()),
774 preferred_username: Some("john.doe".to_string()),
775 groups: vec![],
776 is_admin: false,
777 is_read_only: false,
778 };
779 assert_eq!(OAuth2Handler::generate_username(&user_info), "john.doe");
780 }
781
782 #[test]
783 fn test_generate_username_email() {
784 let user_info = UserInfo {
785 subject: "sub123".to_string(),
786 email: Some("john@example.com".to_string()),
787 preferred_username: None,
788 groups: vec![],
789 is_admin: false,
790 is_read_only: false,
791 };
792 assert_eq!(OAuth2Handler::generate_username(&user_info), "john");
793 }
794
795 #[test]
796 fn test_generate_username_email_preserves_dot_in_local_part() {
797 let user_info = UserInfo {
798 subject: "sub123".to_string(),
799 email: Some("john.doe@example.com".to_string()),
800 preferred_username: None,
801 groups: vec![],
802 is_admin: false,
803 is_read_only: false,
804 };
805 assert_eq!(OAuth2Handler::generate_username(&user_info), "john.doe");
806 }
807
808 #[test]
809 fn test_generate_username_subject() {
810 let user_info = UserInfo {
811 subject: "sub123".to_string(),
812 email: None,
813 preferred_username: None,
814 groups: vec![],
815 is_admin: false,
816 is_read_only: false,
817 };
818 assert_eq!(OAuth2Handler::generate_username(&user_info), "sub123");
820 }
821
822 #[tokio::test]
823 async fn test_generate_unique_username() {
824 let user_info = UserInfo {
825 subject: "sub123".to_string(),
826 email: Some("john@example.com".to_string()),
827 preferred_username: Some("johndoe".to_string()),
828 groups: vec![],
829 is_admin: false,
830 is_read_only: false,
831 };
832
833 let username = generate_unique_username(&user_info, |_| async { true }).await;
835 assert_eq!(username, "johndoe");
836
837 let username =
839 generate_unique_username(&user_info, |name| async move { name != "johndoe" }).await;
840 assert_eq!(username, "johndoe_2");
841
842 let username = generate_unique_username(&user_info, |name| async move {
844 name != "johndoe" && name != "johndoe_2"
845 })
846 .await;
847 assert_eq!(username, "johndoe_3");
848 }
849
850 #[test]
851 fn test_extract_jwt_payload() {
852 let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
854 let payload = extract_jwt_payload(jwt).unwrap();
855 assert_eq!(
856 payload.get("sub").and_then(|v| v.as_str()),
857 Some("1234567890")
858 );
859 assert_eq!(
860 payload.get("name").and_then(|v| v.as_str()),
861 Some("John Doe")
862 );
863 }
864
865 #[test]
866 fn test_get_string_array_from_json() {
867 let payload = serde_json::json!({
868 "groups": ["admin", "users"],
869 "single_group": "single",
870 "number": 42
871 });
872
873 assert_eq!(
874 get_string_array_from_json(&payload, "groups"),
875 Some(vec!["admin".to_string(), "users".to_string()])
876 );
877 assert_eq!(
878 get_string_array_from_json(&payload, "single_group"),
879 Some(vec!["single".to_string()])
880 );
881 assert_eq!(get_string_array_from_json(&payload, "number"), None);
882 assert_eq!(get_string_array_from_json(&payload, "missing"), None);
883 }
884}
885
886#[cfg(test)]
893mod provider_tests {
894 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
895
896 use axum::extract::State;
897 use axum::routing::{get, post};
898 use axum::{Json, Router};
899 use chrono::{Duration, Utc};
900 use openidconnect::core::{
901 CoreIdToken, CoreJsonWebKeySet, CoreJwsSigningAlgorithm, CoreRsaPrivateSigningKey,
902 };
903 use openidconnect::{
904 Audience, EmptyAdditionalClaims, EndUserEmail, JsonWebKeyId, PrivateSigningKey,
905 StandardClaims, SubjectIdentifier,
906 };
907 use serde_json::{Value, json};
908 use tokio::net::TcpListener;
909 use tokio::task::JoinHandle;
910
911 use super::*;
912
913 const KEY_A_PEM: &str = "-----BEGIN RSA PRIVATE KEY-----
915MIIEpAIBAAKCAQEAtZ/uvzCiQirPAQf0w4piStS+zUlL6A3/ZTpTOJCpnRHVtnfh
916EPbqtQ+ohvkB8txKg2nlgDZ5+zS62jtvb2BYzwkH5qU4jlpDEOnwKVtTTjOrCWZx
917a8DVihgvcVkrYREOMRPzlXbF2aACwQAaveM8sbfu0cZ49SmQ34RQNqA8Gs+v/Nmr
918jYwwZ9rQ4XIBYE689TiBjtZJVvQa7I8Waj8euYxWP5rB1tpZVtgz2AARTn5iSgNX
919KM40IaMWLcvVUajPgYdcHE75+GLCzQAeEY4tboYo8ehFuMuehizj5sHz8ghz6reU
920D83gE5wlLndJj/c/9apvvyBYmvWyar5HIPcvEQIDAQABAoIBAAEXqMi+82H6h4JM
921duFvLowyMMlgFQr7vldpSPuSYxBEhN2YoBtJOr34T5VXrK0rPAELDzNduEmepRoK
922KHpiqSd1EJsAPVGLeWkxecPD29eoKFRjcdLT/l30ZdQVJZOa5lP+0ByGpUmhTw1y
923J98/24TXcbRjGtUsQ+4F1rnYIRVVY+RsrtWs2IaLHR8RnI6M3rFjY7y5dKX9x3Wn
924ERC1Nx7blrUMvzK1052qUTC6sCYku2nJXUKBylj095GVZpK9oje62J5c9+SSvuOQ
925MFEX0JSXfpCvoReP+9Ywa+vf+jpywja7hAIbH+bAqEnWNdrsuicTbUaRM39D1MB1
926XbKoemkCgYEA4wSokLEgQggsQ+HEjr5cTXE/dDjokp3CuuXAJg0mAhLWZro3PZnL
927dOElavpRwP4ISt3Lj47CJWcQAPGzl+BuGKPyQkRPhFkpBsUCdxgBi1eA4Suq7xa+
928ARhMEN9Ayfszh2xLD/dRgOh/cVi/x7N3IfihJ9Jd9L3p4TTCjngUC5UCgYEAzM+9
929DtPCdsNfiWtqtK/YolHUMZo3mb8Vu0e3fecIgUH3neOm/XhtxJY2wHehemyFJkYE
930f93Vp50GXEmmmPVnjXuYmrIk3jEjk3j4hn7/gi37EzNzgbSf1RN09swU31FJ5N+R
931/XZDH5zA+ZRIKU+CW8ap4cDTa1hBJNjVLqW2Fo0CgYEA1AFetjlkCaZ+SCqIGFI3
932+u5+trgKohmIaGf1CNQQobEb3rWarwF4Wr+D5SK9xIC4F8qHtpo4Pxu/e1I9SOGD
933j6lTrYUDyXJGeRb01Wlqz8k5B49zQ3K2oGkjaEJFzBq2pYqBkviBeeQmWCDsgL/d
934yrDZN0ojClNtHi7aXphPB/0CgYEAr5nVYOcSrjzopqvgezbhqJo8MqMk1L9O5Jmi
935q2HwmtJyeX78aApfItQf8XkgjBSLPLt/lBog22r4TxweqLqPpHC58LiYf6Dl/cUU
936YEx2yaiewmG0wRqah1f9SrTDmIzbrE47n3NMLch6dAI8tJ6lCAcXFKX9HuY2RF9c
937uHf/3OkCgYA2KePOq4vGOGD+zKDFrI7PSJcEa62oczZidgOylc/Lr9qx2Oiz4HM9
938Di85C5B3y/3ZnYf8+1nTPdvfqTPt4aMjJKBVVWvkEA5yPYmY5FY+Z3sAA4AzGZ8Z
939Xx2ZJaH0xsmAtrhSK8StLDiXayT8ygyZnqXfbZS0nG2HX3tKksYi0Q==
940-----END RSA PRIVATE KEY-----";
941
942 const KEY_B_PEM: &str = "-----BEGIN RSA PRIVATE KEY-----
943MIIEowIBAAKCAQEArOXZ5D7VxWmRkijS8CXQznmFL5bf3XDJ/Vll1imhJjU/sOJP
944aob5U40xIV+b0LbNhxP8C3lu+TKF3xldvv7lYTjXYfVZ3fwjfBMe6Vm5PYr31FiR
945KQLNrTrBE2cTCDoCn51NMERw/fdt0zJmfDcA77o1T6us2jxnDlrddiWYyBs26Y8p
946dYFacxObDsJwqmE60ijP92SR37NjMdwUNDg1Bzh6xw3puAP4KeFwWNNVPHwQpJEd
947pKX3T+CL2Ybfboyh9XCo2FmawfWjoIgUVoecgdpDMgNqtPjQ2HUvsH5JsWCezduE
948MW7sPloZr9doGnYVycuBCKOldwriCFJqjw2VlwIDAQABAoIBAD9lhohFLABXbcuw
949kWwCCbbz4wyon0xko4P0qD0nhZHre3+h8+nFNR3YSzgIBSu6I9GQV95jN/hC+Mht
9501iyG7VfBTmR6YOnfHqnLnw2EW0KANtBTa2KkxwLqZMp3BIkDMFwTgy6cIexVshz7
951QY3xYzQDzLF6awaYmFcwpTzBm2xfxmsjDODnMeeq4YraC/qrLn6yxFC3jH4H5fCu
952mlzYrGHAEN7mdIOQEktJXwyEzi0rVKHDfJEhStGS7gMILw7BZfFSQxnzXyHJEOt7
9533ZJY40N23H2iKaqN3s1Af56PbOcd9xmv3KM0lcmrmp2/cpoZTGSQgHNLZXB8bQ76
954L+u/WyECgYEA8hfrlpn1O66Jh7JKNl8iLFoiBGBozsyZrx0KP0yyFJ4apk7U6GzE
9557dnZJQ6sXklyE1gHSjArtICDnF8I26Jovl7EfijbQr+E4zuQhcZG3vuBzF0/8hy/
956D//QLKXvokqevEvYnt7V81Io17K80R11U5Elxhl1YwxKOMd468MUr3cCgYEAttRh
9576baK/NgktXkDhOf5Mmcz1MtUO+gop8lpOQifcPslE//6M1DE3G+6oRSZuTApSP5o
958NjwhHbu0ko/zbWWvJ760bdcv98R6z60AHFq3TZrG5R4f68o4aD+Llu1mLt26QppP
959vnby8CcCbcvCBXM4xzrORp7LOFs7fx8IsJL/EuECgYAepEZuVmhCuJGxujBId3wU
960zwe1NBqv6hedoXhVkLiNgYFwAVRTYsj+Le+dECFjAbrNlfu+OSCfBREjbqfpXMIT
961Ll2CdltiNGl2dWiSdgksWfsQydC5LUhFlyRbMlmFWhqDTLpLSXsdBA4lVvstIKRk
962AmAclcZU1g5i52R3usZYewKBgGPCn2qXYF920RD0ZycuLJuEFJQYHm5Rz10+WVv1
963iIptf13aXvuBJunhTUR3qSmTTfO2Xca6KJfAxFb0lS8sPNbDCFCnClV3sEuBan4O
964QdbmjYCXX3OZdA3uHozMHOWVtvyAluKmpQjFQF4IwGWY6XJMdCG0o86seVpz5Jn0
965m5oBAoGBAL/uJIrh8ZiL1eQrhJTitkW7BgV6iTN518Pf/I9DzOMnq/kG3W7zhQ2m
966lJZsY5m/jNCs0KrTBBEoVQzkPVlzoQ/dDYZyA2cj4LydXyuM76yth8F+DMr3ckN6
967Y4dOyrc/PytM2BLxs06WhIWeneUpz64RtUlvZUrSDgnO5AQX0ba2
968-----END RSA PRIVATE KEY-----";
969
970 const CLIENT_ID: &str = "test-client";
971 const OTHER_AUDIENCE: &str = "other-audience";
972 const SUBJECT: &str = "user-subject-123";
973 const NONCE: &str = "test-nonce";
974 const REDIRECT_URI: &str = "http://localhost/cb";
975
976 const END_SESSION_ENDPOINT_A: &str = "http://logout-a";
977 const END_SESSION_ENDPOINT_B: &str = "http://logout-b";
978
979 struct MockProvider {
981 issuer: String,
982 key_a: CoreRsaPrivateSigningKey,
983 key_b: CoreRsaPrivateSigningKey,
984 end_session_endpoint_a: String,
985 end_session_endpoint_b: String,
986 rotated: AtomicBool,
989 jwks_fetches: AtomicUsize,
992 include_client_audience: AtomicBool,
994 include_other_audience: AtomicBool,
996 malformed_token_response: AtomicBool,
998 }
999
1000 impl MockProvider {
1001 fn active_key(&self) -> &CoreRsaPrivateSigningKey {
1002 if self.rotated.load(Ordering::SeqCst) {
1003 &self.key_b
1004 } else {
1005 &self.key_a
1006 }
1007 }
1008
1009 fn active_end_session_endpoint(&self) -> &str {
1010 if self.rotated.load(Ordering::SeqCst) {
1011 &self.end_session_endpoint_b
1012 } else {
1013 &self.end_session_endpoint_a
1014 }
1015 }
1016
1017 fn sign_id_token(&self) -> String {
1019 let mut audiences = Vec::new();
1020 if self.include_client_audience.load(Ordering::SeqCst) {
1021 audiences.push(Audience::new(CLIENT_ID.to_string()));
1022 }
1023 if self.include_other_audience.load(Ordering::SeqCst) {
1024 audiences.push(Audience::new(OTHER_AUDIENCE.to_string()));
1025 }
1026
1027 let claims = CoreIdTokenClaims::new(
1028 IssuerUrl::new(self.issuer.clone()).unwrap(),
1029 audiences,
1030 Utc::now() + Duration::minutes(5),
1031 Utc::now(),
1032 StandardClaims::new(SubjectIdentifier::new(SUBJECT.to_string()))
1033 .set_email(Some(EndUserEmail::new("user@example.com".to_string()))),
1034 EmptyAdditionalClaims {},
1035 )
1036 .set_nonce(Some(Nonce::new(NONCE.to_string())));
1037
1038 let id_token = CoreIdToken::new(
1039 claims,
1040 self.active_key(),
1041 CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256,
1042 None,
1043 None,
1044 )
1045 .expect("signing ID token");
1046
1047 serde_json::to_value(&id_token)
1048 .expect("serialize ID token")
1049 .as_str()
1050 .expect("ID token is a JWT string")
1051 .to_string()
1052 }
1053 }
1054
1055 async fn discovery(State(state): State<Arc<MockProvider>>) -> Json<Value> {
1056 Json(json!({
1057 "issuer": state.issuer,
1058 "authorization_endpoint": format!("{}/authorize", state.issuer),
1059 "token_endpoint": format!("{}/token", state.issuer),
1060 "end_session_endpoint": state.active_end_session_endpoint(),
1061 "jwks_uri": format!("{}/jwks", state.issuer),
1062 "response_types_supported": ["code"],
1063 "subject_types_supported": ["public"],
1064 "id_token_signing_alg_values_supported": ["RS256"],
1065 }))
1066 }
1067
1068 async fn jwks(State(state): State<Arc<MockProvider>>) -> Json<Value> {
1069 state.jwks_fetches.fetch_add(1, Ordering::SeqCst);
1070 let jwks = CoreJsonWebKeySet::new(vec![state.active_key().as_verification_key()]);
1071 Json(serde_json::to_value(&jwks).expect("serialize JWKS"))
1072 }
1073
1074 async fn token(State(state): State<Arc<MockProvider>>) -> Json<Value> {
1075 if state.malformed_token_response.load(Ordering::SeqCst) {
1076 return Json(json!({}));
1077 }
1078 Json(json!({
1079 "access_token": "access-token",
1080 "token_type": "Bearer",
1081 "expires_in": 3600,
1082 "id_token": state.sign_id_token(),
1083 }))
1084 }
1085
1086 fn test_settings(issuer: &str, additional_audiences: Vec<String>) -> OAuth2Settings {
1087 OAuth2Settings {
1088 enabled: true,
1089 issuer_url: Some(issuer.to_string()),
1090 client_id: Some(CLIENT_ID.to_string()),
1091 client_secret: Some("test-secret".to_string()),
1092 additional_audiences,
1093 ..Default::default()
1094 }
1095 }
1096
1097 async fn start_mock_provider() -> (Arc<MockProvider>, String, JoinHandle<()>) {
1102 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1103 let issuer = format!("http://{}", listener.local_addr().unwrap());
1104
1105 let provider = Arc::new(MockProvider {
1106 issuer: issuer.clone(),
1107 key_a: CoreRsaPrivateSigningKey::from_pem(
1108 KEY_A_PEM,
1109 Some(JsonWebKeyId::new("key-a".to_string())),
1110 )
1111 .unwrap(),
1112 key_b: CoreRsaPrivateSigningKey::from_pem(
1113 KEY_B_PEM,
1114 Some(JsonWebKeyId::new("key-b".to_string())),
1115 )
1116 .unwrap(),
1117 end_session_endpoint_a: END_SESSION_ENDPOINT_A.to_string(),
1118 end_session_endpoint_b: END_SESSION_ENDPOINT_B.to_string(),
1119 rotated: AtomicBool::new(false),
1120 jwks_fetches: AtomicUsize::new(0),
1121 include_client_audience: AtomicBool::new(true),
1122 include_other_audience: AtomicBool::new(false),
1123 malformed_token_response: AtomicBool::new(false),
1124 });
1125
1126 let app = Router::new()
1127 .route("/.well-known/openid-configuration", get(discovery))
1128 .route("/jwks", get(jwks))
1129 .route("/token", post(token))
1130 .with_state(provider.clone());
1131 let server = tokio::spawn(async move {
1132 axum::serve(listener, app.into_make_service())
1133 .await
1134 .unwrap();
1135 });
1136
1137 (provider, issuer, server)
1138 }
1139
1140 fn assert_audience_error(error: &OAuth2Error) {
1143 match error {
1144 OAuth2Error::TokenVerificationError(message) => assert!(
1145 message.to_ascii_lowercase().contains("audience"),
1146 "expected an audience verification error, got: {message}"
1147 ),
1148 error => panic!("expected an audience verification error, got {error:?}"),
1149 }
1150 }
1151
1152 #[tokio::test]
1155 async fn exchange_recovers_from_signing_key_rotation() {
1156 let (provider, issuer, server) = start_mock_provider().await;
1157
1158 let handler = OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI)
1160 .await
1161 .expect("discovery should succeed");
1162 assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 1);
1163
1164 let result = handler
1167 .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1168 .await
1169 .expect("baseline exchange should verify");
1170 assert_eq!(result.claims.subject().as_str(), SUBJECT);
1171 assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 1);
1172
1173 provider.rotated.store(true, Ordering::SeqCst);
1176
1177 let result = handler
1180 .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1181 .await
1182 .expect("exchange should succeed after JWKS refresh");
1183 assert_eq!(result.claims.subject().as_str(), SUBJECT);
1184 assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 2);
1185
1186 let result = handler
1189 .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1190 .await
1191 .expect("exchange should reuse the refreshed JWKS");
1192 assert_eq!(result.claims.subject().as_str(), SUBJECT);
1193 assert_eq!(provider.jwks_fetches.load(Ordering::SeqCst), 2);
1194
1195 server.abort();
1196 }
1197
1198 #[tokio::test]
1200 async fn rediscovery_updates_end_session_endpoint() {
1201 let (provider, issuer, server) = start_mock_provider().await;
1202
1203 let handler = OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI)
1205 .await
1206 .expect("discovery should succeed");
1207 let url_before = handler
1208 .end_session_url("id-token", "http://kellnr/")
1209 .expect("discovery should pick up the end session endpoint");
1210 assert!(
1211 url_before.starts_with(END_SESSION_ENDPOINT_A),
1212 "expected logout URL built from endpoint A, got {url_before}"
1213 );
1214
1215 provider.rotated.store(true, Ordering::SeqCst);
1217
1218 let _: TokenResult = handler
1220 .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1221 .await
1222 .expect("exchange should succeed after rotation");
1223
1224 let url_after = handler
1225 .end_session_url("id-token", "http://kellnr/")
1226 .expect("rediscovery should keep an end session endpoint");
1227 assert!(
1228 url_after.starts_with(END_SESSION_ENDPOINT_B),
1229 "expected logout URL built from endpoint B after rotation, got {url_after}"
1230 );
1231
1232 server.abort();
1233 }
1234
1235 #[tokio::test]
1238 async fn end_session_url_contains_logout_parameters() {
1239 let (_provider, issuer, server) = start_mock_provider().await;
1240
1241 let handler = OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI)
1242 .await
1243 .expect("discovery should succeed");
1244
1245 let url = handler
1246 .end_session_url("the-id-token", "http://kellnr/")
1247 .expect("provider advertises an end session endpoint");
1248 let parsed = Url::parse(&url).expect("logout URL should be a valid URL");
1249 let params: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
1250
1251 assert_eq!(
1252 params.get("id_token_hint").map(String::as_str),
1253 Some("the-id-token")
1254 );
1255 assert_eq!(params.get("client_id").map(String::as_str), Some(CLIENT_ID));
1256 assert_eq!(
1257 params.get("post_logout_redirect_uri").map(String::as_str),
1258 Some("http://kellnr/")
1259 );
1260
1261 server.abort();
1262 }
1263
1264 #[tokio::test]
1268 async fn discovery_error_includes_underlying_cause() {
1269 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1271 let issuer = format!("http://{}", listener.local_addr().unwrap());
1272 drop(listener);
1273
1274 let Err(OAuth2Error::DiscoveryError(message)) =
1275 OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI).await
1276 else {
1277 panic!("discovery against a closed port should fail");
1278 };
1279
1280 assert!(
1281 message.starts_with("Request failed: "),
1282 "unexpected message: {message}"
1283 );
1284 }
1285
1286 #[tokio::test]
1289 async fn token_exchange_error_includes_underlying_cause() {
1290 let (provider, issuer, server) = start_mock_provider().await;
1291
1292 let handler = OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI)
1293 .await
1294 .expect("discovery should succeed");
1295 provider
1296 .malformed_token_response
1297 .store(true, Ordering::SeqCst);
1298
1299 let Err(OAuth2Error::TokenExchangeError(message)) = handler
1300 .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1301 .await
1302 else {
1303 panic!("exchange with a malformed token response should fail");
1304 };
1305
1306 assert!(
1307 message.starts_with("Failed to parse server response: "),
1308 "unexpected message: {message}"
1309 );
1310
1311 server.abort();
1312 }
1313
1314 #[tokio::test]
1318 async fn additional_audiences_are_accepted_only_when_allowlisted() {
1319 let (provider, issuer, server) = start_mock_provider().await;
1320
1321 let strict_handler =
1322 OAuth2Handler::from_discovery(&test_settings(&issuer, vec![]), REDIRECT_URI)
1323 .await
1324 .expect("strict discovery should succeed");
1325 let allowlisted_handler = OAuth2Handler::from_discovery(
1326 &test_settings(&issuer, vec![OTHER_AUDIENCE.to_string()]),
1327 REDIRECT_URI,
1328 )
1329 .await
1330 .expect("allowlisted discovery should succeed");
1331
1332 let result = strict_handler
1335 .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1336 .await
1337 .expect("a single-audience token should verify");
1338 assert_eq!(result.claims.subject().as_str(), SUBJECT);
1339
1340 provider
1342 .include_other_audience
1343 .store(true, Ordering::SeqCst);
1344 let error = strict_handler
1345 .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1346 .await
1347 .expect_err("an untrusted additional audience must be rejected");
1348 assert_audience_error(&error);
1349
1350 let result = allowlisted_handler
1352 .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1353 .await
1354 .expect("an allowlisted additional audience should be accepted");
1355 assert_eq!(result.claims.subject().as_str(), SUBJECT);
1356
1357 provider
1360 .include_client_audience
1361 .store(false, Ordering::SeqCst);
1362 let error = allowlisted_handler
1363 .exchange_and_validate("auth-code", "pkce-verifier", NONCE)
1364 .await
1365 .expect_err("a token without the client audience must be rejected");
1366 assert_audience_error(&error);
1367
1368 server.abort();
1369 }
1370}