protocol_droid/domain/oauth/resource.rs
1//! RFC 8707 resource indicators, bound to the grant they were requested for.
2//!
3//! # The attack this stops
4//!
5//! Without a resource indicator, an authorization server issues one token and
6//! no record of which API it was for. A client that believes it is talking to
7//! one server can be walked into presenting that token to another, and the
8//! second server accepts it — because a bearer token names no audience, it
9//! only names a bearer. RFC 8707 §1 puts it as a token confused-deputy problem:
10//! the token is stolen from the resource it was meant for and replayed at one
11//! it was not.
12//!
13//! The `2025-06-18` MCP changelog §4 (PR #734) requires the fix for exactly
14//! this reason — clients **MUST** implement resource indicators "to prevent
15//! malicious servers from obtaining access tokens" — so
16//! [`AuthorizationModel::resource_indicators_required`](crate::domain::mcp::authorization::AuthorizationModel::resource_indicators_required)
17//! is a client obligation, not an option.
18//!
19//! # Why the value is held across the redirect
20//!
21//! RFC 8707 §2.1 settles the code-flow question: where the authorization
22//! endpoint returns an intermediate grant rather than a token, "the requested
23//! resource is applicable to **the full authorization grant**". So the same
24//! values have to appear in the token request that redeems the code.
25//!
26//! Sending `A` at authorization and `B` at redemption is the confused deputy,
27//! written out: the authorization server sees two unrelated requests rather
28//! than one grant, and may mint a token for `B` against a consent the user gave
29//! for `A`. [`ResourceBinding`] holds what the grant was asked for and
30//! answers whether a redemption still names it, so the two cannot drift.
31//!
32//! # Ordering
33//!
34//! RFC 8707 §2 says multiple `resource` parameters **MAY** be sent, and §5
35//! notes that when several are the same audience order is not significant. So
36//! the values are compared as a set: two requests naming the same resources in
37//! a different order are the same request, and a duplicate is one value, not
38//! two.
39
40use core::fmt;
41
42use crate::domain::mcp::authorization::AuthorizationModel;
43use crate::domain::mcp::resource_metadata::ResourceUri;
44
45/// The `resource` parameter values carried by one request.
46///
47/// Equality is set equality, not vector equality: RFC 8707 §5 says the order of
48/// several `resource` parameters naming one audience is not significant, so a
49/// redemption that lists the same resources in a different order names the same
50/// grant. [`iter`](Self::iter) still yields request order, because that is what
51/// gets serialised back out.
52///
53/// # Examples
54///
55/// ```
56/// use protocol_droid::domain::mcp::resource_metadata::ResourceUri;
57/// use protocol_droid::domain::oauth::ResourceParameters;
58///
59/// let api = ResourceUri::new("https://api.example.com/app/")?;
60/// let parameters = ResourceParameters::single(&api);
61/// assert_eq!(parameters.len(), 1);
62/// // `ResourceUri` canonicalises, so the trailing slash is gone.
63/// assert_eq!(parameters.iter().next(), Some("https://api.example.com/app"));
64/// # Ok::<(), protocol_droid::domain::mcp::resource_metadata::ResourceUriError>(())
65/// ```
66#[derive(Clone, Default, Eq)]
67pub struct ResourceParameters(Vec<ResourceUri>);
68
69impl PartialEq for ResourceParameters {
70 fn eq(&self, other: &Self) -> bool {
71 self.len() == other.len() && self.0.iter().all(|uri| other.0.contains(uri))
72 }
73}
74
75impl ResourceParameters {
76 /// The parameters for a request naming one resource — MCP's ordinary case,
77 /// where a server has a single canonical identifier.
78 #[must_use]
79 pub fn single(resource: &ResourceUri) -> Self {
80 Self(vec![resource.clone()])
81 }
82
83 /// The parameters for a request naming several, deduplicated with the first
84 /// occurrence of each kept, so two spellings of the same set compare equal.
85 #[must_use]
86 pub fn many(resources: impl IntoIterator<Item = ResourceUri>) -> Self {
87 let mut kept: Vec<ResourceUri> = Vec::new();
88 for resource in resources {
89 if !kept.contains(&resource) {
90 kept.push(resource);
91 }
92 }
93 Self(kept)
94 }
95
96 /// The values, in request order.
97 pub fn iter(&self) -> impl Iterator<Item = &str> {
98 self.0.iter().map(ResourceUri::as_str)
99 }
100
101 /// How many distinct resources are named.
102 #[must_use]
103 pub const fn len(&self) -> usize {
104 self.0.len()
105 }
106
107 /// Whether no resource is named at all.
108 #[must_use]
109 pub const fn is_empty(&self) -> bool {
110 self.0.is_empty()
111 }
112}
113
114impl fmt::Debug for ResourceParameters {
115 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116 formatter.debug_list().entries(self.iter()).finish()
117 }
118}
119
120/// What an authorization grant was requested for, held until the code is
121/// redeemed.
122///
123/// The client builds one from the values it sent in the authorization request
124/// and consults it before sending the token request. It is deliberately not
125/// `Copy` and has no accessor that hands back a mutable vector: the point is
126/// that what the grant is bound to cannot be edited between the two requests.
127#[derive(Clone, PartialEq, Eq)]
128pub struct ResourceBinding {
129 requested: ResourceParameters,
130}
131
132impl ResourceBinding {
133 /// Bind a grant to the resources an authorization request named.
134 ///
135 /// # Errors
136 ///
137 /// [`ResourceIndicatorError::Missing`] when the revision requires resource
138 /// indicators and the request named none. `2025-06-18` §4 makes this a
139 /// **MUST** on the client, so a caller cannot get a binding for one of
140 /// those revisions without naming a resource — the omission is not
141 /// representable rather than being checked later and forgotten.
142 pub fn bind(
143 model: &AuthorizationModel,
144 requested: ResourceParameters,
145 ) -> Result<Self, ResourceIndicatorError> {
146 if requested.is_empty() && model.resource_indicators_required {
147 return Err(ResourceIndicatorError::Missing);
148 }
149 Ok(Self { requested })
150 }
151
152 /// The parameters the token request must carry.
153 ///
154 /// RFC 8707 §2.1: in the code flow the requested resource is applicable to
155 /// the full grant, so these repeat at redemption rather than being left
156 /// behind in the authorization request.
157 #[must_use]
158 pub const fn token_request(&self) -> &ResourceParameters {
159 &self.requested
160 }
161
162 /// Whether a token request still names what the grant was bound to.
163 ///
164 /// `false` for a redemption that names a different resource, and also for
165 /// one that names none: omitting the parameter is how a client silently
166 /// asks for an unbounded token instead.
167 #[must_use]
168 pub fn matches_token_request(&self, offered: &ResourceParameters) -> bool {
169 self.requested == *offered
170 }
171}
172
173impl fmt::Debug for ResourceBinding {
174 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
175 formatter
176 .debug_struct("ResourceBinding")
177 .field("requested", &self.requested)
178 .finish()
179 }
180}
181
182/// An authorization request that does not carry what its revision requires.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
184pub enum ResourceIndicatorError {
185 /// The revision requires a resource indicator and the request named none.
186 ///
187 /// `2025-06-18` changelog §4 (PR #734). Constructed rather than returned
188 /// from a later stage so the omission cannot reach an authorization server.
189 #[error(
190 "this revision requires a resource indicator (RFC 8707), and the request named no resource"
191 )]
192 Missing,
193}
194
195#[cfg(test)]
196mod tests {
197 use super::{ResourceBinding, ResourceIndicatorError, ResourceParameters};
198 use crate::domain::ProtocolVersion;
199 use crate::domain::mcp::authorization::{AuthorizationModel, authorization_model};
200 use crate::domain::mcp::resource_metadata::{ResourceUri, ResourceUriError};
201
202 /// The fallible calls in this module answer two unrelated error types, so
203 /// a test returns this rather than one `?` silently stopping at the first.
204 #[derive(Debug)]
205 enum Failure {
206 Uri(ResourceUriError),
207 Indicator(ResourceIndicatorError),
208 }
209
210 impl From<ResourceUriError> for Failure {
211 fn from(error: ResourceUriError) -> Self {
212 Self::Uri(error)
213 }
214 }
215
216 impl From<ResourceIndicatorError> for Failure {
217 fn from(error: ResourceIndicatorError) -> Self {
218 Self::Indicator(error)
219 }
220 }
221
222 /// Delegates so a failing test prints the engine's own account of what it
223 /// refused, rather than `Failure::Uri(..)`. It also reads the fields, which
224 /// is why they are not dead: nothing else in this module does.
225 impl core::fmt::Display for Failure {
226 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
227 match self {
228 Self::Uri(error) => write!(formatter, "{error}"),
229 Self::Indicator(error) => write!(formatter, "{error}"),
230 }
231 }
232 }
233
234 fn resource(value: &str) -> Result<ResourceUri, ResourceUriError> {
235 ResourceUri::new(value)
236 }
237
238 fn model_for(version: ProtocolVersion) -> AuthorizationModel {
239 authorization_model(version)
240 }
241
242 #[test]
243 fn a_duplicated_resource_is_one_value() -> Result<(), Failure> {
244 let api = resource("https://api.example.com/app/")?;
245 let parameters = ResourceParameters::many([api.clone(), api]);
246 assert_eq!(parameters.len(), 1);
247 assert!(!parameters.is_empty());
248 Ok(())
249 }
250
251 #[test]
252 fn distinct_resources_are_kept_in_request_order() -> Result<(), Failure> {
253 let first = resource("https://api.example.com/one/")?;
254 let second = resource("https://api.example.com/two/")?;
255 let parameters = ResourceParameters::many([first, second]);
256 assert_eq!(parameters.len(), 2);
257 // The fixtures carry a trailing slash and `ResourceUri` canonicalises
258 // it away, so this pins what `iter` actually yields rather than what
259 // was typed in.
260 assert_eq!(
261 parameters.iter().collect::<Vec<_>>(),
262 vec!["https://api.example.com/one", "https://api.example.com/two"]
263 );
264 Ok(())
265 }
266
267 /// RFC 8707 §5: when several parameters name one audience, order is not
268 /// significant. So a reordered request is the same request, not a mismatch.
269 #[test]
270 fn order_does_not_change_what_a_request_names() -> Result<(), Failure> {
271 let one = resource("https://api.example.com/one")?;
272 let two = resource("https://api.example.com/two")?;
273
274 let binding = ResourceBinding::bind(
275 &model_for(ProtocolVersion::V2025_06_18),
276 ResourceParameters::many([one.clone(), two.clone()]),
277 )?;
278
279 let reordered = ResourceParameters::many([two, one]);
280 assert!(
281 binding.matches_token_request(&reordered),
282 "a reordered set names the same resources"
283 );
284 Ok(())
285 }
286
287 /// The confused deputy, written out: `A` at authorization, `B` at
288 /// redemption. If this ever returns true the binding is not doing its job.
289 #[test]
290 fn a_redemption_for_a_different_resource_does_not_match() -> Result<(), Failure> {
291 let granted = resource("https://api.example.com/granted")?;
292 let smuggled = resource("https://evil.example.com")?;
293
294 let binding = ResourceBinding::bind(
295 &model_for(ProtocolVersion::V2025_06_18),
296 ResourceParameters::single(&granted),
297 )?;
298
299 assert!(
300 !binding.matches_token_request(&ResourceParameters::single(&smuggled)),
301 "a token request for another resource must not match the grant"
302 );
303 assert!(
304 !binding.matches_token_request(&ResourceParameters::default()),
305 "omitting the parameter must not match a bound grant"
306 );
307 assert!(binding.matches_token_request(&ResourceParameters::single(&granted)));
308 Ok(())
309 }
310
311 /// `2025-06-18` §4: clients **MUST** implement resource indicators. The
312 /// omission is unrepresentable for those revisions rather than caught by a
313 /// caller remembering to check.
314 #[test]
315 fn a_revision_that_requires_indicators_refuses_an_empty_request() {
316 for version in [
317 ProtocolVersion::V2025_06_18,
318 ProtocolVersion::V2025_11_25,
319 ProtocolVersion::V2026_07_28,
320 ] {
321 let model = model_for(version);
322 assert!(
323 model.resource_indicators_required,
324 "{version:?} must require resource indicators"
325 );
326 assert_eq!(
327 ResourceBinding::bind(&model, ResourceParameters::default()),
328 Err(ResourceIndicatorError::Missing),
329 "{version:?} must refuse an authorization request naming no resource"
330 );
331 }
332 }
333
334 /// Before `2025-06-18` there is no requirement, so an empty request binds.
335 /// `2025-03-26` introduced OAuth 2.1 but the classification and the
336 /// resource indicators both arrived a revision later (§3 and §4).
337 #[test]
338 fn a_revision_that_does_not_require_indicators_accepts_an_empty_request() {
339 for version in [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_03_26] {
340 let model = model_for(version);
341 assert!(
342 !model.resource_indicators_required,
343 "{version:?} must not require resource indicators"
344 );
345 let binding = ResourceBinding::bind(&model, ResourceParameters::default());
346 assert!(
347 binding.is_ok(),
348 "{version:?} must allow a request naming no resource"
349 );
350 }
351 }
352
353 /// The binding is what the token request repeats, so it has to survive a
354 /// round trip through the parameters it was built from.
355 #[test]
356 fn the_binding_repeats_its_own_values_at_redemption() -> Result<(), Failure> {
357 let api = resource("https://api.example.com/app")?;
358 let requested = ResourceParameters::single(&api);
359 let binding =
360 ResourceBinding::bind(&model_for(ProtocolVersion::V2025_06_18), requested.clone())?;
361 assert_eq!(binding.token_request(), &requested);
362 assert!(binding.matches_token_request(&requested));
363 Ok(())
364 }
365}