use http::HeaderMap;
use serde::{Deserialize, Serialize};
use crate::{
EndpointUrl,
error::{Error, ErrorKind},
http::HttpClient,
well_known::insert_well_known_path,
};
#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
#[builder(on(String, into))]
#[non_exhaustive]
pub struct ProtectedResourceMetadata {
pub resource: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub authorization_servers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks_uri: Option<EndpointUrl>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bearer_methods_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_signing_alg_values_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_documentation: Option<EndpointUrl>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_policy_uri: Option<EndpointUrl>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_tos_uri: Option<EndpointUrl>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
#[builder(default)]
pub tls_client_certificate_bound_access_tokens: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub authorization_details_types_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dpop_signing_alg_values_supported: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
#[builder(default)]
pub dpop_bound_access_tokens_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub signed_metadata: Option<String>,
}
#[bon::bon]
impl ProtectedResourceMetadata {
#[builder(on(String, into))]
pub async fn fetch<C: HttpClient>(http_client: &C, resource: String) -> Result<Self, Error> {
let metadata_url = well_known_url(&resource)?;
let metadata: Self = crate::http::get(
http_client,
metadata_url.clone().into_uri(),
HeaderMap::new(),
)
.await
.map_err(|err| {
err.with_context(format!(
"fetching protected resource metadata from {metadata_url}"
))
})?;
if metadata.resource != resource {
return Err(Error::from(ErrorKind::Protocol).with_context(format!(
"resource mismatch (RFC 9728 §3.3): expected {resource:?}, got {:?}",
metadata.resource
)));
}
Ok(metadata)
}
}
pub const WELL_KNOWN_PATH: &str = "/.well-known/oauth-protected-resource";
pub fn well_known_url(resource: &str) -> Result<EndpointUrl, Error> {
let resource_url: EndpointUrl = resource
.parse()
.map_err(|e: Error| e.with_context(format!("invalid resource identifier {resource:?}")))?;
insert_well_known_path(resource_url.into_uri(), WELL_KNOWN_PATH)
.map_err(|source| {
Error::new(ErrorKind::Config, source)
.with_context(format!("invalid resource identifier {resource:?}"))
})?
.try_into()
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[test]
fn deserializes_a_full_document() {
let source = r#"
{
"resource": "https://resource.example.com",
"authorization_servers": ["https://as1.example.com", "https://as2.example.net"],
"jwks_uri": "https://resource.example.com/jwks.json",
"scopes_supported": ["profile.read", "profile.write"],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["ES256"],
"resource_name": "Example Resource",
"resource_name#ja": "リソース",
"resource_documentation": "https://resource.example.com/docs",
"resource_policy_uri": "https://resource.example.com/policy",
"resource_tos_uri": "https://resource.example.com/tos",
"tls_client_certificate_bound_access_tokens": true,
"authorization_details_types_supported": ["payment_initiation"],
"dpop_signing_alg_values_supported": ["ES256", "RS256"],
"dpop_bound_access_tokens_required": true,
"signed_metadata": "eyJhbGciOiJFUzI1NiJ9.e30.sig",
"unknown_future_member": 42
}
"#;
let parsed = serde_json::from_str::<ProtectedResourceMetadata>(source).unwrap();
assert_eq!(parsed.resource, "https://resource.example.com");
assert_eq!(
parsed.authorization_servers.as_deref(),
Some(
&[
"https://as1.example.com".to_string(),
"https://as2.example.net".to_string()
][..]
)
);
assert_eq!(
parsed.jwks_uri,
"https://resource.example.com/jwks.json".parse().ok()
);
assert_eq!(parsed.resource_name.as_deref(), Some("Example Resource"));
assert!(parsed.tls_client_certificate_bound_access_tokens);
assert!(parsed.dpop_bound_access_tokens_required);
assert_eq!(
parsed.signed_metadata.as_deref(),
Some("eyJhbGciOiJFUzI1NiJ9.e30.sig")
);
}
#[test]
fn booleans_default_to_false_when_absent() {
let parsed = serde_json::from_str::<ProtectedResourceMetadata>(
r#"{"resource": "https://resource.example.com"}"#,
)
.unwrap();
assert!(!parsed.tls_client_certificate_bound_access_tokens);
assert!(!parsed.dpop_bound_access_tokens_required);
}
#[test]
fn resource_is_required() {
assert!(serde_json::from_str::<ProtectedResourceMetadata>(r"{}").is_err());
}
#[test]
fn serializes_without_defaulted_or_absent_members() {
let doc = ProtectedResourceMetadata::builder()
.resource("https://resource.example.com")
.build();
assert_eq!(
serde_json::to_value(&doc).unwrap(),
serde_json::json!({"resource": "https://resource.example.com"})
);
}
#[test]
fn serializes_set_members_under_their_rfc_names() {
let doc = ProtectedResourceMetadata::builder()
.resource("https://resource.example.com")
.authorization_servers(vec!["https://as.example.com".to_string()])
.scopes_supported(vec!["profile.read".to_string()])
.resource_name("Example Resource")
.dpop_bound_access_tokens_required(true)
.build();
assert_eq!(
serde_json::to_value(&doc).unwrap(),
serde_json::json!({
"resource": "https://resource.example.com",
"authorization_servers": ["https://as.example.com"],
"scopes_supported": ["profile.read"],
"resource_name": "Example Resource",
"dpop_bound_access_tokens_required": true,
})
);
}
struct FakeResourceClient {
body: String,
requested: std::sync::Mutex<Option<http::Uri>>,
}
impl FakeResourceClient {
fn responding(body: impl Into<String>) -> Self {
Self {
body: body.into(),
requested: std::sync::Mutex::new(None),
}
}
}
impl HttpClient for FakeResourceClient {
fn execute(
&self,
request: http::Request<bytes::Bytes>,
_idempotency: crate::http::Idempotency,
) -> crate::platform::MaybeSendBoxFuture<'_, Result<crate::http::HttpResponse, Error>>
{
*self.requested.lock().unwrap() = Some(request.uri().clone());
Box::pin(async move {
Ok(crate::http::HttpResponse {
status: http::StatusCode::OK,
headers: HeaderMap::new(),
body: bytes::Bytes::from(self.body.clone()),
})
})
}
}
#[tokio::test]
async fn fetch_gets_the_well_known_url_and_returns_the_document() {
let client = FakeResourceClient::responding(
r#"{"resource": "https://resource.example.com/api", "scopes_supported": ["read"]}"#,
);
let metadata = ProtectedResourceMetadata::fetch()
.http_client(&client)
.resource("https://resource.example.com/api")
.call()
.await
.unwrap();
assert_eq!(
client
.requested
.lock()
.unwrap()
.as_ref()
.map(ToString::to_string),
Some(
"https://resource.example.com/.well-known/oauth-protected-resource/api".to_string()
)
);
assert_eq!(
metadata.scopes_supported.as_deref(),
Some(&["read".to_string()][..])
);
}
#[tokio::test]
async fn fetch_rejects_a_resource_mismatch() {
let client =
FakeResourceClient::responding(r#"{"resource": "https://attacker.example.com"}"#);
let err = ProtectedResourceMetadata::fetch()
.http_client(&client)
.resource("https://resource.example.com")
.call()
.await
.unwrap_err();
assert_eq!(err.kind(), ErrorKind::Protocol);
assert!(err.to_string().contains("RFC 9728"), "{err}");
}
#[rstest]
#[case::no_path(
"https://resource.example.com",
"https://resource.example.com/.well-known/oauth-protected-resource"
)]
#[case::with_path(
"https://resource.example.com/resource1",
"https://resource.example.com/.well-known/oauth-protected-resource/resource1"
)]
#[case::root_slash_is_no_path(
"https://resource.example.com/",
"https://resource.example.com/.well-known/oauth-protected-resource"
)]
#[case::trailing_slash_is_stripped(
"https://resource.example.com/resource1/",
"https://resource.example.com/.well-known/oauth-protected-resource/resource1"
)]
#[case::port_is_preserved(
"https://resource.example.com:8443/v1",
"https://resource.example.com:8443/.well-known/oauth-protected-resource/v1"
)]
#[case::http_for_test_servers(
"http://127.0.0.1:8080",
"http://127.0.0.1:8080/.well-known/oauth-protected-resource"
)]
#[case::query_is_preserved(
"https://resource.example.com/api?version=1",
"https://resource.example.com/.well-known/oauth-protected-resource/api?version=1"
)]
#[case::query_without_path(
"https://resource.example.com?tenant=a",
"https://resource.example.com/.well-known/oauth-protected-resource?tenant=a"
)]
fn inserts_well_known_between_host_and_path(#[case] resource: &str, #[case] expected: &str) {
assert_eq!(well_known_url(resource).unwrap().to_string(), expected);
}
#[rstest]
#[case::fragment("https://resource.example.com/api#frag")]
#[case::relative_reference("/api")]
#[case::urn("urn:example:resource")]
fn rejects_invalid_resource_identifiers(#[case] resource: &str) {
let err = well_known_url(resource).unwrap_err();
assert!(matches!(err.kind(), ErrorKind::Config), "{err}");
}
}