Skip to main content

protocol_droid/domain/oauth/
issuer.rs

1//! RFC 9207 issuer identification, checked on the authorization response.
2//!
3//! # What a mix-up attack is
4//!
5//! A client that talks to more than one authorization server sends
6//! authorization requests it cannot tell apart once the responses come back. An
7//! attacker who can interpose routes a request intended for server A to server
8//! B, and returns B's response to a client that believes it is finishing with
9//! A. The client has an authorization code and a token endpoint it trusts, and
10//! no way to notice the two do not belong together — RFC 9207 §1 calls this a
11//! mix-up attack and the `iss` parameter the countermeasure.
12//!
13//! The client already knows which issuer it asked, from the discovery
14//! document it started at. So the check is a comparison against something the
15//! client already holds, not a lookup.
16//!
17//! # Two rules that are easy to get wrong
18//!
19//! **Simple string comparison.** RFC 9207 §2.1 says the comparison "MUST use
20//! simple string comparison". Not URI normalisation, not case folding, not
21//! trailing-slash trimming. A server that sends an issuer differing only in
22//! normalisation from the one discovered is either doing something this
23//! specification does not describe or is an impostor, and quietly normalising
24//! would accept the second.
25//!
26//! **Absent depends on what the server advertised.** The authorization page's
27//! table (SEP-2468, RFC 9207 §2.4) decides it: a server that sets
28//! `authorization_response_iss_parameter_supported: true` and then omits `iss`
29//! is refused, and a server that does not advertise it may omit `iss`. A
30//! present `iss` is compared either way.
31//! <https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#authorization-response-validation>
32//! [`check_response_issuer`] is the not-advertised row, kept for callers that
33//! have no metadata to read the flag from; a client that discovered the server
34//! calls [`check_response_issuer_advertised`].
35
36use crate::domain::mcp::authorization::AuthorizationModel;
37
38/// Why an authorization response was refused.
39///
40/// Owned strings rather than borrows: the caller has `&str` for both issuers and
41/// has no reason to be told it must keep them alive to read the error.
42#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
43pub enum AuthorizationResponseError {
44    /// The response named an issuer other than the one the client asked.
45    ///
46    /// RFC 9207 §2.1: the client "MUST reject the authorization response and
47    /// MUST NOT proceed with the authorization grant". The classic mix-up —
48    /// the code and the token endpoint now belong to different servers.
49    #[error(
50        "the authorization response names issuer `{presented}`, but this client asked `{expected}`"
51    )]
52    IssuerMismatch {
53        /// The issuer the client recorded when it started the flow.
54        expected: String,
55        /// The issuer the response claimed.
56        presented: String,
57    },
58
59    /// The server advertised `authorization_response_iss_parameter_supported`
60    /// and the response carried no `iss`.
61    ///
62    /// RFC 9207 §2.4: such a server must always send it, so a response
63    /// without one did not come from the server the client asked.
64    #[error("the authorization server advertised `iss` and the response omitted it")]
65    IssuerMissing,
66}
67
68/// Check an authorization response's `iss` against the issuer the client asked.
69///
70/// The not-advertised row: returns `Ok(())` for a response that carries no
71/// `iss`, and for one that
72/// carries the expected issuer. Anything else is a mix-up attempt and the
73/// client must not proceed with the grant.
74///
75/// The comparison is RFC 9207 §2.1's simple string comparison — see the module
76/// documentation for why it is not a URI equality.
77///
78/// # Examples
79///
80/// ```
81/// use protocol_droid::domain::ProtocolVersion;
82/// use protocol_droid::domain::mcp::authorization::authorization_model;
83/// use protocol_droid::domain::oauth::check_response_issuer;
84///
85/// let model = authorization_model(ProtocolVersion::V2026_07_28);
86/// let expected = "https://auth.example.com";
87///
88/// // Absent is tolerated; SEP-2468 makes the obligation validate-a-present-one.
89/// assert!(check_response_issuer(&model, expected, None).is_ok());
90/// assert!(check_response_issuer(&model, expected, Some(expected)).is_ok());
91/// assert!(check_response_issuer(&model, expected, Some("https://elsewhere")).is_err());
92/// ```
93pub fn check_response_issuer(
94    model: &AuthorizationModel,
95    expected: &str,
96    presented: Option<&str>,
97) -> Result<(), AuthorizationResponseError> {
98    if !model.issuer_parameter {
99        return Ok(());
100    }
101    match presented {
102        None => Ok(()),
103        // Simple string comparison, per §2.1. Not `eq_ignore_ascii_case`, not
104        // a normalised URI — see the module documentation.
105        Some(presented) if presented == expected => Ok(()),
106        Some(presented) => Err(AuthorizationResponseError::IssuerMismatch {
107            expected: expected.to_owned(),
108            presented: presented.to_owned(),
109        }),
110    }
111}
112
113/// [`check_response_issuer`], knowing whether the server advertised
114/// `authorization_response_iss_parameter_supported`.
115///
116/// # Errors
117///
118/// [`AuthorizationResponseError::IssuerMissing`] for an advertising server
119/// that omitted `iss`, and [`AuthorizationResponseError::IssuerMismatch`] for
120/// a present `iss` that is not the expected one.
121pub fn check_response_issuer_advertised(
122    model: &AuthorizationModel,
123    expected: &str,
124    presented: Option<&str>,
125    advertised: bool,
126) -> Result<(), AuthorizationResponseError> {
127    if model.issuer_parameter && advertised && presented.is_none() {
128        return Err(AuthorizationResponseError::IssuerMissing);
129    }
130    check_response_issuer(model, expected, presented)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::{AuthorizationResponseError, check_response_issuer};
136    use crate::domain::ProtocolVersion;
137    use crate::domain::mcp::authorization::{AuthorizationModel, authorization_model};
138
139    const AS: &str = "https://auth.example.com";
140
141    fn model_for(version: ProtocolVersion) -> AuthorizationModel {
142        authorization_model(version)
143    }
144
145    #[test]
146    fn a_matching_issuer_is_accepted() {
147        let model = model_for(ProtocolVersion::V2026_07_28);
148        assert!(check_response_issuer(&model, AS, Some(AS)).is_ok());
149    }
150
151    /// SEP-2468: the client's obligation is to validate a *present* `iss`.
152    /// Absent is tolerated even on the revision that introduces the parameter.
153    #[test]
154    fn an_absent_issuer_is_tolerated() {
155        for version in [ProtocolVersion::V2025_11_25, ProtocolVersion::V2026_07_28] {
156            let model = model_for(version);
157            assert!(
158                check_response_issuer(&model, AS, None).is_ok(),
159                "{version:?} tolerates an absent iss"
160            );
161        }
162    }
163
164    /// The mix-up. A response from a different server is fatal even though the
165    /// code and the flow are otherwise well formed.
166    #[test]
167    fn a_different_issuer_is_rejected() {
168        let model = model_for(ProtocolVersion::V2026_07_28);
169        let outcome = check_response_issuer(&model, AS, Some("https://auth.attacker.example"));
170        assert_eq!(
171            outcome,
172            Err(AuthorizationResponseError::IssuerMismatch {
173                expected: String::from(AS),
174                presented: String::from("https://auth.attacker.example"),
175            })
176        );
177    }
178
179    /// RFC 9207 §2.1 requires *simple string comparison*. Each of these differs
180    /// from `AS` only under a normalisation rule, and each must be rejected —
181    /// accepting one is what lets an impostor through.
182    #[test]
183    fn normalisation_does_not_make_a_different_issuer_match() {
184        let model = model_for(ProtocolVersion::V2026_07_28);
185        for presented in [
186            "https://auth.example.com/",
187            "https://AUTH.EXAMPLE.COM",
188            "https://auth.example.com:443",
189            "http://auth.example.com",
190            " https://auth.example.com",
191            "https://auth.example.com#",
192        ] {
193            assert!(
194                check_response_issuer(&model, AS, Some(presented)).is_err(),
195                "`{presented}` differs only by normalisation and must be rejected"
196            );
197        }
198    }
199
200    /// Before `2026-07-28` the parameter is nobody's obligation, so a response
201    /// is not examined for it at all — including when it is present and wrong.
202    #[test]
203    fn a_revision_that_does_not_require_the_parameter_does_not_check_it() {
204        for version in [
205            ProtocolVersion::V2024_11_05,
206            ProtocolVersion::V2025_03_26,
207            ProtocolVersion::V2025_06_18,
208            ProtocolVersion::V2025_11_25,
209        ] {
210            let model = model_for(version);
211            assert!(
212                !model.issuer_parameter,
213                "{version:?} must not require issuer validation"
214            );
215            assert!(
216                check_response_issuer(&model, AS, Some("https://auth.attacker.example")).is_ok(),
217                "{version:?} does not inspect iss"
218            );
219        }
220    }
221}