Skip to main content

rskit_auth/oidc/
client.rs

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/// Stateful OIDC client with discovery and JWKS caching.
28#[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    /// Create an OIDC client using the default HTTP client.
37    ///
38    /// # Errors
39    /// Returns an error when the configuration is invalid or the default HTTP client cannot be
40    /// constructed.
41    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    /// Create an OIDC client with a caller-supplied HTTP implementation.
51    ///
52    /// # Errors
53    /// Returns an error when the configuration is invalid.
54    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    /// Fetch and cache the provider discovery document.
64    ///
65    /// # Errors
66    /// Returns an error when discovery fails or the document is invalid.
67    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    /// Build a secure authorization request using exact-match redirect URI, state, nonce, and PKCE.
135    ///
136    /// # Errors
137    /// Returns an error when discovery fails.
138    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    /// Build token-exchange parameters while enforcing state and PKCE rules.
173    ///
174    /// # Errors
175    /// Returns an error when the callback state or PKCE data is invalid.
176    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    /// Validate an ID token using discovery metadata, cached JWKS, and the configured nonce.
206    ///
207    /// # Errors
208    /// Returns an error when the token is invalid or the provider cannot be reached.
209    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    /// Fetch the provider's userinfo document using the bearer access token.
289    ///
290    /// # Errors
291    /// Returns an error when the provider does not expose userinfo or the request fails.
292    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    // Validate nbf when present: tokens with a future nbf are rejected;
314    // tokens that omit nbf still pass (nbf is not in required_spec_claims).
315    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
338/// Validates an OIDC ID token using the default reqwest-based client.
339///
340/// # Errors
341/// Returns an error if the token is invalid, expired, or the provider is unreachable.
342pub 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    /// Generates a unique, non-hard-coded nonce for each test invocation.
385    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}