use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MetadataError {
#[error("the {document} is not a JSON object")]
NotAnObject {
document: &'static str,
},
#[error("the {document} has no usable `{member}`")]
Missing {
document: &'static str,
member: &'static str,
},
#[error("the metadata names issuer `{claimed}`, but it was fetched for `{expected}`")]
IssuerMismatch {
expected: String,
claimed: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProtectedResource {
pub resource: String,
pub authorization_servers: Vec<String>,
pub scopes_supported: Option<Vec<String>>,
}
impl ProtectedResource {
pub fn parse(body: &[u8]) -> Result<Self, MetadataError> {
const DOCUMENT: &str = "protected resource metadata";
let value: Value = serde_json::from_slice(body)
.map_err(|_| MetadataError::NotAnObject { document: DOCUMENT })?;
let object = value
.as_object()
.ok_or(MetadataError::NotAnObject { document: DOCUMENT })?;
let resource = object
.get("resource")
.and_then(Value::as_str)
.ok_or(MetadataError::Missing {
document: DOCUMENT,
member: "resource",
})?
.to_owned();
let authorization_servers: Vec<String> = strings(object.get("authorization_servers"));
if authorization_servers.is_empty() {
return Err(MetadataError::Missing {
document: DOCUMENT,
member: "authorization_servers",
});
}
let scopes_supported = object
.get("scopes_supported")
.filter(|value| value.is_array())
.map(|value| strings(Some(value)));
Ok(Self {
resource,
authorization_servers,
scopes_supported,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct AuthorizationServer {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub registration_endpoint: Option<String>,
pub scopes_supported: Vec<String>,
pub code_challenge_methods_supported: Option<Vec<String>>,
pub token_endpoint_auth_methods_supported: Vec<String>,
pub client_id_metadata_document_supported: bool,
pub authorization_response_iss_parameter_supported: bool,
}
impl AuthorizationServer {
pub fn parse(body: &[u8], expected_issuer: &str) -> Result<Self, MetadataError> {
const DOCUMENT: &str = "authorization server metadata";
let value: Value = serde_json::from_slice(body)
.map_err(|_| MetadataError::NotAnObject { document: DOCUMENT })?;
let object = value
.as_object()
.ok_or(MetadataError::NotAnObject { document: DOCUMENT })?;
let text = |member: &'static str| {
object
.get(member)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or(MetadataError::Missing {
document: DOCUMENT,
member,
})
};
let issuer = text("issuer")?;
if issuer != expected_issuer {
return Err(MetadataError::IssuerMismatch {
expected: expected_issuer.to_owned(),
claimed: issuer,
});
}
Ok(Self {
issuer,
authorization_endpoint: text("authorization_endpoint")?,
token_endpoint: text("token_endpoint")?,
registration_endpoint: text("registration_endpoint").ok(),
scopes_supported: strings(object.get("scopes_supported")),
code_challenge_methods_supported: object
.get("code_challenge_methods_supported")
.filter(|value| value.is_array())
.map(|value| strings(Some(value))),
token_endpoint_auth_methods_supported: strings(
object.get("token_endpoint_auth_methods_supported"),
),
client_id_metadata_document_supported: object
.get("client_id_metadata_document_supported")
.and_then(Value::as_bool)
.unwrap_or(false),
authorization_response_iss_parameter_supported: object
.get("authorization_response_iss_parameter_supported")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
}
fn strings(value: Option<&Value>) -> Vec<String> {
value
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
}
#[cfg(test)]
mod tests {
use super::{AuthorizationServer, MetadataError, ProtectedResource};
#[test]
fn protected_resource_metadata_reads_what_the_flow_needs() -> Result<(), MetadataError> {
let document = ProtectedResource::parse(
br#"{"resource":"https://mcp.example.com/mcp","authorization_servers":["https://auth.example.com"],"scopes_supported":["a","b"]}"#,
)?;
assert_eq!(document.authorization_servers, ["https://auth.example.com"]);
assert_eq!(
document.scopes_supported,
Some(vec!["a".to_owned(), "b".to_owned()])
);
Ok(())
}
#[test]
fn absent_scopes_supported_is_none_not_empty() -> Result<(), MetadataError> {
let document = ProtectedResource::parse(
br#"{"resource":"https://mcp.example.com","authorization_servers":["https://auth.example.com"]}"#,
)?;
assert_eq!(document.scopes_supported, None);
Ok(())
}
#[test]
fn metadata_for_another_issuer_is_refused() {
let outcome = AuthorizationServer::parse(
br#"{"issuer":"https://honest.example","authorization_endpoint":"a","token_endpoint":"t"}"#,
"https://attacker.example",
);
assert!(matches!(outcome, Err(MetadataError::IssuerMismatch { .. })));
}
#[test]
fn the_issuer_is_compared_exactly() {
let outcome = AuthorizationServer::parse(
br#"{"issuer":"https://auth.example.com/","authorization_endpoint":"a","token_endpoint":"t"}"#,
"https://auth.example.com",
);
assert!(outcome.is_err(), "a trailing slash is a different issuer");
}
}