tower_mcp/oauth/
metadata.rs1use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use url::Url;
9
10#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum ProtectedResourceMetadataError {
14 #[error("invalid protected resource URL: {0}")]
16 InvalidResourceUrl(#[source] url::ParseError),
17
18 #[error("protected resource URL must use http or https, got {0}")]
20 UnsupportedResourceScheme(String),
21
22 #[error("protected resource URL must not contain a fragment")]
24 ResourceHasFragment,
25
26 #[error("protected resource metadata must advertise at least one authorization server")]
28 MissingAuthorizationServer,
29
30 #[error("invalid authorization server URL {url}: {source}")]
32 InvalidAuthorizationServerUrl {
33 url: String,
35 #[source]
37 source: url::ParseError,
38 },
39
40 #[error("authorization server URL must use http or https, got {0}")]
42 UnsupportedAuthorizationServerScheme(String),
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ProtectedResourceMetadata {
66 pub resource: String,
70
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub authorization_servers: Vec<String>,
74
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub scopes_supported: Vec<String>,
78
79 #[serde(default = "default_bearer_methods")]
83 pub bearer_methods_supported: Vec<String>,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub resource_documentation: Option<String>,
88}
89
90fn default_bearer_methods() -> Vec<String> {
91 vec!["header".to_string()]
92}
93
94impl ProtectedResourceMetadata {
95 pub fn new(resource: impl Into<String>) -> Self {
97 Self {
98 resource: resource.into(),
99 authorization_servers: Vec::new(),
100 scopes_supported: Vec::new(),
101 bearer_methods_supported: default_bearer_methods(),
102 resource_documentation: None,
103 }
104 }
105
106 pub fn authorization_server(mut self, issuer_url: impl Into<String>) -> Self {
108 self.authorization_servers.push(issuer_url.into());
109 self
110 }
111
112 pub fn scope(mut self, scope: impl Into<String>) -> Self {
114 self.scopes_supported.push(scope.into());
115 self
116 }
117
118 pub fn resource_documentation(mut self, url: impl Into<String>) -> Self {
120 self.resource_documentation = Some(url.into());
121 self
122 }
123
124 pub fn bearer_methods(mut self, methods: Vec<String>) -> Self {
126 self.bearer_methods_supported = methods;
127 self
128 }
129
130 pub fn well_known_path() -> &'static str {
135 "/.well-known/oauth-protected-resource"
136 }
137
138 pub fn well_known_path_for_resource(
144 resource: &str,
145 ) -> Result<String, ProtectedResourceMetadataError> {
146 let url = Self::parse_resource_url(resource)?;
147 let resource_path = url.path();
148 if resource_path.is_empty() || resource_path == "/" {
149 Ok(Self::well_known_path().to_string())
150 } else {
151 Ok(format!("{}{}", Self::well_known_path(), resource_path))
152 }
153 }
154
155 pub fn well_known_url(&self) -> Result<String, ProtectedResourceMetadataError> {
157 let url = Self::parse_resource_url(&self.resource)?;
158 let path = Self::well_known_path_for_resource(&self.resource)?;
159 Ok(format!("{}{}", url.origin().ascii_serialization(), path))
160 }
161
162 pub fn validate(&self) -> Result<(), ProtectedResourceMetadataError> {
168 Self::parse_resource_url(&self.resource)?;
169 if self.authorization_servers.is_empty() {
170 return Err(ProtectedResourceMetadataError::MissingAuthorizationServer);
171 }
172 for issuer in &self.authorization_servers {
173 let url = Url::parse(issuer).map_err(|source| {
174 ProtectedResourceMetadataError::InvalidAuthorizationServerUrl {
175 url: issuer.clone(),
176 source,
177 }
178 })?;
179 if !matches!(url.scheme(), "http" | "https") {
180 return Err(
181 ProtectedResourceMetadataError::UnsupportedAuthorizationServerScheme(
182 url.scheme().to_string(),
183 ),
184 );
185 }
186 }
187 Ok(())
188 }
189
190 fn parse_resource_url(resource: &str) -> Result<Url, ProtectedResourceMetadataError> {
191 let url =
192 Url::parse(resource).map_err(ProtectedResourceMetadataError::InvalidResourceUrl)?;
193 if !matches!(url.scheme(), "http" | "https") {
194 return Err(ProtectedResourceMetadataError::UnsupportedResourceScheme(
195 url.scheme().to_string(),
196 ));
197 }
198 if url.fragment().is_some() {
199 return Err(ProtectedResourceMetadataError::ResourceHasFragment);
200 }
201 Ok(url)
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn test_builder() {
211 let metadata = ProtectedResourceMetadata::new("https://mcp.example.com")
212 .authorization_server("https://auth.example.com")
213 .scope("mcp:read")
214 .scope("mcp:write")
215 .resource_documentation("https://docs.example.com");
216
217 assert_eq!(metadata.resource, "https://mcp.example.com");
218 assert_eq!(
219 metadata.authorization_servers,
220 vec!["https://auth.example.com"]
221 );
222 assert_eq!(metadata.scopes_supported, vec!["mcp:read", "mcp:write"]);
223 assert_eq!(metadata.bearer_methods_supported, vec!["header"]);
224 assert_eq!(
225 metadata.resource_documentation.as_deref(),
226 Some("https://docs.example.com")
227 );
228 }
229
230 #[test]
231 fn test_serialization() {
232 let metadata = ProtectedResourceMetadata::new("https://mcp.example.com")
233 .authorization_server("https://auth.example.com")
234 .scope("mcp:read");
235
236 let json = serde_json::to_value(&metadata).unwrap();
237 assert_eq!(json["resource"], "https://mcp.example.com");
238 assert_eq!(json["authorization_servers"][0], "https://auth.example.com");
239 assert_eq!(json["scopes_supported"][0], "mcp:read");
240 assert_eq!(json["bearer_methods_supported"][0], "header");
241 assert!(json.get("resource_documentation").is_none());
243 }
244
245 #[test]
246 fn test_deserialization() {
247 let json = serde_json::json!({
248 "resource": "https://mcp.example.com",
249 "authorization_servers": ["https://auth.example.com"],
250 "scopes_supported": ["mcp:read"],
251 "bearer_methods_supported": ["header"]
252 });
253
254 let metadata: ProtectedResourceMetadata = serde_json::from_value(json).unwrap();
255 assert_eq!(metadata.resource, "https://mcp.example.com");
256 assert_eq!(metadata.authorization_servers.len(), 1);
257 assert_eq!(metadata.scopes_supported.len(), 1);
258 }
259
260 #[test]
261 fn test_well_known_path() {
262 assert_eq!(
263 ProtectedResourceMetadata::well_known_path(),
264 "/.well-known/oauth-protected-resource"
265 );
266 }
267
268 #[test]
269 fn test_multiple_auth_servers() {
270 let metadata = ProtectedResourceMetadata::new("https://mcp.example.com")
271 .authorization_server("https://auth1.example.com")
272 .authorization_server("https://auth2.example.com");
273
274 assert_eq!(metadata.authorization_servers.len(), 2);
275 }
276
277 #[test]
278 fn test_path_aware_well_known_location() {
279 let metadata = ProtectedResourceMetadata::new("https://mcp.example.com/tenant/mcp?x=1")
280 .authorization_server("https://auth.example.com");
281
282 assert_eq!(
283 metadata.well_known_url().unwrap(),
284 "https://mcp.example.com/.well-known/oauth-protected-resource/tenant/mcp"
285 );
286 assert_eq!(
287 ProtectedResourceMetadata::well_known_path_for_resource(&metadata.resource).unwrap(),
288 "/.well-known/oauth-protected-resource/tenant/mcp"
289 );
290 }
291
292 #[test]
293 fn test_path_aware_location_preserves_encoded_path_segments() {
294 let metadata = ProtectedResourceMetadata::new("https://mcp.example.com/a%2Fb")
295 .authorization_server("https://auth.example.com");
296 assert_eq!(
297 metadata.well_known_url().unwrap(),
298 "https://mcp.example.com/.well-known/oauth-protected-resource/a%2Fb"
299 );
300 }
301
302 #[test]
303 fn test_validate_requires_authorization_server() {
304 let error = ProtectedResourceMetadata::new("https://mcp.example.com")
305 .validate()
306 .unwrap_err();
307 assert!(matches!(
308 error,
309 ProtectedResourceMetadataError::MissingAuthorizationServer
310 ));
311 }
312
313 #[test]
314 fn test_validate_rejects_resource_fragment() {
315 let error = ProtectedResourceMetadata::new("https://mcp.example.com#fragment")
316 .authorization_server("https://auth.example.com")
317 .validate()
318 .unwrap_err();
319 assert!(matches!(
320 error,
321 ProtectedResourceMetadataError::ResourceHasFragment
322 ));
323 }
324}