use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientIdMetadataDocument {
pub client_id: String,
pub client_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<String>,
pub redirect_uris: Vec<String>,
#[serde(default = "default_grant_types")]
pub grant_types: Vec<String>,
#[serde(default = "default_response_types")]
pub response_types: Vec<String>,
#[serde(default = "default_token_endpoint_auth_method")]
pub token_endpoint_auth_method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub software_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub software_version: Option<String>,
}
fn default_grant_types() -> Vec<String> {
vec!["authorization_code".to_string()]
}
fn default_response_types() -> Vec<String> {
vec!["code".to_string()]
}
fn default_token_endpoint_auth_method() -> String {
"none".to_string()
}
#[derive(Debug, Error)]
pub enum ClientMetadataError {
#[error("Invalid client_id format: {0}")]
InvalidClientId(String),
#[error("Failed to fetch metadata document: {0}")]
FetchError(String),
#[error("Invalid metadata document JSON: {0}")]
InvalidJson(String),
#[error("client_id mismatch: document contains '{document}' but was fetched from '{url}'")]
ClientIdMismatch { document: String, url: String },
#[error("Missing required field: {0}")]
MissingField(String),
#[error("Invalid redirect_uri: {0} not in allowed list")]
InvalidRedirectUri(String),
#[error("client_id must use HTTPS scheme")]
NotHttps,
#[error("client_id URL must contain a path component")]
NoPathComponent,
}
pub fn validate_client_id_url(client_id: &str) -> Result<(), ClientMetadataError> {
let url = url::Url::parse(client_id)
.map_err(|e| ClientMetadataError::InvalidClientId(e.to_string()))?;
if url.scheme() != "https" {
return Err(ClientMetadataError::NotHttps);
}
if url.path() == "/" || url.path().is_empty() {
return Err(ClientMetadataError::NoPathComponent);
}
Ok(())
}
pub fn is_client_id_metadata_url(client_id: &str) -> bool {
if !client_id.starts_with("https://") {
return false;
}
if let Ok(url) = url::Url::parse(client_id) {
url.path() != "/" && !url.path().is_empty()
} else {
false
}
}
pub fn validate_metadata_document(
document: &ClientIdMetadataDocument,
fetched_from_url: &str,
) -> Result<(), ClientMetadataError> {
if document.client_id != fetched_from_url {
return Err(ClientMetadataError::ClientIdMismatch {
document: document.client_id.clone(),
url: fetched_from_url.to_string(),
});
}
if document.redirect_uris.is_empty() {
return Err(ClientMetadataError::MissingField(
"redirect_uris".to_string(),
));
}
if document.client_name.is_empty() {
return Err(ClientMetadataError::MissingField("client_name".to_string()));
}
Ok(())
}
pub fn validate_redirect_uri(
redirect_uri: &str,
document: &ClientIdMetadataDocument,
) -> Result<(), ClientMetadataError> {
if document.redirect_uris.contains(&redirect_uri.to_string()) {
Ok(())
} else {
Err(ClientMetadataError::InvalidRedirectUri(
redirect_uri.to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_client_id_url_valid() {
assert!(validate_client_id_url("https://example.com/client.json").is_ok());
assert!(validate_client_id_url("https://example.com/oauth/metadata").is_ok());
assert!(validate_client_id_url("https://app.example.com/client-metadata.json").is_ok());
}
#[test]
fn test_validate_client_id_url_invalid() {
assert!(matches!(
validate_client_id_url("http://example.com/client.json"),
Err(ClientMetadataError::NotHttps)
));
assert!(matches!(
validate_client_id_url("https://example.com"),
Err(ClientMetadataError::NoPathComponent)
));
assert!(matches!(
validate_client_id_url("https://example.com/"),
Err(ClientMetadataError::NoPathComponent)
));
assert!(matches!(
validate_client_id_url("not-a-url"),
Err(ClientMetadataError::InvalidClientId(_))
));
}
#[test]
fn test_is_client_id_metadata_url() {
assert!(is_client_id_metadata_url("https://example.com/client.json"));
assert!(is_client_id_metadata_url(
"https://app.example.com/oauth/metadata"
));
assert!(!is_client_id_metadata_url("http://example.com/client.json")); assert!(!is_client_id_metadata_url("https://example.com")); assert!(!is_client_id_metadata_url("https://example.com/")); assert!(!is_client_id_metadata_url("traditional-client-id")); }
#[test]
fn test_validate_metadata_document() {
let document = ClientIdMetadataDocument {
client_id: "https://example.com/client.json".to_string(),
client_name: "Test Client".to_string(),
client_uri: None,
logo_uri: None,
redirect_uris: vec!["http://127.0.0.1:3000/callback".to_string()],
grant_types: vec!["authorization_code".to_string()],
response_types: vec!["code".to_string()],
token_endpoint_auth_method: "none".to_string(),
jwks_uri: None,
jwks: None,
scope: None,
software_id: None,
software_version: None,
};
assert!(validate_metadata_document(&document, "https://example.com/client.json").is_ok());
assert!(matches!(
validate_metadata_document(&document, "https://different.com/client.json"),
Err(ClientMetadataError::ClientIdMismatch { .. })
));
}
#[test]
fn test_validate_redirect_uri() {
let document = ClientIdMetadataDocument {
client_id: "https://example.com/client.json".to_string(),
client_name: "Test Client".to_string(),
client_uri: None,
logo_uri: None,
redirect_uris: vec![
"http://127.0.0.1:3000/callback".to_string(),
"http://localhost:3000/callback".to_string(),
],
grant_types: vec!["authorization_code".to_string()],
response_types: vec!["code".to_string()],
token_endpoint_auth_method: "none".to_string(),
jwks_uri: None,
jwks: None,
scope: None,
software_id: None,
software_version: None,
};
assert!(validate_redirect_uri("http://127.0.0.1:3000/callback", &document).is_ok());
assert!(validate_redirect_uri("http://localhost:3000/callback", &document).is_ok());
assert!(matches!(
validate_redirect_uri("http://evil.com/callback", &document),
Err(ClientMetadataError::InvalidRedirectUri(_))
));
}
#[test]
fn test_deserialize_metadata_document() {
let json = r#"{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"client_name": "Example MCP Client",
"client_uri": "https://app.example.com",
"logo_uri": "https://app.example.com/logo.png",
"redirect_uris": [
"http://127.0.0.1:3000/callback",
"http://localhost:3000/callback"
],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}"#;
let document: ClientIdMetadataDocument = serde_json::from_str(json).unwrap();
assert_eq!(
document.client_id,
"https://app.example.com/oauth/client-metadata.json"
);
assert_eq!(document.client_name, "Example MCP Client");
assert_eq!(document.redirect_uris.len(), 2);
}
#[test]
fn test_deserialize_minimal_metadata_document() {
let json = r#"{
"client_id": "https://example.com/client.json",
"client_name": "Minimal Client",
"redirect_uris": ["http://localhost/callback"]
}"#;
let document: ClientIdMetadataDocument = serde_json::from_str(json).unwrap();
assert_eq!(document.grant_types, vec!["authorization_code"]);
assert_eq!(document.response_types, vec!["code"]);
assert_eq!(document.token_endpoint_auth_method, "none");
}
}