Skip to main content

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}