Skip to main content

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}