use serde::{Deserialize, Serialize};
use super::oauth::OAuthConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcDiscovery {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes_supported: Option<Vec<String>>,
pub response_types_supported: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_modes_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grant_types_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub acr_values_supported: Option<Vec<String>>,
pub subject_types_supported: Vec<String>,
pub id_token_signing_alg_values_supported: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_encryption_alg_values_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_signing_alg_values_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_signing_alg_values_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_values_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claim_types_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claims_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_documentation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claims_locales_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui_locales_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub introspection_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revocation_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_authorization_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_challenge_methods_supported: Option<Vec<String>>,
}
impl OidcDiscovery {
#[must_use]
pub fn to_oauth_config(&self, client_id: &str, redirect_uri: &str) -> OAuthConfig {
let mut builder = OAuthConfig::builder()
.client_id(client_id)
.authorization_url(&self.authorization_endpoint)
.token_url(&self.token_endpoint)
.redirect_uri(redirect_uri)
.response_type(
self.response_types_supported
.first()
.cloned()
.unwrap_or_else(|| String::from("code")),
);
if let Some(ref supported) = self.scopes_supported {
let defaults = ["openid", "profile", "email"];
let scopes: Vec<String> = defaults
.iter()
.filter(|s| supported.iter().any(|x| x == *s))
.map(|s| s.to_string())
.collect();
if !scopes.is_empty() {
builder = builder.scopes(scopes);
} else if !supported.is_empty() {
builder = builder.scopes(supported.clone());
}
}
if let Some(ref methods) = self.code_challenge_methods_supported {
builder = builder.pkce_enabled(methods.iter().any(|m| m == "S256"));
} else {
builder = builder.pkce_enabled(false);
}
builder.build()
}
#[must_use]
pub fn jwks_url(&self) -> String {
self.jwks_uri
.clone()
.unwrap_or_else(|| format!("{}/jwks", self.issuer.trim_end_matches('/')))
}
}
pub struct DiscoveryClient {
cached: Option<(String, OidcDiscovery)>, }
impl Default for DiscoveryClient {
fn default() -> Self {
Self::new()
}
}
impl DiscoveryClient {
#[must_use]
pub fn new() -> Self {
Self { cached: None }
}
pub async fn get(&mut self, issuer_url: &str) -> Result<&OidcDiscovery, DiscoveryError> {
if let Some((cached_url, _)) = &self.cached {
if cached_url == issuer_url {
return Ok(&self.cached.as_ref().unwrap().1);
}
}
self.fetch(issuer_url).await
}
pub async fn fetch(&mut self, issuer_url: &str) -> Result<&OidcDiscovery, DiscoveryError> {
let url = build_well_known_url(issuer_url);
let discovery = do_fetch(&url).await?;
self.cached = Some((issuer_url.to_string(), discovery));
Ok(&self.cached.as_ref().unwrap().1)
}
pub async fn fetch_from_url(&self, url: &str) -> Result<OidcDiscovery, DiscoveryError> {
do_fetch(url).await
}
pub fn clear_cache(&mut self) {
self.cached = None;
}
#[must_use]
pub fn is_cached(&self, issuer_url: &str) -> bool {
self.cached
.as_ref()
.is_some_and(|(url, _)| url == issuer_url)
}
}
fn build_well_known_url(issuer: &str) -> String {
let base = issuer.trim_end_matches('/');
if base.ends_with("/.well-known/openid-configuration") {
return base.to_string();
}
format!("{base}/.well-known/openid-configuration")
}
async fn do_fetch(url: &str) -> Result<OidcDiscovery, DiscoveryError> {
let json = fetch_discovery(url).await?;
serde_json::from_str(&json)
.map_err(|e| DiscoveryError::ParseError(e.to_string()))
}
#[allow(dead_code)]
fn validate_discovery(d: &OidcDiscovery) -> Result<(), DiscoveryError> {
if d.issuer.is_empty() {
return Err(DiscoveryError::MissingRequiredField(
"issuer is required".into(),
));
}
if d.authorization_endpoint.is_empty() {
return Err(DiscoveryError::MissingRequiredField(
"authorization_endpoint is required".into(),
));
}
if d.token_endpoint.is_empty() {
return Err(DiscoveryError::MissingRequiredField(
"token_endpoint is required".into(),
));
}
Ok(())
}
#[derive(Debug)]
pub enum DiscoveryError {
InvalidIssuerUrl(String),
FetchFailed(String),
ParseError(String),
MissingRequiredField(String),
}
impl core::fmt::Display for DiscoveryError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
DiscoveryError::InvalidIssuerUrl(s) => write!(f, "Invalid issuer URL: {s}"),
DiscoveryError::FetchFailed(s) => write!(f, "Discovery fetch failed: {s}"),
DiscoveryError::ParseError(s) => write!(f, "Discovery parse error: {s}"),
DiscoveryError::MissingRequiredField(s) => {
write!(f, "Discovery missing required field: {s}")
}
}
}
}
impl std::error::Error for DiscoveryError {}
async fn fetch_discovery(url: &str) -> Result<String, DiscoveryError> {
use foundation_core::url::Uri;
use foundation_netio::default_http_client;
use foundation_netio::shared::client::body_reader::try_collect_bytes;
use foundation_netio::shared::client::request::PreparedRequest;
use foundation_netio::shared::http::{
SendSafeBody, SimpleHeader, SimpleHeaders, SimpleMethod,
};
let client = default_http_client();
let uri = Uri::parse(url)
.map_err(|e| DiscoveryError::FetchFailed(format!("invalid URL: {e}")))?;
let mut headers = SimpleHeaders::new();
headers.insert(SimpleHeader::ACCEPT, vec!["application/json".into()]);
let req = PreparedRequest {
method: SimpleMethod::GET,
url: uri,
headers,
body: SendSafeBody::None,
extensions: Default::default(),
};
let resp = client
.send_async(req)
.await
.map_err(|e| DiscoveryError::FetchFailed(e.to_string()))?;
let status: usize = resp.get_status().into();
let body = try_collect_bytes(resp.take_body())
.map(|b| String::from_utf8_lossy(&b).to_string())
.map_err(|e| DiscoveryError::FetchFailed(format!("read body: {e}")))?;
if !(200..300).contains(&status) {
return Err(DiscoveryError::FetchFailed(format!("HTTP {status}: {body}")));
}
Ok(body)
}