Skip to main content

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}