Skip to main content

authplane_sdk/cache/
metadata_cache.rs

1//! AS metadata cache (RFC 8414) with `on_change` notifications.
2//!
3//! Discovers the AS metadata document, validates the discovered `issuer`
4//! against the configured one, exposes typed accessors for individual
5//! endpoints, and notifies a callback when the cached document changes
6//! (e.g. when `jwks_uri` rotates).
7
8use std::sync::Arc;
9
10use serde_json::Value;
11
12use crate::cache::document_cache::{DocumentCache, DocumentChangeCallback, DocumentFetcherFn};
13use crate::transport::validate_fetch_url;
14use crate::{AuthplaneError, FetchSettings};
15
16/// Convenience alias for the metadata `on_change` callback shape.
17pub type MetadataChangeCallback = DocumentChangeCallback;
18
19/// AS metadata cache.
20#[derive(Clone)]
21pub struct MetadataCache {
22    inner: Arc<DocumentCache>,
23    expected_issuer: String,
24    fetch_settings: FetchSettings,
25}
26
27impl std::fmt::Debug for MetadataCache {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.debug_struct("MetadataCache")
30            .field("expected_issuer", &self.expected_issuer)
31            .field("inner", &self.inner)
32            .finish()
33    }
34}
35
36impl MetadataCache {
37    /// Create a new metadata cache.
38    ///
39    /// `fetcher` MUST GET the AS metadata document. `expected_issuer` is
40    /// the issuer the application configured; the discovered document
41    /// is rejected if its `issuer` field does not match exactly (RFC 8414).
42    pub fn new(
43        fetcher: DocumentFetcherFn,
44        expected_issuer: impl Into<String>,
45        fetch_settings: FetchSettings,
46        refresh_seconds: u64,
47        on_change: Option<MetadataChangeCallback>,
48    ) -> Self {
49        let inner = DocumentCache::with_error_factory(
50            fetcher,
51            refresh_seconds,
52            "metadata",
53            on_change,
54            Box::new(metadata_error_factory),
55        );
56        Self {
57            inner,
58            expected_issuer: crate::errors::normalize_issuer(&expected_issuer.into()).to_string(),
59            fetch_settings,
60        }
61    }
62
63    /// Underlying [`DocumentCache`] (for `aclose()` plumbing).
64    pub fn document_cache(&self) -> Arc<DocumentCache> {
65        self.inner.clone()
66    }
67
68    /// Cancel any background refresh task.
69    pub async fn aclose(&self) {
70        self.inner.aclose().await;
71    }
72
73    /// Return the cached metadata document, refreshing if expired.
74    pub async fn get_metadata(&self) -> Result<Value, AuthplaneError> {
75        let document = self.inner.get(false).await?;
76        self.validate_issuer(&document)?;
77        Ok(document)
78    }
79
80    /// Force-refresh and return the metadata document.
81    pub async fn refresh(&self) -> Result<Value, AuthplaneError> {
82        let document = self.inner.get(true).await?;
83        self.validate_issuer(&document)?;
84        Ok(document)
85    }
86
87    /// Force-refresh, surfacing a failed fetch rather than falling back to
88    /// the cached document. See [`DocumentCache::refresh_strict`].
89    pub(crate) async fn refresh_strict(&self) -> Result<Value, AuthplaneError> {
90        let document = self.inner.refresh_strict().await?;
91        self.validate_issuer(&document)?;
92        Ok(document)
93    }
94
95    /// Read a specific endpoint URL out of the cached metadata.
96    pub async fn endpoint(&self, key: &str) -> Result<String, AuthplaneError> {
97        let metadata = self.get_metadata().await?;
98        let value = metadata
99            .get(key)
100            .and_then(Value::as_str)
101            .filter(|value| !value.is_empty())
102            .ok_or_else(|| {
103                metadata_error_factory(&format!("AS metadata missing required '{key}' endpoint"))
104            })?
105            .to_string();
106        validate_fetch_url(&value, &self.fetch_settings, &format!("{key} URL"))?;
107        Ok(value)
108    }
109
110    /// Convenience accessor for `jwks_uri`. The URL is validated through
111    /// the fetch settings (HTTPS-only, SSRF, …).
112    pub async fn get_jwks_uri(&self) -> Result<String, AuthplaneError> {
113        self.endpoint("jwks_uri").await
114    }
115
116    /// Convenience accessor for `token_endpoint`.
117    pub async fn get_token_endpoint(&self) -> Result<String, AuthplaneError> {
118        self.endpoint("token_endpoint").await
119    }
120
121    /// Convenience accessor for `introspection_endpoint`.
122    pub async fn get_introspection_endpoint(&self) -> Result<String, AuthplaneError> {
123        self.endpoint("introspection_endpoint").await
124    }
125
126    /// Convenience accessor for `revocation_endpoint`.
127    pub async fn get_revocation_endpoint(&self) -> Result<String, AuthplaneError> {
128        self.endpoint("revocation_endpoint").await
129    }
130
131    fn validate_issuer(&self, document: &Value) -> Result<(), AuthplaneError> {
132        let discovered = document
133            .get("issuer")
134            .and_then(Value::as_str)
135            .map(crate::errors::normalize_issuer)
136            .ok_or_else(|| metadata_error_factory("AS metadata missing 'issuer' field"))?;
137        if discovered != self.expected_issuer {
138            return Err(metadata_error_factory(&format!(
139                "AS metadata issuer mismatch: configured {:?}, discovered {:?}",
140                self.expected_issuer, discovered
141            )));
142        }
143        Ok(())
144    }
145}
146
147// Local thin wrapper around the shared `errors::metadata_error` helper.
148// Keeps the `Box<dyn Fn(&str) -> AuthplaneError>` shape `DocumentCache`
149// expects without re-stating the `AuthError { code: ... }` literal,
150// matching `prm.rs` and `metadata.rs` which call the shared helper
151// directly.
152fn metadata_error_factory(message: &str) -> AuthplaneError {
153    crate::errors::metadata_error(message)
154}