protocol_droid/application/oauth/
metadata.rs1use serde_json::Value;
15
16#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum MetadataError {
19 #[error("the {document} is not a JSON object")]
21 NotAnObject {
22 document: &'static str,
24 },
25 #[error("the {document} has no usable `{member}`")]
27 Missing {
28 document: &'static str,
30 member: &'static str,
32 },
33 #[error("the metadata names issuer `{claimed}`, but it was fetched for `{expected}`")]
38 IssuerMismatch {
39 expected: String,
41 claimed: String,
43 },
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ProtectedResource {
49 pub resource: String,
51 pub authorization_servers: Vec<String>,
53 pub scopes_supported: Option<Vec<String>>,
55}
56
57impl ProtectedResource {
58 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
100pub struct AuthorizationServer {
101 pub issuer: String,
103 pub authorization_endpoint: String,
105 pub token_endpoint: String,
107 pub registration_endpoint: Option<String>,
109 pub scopes_supported: Vec<String>,
111 pub code_challenge_methods_supported: Option<Vec<String>>,
113 pub token_endpoint_auth_methods_supported: Vec<String>,
115 pub client_id_metadata_document_supported: bool,
117 pub authorization_response_iss_parameter_supported: bool,
119}
120
121impl AuthorizationServer {
122 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}