Add SEP-991 (CIMD) support for URL-based client IDs (#570)

* feat(auth): add cimd support for SEP-991

add cimd support for url-based client ids

Signed-off-by: tanish111 <tanishdesai37@gmail.com>

* test(auth): add unit tests for is_https_url helper

Add test coverage for is_https_url helper to validate HTTPS scheme, non-root paths,
and reject http, javascript, data schemes, and invalid inputs per SEP-991 requirements.

Signed-off-by: tanish111 <tanishdesai37@gmail.com>

* feat(example): add CIMD OAuth server for SEP-991 testing

Implements a new server example (servers_cimd_auth_streamhttp) that
demonstrates CIMD (Client ID Metadata Document) support for URL-based
client IDs. The server validates client_id URLs, fetches and validates
client metadata documents, and provides OAuth 2.0 authorization endpoints
with MCP integration for end-to-end testing.

Signed-off-by: tanish111 <tanishdesai37@gmail.com>

* fix(oauth): add CORS headers to token endpoint

Add CORS headers to token endpoint to allow cross-origin requests from browsers
during OAuth authorization code exchange flow.

Signed-off-by: tanish111 <tanishdesai37@gmail.com>

* refactor: improve is_https_url function and consolidate tests

- Improve is_https_url function formatting and readability
- Merge all test cases into single test_is_https_url_scenarios function
- Add missing test case for "https://" URL

Signed-off-by: tanish111 <tanishdesai37@gmail.com>

* refactor: use map_err instead of match for error handling in auth.rs

Replace the verbose match statement with
map_err for more idiomatic

Signed-off-by: tanish111 <tanishdesai37@gmail.com>

* feat: add client-metadata.json

Add client metadata file for SEP-991 CIMD
authentication support

Signed-off-by: tanish111 <tanishdesai37@gmail.com>

---------

Signed-off-by: tanish111 <tanishdesai37@gmail.com>
This commit is contained in:
Tanish Desai 2025-12-10 19:25:38 +05:30 • committed by GitHub
commit f20ed202af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -240,6 +240,15 @@ struct AuthorizationState {
csrf_token: CsrfToken,
}
/// SEP-991: URL-based Client IDs
/// Validate that the client_id is a valid URL with https scheme and non-root pathname
fn is_https_url(value: &str) -> bool {
Url::parse(value)
.ok()
.map(|url| url.scheme() == "https" && url.path() != "/" && url.host_str().is_some())
.unwrap_or(false)
}
impl AuthorizationManager {
fn well_known_paths(base_path: &str, resource: &str) -> Vec<String> {
let trimmed = base_path.trim_start_matches('/').trim_end_matches('/');
@ -968,30 +977,57 @@ impl AuthorizationSession {
scopes: &[&str],
redirect_uri: &str,
client_name: Option<&str>,
client_metadata_url: Option<&str>,
) -> Result<Self, AuthError> {
// Default client config
let config = OAuthClientConfig {
client_id: "mcp-client".to_string(),
client_secret: None,
scopes: scopes.iter().map(|s| s.to_string()).collect(),
redirect_uri: redirect_uri.to_string(),
};
let metadata = auth_manager.metadata.as_ref();
let supports_url_based_client_id = metadata
.and_then(|m| {
m.additional_fields
.get("client_id_metadata_document_supported")
})
.and_then(|v| v.as_bool())
.unwrap_or(false);
// try to dynamic register client
let config = match auth_manager
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri)
.await
{
Ok(config) => config,
Err(e) => {
warn!(
"Dynamic registration failed: {}, fallback to default config",
e
);
// fallback to default config
config
let config = if supports_url_based_client_id {
if let Some(client_metadata_url) = client_metadata_url {
if !is_https_url(client_metadata_url) {
return Err(AuthError::RegistrationFailed(format!(
"client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {}",
client_metadata_url
)));
}
// SEP-991: URL-based Client IDs - use URL as client_id directly
OAuthClientConfig {
client_id: client_metadata_url.to_string(),
client_secret: None,
scopes: scopes.iter().map(|s| s.to_string()).collect(),
redirect_uri: redirect_uri.to_string(),
}
} else {
// Fallback to dynamic registration
auth_manager
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri)
.await
.map_err(|e| {
AuthError::RegistrationFailed(format!("Dynamic registration failed: {}", e))
})?
}
} else {
// Fallback to dynamic registration
match auth_manager
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri)
.await
{
Ok(config) => config,
Err(e) => {
return Err(AuthError::RegistrationFailed(format!(
"Dynamic registration failed: {}",
e
)));
}
}
};
// reset client config
auth_manager.configure_client(config)?;
let auth_url = auth_manager.get_authorization_url(scopes).await?;
@ -1143,6 +1179,18 @@ impl OAuthState {
scopes: &[&str],
redirect_uri: &str,
client_name: Option<&str>,
) -> Result<(), AuthError> {
self.start_authorization_with_metadata_url(scopes, redirect_uri, client_name, None)
.await
}
/// start authorization with optional client metadata URL (SEP-991)
pub async fn start_authorization_with_metadata_url(
&mut self,
scopes: &[&str],
redirect_uri: &str,
client_name: Option<&str>,
client_metadata_url: Option<&str>,
) -> Result<(), AuthError> {
if let OAuthState::Unauthorized(mut manager) = std::mem::replace(
self,
@ -1152,8 +1200,14 @@ impl OAuthState {
let metadata = manager.discover_metadata().await?;
manager.metadata = Some(metadata);
debug!("start session");
let session =
AuthorizationSession::new(manager, scopes, redirect_uri, client_name).await?;
let session = AuthorizationSession::new(
manager,
scopes,
redirect_uri,
client_name,
client_metadata_url,
)
.await?;
*self = OAuthState::Session(session);
Ok(())
} else {
@ -1274,7 +1328,31 @@ impl OAuthState {
mod tests {
use url::Url;
use super::AuthorizationManager;
use super::{AuthorizationManager, is_https_url};
// SEP-991: URL-based Client IDs
// Tests adapted from the TypeScript SDK's isHttpsUrl test suite
#[test]
fn test_is_https_url_scenarios() {
// Returns true for valid https url with path
assert!(is_https_url("https://example.com/client-metadata.json"));
// Returns true for https url with query params
assert!(is_https_url("https://example.com/metadata?version=1"));
// Returns false for https url without path
assert!(!is_https_url("https://example.com"));
assert!(!is_https_url("https://example.com/"));
assert!(!is_https_url("https://"));
// Returns false for http url
assert!(!is_https_url("http://example.com/metadata"));
// Returns false for non-url strings
assert!(!is_https_url("not a url"));
// Returns false for empty string
assert!(!is_https_url(""));
// Returns false for javascript scheme
assert!(!is_https_url("javascript:alert(1)"));
// Returns false for data scheme
assert!(!is_https_url("data:text/html,<script>alert(1)</script>"));
}
#[test]
fn parses_resource_metadata_parameter() {