protocol_droid/application/oauth/registration.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`].
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
41use crate::domain::mcp::authorization::{AuthorizationModel, ClientRegistration};
42use crate::domain::oauth::registration::{
43 ClientId, ClientIdError, RegisteredClient, RegistrationDecision, decide_registration,
44};
45
46/// The metadata a client presents to register.
47///
48/// This is RFC 7591 §2's client metadata document, and MCP adds one requirement
49/// on top: `2026-07-28` §8 (SEP-837) makes `application_type` a **MUST** for
50/// dynamic registration "to avoid `OpenID Connect` redirect URI conflicts".
51///
52/// # Unknown fields
53///
54/// [`extra`](Self::extra) holds anything not named here and is merged into the
55/// serialized document. The named fields are the ones this crate has to read or
56/// gate on; a client that publishes `client_uri`, `icon_uri` or `title` —
57/// all of which the newer revisions name — sets them through
58/// [`with_field`](Self::with_field) rather than losing them at a struct
59/// boundary. Preserving them matters for the same reason it matters on the IR:
60/// the authorization server sees the document this client published, and
61/// silently dropping a field turns a working registration into a failing one.
62#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
63pub struct ClientMetadata {
64 redirect_uris: Vec<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 client_name: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 token_endpoint_auth_method: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 application_type: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 scope: Option<String>,
73 #[serde(flatten)]
74 extra: Map<String, Value>,
75}
76
77impl ClientMetadata {
78 /// Metadata with the redirect URIs it will be authorized to use.
79 ///
80 /// RFC 7591 §2 makes `redirect_uris` the one required member of a client
81 /// metadata document, so this constructor refuses an empty list rather than
82 /// letting a server reject it.
83 ///
84 /// # Errors
85 ///
86 /// [`ClientIdError::NotHttps`] if a redirect URI is not an absolute `https`
87 /// URL, or [`ClientIdError::DocumentRegistration`] if the list is empty.
88 pub fn new(redirect_uris: Vec<String>) -> Result<Self, ClientIdError> {
89 if redirect_uris.is_empty() {
90 return Err(ClientIdError::DocumentRegistration);
91 }
92 for uri in &redirect_uris {
93 // A native-app loopback redirect is `http://127.0.0.1` and is
94 // legal in RFC 8252 §7.3, so the check is absolute-and-known-scheme
95 // rather than https-always. What is refused is a bare path, which
96 // is what a half-filled configuration produces.
97 let Ok(parsed) = url::Url::parse(uri) else {
98 return Err(ClientIdError::NotHttps);
99 };
100 if !matches!(parsed.scheme(), "https" | "http") || parsed.host_str().is_none() {
101 return Err(ClientIdError::NotHttps);
102 }
103 }
104 Ok(Self {
105 redirect_uris,
106 ..Self::default()
107 })
108 }
109
110 /// The redirect URIs, in the order they were given.
111 #[must_use]
112 pub fn redirect_uris(&self) -> &[String] {
113 &self.redirect_uris
114 }
115
116 /// The name this client presents at a consent screen.
117 #[must_use]
118 pub fn client_name(&self) -> Option<&str> {
119 self.client_name.as_deref()
120 }
121
122 /// Set the consent-screen name.
123 #[must_use]
124 pub fn with_client_name(mut self, name: impl Into<String>) -> Self {
125 self.client_name = Some(name.into());
126 self
127 }
128
129 /// How this client authenticates at the token endpoint.
130 ///
131 /// `"none"` declares a public client, which is what a Client ID Metadata
132 /// Document client is: the draft is explicit that no shared secret can be
133 /// established for that form.
134 #[must_use]
135 pub fn token_endpoint_auth_method(&self) -> Option<&str> {
136 self.token_endpoint_auth_method.as_deref()
137 }
138
139 /// Set the token endpoint authentication method.
140 #[must_use]
141 pub fn with_token_endpoint_auth_method(mut self, method: impl Into<String>) -> Self {
142 self.token_endpoint_auth_method = Some(method.into());
143 self
144 }
145
146 /// The OIDC `application_type` this client is.
147 #[must_use]
148 pub fn application_type(&self) -> Option<&str> {
149 self.application_type.as_deref()
150 }
151
152 /// Set the OIDC `application_type`.
153 #[must_use]
154 pub fn with_application_type(mut self, application_type: impl Into<String>) -> Self {
155 self.application_type = Some(application_type.into());
156 self
157 }
158
159 /// Set one field this crate does not name, preserving it on the wire.
160 ///
161 /// Takes precedence over a named field of the same name, so a caller cannot
162 /// set `redirect_uris` through here and have it win over the typed value —
163 /// that would let a document claim redirects the client never checked.
164 ///
165 /// [`ClientMetadataError::ReservedField`] names the fields that are typed.
166 pub fn with_field(
167 mut self,
168 name: impl Into<String>,
169 value: impl Into<Value>,
170 ) -> Result<Self, ClientMetadataError> {
171 let name = name.into();
172 if RESERVED_FIELDS.contains(&name.as_str()) {
173 return Err(ClientMetadataError::ReservedField(name));
174 }
175 self.extra.insert(name, value.into());
176 Ok(self)
177 }
178
179 /// The fields this crate does not name, as they will be serialized.
180 #[must_use]
181 pub const fn extra(&self) -> &Map<String, Value> {
182 &self.extra
183 }
184}
185
186/// The fields [`ClientMetadata`] models, and so cannot be set through
187/// [`ClientMetadata::with_field`].
188const RESERVED_FIELDS: &[&str] = &[
189 "redirect_uris",
190 "client_name",
191 "token_endpoint_auth_method",
192 "application_type",
193 "scope",
194];
195
196/// A [`ClientMetadata`] field name is one this crate models.
197#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
198pub enum ClientMetadataError {
199 /// A field this crate models was set through the passthrough.
200 #[error("'{0}' is a modelled client metadata field and cannot be set as an extra")]
201 ReservedField(String),
202}
203
204/// What this client is willing to identify itself with.
205///
206/// A client may have both: a published metadata document *and* the metadata to
207/// fall back on. That is the case `2026-07-28` describes — a server may still
208/// implement only RFC 7591 — and it is the reason this is a pair of optionals
209/// rather than an enum.
210#[derive(Debug, Clone, PartialEq, Eq, Default)]
211pub struct ClientIdentity {
212 document: Option<ClientId>,
213 metadata: Option<ClientMetadata>,
214}
215
216impl ClientIdentity {
217 /// A client identified by a published metadata document, with nothing to
218 /// fall back on.
219 #[must_use]
220 pub const fn from_document(client_id: ClientId) -> Self {
221 Self {
222 document: Some(client_id),
223 metadata: None,
224 }
225 }
226
227 /// A client that will register dynamically, with no document.
228 #[must_use]
229 pub const fn from_metadata(metadata: ClientMetadata) -> Self {
230 Self {
231 document: None,
232 metadata: Some(metadata),
233 }
234 }
235
236 /// Add metadata to fall back on if the document cannot be used.
237 #[must_use]
238 pub fn with_metadata_fallback(mut self, metadata: ClientMetadata) -> Self {
239 self.metadata = Some(metadata);
240 self
241 }
242
243 /// The published document, if there is one.
244 #[must_use]
245 pub const fn document(&self) -> Option<&ClientId> {
246 self.document.as_ref()
247 }
248
249 /// The metadata to register with, if there is any.
250 #[must_use]
251 pub const fn metadata(&self) -> Option<&ClientMetadata> {
252 self.metadata.as_ref()
253 }
254}
255
256/// What [`plan_registration`] decided.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum RegistrationPlan {
259 /// Use this credential; nothing needs to be fetched.
260 ///
261 /// The stored value is returned rather than re-registered. That is the
262 /// whole point of a stored credential: `2026-07-28` §9 binds it to its
263 /// issuer precisely so it can be reused.
264 Reuse(RegisteredClient),
265
266 /// Fetch the metadata document at this URL.
267 ///
268 /// `fallback` is what to register dynamically with if the fetch fails and
269 /// the revision still allows RFC 7591. It is `None` when the client has no
270 /// metadata to fall back on, or when the revision does not permit dynamic
271 /// registration at all.
272 FetchDocument {
273 /// The URL the document is published at.
274 client_id: ClientId,
275 /// What to register with instead, if the fetch fails.
276 fallback: Option<ClientMetadata>,
277 },
278
279 /// Register dynamically with this metadata.
280 RegisterDynamic {
281 /// The metadata to present.
282 metadata: ClientMetadata,
283 },
284}
285
286/// Why a client could not be registered.
287#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
288pub enum RegistrationError {
289 /// The revision names no registration mechanism.
290 ///
291 /// `2024-11-05`'s vendored changelog has no authorization entry at all, so
292 /// a client that registers against an OAuth server here is doing something
293 /// the revision never asked for. Refusing is louder than proceeding, and a
294 /// server that would have accepted it is not evidence the client was right.
295 #[error("this revision specifies no client registration mechanism")]
296 NoMechanism,
297
298 /// The client has a published document the revision cannot use.
299 #[error("this revision supports neither client id metadata documents nor dynamic registration")]
300 NoUsableMechanism,
301
302 /// `2026-07-28` §8 requires `application_type` for dynamic registration.
303 ///
304 /// Refused before anything is sent, because the alternative is registering
305 /// a client the server will not honour.
306 #[error(
307 "this revision requires application_type on dynamic registration and the metadata does not set it"
308 )]
309 ApplicationTypeRequired,
310
311 /// A stored credential was issued by another issuer.
312 #[error(
313 "the stored client was issued by a different authorization server and this revision requires issuer binding"
314 )]
315 IssuerBound,
316
317 /// The document could not be used and no fallback was available.
318 #[error("the client id metadata document could not be fetched: {0}")]
319 DocumentUnavailable(#[from] RegistrarError),
320
321 /// Dynamic registration failed.
322 #[error("dynamic client registration failed: {0}")]
323 DynamicFailed(RegistrarError),
324}
325
326/// Why a registration request did not produce a client.
327///
328/// Transport-neutral on purpose: the adapter maps its HTTP client and its JSON
329/// parser into these, and nothing in `application/` names either.
330#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
331pub enum RegistrarError {
332 /// The request could not be completed.
333 #[error("the registration request could not be completed: {0}")]
334 Unavailable(String),
335
336 /// The server answered with a non-success status.
337 #[error("the authorization server answered {status} to a client registration request")]
338 Refused {
339 /// The HTTP status the server returned.
340 status: u16,
341 },
342
343 /// The response was not a registration response this crate can use.
344 #[error("the registration response could not be used: {0}")]
345 Malformed(String),
346
347 /// A metadata document failed the check that makes it usable.
348 #[error(transparent)]
349 Document(#[from] ClientIdError),
350}
351
352/// Registers a client dynamically through RFC 7591 §3.1.
353///
354/// A port of its own rather than a method on a wider trait, because a
355/// deployment may be able to do only one of the two mechanisms: a client that
356/// publishes a document and points at a server that implements only documents
357/// has no use for this, and an adapter that claimed it would be promising a
358/// capability it does not have.
359///
360/// Native `async fn`, so it is not object safe. That is the right trade:
361/// registration happens once per client rather than per request, and the call
362/// site takes `&impl DynamicRegistrar` for a zero-cost monomorphised call.
363///
364/// # Examples
365///
366/// ```
367/// use protocol_droid::application::oauth::registration::{
368/// ClientMetadata, DynamicRegistrar, RegistrarError,
369/// };
370/// use protocol_droid::domain::oauth::registration::{ClientId, RegisteredClient};
371///
372/// struct AlwaysTheSame;
373///
374/// impl DynamicRegistrar for AlwaysTheSame {
375/// async fn register_dynamic(
376/// &self,
377/// issuer: &str,
378/// _metadata: &ClientMetadata,
379/// ) -> Result<RegisteredClient, RegistrarError> {
380/// Ok(RegisteredClient::dynamic(
381/// issuer,
382/// ClientId::opaque("client-1"),
383/// None,
384/// )?)
385/// }
386/// }
387/// # Ok::<(), RegistrarError>(())
388/// ```
389pub trait DynamicRegistrar {
390 /// POST the metadata to the registration endpoint and read the credential
391 /// the server issues.
392 ///
393 /// # Errors
394 ///
395 /// [`RegistrarError`] if the request could not be completed, the server
396 /// refused it, or the response was not a usable registration response.
397 fn register_dynamic(
398 &self,
399 issuer: &str,
400 metadata: &ClientMetadata,
401 ) -> impl Future<Output = Result<RegisteredClient, RegistrarError>> + Send;
402}
403
404/// Fetches the Client ID Metadata Document a client published.
405///
406/// The counterpart to [`DynamicRegistrar`], and separate for the same reason.
407/// The two differ in what they establish as well as in what they send: this one
408/// yields no secret at all.
409///
410/// # Examples
411///
412/// ```
413/// use protocol_droid::application::oauth::registration::{
414/// MetadataDocumentLoader, RegistrarError,
415/// };
416/// use protocol_droid::domain::oauth::registration::{ClientId, RegisteredClient};
417///
418/// struct RefusesEverything;
419///
420/// impl MetadataDocumentLoader for RefusesEverything {
421/// async fn fetch_metadata_document(
422/// &self,
423/// _issuer: &str,
424/// _client_id: &ClientId,
425/// ) -> Result<RegisteredClient, RegistrarError> {
426/// Err(RegistrarError::Refused { status: 404 })
427/// }
428/// }
429/// # Ok::<(), RegistrarError>(())
430/// ```
431pub trait MetadataDocumentLoader {
432 /// Fetch the document published at `client_id` and verify what it claims.
433 ///
434 /// # Errors
435 ///
436 /// [`RegistrarError`] if the document could not be fetched, or
437 /// [`RegistrarError::Document`] if it does not claim the URL it was served
438 /// from. The two are distinct because the use case reacts differently.
439 fn fetch_metadata_document(
440 &self,
441 issuer: &str,
442 client_id: &ClientId,
443 ) -> impl Future<Output = Result<RegisteredClient, RegistrarError>> + Send;
444}
445
446/// What this deployment can actually reach.
447///
448/// A separate argument to [`plan_registration`] rather than something it reads
449/// off the identity, because the two fail independently: a client can publish
450/// a document to a URL that resolves nowhere, and a deployment can wire a
451/// dynamic registrar while its client has no metadata to present. Planning on
452/// one and discovering the other during execution is how a client ends up
453/// explaining a failure it could have refused to start.
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
455pub struct Available {
456 dynamic: bool,
457 metadata_document: bool,
458}
459
460impl Available {
461 /// Neither mechanism is reachable.
462 #[must_use]
463 pub const fn neither() -> Self {
464 Self {
465 dynamic: false,
466 metadata_document: false,
467 }
468 }
469
470 /// Only RFC 7591 dynamic registration.
471 #[must_use]
472 pub const fn dynamic_only() -> Self {
473 Self {
474 dynamic: true,
475 metadata_document: false,
476 }
477 }
478
479 /// Only Client ID Metadata Documents.
480 #[must_use]
481 pub const fn metadata_document_only() -> Self {
482 Self {
483 dynamic: false,
484 metadata_document: true,
485 }
486 }
487
488 /// Both, with a fallback available in either direction.
489 #[must_use]
490 pub const fn both() -> Self {
491 Self {
492 dynamic: true,
493 metadata_document: true,
494 }
495 }
496
497 /// Whether dynamic registration can be reached.
498 #[must_use]
499 pub const fn dynamic(self) -> bool {
500 self.dynamic
501 }
502
503 /// Whether a metadata document can be fetched.
504 #[must_use]
505 pub const fn metadata_document(self) -> bool {
506 self.metadata_document
507 }
508}
509
510/// Decide how to register, without doing it.
511///
512/// The order is: reuse a stored credential the revision lets this client reuse,
513/// then a published document, then dynamic registration. Document before
514/// dynamic is not a preference — `2026-07-28` changelog §4 deprecates RFC 7591,
515/// so leading with it against a server that supports both is a client
516/// provoking a rejection.
517///
518/// # Errors
519///
520/// [`RegistrationError::IssuerBound`] if a stored credential belongs to another
521/// issuer and the revision requires binding. [`RegistrationError::NoMechanism`]
522/// if the revision names none. [`RegistrationError::NoUsableMechanism`] if what
523/// the client has is a mechanism this revision does not define.
524/// [`RegistrationError::ApplicationTypeRequired`] if dynamic registration is
525/// planned for `2026-07-28` without `application_type` set.
526pub fn plan_registration(
527 model: &AuthorizationModel,
528 issuer: &str,
529 identity: &ClientIdentity,
530 stored: Option<&RegisteredClient>,
531 available: Available,
532) -> Result<RegistrationPlan, RegistrationError> {
533 if let Some(stored) = stored {
534 match decide_registration(model, issuer, Some(stored)) {
535 // Reused across issuers only where the revision does not require
536 // binding, and even then the returned value carries its original
537 // issuer, so nothing downstream can mistake it for this server's.
538 RegistrationDecision::Reuse => {
539 return Ok(RegistrationPlan::Reuse(stored.clone()));
540 }
541 RegistrationDecision::ReRegister => {
542 return Err(RegistrationError::IssuerBound);
543 }
544 RegistrationDecision::Register => {}
545 }
546 }
547
548 let mechanism = model.client_registration;
549 if mechanism == ClientRegistration::None {
550 return Err(RegistrationError::NoMechanism);
551 }
552
553 // A document the revision defines and this deployment can reach, with
554 // dynamic registration as the fallback exactly when both of those are also
555 // true and the client has metadata to fall back on.
556 if let (true, Some(client_id)) = (
557 mechanism.supports_metadata_document() && available.metadata_document,
558 identity.document(),
559 ) {
560 let fallback = identity
561 .metadata()
562 .filter(|_| mechanism.supports_dynamic() && available.dynamic)
563 .cloned();
564 return Ok(RegistrationPlan::FetchDocument {
565 client_id: client_id.clone(),
566 fallback,
567 });
568 }
569
570 // Otherwise dynamic registration, if both the revision and the client allow
571 // it. A document this revision cannot use is not silently ignored: if it is
572 // the only thing the client has, there is nothing left to try.
573 if let (true, Some(metadata)) = (mechanism.supports_dynamic(), identity.metadata()) {
574 if mechanism.application_type_required() && metadata.application_type().is_none() {
575 return Err(RegistrationError::ApplicationTypeRequired);
576 }
577 return Ok(RegistrationPlan::RegisterDynamic {
578 metadata: metadata.clone(),
579 });
580 }
581
582 Err(RegistrationError::NoUsableMechanism)
583}
584
585/// Plan a registration and carry it out.
586///
587/// Falls back from a document to dynamic registration when [`RegistrationPlan`]
588/// carries a fallback and the document could not be used. It does **not** fall
589/// back the other way: a client that publishes a document and cannot fetch it
590/// has no identity to present, and registering one dynamically would create a
591/// second identity rather than recover the first.
592///
593/// # Errors
594///
595/// Whatever [`plan_registration`] returns, or [`RegistrationError::DynamicFailed`]
596/// if the fallback was needed and also failed.
597///
598/// # Examples
599///
600/// ```
601/// use protocol_droid::application::oauth::registration::{
602/// ClientIdentity, ClientMetadata, acquire_client, plan_registration,
603/// };
604/// use protocol_droid::domain::ProtocolVersion;
605/// use protocol_droid::domain::mcp::authorization::authorization_model;
606/// use protocol_droid::domain::oauth::registration::ClientId;
607///
608/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
609/// use protocol_droid::application::oauth::registration::Available;
610///
611/// let model = authorization_model(ProtocolVersion::V2026_07_28);
612/// let identity = ClientIdentity::from_document(
613/// ClientId::metadata_url("https://client.example.com/metadata.json")?,
614/// );
615///
616/// // Planning is pure: the document is preferred, and the plan says so.
617/// let plan = plan_registration(
618/// &model,
619/// "https://auth.example.com",
620/// &identity,
621/// None,
622/// Available::metadata_document_only(),
623/// )?;
624/// assert!(matches!(
625/// plan,
626/// protocol_droid::application::oauth::registration::RegistrationPlan::FetchDocument { .. }
627/// ));
628///
629/// // Acting on it needs a loader to fetch with, so it is not exercised here.
630/// # Ok(())
631/// # }
632/// # Ok::<(), Box<dyn std::error::Error>>(())
633/// ```
634pub async fn acquire_client(
635 model: &AuthorizationModel,
636 issuer: &str,
637 identity: &ClientIdentity,
638 stored: Option<&RegisteredClient>,
639 dynamic: Option<&(impl DynamicRegistrar + Sync)>,
640 documents: Option<&(impl MetadataDocumentLoader + Sync)>,
641) -> Result<RegisteredClient, RegistrationError> {
642 let available = match (dynamic.is_some(), documents.is_some()) {
643 (true, true) => Available::both(),
644 (true, false) => Available::dynamic_only(),
645 (false, true) => Available::metadata_document_only(),
646 (false, false) => Available::neither(),
647 };
648
649 match plan_registration(model, issuer, identity, stored, available)? {
650 RegistrationPlan::Reuse(client) => Ok(client),
651 RegistrationPlan::FetchDocument {
652 client_id,
653 fallback,
654 } => {
655 let loader = documents.ok_or(RegistrationError::NoUsableMechanism)?;
656 match loader.fetch_metadata_document(issuer, &client_id).await {
657 Ok(client) => Ok(client),
658 // Only a document that could not be *fetched* falls back. A
659 // document that was fetched and is wrong is a forgery, and
660 // falling back from it would let a client that has been
661 // impersonated quietly register a fresh identity instead.
662 Err(failure @ RegistrarError::Document(_)) => {
663 Err(RegistrationError::DocumentUnavailable(failure))
664 }
665 Err(failure) => match fallback {
666 Some(metadata) => {
667 let registrar = dynamic.ok_or(RegistrationError::NoUsableMechanism)?;
668 registrar
669 .register_dynamic(issuer, &metadata)
670 .await
671 .map_err(RegistrationError::DynamicFailed)
672 }
673 None => Err(RegistrationError::DocumentUnavailable(failure)),
674 },
675 }
676 }
677 RegistrationPlan::RegisterDynamic { metadata } => {
678 let registrar = dynamic.ok_or(RegistrationError::NoUsableMechanism)?;
679 registrar
680 .register_dynamic(issuer, &metadata)
681 .await
682 .map_err(RegistrationError::DynamicFailed)
683 }
684 }
685}
686#[cfg(test)]
687mod tests {
688 use std::assert_matches;
689 use std::future::Future;
690 use std::sync::atomic::{AtomicU32, Ordering};
691 use std::task::{Context, Poll, Waker};
692
693 use super::{
694 Available, ClientIdentity, ClientMetadata, ClientMetadataError, DynamicRegistrar,
695 MetadataDocumentLoader, RegistrarError, RegistrationError, RegistrationPlan,
696 acquire_client, plan_registration,
697 };
698 use crate::domain::ProtocolVersion;
699 use crate::domain::mcp::authorization::{AuthorizationModel, authorization_model};
700 use crate::domain::oauth::registration::{ClientId, ClientIdError, RegisteredClient};
701
702 const AS: &str = "https://auth.example.com";
703 const OTHER_AS: &str = "https://other-auth.example.com";
704 const URL: &str = "https://client.example.com/metadata.json";
705
706 /// The revisions whose authorization spec differs in a way this module
707 /// acts on. A bare `AuthorizationModel` would let a test pick a revision by
708 /// accident and assert something true of all of them.
709 const EARLY: ProtocolVersion = ProtocolVersion::V2024_11_05;
710 const DYNAMIC_ONLY: ProtocolVersion = ProtocolVersion::V2025_03_26;
711 const BOTH: ProtocolVersion = ProtocolVersion::V2025_11_25;
712 const DOCUMENT_FIRST: ProtocolVersion = ProtocolVersion::V2026_07_28;
713
714 fn model(version: ProtocolVersion) -> AuthorizationModel {
715 authorization_model(version)
716 }
717
718 /// The error types these tests answer, collected so `?` works and a failure
719 /// prints the engine's own account of what it refused.
720 #[derive(Debug, thiserror::Error)]
721 enum Failure {
722 #[error(transparent)]
723 Id(#[from] ClientIdError),
724 #[error(transparent)]
725 Metadata(#[from] ClientMetadataError),
726 #[error(transparent)]
727 Registration(#[from] RegistrationError),
728 #[error("a fake port pended, which none of them should")]
729 Pended,
730 }
731
732 impl From<RegistrarError> for Failure {
733 fn from(error: RegistrarError) -> Self {
734 Self::Registration(RegistrationError::DynamicFailed(error))
735 }
736 }
737
738 /// Drives a future that is expected never to pend, and says so when it
739 /// does.
740 ///
741 /// `#[tokio::test]` is not available here: this module is gated on `oauth`,
742 /// and the tokio macros come in with `http`. Pulling a runtime in to test
743 /// fakes that return immediately would be a heavier dependency than the
744 /// thing under test. `Waker::noop` is correct for a future that resolves on
745 /// its first poll, and returning `None` on `Pending` is what makes that
746 /// assumption checkable rather than merely stated.
747 fn now<T>(future: impl Future<Output = T>) -> Option<T> {
748 let mut future = std::pin::pin!(future);
749 let waker = Waker::noop();
750 match future.as_mut().poll(&mut Context::from_waker(waker)) {
751 Poll::Ready(output) => Some(output),
752 Poll::Pending => None,
753 }
754 }
755
756 fn metadata() -> Result<ClientMetadata, Failure> {
757 Ok(
758 ClientMetadata::new(vec!["https://client.example.com/callback".to_owned()])?
759 .with_client_name("Example"),
760 )
761 }
762
763 fn document_identity() -> Result<ClientIdentity, Failure> {
764 Ok(ClientIdentity::from_document(ClientId::metadata_url(URL)?))
765 }
766
767 /// A dynamic registrar that always succeeds, counting its calls so a test
768 /// can prove it was *not* reached.
769 struct Dynamic {
770 calls: AtomicU32,
771 }
772
773 impl DynamicRegistrar for Dynamic {
774 // Not `async`: this fake resolves on its first poll, so `async` here
775 // would be an async block with no await point in it.
776 fn register_dynamic(
777 &self,
778 issuer: &str,
779 _metadata: &ClientMetadata,
780 ) -> impl Future<Output = Result<RegisteredClient, RegistrarError>> + Send {
781 self.calls.fetch_add(1, Ordering::Relaxed);
782 std::future::ready(
783 RegisteredClient::dynamic(issuer, ClientId::opaque("dynamic-1"), None)
784 .map_err(Into::into),
785 )
786 }
787 }
788
789 /// A document loader that answers with whatever it was built to answer.
790 struct Loader {
791 outcome: Result<(), RegistrarError>,
792 calls: AtomicU32,
793 }
794
795 impl Loader {
796 fn serving() -> Self {
797 Self {
798 outcome: Ok(()),
799 calls: AtomicU32::new(0),
800 }
801 }
802
803 fn failing(error: RegistrarError) -> Self {
804 Self {
805 outcome: Err(error),
806 calls: AtomicU32::new(0),
807 }
808 }
809 }
810
811 impl MetadataDocumentLoader for Loader {
812 fn fetch_metadata_document(
813 &self,
814 issuer: &str,
815 client_id: &ClientId,
816 ) -> impl Future<Output = Result<RegisteredClient, RegistrarError>> + Send {
817 self.calls.fetch_add(1, Ordering::Relaxed);
818 std::future::ready(match &self.outcome {
819 Ok(()) => Ok(RegisteredClient::from_metadata_document(
820 issuer,
821 client_id.clone(),
822 )),
823 Err(failure) => Err(failure.clone()),
824 })
825 }
826 }
827
828 // --- what each revision allows -----------------------------------------
829
830 /// `2024-11-05` has no authorization entry at all, so a client that
831 /// registers here is doing something the revision never asked for. Proceeding
832 /// quietly is how that goes unnoticed.
833 #[test]
834 fn a_revision_with_no_authorization_framework_refuses_to_register() -> Result<(), Failure> {
835 assert_matches!(
836 plan_registration(
837 &model(EARLY),
838 AS,
839 &ClientIdentity::from_metadata(metadata()?),
840 None,
841 Available::both(),
842 ),
843 Err(RegistrationError::NoMechanism)
844 );
845 Ok(())
846 }
847
848 /// `2025-03-26` names RFC 7591 and nothing else. A client holding only a
849 /// document has nothing it can use, and saying so beats silently
850 /// registering a second identity.
851 #[test]
852 fn a_document_is_not_usable_where_the_revision_never_named_one() -> Result<(), Failure> {
853 assert_matches!(
854 plan_registration(
855 &model(DYNAMIC_ONLY),
856 AS,
857 &document_identity()?,
858 None,
859 Available::both(),
860 ),
861 Err(RegistrationError::NoUsableMechanism)
862 );
863 Ok(())
864 }
865
866 #[test]
867 fn a_revision_naming_only_dynamic_registers_dynamically() -> Result<(), Failure> {
868 let plan = plan_registration(
869 &model(DYNAMIC_ONLY),
870 AS,
871 &ClientIdentity::from_metadata(metadata()?),
872 None,
873 Available::dynamic_only(),
874 )?;
875
876 assert_matches!(plan, RegistrationPlan::RegisterDynamic { .. });
877 Ok(())
878 }
879
880 /// The ordering the whole module exists for. `2026-07-28` changelog §4
881 /// deprecates RFC 7591 "in favor of" documents, so a client with both must
882 /// lead with the document — a client that leads with the deprecated
883 /// mechanism against a server supporting both is provoking a rejection.
884 #[test]
885 fn a_document_is_preferred_even_when_the_client_can_also_register() -> Result<(), Failure> {
886 let identity = document_identity()?.with_metadata_fallback(metadata()?);
887
888 let plan = plan_registration(
889 &model(DOCUMENT_FIRST),
890 AS,
891 &identity,
892 None,
893 Available::both(),
894 )?;
895
896 assert_matches!(plan, RegistrationPlan::FetchDocument { .. });
897 Ok(())
898 }
899
900 /// `2025-11-25` changelog §8 recommends documents without deprecating RFC
901 /// 7591, so the same ordering applies a revision earlier.
902 #[test]
903 fn the_preference_predates_the_deprecation() -> Result<(), Failure> {
904 let identity = document_identity()?.with_metadata_fallback(metadata()?);
905
906 let plan = plan_registration(&model(BOTH), AS, &identity, None, Available::both())?;
907
908 assert_matches!(plan, RegistrationPlan::FetchDocument { .. });
909 Ok(())
910 }
911
912 /// A deployment that wired no document loader must not be handed a plan to
913 /// fetch one.
914 #[test]
915 fn a_document_needs_a_loader_to_fetch_it_with() -> Result<(), Failure> {
916 let identity = document_identity()?;
917
918 assert_matches!(
919 plan_registration(
920 &model(DOCUMENT_FIRST),
921 AS,
922 &identity,
923 None,
924 Available::dynamic_only(),
925 ),
926 Err(RegistrationError::NoUsableMechanism)
927 );
928 Ok(())
929 }
930
931 // --- the MUST that has to be caught before anything is sent ------------
932
933 /// `2026-07-28` §8 makes `application_type` a MUST for dynamic
934 /// registration. Catching it at plan time is the difference between a clear
935 /// refusal and a registered client the server will not honour.
936 #[test]
937 fn the_required_application_type_is_refused_before_anything_is_sent() -> Result<(), Failure> {
938 assert_matches!(
939 plan_registration(
940 &model(DOCUMENT_FIRST),
941 AS,
942 &ClientIdentity::from_metadata(metadata()?),
943 None,
944 Available::dynamic_only(),
945 ),
946 Err(RegistrationError::ApplicationTypeRequired)
947 );
948 Ok(())
949 }
950
951 #[test]
952 fn supplying_the_application_type_satisfies_the_requirement() -> Result<(), Failure> {
953 let identity = ClientIdentity::from_metadata(metadata()?.with_application_type("native"));
954
955 assert_matches!(
956 plan_registration(
957 &model(DOCUMENT_FIRST),
958 AS,
959 &identity,
960 None,
961 Available::dynamic_only(),
962 ),
963 Ok(RegistrationPlan::RegisterDynamic { .. })
964 );
965 Ok(())
966 }
967
968 /// `2026-07-28` deprecates RFC 7591 rather than removing it, so a server
969 /// implementing only RFC 7591 is not misconfigured and a client must still
970 /// be able to use it.
971 #[test]
972 fn the_deprecated_mechanism_is_still_available() {
973 assert!(
974 model(DOCUMENT_FIRST).client_registration.supports_dynamic(),
975 "2026-07-28 still permits RFC 7591"
976 );
977 }
978
979 // --- stored credentials ------------------------------------------------
980
981 #[test]
982 fn a_stored_credential_from_this_issuer_is_reused() -> Result<(), Failure> {
983 let stored = RegisteredClient::dynamic(AS, ClientId::opaque("stored"), None)?;
984
985 let plan = plan_registration(
986 &model(DOCUMENT_FIRST),
987 AS,
988 &document_identity()?,
989 Some(&stored),
990 Available::both(),
991 )?;
992
993 assert_matches!(plan, RegistrationPlan::Reuse(ref client) if client == &stored);
994 Ok(())
995 }
996
997 /// `2026-07-28` binds a credential to its issuer so a credential minted by
998 /// one server cannot be presented to another. A stored credential from
999 /// elsewhere has to be refused, not quietly reused.
1000 #[test]
1001 fn a_credential_from_another_issuer_is_refused_where_binding_is_required() -> Result<(), Failure>
1002 {
1003 let stored = RegisteredClient::dynamic(OTHER_AS, ClientId::opaque("stored"), None)?;
1004
1005 assert_matches!(
1006 plan_registration(
1007 &model(DOCUMENT_FIRST),
1008 AS,
1009 &document_identity()?,
1010 Some(&stored),
1011 Available::both(),
1012 ),
1013 Err(RegistrationError::IssuerBound)
1014 );
1015 Ok(())
1016 }
1017
1018 /// Older revisions do not require binding, so the same stored credential
1019 /// is reused. It still carries the issuer it was issued by, so nothing
1020 /// downstream can mistake it for the current server's.
1021 #[test]
1022 fn a_credential_from_another_issuer_is_reused_where_binding_is_not_required()
1023 -> Result<(), Failure> {
1024 let stored = RegisteredClient::dynamic(OTHER_AS, ClientId::opaque("stored"), None)?;
1025
1026 let plan = plan_registration(
1027 &model(DYNAMIC_ONLY),
1028 AS,
1029 &ClientIdentity::from_metadata(metadata()?),
1030 Some(&stored),
1031 Available::dynamic_only(),
1032 )?;
1033
1034 assert_matches!(plan, RegistrationPlan::Reuse(ref client) if client.is_issued_by(OTHER_AS));
1035 Ok(())
1036 }
1037
1038 // --- executing the plan ------------------------------------------------
1039
1040 #[test]
1041 fn a_served_document_is_used_without_registering() -> Result<(), Failure> {
1042 let dynamic = Dynamic {
1043 calls: AtomicU32::new(0),
1044 };
1045 let loader = Loader::serving();
1046
1047 let client = match now(acquire_client(
1048 &model(DOCUMENT_FIRST),
1049 AS,
1050 &document_identity()?,
1051 None,
1052 Some(&dynamic),
1053 Some(&loader),
1054 )) {
1055 Some(result) => result?,
1056 None => return Err(Failure::Pended),
1057 };
1058
1059 assert_eq!(client.client_id().as_str(), URL);
1060 assert_eq!(loader.calls.load(Ordering::Relaxed), 1);
1061 assert_eq!(
1062 dynamic.calls.load(Ordering::Relaxed),
1063 0,
1064 "the document path registered anyway"
1065 );
1066 Ok(())
1067 }
1068
1069 /// `2026-07-28` keeps RFC 7591 for servers that do not implement documents,
1070 /// and a document that does not resolve is exactly that server.
1071 #[test]
1072 fn an_unreachable_document_falls_back_to_dynamic_registration() -> Result<(), Failure> {
1073 let dynamic = Dynamic {
1074 calls: AtomicU32::new(0),
1075 };
1076 let loader = Loader::failing(RegistrarError::Unavailable("connection refused".into()));
1077 let identity = document_identity()?.with_metadata_fallback(metadata()?);
1078
1079 let client = match now(acquire_client(
1080 &model(DOCUMENT_FIRST),
1081 AS,
1082 &identity,
1083 None,
1084 Some(&dynamic),
1085 Some(&loader),
1086 )) {
1087 Some(result) => result?,
1088 None => return Err(Failure::Pended),
1089 };
1090
1091 assert_eq!(client.client_id().as_str(), "dynamic-1");
1092 assert_eq!(dynamic.calls.load(Ordering::Relaxed), 1);
1093 Ok(())
1094 }
1095
1096 /// The distinction that matters. A document that could not be *fetched* is a
1097 /// server without documents; a document that was fetched and does not claim
1098 /// the URL it came from is a forgery, and falling back from it would let an
1099 /// impersonated client quietly register a fresh identity instead of
1100 /// surfacing the problem.
1101 #[test]
1102 fn a_document_that_is_present_but_wrong_never_falls_back() -> Result<(), Failure> {
1103 let dynamic = Dynamic {
1104 calls: AtomicU32::new(0),
1105 };
1106 let loader = Loader::failing(RegistrarError::Document(ClientIdError::DocumentMismatch));
1107 let identity = document_identity()?.with_metadata_fallback(metadata()?);
1108
1109 let Some(outcome) = now(acquire_client(
1110 &model(DOCUMENT_FIRST),
1111 AS,
1112 &identity,
1113 None,
1114 Some(&dynamic),
1115 Some(&loader),
1116 )) else {
1117 return Err(Failure::Pended);
1118 };
1119
1120 assert_matches!(outcome, Err(RegistrationError::DocumentUnavailable(_)));
1121 assert_eq!(
1122 dynamic.calls.load(Ordering::Relaxed),
1123 0,
1124 "a forged document silently became a fresh registration"
1125 );
1126 Ok(())
1127 }
1128
1129 /// With no metadata to fall back on, an unreachable document is just a
1130 /// failure.
1131 #[test]
1132 fn an_unreachable_document_with_no_fallback_is_an_error() -> Result<(), Failure> {
1133 let dynamic = Dynamic {
1134 calls: AtomicU32::new(0),
1135 };
1136 let loader = Loader::failing(RegistrarError::Unavailable("connection refused".into()));
1137
1138 let Some(outcome) = now(acquire_client(
1139 &model(DOCUMENT_FIRST),
1140 AS,
1141 &document_identity()?,
1142 None,
1143 Some(&dynamic),
1144 Some(&loader),
1145 )) else {
1146 return Err(Failure::Pended);
1147 };
1148
1149 assert_matches!(outcome, Err(RegistrationError::DocumentUnavailable(_)));
1150 assert_eq!(dynamic.calls.load(Ordering::Relaxed), 0);
1151 Ok(())
1152 }
1153
1154 #[test]
1155 fn a_stored_credential_is_returned_without_any_port_being_called() -> Result<(), Failure> {
1156 let dynamic = Dynamic {
1157 calls: AtomicU32::new(0),
1158 };
1159 let loader = Loader::serving();
1160 let stored = RegisteredClient::dynamic(AS, ClientId::opaque("stored"), None)?;
1161
1162 let client = match now(acquire_client(
1163 &model(DOCUMENT_FIRST),
1164 AS,
1165 &document_identity()?,
1166 Some(&stored),
1167 Some(&dynamic),
1168 Some(&loader),
1169 )) {
1170 Some(result) => result?,
1171 None => return Err(Failure::Pended),
1172 };
1173
1174 assert_eq!(&client, &stored);
1175 assert_eq!(loader.calls.load(Ordering::Relaxed), 0);
1176 assert_eq!(dynamic.calls.load(Ordering::Relaxed), 0);
1177 Ok(())
1178 }
1179
1180 // --- the metadata itself ----------------------------------------------
1181
1182 /// RFC 7591 §2 makes `redirect_uris` the one required member.
1183 #[test]
1184 fn metadata_without_redirect_uris_is_refused() {
1185 assert!(ClientMetadata::new(Vec::new()).is_err());
1186 }
1187
1188 /// A bare path is what a half-filled configuration produces, and a server
1189 /// would reject it.
1190 #[test]
1191 fn metadata_with_a_relative_redirect_uri_is_refused() {
1192 assert!(ClientMetadata::new(vec!["/callback".to_owned()]).is_err());
1193 }
1194
1195 #[test]
1196 fn a_loopback_redirect_is_allowed() -> Result<(), Failure> {
1197 // RFC 8252 §7.3 makes the native-app loopback redirect legal, so an
1198 // https-only rule would refuse a legitimate client.
1199 let metadata = ClientMetadata::new(vec!["http://127.0.0.1:8080/callback".to_owned()])?;
1200
1201 assert_eq!(metadata.redirect_uris().len(), 1);
1202 Ok(())
1203 }
1204
1205 /// The named fields are the ones this crate gates on, so the passthrough
1206 /// must not be able to set one — otherwise a document could claim redirect
1207 /// URIs no constructor ever checked.
1208 #[test]
1209 fn the_passthrough_cannot_overwrite_a_modelled_field() -> Result<(), Failure> {
1210 let metadata = metadata()?;
1211
1212 assert_matches!(
1213 metadata.with_field("redirect_uris", serde_json::json!(["https://evil.example.com"])),
1214 Err(ClientMetadataError::ReservedField(ref name)) if name == "redirect_uris"
1215 );
1216 Ok(())
1217 }
1218
1219 /// `2025-11-25` and later name `client_uri`, `icon_uri` and `title`. A
1220 /// client that publishes one and has it dropped at a struct boundary has
1221 /// registered something other than what it meant to.
1222 #[test]
1223 fn an_unmodelled_field_reaches_the_wire() -> Result<(), Failure> {
1224 let metadata = metadata()?
1225 .with_field("client_uri", "https://client.example.com")?
1226 .with_field("title", "Example Client")?;
1227
1228 let document = serde_json::to_value(&metadata)
1229 .map_err(|_| Failure::Registration(RegistrationError::NoMechanism))?;
1230
1231 assert_eq!(
1232 document.get("client_uri"),
1233 Some(&serde_json::json!("https://client.example.com"))
1234 );
1235 assert_eq!(
1236 document.get("title"),
1237 Some(&serde_json::json!("Example Client"))
1238 );
1239 Ok(())
1240 }
1241
1242 /// The named fields and the passthrough coexist on the wire without either
1243 /// shadowing the other.
1244 #[test]
1245 fn modelled_and_extra_fields_coexist() -> Result<(), Failure> {
1246 let metadata = metadata()?
1247 .with_application_type("native")
1248 .with_field("title", "Example")?;
1249
1250 let document = serde_json::to_value(&metadata)
1251 .map_err(|_| Failure::Registration(RegistrationError::NoMechanism))?;
1252
1253 assert_eq!(
1254 document.get("application_type"),
1255 Some(&serde_json::json!("native"))
1256 );
1257 assert_eq!(
1258 document.get("client_name"),
1259 Some(&serde_json::json!("Example"))
1260 );
1261 assert_eq!(document.get("title"), Some(&serde_json::json!("Example")));
1262 assert_eq!(
1263 document.get("redirect_uris"),
1264 Some(&serde_json::json!(["https://client.example.com/callback"]))
1265 );
1266 Ok(())
1267 }
1268}