use std::sync::Arc;
use core::fmt;
use core::str::FromStr;
use serde::{Serialize, Serializer};
pub const WELL_KNOWN: &str = "/.well-known/oauth-protected-resource";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BearerMethod {
#[default]
Header,
}
impl BearerMethod {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Header => "header",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ResourceUri(Arc<str>);
impl Serialize for ResourceUri {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl ResourceUri {
pub fn new(value: &str) -> Result<Self, ResourceUriError> {
value.parse()
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
pub fn from_parts(origin: &str, path: &str) -> Result<Self, ResourceUriError> {
let base = origin.trim_end_matches('/');
let trimmed = path.trim_matches('/');
let joined = if trimmed.is_empty() {
base.to_owned()
} else {
format!("{base}/{trimmed}")
};
Self::new(&joined)
}
#[must_use]
pub fn metadata_path(&self) -> String {
let Some(path) = self.path() else {
return WELL_KNOWN.to_owned();
};
format!("{WELL_KNOWN}{path}")
}
pub fn metadata_url(&self) -> Result<String, ResourceUriError> {
let origin = self.origin().ok_or(ResourceUriError::NoOrigin)?;
Ok(format!("{origin}{}", self.metadata_path()))
}
fn origin(&self) -> Option<&str> {
let after_scheme = self.0.find("://")? + 3;
let end = self.0[after_scheme..]
.find('/')
.map_or(self.0.len(), |offset| after_scheme + offset);
Some(&self.0[..end])
}
fn path(&self) -> Option<&str> {
let after_scheme = self.0.find("://")? + 3;
let slash = self.0[after_scheme..].find('/')? + after_scheme;
Some(&self.0[slash..])
}
}
impl FromStr for ResourceUri {
type Err = ResourceUriError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ResourceUriError::Empty);
}
let Some(scheme_end) = trimmed.find("://") else {
return Err(ResourceUriError::NotAbsolute);
};
let scheme = &trimmed[..scheme_end];
if scheme.is_empty()
|| !scheme
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
{
return Err(ResourceUriError::NotAbsolute);
}
let mut canonical = String::with_capacity(trimmed.len());
canonical.push_str(&scheme.to_ascii_lowercase());
canonical.push_str("://");
let rest = &trimmed[scheme_end + 3..];
let (authority, path) = rest
.find('/')
.map_or((rest, ""), |slash| (&rest[..slash], &rest[slash..]));
if authority.is_empty() {
return Err(ResourceUriError::NoAuthority);
}
canonical.push_str(&drop_default_port(authority, scheme));
let path = path.strip_suffix('/').unwrap_or(path);
canonical.push_str(path);
Ok(Self(Arc::from(canonical.as_str())))
}
}
fn drop_default_port(authority: &str, scheme: &str) -> String {
let default = match scheme.to_ascii_lowercase().as_str() {
"https" => Some("443"),
"http" => Some("80"),
_ => None,
};
let separator = if authority.starts_with('[') {
authority.find(']').and_then(|close| {
authority[close + 1..]
.find(':')
.map(|in_tail| close + 1 + in_tail)
})
} else {
authority.rfind(':')
};
let Some(separator) = separator else {
return authority.to_owned();
};
debug_assert!(authority.is_char_boundary(separator));
let (host, port) = authority.split_at(separator);
let port = &port[1..];
if default == Some(port) {
return host.to_owned();
}
authority.to_owned()
}
impl TryFrom<&str> for ResourceUri {
type Error = ResourceUriError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
value.parse()
}
}
impl fmt::Display for ResourceUri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ResourceUriError {
#[error("the resource identifier is empty")]
Empty,
#[error("the resource identifier is not an absolute URI with an http(s) scheme")]
NotAbsolute,
#[error("the resource identifier names no host")]
NoAuthority,
#[error("the resource identifier has no origin to publish metadata under")]
NoOrigin,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResourceMetadata {
resource: ResourceUri,
#[serde(skip_serializing_if = "Vec::is_empty")]
authorization_servers: Vec<Arc<str>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
scopes_supported: Vec<Arc<str>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
bearer_methods_supported: Vec<BearerMethod>,
#[serde(skip_serializing_if = "Option::is_none")]
resource_name: Option<Arc<str>>,
}
impl ResourceMetadata {
#[must_use]
pub fn new(resource: ResourceUri) -> Self {
Self {
resource,
authorization_servers: Vec::new(),
scopes_supported: Vec::new(),
bearer_methods_supported: vec![BearerMethod::Header],
resource_name: None,
}
}
#[must_use]
pub fn with_authorization_server(mut self, issuer: impl Into<Arc<str>>) -> Self {
self.authorization_servers.push(issuer.into());
self
}
#[must_use]
pub fn with_scope(mut self, scope: impl Into<Arc<str>>) -> Self {
self.scopes_supported.push(scope.into());
self
}
#[must_use]
pub fn with_name(mut self, name: impl Into<Arc<str>>) -> Self {
self.resource_name = Some(name.into());
self
}
#[must_use]
pub const fn resource(&self) -> &ResourceUri {
&self.resource
}
#[must_use]
pub fn authorization_servers(&self) -> &[Arc<str>] {
&self.authorization_servers
}
#[must_use]
pub fn scopes_supported(&self) -> &[Arc<str>] {
&self.scopes_supported
}
#[must_use]
pub fn bearer_methods_supported(&self) -> &[BearerMethod] {
&self.bearer_methods_supported
}
pub fn challenge(&self) -> Result<String, ResourceUriError> {
Ok(format!(
"Bearer resource_metadata=\"{}\"",
self.resource.metadata_url()?
))
}
}
#[cfg(test)]
#[path = "resource_metadata/tests.rs"]
mod tests;
#[cfg(test)]
mod wire_tests {
use super::{ResourceMetadata, ResourceUri, ResourceUriError};
fn first_element(value: &serde_json::Value) -> Option<serde_json::Value> {
value.as_array().and_then(|values| values.first().cloned())
}
impl core::fmt::Display for Failure {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Uri(e) => write!(f, "not a legal resource identifier: {e}"),
Self::Json(e) => write!(f, "document did not serialise: {e}"),
}
}
}
impl std::error::Error for Failure {}
#[derive(Debug)]
enum Failure {
Uri(ResourceUriError),
Json(serde_json::Error),
}
#[test]
fn emits_rfc_9728_field_names_and_omits_empty_parameters() -> Result<(), Failure> {
let full = ResourceMetadata::new(
ResourceUri::new("https://example.com/mcp").map_err(Failure::Uri)?,
)
.with_authorization_server("https://auth.example.com")
.with_scope("files:read")
.with_name("Example");
let full_json: serde_json::Value = serde_json::to_value(&full).map_err(Failure::Json)?;
let first = |key: &str| full_json.get(key).and_then(first_element);
assert_eq!(
full_json.get("resource"),
Some(&serde_json::json!("https://example.com/mcp"))
);
assert_eq!(
first("authorization_servers"),
Some(serde_json::json!("https://auth.example.com"))
);
assert_eq!(
first("scopes_supported"),
Some(serde_json::json!("files:read"))
);
assert_eq!(
first("bearer_methods_supported"),
Some(serde_json::json!("header"))
);
assert_eq!(
full_json.get("resource_name"),
Some(&serde_json::json!("Example"))
);
let bare =
ResourceMetadata::new(ResourceUri::new("https://example.com").map_err(Failure::Uri)?);
let bare_json: serde_json::Value = serde_json::to_value(&bare).map_err(Failure::Json)?;
assert_eq!(
bare_json.get("resource"),
Some(&serde_json::json!("https://example.com"))
);
for absent in ["authorization_servers", "scopes_supported", "resource_name"] {
assert!(
bare_json.get(absent).is_none(),
"RFC 9728 3.2 requires {absent} be omitted when empty, got {bare_json}"
);
}
assert_eq!(
bare_json
.get("bearer_methods_supported")
.and_then(first_element),
Some(serde_json::json!("header"))
);
Ok(())
}
}