Skip to main content

protocol_droid/application/oauth/
metadata.rs

1//! The two metadata documents a client reads before it can authorize.
2//!
3//! Only the members MCP's flow acts on are read; everything else in either
4//! document is ignored rather than rejected, because both are extensible.
5//!
6//! - Protected resource metadata (RFC 9728): `resource`, `authorization_servers`,
7//!   `scopes_supported`.
8//! - Authorization server metadata (RFC 8414 / `OpenID` Connect Discovery):
9//!   the endpoints, the advertised capabilities the flow branches on, and
10//!   `issuer`, which "MUST be identical to the issuer identifier used to
11//!   construct the well-known URL".
12//!   <https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery>
13
14use serde_json::Value;
15
16/// Why a metadata document could not be used.
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum MetadataError {
19    /// The body is not a JSON object.
20    #[error("the {document} is not a JSON object")]
21    NotAnObject {
22        /// Which document.
23        document: &'static str,
24    },
25    /// A required member is missing or not a string.
26    #[error("the {document} has no usable `{member}`")]
27    Missing {
28        /// Which document.
29        document: &'static str,
30        /// Which member.
31        member: &'static str,
32    },
33    /// The document's `issuer` is not the issuer it was fetched for.
34    ///
35    /// The mix-up defence: a document served from one issuer's well-known URL
36    /// that claims to be another's must not be used.
37    #[error("the metadata names issuer `{claimed}`, but it was fetched for `{expected}`")]
38    IssuerMismatch {
39        /// The issuer the URL was built from.
40        expected: String,
41        /// The issuer the document claims.
42        claimed: String,
43    },
44}
45
46/// What a client reads from protected resource metadata.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ProtectedResource {
49    /// The resource identifier the document describes.
50    pub resource: String,
51    /// The authorization servers, in the order listed.
52    pub authorization_servers: Vec<String>,
53    /// The scopes the resource lists, if it lists any.
54    pub scopes_supported: Option<Vec<String>>,
55}
56
57impl ProtectedResource {
58    /// Parses an RFC 9728 document.
59    ///
60    /// # Errors
61    ///
62    /// [`MetadataError`] for a body that is not an object, or that lacks
63    /// `resource` or a non-empty `authorization_servers`.
64    pub fn parse(body: &[u8]) -> Result<Self, MetadataError> {
65        const DOCUMENT: &str = "protected resource metadata";
66        let value: Value = serde_json::from_slice(body)
67            .map_err(|_| MetadataError::NotAnObject { document: DOCUMENT })?;
68        let object = value
69            .as_object()
70            .ok_or(MetadataError::NotAnObject { document: DOCUMENT })?;
71        let resource = object
72            .get("resource")
73            .and_then(Value::as_str)
74            .ok_or(MetadataError::Missing {
75                document: DOCUMENT,
76                member: "resource",
77            })?
78            .to_owned();
79        let authorization_servers: Vec<String> = strings(object.get("authorization_servers"));
80        if authorization_servers.is_empty() {
81            return Err(MetadataError::Missing {
82                document: DOCUMENT,
83                member: "authorization_servers",
84            });
85        }
86        let scopes_supported = object
87            .get("scopes_supported")
88            .filter(|value| value.is_array())
89            .map(|value| strings(Some(value)));
90        Ok(Self {
91            resource,
92            authorization_servers,
93            scopes_supported,
94        })
95    }
96}
97
98/// What a client reads from authorization server metadata.
99#[derive(Debug, Clone, PartialEq, Eq, Default)]
100pub struct AuthorizationServer {
101    /// The issuer identifier, verified against the one the URL was built for.
102    pub issuer: String,
103    /// Where the user is sent.
104    pub authorization_endpoint: String,
105    /// Where a code is redeemed.
106    pub token_endpoint: String,
107    /// Where RFC 7591 registration is offered, if it is.
108    pub registration_endpoint: Option<String>,
109    /// The scopes the server lists.
110    pub scopes_supported: Vec<String>,
111    /// The PKCE methods the server lists; `None` when the member is absent.
112    pub code_challenge_methods_supported: Option<Vec<String>>,
113    /// The token endpoint authentication methods the server lists.
114    pub token_endpoint_auth_methods_supported: Vec<String>,
115    /// Whether a URL-shaped `client_id` (a metadata document) is accepted.
116    pub client_id_metadata_document_supported: bool,
117    /// Whether the server includes `iss` in authorization responses.
118    pub authorization_response_iss_parameter_supported: bool,
119}
120
121impl AuthorizationServer {
122    /// Parses a metadata document fetched for `expected_issuer`.
123    ///
124    /// # Errors
125    ///
126    /// [`MetadataError::IssuerMismatch`] when the document claims a different
127    /// issuer — compared by simple string equality, as RFC 8414 §3.3 asks —
128    /// and [`MetadataError::Missing`] when an endpoint the flow needs is absent.
129    pub fn parse(body: &[u8], expected_issuer: &str) -> Result<Self, MetadataError> {
130        const DOCUMENT: &str = "authorization server metadata";
131        let value: Value = serde_json::from_slice(body)
132            .map_err(|_| MetadataError::NotAnObject { document: DOCUMENT })?;
133        let object = value
134            .as_object()
135            .ok_or(MetadataError::NotAnObject { document: DOCUMENT })?;
136        let text = |member: &'static str| {
137            object
138                .get(member)
139                .and_then(Value::as_str)
140                .map(ToOwned::to_owned)
141                .ok_or(MetadataError::Missing {
142                    document: DOCUMENT,
143                    member,
144                })
145        };
146        let issuer = text("issuer")?;
147        if issuer != expected_issuer {
148            return Err(MetadataError::IssuerMismatch {
149                expected: expected_issuer.to_owned(),
150                claimed: issuer,
151            });
152        }
153        Ok(Self {
154            issuer,
155            authorization_endpoint: text("authorization_endpoint")?,
156            token_endpoint: text("token_endpoint")?,
157            registration_endpoint: text("registration_endpoint").ok(),
158            scopes_supported: strings(object.get("scopes_supported")),
159            code_challenge_methods_supported: object
160                .get("code_challenge_methods_supported")
161                .filter(|value| value.is_array())
162                .map(|value| strings(Some(value))),
163            token_endpoint_auth_methods_supported: strings(
164                object.get("token_endpoint_auth_methods_supported"),
165            ),
166            client_id_metadata_document_supported: object
167                .get("client_id_metadata_document_supported")
168                .and_then(Value::as_bool)
169                .unwrap_or(false),
170            authorization_response_iss_parameter_supported: object
171                .get("authorization_response_iss_parameter_supported")
172                .and_then(Value::as_bool)
173                .unwrap_or(false),
174        })
175    }
176}
177
178fn strings(value: Option<&Value>) -> Vec<String> {
179    value
180        .and_then(Value::as_array)
181        .into_iter()
182        .flatten()
183        .filter_map(Value::as_str)
184        .map(ToOwned::to_owned)
185        .collect()
186}
187
188#[cfg(test)]
189mod tests {
190    use super::{AuthorizationServer, MetadataError, ProtectedResource};
191
192    #[test]
193    fn protected_resource_metadata_reads_what_the_flow_needs() -> Result<(), MetadataError> {
194        let document = ProtectedResource::parse(
195            br#"{"resource":"https://mcp.example.com/mcp","authorization_servers":["https://auth.example.com"],"scopes_supported":["a","b"]}"#,
196        )?;
197        assert_eq!(document.authorization_servers, ["https://auth.example.com"]);
198        assert_eq!(
199            document.scopes_supported,
200            Some(vec!["a".to_owned(), "b".to_owned()])
201        );
202        Ok(())
203    }
204
205    #[test]
206    fn absent_scopes_supported_is_none_not_empty() -> Result<(), MetadataError> {
207        let document = ProtectedResource::parse(
208            br#"{"resource":"https://mcp.example.com","authorization_servers":["https://auth.example.com"]}"#,
209        )?;
210        assert_eq!(document.scopes_supported, None);
211        Ok(())
212    }
213
214    #[test]
215    fn metadata_for_another_issuer_is_refused() {
216        let outcome = AuthorizationServer::parse(
217            br#"{"issuer":"https://honest.example","authorization_endpoint":"a","token_endpoint":"t"}"#,
218            "https://attacker.example",
219        );
220        assert!(matches!(outcome, Err(MetadataError::IssuerMismatch { .. })));
221    }
222
223    #[test]
224    fn the_issuer_is_compared_exactly() {
225        let outcome = AuthorizationServer::parse(
226            br#"{"issuer":"https://auth.example.com/","authorization_endpoint":"a","token_endpoint":"t"}"#,
227            "https://auth.example.com",
228        );
229        assert!(outcome.is_err(), "a trailing slash is a different issuer");
230    }
231}