use core::fmt;
use std::sync::Arc;
use crate::domain::mcp::authorization::AuthorizationModel;
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ClientId(Arc<str>);
impl ClientId {
#[must_use]
pub fn opaque(value: impl AsRef<str>) -> Self {
Self(Arc::from(value.as_ref()))
}
pub fn metadata_url(value: impl AsRef<str>) -> Result<Self, ClientIdError> {
let value = value.as_ref();
if value.starts_with("https://") {
Ok(Self(Arc::from(value)))
} else {
Err(ClientIdError::NotHttps)
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
pub fn verify_metadata_document(&self, document_client_id: &str) -> Result<(), ClientIdError> {
if document_client_id == self.0.as_ref() {
Ok(())
} else {
Err(ClientIdError::DocumentMismatch)
}
}
}
impl fmt::Debug for ClientId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.debug_tuple("ClientId").field(&self.0).finish()
}
}
impl fmt::Display for ClientId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ClientSecret(Arc<str>);
impl ClientSecret {
#[must_use]
pub fn issued(value: impl AsRef<str>) -> Self {
Self(Arc::from(value.as_ref()))
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for ClientSecret {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ClientSecret(…)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenAuthMethod {
ClientSecretBasic,
ClientSecretPost,
None,
}
impl TokenAuthMethod {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ClientSecretBasic => "client_secret_basic",
Self::ClientSecretPost => "client_secret_post",
Self::None => "none",
}
}
#[must_use]
pub fn parse(name: &str) -> Option<Self> {
match name {
"client_secret_basic" => Some(Self::ClientSecretBasic),
"client_secret_post" => Some(Self::ClientSecretPost),
"none" => Some(Self::None),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisteredClient {
issuer: Arc<str>,
client_id: ClientId,
secret: Option<ClientSecret>,
auth_method: TokenAuthMethod,
}
impl RegisteredClient {
pub fn dynamic(
issuer: impl AsRef<str>,
client_id: ClientId,
secret: Option<ClientSecret>,
) -> Result<Self, ClientIdError> {
if client_id.0.starts_with("https://") {
return Err(ClientIdError::DocumentRegistration);
}
let auth_method = if secret.is_some() {
TokenAuthMethod::ClientSecretBasic
} else {
TokenAuthMethod::None
};
Ok(Self {
issuer: Arc::from(issuer.as_ref()),
client_id,
secret,
auth_method,
})
}
#[must_use]
pub fn from_metadata_document(issuer: impl AsRef<str>, client_id: ClientId) -> Self {
Self {
issuer: Arc::from(issuer.as_ref()),
client_id,
secret: None,
auth_method: TokenAuthMethod::None,
}
}
#[must_use]
pub const fn with_auth_method(mut self, method: TokenAuthMethod) -> Self {
self.auth_method = method;
self
}
#[must_use]
pub const fn auth_method(&self) -> TokenAuthMethod {
self.auth_method
}
#[must_use]
pub fn issuer(&self) -> &str {
&self.issuer
}
#[must_use]
pub const fn client_id(&self) -> &ClientId {
&self.client_id
}
#[must_use]
pub const fn secret(&self) -> Option<&ClientSecret> {
self.secret.as_ref()
}
#[must_use]
pub fn is_issued_by(&self, issuer: &str) -> bool {
self.issuer.as_ref() == issuer
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegistrationDecision {
Register,
Reuse,
ReRegister,
}
#[must_use]
pub fn decide_registration(
model: &AuthorizationModel,
issuer: &str,
stored: Option<&RegisteredClient>,
) -> RegistrationDecision {
match stored {
None => RegistrationDecision::Register,
Some(stored) if stored.is_issued_by(issuer) => RegistrationDecision::Reuse,
Some(_) if model.credentials_bound_to_issuer => RegistrationDecision::ReRegister,
Some(_) => RegistrationDecision::Reuse,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ClientIdError {
#[error("a client id metadata document URL must use the https scheme")]
NotHttps,
#[error("the metadata document's client_id does not match the URL it was served from")]
DocumentMismatch,
#[error("a dynamically registered client_id must not be a metadata document URL")]
DocumentRegistration,
#[error("a client id metadata document client cannot hold a client secret")]
SecretForDocument,
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::{
ClientId, ClientIdError, ClientSecret, RegisteredClient, RegistrationDecision,
decide_registration,
};
use crate::domain::ProtocolVersion;
use crate::domain::mcp::authorization::{AuthorizationModel, authorization_model};
const AS: &str = "https://auth.example.com";
const OTHER_AS: &str = "https://auth.other.example";
const DOCUMENT_URL: &str = "https://client.example.com/metadata.json";
const fn model_for(version: ProtocolVersion) -> AuthorizationModel {
authorization_model(version)
}
fn document_url() -> Result<ClientId, ClientIdError> {
ClientId::metadata_url(DOCUMENT_URL)
}
fn secret() -> ClientSecret {
ClientSecret(Arc::from("s3cret-value"))
}
fn document_client() -> Result<RegisteredClient, ClientIdError> {
Ok(RegisteredClient::from_metadata_document(
AS,
document_url()?,
))
}
#[test]
fn a_dynamic_client_may_or_may_not_hold_a_secret() -> Result<(), ClientIdError> {
let confidential =
RegisteredClient::dynamic(AS, ClientId::opaque("client-abc"), Some(secret()))?;
assert!(confidential.secret().is_some());
assert_eq!(confidential.secret(), Some(&secret()));
let public = RegisteredClient::dynamic(AS, ClientId::opaque("client-xyz"), None)?;
assert!(public.secret().is_none());
Ok(())
}
#[test]
fn a_metadata_document_client_has_no_secret() -> Result<(), ClientIdError> {
assert!(document_client()?.secret().is_none());
Ok(())
}
#[test]
fn a_document_url_cannot_be_a_dynamic_registration() -> Result<(), ClientIdError> {
assert_eq!(
RegisteredClient::dynamic(AS, document_url()?, None),
Err(ClientIdError::DocumentRegistration)
);
Ok(())
}
#[test]
fn a_metadata_client_id_must_be_https() {
for value in [
"http://client.example.com/metadata.json",
"ftp://client.example.com/metadata.json",
"client.example.com/metadata.json",
] {
assert_eq!(
ClientId::metadata_url(value),
Err(ClientIdError::NotHttps),
"`{value}` is not an https URL"
);
}
}
#[test]
fn a_document_must_vouch_for_its_own_url() -> Result<(), ClientIdError> {
let document = document_url()?;
assert_eq!(document.verify_metadata_document(DOCUMENT_URL), Ok(()));
for claimed in [
"https://client.example.com/metadata.json/",
"https://CLIENT.example.com/metadata.json",
"https://client.example.com/metadata.json#",
"https://client.example.com/other.json",
"https://evil.example.com/metadata.json",
] {
assert_eq!(
document.verify_metadata_document(claimed),
Err(ClientIdError::DocumentMismatch),
"`{claimed}` must not verify as the document URL"
);
}
Ok(())
}
#[test]
fn a_secret_is_redacted_in_debug_and_exposed_only_on_request() {
let secret = secret();
let rendered = format!("{secret:?}");
assert_eq!(rendered, "ClientSecret(…)");
assert!(
!rendered.contains("s3cret"),
"the client secret leaked: {rendered}"
);
assert_eq!(secret.expose(), "s3cret-value");
}
#[test]
fn no_stored_credential_means_register() {
for version in [ProtocolVersion::V2024_11_05, ProtocolVersion::V2026_07_28] {
assert_eq!(
decide_registration(&model_for(version), AS, None),
RegistrationDecision::Register
);
}
}
#[test]
fn a_credential_for_this_issuer_is_reused() -> Result<(), ClientIdError> {
let stored = document_client()?;
for version in [ProtocolVersion::V2025_06_18, ProtocolVersion::V2026_07_28] {
assert_eq!(
decide_registration(&model_for(version), AS, Some(&stored)),
RegistrationDecision::Reuse
);
}
Ok(())
}
#[test]
fn a_credential_from_another_issuer_is_never_reused_in_2026_07_28() -> Result<(), ClientIdError>
{
let stored = document_client()?;
let model = model_for(ProtocolVersion::V2026_07_28);
assert!(model.credentials_bound_to_issuer);
assert_eq!(
decide_registration(&model, OTHER_AS, Some(&stored)),
RegistrationDecision::ReRegister
);
Ok(())
}
#[test]
fn an_earlier_revision_permits_cross_issuer_reuse() -> Result<(), ClientIdError> {
let stored = document_client()?;
for version in [
ProtocolVersion::V2025_03_26,
ProtocolVersion::V2025_06_18,
ProtocolVersion::V2025_11_25,
] {
let model = model_for(version);
assert!(
!model.credentials_bound_to_issuer,
"{version:?} must not bind credentials to the issuer"
);
assert_eq!(
decide_registration(&model, OTHER_AS, Some(&stored)),
RegistrationDecision::Reuse,
"{version:?} does not require re-registration"
);
}
Ok(())
}
#[test]
fn a_credential_names_the_issuer_that_issued_it() -> Result<(), ClientIdError> {
let stored = document_client()?;
assert!(stored.is_issued_by(AS));
assert!(!stored.is_issued_by(OTHER_AS));
assert_eq!(stored.issuer(), AS);
assert_eq!(stored.client_id().as_str(), DOCUMENT_URL);
Ok(())
}
}