1use std::sync::Arc;
2
3use reqwest::Client;
4
5use crate::auth::AuthplaneAuth;
6use crate::cache::DocumentFetcherFn;
7use crate::cache::{
8 DocumentFetcher, FetchResult, JwksCache, MetadataCache, MetadataChangeCallback, TokenCache,
9};
10use crate::circuit_breaker::CircuitBreaker;
11use crate::client_builder::AuthplaneClientBuilder;
12use crate::dpop_provider::DpopProvider;
13use crate::errors::transport_error;
14use crate::metadata::{AuthorizationServerMetadata, build_metadata_url};
15use crate::metadata_binding::{JwksUriCell, MetadataBinding};
16use crate::oauth::{IntrospectionResponse, TokenExchangeOptions, TokenResponse};
17use crate::prm::{ProtectedResourceMetadata, build_prm};
18use crate::resource::{AuthplaneResource, ResourceOptions};
19use crate::transport::{build_http_client, validate_fetch_url};
20use crate::{AuthplaneError, FetchSettings};
21
22#[derive(Clone)]
24pub(crate) struct ClientRuntimeConfig {
25 pub jwks_refresh_seconds: u64,
26 pub metadata_refresh_seconds: u64,
27 pub cache_ttl_buffer_seconds: f64,
28 pub default_token_ttl_seconds: f64,
29 pub circuit_breaker_threshold: u32,
30 pub circuit_breaker_cooldown_seconds: f64,
31 pub dpop_provider: Option<Arc<DpopProvider>>,
32 pub auth_provider: Option<Arc<dyn crate::auth_provider::AuthProvider>>,
33 pub on_metadata_change: Option<MetadataChangeCallback>,
34}
35
36impl ClientRuntimeConfig {
37 fn defaults() -> Self {
38 Self {
39 jwks_refresh_seconds: AuthplaneClientBuilder::DEFAULT_JWKS_REFRESH_SECONDS,
40 metadata_refresh_seconds: AuthplaneClientBuilder::DEFAULT_METADATA_REFRESH_SECONDS,
41 cache_ttl_buffer_seconds: TokenCache::DEFAULT_TTL_BUFFER_SECONDS,
42 default_token_ttl_seconds: TokenCache::DEFAULT_TTL_SECONDS,
43 circuit_breaker_threshold: CircuitBreaker::DEFAULT_THRESHOLD,
44 circuit_breaker_cooldown_seconds: CircuitBreaker::DEFAULT_COOLDOWN_SECONDS,
45 dpop_provider: None,
46 auth_provider: None,
47 on_metadata_change: None,
48 }
49 }
50}
51
52#[derive(Clone)]
70pub struct AuthplaneClient {
71 issuer: String,
72 metadata: AuthorizationServerMetadata,
73 metadata_cache: MetadataCache,
74 fetch_settings: FetchSettings,
75 http: Arc<Client>,
76 jwks_cache: Arc<JwksCache>,
77 metadata_binding: Arc<MetadataBinding>,
78 circuit_breaker: Arc<CircuitBreaker>,
79 token_cache: Arc<TokenCache>,
80 dpop_provider: Option<Arc<DpopProvider>>,
81 auth_provider: Option<Arc<dyn crate::auth_provider::AuthProvider>>,
82}
83
84impl std::fmt::Debug for AuthplaneClient {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.debug_struct("AuthplaneClient")
87 .field("issuer", &self.issuer)
88 .finish()
89 }
90}
91
92impl AuthplaneClient {
93 pub fn builder(issuer: impl Into<String>) -> AuthplaneClientBuilder {
95 AuthplaneClientBuilder::new(issuer)
96 }
97
98 pub async fn create(
103 issuer: &str,
104 fetch_settings: FetchSettings,
105 ) -> Result<Self, AuthplaneError> {
106 Self::build(
107 issuer.to_string(),
108 fetch_settings,
109 ClientRuntimeConfig::defaults(),
110 )
111 .await
112 }
113
114 pub async fn discover(issuer: &str) -> Result<Self, AuthplaneError> {
116 Self::create(issuer, FetchSettings::default()).await
117 }
118
119 pub(crate) async fn build(
121 issuer: String,
122 fetch_settings: FetchSettings,
123 runtime: ClientRuntimeConfig,
124 ) -> Result<Self, AuthplaneError> {
125 let http = Arc::new(build_http_client(&fetch_settings)?);
126 let normalized_issuer = crate::errors::normalize_issuer(&issuer).to_string();
127
128 let metadata_url = build_metadata_url(&normalized_issuer)?;
131 validate_fetch_url(
132 &metadata_url,
133 &fetch_settings,
134 "authorization server metadata URL",
135 )?;
136 let metadata_fetcher = DocumentFetcher::with_client(
137 metadata_url.clone(),
138 "metadata",
139 fetch_settings.clone(),
140 DocumentFetcher::DEFAULT_METADATA_MAX_BYTES,
141 http.clone(),
142 );
143 let metadata_fetcher_fn = fetcher_to_fn(metadata_fetcher);
144 let metadata_cache = MetadataCache::new(
145 metadata_fetcher_fn,
146 normalized_issuer.clone(),
147 fetch_settings.clone(),
148 runtime.metadata_refresh_seconds,
149 runtime.on_metadata_change.clone(),
150 );
151
152 let metadata_value = metadata_cache.get_metadata().await?;
153 let metadata: AuthorizationServerMetadata = serde_json::from_value(metadata_value.clone())
154 .map_err(|error| transport_error(&error.to_string()))?;
155 metadata.validate(&normalized_issuer, &fetch_settings)?;
156
157 let jwks_uri: JwksUriCell = Arc::new(std::sync::RwLock::new(metadata.jwks_uri.clone()));
165 let jwks_fetcher_fn =
166 rebindable_jwks_fetcher(jwks_uri.clone(), fetch_settings.clone(), http.clone());
167 let jwks_cache = Arc::new(JwksCache::new(
168 jwks_fetcher_fn,
169 runtime.jwks_refresh_seconds,
170 ));
171 let metadata_binding = Arc::new(MetadataBinding::new(
172 metadata_cache.clone(),
173 jwks_cache.clone(),
174 jwks_uri,
175 normalized_issuer.clone(),
176 fetch_settings.clone(),
177 runtime.metadata_refresh_seconds,
178 ));
179
180 let circuit_breaker = Arc::new(CircuitBreaker::with_config(
181 runtime.circuit_breaker_threshold,
182 runtime.circuit_breaker_cooldown_seconds,
183 ));
184 let token_cache = Arc::new(TokenCache::with_config(
185 runtime.cache_ttl_buffer_seconds,
186 runtime.default_token_ttl_seconds,
187 ));
188
189 Ok(Self {
190 issuer: normalized_issuer,
191 metadata,
192 metadata_cache,
193 fetch_settings,
194 http,
195 jwks_cache,
196 metadata_binding,
197 circuit_breaker,
198 token_cache,
199 dpop_provider: runtime.dpop_provider,
200 auth_provider: runtime.auth_provider,
201 })
202 }
203
204 pub fn issuer(&self) -> &str {
205 &self.issuer
206 }
207
208 pub fn metadata(&self) -> &AuthorizationServerMetadata {
219 &self.metadata
220 }
221
222 pub fn jwks_cache(&self) -> Arc<JwksCache> {
225 self.jwks_cache.clone()
226 }
227
228 pub fn metadata_cache(&self) -> MetadataCache {
230 self.metadata_cache.clone()
231 }
232
233 pub fn token_cache(&self) -> Arc<TokenCache> {
235 self.token_cache.clone()
236 }
237
238 pub fn circuit_breaker(&self) -> Arc<CircuitBreaker> {
240 self.circuit_breaker.clone()
241 }
242
243 pub fn dpop_provider(&self) -> Option<Arc<DpopProvider>> {
245 self.dpop_provider.clone()
246 }
247
248 pub fn auth_provider(&self) -> Option<Arc<dyn crate::auth_provider::AuthProvider>> {
250 self.auth_provider.clone()
251 }
252
253 pub fn fetch_settings(&self) -> &FetchSettings {
255 &self.fetch_settings
256 }
257
258 pub fn auth(&self) -> AuthplaneAuth {
259 AuthplaneAuth::new(
260 self.metadata.clone(),
261 self.fetch_settings.clone(),
262 (*self.http).clone(),
263 )
264 }
265
266 pub fn prm_response(&self, resource: &str, scopes: &[String]) -> ProtectedResourceMetadata {
271 build_prm(&self.issuer, resource, scopes, None, false)
272 }
273
274 pub async fn client_credentials(
280 &self,
281 client_id: &str,
282 client_secret: &str,
283 scopes: &[String],
284 resources: &[String],
285 dpop: Option<&DpopProvider>,
286 ) -> Result<TokenResponse, AuthplaneError> {
287 self.guarded_client_credentials(client_id, client_secret, scopes, resources, dpop)
288 .await
289 }
290
291 pub async fn client_credentials_stored(
303 &self,
304 scopes: &[String],
305 resources: &[String],
306 dpop: Option<&DpopProvider>,
307 ) -> Result<TokenResponse, AuthplaneError> {
308 let provider = self.auth_provider.as_ref().ok_or_else(|| {
309 crate::errors::auth_error(
310 "auth_provider_not_configured",
311 "no auth provider configured on this client",
312 )
313 })?;
314 let auth_header = provider.auth_header();
315
316 let scope_key = scopes.join(" ");
317 let resource_key = resources.join(",");
318 let cache_key = format!(
319 "cc_stored:{}",
320 TokenCache::cache_key(&scope_key, &resource_key)
321 );
322 if dpop.is_none()
323 && let Some(cached) = self.token_cache.get(&cache_key)
324 {
325 return Ok(cached.into());
326 }
327
328 let result = self
329 .run_guarded(|| async {
330 self.auth()
331 .client_credentials_with_header(&auth_header, scopes, resources, dpop)
332 .await
333 })
334 .await?;
335
336 if dpop.is_none() {
337 self.token_cache.set(
338 &cache_key,
339 &result.access_token,
340 &result.token_type,
341 result.expires_in,
342 &result.scope,
343 result.cnf.as_ref(),
344 &result.cnf_jkt,
345 );
346 }
347 Ok(result)
348 }
349
350 pub async fn exchange_token(
353 &self,
354 client_id: &str,
355 client_secret: &str,
356 options: &TokenExchangeOptions,
357 dpop: Option<&DpopProvider>,
358 ) -> Result<TokenResponse, AuthplaneError> {
359 self.run_guarded(|| async {
360 self.auth()
361 .exchange_token(client_id, client_secret, options, dpop)
362 .await
363 })
364 .await
365 }
366
367 pub async fn introspect(
370 &self,
371 client_id: &str,
372 client_secret: &str,
373 token: &str,
374 dpop: Option<&DpopProvider>,
375 ) -> Result<IntrospectionResponse, AuthplaneError> {
376 self.run_guarded(|| async {
377 self.auth()
378 .introspect(client_id, client_secret, token, dpop)
379 .await
380 })
381 .await
382 }
383
384 pub async fn revoke(
387 &self,
388 client_id: &str,
389 client_secret: &str,
390 token: &str,
391 dpop: Option<&DpopProvider>,
392 ) -> Result<(), AuthplaneError> {
393 self.run_guarded(|| async {
394 self.auth()
395 .revoke(client_id, client_secret, token, dpop)
396 .await
397 })
398 .await
399 }
400
401 pub async fn resource(
402 &self,
403 resource: &str,
404 scopes: &[String],
405 ) -> Result<AuthplaneResource, crate::VerifierError> {
406 self.resource_with_options(resource, scopes, ResourceOptions::default())
407 .await
408 }
409
410 pub async fn resource_with_options(
411 &self,
412 resource: &str,
413 scopes: &[String],
414 options: ResourceOptions,
415 ) -> Result<AuthplaneResource, crate::VerifierError> {
416 AuthplaneResource::from_parts(
417 self.issuer.clone(),
418 resource.to_string(),
419 scopes.to_vec(),
420 self.metadata.clone(),
421 self.fetch_settings.clone(),
422 options,
423 (*self.http).clone(),
424 Some(self.jwks_cache.clone()),
425 Some(self.metadata_binding.clone()),
426 Some(self.circuit_breaker.clone()),
427 )
428 .await
429 }
430
431 pub fn dpop_headers(
437 &self,
438 method: &str,
439 url: &str,
440 access_token: Option<&str>,
441 ) -> Result<Vec<(String, String)>, AuthplaneError> {
442 let provider = self.dpop_provider.as_ref().ok_or_else(|| {
443 crate::errors::auth_error(
444 "dpop_not_configured",
445 "no DPoP provider configured on this client",
446 )
447 })?;
448 provider.build_headers(method, url, access_token)
449 }
450
451 pub async fn aclose(&self) {
454 self.metadata_cache.aclose().await;
455 self.jwks_cache.aclose().await;
456 }
457
458 async fn guarded_client_credentials(
459 &self,
460 client_id: &str,
461 client_secret: &str,
462 scopes: &[String],
463 resources: &[String],
464 dpop: Option<&DpopProvider>,
465 ) -> Result<TokenResponse, AuthplaneError> {
466 let scope_key = scopes.join(" ");
468 let resource_key = resources.join(",");
469 let cache_key = format!("cc:{}", TokenCache::cache_key(&scope_key, &resource_key));
470 if dpop.is_none()
471 && let Some(cached) = self.token_cache.get(&cache_key)
472 {
473 return Ok(cached.into());
474 }
475
476 let result = self
477 .run_guarded(|| async {
478 self.auth()
479 .client_credentials(client_id, client_secret, scopes, resources, dpop)
480 .await
481 })
482 .await?;
483
484 if dpop.is_none() {
485 self.token_cache.set(
486 &cache_key,
487 &result.access_token,
488 &result.token_type,
489 result.expires_in,
490 &result.scope,
491 result.cnf.as_ref(),
492 &result.cnf_jkt,
493 );
494 }
495 Ok(result)
496 }
497
498 async fn run_guarded<F, Fut, T>(&self, op: F) -> Result<T, AuthplaneError>
499 where
500 F: FnOnce() -> Fut,
501 Fut: std::future::Future<Output = Result<T, AuthplaneError>>,
502 {
503 if !self.circuit_breaker.allow() {
504 return Err(AuthplaneError::CircuitOpen);
505 }
506 match op().await {
507 Ok(value) => {
508 self.circuit_breaker.record_success();
509 Ok(value)
510 }
511 Err(error) => {
512 if crate::circuit_policy::should_count_failure(&error) {
513 self.circuit_breaker.record_failure();
514 }
515 Err(error)
516 }
517 }
518 }
519}
520
521fn rebindable_jwks_fetcher(
529 jwks_uri: JwksUriCell,
530 fetch_settings: FetchSettings,
531 http: Arc<Client>,
532) -> DocumentFetcherFn {
533 Arc::new(move || {
534 let url = jwks_uri.read().expect("jwks_uri lock poisoned").clone();
535 let fetcher = DocumentFetcher::with_client(
536 url,
537 "jwks",
538 fetch_settings.clone(),
539 DocumentFetcher::DEFAULT_JWKS_MAX_BYTES,
540 http.clone(),
541 );
542 Box::pin(async move {
543 let result = fetcher.fetch().await?;
544 Ok(FetchResult {
545 document: result.document,
546 expires_at: result.expires_at,
547 })
548 })
549 })
550}
551
552fn fetcher_to_fn(fetcher: DocumentFetcher) -> DocumentFetcherFn {
553 let fetcher = Arc::new(fetcher);
554 Arc::new(move || {
555 let fetcher = fetcher.clone();
556 Box::pin(async move {
557 let result = fetcher.fetch().await?;
558 Ok(FetchResult {
559 document: result.document,
560 expires_at: result.expires_at,
561 })
562 })
563 })
564}
565
566#[cfg(test)]
567mod tests {
568 use super::AuthplaneClient;
569 use crate::transport::build_basic_auth_header;
570 use crate::{AuthorizationServerMetadata, FetchSettings};
571 use mockito::Server;
572 use serde_json::json;
573
574 fn dummy_metadata(issuer: &str) -> AuthorizationServerMetadata {
575 AuthorizationServerMetadata {
576 issuer: issuer.to_string(),
577 jwks_uri: format!("{issuer}/jwks"),
578 token_endpoint: Some(format!("{issuer}/token")),
579 introspection_endpoint: None,
580 revocation_endpoint: None,
581 }
582 }
583
584 #[test]
585 fn basic_auth_percent_encodes_credentials_before_base64() {
586 let header = build_basic_auth_header("http://localhost:8080/mcp", "s3cret");
587 assert_eq!(
588 header,
589 "Basic aHR0cCUzQSUyRiUyRmxvY2FsaG9zdCUzQTgwODAlMkZtY3A6czNjcmV0"
590 );
591 }
592
593 #[test]
594 fn discover_uses_secure_default_fetch_settings() {
595 let settings = FetchSettings::default();
596 assert!(settings.ssrf_protection);
597 assert!(!settings.allow_http);
598 }
599
600 #[test]
601 fn dummy_metadata_round_trip() {
602 let meta = dummy_metadata("https://auth.example.com");
603 assert_eq!(meta.issuer, "https://auth.example.com");
604 }
605
606 #[tokio::test]
607 async fn create_loads_metadata_and_normalizes_issuer() {
608 let mut server = Server::new_async().await;
609 let issuer = server.url();
610 let metadata_body = json!({
611 "issuer": issuer,
612 "jwks_uri": format!("{issuer}/jwks"),
613 "token_endpoint": format!("{issuer}/oauth/token"),
614 "introspection_endpoint": format!("{issuer}/oauth/introspect"),
615 "revocation_endpoint": format!("{issuer}/oauth/revoke")
616 });
617 let _mock = server
618 .mock("GET", "/.well-known/oauth-authorization-server")
619 .with_status(200)
620 .with_header("content-type", "application/json")
621 .with_body(metadata_body.to_string())
622 .create();
623
624 let client =
625 AuthplaneClient::create(&(issuer.clone() + "/"), FetchSettings::from_dev_mode(true))
626 .await
627 .expect("client should be created");
628
629 assert_eq!(client.issuer(), issuer);
630 assert_eq!(
631 client.metadata().token_endpoint.as_deref(),
632 Some(format!("{issuer}/oauth/token").as_str())
633 );
634 }
635
636 #[tokio::test]
637 async fn create_fails_when_metadata_fetch_returns_error_status() {
638 let mut server = Server::new_async().await;
639 let _mock = server
640 .mock("GET", "/.well-known/oauth-authorization-server")
641 .with_status(500)
642 .with_header("content-type", "application/json")
643 .with_body(r#"{"error":"server_error","error_description":"boom"}"#)
644 .create();
645
646 let error = AuthplaneClient::create(&server.url(), FetchSettings::from_dev_mode(true))
647 .await
648 .expect_err("must fail");
649 assert!(
650 error.to_string().to_lowercase().contains("metadata")
651 || error.to_string().contains("HTTP")
652 );
653 }
654
655 #[tokio::test]
656 async fn token_cache_short_circuits_repeated_client_credentials_calls() {
657 let mut server = Server::new_async().await;
658 let issuer = server.url();
659 let _metadata = server
660 .mock("GET", "/.well-known/oauth-authorization-server")
661 .with_status(200)
662 .with_header("content-type", "application/json")
663 .with_body(
664 json!({
665 "issuer": issuer,
666 "jwks_uri": format!("{issuer}/jwks"),
667 "token_endpoint": format!("{issuer}/oauth/token")
668 })
669 .to_string(),
670 )
671 .create();
672 let token_mock = server
674 .mock("POST", "/oauth/token")
675 .expect(1)
676 .with_status(200)
677 .with_header("content-type", "application/json")
678 .with_body(
679 json!({
680 "access_token":"t1",
681 "token_type":"Bearer",
682 "expires_in":3600,
683 "scope":"tools/read"
684 })
685 .to_string(),
686 )
687 .create();
688
689 let client = AuthplaneClient::create(&issuer, FetchSettings::from_dev_mode(true))
690 .await
691 .expect("client");
692 let first = client
693 .client_credentials(
694 "cid",
695 "csecret",
696 &["tools/read".into()],
697 &["https://api".into()],
698 None,
699 )
700 .await
701 .expect("first");
702 let second = client
703 .client_credentials(
704 "cid",
705 "csecret",
706 &["tools/read".into()],
707 &["https://api".into()],
708 None,
709 )
710 .await
711 .expect("second");
712 assert_eq!(first.access_token, second.access_token);
713 token_mock.assert();
714 }
715
716 #[tokio::test]
717 async fn dpop_use_dpop_nonce_retry_is_transparent_to_callers() {
718 use crate::dpop_provider::DpopProvider;
725 use jsonwebtoken::Algorithm;
726
727 let mut server = Server::new_async().await;
728 let issuer = server.url();
729 let _metadata = server
730 .mock("GET", "/.well-known/oauth-authorization-server")
731 .with_status(200)
732 .with_header("content-type", "application/json")
733 .with_body(
734 json!({
735 "issuer": issuer,
736 "jwks_uri": format!("{issuer}/jwks"),
737 "token_endpoint": format!("{issuer}/oauth/token")
738 })
739 .to_string(),
740 )
741 .create();
742
743 let nonce_mock = server
745 .mock("POST", "/oauth/token")
746 .expect(1)
747 .with_status(400)
748 .with_header("content-type", "application/json")
749 .with_header("dpop-nonce", "as-issued-nonce")
750 .with_body(r#"{"error":"use_dpop_nonce"}"#)
751 .create();
752 let success_mock = server
754 .mock("POST", "/oauth/token")
755 .expect(1)
756 .with_status(200)
757 .with_header("content-type", "application/json")
758 .with_body(
759 json!({
760 "access_token": "dpop-bound-token",
761 "token_type": "DPoP",
762 "expires_in": 3600,
763 "scope": "tools/read"
764 })
765 .to_string(),
766 )
767 .create();
768
769 let client = AuthplaneClient::create(&issuer, FetchSettings::from_dev_mode(true))
770 .await
771 .expect("client");
772
773 let pem = include_str!("../tests/fixtures/test-private.pem");
774 let provider = DpopProvider::from_pem(pem, Algorithm::RS256).expect("provider");
775
776 let response = client
777 .client_credentials(
778 "cid",
779 "csecret",
780 &["tools/read".into()],
781 &[],
782 Some(&provider),
783 )
784 .await
785 .expect("retry should succeed");
786
787 assert_eq!(response.access_token, "dpop-bound-token");
788 assert_eq!(response.token_type, "DPoP");
789 nonce_mock.assert();
792 success_mock.assert();
793 assert_eq!(
796 provider
797 .current_nonce(&format!("{issuer}/oauth/token"))
798 .expect("current_nonce"),
799 "as-issued-nonce"
800 );
801 }
802
803 #[tokio::test]
804 async fn revoke_succeeds_on_empty_response_body() {
805 let mut server = Server::new_async().await;
811 let issuer = server.url();
812 let _metadata = server
813 .mock("GET", "/.well-known/oauth-authorization-server")
814 .with_status(200)
815 .with_header("content-type", "application/json")
816 .with_body(
817 json!({
818 "issuer": issuer,
819 "jwks_uri": format!("{issuer}/jwks"),
820 "token_endpoint": format!("{issuer}/oauth/token"),
821 "revocation_endpoint": format!("{issuer}/oauth/revoke")
822 })
823 .to_string(),
824 )
825 .create();
826 let revoke_mock = server
829 .mock("POST", "/oauth/revoke")
830 .expect(1)
831 .with_status(200)
832 .create();
833
834 let client = AuthplaneClient::create(&issuer, FetchSettings::from_dev_mode(true))
835 .await
836 .expect("client");
837 client
838 .revoke("cid", "csecret", "tok", None)
839 .await
840 .expect("revoke must succeed on empty 200 body");
841 revoke_mock.assert();
842 }
843
844 #[tokio::test]
845 async fn circuit_breaker_short_circuits_after_threshold_failures() {
846 let mut server = Server::new_async().await;
847 let issuer = server.url();
848 let _metadata = server
849 .mock("GET", "/.well-known/oauth-authorization-server")
850 .with_status(200)
851 .with_header("content-type", "application/json")
852 .with_body(
853 json!({
854 "issuer": issuer,
855 "jwks_uri": format!("{issuer}/jwks"),
856 "token_endpoint": format!("{issuer}/oauth/token")
857 })
858 .to_string(),
859 )
860 .create();
861 let _token = server
864 .mock("POST", "/oauth/token")
865 .with_status(500)
866 .with_header("content-type", "application/json")
867 .with_body(r#"{"error":"server_error"}"#)
868 .expect_at_most(5)
869 .create();
870
871 let client = AuthplaneClient::builder(&issuer)
872 .with_fetch_settings(FetchSettings::from_dev_mode(true))
873 .with_circuit_breaker_threshold(5)
874 .with_circuit_breaker_cooldown_seconds(60.0)
875 .build()
876 .await
877 .expect("client");
878
879 for _ in 0..5 {
880 let _ = client
881 .client_credentials("cid", "csecret", &["s".into()], &["r".into()], None)
882 .await;
883 }
884 let error = client
886 .client_credentials("cid", "csecret", &["s".into()], &["r".into()], None)
887 .await
888 .expect_err("circuit must short-circuit");
889 let msg = error.to_string().to_lowercase();
890 assert!(
891 msg.contains("circuit"),
892 "expected circuit-open error, got {msg}"
893 );
894 }
895}