use std::collections::HashMap;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use crate::social::core::{OAuth2Client, SocialAuthError};
use crate::social::url_validation::{sanitize_url, validate_endpoint_url};
use url::Url;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OIDCDiscovery {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub jwks_uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_types_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 subject_types_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_signing_alg_values_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claims_supported: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
struct CachedDiscovery {
document: OIDCDiscovery,
expires_at: DateTime<Utc>,
}
impl CachedDiscovery {
fn new(document: OIDCDiscovery, ttl: Duration) -> Self {
Self {
document,
expires_at: Utc::now() + ttl,
}
}
fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
}
pub struct DiscoveryClient {
client: OAuth2Client,
cache: RwLock<HashMap<String, CachedDiscovery>>,
cache_ttl: Duration,
}
impl DiscoveryClient {
pub fn new(client: OAuth2Client) -> Self {
Self {
client,
cache: RwLock::new(HashMap::new()),
cache_ttl: Duration::hours(24),
}
}
pub fn with_ttl(client: OAuth2Client, cache_ttl: Duration) -> Self {
Self {
client,
cache: RwLock::new(HashMap::new()),
cache_ttl,
}
}
pub async fn discover(&self, issuer_url: &str) -> Result<OIDCDiscovery, SocialAuthError> {
self.discover_with_policy(issuer_url, false).await
}
pub async fn discover_same_origin(
&self,
issuer_url: &str,
) -> Result<OIDCDiscovery, SocialAuthError> {
self.discover_with_policy(issuer_url, true).await
}
async fn discover_with_policy(
&self,
issuer_url: &str,
require_same_origin: bool,
) -> Result<OIDCDiscovery, SocialAuthError> {
{
let cache = self.cache.read().await;
if let Some(cached) = cache.get(issuer_url)
&& !cached.is_expired()
{
validate_discovery_document(issuer_url, &cached.document, require_same_origin)?;
return Ok(cached.document.clone());
}
}
let discovery_url = format!("{}/.well-known/openid-configuration", issuer_url);
let response = self
.client
.client()
.get(&discovery_url)
.send()
.await
.map_err(|e| SocialAuthError::Network(e.to_string()))?;
if !response.status().is_success() {
return Err(SocialAuthError::Discovery(format!(
"Discovery request failed: {}",
response.status()
)));
}
let document: OIDCDiscovery = response
.json()
.await
.map_err(|e| SocialAuthError::Discovery(e.to_string()))?;
validate_discovery_document(issuer_url, &document, require_same_origin)?;
{
let mut cache = self.cache.write().await;
cache.insert(
issuer_url.to_string(),
CachedDiscovery::new(document.clone(), self.cache_ttl),
);
}
Ok(document)
}
pub async fn clear_cache(&self) {
let mut cache = self.cache.write().await;
cache.clear();
}
}
fn validate_discovery_document(
issuer_url: &str,
document: &OIDCDiscovery,
require_same_origin: bool,
) -> Result<(), SocialAuthError> {
validate_discovered_endpoint_url(
issuer_url,
&document.authorization_endpoint,
require_same_origin,
)?;
validate_discovered_endpoint_url(issuer_url, &document.token_endpoint, require_same_origin)?;
validate_discovered_endpoint_url(issuer_url, &document.jwks_uri, require_same_origin)?;
if let Some(ref userinfo_url) = document.userinfo_endpoint {
validate_discovered_endpoint_url(issuer_url, userinfo_url, require_same_origin)?;
}
Ok(())
}
fn validate_discovered_endpoint_url(
issuer_url: &str,
endpoint_url: &str,
require_same_origin: bool,
) -> Result<(), SocialAuthError> {
validate_endpoint_url(endpoint_url)?;
if !require_same_origin {
return Ok(());
}
let issuer = Url::parse(issuer_url)
.map_err(|e| SocialAuthError::Configuration(format!("invalid issuer URL: {}", e)))?;
let endpoint = Url::parse(endpoint_url)
.map_err(|e| SocialAuthError::Configuration(format!("invalid endpoint URL: {}", e)))?;
if issuer.scheme() == endpoint.scheme()
&& issuer.host_str() == endpoint.host_str()
&& issuer.port_or_known_default() == endpoint.port_or_known_default()
{
return Ok(());
}
Err(SocialAuthError::InsecureEndpoint(format!(
"discovered endpoint '{}' is outside issuer origin '{}'",
sanitize_url(&endpoint),
sanitize_url(&issuer)
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cached_discovery_expiration() {
let document = OIDCDiscovery {
issuer: "https://example.com".to_string(),
authorization_endpoint: "https://example.com/auth".to_string(),
token_endpoint: "https://example.com/token".to_string(),
jwks_uri: "https://example.com/jwks".to_string(),
userinfo_endpoint: None,
scopes_supported: None,
response_types_supported: None,
grant_types_supported: None,
subject_types_supported: None,
id_token_signing_alg_values_supported: None,
claims_supported: None,
};
let cached = CachedDiscovery::new(document, Duration::seconds(1));
assert!(!cached.is_expired());
let expired = CachedDiscovery::new(cached.document.clone(), Duration::seconds(-1));
assert!(expired.is_expired());
}
#[test]
fn test_discovered_endpoint_allows_same_origin() {
let issuer = "https://issuer.example.com/auth";
let endpoint = "https://issuer.example.com/token";
let result = validate_discovered_endpoint_url(issuer, endpoint, true);
assert!(result.is_ok());
}
#[test]
fn test_discovered_endpoint_rejects_different_host() {
let issuer = "https://issuer.example.com";
let endpoint = "https://127.0.0.1/token";
let result = validate_discovered_endpoint_url(issuer, endpoint, true);
let err = result.expect_err("different host must be rejected");
assert!(matches!(err, SocialAuthError::InsecureEndpoint(_)));
}
#[test]
fn test_discovered_endpoint_rejects_different_port() {
let issuer = "https://issuer.example.com";
let endpoint = "https://issuer.example.com:8443/token";
let result = validate_discovered_endpoint_url(issuer, endpoint, true);
let err = result.expect_err("different port must be rejected");
assert!(matches!(err, SocialAuthError::InsecureEndpoint(_)));
}
#[test]
fn test_discovered_endpoint_allows_same_loopback_http_origin_for_dev() {
let issuer = "http://127.0.0.1:8080";
let endpoint = "http://127.0.0.1:8080/token";
let result = validate_discovered_endpoint_url(issuer, endpoint, true);
assert!(result.is_ok());
}
#[tokio::test]
async fn test_client_creation() {
let client = OAuth2Client::new();
let discovery_client = DiscoveryClient::new(client);
assert!(discovery_client.cache.read().await.is_empty());
}
#[tokio::test]
async fn test_client_with_custom_ttl() {
let client = OAuth2Client::new();
let discovery_client = DiscoveryClient::with_ttl(client, Duration::hours(1));
assert_eq!(discovery_client.cache_ttl, Duration::hours(1));
}
#[tokio::test]
async fn test_clear_cache() {
let client = OAuth2Client::new();
let discovery_client = DiscoveryClient::new(client);
{
let document = OIDCDiscovery {
issuer: "https://example.com".to_string(),
authorization_endpoint: "https://example.com/auth".to_string(),
token_endpoint: "https://example.com/token".to_string(),
jwks_uri: "https://example.com/jwks".to_string(),
userinfo_endpoint: None,
scopes_supported: None,
response_types_supported: None,
grant_types_supported: None,
subject_types_supported: None,
id_token_signing_alg_values_supported: None,
claims_supported: None,
};
let mut cache = discovery_client.cache.write().await;
cache.insert(
"https://example.com".to_string(),
CachedDiscovery::new(document, Duration::hours(1)),
);
}
assert!(!discovery_client.cache.read().await.is_empty());
discovery_client.clear_cache().await;
assert!(discovery_client.cache.read().await.is_empty());
}
#[tokio::test]
async fn test_strict_discovery_revalidates_laxly_cached_document() {
let issuer = "https://issuer.example.com";
let client = OAuth2Client::new();
let discovery_client = DiscoveryClient::new(client);
let document = OIDCDiscovery {
issuer: issuer.to_string(),
authorization_endpoint: "https://issuer.example.com/authorize".to_string(),
token_endpoint: "https://attacker.example.com/token".to_string(),
jwks_uri: "https://issuer.example.com/jwks".to_string(),
userinfo_endpoint: None,
scopes_supported: None,
response_types_supported: None,
grant_types_supported: None,
subject_types_supported: None,
id_token_signing_alg_values_supported: None,
claims_supported: None,
};
discovery_client.cache.write().await.insert(
issuer.to_string(),
CachedDiscovery::new(document, Duration::hours(1)),
);
let result = discovery_client.discover_same_origin(issuer).await;
let err = result.expect_err("strict discovery must revalidate cached endpoints");
assert!(matches!(err, SocialAuthError::InsecureEndpoint(_)));
}
}