Skip to main content

rust_mcp_sdk/auth/spec/
discovery.rs

1use crate::{
2    auth::{OauthEndpoint, OAUTH_PROTECTED_RESOURCE_BASE, WELL_KNOWN_OAUTH_AUTHORIZATION_SERVER},
3    error::McpSdkError,
4    mcp_http::url_base,
5};
6use reqwest::Client;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use url::Url;
10
11#[derive(Debug, Serialize, Deserialize, Clone)]
12pub struct AuthorizationServerMetadata {
13    /// The base URL of the authorization server (e.g., "http://localhost:8080/realms/master/").
14    pub issuer: Url,
15
16    /// URL to which the client redirects the user for authorization.
17    pub authorization_endpoint: Url,
18
19    /// URL to exchange authorization codes for tokens or refresh tokens.
20    pub token_endpoint: Url,
21
22    /// URL of the authorization server's JWK Set `JWK` document
23    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
24    pub jwks_uri: Option<Url>,
25
26    /// Endpoint where clients can register dynamically.
27    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
28    pub registration_endpoint: Option<Url>,
29
30    /// List of supported OAuth scopes (e.g., "openid", "profile", "email", mcp:tools)
31    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
32    pub scopes_supported: Option<Vec<String>>,
33
34    ///  Response Types. Required by spec. If missing, default is empty vec.
35    /// Examples: "code", "token", "id_token"
36    #[serde(default, skip_serializing_if = "::std::vec::Vec::is_empty")]
37    pub response_types_supported: Vec<String>,
38
39    ///  Response Modes. Indicates how the authorization response is returned.
40    /// Examples: "query", "fragment", "form_post"
41    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
42    pub response_modes_supported: Option<Vec<String>>,
43
44    // ui_locales_supported
45    // op_policy_uri
46    // op_tos_uri
47    /// List of supported Grant Types
48    /// Examples: "authorization_code", "client_credentials", "refresh_token"
49    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
50    pub grant_types_supported: Option<Vec<String>>,
51
52    /// Methods like "client_secret_basic", "client_secret_post"
53    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
54    pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
55
56    /// Signing algorithms for client authentication (e.g., "RS256")
57    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
58    pub token_endpoint_auth_signing_alg_values_supported: Option<Vec<String>>,
59
60    /// Link to human-readable docs for developers.
61    /// <https://datatracker.ietf.org/doc/html/rfc8414>
62    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
63    pub service_documentation: Option<Url>,
64
65    /// OAuth 2.0 Token Revocation endpoint.
66    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
67    pub revocation_endpoint: Option<Url>,
68
69    /// Similar to token endpoint, but for revocation-specific auth.
70    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
71    pub revocation_endpoint_auth_signing_alg_values_supported: Option<Vec<String>>,
72
73    /// Tells the client which authentication methods are supported when accessing the token revocation endpoint.
74    /// These are standardized methods from RFC 6749 (OAuth 2.0)
75    /// Common values: "client_secret_basic", "client_secret_post", "private_key_jwt"
76    /// `client_secret_basic` – client credentials sent in HTTP Basic Auth.
77    /// `client_secret_post` – client credentials sent in the POST body.
78    /// `private_key_jwt` – client authenticates using a signed JWT.
79    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
80    pub revocation_endpoint_auth_methods_supported: Option<Vec<String>>,
81
82    /// URL to validate tokens and get their metadata.
83    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
84    pub introspection_endpoint: Option<Url>,
85
86    /// Auth methods for accessing introspection.
87    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
88    pub introspection_endpoint_auth_methods_supported: Option<Vec<String>>,
89
90    /// Algorithms for accessing introspection.
91    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
92    pub introspection_endpoint_auth_signing_alg_values_supported: Option<Vec<String>>,
93
94    /// Methods supported for PKCE (Proof Key for Code Exchange).
95    /// Common values: "plain", "S256"
96    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
97    pub code_challenge_methods_supported: Option<Vec<String>>,
98
99    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
100    pub userinfo_endpoint: Option<String>,
101}
102
103impl AuthorizationServerMetadata {
104    /// Creates a new `AuthorizationServerMetadata` instance with the minimal required fields.
105    /// According to the OAuth 2.0 Authorization Server Metadata Metadata specification (RFC 8414),
106    /// the following fields are **required** for a valid metadata document:
107    /// - `issuer`
108    /// - `authorization_endpoint`
109    /// - `token_endpoint`
110    ///
111    /// All other fields are initialized with their default values (typically `None` or empty collections).
112    ///
113    pub fn new(
114        issuer: &str,
115        authorization_endpoint: &str,
116        token_endpoint: &str,
117    ) -> Result<Self, url::ParseError> {
118        let issuer = Url::parse(issuer)?;
119        let authorization_endpoint = Url::parse(authorization_endpoint)?;
120        let token_endpoint = Url::parse(token_endpoint)?;
121
122        Ok(Self {
123            issuer,
124            authorization_endpoint,
125            token_endpoint,
126            jwks_uri: Default::default(),
127            registration_endpoint: Default::default(),
128            scopes_supported: Default::default(),
129            response_types_supported: Default::default(),
130            response_modes_supported: Default::default(),
131            grant_types_supported: Default::default(),
132            token_endpoint_auth_methods_supported: Default::default(),
133            token_endpoint_auth_signing_alg_values_supported: Default::default(),
134            service_documentation: Default::default(),
135            revocation_endpoint: Default::default(),
136            revocation_endpoint_auth_signing_alg_values_supported: Default::default(),
137            revocation_endpoint_auth_methods_supported: Default::default(),
138            introspection_endpoint: Default::default(),
139            introspection_endpoint_auth_methods_supported: Default::default(),
140            introspection_endpoint_auth_signing_alg_values_supported: Default::default(),
141            code_challenge_methods_supported: Default::default(),
142            userinfo_endpoint: Default::default(),
143        })
144    }
145
146    /// Fetches authorization server metadata from a remote `.well-known/openid-configuration`
147    /// or OAuth 2.0 Authorization Server Metadata endpoint.
148    ///
149    /// This performs an HTTP GET request and deserializes the response directly into
150    /// `AuthorizationServerMetadata`. The endpoint must return a JSON document conforming
151    /// to RFC 8414 (OAuth 2.0 Authorization Server Metadata) or OpenID Connect Discovery 1.0.
152    ///
153    pub async fn from_discovery_url(discovery_url: &str) -> Result<Self, McpSdkError> {
154        let client = Client::new();
155        let metadata = client
156            .get(discovery_url)
157            .send()
158            .await
159            .map_err(|err| McpSdkError::Internal {
160                description: err.to_string(),
161            })?
162            .json::<AuthorizationServerMetadata>()
163            .await
164            .map_err(|err| McpSdkError::Internal {
165                description: err.to_string(),
166            })?;
167        Ok(metadata)
168    }
169}
170
171/// represents metadata about a protected resource in the OAuth 2.0 ecosystem.
172/// It allows clients and authorization servers to discover how to interact with a protected resource (like an MCP endpoint),
173/// including security requirements and supported features.
174/// <https://datatracker.ietf.org/doc/rfc9728>
175#[derive(Debug, Serialize, Deserialize, Clone)]
176pub struct OauthProtectedResourceMetadata {
177    /// The base identifier of the protected resource (e.g., an MCP server's URI).
178    /// This is the only required field.
179    pub resource: Url,
180
181    /// List of authorization servers that can issue access tokens for this resource.
182    /// Allows dynamic trust discovery.
183    #[serde(default, skip_serializing_if = "::std::vec::Vec::is_empty")]
184    pub authorization_servers: Vec<Url>,
185
186    /// URL where the resource exposes its public keys (JWKS) to verify signed tokens.
187    /// Typically used to verify JWT access tokens.
188    /// Example: `https://example.com/.well-known/jwks.json`
189    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
190    pub jwks_uri: Option<Url>,
191
192    /// OAuth scopes the resource supports (e.g., "mcp:tool", "read", "write", "admin").
193    /// Helps clients know what they can request for access.
194    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
195    pub scopes_supported: Option<Vec<String>>,
196
197    /// Methods accepted for presenting Bearer tokens:
198    /// `authorization_header` (typical)
199    /// `form_post`
200    /// `uri_query`
201    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
202    pub bearer_methods_supported: Option<Vec<String>>,
203
204    /// Supported signing algorithms for access tokens (if tokens are JWTs).
205    /// Example: ["RS256", "ES256"]
206    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
207    pub resource_signing_alg_values_supported: Option<Vec<String>>,
208
209    /// A human-readable name for the resource.
210    /// Useful for UIs, logs, or developer documentation.
211    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
212    pub resource_name: Option<String>,
213
214    /// URL to developer docs describing the resource and how to use it.
215    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
216    pub resource_documentation: Option<String>,
217
218    /// URL to the resource's access policy or terms (e.g., rules on who can access what).
219    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
220    pub resource_policy_uri: Option<Url>,
221
222    /// URL to terms of service applicable to this resource.
223    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
224    pub resource_tos_uri: Option<Url>,
225
226    /// If true, access tokens must be bound to a client TLS certificate.
227    /// Used in mutual TLS scenarios for additional security.
228    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
229    pub tls_client_certificate_bound_access_tokens: Option<bool>,
230
231    ///Lists structured authorization types supported (used with Rich Authorization Requests (RAR)
232    /// Example: ["payment_initiation", "account_information"]
233    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
234    pub authorization_details_types_supported: Option<Vec<String>>,
235
236    /// Supported algorithms for DPoP (Demonstration of Proof-of-Possession) tokens.
237    /// Example: ["ES256", "RS256"]
238    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
239    pub dpop_signing_alg_values_supported: Option<Vec<String>>,
240
241    /// If true, the resource requires access tokens to be DPoP-bound.
242    /// Enhances security by tying tokens to a specific client and key.
243    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
244    pub dpop_bound_access_tokens_required: Option<bool>,
245}
246
247impl OauthProtectedResourceMetadata {
248    /// Creates a new `OAuthProtectedResourceMetadata` instance with only the
249    /// minimal required fields populated.
250    ///
251    /// The `resource` and each entry in `authorization_servers` must be valid URLs.
252    /// All other metadata fields are initialized to their defaults.
253    /// To provide optional or extended metadata, assign those fields after creation or construct the struct directly.
254    pub fn new<S>(
255        resource: S,
256        authorization_servers: Vec<S>,
257        scopes_supported: Option<Vec<String>>,
258    ) -> Result<Self, url::ParseError>
259    where
260        S: AsRef<str>,
261    {
262        let resource = Url::parse(resource.as_ref())?;
263        let authorization_servers: Vec<_> = authorization_servers
264            .iter()
265            .map(|s| Url::parse(s.as_ref()))
266            .collect::<Result<_, _>>()?;
267
268        Ok(Self {
269            resource,
270            authorization_servers,
271            jwks_uri: Default::default(),
272            scopes_supported,
273            bearer_methods_supported: Default::default(),
274            resource_signing_alg_values_supported: Default::default(),
275            resource_name: Default::default(),
276            resource_documentation: Default::default(),
277            resource_policy_uri: Default::default(),
278            resource_tos_uri: Default::default(),
279            tls_client_certificate_bound_access_tokens: Default::default(),
280            authorization_details_types_supported: Default::default(),
281            dpop_signing_alg_values_supported: Default::default(),
282            dpop_bound_access_tokens_required: Default::default(),
283        })
284    }
285}
286
287pub fn create_protected_resource_metadata_url(path: &str) -> String {
288    format!(
289        "{OAUTH_PROTECTED_RESOURCE_BASE}{}",
290        if path == "/" { "" } else { path }
291    )
292}
293
294pub fn create_discovery_endpoints(
295    mcp_server_url: &str,
296) -> Result<(HashMap<String, OauthEndpoint>, String), McpSdkError> {
297    let mut endpoint_map = HashMap::new();
298    endpoint_map.insert(
299        WELL_KNOWN_OAUTH_AUTHORIZATION_SERVER.to_string(),
300        OauthEndpoint::AuthorizationServerMetadata,
301    );
302
303    let resource_url = Url::parse(mcp_server_url).map_err(|err| McpSdkError::Internal {
304        description: err.to_string(),
305    })?;
306
307    let relative_url = create_protected_resource_metadata_url(resource_url.path());
308    let base_url = url_base(&resource_url);
309    let protected_resource_metadata_url =
310        format!("{}{relative_url}", base_url.trim_end_matches('/'));
311
312    endpoint_map.insert(relative_url, OauthEndpoint::ProtectedResourceMetadata);
313
314    Ok((endpoint_map, protected_resource_metadata_url))
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use serde_json::{json, Value};
321
322    fn sample_full_metadata_json() -> Value {
323        json!({
324            "issuer": "https://auth.example.com/realms/demo",
325            "authorization_endpoint": "https://auth.example.com/realms/demo/protocol/openid-connect/auth",
326            "token_endpoint": "https://auth.example.com/realms/demo/protocol/openid-connect/token",
327            "jwks_uri": "https://auth.example.com/realms/demo/protocol/openid-connect/certs",
328            "registration_endpoint": "https://auth.example.com/realms/demo/clients-registrations",
329            "scopes_supported": ["openid", "profile", "email", "mcp:tools", "offline_access"],
330            "response_types_supported": ["code", "id_token", "code id_token", "token"],
331            "response_modes_supported": ["query", "fragment", "form_post"],
332            "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
333            "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "private_key_jwt"],
334            "token_endpoint_auth_signing_alg_values_supported": ["RS256", "ES256"],
335            "service_documentation": "https://docs.example.com/oauth2",
336            "revocation_endpoint": "https://auth.example.com/realms/demo/protocol/openid-connect/revoke",
337            "revocation_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
338            "introspection_endpoint": "https://auth.example.com/realms/demo/protocol/openid-connect/token/introspect",
339            "code_challenge_methods_supported": ["S256", "plain"],
340            "userinfo_endpoint": "https://auth.example.com/realms/demo/protocol/openid-connect/userinfo"
341        })
342    }
343
344    #[test]
345    fn test_serialize_minimal_metadata() {
346        let meta = AuthorizationServerMetadata::new(
347            "https://auth.test/realms/min",
348            "https://auth.test/realms/min/auth",
349            "https://auth.test/realms/min/token",
350        )
351        .unwrap();
352
353        let json = serde_json::to_value(&meta).expect("serialize failed");
354
355        assert_eq!(json["issuer"], "https://auth.test/realms/min");
356        assert_eq!(
357            json["authorization_endpoint"],
358            "https://auth.test/realms/min/auth"
359        );
360        assert_eq!(json["token_endpoint"], "https://auth.test/realms/min/token");
361
362        // optional fields should be absent when empty/default
363        assert!(!json.as_object().unwrap().contains_key("jwks_uri"));
364        assert!(!json.as_object().unwrap().contains_key("scopes_supported"));
365        assert_eq!(json["response_types_supported"], Value::Null);
366    }
367
368    #[test]
369    fn test_round_trip_minimal() {
370        let original = AuthorizationServerMetadata::new(
371            "https://issuer.example.com/",
372            "https://issuer.example.com/authorize",
373            "https://issuer.example.com/token",
374        )
375        .unwrap();
376
377        let json_str = serde_json::to_string(&original).unwrap();
378        let deserialized: AuthorizationServerMetadata = serde_json::from_str(&json_str).unwrap();
379
380        assert_eq!(original.issuer, deserialized.issuer);
381        assert_eq!(
382            original.authorization_endpoint,
383            deserialized.authorization_endpoint
384        );
385        assert_eq!(original.token_endpoint, deserialized.token_endpoint);
386        assert_eq!(original.jwks_uri, None);
387        assert_eq!(original.response_types_supported, Vec::<String>::new());
388    }
389
390    #[test]
391    fn test_deserialize_full_document() {
392        let json = sample_full_metadata_json();
393        let json_str = serde_json::to_string(&json).unwrap();
394
395        let meta: AuthorizationServerMetadata =
396            serde_json::from_str(&json_str).expect("deserialization failed");
397
398        assert_eq!(meta.issuer.as_str(), "https://auth.example.com/realms/demo");
399        assert_eq!(
400            meta.jwks_uri.as_ref().unwrap().as_str(),
401            "https://auth.example.com/realms/demo/protocol/openid-connect/certs"
402        );
403        assert_eq!(meta.scopes_supported.as_ref().unwrap().len(), 5);
404        assert!(meta
405            .scopes_supported
406            .as_ref()
407            .unwrap()
408            .contains(&"mcp:tools".to_string()));
409        assert_eq!(
410            meta.code_challenge_methods_supported.as_ref().unwrap(),
411            &vec!["S256".to_string(), "plain".to_string()]
412        );
413        assert_eq!(
414            meta.userinfo_endpoint.as_ref().unwrap(),
415            "https://auth.example.com/realms/demo/protocol/openid-connect/userinfo"
416        );
417    }
418
419    #[test]
420    fn test_round_trip_full_document() {
421        let json_val = sample_full_metadata_json();
422        let original: AuthorizationServerMetadata =
423            serde_json::from_value(json_val.clone()).unwrap();
424
425        let serialized = serde_json::to_value(&original).unwrap();
426        assert_eq!(serialized, json_val);
427
428        // also test via string round-trip
429        let json_str = serde_json::to_string(&original).unwrap();
430        let round_tripped: AuthorizationServerMetadata = serde_json::from_str(&json_str).unwrap();
431
432        assert_eq!(original.issuer, round_tripped.issuer);
433        assert_eq!(original.jwks_uri, round_tripped.jwks_uri);
434        assert_eq!(original.scopes_supported, round_tripped.scopes_supported);
435        assert_eq!(
436            original.response_types_supported,
437            round_tripped.response_types_supported
438        );
439    }
440
441    #[test]
442    fn test_deserialize_missing_required_field() {
443        let mut json = sample_full_metadata_json();
444        json.as_object_mut().unwrap().remove("token_endpoint");
445
446        let err = serde_json::from_value::<AuthorizationServerMetadata>(json).unwrap_err();
447        assert!(err.to_string().contains("token_endpoint"));
448    }
449
450    #[test]
451    fn test_deserialize_unknown_fields_are_ignored() {
452        let mut json = sample_full_metadata_json();
453        json["issuer"] = json!("https://auth.example.com/realms/demo");
454        json["some_new_field"] = json!(42);
455        json["claims_supported"] = json!(["sub", "name", "email"]); // common extra field
456
457        let meta: AuthorizationServerMetadata =
458            serde_json::from_value(json).expect("should ignore unknown fields");
459
460        assert_eq!(meta.issuer.as_str(), "https://auth.example.com/realms/demo");
461    }
462
463    #[test]
464    fn test_serialize_and_deserialize_with_empty_optional_arrays() {
465        let mut meta = AuthorizationServerMetadata::new(
466            "https://a.b/c",
467            "https://a.b/auth",
468            "https://a.b/token",
469        )
470        .unwrap();
471
472        meta.scopes_supported = Some(vec![]);
473        meta.grant_types_supported = Some(vec![]);
474        meta.response_modes_supported = None;
475
476        let json = serde_json::to_value(&meta).unwrap();
477
478        // empty vec should be serialized when Some()
479        assert_eq!(json["scopes_supported"], Value::Array(vec![]));
480        assert_eq!(json["grant_types_supported"], Value::Array(vec![]));
481
482        // None should be skipped
483        assert!(!json
484            .as_object()
485            .unwrap()
486            .contains_key("response_modes_supported"));
487
488        let round: AuthorizationServerMetadata = serde_json::from_value(json).unwrap();
489        assert_eq!(round.scopes_supported, Some(vec![]));
490        assert_eq!(round.grant_types_supported, Some(vec![]));
491        assert_eq!(round.response_modes_supported, None);
492        let _ = serde_json::to_string(&round).unwrap();
493    }
494}