1use async_trait::async_trait;
12use jsonwebtoken::jwk::{AlgorithmParameters, Jwk, JwkSet};
13use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
14use notedthat_core::{TokenRejected, TokenVerifier, UserIdentity};
15use serde::Deserialize;
16use std::collections::BTreeSet;
17use std::sync::RwLock;
18use std::time::{Duration, Instant};
19
20const ACCEPTED_ALGORITHMS: [Algorithm; 5] = [
26 Algorithm::RS256,
27 Algorithm::RS384,
28 Algorithm::RS512,
29 Algorithm::ES256,
30 Algorithm::ES384,
31];
32
33const LEEWAY: Duration = Duration::from_secs(60);
35
36const MIN_REFETCH_INTERVAL: Duration = Duration::from_secs(30);
41
42const MAX_KEY_AGE: Duration = Duration::from_hours(1);
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct OidcSettings {
49 pub issuer: String,
51 pub audiences: Vec<String>,
53 pub username_claim: String,
55 pub groups_claim: String,
57 pub http_timeout: Duration,
59 pub resource: Option<String>,
61 pub ca_cert: Option<std::path::PathBuf>,
64}
65
66impl OidcSettings {
67 pub const DEFAULT_USERNAME_CLAIM: &'static str = "preferred_username";
69 pub const DEFAULT_GROUPS_CLAIM: &'static str = "groups";
71 pub const DEFAULT_HTTP_TIMEOUT_MS: u64 = 5_000;
73
74 pub fn discovery_url(&self) -> String {
76 format!(
77 "{}/.well-known/openid-configuration",
78 self.issuer.trim_end_matches('/')
79 )
80 }
81}
82
83#[derive(Debug, Deserialize)]
85struct DiscoveryDocument {
86 issuer: String,
87 jwks_uri: String,
88}
89
90struct CachedKey {
92 kid: Option<String>,
93 key: DecodingKey,
94}
95
96struct KeyCache {
97 keys: Vec<CachedKey>,
98 fetched_at: Option<Instant>,
100}
101
102pub struct OidcVerifier {
104 settings: OidcSettings,
105 http: reqwest::Client,
106 jwks_uri: String,
107 cache: RwLock<KeyCache>,
108 refetch: tokio::sync::Mutex<()>,
110}
111
112impl std::fmt::Debug for OidcVerifier {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct("OidcVerifier")
115 .field("issuer", &self.settings.issuer)
116 .field("jwks_uri", &self.jwks_uri)
117 .finish_non_exhaustive()
118 }
119}
120
121impl OidcVerifier {
122 pub async fn discover(settings: OidcSettings) -> anyhow::Result<Self> {
133 let mut builder = reqwest::Client::builder().timeout(settings.http_timeout);
134 if let Some(path) = &settings.ca_cert {
135 let bundle = std::fs::read(path).map_err(|error| {
136 anyhow::anyhow!("NOTEDTHAT_OIDC_CA_CERT {}: {error}", path.display())
137 })?;
138 let certificates = reqwest::Certificate::from_pem_bundle(&bundle).map_err(|error| {
139 anyhow::anyhow!(
140 "NOTEDTHAT_OIDC_CA_CERT {} is not a PEM certificate bundle: {error}",
141 path.display()
142 )
143 })?;
144 if certificates.is_empty() {
145 anyhow::bail!(
146 "NOTEDTHAT_OIDC_CA_CERT {} contains no certificates",
147 path.display()
148 );
149 }
150 for certificate in certificates {
151 builder = builder.add_root_certificate(certificate);
152 }
153 }
154 let http = builder
155 .build()
156 .map_err(|error| anyhow::anyhow!("could not build the OIDC HTTP client: {error}"))?;
157
158 let discovery_url = settings.discovery_url();
159 let document: DiscoveryDocument = http
160 .get(&discovery_url)
161 .send()
162 .await
163 .and_then(reqwest::Response::error_for_status)
164 .map_err(|error| anyhow::Error::new(error).context(format!("GET {discovery_url}")))?
165 .json()
166 .await
167 .map_err(|error| {
168 anyhow::Error::new(error)
169 .context(format!("{discovery_url} is not a discovery document"))
170 })?;
171
172 if document.issuer != settings.issuer {
176 anyhow::bail!(
177 "{discovery_url} reports issuer `{}` but NOTEDTHAT_OIDC_ISSUER is `{}`; \
178 they must match exactly, trailing slash included",
179 document.issuer,
180 settings.issuer
181 );
182 }
183
184 let jwks = fetch_jwks(&http, &document.jwks_uri).await?;
185 tracing::info!(
186 issuer = %settings.issuer,
187 jwks_uri = %document.jwks_uri,
188 keys = jwks.keys.len(),
189 "OIDC issuer discovered"
190 );
191 Ok(Self::assemble(settings, http, document.jwks_uri, &jwks))
192 }
193
194 #[cfg(any(test, feature = "test-support"))]
199 pub fn from_jwks(settings: OidcSettings, jwks: &JwkSet, jwks_uri: Option<String>) -> Self {
200 let http = reqwest::Client::builder()
201 .timeout(settings.http_timeout)
202 .build()
203 .expect("reqwest client");
204 Self::assemble(
205 settings,
206 http,
207 jwks_uri.unwrap_or_else(|| "unset:".to_string()),
208 jwks,
209 )
210 }
211
212 fn assemble(
213 settings: OidcSettings,
214 http: reqwest::Client,
215 jwks_uri: String,
216 jwks: &JwkSet,
217 ) -> Self {
218 Self {
219 settings,
220 http,
221 jwks_uri,
222 cache: RwLock::new(KeyCache {
223 keys: cache_keys(jwks),
224 fetched_at: Some(Instant::now()),
225 }),
226 refetch: tokio::sync::Mutex::new(()),
227 }
228 }
229
230 pub fn settings(&self) -> &OidcSettings {
232 &self.settings
233 }
234
235 fn has_key(&self, kid: Option<&str>) -> bool {
237 self.lookup(kid).is_some()
238 }
239
240 fn lookup(&self, kid: Option<&str>) -> Option<DecodingKey> {
242 let cache = self
243 .cache
244 .read()
245 .unwrap_or_else(std::sync::PoisonError::into_inner);
246 match kid {
247 Some(kid) => cache
248 .keys
249 .iter()
250 .find(|key| key.kid.as_deref() == Some(kid)),
251 None if cache.keys.len() == 1 => cache.keys.first(),
252 None => None,
253 }
254 .map(|key| key.key.clone())
255 }
256
257 fn cache_age(&self) -> Duration {
258 self.cache
259 .read()
260 .unwrap_or_else(std::sync::PoisonError::into_inner)
261 .fetched_at
262 .map_or(Duration::MAX, |fetched_at| fetched_at.elapsed())
263 }
264
265 #[cfg(test)]
267 fn mark_stale(&self) {
268 self.cache
269 .write()
270 .unwrap_or_else(std::sync::PoisonError::into_inner)
271 .fetched_at = None;
272 }
273
274 async fn refresh(&self) {
279 let _serialised = self.refetch.lock().await;
280 if self.cache_age() < MIN_REFETCH_INTERVAL {
281 return;
282 }
283 match fetch_jwks(&self.http, &self.jwks_uri).await {
284 Ok(jwks) => {
285 let mut cache = self
286 .cache
287 .write()
288 .unwrap_or_else(std::sync::PoisonError::into_inner);
289 cache.keys = cache_keys(&jwks);
290 cache.fetched_at = Some(Instant::now());
291 tracing::debug!(keys = cache.keys.len(), "OIDC key set refreshed");
292 }
293 Err(error) => {
294 self.cache
297 .write()
298 .unwrap_or_else(std::sync::PoisonError::into_inner)
299 .fetched_at = Some(Instant::now());
300 tracing::warn!(error = %format!("{error:#}"), "OIDC key set refresh failed; keeping the previous keys");
301 }
302 }
303 }
304}
305
306#[async_trait]
307impl TokenVerifier for OidcVerifier {
308 async fn verify(&self, token: &str) -> Result<UserIdentity, TokenRejected> {
309 let header = decode_header(token).map_err(|_| TokenRejected::new("not a JWT"))?;
310 if !ACCEPTED_ALGORITHMS.contains(&header.alg) {
311 return Err(TokenRejected::new(format!(
312 "algorithm {:?} is not accepted",
313 header.alg
314 )));
315 }
316
317 let kid = header.kid.as_deref();
318 if self.cache_age() > MAX_KEY_AGE || !self.has_key(kid) {
319 self.refresh().await;
320 }
321 let key = self.lookup(kid).ok_or_else(|| {
322 TokenRejected::new(match kid {
323 Some(_) => "signed with a key the issuer does not publish",
324 None => "no `kid`, and the issuer publishes more than one key",
325 })
326 })?;
327
328 let mut validation = Validation::new(header.alg);
329 validation.set_issuer(&[&self.settings.issuer]);
330 validation.set_audience(&self.settings.audiences);
331 validation.set_required_spec_claims(&["exp", "iss", "aud"]);
332 validation.leeway = LEEWAY.as_secs();
333 validation.validate_nbf = true;
334
335 let claims = decode::<serde_json::Map<String, serde_json::Value>>(token, &key, &validation)
336 .map_err(|error| TokenRejected::new(describe(&error)))?
337 .claims;
338
339 let subject = claims
340 .get(&self.settings.username_claim)
341 .or_else(|| claims.get("sub"))
342 .and_then(serde_json::Value::as_str)
343 .filter(|subject| !subject.is_empty())
344 .ok_or_else(|| TokenRejected::new("no username claim and no `sub`"))?
345 .to_string();
346 let groups = claims
347 .get(&self.settings.groups_claim)
348 .map(groups_from_claim)
349 .unwrap_or_default();
350
351 Ok(UserIdentity { subject, groups })
352 }
353}
354
355fn describe(error: &jsonwebtoken::errors::Error) -> String {
357 use jsonwebtoken::errors::ErrorKind;
358 match error.kind() {
359 ErrorKind::ExpiredSignature => "expired".to_string(),
360 ErrorKind::ImmatureSignature => "not yet valid (`nbf`)".to_string(),
361 ErrorKind::InvalidIssuer => "issuer mismatch".to_string(),
362 ErrorKind::InvalidAudience => "audience mismatch".to_string(),
363 ErrorKind::InvalidSignature => "signature does not verify".to_string(),
364 ErrorKind::MissingRequiredClaim(claim) => format!("missing `{claim}`"),
365 ErrorKind::InvalidAlgorithm => "key and algorithm disagree".to_string(),
366 other => format!("invalid: {other:?}"),
367 }
368}
369
370fn groups_from_claim(value: &serde_json::Value) -> BTreeSet<String> {
379 use serde_json::Value;
380 let mut groups = BTreeSet::new();
381 match value {
382 Value::String(group) => {
383 groups.insert(group.clone());
384 }
385 Value::Array(items) => {
386 for item in items {
387 match item {
388 Value::String(group) => {
389 groups.insert(group.clone());
390 }
391 Value::Object(roles) => groups.extend(roles.keys().cloned()),
392 _ => {}
393 }
394 }
395 }
396 Value::Object(roles) => groups.extend(roles.keys().cloned()),
397 _ => {}
398 }
399 groups
400}
401
402async fn fetch_jwks(http: &reqwest::Client, jwks_uri: &str) -> anyhow::Result<JwkSet> {
403 http.get(jwks_uri)
404 .send()
405 .await
406 .and_then(reqwest::Response::error_for_status)
407 .map_err(|error| anyhow::Error::new(error).context(format!("GET {jwks_uri}")))?
408 .json()
409 .await
410 .map_err(|error| anyhow::Error::new(error).context(format!("{jwks_uri} is not a JWK set")))
411}
412
413fn cache_keys(jwks: &JwkSet) -> Vec<CachedKey> {
419 jwks.keys
420 .iter()
421 .filter(|jwk| {
422 matches!(
423 jwk.algorithm,
424 AlgorithmParameters::RSA(_) | AlgorithmParameters::EllipticCurve(_)
425 )
426 })
427 .filter_map(|jwk: &Jwk| {
428 let kid = jwk.common.key_id.clone();
429 match DecodingKey::from_jwk(jwk) {
430 Ok(key) => Some(CachedKey { kid, key }),
431 Err(error) => {
432 tracing::warn!(
433 kid = kid.as_deref().unwrap_or("<none>"),
434 error = %error,
435 "OIDC key set publishes a key this server cannot use; skipping it"
436 );
437 None
438 }
439 }
440 })
441 .collect()
442}
443
444#[cfg(any(test, feature = "test-support"))]
446pub mod test_support {
447 use super::{JwkSet, OidcSettings};
448 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
449 use std::time::Duration;
450
451 pub const KID: &str = "test-2026";
453 const PRIVATE_KEY_PEM: &str = include_str!("oidc/testdata/rs256-private.pem");
454 pub const JWKS_JSON: &str = include_str!("oidc/testdata/jwks.json");
456
457 pub fn jwks() -> JwkSet {
459 serde_json::from_str(JWKS_JSON).expect("fixture JWKS parses")
460 }
461
462 pub fn settings(issuer: &str) -> OidcSettings {
464 OidcSettings {
465 issuer: issuer.to_string(),
466 audiences: vec!["notedthat".to_string()],
467 username_claim: OidcSettings::DEFAULT_USERNAME_CLAIM.to_string(),
468 groups_claim: OidcSettings::DEFAULT_GROUPS_CLAIM.to_string(),
469 http_timeout: Duration::from_millis(OidcSettings::DEFAULT_HTTP_TIMEOUT_MS),
470 resource: None,
471 ca_cert: None,
472 }
473 }
474
475 pub fn mint(claims: &serde_json::Value) -> String {
477 mint_with(Algorithm::RS256, Some(KID), claims)
478 }
479
480 pub fn mint_with(alg: Algorithm, kid: Option<&str>, claims: &serde_json::Value) -> String {
482 let mut header = Header::new(alg);
483 header.kid = kid.map(str::to_string);
484 let key = match alg {
485 Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
486 EncodingKey::from_secret(b"not-a-secret-anyone-shares")
487 }
488 _ => EncodingKey::from_rsa_pem(PRIVATE_KEY_PEM.as_bytes()).expect("fixture key"),
489 };
490 encode(&header, claims, &key).expect("token encodes")
491 }
492
493 pub fn now() -> u64 {
495 std::time::SystemTime::now()
496 .duration_since(std::time::UNIX_EPOCH)
497 .expect("after the epoch")
498 .as_secs()
499 }
500
501 pub fn claims(issuer: &str, subject: &str, groups: &[&str]) -> serde_json::Value {
503 serde_json::json!({
504 "iss": issuer,
505 "sub": format!("{subject}-opaque-id"),
506 "aud": "notedthat",
507 "exp": now() + 3600,
508 "iat": now(),
509 "preferred_username": subject,
510 "groups": groups,
511 })
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::test_support::{JWKS_JSON, KID, claims, jwks, mint, mint_with, now, settings};
518 use super::*;
519 use jsonwebtoken::Algorithm;
520 use wiremock::matchers::{method, path};
521 use wiremock::{Mock, MockServer, ResponseTemplate};
522
523 const ISSUER: &str = "https://auth.example.com";
524
525 fn verifier() -> OidcVerifier {
526 OidcVerifier::from_jwks(settings(ISSUER), &jwks(), None)
527 }
528
529 async fn issuer_serving(jwks_body: &str) -> MockServer {
530 let server = MockServer::start().await;
531 let issuer = server.uri();
532 Mock::given(method("GET"))
533 .and(path("/.well-known/openid-configuration"))
534 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
535 "issuer": issuer,
536 "jwks_uri": format!("{issuer}/jwks"),
537 })))
538 .mount(&server)
539 .await;
540 Mock::given(method("GET"))
541 .and(path("/jwks"))
542 .respond_with(ResponseTemplate::new(200).set_body_string(jwks_body))
543 .mount(&server)
544 .await;
545 server
546 }
547
548 #[tokio::test]
549 async fn a_token_signed_by_a_published_key_yields_subject_and_groups() {
550 let token = mint(&claims(ISSUER, "alice", &["editors", "staff"]));
552
553 let identity = verifier().verify(&token).await.expect("verifies");
555
556 assert_eq!(identity.subject, "alice");
558 assert_eq!(
559 identity.groups,
560 ["editors", "staff"]
561 .map(str::to_string)
562 .into_iter()
563 .collect()
564 );
565 }
566
567 #[tokio::test]
568 async fn an_expired_token_is_rejected() {
569 let mut claims = claims(ISSUER, "alice", &[]);
571 claims["exp"] = serde_json::json!(now() - 3600);
572
573 let rejected = verifier()
575 .verify(&mint(&claims))
576 .await
577 .expect_err("expired");
578 assert_eq!(rejected.reason, "expired");
579 }
580
581 #[tokio::test]
582 async fn a_token_for_another_audience_is_rejected() {
583 let mut claims = claims(ISSUER, "alice", &[]);
584 claims["aud"] = serde_json::json!("someone-else");
585 let rejected = verifier().verify(&mint(&claims)).await.expect_err("aud");
586 assert_eq!(rejected.reason, "audience mismatch");
587 }
588
589 #[tokio::test]
590 async fn an_audience_array_containing_ours_is_accepted() {
591 let mut claims = claims(ISSUER, "alice", &[]);
593 claims["aud"] = serde_json::json!(["123456", "notedthat"]);
594
595 assert!(verifier().verify(&mint(&claims)).await.is_ok());
597 }
598
599 #[tokio::test]
600 async fn a_token_from_another_issuer_is_rejected() {
601 let rejected = verifier()
602 .verify(&mint(&claims("https://evil.example.com", "alice", &[])))
603 .await
604 .expect_err("iss");
605 assert_eq!(rejected.reason, "issuer mismatch");
606 }
607
608 #[tokio::test]
609 async fn hs256_is_rejected_before_any_key_is_consulted() {
610 let token = mint_with(Algorithm::HS256, Some(KID), &claims(ISSUER, "alice", &[]));
611 let rejected = verifier().verify(&token).await.expect_err("HS256");
612 assert!(rejected.reason.contains("HS256"), "{}", rejected.reason);
613 }
614
615 #[tokio::test]
616 async fn a_token_that_is_not_a_jwt_is_rejected() {
617 let rejected = verifier()
618 .verify("sk-live-not-a-jwt")
619 .await
620 .expect_err("shape");
621 assert_eq!(rejected.reason, "not a JWT");
622 }
623
624 #[tokio::test]
625 async fn a_token_signed_with_a_different_key_is_rejected() {
626 let empty = OidcVerifier::from_jwks(settings(ISSUER), &JwkSet { keys: Vec::new() }, None);
628
629 let rejected = empty
631 .verify(&mint(&claims(ISSUER, "alice", &[])))
632 .await
633 .expect_err("no key");
634 assert!(
635 rejected.reason.contains("does not publish"),
636 "{}",
637 rejected.reason
638 );
639 }
640
641 #[tokio::test]
642 async fn a_key_that_does_not_decode_is_skipped_not_fatal() {
643 let mut published: JwkSet = serde_json::from_str(JWKS_JSON).expect("fixture");
646 let mut broken: Jwk = published.keys[0].clone();
647 broken.common.key_id = Some("broken".into());
648 if let AlgorithmParameters::RSA(params) = &mut broken.algorithm {
649 params.n = "not base64url!".into();
650 }
651 published.keys.insert(0, broken);
652 let verifier = OidcVerifier::from_jwks(settings(ISSUER), &published, None);
653
654 assert!(
657 verifier
658 .verify(&mint(&claims(ISSUER, "alice", &[])))
659 .await
660 .is_ok()
661 );
662 let rejected = verifier
663 .verify(&mint_with(
664 Algorithm::RS256,
665 Some("broken"),
666 &claims(ISSUER, "alice", &[]),
667 ))
668 .await
669 .expect_err("broken kid");
670 assert!(
671 rejected.reason.contains("does not publish"),
672 "{}",
673 rejected.reason
674 );
675 }
676
677 #[tokio::test]
678 async fn a_token_without_a_kid_uses_the_only_key() {
679 let token = mint_with(Algorithm::RS256, None, &claims(ISSUER, "alice", &[]));
680 assert!(verifier().verify(&token).await.is_ok());
681 }
682
683 #[tokio::test]
684 async fn username_claim_falls_back_to_sub() {
685 let mut claims = claims(ISSUER, "alice", &[]);
687 claims.as_object_mut().unwrap().remove("preferred_username");
688
689 let identity = verifier().verify(&mint(&claims)).await.expect("verifies");
691 assert_eq!(identity.subject, "alice-opaque-id");
692 }
693
694 #[tokio::test]
695 async fn a_token_with_neither_username_nor_sub_is_rejected() {
696 let mut claims = claims(ISSUER, "alice", &[]);
697 let object = claims.as_object_mut().unwrap();
698 object.remove("preferred_username");
699 object.remove("sub");
700 assert!(verifier().verify(&mint(&claims)).await.is_err());
701 }
702
703 #[tokio::test]
704 async fn the_groups_claim_name_is_configurable_and_zitadel_roles_become_groups() {
705 let mut settings = settings(ISSUER);
707 settings.groups_claim = "urn:zitadel:iam:org:project:roles".to_string();
708 let verifier = OidcVerifier::from_jwks(settings, &jwks(), None);
709 let mut claims = claims(ISSUER, "alice", &["ignored-under-the-default-name"]);
710 claims["urn:zitadel:iam:org:project:roles"] = serde_json::json!({
711 "admin": { "123": "example.zitadel.cloud" },
712 "editor": { "123": "example.zitadel.cloud" },
713 });
714
715 let identity = verifier.verify(&mint(&claims)).await.expect("verifies");
717 assert_eq!(
718 identity.groups,
719 ["admin", "editor"]
720 .map(str::to_string)
721 .into_iter()
722 .collect()
723 );
724 }
725
726 #[test]
727 fn every_documented_groups_shape_is_read() {
728 use serde_json::json;
729 let expect = |value: serde_json::Value, groups: &[&str]| {
730 assert_eq!(
731 groups_from_claim(&value),
732 groups.iter().map(ToString::to_string).collect(),
733 "{value}"
734 );
735 };
736 expect(json!(["a", "b"]), &["a", "b"]);
737 expect(json!("solo"), &["solo"]);
738 expect(json!({"role-a": {}, "role-b": {}}), &["role-a", "role-b"]);
739 expect(
740 json!([{"role-a": {}}, {"role-b": {}}]),
741 &["role-a", "role-b"],
742 );
743 expect(json!(["a", 7, null, {"b": {}}]), &["a", "b"]);
744 expect(json!(42), &[]);
745 expect(json!(null), &[]);
746 }
747
748 #[tokio::test]
749 async fn discovery_fetches_the_key_set_and_verifies() {
750 let server = issuer_serving(JWKS_JSON).await;
752
753 let verifier = OidcVerifier::discover(settings(&server.uri()))
755 .await
756 .expect("discovers");
757
758 let token = mint(&claims(&server.uri(), "alice", &["editors"]));
760 assert_eq!(
761 verifier.verify(&token).await.expect("verifies").subject,
762 "alice"
763 );
764 }
765
766 #[tokio::test]
767 async fn discovery_with_a_mismatched_issuer_refuses_startup() {
768 let server = MockServer::start().await;
770 Mock::given(method("GET"))
771 .and(path("/.well-known/openid-configuration"))
772 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
773 "issuer": format!("{}/", server.uri()),
774 "jwks_uri": format!("{}/jwks", server.uri()),
775 })))
776 .mount(&server)
777 .await;
778
779 let error = OidcVerifier::discover(settings(&server.uri()))
781 .await
782 .expect_err("mismatch");
783
784 assert!(error.to_string().contains("trailing slash"), "{error}");
786 }
787
788 #[tokio::test]
789 async fn a_ca_bundle_that_is_not_pem_refuses_startup_before_any_request() {
790 let dir = tempfile::tempdir().expect("tempdir");
792 let garbage = dir.path().join("ca.pem");
793 std::fs::write(&garbage, b"not a certificate").expect("write");
794 let mut settings = settings("https://auth.example.com");
795 settings.ca_cert = Some(garbage.clone());
796
797 let error = OidcVerifier::discover(settings).await.expect_err("refused");
799
800 let message = error.to_string();
802 assert!(message.contains("NOTEDTHAT_OIDC_CA_CERT"), "{message}");
803 assert!(
804 message.contains(&garbage.display().to_string()),
805 "{message}"
806 );
807 }
808
809 #[tokio::test]
810 async fn discovery_failure_refuses_startup() {
811 let server = MockServer::start().await;
812 let error = OidcVerifier::discover(settings(&server.uri()))
813 .await
814 .expect_err("404");
815 assert!(
816 error.to_string().contains("openid-configuration"),
817 "{error}"
818 );
819 }
820
821 #[tokio::test]
822 async fn an_unknown_kid_triggers_one_refetch_and_then_verifies() {
823 let server = issuer_serving(JWKS_JSON).await;
825 let verifier = OidcVerifier::from_jwks(
826 settings(&server.uri()),
827 &JwkSet { keys: Vec::new() },
828 Some(format!("{}/jwks", server.uri())),
829 );
830 verifier.mark_stale();
832
833 let token = mint(&claims(&server.uri(), "alice", &[]));
835 let first = verifier.verify(&token).await;
836 let second = verifier.verify(&token).await;
837
838 assert!(first.is_ok(), "{first:?}");
840 assert!(second.is_ok());
841 assert_eq!(server.received_requests().await.unwrap().len(), 1);
842 }
843
844 #[tokio::test]
845 async fn refetches_are_rate_limited() {
846 let server = issuer_serving(JWKS_JSON).await;
848 let verifier = OidcVerifier::from_jwks(
849 settings(&server.uri()),
850 &JwkSet { keys: Vec::new() },
851 Some(format!("{}/jwks", server.uri())),
852 );
853
854 let token = mint(&claims(&server.uri(), "alice", &[]));
856 for _ in 0..5 {
857 assert!(verifier.verify(&token).await.is_err());
858 }
859
860 assert!(server.received_requests().await.unwrap().is_empty());
862 }
863
864 #[tokio::test]
865 async fn a_stale_key_set_is_refreshed_before_use() {
866 let server = issuer_serving(JWKS_JSON).await;
868 let verifier = OidcVerifier::from_jwks(
869 settings(&server.uri()),
870 &JwkSet { keys: Vec::new() },
871 Some(format!("{}/jwks", server.uri())),
872 );
873 verifier.mark_stale();
874
875 let token = mint(&claims(&server.uri(), "alice", &[]));
877 assert!(verifier.verify(&token).await.is_ok());
878 }
879
880 #[tokio::test]
881 async fn a_failed_refetch_keeps_the_previous_keys() {
882 let server = MockServer::start().await;
884 let verifier = OidcVerifier::from_jwks(
885 settings(ISSUER),
886 &jwks(),
887 Some(format!("{}/jwks", server.uri())),
888 );
889 verifier.mark_stale();
890
891 let token = mint(&claims(ISSUER, "alice", &[]));
893 assert!(verifier.verify(&token).await.is_ok());
894 }
895
896 #[test]
897 fn debug_output_names_the_issuer_and_nothing_secret() {
898 let rendered = format!("{:?}", verifier());
899 assert!(rendered.contains(ISSUER));
900 assert!(!rendered.contains("keys:"));
901 }
902}