1use std::{
2 sync::Arc,
3 time::{Duration, Instant},
4};
5
6use base64::Engine;
7use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet};
8use reqwest::Url;
9use serde_json::Value;
10use tokio::sync::RwLock;
11
12use super::types::RawOidcClaims;
13use super::{
14 OidcAuthorizationRequest, OidcClaims, OidcClientType, OidcConfig, OidcError, OidcHttpClient,
15 OidcProviderMetadata, OidcTokenExchangeRequest, OidcUserInfo, PkcePair, ReqwestOidcHttpClient,
16};
17
18const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(30);
19
20#[derive(Debug, Default)]
21struct OidcCache {
22 metadata: Option<OidcProviderMetadata>,
23 jwks: Option<Arc<JwkSet>>,
24 last_forced_jwks_refresh: Option<Instant>,
25}
26
27#[derive(Debug)]
29pub struct OidcClient<H = ReqwestOidcHttpClient> {
30 config: OidcConfig,
31 http_client: H,
32 cache: Arc<RwLock<OidcCache>>,
33}
34
35impl OidcClient<ReqwestOidcHttpClient> {
36 pub fn new(config: OidcConfig) -> Result<Self, OidcError> {
42 Self::with_http_client(config, ReqwestOidcHttpClient::new()?)
43 }
44}
45
46impl<H> OidcClient<H>
47where
48 H: OidcHttpClient,
49{
50 pub fn with_http_client(config: OidcConfig, http_client: H) -> Result<Self, OidcError> {
55 config.validate()?;
56 Ok(Self {
57 config,
58 http_client,
59 cache: Arc::new(RwLock::new(OidcCache::default())),
60 })
61 }
62
63 pub async fn discover(&self) -> Result<OidcProviderMetadata, OidcError> {
68 let cached_metadata = self.cache.read().await.metadata.clone();
69 if let Some(metadata) = cached_metadata {
70 return Ok(metadata);
71 }
72
73 let issuer = self.config.issuer.trim_end_matches('/');
74 let url = format!("{issuer}/.well-known/openid-configuration");
75 let json = self.http_client.get_json(&url, None).await?;
76 let metadata = serde_json::from_value::<OidcProviderMetadata>(json).map_err(|error| {
77 OidcError::Discovery(format!("invalid discovery document: {error}"))
78 })?;
79 if metadata.issuer.trim_end_matches('/') != self.config.issuer.trim_end_matches('/') {
80 return Err(OidcError::Discovery(
81 "provider issuer did not exactly match configured issuer".into(),
82 ));
83 }
84 if !metadata
85 .response_types_supported
86 .iter()
87 .any(|value| value == "code")
88 {
89 return Err(OidcError::Discovery(
90 "provider must support the authorization code flow".into(),
91 ));
92 }
93 if matches!(self.config.client_type, OidcClientType::Public)
94 && !metadata
95 .code_challenge_methods_supported
96 .iter()
97 .any(|method| method == "S256")
98 {
99 return Err(OidcError::Discovery(
100 "public clients require PKCE S256 support".into(),
101 ));
102 }
103
104 self.cache.write().await.metadata = Some(metadata.clone());
105 Ok(metadata)
106 }
107
108 async fn jwks(&self, force_refresh: bool) -> Result<Arc<JwkSet>, OidcError> {
109 if !force_refresh && let Some(jwks) = self.cache.read().await.jwks.clone() {
110 return Ok(jwks);
111 }
112
113 if force_refresh {
114 let mut cache = self.cache.write().await;
115 if let Some(jwks) = cache.jwks.clone()
116 && cache
117 .last_forced_jwks_refresh
118 .is_some_and(|last_refresh| last_refresh.elapsed() < JWKS_REFRESH_COOLDOWN)
119 {
120 return Ok(jwks);
121 }
122 cache.last_forced_jwks_refresh = Some(Instant::now());
123 }
124
125 let metadata = self.discover().await?;
126 let json = self.http_client.get_json(&metadata.jwks_uri, None).await?;
127 let jwks = serde_json::from_value::<JwkSet>(json)
128 .map_err(|error| OidcError::Discovery(format!("invalid JWKS document: {error}")))?;
129 let jwks = Arc::new(jwks);
130 self.cache.write().await.jwks = Some(Arc::clone(&jwks));
131 Ok(jwks)
132 }
133
134 pub async fn build_authorization_request(
139 &self,
140 scopes: &[&str],
141 ) -> Result<OidcAuthorizationRequest, OidcError> {
142 let metadata = self.discover().await?;
143 let state = random_urlsafe(24);
144 let nonce = random_urlsafe(24);
145 let pkce = Some(PkcePair::generate());
146
147 let mut url = Url::parse(&metadata.authorization_endpoint).map_err(|error| {
148 OidcError::Discovery(format!("invalid authorization endpoint: {error}"))
149 })?;
150 {
151 let mut query = url.query_pairs_mut();
152 query.append_pair("response_type", "code");
153 query.append_pair("client_id", &self.config.client_id);
154 query.append_pair("redirect_uri", &self.config.redirect_uri);
155 query.append_pair("scope", &scopes.join(" "));
156 query.append_pair("state", &state);
157 query.append_pair("nonce", &nonce);
158 if let Some(pkce) = &pkce {
159 query.append_pair("code_challenge", &pkce.challenge);
160 query.append_pair("code_challenge_method", pkce.method);
161 }
162 }
163
164 Ok(OidcAuthorizationRequest {
165 url: url.to_string(),
166 state,
167 nonce,
168 pkce,
169 })
170 }
171
172 pub async fn build_token_exchange_request(
177 &self,
178 pending: &OidcAuthorizationRequest,
179 code: &str,
180 returned_state: &str,
181 code_verifier: Option<&str>,
182 ) -> Result<OidcTokenExchangeRequest, OidcError> {
183 if pending.state != returned_state {
184 return Err(OidcError::StateMismatch);
185 }
186
187 let verifier = code_verifier
188 .map(ToOwned::to_owned)
189 .or_else(|| pending.pkce.as_ref().map(|pkce| pkce.verifier.clone()));
190
191 if matches!(self.config.client_type, OidcClientType::Public) && verifier.is_none() {
192 return Err(OidcError::MissingPkce);
193 }
194
195 let metadata = self.discover().await?;
196 Ok(OidcTokenExchangeRequest {
197 token_endpoint: metadata.token_endpoint,
198 code: code.to_string(),
199 redirect_uri: self.config.redirect_uri.clone(),
200 state: returned_state.to_string(),
201 code_verifier: verifier,
202 })
203 }
204
205 pub async fn validate_id_token(
210 &self,
211 id_token: &str,
212 expected_nonce: Option<&str>,
213 ) -> Result<OidcClaims, OidcError> {
214 let metadata = self.discover().await?;
215 let header = decode_header(id_token)
216 .map_err(|error| OidcError::InvalidToken(format!("invalid token header: {error}")))?;
217
218 if !self.config.allowed_algorithms.contains(&header.alg) {
219 return Err(OidcError::UnsupportedAlgorithm(format!("{:?}", header.alg)));
220 }
221 let alg_name = format!("{:?}", header.alg);
222 if !metadata.id_token_signing_alg_values_supported.is_empty()
223 && !metadata
224 .id_token_signing_alg_values_supported
225 .iter()
226 .any(|value| value == &alg_name)
227 {
228 return Err(OidcError::UnsupportedAlgorithm(alg_name));
229 }
230
231 let jwk = self.select_jwk(header.kid.as_deref()).await?;
232 let decoding_key = DecodingKey::from_jwk(&jwk).map_err(|error| {
233 OidcError::InvalidToken(format!("could not build decoding key from JWK: {error}"))
234 })?;
235 let claims = decode::<Value>(
236 id_token,
237 &decoding_key,
238 &oidc_validation(&self.config, header.alg),
239 )
240 .map_err(|error| map_oidc_jwt_error(&error))?
241 .claims;
242
243 let raw_claims = serde_json::from_value::<RawOidcClaims>(claims)
244 .map_err(|error| OidcError::InvalidToken(format!("invalid OIDC claims: {error}")))?;
245 let iat = raw_claims
246 .iat
247 .ok_or_else(|| OidcError::MissingClaim("iat".into()))?;
248 if let Some(expected_nonce) = expected_nonce
249 && raw_claims.nonce.as_deref() != Some(expected_nonce)
250 {
251 return Err(OidcError::NonceMismatch);
252 }
253
254 Ok(OidcClaims {
255 sub: raw_claims.sub,
256 iss: raw_claims.iss,
257 aud: raw_claims.aud.into_vec(),
258 exp: raw_claims.exp,
259 iat,
260 nbf: raw_claims.nbf,
261 nonce: raw_claims.nonce,
262 email: raw_claims.email,
263 email_verified: raw_claims.email_verified,
264 name: raw_claims.name,
265 })
266 }
267
268 async fn select_jwk(&self, kid: Option<&str>) -> Result<jsonwebtoken::jwk::Jwk, OidcError> {
269 let kid = kid.ok_or_else(|| {
270 OidcError::InvalidToken("token header is missing required kid".into())
271 })?;
272 for force_refresh in [false, true] {
273 let jwks = self.jwks(force_refresh).await?;
274 if let Some(jwk) = jwks
275 .keys
276 .iter()
277 .find(|jwk| jwk.common.key_id.as_deref() == Some(kid))
278 .cloned()
279 {
280 return Ok(jwk);
281 }
282 }
283 Err(OidcError::InvalidToken(
284 "no matching JWK found for token header".into(),
285 ))
286 }
287
288 pub async fn fetch_userinfo(&self, access_token: &str) -> Result<OidcUserInfo, OidcError> {
293 let metadata = self.discover().await?;
294 let endpoint = metadata.userinfo_endpoint.ok_or_else(|| {
295 OidcError::Discovery("provider does not expose a userinfo endpoint".into())
296 })?;
297 let json = self
298 .http_client
299 .get_json(&endpoint, Some(access_token))
300 .await?;
301 serde_json::from_value(json).map_err(|error| {
302 OidcError::ProviderUnreachable(format!("invalid userinfo response: {error}"))
303 })
304 }
305}
306
307fn oidc_validation(config: &OidcConfig, algorithm: Algorithm) -> Validation {
308 let mut validation = Validation::new(algorithm);
309 validation.algorithms = vec![algorithm];
310 validation.set_issuer(&[config.issuer.as_str()]);
311 validation.set_audience(&config.audience);
312 validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
313 validation.validate_nbf = true;
316 validation.leeway = config.clock_skew.as_secs();
317 validation
318}
319
320fn map_oidc_jwt_error(error: &jsonwebtoken::errors::Error) -> OidcError {
321 match error.kind() {
322 jsonwebtoken::errors::ErrorKind::ExpiredSignature => {
323 OidcError::InvalidToken("OIDC ID token has expired".into())
324 }
325 jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(claim) => {
326 OidcError::MissingClaim(claim.clone())
327 }
328 _ => OidcError::InvalidToken(error.to_string()),
329 }
330}
331
332fn random_urlsafe(len: usize) -> String {
333 let mut bytes = vec![0_u8; len];
334 rand::fill(bytes.as_mut_slice());
335 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
336}
337
338pub async fn validate_id_token(
343 config: &OidcConfig,
344 id_token: &str,
345) -> Result<OidcClaims, OidcError> {
346 OidcClient::new(config.clone())?
347 .validate_id_token(id_token, None)
348 .await
349}
350
351#[cfg(test)]
352mod tests {
353 use std::{
354 collections::HashMap,
355 sync::{
356 Arc,
357 atomic::{AtomicUsize, Ordering},
358 },
359 };
360
361 use async_trait::async_trait;
362 use jsonwebtoken::{EncodingKey, Header, encode};
363
364 use super::*;
365
366 const ISSUER: &str = "https://issuer.example";
367 const CLIENT_ID: &str = "client-123";
368 const REDIRECT_URI: &str = "https://app.example/callback";
369 const RSA_PRIVATE_KEY: &str = include_str!(concat!(
370 env!("CARGO_MANIFEST_DIR"),
371 "/testdata/rsa_private_key.pem"
372 ));
373 const JWKS_JSON: &str = r#"{
374 "keys": [{
375 "kty": "RSA",
376 "kid": "rsa-1",
377 "use": "sig",
378 "alg": "RS256",
379 "n": "oT6vqY7IFYxu8LYnCgPsGZICOgRF57XQI9wAoILWbGwIBTzZMi_KuWWtYCo-Ph3LhN2vsIUQkKwT1-kob3IfBoCh9kN0iO6f_XLXgAOCCtVyrt5bjLyFJvtYcFecfh80LvdEeL8VE7Fxnd6CoRv1zNszWFxsfSCeWPyWQefJjLcWOqRg5zyFuP5yHwGwzEI3wLKhPFC2ufTupu4GRcFpjKpM3ZxoWw2BEaPcSmKMFWBGd7lsKaBe6TfLvL3pDwzBHSngURGInXrTQGmw-KRpDrcv5vlUD7QwVWP3JWGW66RtWH5qS5MP2eeOOSd48_Yccbx1kbGZ-NQtk6fSOTwd_Q",
380 "e": "AQAB"
381 }]
382 }"#;
383
384 fn random_nonce() -> String {
386 format!("nonce-{:016x}", rand::random::<u64>())
387 }
388
389 #[derive(Debug, Clone)]
390 struct MockHttpClient {
391 responses: Arc<HashMap<String, Value>>,
392 request_counts: Arc<HashMap<String, AtomicUsize>>,
393 }
394
395 #[async_trait]
396 impl OidcHttpClient for MockHttpClient {
397 async fn get_json(
398 &self,
399 url: &str,
400 _bearer_token: Option<&str>,
401 ) -> Result<Value, OidcError> {
402 if let Some(counter) = self.request_counts.get(url) {
403 counter.fetch_add(1, Ordering::Relaxed);
404 }
405 self.responses.get(url).cloned().ok_or_else(|| {
406 OidcError::ProviderUnreachable(format!("no response configured for {url}"))
407 })
408 }
409 }
410
411 fn mock_client() -> OidcClient<MockHttpClient> {
412 let responses = HashMap::from([
413 (
414 format!("{ISSUER}/.well-known/openid-configuration"),
415 serde_json::json!({
416 "issuer": ISSUER,
417 "authorization_endpoint": format!("{ISSUER}/authorize"),
418 "token_endpoint": format!("{ISSUER}/token"),
419 "jwks_uri": format!("{ISSUER}/jwks"),
420 "userinfo_endpoint": format!("{ISSUER}/userinfo"),
421 "response_types_supported": ["code"],
422 "code_challenge_methods_supported": ["S256"],
423 "id_token_signing_alg_values_supported": ["RS256"]
424 }),
425 ),
426 (
427 format!("{ISSUER}/jwks"),
428 serde_json::from_str(JWKS_JSON).unwrap(),
429 ),
430 (
431 format!("{ISSUER}/userinfo"),
432 serde_json::json!({
433 "sub": "user-123",
434 "email": "user@example.com",
435 "email_verified": true,
436 "name": "Example User"
437 }),
438 ),
439 ]);
440
441 OidcClient::with_http_client(
442 OidcConfig::new(ISSUER, CLIENT_ID, REDIRECT_URI, OidcClientType::Public),
443 MockHttpClient {
444 responses: Arc::new(responses),
445 request_counts: Arc::new(HashMap::from([
446 (
447 format!("{ISSUER}/.well-known/openid-configuration"),
448 AtomicUsize::new(0),
449 ),
450 (format!("{ISSUER}/jwks"), AtomicUsize::new(0)),
451 (format!("{ISSUER}/userinfo"), AtomicUsize::new(0)),
452 ])),
453 },
454 )
455 .unwrap()
456 }
457
458 fn mock_client_with_discovery(discovery: Value) -> OidcClient<MockHttpClient> {
459 let responses = HashMap::from([(
460 format!("{ISSUER}/.well-known/openid-configuration"),
461 discovery,
462 )]);
463
464 OidcClient::with_http_client(
465 OidcConfig::new(ISSUER, CLIENT_ID, REDIRECT_URI, OidcClientType::Public),
466 MockHttpClient {
467 responses: Arc::new(responses),
468 request_counts: Arc::new(HashMap::new()),
469 },
470 )
471 .unwrap()
472 }
473
474 fn issue_token(nonce: &str) -> String {
475 let now = std::time::SystemTime::now()
476 .duration_since(std::time::UNIX_EPOCH)
477 .unwrap()
478 .as_secs();
479 let mut header = Header::new(Algorithm::RS256);
480 header.kid = Some("rsa-1".into());
481 encode(
482 &header,
483 &serde_json::json!({
484 "sub": "user-123",
485 "iss": ISSUER,
486 "aud": [CLIENT_ID],
487 "exp": now + 3600,
488 "nbf": now.saturating_sub(1),
489 "iat": now,
490 "nonce": nonce,
491 "email": "user@example.com",
492 "email_verified": true,
493 "name": "Example User"
494 }),
495 &EncodingKey::from_rsa_pem(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
496 )
497 .unwrap()
498 }
499
500 fn issue_token_without_nbf(nonce: &str) -> String {
501 let now = std::time::SystemTime::now()
502 .duration_since(std::time::UNIX_EPOCH)
503 .unwrap()
504 .as_secs();
505 let mut header = Header::new(Algorithm::RS256);
506 header.kid = Some("rsa-1".into());
507 encode(
508 &header,
509 &serde_json::json!({
510 "sub": "user-123",
511 "iss": ISSUER,
512 "aud": [CLIENT_ID],
513 "exp": now + 3600,
514 "iat": now,
515 "nonce": nonce,
516 "email": "user@example.com",
517 "email_verified": true,
518 "name": "Example User"
519 }),
520 &EncodingKey::from_rsa_pem(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
521 )
522 .unwrap()
523 }
524
525 #[tokio::test]
526 async fn authorization_request_includes_pkce_state_and_nonce() {
527 let client = mock_client();
528 let request = client
529 .build_authorization_request(&["openid", "profile", "email"])
530 .await
531 .unwrap();
532
533 assert!(request.url.contains("response_type=code"));
534 assert!(request.url.contains("code_challenge="));
535 assert!(!request.state.is_empty());
536 assert!(!request.nonce.is_empty());
537 }
538
539 #[tokio::test]
540 async fn discovery_rejects_issuer_mismatch_and_missing_authorization_code_support() {
541 let issuer_mismatch = mock_client_with_discovery(serde_json::json!({
542 "issuer": "https://other-issuer.example",
543 "authorization_endpoint": format!("{ISSUER}/authorize"),
544 "token_endpoint": format!("{ISSUER}/token"),
545 "jwks_uri": format!("{ISSUER}/jwks"),
546 "response_types_supported": ["code"],
547 "code_challenge_methods_supported": ["S256"],
548 "id_token_signing_alg_values_supported": ["RS256"]
549 }));
550 assert!(matches!(
551 issuer_mismatch.discover().await,
552 Err(OidcError::Discovery(message))
553 if message.contains("issuer did not exactly match")
554 ));
555
556 let no_code_flow = mock_client_with_discovery(serde_json::json!({
557 "issuer": ISSUER,
558 "authorization_endpoint": format!("{ISSUER}/authorize"),
559 "token_endpoint": format!("{ISSUER}/token"),
560 "jwks_uri": format!("{ISSUER}/jwks"),
561 "response_types_supported": ["token"],
562 "code_challenge_methods_supported": ["S256"],
563 "id_token_signing_alg_values_supported": ["RS256"]
564 }));
565 assert!(matches!(
566 no_code_flow.discover().await,
567 Err(OidcError::Discovery(message))
568 if message.contains("authorization code flow")
569 ));
570 }
571
572 #[tokio::test]
573 async fn public_client_discovery_requires_s256_pkce_support() {
574 let client = mock_client_with_discovery(serde_json::json!({
575 "issuer": ISSUER,
576 "authorization_endpoint": format!("{ISSUER}/authorize"),
577 "token_endpoint": format!("{ISSUER}/token"),
578 "jwks_uri": format!("{ISSUER}/jwks"),
579 "response_types_supported": ["code"],
580 "code_challenge_methods_supported": ["plain"],
581 "id_token_signing_alg_values_supported": ["RS256"]
582 }));
583
584 assert!(matches!(
585 client.discover().await,
586 Err(OidcError::Discovery(message)) if message.contains("PKCE S256")
587 ));
588 }
589
590 #[tokio::test]
591 async fn state_mismatch_is_rejected() {
592 let client = mock_client();
593 let request = client
594 .build_authorization_request(&["openid"])
595 .await
596 .unwrap();
597 let result = client
598 .build_token_exchange_request(&request, "code-123", "wrong-state", None)
599 .await;
600 assert_eq!(result.unwrap_err(), OidcError::StateMismatch);
601 }
602
603 #[tokio::test]
604 async fn pkce_missing_is_rejected_for_public_client() {
605 let client = mock_client();
606 let request = OidcAuthorizationRequest {
607 url: "https://issuer.example/authorize".into(),
608 state: "state-123".into(),
609 nonce: random_nonce(),
610 pkce: None,
611 };
612 let result = client
613 .build_token_exchange_request(&request, "code-123", "state-123", None)
614 .await;
615 assert_eq!(result.unwrap_err(), OidcError::MissingPkce);
616 }
617
618 #[tokio::test]
619 async fn token_exchange_uses_pending_pkce_when_state_matches() {
620 let client = mock_client();
621 let pending = client
622 .build_authorization_request(&["openid"])
623 .await
624 .unwrap();
625 let verifier = pending.pkce.as_ref().unwrap().verifier.clone();
626
627 let exchange = client
628 .build_token_exchange_request(&pending, "code-123", &pending.state, None)
629 .await
630 .unwrap();
631
632 assert_eq!(exchange.token_endpoint, format!("{ISSUER}/token"));
633 assert_eq!(exchange.code, "code-123");
634 assert_eq!(exchange.redirect_uri, REDIRECT_URI);
635 assert_eq!(exchange.state, pending.state);
636 assert_eq!(exchange.code_verifier.as_deref(), Some(verifier.as_str()));
637 }
638
639 #[tokio::test]
640 async fn validate_id_token_rejects_malformed_token_before_fetching_jwks() {
641 let client = mock_client();
642
643 let error = client
644 .validate_id_token("not-a-jwt", Some(&random_nonce()))
645 .await
646 .unwrap_err();
647
648 assert!(matches!(
649 error,
650 OidcError::InvalidToken(message) if message.contains("invalid token header")
651 ));
652 }
653
654 #[tokio::test]
655 async fn nonce_mismatch_is_rejected() {
656 let client = mock_client();
657 let expected = random_nonce();
658 let wrong = format!("{expected}-mismatch");
659 let token = issue_token(&expected);
660 let result = client.validate_id_token(&token, Some(&wrong)).await;
661 assert_eq!(result.unwrap_err(), OidcError::NonceMismatch);
662 }
663
664 #[tokio::test]
665 async fn valid_id_token_and_userinfo_roundtrip() {
666 let client = mock_client();
667 let nonce = random_nonce();
668 let token = issue_token(&nonce);
669 let claims = client
670 .validate_id_token(&token, Some(&nonce))
671 .await
672 .unwrap();
673 let userinfo = client.fetch_userinfo("opaque-access-token").await.unwrap();
674
675 assert_eq!(claims.sub, "user-123");
676 assert_eq!(claims.email.as_deref(), Some("user@example.com"));
677 assert_eq!(userinfo.name.as_deref(), Some("Example User"));
678 }
679
680 #[tokio::test]
681 async fn valid_id_token_without_nbf_is_accepted() {
682 let client = mock_client();
683 let nonce = random_nonce();
684 let token = issue_token_without_nbf(&nonce);
685
686 let claims = client
687 .validate_id_token(&token, Some(&nonce))
688 .await
689 .unwrap();
690
691 assert_eq!(claims.sub, "user-123");
692 assert!(claims.nbf.is_none());
693 }
694
695 #[tokio::test]
696 async fn id_token_without_kid_is_rejected() {
697 let client = mock_client();
698 let now = std::time::SystemTime::now()
699 .duration_since(std::time::UNIX_EPOCH)
700 .unwrap()
701 .as_secs();
702 let nonce = random_nonce();
703 let token = encode(
704 &Header::new(Algorithm::RS256),
705 &serde_json::json!({
706 "sub": "user-123",
707 "iss": ISSUER,
708 "aud": [CLIENT_ID],
709 "exp": now + 3600,
710 "nbf": now.saturating_sub(1),
711 "iat": now,
712 "nonce": nonce
713 }),
714 &EncodingKey::from_rsa_pem(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
715 )
716 .unwrap();
717
718 let result = client.validate_id_token(&token, Some(&nonce)).await;
719 assert_eq!(
720 result.unwrap_err(),
721 OidcError::InvalidToken("token header is missing required kid".into())
722 );
723 }
724
725 #[tokio::test]
726 async fn fetch_userinfo_fails_closed_when_endpoint_is_absent_or_malformed() {
727 let without_userinfo = mock_client_with_discovery(serde_json::json!({
728 "issuer": ISSUER,
729 "authorization_endpoint": format!("{ISSUER}/authorize"),
730 "token_endpoint": format!("{ISSUER}/token"),
731 "jwks_uri": format!("{ISSUER}/jwks"),
732 "response_types_supported": ["code"],
733 "code_challenge_methods_supported": ["S256"],
734 "id_token_signing_alg_values_supported": ["RS256"]
735 }));
736 assert!(matches!(
737 without_userinfo.fetch_userinfo("token").await,
738 Err(OidcError::Discovery(message))
739 if message.contains("userinfo endpoint")
740 ));
741
742 let mut responses = HashMap::new();
743 responses.insert(
744 format!("{ISSUER}/.well-known/openid-configuration"),
745 serde_json::json!({
746 "issuer": ISSUER,
747 "authorization_endpoint": format!("{ISSUER}/authorize"),
748 "token_endpoint": format!("{ISSUER}/token"),
749 "jwks_uri": format!("{ISSUER}/jwks"),
750 "userinfo_endpoint": format!("{ISSUER}/userinfo"),
751 "response_types_supported": ["code"],
752 "code_challenge_methods_supported": ["S256"],
753 "id_token_signing_alg_values_supported": ["RS256"]
754 }),
755 );
756 responses.insert(
757 format!("{ISSUER}/userinfo"),
758 serde_json::json!({"email": "missing-sub@example.com"}),
759 );
760 let malformed_userinfo = OidcClient::with_http_client(
761 OidcConfig::new(ISSUER, CLIENT_ID, REDIRECT_URI, OidcClientType::Public),
762 MockHttpClient {
763 responses: Arc::new(responses),
764 request_counts: Arc::new(HashMap::new()),
765 },
766 )
767 .unwrap();
768
769 assert!(matches!(
770 malformed_userinfo.fetch_userinfo("token").await,
771 Err(OidcError::ProviderUnreachable(message))
772 if message.contains("invalid userinfo response")
773 ));
774 }
775
776 #[tokio::test]
777 async fn jwks_forced_refresh_is_throttled_after_unknown_kid() {
778 let client = mock_client();
779 let now = std::time::SystemTime::now()
780 .duration_since(std::time::UNIX_EPOCH)
781 .unwrap()
782 .as_secs();
783 let mut header = Header::new(Algorithm::RS256);
784 header.kid = Some("missing-kid".into());
785 let nonce = random_nonce();
786 let token = encode(
787 &header,
788 &serde_json::json!({
789 "sub": "user-123",
790 "iss": ISSUER,
791 "aud": [CLIENT_ID],
792 "exp": now + 3600,
793 "nbf": now.saturating_sub(1),
794 "iat": now,
795 "nonce": nonce
796 }),
797 &EncodingKey::from_rsa_pem(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
798 )
799 .unwrap();
800
801 let jwks_url = format!("{ISSUER}/jwks");
802 let counter = client
803 .http_client
804 .request_counts
805 .get(&jwks_url)
806 .expect("jwks counter must exist");
807
808 let first = client.validate_id_token(&token, Some(&nonce)).await;
809 assert_eq!(
810 first.unwrap_err(),
811 OidcError::InvalidToken("no matching JWK found for token header".into())
812 );
813 assert_eq!(counter.load(Ordering::Relaxed), 2);
814
815 let second = client.validate_id_token(&token, Some(&nonce)).await;
816 assert_eq!(
817 second.unwrap_err(),
818 OidcError::InvalidToken("no matching JWK found for token header".into())
819 );
820 assert_eq!(counter.load(Ordering::Relaxed), 2);
821 }
822}