use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::domain::mcp::authorization::{AuthorizationModel, ClientRegistration};
use crate::domain::oauth::registration::{
ClientId, ClientIdError, RegisteredClient, RegistrationDecision, decide_registration,
};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ClientMetadata {
redirect_uris: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
client_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
token_endpoint_auth_method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
application_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
#[serde(flatten)]
extra: Map<String, Value>,
}
impl ClientMetadata {
pub fn new(redirect_uris: Vec<String>) -> Result<Self, ClientIdError> {
if redirect_uris.is_empty() {
return Err(ClientIdError::DocumentRegistration);
}
for uri in &redirect_uris {
let Ok(parsed) = url::Url::parse(uri) else {
return Err(ClientIdError::NotHttps);
};
if !matches!(parsed.scheme(), "https" | "http") || parsed.host_str().is_none() {
return Err(ClientIdError::NotHttps);
}
}
Ok(Self {
redirect_uris,
..Self::default()
})
}
#[must_use]
pub fn redirect_uris(&self) -> &[String] {
&self.redirect_uris
}
#[must_use]
pub fn client_name(&self) -> Option<&str> {
self.client_name.as_deref()
}
#[must_use]
pub fn with_client_name(mut self, name: impl Into<String>) -> Self {
self.client_name = Some(name.into());
self
}
#[must_use]
pub fn token_endpoint_auth_method(&self) -> Option<&str> {
self.token_endpoint_auth_method.as_deref()
}
#[must_use]
pub fn with_token_endpoint_auth_method(mut self, method: impl Into<String>) -> Self {
self.token_endpoint_auth_method = Some(method.into());
self
}
#[must_use]
pub fn application_type(&self) -> Option<&str> {
self.application_type.as_deref()
}
#[must_use]
pub fn with_application_type(mut self, application_type: impl Into<String>) -> Self {
self.application_type = Some(application_type.into());
self
}
pub fn with_field(
mut self,
name: impl Into<String>,
value: impl Into<Value>,
) -> Result<Self, ClientMetadataError> {
let name = name.into();
if RESERVED_FIELDS.contains(&name.as_str()) {
return Err(ClientMetadataError::ReservedField(name));
}
self.extra.insert(name, value.into());
Ok(self)
}
#[must_use]
pub const fn extra(&self) -> &Map<String, Value> {
&self.extra
}
}
const RESERVED_FIELDS: &[&str] = &[
"redirect_uris",
"client_name",
"token_endpoint_auth_method",
"application_type",
"scope",
];
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ClientMetadataError {
#[error("'{0}' is a modelled client metadata field and cannot be set as an extra")]
ReservedField(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ClientIdentity {
document: Option<ClientId>,
metadata: Option<ClientMetadata>,
}
impl ClientIdentity {
#[must_use]
pub const fn from_document(client_id: ClientId) -> Self {
Self {
document: Some(client_id),
metadata: None,
}
}
#[must_use]
pub const fn from_metadata(metadata: ClientMetadata) -> Self {
Self {
document: None,
metadata: Some(metadata),
}
}
#[must_use]
pub fn with_metadata_fallback(mut self, metadata: ClientMetadata) -> Self {
self.metadata = Some(metadata);
self
}
#[must_use]
pub const fn document(&self) -> Option<&ClientId> {
self.document.as_ref()
}
#[must_use]
pub const fn metadata(&self) -> Option<&ClientMetadata> {
self.metadata.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistrationPlan {
Reuse(RegisteredClient),
FetchDocument {
client_id: ClientId,
fallback: Option<ClientMetadata>,
},
RegisterDynamic {
metadata: ClientMetadata,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RegistrationError {
#[error("this revision specifies no client registration mechanism")]
NoMechanism,
#[error("this revision supports neither client id metadata documents nor dynamic registration")]
NoUsableMechanism,
#[error(
"this revision requires application_type on dynamic registration and the metadata does not set it"
)]
ApplicationTypeRequired,
#[error(
"the stored client was issued by a different authorization server and this revision requires issuer binding"
)]
IssuerBound,
#[error("the client id metadata document could not be fetched: {0}")]
DocumentUnavailable(#[from] RegistrarError),
#[error("dynamic client registration failed: {0}")]
DynamicFailed(RegistrarError),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RegistrarError {
#[error("the registration request could not be completed: {0}")]
Unavailable(String),
#[error("the authorization server answered {status} to a client registration request")]
Refused {
status: u16,
},
#[error("the registration response could not be used: {0}")]
Malformed(String),
#[error(transparent)]
Document(#[from] ClientIdError),
}
pub trait DynamicRegistrar {
fn register_dynamic(
&self,
issuer: &str,
metadata: &ClientMetadata,
) -> impl Future<Output = Result<RegisteredClient, RegistrarError>> + Send;
}
pub trait MetadataDocumentLoader {
fn fetch_metadata_document(
&self,
issuer: &str,
client_id: &ClientId,
) -> impl Future<Output = Result<RegisteredClient, RegistrarError>> + Send;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Available {
dynamic: bool,
metadata_document: bool,
}
impl Available {
#[must_use]
pub const fn neither() -> Self {
Self {
dynamic: false,
metadata_document: false,
}
}
#[must_use]
pub const fn dynamic_only() -> Self {
Self {
dynamic: true,
metadata_document: false,
}
}
#[must_use]
pub const fn metadata_document_only() -> Self {
Self {
dynamic: false,
metadata_document: true,
}
}
#[must_use]
pub const fn both() -> Self {
Self {
dynamic: true,
metadata_document: true,
}
}
#[must_use]
pub const fn dynamic(self) -> bool {
self.dynamic
}
#[must_use]
pub const fn metadata_document(self) -> bool {
self.metadata_document
}
}
pub fn plan_registration(
model: &AuthorizationModel,
issuer: &str,
identity: &ClientIdentity,
stored: Option<&RegisteredClient>,
available: Available,
) -> Result<RegistrationPlan, RegistrationError> {
if let Some(stored) = stored {
match decide_registration(model, issuer, Some(stored)) {
RegistrationDecision::Reuse => {
return Ok(RegistrationPlan::Reuse(stored.clone()));
}
RegistrationDecision::ReRegister => {
return Err(RegistrationError::IssuerBound);
}
RegistrationDecision::Register => {}
}
}
let mechanism = model.client_registration;
if mechanism == ClientRegistration::None {
return Err(RegistrationError::NoMechanism);
}
if let (true, Some(client_id)) = (
mechanism.supports_metadata_document() && available.metadata_document,
identity.document(),
) {
let fallback = identity
.metadata()
.filter(|_| mechanism.supports_dynamic() && available.dynamic)
.cloned();
return Ok(RegistrationPlan::FetchDocument {
client_id: client_id.clone(),
fallback,
});
}
if let (true, Some(metadata)) = (mechanism.supports_dynamic(), identity.metadata()) {
if mechanism.application_type_required() && metadata.application_type().is_none() {
return Err(RegistrationError::ApplicationTypeRequired);
}
return Ok(RegistrationPlan::RegisterDynamic {
metadata: metadata.clone(),
});
}
Err(RegistrationError::NoUsableMechanism)
}
pub async fn acquire_client(
model: &AuthorizationModel,
issuer: &str,
identity: &ClientIdentity,
stored: Option<&RegisteredClient>,
dynamic: Option<&(impl DynamicRegistrar + Sync)>,
documents: Option<&(impl MetadataDocumentLoader + Sync)>,
) -> Result<RegisteredClient, RegistrationError> {
let available = match (dynamic.is_some(), documents.is_some()) {
(true, true) => Available::both(),
(true, false) => Available::dynamic_only(),
(false, true) => Available::metadata_document_only(),
(false, false) => Available::neither(),
};
match plan_registration(model, issuer, identity, stored, available)? {
RegistrationPlan::Reuse(client) => Ok(client),
RegistrationPlan::FetchDocument {
client_id,
fallback,
} => {
let loader = documents.ok_or(RegistrationError::NoUsableMechanism)?;
match loader.fetch_metadata_document(issuer, &client_id).await {
Ok(client) => Ok(client),
Err(failure @ RegistrarError::Document(_)) => {
Err(RegistrationError::DocumentUnavailable(failure))
}
Err(failure) => match fallback {
Some(metadata) => {
let registrar = dynamic.ok_or(RegistrationError::NoUsableMechanism)?;
registrar
.register_dynamic(issuer, &metadata)
.await
.map_err(RegistrationError::DynamicFailed)
}
None => Err(RegistrationError::DocumentUnavailable(failure)),
},
}
}
RegistrationPlan::RegisterDynamic { metadata } => {
let registrar = dynamic.ok_or(RegistrationError::NoUsableMechanism)?;
registrar
.register_dynamic(issuer, &metadata)
.await
.map_err(RegistrationError::DynamicFailed)
}
}
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use std::future::Future;
use std::sync::atomic::{AtomicU32, Ordering};
use std::task::{Context, Poll, Waker};
use super::{
Available, ClientIdentity, ClientMetadata, ClientMetadataError, DynamicRegistrar,
MetadataDocumentLoader, RegistrarError, RegistrationError, RegistrationPlan,
acquire_client, plan_registration,
};
use crate::domain::ProtocolVersion;
use crate::domain::mcp::authorization::{AuthorizationModel, authorization_model};
use crate::domain::oauth::registration::{ClientId, ClientIdError, RegisteredClient};
const AS: &str = "https://auth.example.com";
const OTHER_AS: &str = "https://other-auth.example.com";
const URL: &str = "https://client.example.com/metadata.json";
const EARLY: ProtocolVersion = ProtocolVersion::V2024_11_05;
const DYNAMIC_ONLY: ProtocolVersion = ProtocolVersion::V2025_03_26;
const BOTH: ProtocolVersion = ProtocolVersion::V2025_11_25;
const DOCUMENT_FIRST: ProtocolVersion = ProtocolVersion::V2026_07_28;
fn model(version: ProtocolVersion) -> AuthorizationModel {
authorization_model(version)
}
#[derive(Debug, thiserror::Error)]
enum Failure {
#[error(transparent)]
Id(#[from] ClientIdError),
#[error(transparent)]
Metadata(#[from] ClientMetadataError),
#[error(transparent)]
Registration(#[from] RegistrationError),
#[error("a fake port pended, which none of them should")]
Pended,
}
impl From<RegistrarError> for Failure {
fn from(error: RegistrarError) -> Self {
Self::Registration(RegistrationError::DynamicFailed(error))
}
}
fn now<T>(future: impl Future<Output = T>) -> Option<T> {
let mut future = std::pin::pin!(future);
let waker = Waker::noop();
match future.as_mut().poll(&mut Context::from_waker(waker)) {
Poll::Ready(output) => Some(output),
Poll::Pending => None,
}
}
fn metadata() -> Result<ClientMetadata, Failure> {
Ok(
ClientMetadata::new(vec!["https://client.example.com/callback".to_owned()])?
.with_client_name("Example"),
)
}
fn document_identity() -> Result<ClientIdentity, Failure> {
Ok(ClientIdentity::from_document(ClientId::metadata_url(URL)?))
}
struct Dynamic {
calls: AtomicU32,
}
impl DynamicRegistrar for Dynamic {
fn register_dynamic(
&self,
issuer: &str,
_metadata: &ClientMetadata,
) -> impl Future<Output = Result<RegisteredClient, RegistrarError>> + Send {
self.calls.fetch_add(1, Ordering::Relaxed);
std::future::ready(
RegisteredClient::dynamic(issuer, ClientId::opaque("dynamic-1"), None)
.map_err(Into::into),
)
}
}
struct Loader {
outcome: Result<(), RegistrarError>,
calls: AtomicU32,
}
impl Loader {
fn serving() -> Self {
Self {
outcome: Ok(()),
calls: AtomicU32::new(0),
}
}
fn failing(error: RegistrarError) -> Self {
Self {
outcome: Err(error),
calls: AtomicU32::new(0),
}
}
}
impl MetadataDocumentLoader for Loader {
fn fetch_metadata_document(
&self,
issuer: &str,
client_id: &ClientId,
) -> impl Future<Output = Result<RegisteredClient, RegistrarError>> + Send {
self.calls.fetch_add(1, Ordering::Relaxed);
std::future::ready(match &self.outcome {
Ok(()) => Ok(RegisteredClient::from_metadata_document(
issuer,
client_id.clone(),
)),
Err(failure) => Err(failure.clone()),
})
}
}
#[test]
fn a_revision_with_no_authorization_framework_refuses_to_register() -> Result<(), Failure> {
assert_matches!(
plan_registration(
&model(EARLY),
AS,
&ClientIdentity::from_metadata(metadata()?),
None,
Available::both(),
),
Err(RegistrationError::NoMechanism)
);
Ok(())
}
#[test]
fn a_document_is_not_usable_where_the_revision_never_named_one() -> Result<(), Failure> {
assert_matches!(
plan_registration(
&model(DYNAMIC_ONLY),
AS,
&document_identity()?,
None,
Available::both(),
),
Err(RegistrationError::NoUsableMechanism)
);
Ok(())
}
#[test]
fn a_revision_naming_only_dynamic_registers_dynamically() -> Result<(), Failure> {
let plan = plan_registration(
&model(DYNAMIC_ONLY),
AS,
&ClientIdentity::from_metadata(metadata()?),
None,
Available::dynamic_only(),
)?;
assert_matches!(plan, RegistrationPlan::RegisterDynamic { .. });
Ok(())
}
#[test]
fn a_document_is_preferred_even_when_the_client_can_also_register() -> Result<(), Failure> {
let identity = document_identity()?.with_metadata_fallback(metadata()?);
let plan = plan_registration(
&model(DOCUMENT_FIRST),
AS,
&identity,
None,
Available::both(),
)?;
assert_matches!(plan, RegistrationPlan::FetchDocument { .. });
Ok(())
}
#[test]
fn the_preference_predates_the_deprecation() -> Result<(), Failure> {
let identity = document_identity()?.with_metadata_fallback(metadata()?);
let plan = plan_registration(&model(BOTH), AS, &identity, None, Available::both())?;
assert_matches!(plan, RegistrationPlan::FetchDocument { .. });
Ok(())
}
#[test]
fn a_document_needs_a_loader_to_fetch_it_with() -> Result<(), Failure> {
let identity = document_identity()?;
assert_matches!(
plan_registration(
&model(DOCUMENT_FIRST),
AS,
&identity,
None,
Available::dynamic_only(),
),
Err(RegistrationError::NoUsableMechanism)
);
Ok(())
}
#[test]
fn the_required_application_type_is_refused_before_anything_is_sent() -> Result<(), Failure> {
assert_matches!(
plan_registration(
&model(DOCUMENT_FIRST),
AS,
&ClientIdentity::from_metadata(metadata()?),
None,
Available::dynamic_only(),
),
Err(RegistrationError::ApplicationTypeRequired)
);
Ok(())
}
#[test]
fn supplying_the_application_type_satisfies_the_requirement() -> Result<(), Failure> {
let identity = ClientIdentity::from_metadata(metadata()?.with_application_type("native"));
assert_matches!(
plan_registration(
&model(DOCUMENT_FIRST),
AS,
&identity,
None,
Available::dynamic_only(),
),
Ok(RegistrationPlan::RegisterDynamic { .. })
);
Ok(())
}
#[test]
fn the_deprecated_mechanism_is_still_available() {
assert!(
model(DOCUMENT_FIRST).client_registration.supports_dynamic(),
"2026-07-28 still permits RFC 7591"
);
}
#[test]
fn a_stored_credential_from_this_issuer_is_reused() -> Result<(), Failure> {
let stored = RegisteredClient::dynamic(AS, ClientId::opaque("stored"), None)?;
let plan = plan_registration(
&model(DOCUMENT_FIRST),
AS,
&document_identity()?,
Some(&stored),
Available::both(),
)?;
assert_matches!(plan, RegistrationPlan::Reuse(ref client) if client == &stored);
Ok(())
}
#[test]
fn a_credential_from_another_issuer_is_refused_where_binding_is_required() -> Result<(), Failure>
{
let stored = RegisteredClient::dynamic(OTHER_AS, ClientId::opaque("stored"), None)?;
assert_matches!(
plan_registration(
&model(DOCUMENT_FIRST),
AS,
&document_identity()?,
Some(&stored),
Available::both(),
),
Err(RegistrationError::IssuerBound)
);
Ok(())
}
#[test]
fn a_credential_from_another_issuer_is_reused_where_binding_is_not_required()
-> Result<(), Failure> {
let stored = RegisteredClient::dynamic(OTHER_AS, ClientId::opaque("stored"), None)?;
let plan = plan_registration(
&model(DYNAMIC_ONLY),
AS,
&ClientIdentity::from_metadata(metadata()?),
Some(&stored),
Available::dynamic_only(),
)?;
assert_matches!(plan, RegistrationPlan::Reuse(ref client) if client.is_issued_by(OTHER_AS));
Ok(())
}
#[test]
fn a_served_document_is_used_without_registering() -> Result<(), Failure> {
let dynamic = Dynamic {
calls: AtomicU32::new(0),
};
let loader = Loader::serving();
let client = match now(acquire_client(
&model(DOCUMENT_FIRST),
AS,
&document_identity()?,
None,
Some(&dynamic),
Some(&loader),
)) {
Some(result) => result?,
None => return Err(Failure::Pended),
};
assert_eq!(client.client_id().as_str(), URL);
assert_eq!(loader.calls.load(Ordering::Relaxed), 1);
assert_eq!(
dynamic.calls.load(Ordering::Relaxed),
0,
"the document path registered anyway"
);
Ok(())
}
#[test]
fn an_unreachable_document_falls_back_to_dynamic_registration() -> Result<(), Failure> {
let dynamic = Dynamic {
calls: AtomicU32::new(0),
};
let loader = Loader::failing(RegistrarError::Unavailable("connection refused".into()));
let identity = document_identity()?.with_metadata_fallback(metadata()?);
let client = match now(acquire_client(
&model(DOCUMENT_FIRST),
AS,
&identity,
None,
Some(&dynamic),
Some(&loader),
)) {
Some(result) => result?,
None => return Err(Failure::Pended),
};
assert_eq!(client.client_id().as_str(), "dynamic-1");
assert_eq!(dynamic.calls.load(Ordering::Relaxed), 1);
Ok(())
}
#[test]
fn a_document_that_is_present_but_wrong_never_falls_back() -> Result<(), Failure> {
let dynamic = Dynamic {
calls: AtomicU32::new(0),
};
let loader = Loader::failing(RegistrarError::Document(ClientIdError::DocumentMismatch));
let identity = document_identity()?.with_metadata_fallback(metadata()?);
let Some(outcome) = now(acquire_client(
&model(DOCUMENT_FIRST),
AS,
&identity,
None,
Some(&dynamic),
Some(&loader),
)) else {
return Err(Failure::Pended);
};
assert_matches!(outcome, Err(RegistrationError::DocumentUnavailable(_)));
assert_eq!(
dynamic.calls.load(Ordering::Relaxed),
0,
"a forged document silently became a fresh registration"
);
Ok(())
}
#[test]
fn an_unreachable_document_with_no_fallback_is_an_error() -> Result<(), Failure> {
let dynamic = Dynamic {
calls: AtomicU32::new(0),
};
let loader = Loader::failing(RegistrarError::Unavailable("connection refused".into()));
let Some(outcome) = now(acquire_client(
&model(DOCUMENT_FIRST),
AS,
&document_identity()?,
None,
Some(&dynamic),
Some(&loader),
)) else {
return Err(Failure::Pended);
};
assert_matches!(outcome, Err(RegistrationError::DocumentUnavailable(_)));
assert_eq!(dynamic.calls.load(Ordering::Relaxed), 0);
Ok(())
}
#[test]
fn a_stored_credential_is_returned_without_any_port_being_called() -> Result<(), Failure> {
let dynamic = Dynamic {
calls: AtomicU32::new(0),
};
let loader = Loader::serving();
let stored = RegisteredClient::dynamic(AS, ClientId::opaque("stored"), None)?;
let client = match now(acquire_client(
&model(DOCUMENT_FIRST),
AS,
&document_identity()?,
Some(&stored),
Some(&dynamic),
Some(&loader),
)) {
Some(result) => result?,
None => return Err(Failure::Pended),
};
assert_eq!(&client, &stored);
assert_eq!(loader.calls.load(Ordering::Relaxed), 0);
assert_eq!(dynamic.calls.load(Ordering::Relaxed), 0);
Ok(())
}
#[test]
fn metadata_without_redirect_uris_is_refused() {
assert!(ClientMetadata::new(Vec::new()).is_err());
}
#[test]
fn metadata_with_a_relative_redirect_uri_is_refused() {
assert!(ClientMetadata::new(vec!["/callback".to_owned()]).is_err());
}
#[test]
fn a_loopback_redirect_is_allowed() -> Result<(), Failure> {
let metadata = ClientMetadata::new(vec!["http://127.0.0.1:8080/callback".to_owned()])?;
assert_eq!(metadata.redirect_uris().len(), 1);
Ok(())
}
#[test]
fn the_passthrough_cannot_overwrite_a_modelled_field() -> Result<(), Failure> {
let metadata = metadata()?;
assert_matches!(
metadata.with_field("redirect_uris", serde_json::json!(["https://evil.example.com"])),
Err(ClientMetadataError::ReservedField(ref name)) if name == "redirect_uris"
);
Ok(())
}
#[test]
fn an_unmodelled_field_reaches_the_wire() -> Result<(), Failure> {
let metadata = metadata()?
.with_field("client_uri", "https://client.example.com")?
.with_field("title", "Example Client")?;
let document = serde_json::to_value(&metadata)
.map_err(|_| Failure::Registration(RegistrationError::NoMechanism))?;
assert_eq!(
document.get("client_uri"),
Some(&serde_json::json!("https://client.example.com"))
);
assert_eq!(
document.get("title"),
Some(&serde_json::json!("Example Client"))
);
Ok(())
}
#[test]
fn modelled_and_extra_fields_coexist() -> Result<(), Failure> {
let metadata = metadata()?
.with_application_type("native")
.with_field("title", "Example")?;
let document = serde_json::to_value(&metadata)
.map_err(|_| Failure::Registration(RegistrationError::NoMechanism))?;
assert_eq!(
document.get("application_type"),
Some(&serde_json::json!("native"))
);
assert_eq!(
document.get("client_name"),
Some(&serde_json::json!("Example"))
);
assert_eq!(document.get("title"), Some(&serde_json::json!("Example")));
assert_eq!(
document.get("redirect_uris"),
Some(&serde_json::json!(["https://client.example.com/callback"]))
);
Ok(())
}
}