protocol_droid/application/oauth/registration/mod.rs
1//! Choosing a client registration mechanism and carrying it out.
2//!
3//! MCP changed its mind about this twice. `2025-03-26` and `2025-06-18` name
4//! RFC 7591 and nothing else; `2025-11-25` adds Client ID Metadata Documents as
5//! *recommended* without deprecating RFC 7591; `2026-07-28` deprecates RFC 7591
6//! "in favor of" documents while keeping it for servers that implement nothing
7//! else. Which of those a client must do is a pure function of the negotiated
8//! revision, and it lives in
9//! [`ClientRegistration`](crate::domain::mcp::authorization::ClientRegistration).
10//! This module is what acts on it.
11//!
12//! # Why planning is separate from doing
13//!
14//! [`plan_registration`] decides, and returns a value saying what it decided
15//! and why. [`acquire_client`] then does it. The split is not ceremony: a
16//! client that has to make this choice at the moment it is holding an HTTP
17//! response has no way to log what it chose or to refuse before it opens a
18//! socket. Concretely, `2026-07-28` §8 makes `application_type` a **MUST** for
19//! dynamic registration, and a client that discovers that after the POST has
20//! already registered a client the server will not honour.
21//!
22//! # What is not trusted
23//!
24//! Two things arrive from outside and neither is checked here, because both are
25//! checks that belong with the thing that fetched it. A registration response
26//! is the authorization server telling the client what it called it; the
27//! adapter turns that into a [`RegisteredClient`] and refuses a `client_id`
28//! that is a metadata document URL when dynamic registration was used. A
29//! metadata document is the client publishing its own identity at a URL; the
30//! adapter verifies the document's `client_id` is the URL it was served from,
31//! because a document that claims to be some *other* client's is a forgery and
32//! nothing downstream can detect it.
33//!
34//! What this module does guarantee is that neither result is ever issued by the
35//! wrong issuer, because [`RegisteredClient`] carries its issuer and
36//! [`acquire_client`] builds every one it returns itself.
37
38use serde::{Deserialize, Serialize};
39use serde_json::{Map, Value};
40
41mod plan;
42mod ports;
43
44#[cfg(test)]
45mod tests;
46
47// The ports and the use case are separate modules because they are separate
48// questions: `plan_registration` decides whether a client can be registered,
49// and the traits say what a deployment has to offer for that to happen. The
50// public items are re-exported here so that
51// `application::oauth::registration::DynamicRegistrar` still means what it
52// did when the module was one file.
53pub use plan::{acquire_client, plan_registration};
54pub use ports::{Available, DynamicRegistrar, MetadataDocumentLoader};
55
56use crate::domain::oauth::registration::{ClientId, ClientIdError, RegisteredClient};
57
58/// The metadata a client presents to register.
59///
60/// This is RFC 7591 §2's client metadata document, and MCP adds one requirement
61/// on top: `2026-07-28` §8 (SEP-837) makes `application_type` a **MUST** for
62/// dynamic registration "to avoid `OpenID Connect` redirect URI conflicts".
63///
64/// # Unknown fields
65///
66/// [`extra`](Self::extra) holds anything not named here and is merged into the
67/// serialized document. The named fields are the ones this crate has to read or
68/// gate on; a client that publishes `client_uri`, `icon_uri` or `title` —
69/// all of which the newer revisions name — sets them through
70/// [`with_field`](Self::with_field) rather than losing them at a struct
71/// boundary. Preserving them matters for the same reason it matters on the IR:
72/// the authorization server sees the document this client published, and
73/// silently dropping a field turns a working registration into a failing one.
74#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
75pub struct ClientMetadata {
76 redirect_uris: Vec<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 client_name: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 token_endpoint_auth_method: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 application_type: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 scope: Option<String>,
85 #[serde(flatten)]
86 extra: Map<String, Value>,
87}
88
89impl ClientMetadata {
90 /// Metadata with the redirect URIs it will be authorized to use.
91 ///
92 /// RFC 7591 §2 makes `redirect_uris` the one required member of a client
93 /// metadata document, so this constructor refuses an empty list rather than
94 /// letting a server reject it.
95 ///
96 /// # Errors
97 ///
98 /// [`ClientIdError::NotHttps`] if a redirect URI is not an absolute `https`
99 /// URL, or [`ClientIdError::DocumentRegistration`] if the list is empty.
100 pub fn new(redirect_uris: Vec<String>) -> Result<Self, ClientIdError> {
101 if redirect_uris.is_empty() {
102 return Err(ClientIdError::DocumentRegistration);
103 }
104 for uri in &redirect_uris {
105 // A native-app loopback redirect is `http://127.0.0.1` and is
106 // legal in RFC 8252 §7.3, so the check is absolute-and-known-scheme
107 // rather than https-always. What is refused is a bare path, which
108 // is what a half-filled configuration produces.
109 let Ok(parsed) = url::Url::parse(uri) else {
110 return Err(ClientIdError::NotHttps);
111 };
112 if !matches!(parsed.scheme(), "https" | "http") || parsed.host_str().is_none() {
113 return Err(ClientIdError::NotHttps);
114 }
115 }
116 Ok(Self {
117 redirect_uris,
118 ..Self::default()
119 })
120 }
121
122 /// The redirect URIs, in the order they were given.
123 #[must_use]
124 pub fn redirect_uris(&self) -> &[String] {
125 &self.redirect_uris
126 }
127
128 /// The name this client presents at a consent screen.
129 #[must_use]
130 pub fn client_name(&self) -> Option<&str> {
131 self.client_name.as_deref()
132 }
133
134 /// Set the consent-screen name.
135 #[must_use]
136 pub fn with_client_name(mut self, name: impl Into<String>) -> Self {
137 self.client_name = Some(name.into());
138 self
139 }
140
141 /// How this client authenticates at the token endpoint.
142 ///
143 /// `"none"` declares a public client, which is what a Client ID Metadata
144 /// Document client is: the draft is explicit that no shared secret can be
145 /// established for that form.
146 #[must_use]
147 pub fn token_endpoint_auth_method(&self) -> Option<&str> {
148 self.token_endpoint_auth_method.as_deref()
149 }
150
151 /// Set the token endpoint authentication method.
152 #[must_use]
153 pub fn with_token_endpoint_auth_method(mut self, method: impl Into<String>) -> Self {
154 self.token_endpoint_auth_method = Some(method.into());
155 self
156 }
157
158 /// The OIDC `application_type` this client is.
159 #[must_use]
160 pub fn application_type(&self) -> Option<&str> {
161 self.application_type.as_deref()
162 }
163
164 /// Set the OIDC `application_type`.
165 #[must_use]
166 pub fn with_application_type(mut self, application_type: impl Into<String>) -> Self {
167 self.application_type = Some(application_type.into());
168 self
169 }
170
171 /// Set one field this crate does not name, preserving it on the wire.
172 ///
173 /// Takes precedence over a named field of the same name, so a caller cannot
174 /// set `redirect_uris` through here and have it win over the typed value —
175 /// that would let a document claim redirects the client never checked.
176 ///
177 /// [`ClientMetadataError::ReservedField`] names the fields that are typed.
178 pub fn with_field(
179 mut self,
180 name: impl Into<String>,
181 value: impl Into<Value>,
182 ) -> Result<Self, ClientMetadataError> {
183 let name = name.into();
184 if RESERVED_FIELDS.contains(&name.as_str()) {
185 return Err(ClientMetadataError::ReservedField(name));
186 }
187 self.extra.insert(name, value.into());
188 Ok(self)
189 }
190
191 /// The fields this crate does not name, as they will be serialized.
192 #[must_use]
193 pub const fn extra(&self) -> &Map<String, Value> {
194 &self.extra
195 }
196}
197
198/// The fields [`ClientMetadata`] models, and so cannot be set through
199/// [`ClientMetadata::with_field`].
200const RESERVED_FIELDS: &[&str] = &[
201 "redirect_uris",
202 "client_name",
203 "token_endpoint_auth_method",
204 "application_type",
205 "scope",
206];
207
208/// A [`ClientMetadata`] field name is one this crate models.
209#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
210pub enum ClientMetadataError {
211 /// A field this crate models was set through the passthrough.
212 #[error("'{0}' is a modelled client metadata field and cannot be set as an extra")]
213 ReservedField(String),
214}
215
216/// What this client is willing to identify itself with.
217///
218/// A client may have both: a published metadata document *and* the metadata to
219/// fall back on. That is the case `2026-07-28` describes — a server may still
220/// implement only RFC 7591 — and it is the reason this is a pair of optionals
221/// rather than an enum.
222#[derive(Debug, Clone, PartialEq, Eq, Default)]
223pub struct ClientIdentity {
224 document: Option<ClientId>,
225 metadata: Option<ClientMetadata>,
226}
227
228impl ClientIdentity {
229 /// A client identified by a published metadata document, with nothing to
230 /// fall back on.
231 #[must_use]
232 pub const fn from_document(client_id: ClientId) -> Self {
233 Self {
234 document: Some(client_id),
235 metadata: None,
236 }
237 }
238
239 /// A client that will register dynamically, with no document.
240 #[must_use]
241 pub const fn from_metadata(metadata: ClientMetadata) -> Self {
242 Self {
243 document: None,
244 metadata: Some(metadata),
245 }
246 }
247
248 /// Add metadata to fall back on if the document cannot be used.
249 #[must_use]
250 pub fn with_metadata_fallback(mut self, metadata: ClientMetadata) -> Self {
251 self.metadata = Some(metadata);
252 self
253 }
254
255 /// The published document, if there is one.
256 #[must_use]
257 pub const fn document(&self) -> Option<&ClientId> {
258 self.document.as_ref()
259 }
260
261 /// The metadata to register with, if there is any.
262 #[must_use]
263 pub const fn metadata(&self) -> Option<&ClientMetadata> {
264 self.metadata.as_ref()
265 }
266}
267
268/// What [`plan_registration`] decided.
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub enum RegistrationPlan {
271 /// Use this credential; nothing needs to be fetched.
272 ///
273 /// The stored value is returned rather than re-registered. That is the
274 /// whole point of a stored credential: `2026-07-28` §9 binds it to its
275 /// issuer precisely so it can be reused.
276 Reuse(RegisteredClient),
277
278 /// Fetch the metadata document at this URL.
279 ///
280 /// `fallback` is what to register dynamically with if the fetch fails and
281 /// the revision still allows RFC 7591. It is `None` when the client has no
282 /// metadata to fall back on, or when the revision does not permit dynamic
283 /// registration at all.
284 FetchDocument {
285 /// The URL the document is published at.
286 client_id: ClientId,
287 /// What to register with instead, if the fetch fails.
288 fallback: Option<ClientMetadata>,
289 },
290
291 /// Register dynamically with this metadata.
292 RegisterDynamic {
293 /// The metadata to present.
294 metadata: ClientMetadata,
295 },
296}
297
298/// Why a client could not be registered.
299#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
300pub enum RegistrationError {
301 /// The revision names no registration mechanism.
302 ///
303 /// `2024-11-05`'s vendored changelog has no authorization entry at all, so
304 /// a client that registers against an OAuth server here is doing something
305 /// the revision never asked for. Refusing is louder than proceeding, and a
306 /// server that would have accepted it is not evidence the client was right.
307 #[error("this revision specifies no client registration mechanism")]
308 NoMechanism,
309
310 /// The client has a published document the revision cannot use.
311 #[error("this revision supports neither client id metadata documents nor dynamic registration")]
312 NoUsableMechanism,
313
314 /// `2026-07-28` §8 requires `application_type` for dynamic registration.
315 ///
316 /// Refused before anything is sent, because the alternative is registering
317 /// a client the server will not honour.
318 #[error(
319 "this revision requires application_type on dynamic registration and the metadata does not set it"
320 )]
321 ApplicationTypeRequired,
322
323 /// A stored credential was issued by another issuer.
324 #[error(
325 "the stored client was issued by a different authorization server and this revision requires issuer binding"
326 )]
327 IssuerBound,
328
329 /// The document could not be used and no fallback was available.
330 #[error("the client id metadata document could not be fetched: {0}")]
331 DocumentUnavailable(#[from] RegistrarError),
332
333 /// Dynamic registration failed.
334 #[error("dynamic client registration failed: {0}")]
335 DynamicFailed(RegistrarError),
336}
337
338/// Why a registration request did not produce a client.
339///
340/// Transport-neutral on purpose: the adapter maps its HTTP client and its JSON
341/// parser into these, and nothing in `application/` names either.
342#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
343pub enum RegistrarError {
344 /// The request could not be completed.
345 #[error("the registration request could not be completed: {0}")]
346 Unavailable(String),
347
348 /// The server answered with a non-success status.
349 #[error("the authorization server answered {status} to a client registration request")]
350 Refused {
351 /// The HTTP status the server returned.
352 status: u16,
353 },
354
355 /// The response was not a registration response this crate can use.
356 #[error("the registration response could not be used: {0}")]
357 Malformed(String),
358
359 /// A metadata document failed the check that makes it usable.
360 #[error(transparent)]
361 Document(#[from] ClientIdError),
362}