use serde::Deserialize;
use url::Url;
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Discovery {
pub issuer: Url,
pub authorization_endpoint: Url,
pub token_endpoint: Url,
pub jwks_uri: Url,
#[serde(default)]
pub userinfo_endpoint: Option<Url>,
}
#[cfg(any(test, feature = "test-support"))]
impl Discovery {
#[must_use]
pub fn for_test(
issuer: Url,
authorization_endpoint: Url,
token_endpoint: Url,
jwks_uri: Url,
) -> Self {
Self {
issuer,
authorization_endpoint,
token_endpoint,
jwks_uri,
userinfo_endpoint: None,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
#[error("discovery fetch failed: {0}")]
Fetch(String),
#[error("discovery payload parse failed: {0}")]
Parse(String),
#[error("issuer mismatch: expected {expected}, got {actual}")]
IssuerMismatch { expected: String, actual: String },
}
pub async fn fetch_discovery(issuer: &Url) -> Result<Discovery, DiscoveryError> {
let mut url = issuer.clone();
let new_path = format!(
"{}/.well-known/openid-configuration",
url.path().trim_end_matches('/')
);
url.set_path(&new_path);
let response = reqwest::get(url)
.await
.map_err(|e| DiscoveryError::Fetch(e.to_string()))?;
if !response.status().is_success() {
return Err(DiscoveryError::Fetch(format!(
"discovery returned HTTP {}",
response.status()
)));
}
let discovery: Discovery = response
.json()
.await
.map_err(|e| DiscoveryError::Parse(e.to_string()))?;
if discovery.issuer != *issuer {
return Err(DiscoveryError::IssuerMismatch {
expected: issuer.to_string(),
actual: discovery.issuer.to_string(),
});
}
Ok(discovery)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn fetch_discovery_with_invalid_url_yields_fetch_error() {
let issuer: Url = "http://nonexistent.invalid/".parse().expect("test url");
let err = fetch_discovery(&issuer)
.await
.expect_err("bad URL must fail");
assert!(
matches!(err, DiscoveryError::Fetch(_)),
"expected Fetch, got {err:?}"
);
}
}