Skip to main content

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;