fix(auth): pass WWW-Authenticate scopes to DCR registration request (#705)

* fix(auth): pass WWW-Authenticate scopes to DCR registration request

When an MCP server returns a 401 with `WWW-Authenticate: Bearer scope="..."`,
the scopes are parsed but never included in the Dynamic Client Registration
(DCR) request. Per RFC 7591, the DCR request should include a `scope` field
so the authorization server knows what scopes the client intends to use.
Servers that enforce scope-matching between registration and authorization
will reject the flow without this.

Changes:
- Add optional `scope` field to `ClientRegistrationRequest` with
  `skip_serializing_if` for backward compatibility
- Update `register_client()` to accept scopes parameter and include
  them in the DCR request body and returned `OAuthClientConfig`
- Thread scopes from `AuthorizationSession::new()` into both
  `register_client()` call sites
- Re-export `oauth2::TokenResponse` trait so consumers can extract
  scopes from token responses
- Add serialization tests for the new `scope` field

* refactor(auth): change register_client to accept &[&str] instead of &[String]

Avoids unnecessary Vec<String> allocation in callers that already have &[&str].

* fix(auth): make ClientRegistrationRequest crate-private

* refactor(auth): stop re-exporting oauth2 TokenResponse trait

* style(auth): merge TokenResponse into grouped oauth2 import

Fix nightly rustfmt check by consolidating the separate
`use oauth2::TokenResponse` into the existing `use oauth2::{...}` block.
This commit is contained in:
Peter Siska 2026-03-03 18:43:31 +01:00 • committed by GitHub
commit 434ccb7812
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -438,12 +438,14 @@ pub struct AuthorizationManager {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientRegistrationRequest {
pub(crate) struct ClientRegistrationRequest {
pub client_name: String,
pub redirect_uris: Vec<String>,
pub grant_types: Vec<String>,
pub token_endpoint_auth_method: String,
pub response_types: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -683,6 +685,7 @@ impl AuthorizationManager {
&mut self,
name: &str,
redirect_uri: &str,
scopes: &[&str],
) -> Result<OAuthClientConfig, AuthError> {
if self.metadata.is_none() {
return Err(AuthError::NoAuthorizationSupport);
@ -705,6 +708,11 @@ impl AuthorizationManager {
],
token_endpoint_auth_method: "none".to_string(), // public client
response_types: vec!["code".to_string()],
scope: if scopes.is_empty() {
None
} else {
Some(scopes.join(" "))
},
};
let response = match self
@ -758,7 +766,7 @@ impl AuthorizationManager {
// as a password, which is not a goal of the client secret.
client_secret: reg_response.client_secret.filter(|s| !s.is_empty()),
redirect_uri: redirect_uri.to_string(),
scopes: vec![],
scopes: scopes.iter().map(|s| s.to_string()).collect(),
};
self.configure_client(config.clone())?;
@ -1526,7 +1534,7 @@ impl AuthorizationSession {
} else {
// Fallback to dynamic registration
auth_manager
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri)
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes)
.await
.map_err(|e| {
AuthError::RegistrationFailed(format!("Dynamic registration failed: {}", e))
@ -1535,7 +1543,7 @@ impl AuthorizationSession {
} else {
// Fallback to dynamic registration
match auth_manager
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri)
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes)
.await
{
Ok(config) => config,
@ -2831,4 +2839,34 @@ mod tests {
"expected InternalError when OAuth client is not configured, got: {err:?}"
);
}
// -- ClientRegistrationRequest serialization --
#[test]
fn client_registration_request_includes_scope_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: Some("read write".to_string()),
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["scope"], "read write");
}
#[test]
fn client_registration_request_omits_scope_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,
};
let json = serde_json::to_value(&req).unwrap();
assert!(!json.as_object().unwrap().contains_key("scope"));
}
}