feat: specify OIDC application_type during dynamic client registration (SEP-837) (#883)
* feat(auth): specify OIDC application_type during client registration SEP-837 [1] requires an MCP client to specify an application_type during OIDC Dynamic Client Registration. When it is omitted, OIDC servers default the client to "web", which conflicts with the loopback redirect URIs that CLI and desktop clients use, so the registration can be rejected. I make register_client always send an application_type. It defaults to "native" to match the loopback redirect this SDK uses, and I added OAuthClientConfig::with_application_type so web clients can opt in. Tests cover the serialized request body and the config default. Implements [2]. [1]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/authorization.mdx#L395 [2]: https://github.com/modelcontextprotocol/rust-sdk/issues/880 Signed-off-by: Stefano Amorelli <stefano@amorelli.tech> * chore(auth): declare application_type in client metadata document I set application_type to "native" in the hosted client metadata document so the URL-based client id flow and dynamic registration agree on the client type that SEP-837 [1] expects. [1]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/authorization.mdx#L395 Signed-off-by: Stefano Amorelli <stefano@amorelli.tech> --------- Signed-off-by: Stefano Amorelli <stefano@amorelli.tech>
This commit is contained in:
parent
82b04a31f5
commit
f1ef2ec86c
2 changed files with 81 additions and 2 deletions
|
|
@ -3,5 +3,6 @@
|
||||||
"redirect_uris": ["http://127.0.0.1:8080/callback"],
|
"redirect_uris": ["http://127.0.0.1:8080/callback"],
|
||||||
"grant_types": ["authorization_code", "refresh_token"],
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
"response_types": ["code"],
|
"response_types": ["code"],
|
||||||
"token_endpoint_auth_method": "none"
|
"token_endpoint_auth_method": "none",
|
||||||
|
"application_type": "native"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ impl<'c> AsyncHttpClient<'c> for OAuthReqwestClient {
|
||||||
|
|
||||||
const DEFAULT_EXCHANGE_URL: &str = "http://localhost";
|
const DEFAULT_EXCHANGE_URL: &str = "http://localhost";
|
||||||
|
|
||||||
|
/// Default OIDC Dynamic Client Registration `application_type` (SEP-837)
|
||||||
|
const DEFAULT_APPLICATION_TYPE: &str = "native";
|
||||||
|
|
||||||
/// Stored credentials for OAuth2 authorization
|
/// Stored credentials for OAuth2 authorization
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
|
|
@ -423,6 +426,7 @@ pub struct OAuthClientConfig {
|
||||||
pub client_secret: Option<String>,
|
pub client_secret: Option<String>,
|
||||||
pub scopes: Vec<String>,
|
pub scopes: Vec<String>,
|
||||||
pub redirect_uri: String,
|
pub redirect_uri: String,
|
||||||
|
pub application_type: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OAuthClientConfig {
|
impl OAuthClientConfig {
|
||||||
|
|
@ -432,6 +436,7 @@ impl OAuthClientConfig {
|
||||||
client_secret: None,
|
client_secret: None,
|
||||||
scopes: Vec::new(),
|
scopes: Vec::new(),
|
||||||
redirect_uri: redirect_uri.into(),
|
redirect_uri: redirect_uri.into(),
|
||||||
|
application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,6 +449,12 @@ impl OAuthClientConfig {
|
||||||
self.scopes = scopes;
|
self.scopes = scopes;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the OIDC Dynamic Client Registration `application_type` (SEP-837), e.g. `"native"` or `"web"`
|
||||||
|
pub fn with_application_type(mut self, application_type: impl Into<String>) -> Self {
|
||||||
|
self.application_type = Some(application_type.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// add type aliases for oauth2 types
|
// add type aliases for oauth2 types
|
||||||
|
|
@ -613,6 +624,8 @@ pub struct AuthorizationManager {
|
||||||
www_auth_scopes: RwLock<Vec<String>>,
|
www_auth_scopes: RwLock<Vec<String>>,
|
||||||
/// scopes_supported from protected resource metadata (RFC 9728)
|
/// scopes_supported from protected resource metadata (RFC 9728)
|
||||||
resource_scopes: RwLock<Vec<String>>,
|
resource_scopes: RwLock<Vec<String>>,
|
||||||
|
/// OIDC Dynamic Client Registration `application_type` (SEP-837)
|
||||||
|
application_type: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -624,6 +637,8 @@ pub(crate) struct ClientRegistrationRequest {
|
||||||
pub response_types: Vec<String>,
|
pub response_types: Vec<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub scope: Option<String>,
|
pub scope: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub application_type: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -707,6 +722,7 @@ impl AuthorizationManager {
|
||||||
scope_upgrade_config: ScopeUpgradeConfig::default(),
|
scope_upgrade_config: ScopeUpgradeConfig::default(),
|
||||||
www_auth_scopes: RwLock::new(Vec::new()),
|
www_auth_scopes: RwLock::new(Vec::new()),
|
||||||
resource_scopes: RwLock::new(Vec::new()),
|
resource_scopes: RwLock::new(Vec::new()),
|
||||||
|
application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(manager)
|
Ok(manager)
|
||||||
|
|
@ -800,6 +816,11 @@ impl AuthorizationManager {
|
||||||
return Err(AuthError::NoAuthorizationSupport);
|
return Err(AuthError::NoAuthorizationSupport);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SEP-837: only override application_type when the config sets one
|
||||||
|
if let Some(application_type) = &config.application_type {
|
||||||
|
self.application_type = Some(application_type.clone());
|
||||||
|
}
|
||||||
|
|
||||||
let metadata = self.metadata.as_ref().unwrap();
|
let metadata = self.metadata.as_ref().unwrap();
|
||||||
|
|
||||||
let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone())
|
let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone())
|
||||||
|
|
@ -890,6 +911,7 @@ impl AuthorizationManager {
|
||||||
};
|
};
|
||||||
self.validate_server_metadata("code")?;
|
self.validate_server_metadata("code")?;
|
||||||
|
|
||||||
|
let application_type = self.application_type.clone();
|
||||||
let registration_request = ClientRegistrationRequest {
|
let registration_request = ClientRegistrationRequest {
|
||||||
client_name: name.to_string(),
|
client_name: name.to_string(),
|
||||||
redirect_uris: vec![redirect_uri.to_string()],
|
redirect_uris: vec![redirect_uri.to_string()],
|
||||||
|
|
@ -904,6 +926,7 @@ impl AuthorizationManager {
|
||||||
} else {
|
} else {
|
||||||
Some(scopes.join(" "))
|
Some(scopes.join(" "))
|
||||||
},
|
},
|
||||||
|
application_type: application_type.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = match self
|
let response = match self
|
||||||
|
|
@ -958,6 +981,7 @@ impl AuthorizationManager {
|
||||||
client_secret: reg_response.client_secret.filter(|s| !s.is_empty()),
|
client_secret: reg_response.client_secret.filter(|s| !s.is_empty()),
|
||||||
redirect_uri: redirect_uri.to_string(),
|
redirect_uri: redirect_uri.to_string(),
|
||||||
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
||||||
|
application_type,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.configure_client(config.clone())?;
|
self.configure_client(config.clone())?;
|
||||||
|
|
@ -972,6 +996,8 @@ impl AuthorizationManager {
|
||||||
client_secret: None,
|
client_secret: None,
|
||||||
scopes: vec![],
|
scopes: vec![],
|
||||||
redirect_uri: self.base_url.to_string(),
|
redirect_uri: self.base_url.to_string(),
|
||||||
|
// keep the manager's current application_type
|
||||||
|
application_type: None,
|
||||||
};
|
};
|
||||||
self.configure_client(config)
|
self.configure_client(config)
|
||||||
}
|
}
|
||||||
|
|
@ -2140,12 +2166,14 @@ impl AuthorizationSession {
|
||||||
client_metadata_url
|
client_metadata_url
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
// SEP-991: URL-based Client IDs - use URL as client_id directly
|
// SEP-991: URL-based Client IDs - use URL as client_id directly.
|
||||||
|
// SEP-837: match the hosted client-metadata.json application_type ("native")
|
||||||
OAuthClientConfig {
|
OAuthClientConfig {
|
||||||
client_id: client_metadata_url.to_string(),
|
client_id: client_metadata_url.to_string(),
|
||||||
client_secret: None,
|
client_secret: None,
|
||||||
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
||||||
redirect_uri: redirect_uri.to_string(),
|
redirect_uri: redirect_uri.to_string(),
|
||||||
|
application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to dynamic registration
|
// Fallback to dynamic registration
|
||||||
|
|
@ -3117,6 +3145,7 @@ mod tests {
|
||||||
client_secret: Some("my-secret".to_string()),
|
client_secret: Some("my-secret".to_string()),
|
||||||
scopes: vec![],
|
scopes: vec![],
|
||||||
redirect_uri: "http://localhost/callback".to_string(),
|
redirect_uri: "http://localhost/callback".to_string(),
|
||||||
|
application_type: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3678,6 +3707,7 @@ mod tests {
|
||||||
token_endpoint_auth_method: "none".to_string(),
|
token_endpoint_auth_method: "none".to_string(),
|
||||||
response_types: vec!["code".to_string()],
|
response_types: vec!["code".to_string()],
|
||||||
scope: Some("read write".to_string()),
|
scope: Some("read write".to_string()),
|
||||||
|
application_type: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&req).unwrap();
|
let json = serde_json::to_value(&req).unwrap();
|
||||||
assert_eq!(json["scope"], "read write");
|
assert_eq!(json["scope"], "read write");
|
||||||
|
|
@ -3692,11 +3722,59 @@ mod tests {
|
||||||
token_endpoint_auth_method: "none".to_string(),
|
token_endpoint_auth_method: "none".to_string(),
|
||||||
response_types: vec!["code".to_string()],
|
response_types: vec!["code".to_string()],
|
||||||
scope: None,
|
scope: None,
|
||||||
|
application_type: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&req).unwrap();
|
let json = serde_json::to_value(&req).unwrap();
|
||||||
assert!(!json.as_object().unwrap().contains_key("scope"));
|
assert!(!json.as_object().unwrap().contains_key("scope"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- ClientRegistrationRequest application_type (SEP-837) --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_registration_request_includes_application_type_when_present() {
|
||||||
|
let req = super::ClientRegistrationRequest {
|
||||||
|
client_name: "test".to_string(),
|
||||||
|
redirect_uris: vec!["http://localhost/callback".to_string()],
|
||||||
|
grant_types: vec!["authorization_code".to_string()],
|
||||||
|
token_endpoint_auth_method: "none".to_string(),
|
||||||
|
response_types: vec!["code".to_string()],
|
||||||
|
scope: None,
|
||||||
|
application_type: Some("native".to_string()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&req).unwrap();
|
||||||
|
assert_eq!(json["application_type"], "native");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_registration_request_omits_application_type_when_none() {
|
||||||
|
let req = super::ClientRegistrationRequest {
|
||||||
|
client_name: "test".to_string(),
|
||||||
|
redirect_uris: vec!["http://localhost/callback".to_string()],
|
||||||
|
grant_types: vec!["authorization_code".to_string()],
|
||||||
|
token_endpoint_auth_method: "none".to_string(),
|
||||||
|
response_types: vec!["code".to_string()],
|
||||||
|
scope: None,
|
||||||
|
application_type: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&req).unwrap();
|
||||||
|
assert!(!json.as_object().unwrap().contains_key("application_type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- OAuthClientConfig application_type (SEP-837) --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_client_config_defaults_application_type_to_native() {
|
||||||
|
let config = super::OAuthClientConfig::new("client-id", "http://127.0.0.1:8080/callback");
|
||||||
|
assert_eq!(config.application_type.as_deref(), Some("native"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_client_config_with_application_type_overrides_default() {
|
||||||
|
let config = super::OAuthClientConfig::new("client-id", "https://app.example.com/callback")
|
||||||
|
.with_application_type("web");
|
||||||
|
assert_eq!(config.application_type.as_deref(), Some("web"));
|
||||||
|
}
|
||||||
|
|
||||||
// -- client credentials (SEP-1046) --
|
// -- client credentials (SEP-1046) --
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue