protocol_droid/domain/mcp/authorization.rs
1//! What each revision's authorization spec asks of the two sides.
2//!
3//! MCP's authorization model changed three times between `2025-03-26` and
4//! `2026-07-28`, and the changes are not additive: `2026-07-28` *deprecates*
5//! Dynamic Client Registration rather than extending it. So this is a table of
6//! facts per revision rather than a set of feature bits, and
7//! [`authorization_model`] is the one place a row is read.
8//!
9//! Every row below cites the vendored changelog that introduced it, under
10//! `crates/protocol-droid-schema/spec/<version>/CHANGELOG.md`. The vendored
11//! `schema.json` is not the source for any of them: none of these facts is a
12//! message field, so none of them appears in a schema. They are spec
13//! *prose*, and a row with no citation is a row nobody checked.
14//!
15//! # Why this is in `domain/`
16//!
17//! Every value here is a pure function of the negotiated revision. Which
18//! client registration mechanism to try, whether a `WWW-Authenticate` header
19//! should carry `resource_metadata`, and whether a presented token must be
20//! audience-checked are all decidable before any socket exists — and all of
21//! them are security decisions, so they belong where a deployment cannot
22//! override them with an adapter's opinion.
23
24use crate::domain::ProtocolVersion;
25
26/// How a client is expected to register with an authorization server.
27///
28/// Not a bitmask because the three states are a progression, not a set: a
29/// client that treats `2026-07-28` as "both work" would send a Dynamic Client
30/// Registration request to a server that has been told to deprecate it, and a
31/// server that rejects it looks like a broken server rather than a wrong client.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
33pub enum ClientRegistration {
34 /// The revision specifies no authorization framework at all.
35 ///
36 /// `2024-11-05`. Its vendored changelog has no authorization entry, so a
37 /// client that registers against an OAuth server is doing something the
38 /// revision never asked for.
39 #[default]
40 None,
41 /// RFC 7591 Dynamic Client Registration.
42 ///
43 /// `2025-03-26` and `2025-06-18`. The framework was introduced here
44 /// (`2025-03-26` changelog §1, PR #133) with no alternative registration
45 /// mechanism named, so this is what those revisions' servers implement.
46 Dynamic,
47 /// Both, with Client ID Metadata Documents *recommended*.
48 ///
49 /// `2025-11-25` changelog §8 (SEP-991, PR #1296) adds Client ID Metadata
50 /// Documents as a recommended registration mechanism and does not deprecate
51 /// Dynamic Client Registration, so a client may use either. Prefer
52 /// [`Self::preferred`] over "either" when choosing.
53 DynamicOrMetadataDocument,
54 /// Client ID Metadata Documents; Dynamic Client Registration deprecated.
55 ///
56 /// `2026-07-28` changelog §4 (PR #2858) deprecates RFC 7591 "in favor of"
57 /// Client ID Metadata Documents, keeping it only "for backwards
58 /// compatibility with authorization servers that do not support" them. So
59 /// this is a preference with a fallback, not a removal — see
60 /// [`Self::supports_dynamic`].
61 MetadataDocument,
62}
63
64impl ClientRegistration {
65 /// Whether a client may register through RFC 7591.
66 ///
67 /// `true` even for [`Self::MetadataDocument`]: the deprecation is a
68 /// statement about what the spec prefers, and the spec keeps the mechanism
69 /// for servers that do not implement the replacement. A client that
70 /// refused outright would fail against those servers, and a server that
71 /// implements only RFC 7591 is not misconfigured.
72 #[must_use]
73 pub const fn supports_dynamic(self) -> bool {
74 matches!(
75 self,
76 Self::Dynamic | Self::DynamicOrMetadataDocument | Self::MetadataDocument
77 )
78 }
79
80 /// Whether a client may register with a Client ID Metadata Document.
81 #[must_use]
82 pub const fn supports_metadata_document(self) -> bool {
83 matches!(
84 self,
85 Self::DynamicOrMetadataDocument | Self::MetadataDocument
86 )
87 }
88
89 /// The mechanism to try first.
90 ///
91 /// Metadata documents wherever they exist, because a client that leads with
92 /// the mechanism its own revision deprecates is doing the wrong thing
93 /// against a server that supports both. Falls back to dynamic registration
94 /// only where documents are not defined, so this never returns
95 /// [`Self::None`] for a revision that can register at all.
96 #[must_use]
97 pub const fn preferred(self) -> Option<Self> {
98 match self {
99 Self::None => None,
100 Self::Dynamic => Some(Self::Dynamic),
101 Self::DynamicOrMetadataDocument | Self::MetadataDocument => {
102 Some(Self::MetadataDocument)
103 }
104 }
105 }
106
107 /// What to try after `self` did not work, or `None` at the end of the list.
108 ///
109 /// The second half of [`Self::preferred`]: a client has to be able to fall
110 /// back, and [`Self::MetadataDocument`] is precisely the revision whose
111 /// servers may still implement only RFC 7591.
112 #[must_use]
113 pub const fn fallback(self) -> Option<Self> {
114 match self {
115 Self::DynamicOrMetadataDocument | Self::MetadataDocument => Some(Self::Dynamic),
116 Self::Dynamic | Self::None => None,
117 }
118 }
119
120 /// Whether `application_type` must be sent during registration.
121 ///
122 /// `2026-07-28` changelog §8 (SEP-837): clients "**MUST** specify an
123 /// appropriate `application_type` during Dynamic Client Registration to
124 /// avoid `OpenID Connect` redirect URI conflicts". A `MUST`, so this is
125 /// about revisions that require the field, not about ones that tolerate it.
126 #[must_use]
127 pub const fn application_type_required(self) -> bool {
128 matches!(self, Self::MetadataDocument)
129 }
130}
131
132/// What a revision's authorization spec requires of the two sides.
133///
134/// Built by [`authorization_model`] and read by the resource-server and client
135/// paths. `Copy` so it can be held on a session without a reference, which
136/// matters because every request handler consults it.
137///
138/// # Examples
139///
140/// ```
141/// use protocol_droid::domain::mcp::authorization::{
142/// ClientRegistration, authorization_model,
143/// };
144/// use protocol_droid::domain::ProtocolVersion;
145///
146/// // 2026-07-28 deprecated RFC 7591 in favour of client id metadata documents.
147/// assert_eq!(
148/// authorization_model(ProtocolVersion::V2026_07_28).client_registration,
149/// ClientRegistration::MetadataDocument,
150/// );
151/// ```
152#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
153pub struct AuthorizationModel {
154 /// The revision defines an authorization framework.
155 ///
156 /// `false` only for `2024-11-05`, whose vendored changelog contains no
157 /// authorization entry at all. Everything else here is meaningless when this
158 /// is `false`, and a caller that has not checked it will advertise a
159 /// protected-resource document to a peer whose revision never defined one.
160 pub specified: bool,
161
162 /// The server is an OAuth resource server and publishes protected-resource
163 /// metadata naming its authorization servers.
164 ///
165 /// `2025-06-18` changelog §3 (PR #338): "Classify MCP servers as OAuth
166 /// Resource Servers, adding protected resource metadata to discover the
167 /// corresponding Authorization server." Absent from `2025-03-26`, whose
168 /// framework predates the classification.
169 pub protected_resource_metadata: bool,
170
171 /// A client **MUST** send the resource indicator (RFC 8707).
172 ///
173 /// `2025-06-18` changelog §4 (PR #734): "Require MCP clients to implement
174 /// Resource Indicators as described in RFC 8707 to prevent malicious
175 /// servers from obtaining access tokens." A `MUST` on the client, and the
176 /// threat is concrete: without it, a token minted for one server can be
177 /// replayed at another.
178 pub resource_indicators_required: bool,
179
180 /// Authorization-server discovery may use `OpenID Connect Discovery 1.0`.
181 ///
182 /// `2025-11-25` changelog §1 (PR #797). An *enhancement* rather than a
183 /// replacement — `/.well-known/oauth-authorization-server` still works —
184 /// so this gates whether the client *offers* the other form, not which one
185 /// it requires.
186 pub oidc_discovery: bool,
187
188 /// `WWW-Authenticate` may carry incremental scope consent.
189 ///
190 /// `2025-11-25` changelog §3 (SEP-835). Lets a server name the exact scope
191 /// it wants instead of rejecting wholesale, so a client that does not read
192 /// it still works — it just re-prompts for everything.
193 pub incremental_scope_consent: bool,
194
195 /// A client **MUST** validate an `iss` parameter when the server sends one.
196 ///
197 /// `2026-07-28` changelog §7 (SEP-2468): authorization servers "**SHOULD**
198 /// include the `iss` parameter in authorization responses per RFC 9207, and
199 /// MCP clients **MUST** validate a present `iss` against the recorded
200 /// issuer before redeeming the authorization code". So the server's
201 /// obligation is a `SHOULD` and the client's is a `MUST` — absent `iss` is
202 /// tolerated, present-and-wrong is fatal. Modelled as one row rather than
203 /// two because the client's obligation is what code has to implement.
204 pub issuer_parameter: bool,
205
206 /// Persisted client credentials are keyed by issuer and never reused across
207 /// authorization servers.
208 ///
209 /// `2026-07-28` changelog §9 (SEP-2352): clients "**MUST** key persisted
210 /// credentials by the issuer identifier, **MUST NOT** reuse them with a
211 /// different authorization server, and **MUST** re-register when the
212 /// authorization server changes". The first clause is why this is a domain
213 /// fact and not a storage detail: the key is part of the contract.
214 pub credentials_bound_to_issuer: bool,
215
216 /// How a client registers. See [`ClientRegistration`].
217 pub client_registration: ClientRegistration,
218}
219
220impl AuthorizationModel {
221 /// Whether a token presented to this revision's server must be audience-
222 /// bound to this server's canonical resource identifier.
223 ///
224 /// True for every revision with an authorization framework, and `true`
225 /// regardless of [`Self::protected_resource_metadata`] — the metadata
226 /// document is how a *client* learns the audience, and refusing a
227 /// wrong-audience token is a separate rule that does not depend on the
228 /// client having done that discovery. `2024-11-05` is the only revision with
229 /// no authorization framework at all, and a token check with no audience
230 /// concept behind it is not a weaker check, it is a different one that this
231 /// crate does not implement.
232 #[must_use]
233 pub const fn audience_bound(self) -> bool {
234 self.specified
235 }
236}
237
238/// What `version`'s authorization spec requires.
239///
240/// A pure function of the revision, so it is testable per row without a socket
241/// and cannot drift from the revision it was written against.
242///
243/// # Examples
244///
245/// ```
246/// use protocol_droid::domain::mcp::authorization::authorization_model;
247/// use protocol_droid::domain::ProtocolVersion;
248///
249/// // 2024-11-05 never defined an authorization framework.
250/// assert!(!authorization_model(ProtocolVersion::V2024_11_05).specified);
251///
252/// // 2025-03-26 introduced OAuth 2.1 but not the resource-server
253/// // classification, so it has no protected-resource metadata.
254/// let m = authorization_model(ProtocolVersion::V2025_03_26);
255/// assert!(m.specified);
256/// assert!(!m.protected_resource_metadata);
257/// ```
258#[must_use]
259pub const fn authorization_model(version: ProtocolVersion) -> AuthorizationModel {
260 // Defaulted to the `2024-11-05` row: everything below then *turns on*
261 // rather than off, so a revision added to this match without a row here
262 // degrades to "no authorization framework" instead of silently inheriting
263 // `2026-07-28`'s requirements. That is the right direction for a security
264 // field — a revision this build has not read about gets no behaviour it
265 // cannot justify, and the failure is visible rather than permissive.
266 let mut model = AuthorizationModel {
267 specified: false,
268 protected_resource_metadata: false,
269 resource_indicators_required: false,
270 oidc_discovery: false,
271 incremental_scope_consent: false,
272 issuer_parameter: false,
273 credentials_bound_to_issuer: false,
274 client_registration: ClientRegistration::None,
275 };
276
277 match version {
278 // Both of these turn nothing on, and saying so in one arm is the honest
279 // shape: `2024-11-05`'s changelog has no authorization entry at all, and
280 // an `Unknown` revision's spec is one this build has not read. Written
281 // out rather than left to the default so a reader diffing this table
282 // against the changelogs can see that the absence is a decision.
283 ProtocolVersion::V2024_11_05 | ProtocolVersion::Unknown(_) => {}
284 // `2025-03-26` changelog §1 (PR #133): "Added a comprehensive
285 // authorization framework based on OAuth 2.1." No resource-server
286 // classification (§3) and no resource indicators (§4) — both arrive in
287 // `2025-06-18`.
288 ProtocolVersion::V2025_03_26 => {
289 model.specified = true;
290 model.client_registration = ClientRegistration::Dynamic;
291 }
292 // `2025-06-18` changelog §3 (PR #338) adds protected-resource metadata
293 // and §4 (PR #734) makes resource indicators mandatory. §5 (PR #338
294 // family) clarifies security considerations but adds no behaviour.
295 ProtocolVersion::V2025_06_18 => {
296 model.specified = true;
297 model.protected_resource_metadata = true;
298 model.resource_indicators_required = true;
299 model.client_registration = ClientRegistration::Dynamic;
300 }
301 // `2025-11-25` changelog §1 (PR #797) adds OpenID Connect Discovery,
302 // §3 (SEP-835) adds incremental scope consent, and §8 (SEP-991,
303 // PR #1296) adds Client ID Metadata Documents as a *recommended*
304 // registration mechanism alongside RFC 7591.
305 ProtocolVersion::V2025_11_25 => {
306 model.specified = true;
307 model.protected_resource_metadata = true;
308 model.resource_indicators_required = true;
309 model.oidc_discovery = true;
310 model.incremental_scope_consent = true;
311 model.client_registration = ClientRegistration::DynamicOrMetadataDocument;
312 }
313 // `2026-07-28` changelog §7 (SEP-2468) adds the `iss` requirement,
314 // §8 (SEP-837) requires `application_type`, §9 (SEP-2352) binds
315 // credentials to the issuer, and §4 (PR #2858) deprecates RFC 7591 in
316 // favour of Client ID Metadata Documents. Nothing is withdrawn from
317 // `2025-11-25`: every row carries forward.
318 ProtocolVersion::V2026_07_28 => {
319 model.specified = true;
320 model.protected_resource_metadata = true;
321 model.resource_indicators_required = true;
322 model.oidc_discovery = true;
323 model.incremental_scope_consent = true;
324 model.issuer_parameter = true;
325 model.credentials_bound_to_issuer = true;
326 model.client_registration = ClientRegistration::MetadataDocument;
327 }
328 }
329
330 model
331}
332
333#[cfg(test)]
334#[path = "authorization/tests.rs"]
335mod tests;