use serde_json::Value;
use crate::config::ResolvedOAuthConfig;
use crate::validator::is_canonical_url;
pub const PROTECTED_RESOURCE_METADATA_PREFIX: &str = "/.well-known/oauth-protected-resource";
pub(crate) fn resource_metadata_url(resource: &str) -> String {
let trimmed = resource.trim();
if reqwest::Url::parse(trimmed).is_err() {
return PROTECTED_RESOURCE_METADATA_PREFIX.to_string();
}
let split = trimmed
.split_once("://")
.filter(|_| is_canonical_url(trimmed));
let Some((scheme, rest)) = split else {
return format!(
"{}{PROTECTED_RESOURCE_METADATA_PREFIX}",
trimmed.trim_end_matches('/')
);
};
let (authority, path) = match rest.find('/') {
Some(i) if &rest[i..] == "/" => (&rest[..i], ""),
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
format!("{scheme}://{authority}{PROTECTED_RESOURCE_METADATA_PREFIX}{path}")
}
pub(crate) fn metadata_path(metadata_url: &str) -> String {
metadata_url
.split_once("://")
.and_then(|(_, rest)| rest.find('/').map(|i| rest[i..].to_string()))
.unwrap_or_else(|| PROTECTED_RESOURCE_METADATA_PREFIX.to_string())
}
pub(crate) fn metadata_document(config: &ResolvedOAuthConfig) -> Value {
let mut doc = serde_json::json!({
"resource": config.resource,
"authorization_servers": [config.issuer],
"bearer_methods_supported": ["header"],
});
if !config.scopes_supported.is_empty() {
doc["scopes_supported"] = serde_json::json!(config.scopes_supported);
}
if let Some(name) = &config.resource_name {
doc["resource_name"] = Value::String(name.clone());
}
doc
}
pub(crate) fn invalid_token(resource_metadata_url: &str, supported_scopes: &str) -> String {
if supported_scopes.is_empty() {
return format!(
"Bearer error=\"invalid_token\", resource_metadata=\"{}\"",
quoted(resource_metadata_url)
);
}
format!(
"Bearer error=\"invalid_token\", resource_metadata=\"{}\", scope=\"{}\"",
quoted(resource_metadata_url),
quoted(supported_scopes)
)
}
pub(crate) fn insufficient_scope(required_scopes: &str, resource_metadata_url: &str) -> String {
if required_scopes.is_empty() {
return format!(
"Bearer error=\"insufficient_scope\", resource_metadata=\"{}\"",
quoted(resource_metadata_url)
);
}
format!(
"Bearer error=\"insufficient_scope\", scope=\"{}\", resource_metadata=\"{}\"",
quoted(required_scopes),
quoted(resource_metadata_url)
)
}
pub(crate) const MAX_ERROR_DESCRIPTION_BYTES: usize = 256;
pub(crate) fn insufficient_scope_for(
scopes: &[&str],
resource_metadata_url: Option<&str>,
description: Option<&str>,
) -> String {
let mut kept: Vec<&str> = Vec::new();
for scope in scopes {
if crate::config::is_scope_token(scope) && !kept.contains(scope) {
kept.push(scope);
}
}
let mut out = String::from("Bearer error=\"insufficient_scope\"");
if !kept.is_empty() {
out.push_str(&format!(", scope=\"{}\"", kept.join(" ")));
}
if let Some(url) = resource_metadata_url {
out.push_str(&format!(", resource_metadata=\"{}\"", quoted(url)));
}
if let Some(description) = description.map(error_description).filter(|d| !d.is_empty()) {
out.push_str(&format!(", error_description=\"{description}\""));
}
out
}
pub(crate) fn error_description(description: &str) -> String {
let allowed = |c: char| c == ' ' || c == '!' || matches!(c, '#'..='[' | ']'..='~');
let replaced: String = description
.chars()
.map(|c| if allowed(c) { c } else { ' ' })
.collect();
let mut trimmed = replaced.trim().to_string();
if trimmed.len() > MAX_ERROR_DESCRIPTION_BYTES {
trimmed.truncate(MAX_ERROR_DESCRIPTION_BYTES);
trimmed.truncate(trimmed.trim_end().len());
}
trimmed
}
pub(crate) fn quoted(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
pub(crate) fn fallback(error: &str, scopes: &str) -> String {
let bare = format!("Bearer error=\"{error}\"");
if scopes.is_empty() {
return bare;
}
let with_scope = format!("{bare}, scope=\"{}\"", quoted(scopes));
if is_header_value(&with_scope) {
with_scope
} else {
bare
}
}
pub(crate) fn is_header_value(value: &str) -> bool {
value
.bytes()
.all(|b| b == b' ' || b == b'\t' || (0x21..=0x7e).contains(&b))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_url_splices_the_well_known_segment_before_the_path() {
assert_eq!(
resource_metadata_url("https://kb.example.com/mcp"),
"https://kb.example.com/.well-known/oauth-protected-resource/mcp"
);
}
#[test]
fn metadata_url_for_a_path_less_resource_is_the_bare_well_known() {
assert_eq!(
resource_metadata_url("https://kb.example.com"),
"https://kb.example.com/.well-known/oauth-protected-resource"
);
assert_eq!(
resource_metadata_url("https://kb.example.com/"),
"https://kb.example.com/.well-known/oauth-protected-resource"
);
}
#[test]
fn metadata_url_keeps_a_port_and_the_path_verbatim() {
assert_eq!(
resource_metadata_url("http://localhost:8001/mcp"),
"http://localhost:8001/.well-known/oauth-protected-resource/mcp"
);
assert_eq!(
resource_metadata_url("http://localhost:8001/mcp/"),
"http://localhost:8001/.well-known/oauth-protected-resource/mcp/"
);
}
#[test]
fn metadata_url_of_a_malformed_resource_does_not_panic() {
for malformed in ["kb.example.com/mcp", "https://", "https:///", "http:"] {
assert_eq!(
resource_metadata_url(malformed),
PROTECTED_RESOURCE_METADATA_PREFIX,
"{malformed:?}"
);
}
}
#[test]
fn metadata_path_is_the_route_to_serve() {
for (resource, path) in [
(
"https://kb.example.com/mcp",
"/.well-known/oauth-protected-resource/mcp",
),
(
"https://kb.example.com/api/v1/",
"/.well-known/oauth-protected-resource/api/v1/",
),
(
"https://kb.example.com",
"/.well-known/oauth-protected-resource",
),
(
"kb.example.com/mcp",
"/.well-known/oauth-protected-resource",
),
] {
assert_eq!(
metadata_path(&resource_metadata_url(resource)),
path,
"{resource}"
);
}
}
#[test]
fn challenge_values_are_escaped_not_pasted() {
assert_eq!(quoted(r#"a"b\c"#), r#"a\"b\\c"#);
}
#[test]
fn an_invalid_token_challenge_with_no_advertised_scope_omits_scope() {
assert_eq!(
invalid_token("https://x/.well-known/oauth-protected-resource", ""),
"Bearer error=\"invalid_token\", \
resource_metadata=\"https://x/.well-known/oauth-protected-resource\""
);
assert_eq!(
invalid_token("https://x/.well-known/oauth-protected-resource", "a b"),
"Bearer error=\"invalid_token\", \
resource_metadata=\"https://x/.well-known/oauth-protected-resource\", scope=\"a b\""
);
}
#[test]
fn an_insufficient_scope_challenge_with_no_required_scope_omits_scope() {
assert_eq!(
insufficient_scope("", "https://x/.well-known/oauth-protected-resource"),
"Bearer error=\"insufficient_scope\", \
resource_metadata=\"https://x/.well-known/oauth-protected-resource\""
);
}
fn host(url: &str) -> Option<String> {
reqwest::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(str::to_owned))
}
#[test]
fn urls_built_from_a_non_canonical_spelling_stay_on_its_host() {
for resource in [
"https:/api.example.test/v1",
"https:api.example.test/v1",
"https://api.example.test\\v1",
"https:///api.example.test/v1",
"HTTPS:\\\\api.example.test\\v1",
] {
let url = resource_metadata_url(resource);
assert_eq!(
host(&url).as_deref(),
Some("api.example.test"),
"{resource:?} -> {url:?}"
);
assert_eq!(
url,
format!("{resource}{PROTECTED_RESOURCE_METADATA_PREFIX}"),
"{resource:?}"
);
}
for issuer in [
"https:/idp.example.test/app/",
"https:idp.example.test/app/",
"https://idp.example.test\\app\\",
"https:///idp.example.test/app/",
"HTTPS:\\\\idp.example.test\\app\\",
] {
let urls = crate::jwks::discovery_urls(issuer);
assert_eq!(urls.len(), 1, "{issuer:?}: {urls:?}");
assert_eq!(
host(&urls[0]).as_deref(),
Some("idp.example.test"),
"{issuer:?}: {urls:?}"
);
}
assert_eq!(
crate::jwks::discovery_urls("https://idp.example.test/app/").len(),
2
);
}
}