use serde::de::DeserializeOwned;
use std::sync::Arc;
use volga_oauth_core::{
AuthorizationServerMetadata, ProtectedResourceMetadata, authorization_server_metadata_url,
openid_configuration_url, protected_resource_metadata_url,
};
use crate::{ClientConfig, ClientError, MetadataCache, transport::Transport};
pub struct DiscoveryClient {
transport: Transport,
cache: Option<Arc<dyn MetadataCache>>,
}
impl std::fmt::Debug for DiscoveryClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DiscoveryClient")
.field("transport", &self.transport)
.field("cache", &self.cache.as_ref().map(|_| "dyn MetadataCache"))
.finish()
}
}
impl Default for DiscoveryClient {
#[inline]
fn default() -> Self {
Self::new()
}
}
impl DiscoveryClient {
pub fn new() -> Self {
Self::with_config(ClientConfig::new())
}
pub fn with_config(config: ClientConfig) -> Self {
Self {
transport: Transport::new(config),
cache: None,
}
}
pub fn with_cache(mut self, cache: Arc<dyn MetadataCache>) -> Self {
self.cache = Some(cache);
self
}
pub async fn fetch_server_metadata(
&self,
issuer: &str,
) -> Result<AuthorizationServerMetadata, ClientError> {
let url = authorization_server_metadata_url(issuer)
.map_err(|err| ClientError::validation(err.to_string()))?;
self.fetch_document(&url, |metadata: &AuthorizationServerMetadata| {
validate_identifier("issuer", &metadata.issuer, issuer)
})
.await
}
pub async fn fetch_oidc_metadata(
&self,
issuer: &str,
) -> Result<AuthorizationServerMetadata, ClientError> {
let url = openid_configuration_url(issuer)
.map_err(|err| ClientError::validation(err.to_string()))?;
self.fetch_document(&url, |metadata: &AuthorizationServerMetadata| {
validate_identifier("issuer", &metadata.issuer, issuer)
})
.await
}
pub async fn fetch_resource_metadata(
&self,
resource: &str,
) -> Result<ProtectedResourceMetadata, ClientError> {
let url = protected_resource_metadata_url(resource)
.map_err(|err| ClientError::validation(err.to_string()))?;
self.fetch_document(&url, |metadata: &ProtectedResourceMetadata| {
validate_identifier("resource", &metadata.resource, resource)
})
.await
}
pub async fn fetch_resource_metadata_from_url(
&self,
url: &str,
expected_resource: Option<&str>,
) -> Result<ProtectedResourceMetadata, ClientError> {
self.fetch_document(url, |metadata: &ProtectedResourceMetadata| {
expected_resource.map_or(Ok(()), |expected| {
validate_identifier("resource", &metadata.resource, expected)
})
})
.await
}
pub async fn discover_authorization_server(
&self,
resource_metadata: &ProtectedResourceMetadata,
) -> Result<AuthorizationServerMetadata, ClientError> {
let issuer = resource_metadata
.authorization_servers
.first()
.ok_or_else(|| {
ClientError::validation(
"resource metadata advertises no authorization servers".to_owned(),
)
})?;
match self.fetch_server_metadata(issuer).await {
Err(ClientError::Http(status)) if status == http::StatusCode::NOT_FOUND => {
self.fetch_oidc_metadata(issuer).await
}
other => other,
}
}
pub async fn fetch_jwks(
&self,
metadata: &AuthorizationServerMetadata,
) -> Result<serde_json::Value, ClientError> {
let url = metadata
.jwks_uri
.as_deref()
.ok_or_else(|| ClientError::validation("server metadata declares no jwks_uri"))?;
self.fetch_jwks_from_url(url).await
}
pub async fn fetch_jwks_from_url(&self, url: &str) -> Result<serde_json::Value, ClientError> {
self.transport.get_json(url).await
}
async fn fetch_document<T: DeserializeOwned>(
&self,
url: &str,
validate: impl FnOnce(&T) -> Result<(), ClientError>,
) -> Result<T, ClientError> {
self.transport.check_scheme(url)?;
if let Some(cache) = &self.cache
&& let Some(document) = cache.get(url)
{
let document: T = serde_json::from_value(document)?;
validate(&document)?;
return Ok(document);
}
let raw = self.transport.get_json(url).await?;
if let Some(cache) = &self.cache {
let document: T = serde_json::from_value(raw.clone())?;
validate(&document)?;
cache.put(url, &raw);
Ok(document)
} else {
let document: T = serde_json::from_value(raw)?;
validate(&document)?;
Ok(document)
}
}
}
fn validate_identifier(field: &str, returned: &str, requested: &str) -> Result<(), ClientError> {
if returned == requested {
Ok(())
} else {
Err(ClientError::validation(format!(
"{field} mismatch: requested '{requested}', document declares '{returned}'"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
use std::sync::Mutex;
use volga_oauth_core::protected_resource_metadata_url;
struct StaticCache(Mutex<std::collections::HashMap<String, Value>>);
impl StaticCache {
fn with_document(url: &str, document: Value) -> Arc<Self> {
Arc::new(Self(Mutex::new(
[(url.to_owned(), document)].into_iter().collect(),
)))
}
}
impl MetadataCache for StaticCache {
fn get(&self, url: &str) -> Option<Value> {
self.0.lock().unwrap().get(url).cloned()
}
fn put(&self, url: &str, document: &Value) {
self.0
.lock()
.unwrap()
.insert(url.to_owned(), document.clone());
}
}
#[tokio::test]
async fn it_enforces_https_before_consulting_the_cache() {
let resource = "http://api.example.com";
let url = protected_resource_metadata_url(resource).unwrap();
let cache = StaticCache::with_document(
&url,
json!({ "resource": resource, "authorization_servers": ["http://auth.example.com"] }),
);
let strict = DiscoveryClient::new().with_cache(cache.clone());
assert!(matches!(
strict.fetch_resource_metadata(resource).await,
Err(ClientError::InsecureUrl(_))
));
let relaxed = DiscoveryClient::with_config(crate::ClientConfig::new().require_https(false))
.with_cache(cache);
let metadata = relaxed.fetch_resource_metadata(resource).await.unwrap();
assert_eq!(metadata.resource, resource);
}
#[tokio::test]
async fn it_requires_a_jwks_uri_in_metadata() {
let metadata = AuthorizationServerMetadata::new("https://auth.example.com");
let err = DiscoveryClient::new()
.fetch_jwks(&metadata)
.await
.unwrap_err();
assert!(
matches!(&err, ClientError::Validation(reason) if reason.contains("jwks_uri")),
"error was: {err}"
);
}
#[tokio::test]
async fn it_enforces_https_for_jwks_urls() {
let err = DiscoveryClient::new()
.fetch_jwks_from_url("http://auth.example.com/jwks")
.await
.unwrap_err();
assert!(
matches!(err, ClientError::InsecureUrl(_)),
"error was: {err}"
);
}
#[test]
fn it_requires_identical_identifiers() {
assert!(
validate_identifier(
"issuer",
"https://auth.example.com",
"https://auth.example.com"
)
.is_ok()
);
assert!(
validate_identifier(
"issuer",
"https://AUTH.example.com:443",
"https://auth.example.com"
)
.is_err()
);
assert!(
validate_identifier(
"issuer",
"https://auth.example.com/",
"https://auth.example.com"
)
.is_err()
);
assert!(matches!(
validate_identifier("issuer", "https://other.example.com", "https://auth.example.com"),
Err(ClientError::Validation(reason)) if reason.contains("issuer mismatch")
));
}
}