Skip to main content

authplane_sdk/
client.rs

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/// Internal runtime configuration assembled by [`AuthplaneClientBuilder`].
23#[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/// Authplane client — the entry point for AS discovery, token operations,
53/// and resource creation.
54///
55/// `AuthplaneClient` wires the production-ready runtime the SDK exposes by
56/// default:
57/// * [`MetadataCache`] backing AS-metadata discovery, with `on_change`
58///   callbacks fired on rotation.
59/// * [`JwksCache`] with background refresh and `kid`-miss force-refresh
60///   semantics, shared with every [`AuthplaneResource`] obtained via
61///   [`AuthplaneClient::resource`]. Its fetch target follows the
62///   `jwks_uri` published by the AS: once `metadata_refresh_seconds` has
63///   elapsed, ordinary verification traffic re-reads the metadata
64///   document and rebinds JWKS fetching to the rotated URL (RFC 8414 §2).
65/// * [`TokenCache`] caching `client_credentials` results with a TTL buffer.
66/// * [`CircuitBreaker`] guarding every outbound AS call.
67/// * Optional [`DpopProvider`] supplying outbound DPoP proofs when an
68///   operation is called with `Some(&DpopProofOptions)`.
69#[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    /// Build a builder seeded with SDK defaults.
94    pub fn builder(issuer: impl Into<String>) -> AuthplaneClientBuilder {
95        AuthplaneClientBuilder::new(issuer)
96    }
97
98    /// Backwards-compatible constructor.
99    ///
100    /// Mirrors the previous signature so existing callers keep compiling.
101    /// Wires the same defaults as [`AuthplaneClient::builder`].
102    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    /// Convenience constructor using `FetchSettings::default()`.
115    pub async fn discover(issuer: &str) -> Result<Self, AuthplaneError> {
116        Self::create(issuer, FetchSettings::default()).await
117    }
118
119    /// Build path used by the public constructors and the builder.
120    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        // Metadata cache (one fetch happens during validation below; the
129        // returned document is then reused so we do not re-hit the AS).
130        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        // JWKS cache (primed lazily on first verifier use). The unified
158        // `fetch_settings` governs both metadata and JWKS document fetches
159        // (RFC 8414 / RFC 7517 — same threat profile).
160        //
161        // The fetch target is held in a shared cell instead of being baked
162        // into the fetcher, so `MetadataBinding` can follow a `jwks_uri`
163        // rotation without rebuilding a cache that resources already hold.
164        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    /// AS metadata as discovered when this client was built.
209    ///
210    /// This is a snapshot, not a live view: it returns a borrow, so it
211    /// cannot hand out a document that a background rotation may replace.
212    /// The JWKS fetch target is *not* read from here — it follows the
213    /// rotating document (see [`MetadataCache`] and
214    /// `metadata_refresh_seconds`). Callers that need the current
215    /// endpoints should read them from
216    /// [`AuthplaneClient::metadata_cache`], which re-fetches on its own
217    /// TTL.
218    pub fn metadata(&self) -> &AuthorizationServerMetadata {
219        &self.metadata
220    }
221
222    /// Shared JWKS cache; passed into [`AuthplaneResource`] so token
223    /// verification benefits from background refresh + stale fallback.
224    pub fn jwks_cache(&self) -> Arc<JwksCache> {
225        self.jwks_cache.clone()
226    }
227
228    /// Shared metadata cache.
229    pub fn metadata_cache(&self) -> MetadataCache {
230        self.metadata_cache.clone()
231    }
232
233    /// Token cache for `client_credentials` results.
234    pub fn token_cache(&self) -> Arc<TokenCache> {
235        self.token_cache.clone()
236    }
237
238    /// Circuit breaker guarding outbound AS calls.
239    pub fn circuit_breaker(&self) -> Arc<CircuitBreaker> {
240        self.circuit_breaker.clone()
241    }
242
243    /// Outbound DPoP provider, if configured via the builder.
244    pub fn dpop_provider(&self) -> Option<Arc<DpopProvider>> {
245        self.dpop_provider.clone()
246    }
247
248    /// Stored auth provider, if configured via the builder.
249    pub fn auth_provider(&self) -> Option<Arc<dyn crate::auth_provider::AuthProvider>> {
250        self.auth_provider.clone()
251    }
252
253    /// Fetch settings (HTTPS-only / SSRF / dev-mode policy).
254    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    /// Client-level PRM convenience: emits a Mode-3 (DPoP-unconfigured) document.
267    /// Inbound DPoP advertising is per-resource state, so use
268    /// [`AuthplaneResource::prm_response`](crate::AuthplaneResource::prm_response)
269    /// when serving PRM for a resource that may have `inbound_dpop` configured.
270    pub fn prm_response(&self, resource: &str, scopes: &[String]) -> ProtectedResourceMetadata {
271        build_prm(&self.issuer, resource, scopes, None, false)
272    }
273
274    /// `client_credentials` grant with circuit-breaker + token-cache.
275    ///
276    /// Pass `Some(&proof_options)` to attach an outbound DPoP proof; `None`
277    /// for the plain bearer path. DPoP-bound results bypass the token cache
278    /// (each call mints a fresh proof bound to the token endpoint).
279    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    /// `client_credentials` grant using the stored [`AuthProvider`].
292    ///
293    /// Returns `Err` if no auth provider was configured via the builder.
294    /// The provider's `auth_header()` value is used as the `Authorization`
295    /// header for the token request.
296    ///
297    /// Pass `Some(&proof_options)` to attach an outbound DPoP proof; `None`
298    /// for the plain bearer path. DPoP-bound results bypass the token
299    /// cache (mirrors [`AuthplaneClient::client_credentials`]).
300    ///
301    /// [`AuthProvider`]: crate::auth_provider::AuthProvider
302    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    /// RFC 8693 token exchange (guarded by the circuit breaker).
351    /// Pass `Some(&proof_options)` to attach an outbound DPoP proof.
352    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    /// RFC 7662 introspection (guarded by the circuit breaker).
368    /// Pass `Some(&proof_options)` to attach an outbound DPoP proof.
369    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    /// RFC 7009 revocation (guarded by the circuit breaker).
385    /// Pass `Some(&proof_options)` to attach an outbound DPoP proof.
386    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    /// Build DPoP proof headers for downstream API calls.
432    ///
433    /// Exposes the configured [`DpopProvider`]'s `build_headers` so
434    /// callers can attach a DPoP proof to outbound resource requests.
435    /// Returns `Err` if no DPoP provider was configured.
436    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    /// Cancel background refresh tasks held by the metadata + JWKS caches.
452    /// Idempotent.
453    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        // Cache is keyed by sorted scopes + comma-joined resources.
467        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
521/// JWKS fetcher that reads its target URL from `jwks_uri` on every fetch.
522///
523/// [`DocumentFetcher`] pins one URL for its lifetime and [`JwksCache`]
524/// owns its fetcher immutably, so following an RFC 8414 `jwks_uri`
525/// rotation means resolving the URL per fetch rather than at wiring time.
526/// Building the fetcher here is cheap — it clones the settings and the
527/// shared HTTP client, and performs no I/O until `fetch()` runs.
528fn 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        // Token endpoint should be hit exactly once even after two calls.
673        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        // RFC 9449 §6.1: a server may return `use_dpop_nonce` plus a
719        // `DPoP-Nonce` header on the first DPoP-authenticated request.
720        // The SDK must rebuild the proof with the supplied nonce and
721        // retry transparently. Previously the four `pub async fn` callers
722        // in `oauth.rs` discarded the nonce (`let (_, _, _nonce)`) and
723        // surfaced the error to the caller; this test pins the new contract.
724        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        // First POST: 400 + DPoP-Nonce header (RFC 9449 §6.1).
744        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        // Second POST (retry with the supplied nonce): 200 success.
753        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        // Both endpoints must have been hit exactly once — proves the
790        // retry happened (not just a silent success on first call).
791        nonce_mock.assert();
792        success_mock.assert();
793        // Provider must have noted the AS-supplied nonce so subsequent
794        // requests reuse it (the whole point of `note_nonce`).
795        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        // RFC 7009 §2.2: a successful revocation returns 200 with no body.
806        // `do_form_post` previously called `response.json()` unconditionally,
807        // mapping the resulting EOF/parse error to `transport_error` — so a
808        // spec-conformant revoke surfaced as `Err`. Treat an empty body as
809        // `{}` so the 2xx path through `revoke_token` returns `Ok(())`.
810        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        // RFC 7009-conformant revoke response: 200 with no body and no
827        // content-type. Pre-fix, `response.json()` rejected this.
828        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        // Repeated 500s from token endpoint should trip the breaker after
862        // threshold=5 failures, then short-circuit subsequent calls.
863        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        // 6th call: must short-circuit BEFORE hitting the AS.
885        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}