use serde::{Deserialize, Serialize};
use url::Url;
use crate::error::{DiscoveryError, SsrfError};
use crate::identity::IdentityResolver;
use crate::ssrf::SsrfFilter;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtectedResourceMetadata {
pub resource: String,
pub authorization_servers: Vec<String>,
#[serde(default)]
pub scopes_supported: Vec<String>,
#[serde(default)]
pub bearer_methods_supported: Vec<String>,
#[serde(default)]
pub resource_documentation: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthorizationServerMetadata {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
#[serde(default)]
pub pushed_authorization_request_endpoint: String,
#[serde(default)]
pub require_pushed_authorization_requests: bool,
#[serde(default)]
pub dpop_signing_alg_values_supported: Vec<String>,
#[serde(default)]
pub code_challenge_methods_supported: Vec<String>,
#[serde(default)]
pub response_types_supported: Vec<String>,
#[serde(default)]
pub grant_types_supported: Vec<String>,
#[serde(default)]
pub token_endpoint_auth_methods_supported: Vec<String>,
#[serde(default)]
pub token_endpoint_auth_signing_alg_values_supported: Vec<String>,
#[serde(default)]
pub scopes_supported: Vec<String>,
#[serde(default)]
pub authorization_response_iss_parameter_supported: bool,
#[serde(default)]
pub client_id_metadata_document_supported: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscoveredAuthEndpoints {
pub did: String,
pub handle: Option<String>,
pub pds_endpoint: String,
pub auth_server_issuer: String,
pub par_endpoint: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub dpop_algs: Vec<String>,
pub scopes: Vec<String>,
pub protected_resource_metadata: ProtectedResourceMetadata,
pub auth_server_metadata: AuthorizationServerMetadata,
}
pub async fn fetch_protected_resource_metadata(
ssrf_filter: &SsrfFilter,
pds_endpoint: &str,
) -> Result<ProtectedResourceMetadata, DiscoveryError> {
let url = format!(
"{}/.well-known/oauth-protected-resource",
pds_endpoint.trim_end_matches('/')
);
let meta: ProtectedResourceMetadata = ssrf_filter
.safe_get_json(&url, 1_048_576)
.await
.map_err(|e| match e {
SsrfError::HttpStatus(status, msg) => DiscoveryError::ProtectedResourceDiscoveryFailed(
format!("HTTP {status} fetching protected resource metadata from {url}: {msg}"),
),
SsrfError::Json(err) => DiscoveryError::ProtectedResourceDiscoveryFailed(format!(
"Invalid JSON in protected resource metadata from {url}: {err}"
)),
other => DiscoveryError::Ssrf(other),
})?;
if meta.authorization_servers.is_empty() {
return Err(DiscoveryError::MissingAuthorizationServers(
pds_endpoint.to_string(),
));
}
Ok(meta)
}
pub async fn fetch_auth_server_metadata(
ssrf_filter: &SsrfFilter,
auth_server_url: &str,
) -> Result<AuthorizationServerMetadata, DiscoveryError> {
let base = auth_server_url.trim_end_matches('/');
let primary_url = format!("{base}/.well-known/oauth-authorization-server");
let meta: AuthorizationServerMetadata = match ssrf_filter
.safe_get_json(&primary_url, 1_048_576)
.await
{
Ok(m) => m,
Err(SsrfError::HttpStatus(404, _)) => {
let fallback_url = format!("{base}/.well-known/openid-configuration");
ssrf_filter
.safe_get_json(&fallback_url, 1_048_576)
.await
.map_err(|e| match e {
SsrfError::HttpStatus(status, msg) => {
DiscoveryError::AuthServerDiscoveryFailed(format!(
"HTTP {status} fetching fallback authorization server metadata from {fallback_url}: {msg}"
))
}
SsrfError::Json(err) => DiscoveryError::AuthServerDiscoveryFailed(format!(
"Invalid JSON in fallback authorization server metadata from {fallback_url}: {err}"
)),
other => DiscoveryError::Ssrf(other),
})?
}
Err(SsrfError::HttpStatus(status, msg)) => {
return Err(DiscoveryError::AuthServerDiscoveryFailed(format!(
"HTTP {status} fetching authorization server metadata from {primary_url}: {msg}"
)));
}
Err(SsrfError::Json(err)) => {
return Err(DiscoveryError::AuthServerDiscoveryFailed(format!(
"Invalid JSON in authorization server metadata from {primary_url}: {err}"
)));
}
Err(other) => return Err(DiscoveryError::Ssrf(other)),
};
validate_auth_server_capabilities(&meta, auth_server_url)?;
Ok(meta)
}
pub fn validate_auth_server_capabilities(
meta: &AuthorizationServerMetadata,
auth_server_url: &str,
) -> Result<(), DiscoveryError> {
let expected_norm = auth_server_url.trim().trim_end_matches('/');
let actual_norm = meta.issuer.trim().trim_end_matches('/');
if expected_norm != actual_norm {
return Err(DiscoveryError::IssuerMismatch {
expected: auth_server_url.to_string(),
actual: meta.issuer.clone(),
});
}
if meta.pushed_authorization_request_endpoint.trim().is_empty() {
return Err(DiscoveryError::MissingParEndpoint(
auth_server_url.to_string(),
));
}
if let Err(e) = Url::parse(&meta.pushed_authorization_request_endpoint) {
return Err(DiscoveryError::InvalidEndpointUrl(format!(
"Invalid PAR endpoint URL '{}': {e}",
meta.pushed_authorization_request_endpoint
)));
}
if meta.token_endpoint.trim().is_empty() {
return Err(DiscoveryError::MissingTokenEndpoint(
auth_server_url.to_string(),
));
}
if let Err(e) = Url::parse(&meta.token_endpoint) {
return Err(DiscoveryError::InvalidEndpointUrl(format!(
"Invalid token endpoint URL '{}': {e}",
meta.token_endpoint
)));
}
if meta.authorization_endpoint.trim().is_empty() {
return Err(DiscoveryError::MissingAuthorizationEndpoint(
auth_server_url.to_string(),
));
}
if let Err(e) = Url::parse(&meta.authorization_endpoint) {
return Err(DiscoveryError::InvalidEndpointUrl(format!(
"Invalid authorization endpoint URL '{}': {e}",
meta.authorization_endpoint
)));
}
if !meta
.dpop_signing_alg_values_supported
.iter()
.any(|alg| alg == "ES256")
{
return Err(DiscoveryError::MissingDpopAlgorithm(
auth_server_url.to_string(),
));
}
if !meta
.code_challenge_methods_supported
.iter()
.any(|method| method == "S256")
{
return Err(DiscoveryError::MissingPkceMethod(
auth_server_url.to_string(),
));
}
Ok(())
}
pub async fn discover_oauth_endpoints(
resolver: &IdentityResolver,
did_or_handle: &str,
) -> Result<DiscoveredAuthEndpoints, DiscoveryError> {
let identity = resolver.resolve_ident(did_or_handle).await?;
let pds_meta =
fetch_protected_resource_metadata(resolver.ssrf_filter(), &identity.pds_endpoint).await?;
let auth_server_url = &pds_meta.authorization_servers[0];
let as_meta = fetch_auth_server_metadata(resolver.ssrf_filter(), auth_server_url).await?;
Ok(DiscoveredAuthEndpoints {
did: identity.did,
handle: identity.handle,
pds_endpoint: identity.pds_endpoint,
auth_server_issuer: as_meta.issuer.clone(),
par_endpoint: as_meta.pushed_authorization_request_endpoint.clone(),
authorization_endpoint: as_meta.authorization_endpoint.clone(),
token_endpoint: as_meta.token_endpoint.clone(),
dpop_algs: as_meta.dpop_signing_alg_values_supported.clone(),
scopes: if !as_meta.scopes_supported.is_empty() {
as_meta.scopes_supported.clone()
} else if !pds_meta.scopes_supported.is_empty() {
pds_meta.scopes_supported.clone()
} else {
vec!["atproto".to_string()]
},
protected_resource_metadata: pds_meta,
auth_server_metadata: as_meta,
})
}
impl IdentityResolver {
pub async fn discover_oauth_endpoints(
&self,
did_or_handle: &str,
) -> Result<DiscoveredAuthEndpoints, DiscoveryError> {
discover_oauth_endpoints(self, did_or_handle).await
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, missing_docs)]
mod tests {
use super::*;
#[test]
fn test_validate_auth_server_capabilities_valid() {
let meta = AuthorizationServerMetadata {
issuer: "https://auth.example.com".to_string(),
authorization_endpoint: "https://auth.example.com/oauth/authorize".to_string(),
token_endpoint: "https://auth.example.com/oauth/token".to_string(),
pushed_authorization_request_endpoint: "https://auth.example.com/oauth/par".to_string(),
require_pushed_authorization_requests: true,
dpop_signing_alg_values_supported: vec!["ES256".to_string()],
code_challenge_methods_supported: vec!["S256".to_string()],
response_types_supported: vec!["code".to_string()],
grant_types_supported: vec![
"authorization_code".to_string(),
"refresh_token".to_string(),
],
token_endpoint_auth_methods_supported: vec!["none".to_string()],
token_endpoint_auth_signing_alg_values_supported: vec!["ES256".to_string()],
scopes_supported: vec!["atproto".to_string()],
authorization_response_iss_parameter_supported: true,
client_id_metadata_document_supported: true,
};
assert!(validate_auth_server_capabilities(&meta, "https://auth.example.com").is_ok());
}
#[test]
fn test_validate_auth_server_capabilities_missing_es256() {
let meta = AuthorizationServerMetadata {
issuer: "https://auth.example.com".to_string(),
authorization_endpoint: "https://auth.example.com/oauth/authorize".to_string(),
token_endpoint: "https://auth.example.com/oauth/token".to_string(),
pushed_authorization_request_endpoint: "https://auth.example.com/oauth/par".to_string(),
require_pushed_authorization_requests: true,
dpop_signing_alg_values_supported: vec!["RS256".to_string()],
code_challenge_methods_supported: vec!["S256".to_string()],
response_types_supported: vec!["code".to_string()],
grant_types_supported: vec!["authorization_code".to_string()],
token_endpoint_auth_methods_supported: vec![],
token_endpoint_auth_signing_alg_values_supported: vec![],
scopes_supported: vec!["atproto".to_string()],
authorization_response_iss_parameter_supported: true,
client_id_metadata_document_supported: true,
};
assert!(matches!(
validate_auth_server_capabilities(&meta, "https://auth.example.com"),
Err(DiscoveryError::MissingDpopAlgorithm(_))
));
}
#[test]
fn test_validate_auth_server_capabilities_missing_s256_pkce() {
let meta = AuthorizationServerMetadata {
issuer: "https://auth.example.com".to_string(),
authorization_endpoint: "https://auth.example.com/oauth/authorize".to_string(),
token_endpoint: "https://auth.example.com/oauth/token".to_string(),
pushed_authorization_request_endpoint: "https://auth.example.com/oauth/par".to_string(),
require_pushed_authorization_requests: true,
dpop_signing_alg_values_supported: vec!["ES256".to_string()],
code_challenge_methods_supported: vec!["plain".to_string()],
response_types_supported: vec!["code".to_string()],
grant_types_supported: vec!["authorization_code".to_string()],
token_endpoint_auth_methods_supported: vec![],
token_endpoint_auth_signing_alg_values_supported: vec![],
scopes_supported: vec!["atproto".to_string()],
authorization_response_iss_parameter_supported: true,
client_id_metadata_document_supported: true,
};
assert!(matches!(
validate_auth_server_capabilities(&meta, "https://auth.example.com"),
Err(DiscoveryError::MissingPkceMethod(_))
));
}
#[test]
fn test_validate_auth_server_capabilities_issuer_mismatch() {
let meta = AuthorizationServerMetadata {
issuer: "https://attacker.example.com".to_string(),
authorization_endpoint: "https://auth.example.com/oauth/authorize".to_string(),
token_endpoint: "https://auth.example.com/oauth/token".to_string(),
pushed_authorization_request_endpoint: "https://auth.example.com/oauth/par".to_string(),
require_pushed_authorization_requests: true,
dpop_signing_alg_values_supported: vec!["ES256".to_string()],
code_challenge_methods_supported: vec!["S256".to_string()],
response_types_supported: vec!["code".to_string()],
grant_types_supported: vec!["authorization_code".to_string()],
token_endpoint_auth_methods_supported: vec![],
token_endpoint_auth_signing_alg_values_supported: vec![],
scopes_supported: vec!["atproto".to_string()],
authorization_response_iss_parameter_supported: true,
client_id_metadata_document_supported: true,
};
assert!(matches!(
validate_auth_server_capabilities(&meta, "https://auth.example.com"),
Err(DiscoveryError::IssuerMismatch { .. })
));
}
}