use serde::{Deserialize, Serialize};
use crate::AuthplaneError;
use crate::errors::{QueryComponent, auth_error, build_well_known_url};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtectedResourceMetadata {
pub resource: String,
pub authorization_servers: Vec<String>,
pub bearer_methods_supported: Vec<String>,
pub scopes_supported: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dpop_signing_alg_values_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dpop_bound_access_tokens_required: Option<bool>,
}
pub fn build_prm(
issuer: &str,
resource: &str,
scopes: &[String],
dpop_algs: Option<&[String]>,
dpop_required: bool,
) -> ProtectedResourceMetadata {
let dpop_required_field = if dpop_algs.is_some() || dpop_required {
Some(dpop_required)
} else {
None
};
ProtectedResourceMetadata {
resource: resource.to_string(),
authorization_servers: vec![issuer.to_string()],
bearer_methods_supported: vec!["header".to_string()],
scopes_supported: scopes.to_vec(),
dpop_signing_alg_values_supported: dpop_algs.map(|items| items.to_vec()),
dpop_bound_access_tokens_required: dpop_required_field,
}
}
pub(crate) fn validate_resource_identifier(resource: &str) -> Result<(), AuthplaneError> {
let reject = |message| auth_error("invalid_resource", message);
if resource.contains('#') {
return Err(reject(
"resource identifier must not include a fragment component (RFC 8707 section 2; RFC 9728 section 1.2)",
));
}
if resource
.chars()
.any(|c| c.is_ascii_whitespace() || c.is_ascii_control())
{
return Err(reject(
"resource identifier must not contain whitespace or control characters",
));
}
let Some(authority) = explicit_authority(resource) else {
return Err(reject(
"resource identifier must be an absolute URL with a scheme and a host",
));
};
if authority.contains('@') {
return Err(reject(
"resource identifier must not include userinfo in its authority (RFC 9110 section 4.2.4)",
));
}
let parsed = url::Url::parse(resource).map_err(|_| {
reject("resource identifier must be an absolute URL with a scheme and a host")
})?;
if !parsed.has_host() {
return Err(reject(
"resource identifier must be an absolute URL with a scheme and a host",
));
}
Ok(())
}
fn explicit_authority(resource: &str) -> Option<&str> {
let (scheme, rest) = resource.split_once("://")?;
let mut scheme_chars = scheme.chars();
if !scheme_chars.next()?.is_ascii_alphabetic() {
return None;
}
if !scheme_chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
return None;
}
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
Some(&rest[..authority_end])
}
pub fn build_prm_url(resource: &str) -> Result<String, AuthplaneError> {
validate_resource_identifier(resource)?;
build_well_known_url(
resource,
"oauth-protected-resource",
QueryComponent::Preserve,
"invalid_resource",
|| format!("invalid resource URL: {resource}"),
)
}
#[cfg(test)]
mod tests {
use super::{build_prm, build_prm_url};
#[test]
fn prm_builder_keeps_expected_fields() {
let prm = build_prm(
"https://auth.example.com",
"https://api.example.com/mcp",
&["tools/read".to_string()],
Some(&["ES256".to_string()]),
true,
);
assert_eq!(prm.resource, "https://api.example.com/mcp");
assert_eq!(
prm.authorization_servers,
vec!["https://auth.example.com".to_string()]
);
assert_eq!(prm.bearer_methods_supported, vec!["header".to_string()]);
assert_eq!(
prm.dpop_signing_alg_values_supported,
Some(vec!["ES256".to_string()])
);
assert_eq!(prm.dpop_bound_access_tokens_required, Some(true));
}
#[test]
fn prm_url_inserts_well_known_before_path() {
let url = build_prm_url("https://api.example.com/v1/mcp").expect("valid prm url");
assert_eq!(
url,
"https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
);
}
#[test]
fn prm_url_preserves_resource_query() {
let url = build_prm_url("https://api.example.com/mcp?tenant=a").expect("valid prm url");
assert_eq!(
url,
"https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a"
);
}
#[test]
fn prm_url_query_only_resource_appends_suffix_directly_after_host() {
let url = build_prm_url("https://api.example.com?x=1").expect("valid prm url");
assert_eq!(
url,
"https://api.example.com/.well-known/oauth-protected-resource?x=1"
);
}
#[test]
fn prm_url_removes_terminating_slash_before_query() {
let url = build_prm_url("https://api.example.com/?x=1").expect("valid prm url");
assert_eq!(
url,
"https://api.example.com/.well-known/oauth-protected-resource?x=1"
);
}
#[test]
fn prm_url_treats_bare_trailing_question_mark_as_no_query() {
let url = build_prm_url("https://api.example.com/mcp?").expect("valid prm url");
assert_eq!(
url,
"https://api.example.com/.well-known/oauth-protected-resource/mcp"
);
}
#[test]
fn prm_url_removes_terminating_slash_from_path() {
let url = build_prm_url("https://api.example.com/mcp/").expect("valid prm url");
assert_eq!(
url,
"https://api.example.com/.well-known/oauth-protected-resource/mcp"
);
}
#[test]
fn prm_url_removes_all_terminating_slashes_from_path() {
let url = build_prm_url("https://api.example.com/mcp//").expect("valid prm url");
assert_eq!(
url,
"https://api.example.com/.well-known/oauth-protected-resource/mcp"
);
}
#[test]
fn prm_url_keeps_query_distinct_identifiers_distinct() {
let tenant_a = build_prm_url("https://api.example.com/mcp?tenant=a").expect("valid");
let tenant_b = build_prm_url("https://api.example.com/mcp?tenant=b").expect("valid");
assert_ne!(tenant_a, tenant_b);
}
const ABSOLUTE_URL_MESSAGE: &str =
"resource identifier must be an absolute URL with a scheme and a host";
const FRAGMENT_MESSAGE: &str = "resource identifier must not include a fragment component";
const WHITESPACE_MESSAGE: &str =
"resource identifier must not contain whitespace or control characters";
const USERINFO_MESSAGE: &str = "resource identifier must not include userinfo";
fn assert_rejects_as_invalid_resource(resource: &str, expected_message: &str) {
let error = build_prm_url(resource).expect_err("resource must be rejected");
let crate::AuthplaneError::Auth(auth_error) = error else {
panic!("expected auth error");
};
assert_eq!(auth_error.code, "invalid_resource");
assert!(
auth_error.message.contains(expected_message),
"unexpected message: {}",
auth_error.message
);
}
#[test]
fn prm_url_rejects_relative_resource() {
assert_rejects_as_invalid_resource("/mcp", ABSOLUTE_URL_MESSAGE);
}
#[test]
fn prm_url_rejects_scheme_relative_resource() {
assert_rejects_as_invalid_resource("//api.example.com/mcp", ABSOLUTE_URL_MESSAGE);
}
#[test]
fn prm_url_rejects_opaque_resource_without_host() {
assert_rejects_as_invalid_resource("urn:example:api", ABSOLUTE_URL_MESSAGE);
}
#[test]
fn prm_url_rejects_fragment_bearing_resource() {
assert_rejects_as_invalid_resource("https://api.example.com/mcp#v2", FRAGMENT_MESSAGE);
}
#[test]
fn prm_url_reports_fragment_first_on_doubly_invalid_resource() {
assert_rejects_as_invalid_resource("//api.example.com/mcp#v2", FRAGMENT_MESSAGE);
}
#[test]
fn prm_url_rejects_authority_less_special_scheme_form() {
assert_rejects_as_invalid_resource("https:example.com/mcp", ABSOLUTE_URL_MESSAGE);
}
#[test]
fn prm_url_rejects_whitespace_in_resource() {
assert_rejects_as_invalid_resource("https://api.exa\tmple.com/mcp", WHITESPACE_MESSAGE);
assert_rejects_as_invalid_resource(" https://api.example.com/mcp", WHITESPACE_MESSAGE);
}
#[test]
fn prm_url_rejects_userinfo_in_authority() {
assert_rejects_as_invalid_resource("https://svc:pw@api.example.com/mcp", USERINFO_MESSAGE);
}
#[test]
fn prm_url_rejects_empty_authority() {
assert_rejects_as_invalid_resource("foo:///mcp", ABSOLUTE_URL_MESSAGE);
}
#[test]
fn prm_url_accepts_http_localhost() {
let url = build_prm_url("http://localhost:8080/mcp").expect("http localhost accepted");
assert_eq!(
url,
"http://localhost:8080/.well-known/oauth-protected-resource/mcp"
);
}
#[test]
fn prm_url_keeps_doubled_leading_slash_distinct() {
let doubled = build_prm_url("https://api.example.com//mcp").expect("valid prm url");
let single = build_prm_url("https://api.example.com/mcp").expect("valid prm url");
assert_eq!(
doubled,
"https://api.example.com/.well-known/oauth-protected-resource//mcp"
);
assert_ne!(doubled, single);
}
#[test]
fn prm_required_flag_survives_when_no_algs_given() {
let prm = build_prm(
"https://auth.example.com",
"https://api.example.com/mcp",
&[],
None,
true,
);
assert_eq!(prm.dpop_signing_alg_values_supported, None);
assert_eq!(prm.dpop_bound_access_tokens_required, Some(true));
}
#[test]
fn prm_omits_both_fields_for_bearer_only_mode() {
let prm = build_prm(
"https://auth.example.com",
"https://api.example.com/mcp",
&[],
None,
false,
);
assert_eq!(prm.dpop_signing_alg_values_supported, None);
assert_eq!(prm.dpop_bound_access_tokens_required, None);
}
}