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}