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