11-25-2025 compliant Auth (#651)

* fix: correct discovery for AS metadata

* fix: add commitlint to dev container

* feat: add RFC 8707 support for resource parameter

* feat: pkce method verification

* feat(auth): implement SEP-835 scope handling and 403 upgrade flow

- add WWWAuthenticateParams for parsing scope and resource_metadata from headers
- add ScopeUpgradeConfig and scope tracking in AuthorizationManager
- add InsufficientScopeError and 403 handling in streamable HTTP client
- add scope union computation for progressive authorization
- export new public types: AuthClient, ScopeUpgradeConfig, WWWAuthenticateParams

Co-authored-by: fizy069 <fizy069@users.noreply.github.com>

* fix: reorg auth tests

* feat: add error to www-authenticate header parsing

* feat: consider protected resource metadata in scope selection

* fix: reorganize auth tests

* feat: add examples and docs for updated auth

---------

Co-authored-by: fizy069 <fizy069@users.noreply.github.com>
This commit is contained in:
Wils Dawson 2026-02-12 08:30:13 -08:00 committed by GitHub
parent a1c66a8a36
commit 61845d61c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1037 additions and 297 deletions

View file

@ -1,41 +1,41 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the // For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/rust // README at: https://github.com/devcontainers/templates/tree/main/src/rust
{ {
"name": "Rust", "name": "Rust",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye", "image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye",
"features": { "features": {
"ghcr.io/devcontainers/features/node:1": {}, "ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/devcontainers/features/python:1": { "ghcr.io/devcontainers/features/python:1": {
"version": "3.10", "version": "3.10",
"toolsToInstall": "uv" "toolsToInstall": "uv"
} }
}, },
// Configure tool-specific properties. // Configure tool-specific properties.
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": { "settings": {
"editor.formatOnSave": true, "editor.formatOnSave": true,
"[rust]": { "[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer" "editor.defaultFormatter": "rust-lang.rust-analyzer"
} }
} }
} }
}, },
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "uv venv" "postCreateCommand": "uv venv && npm install -g @commitlint/config-conventional"
// Use 'mounts' to make the cargo cache persistent in a Docker Volume. // Use 'mounts' to make the cargo cache persistent in a Docker Volume.
// "mounts": [ // "mounts": [
// { // {
// "source": "devcontainer-cargo-cache-${devcontainerId}", // "source": "devcontainer-cargo-cache-${devcontainerId}",
// "target": "/usr/local/cargo", // "target": "/usr/local/cargo",
// "type": "volume" // "type": "volume"
// } // }
// ] // ]
// Features to add to the dev container. More info: https://containers.dev/features. // Features to add to the dev container. More info: https://containers.dev/features.
// "features": {}, // "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [], // "forwardPorts": [],
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root" // "remoteUser": "root"
} }

View file

@ -107,9 +107,9 @@ pub mod auth;
#[cfg(feature = "auth")] #[cfg(feature = "auth")]
#[cfg_attr(docsrs, doc(cfg(feature = "auth")))] #[cfg_attr(docsrs, doc(cfg(feature = "auth")))]
pub use auth::{ pub use auth::{
AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient, CredentialStore, AuthClient, AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient,
InMemoryCredentialStore, InMemoryStateStore, StateStore, StoredAuthorizationState, CredentialStore, InMemoryCredentialStore, InMemoryStateStore, ScopeUpgradeConfig, StateStore,
StoredCredentials, StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams,
}; };
// #[cfg(feature = "transport-ws")] // #[cfg(feature = "transport-ws")]

File diff suppressed because it is too large Load diff

View file

@ -120,6 +120,22 @@ impl StreamableHttpClient for reqwest::Client {
})); }));
} }
} }
if response.status() == reqwest::StatusCode::FORBIDDEN {
if let Some(header) = response.headers().get(WWW_AUTHENTICATE) {
let header_str = header.to_str().map_err(|_| {
StreamableHttpError::UnexpectedServerResponse(Cow::from(
"invalid www-authenticate header value",
))
})?;
let scope = extract_scope_from_header(header_str);
return Err(StreamableHttpError::InsufficientScope(
InsufficientScopeError {
www_authenticate_header: header_str.to_string(),
required_scope: scope,
},
));
}
}
let status = response.status(); let status = response.status();
if matches!( if matches!(
status, status,
@ -197,3 +213,81 @@ impl StreamableHttpClientTransport<reqwest::Client> {
StreamableHttpClientTransport::with_client(reqwest::Client::default(), config) StreamableHttpClientTransport::with_client(reqwest::Client::default(), config)
} }
} }
/// extract scope parameter from WWW-Authenticate header
fn extract_scope_from_header(header: &str) -> Option<String> {
let header_lowercase = header.to_ascii_lowercase();
let scope_key = "scope=";
if let Some(pos) = header_lowercase.find(scope_key) {
let start = pos + scope_key.len();
let value_slice = &header[start..];
if let Some(stripped) = value_slice.strip_prefix('"') {
if let Some(end_quote) = stripped.find('"') {
return Some(stripped[..end_quote].to_string());
}
} else {
let end = value_slice
.find(|c: char| c == ',' || c == ';' || c.is_whitespace())
.unwrap_or(value_slice.len());
if end > 0 {
return Some(value_slice[..end].to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::extract_scope_from_header;
use crate::transport::streamable_http_client::InsufficientScopeError;
#[test]
fn extract_scope_quoted() {
let header = r#"Bearer error="insufficient_scope", scope="files:read files:write""#;
assert_eq!(
extract_scope_from_header(header),
Some("files:read files:write".to_string())
);
}
#[test]
fn extract_scope_unquoted() {
let header = r#"Bearer scope=read:data, error="insufficient_scope""#;
assert_eq!(
extract_scope_from_header(header),
Some("read:data".to_string())
);
}
#[test]
fn extract_scope_missing() {
let header = r#"Bearer error="invalid_token""#;
assert_eq!(extract_scope_from_header(header), None);
}
#[test]
fn extract_scope_empty_header() {
assert_eq!(extract_scope_from_header("Bearer"), None);
}
#[test]
fn insufficient_scope_error_can_upgrade() {
let with_scope = InsufficientScopeError {
www_authenticate_header: "Bearer scope=\"admin\"".to_string(),
required_scope: Some("admin".to_string()),
};
assert!(with_scope.can_upgrade());
assert_eq!(with_scope.get_required_scope(), Some("admin"));
let without_scope = InsufficientScopeError {
www_authenticate_header: "Bearer error=\"insufficient_scope\"".to_string(),
required_scope: None,
};
assert!(!without_scope.can_upgrade());
assert_eq!(without_scope.get_required_scope(), None);
}
}

View file

@ -24,6 +24,24 @@ pub struct AuthRequiredError {
pub www_authenticate_header: String, pub www_authenticate_header: String,
} }
#[derive(Debug)]
pub struct InsufficientScopeError {
pub www_authenticate_header: String,
pub required_scope: Option<String>,
}
impl InsufficientScopeError {
/// check if scope upgrade is possible (i.e., we know what scope is required)
pub fn can_upgrade(&self) -> bool {
self.required_scope.is_some()
}
/// get the required scope for upgrade
pub fn get_required_scope(&self) -> Option<&str> {
self.required_scope.as_deref()
}
}
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum StreamableHttpError<E: std::error::Error + Send + Sync + 'static> { pub enum StreamableHttpError<E: std::error::Error + Send + Sync + 'static> {
#[error("SSE error: {0}")] #[error("SSE error: {0}")]
@ -56,6 +74,8 @@ pub enum StreamableHttpError<E: std::error::Error + Send + Sync + 'static> {
Auth(#[from] crate::transport::auth::AuthError), Auth(#[from] crate::transport::auth::AuthError),
#[error("Auth required")] #[error("Auth required")]
AuthRequired(AuthRequiredError), AuthRequired(AuthRequiredError),
#[error("Insufficient scope")]
InsufficientScope(InsufficientScopeError),
} }
#[derive(Debug, Clone, Error)] #[derive(Debug, Clone, Error)]

View file

@ -1,13 +1,17 @@
# Model Context Protocol OAuth Authorization # Model Context Protocol OAuth Authorization
This document describes the OAuth 2.1 authorization implementation for Model Context Protocol (MCP), following the [MCP 2025-03-26 Authorization Specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization/). This document describes the OAuth 2.1 authorization implementation for Model Context Protocol (MCP), following the [MCP 2025-11-25 Authorization Specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization/).
## Features ## Features
- Full support for OAuth 2.1 authorization flow - Full support for OAuth 2.1 authorization flow with PKCE (S256)
- PKCE support for enhanced security - RFC 8707 resource parameter binding
- Authorization server metadata discovery - Protected Resource Metadata discovery (RFC 9728)
- Dynamic client registration - Authorization Server Metadata discovery (RFC 8414 + OpenID Connect)
- Dynamic client registration (RFC 7591)
- Client ID Metadata Documents (CIMD) (SEP-991 / Client ID Metadata Documents )
- Scope selection from WWW-Authenticate, Protected Resource Metadata, and AS metadata
- Scope upgrade on 403 insufficient_scope (SEP-835)
- Automatic token refresh - Automatic token refresh
- Authorized HTTP Client implementation - Authorized HTTP Client implementation
@ -24,32 +28,43 @@ rmcp = { version = "0.1", features = ["auth", "transport-streamable-http-client-
### 2. Use OAuthState ### 2. Use OAuthState
The `OAuthState` state machine manages the full authorization lifecycle. When no
scopes are provided, the SDK automatically selects scopes from the server's
WWW-Authenticate header, Protected Resource Metadata, or AS metadata.
```rust ignore ```rust ignore
// Initialize oauth state machine // initialize oauth state machine
let mut oauth_state = OAuthState::new(&server_url, None) let mut oauth_state = OAuthState::new(&server_url, None)
.await .await
.context("Failed to initialize oauth state machine")?; .context("Failed to initialize oauth state machine")?;
// start authorization - pass empty scopes to let the SDK auto-select
oauth_state oauth_state
.start_authorization(&["mcp", "profile", "email"], MCP_REDIRECT_URI) .start_authorization(&[], MCP_REDIRECT_URI, Some("My MCP Client"))
.await .await
.context("Failed to start authorization")?; .context("Failed to start authorization")?;
``` ```
### 3. Get authorization url and do callback If you know the scopes you need, you can still pass them explicitly:
```rust ignore ```rust ignore
// Get authorization URL and guide user to open it oauth_state
.start_authorization(&["mcp", "profile"], MCP_REDIRECT_URI, Some("My MCP Client"))
.await
.context("Failed to start authorization")?;
```
### 3. Get authorization url and handle callback
```rust ignore
// get authorization URL and guide user to open it
let auth_url = oauth_state.get_authorization_url().await?; let auth_url = oauth_state.get_authorization_url().await?;
println!("Please open the following URL in your browser for authorization:\n{}", auth_url); println!("Please open the following URL in your browser for authorization:\n{}", auth_url);
// Handle callback - In real applications, this is typically done in a callback server // handle callback - in real applications, this is typically done in a callback server
let auth_code = "Authorization code (`code` param) obtained from browser after user authorization"; let auth_code = "Authorization code (`code` param) obtained from browser after user authorization";
let csrf_token = "CSRF token (`state` param) obtained from browser after user authorization"; let csrf_token = "CSRF token (`state` param) obtained from browser after user authorization";
let credentials = oauth_state.handle_callback(auth_code, csrf_token).await?; oauth_state.handle_callback(auth_code, csrf_token).await?;
println!("Authorization successful, access token: {}", credentials.access_token);
``` ```
### 4. Use Authorized Streamable HTTP Transport and create client ### 4. Use Authorized Streamable HTTP Transport and create client
@ -64,15 +79,27 @@ rmcp = { version = "0.1", features = ["auth", "transport-streamable-http-client-
StreamableHttpClientTransportConfig::with_uri(MCP_SERVER_URL), StreamableHttpClientTransportConfig::with_uri(MCP_SERVER_URL),
); );
// Create client and connect to MCP server // create client and connect to MCP server
let client_service = ClientInfo::default(); let client_service = ClientInfo::default();
let client = client_service.serve(transport).await?; let client = client_service.serve(transport).await?;
``` ```
### 5. Use Authorized HTTP Client after authorized ### 5. Handle scope upgrades
If a server returns 403 with `insufficient_scope`, you can request a scope
upgrade. The SDK computes the union of current and required scopes and
transitions back to the session state for re-authorization.
```rust ignore ```rust ignore
let client = oauth_state.to_authorized_http_client().await?; match oauth_state.request_scope_upgrade("admin:write", MCP_REDIRECT_URI).await {
Ok(auth_url) => {
// open auth_url in browser, handle callback as before
println!("Re-authorize at: {}", auth_url);
}
Err(e) => {
eprintln!("Scope upgrade failed: {}", e);
}
}
``` ```
## Complete Examples ## Complete Examples
@ -92,19 +119,24 @@ cargo run -p mcp-client-examples --example clients_oauth_client
## Authorization Flow Description ## Authorization Flow Description
1. **Metadata Discovery**: Client attempts to get authorization server metadata from `/.well-known/oauth-authorization-server` 1. **Resource Metadata Discovery**: Client probes the server and extracts `WWW-Authenticate` parameters including `resource_metadata` URL and `scope`
2. **Client Registration**: If supported, client dynamically registers itself 2. **Protected Resource Metadata**: Client fetches resource server metadata (RFC 9728) to find authorization server(s) and supported scopes
3. **Authorization Request**: Build authorization URL with PKCE and guide user to access 3. **AS Metadata Discovery**: Client discovers authorization server metadata via RFC 8414 and OpenID Connect well-known endpoints
4. **Authorization Code Exchange**: After user authorization, exchange authorization code for access token 4. **Client Registration**: If supported, client dynamically registers itself (or uses URL-based Client ID via SEP-991)
5. **Token Usage**: Use access token for API calls 5. **Scope Selection**: SDK picks scopes from WWW-Authenticate > PRM > AS metadata > caller defaults
6. **Token Refresh**: Automatically use refresh token to get new access token when current one expires 6. **Authorization Request**: Build authorization URL with PKCE (S256) and RFC 8707 resource parameter
7. **Authorization Code Exchange**: After user authorization, exchange code for access token (with resource parameter)
8. **Token Usage**: Use access token for API calls via `AuthClient` or `AuthorizedHttpClient`
9. **Token Refresh**: Automatically use refresh token to get new access token when current one expires
10. **Scope Upgrade**: On 403 insufficient_scope, compute scope union and re-authorize with upgraded scopes
## Security Considerations ## Security Considerations
- All tokens are securely stored in memory - **PKCE S256 always enforced**: never falls back to `plain` or no challenge. OAuth 2.1 mandates S256 as Mandatory To Implement for servers.
- PKCE implementation prevents authorization code interception attacks - **RFC 8707 resource binding**: authorization and token requests include the `resource` parameter to bind tokens to the protected resource
- Automatic token refresh support reduces user intervention - All tokens are securely stored in memory (custom credential stores supported)
- Only accepts HTTPS connections or secure local callback URIs - Automatic token refresh reduces user intervention
- Server metadata validation warns on non-compliant configurations but proceeds where relatively safe
## Troubleshooting ## Troubleshooting
@ -114,10 +146,15 @@ If you encounter authorization issues, check the following:
2. Verify callback URI matches server's allowed redirect URIs 2. Verify callback URI matches server's allowed redirect URIs
3. Check network connection and firewall settings 3. Check network connection and firewall settings
4. Verify server supports metadata discovery or dynamic client registration 4. Verify server supports metadata discovery or dynamic client registration
5. If PKCE fails, the server may not support S256 (non-compliant with OAuth 2.1)
6. Check `tracing` logs at debug level for detailed discovery and validation info
## References ## References
- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization/) - [MCP Authorization Specification (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization/)
- [OAuth 2.1 Specification Draft](https://oauth.net/2.1/) - [OAuth 2.1 Specification Draft](https://oauth.net/2.1/)
- [RFC 8414: OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) - [RFC 8414: OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414)
- [RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591) - [RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591)
- [RFC 8707: Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728)
- [RFC 7636: Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636)

View file

@ -114,14 +114,16 @@ async fn main() -> Result<()> {
client_metadata_url client_metadata_url
); );
// Initialize oauth state machine // initialize oauth state machine
let mut oauth_state = OAuthState::new(&server_url, None) let mut oauth_state = OAuthState::new(&server_url, None)
.await .await
.context("Failed to initialize oauth state machine")?; .context("Failed to initialize oauth state machine")?;
// Use CIMD (SEP-991) with client metadata URL // use CIMD (SEP-991) with client metadata URL.
// passing empty scopes lets the SDK auto-select from the server's
// WWW-Authenticate header, Protected Resource Metadata, or AS metadata.
oauth_state oauth_state
.start_authorization_with_metadata_url( .start_authorization_with_metadata_url(
&["mcp", "profile", "email"], &[],
MCP_REDIRECT_URI, MCP_REDIRECT_URI,
Some("Test MCP Client"), Some("Test MCP Client"),
Some(&client_metadata_url), Some(&client_metadata_url),

View file

@ -520,16 +520,13 @@ async fn oauth_authorization_server() -> impl IntoResponse {
"response_types_supported".into(), "response_types_supported".into(),
Value::Array(vec![Value::String("code".into())]), Value::Array(vec![Value::String("code".into())]),
); );
additional_fields.insert(
"code_challenge_methods_supported".into(),
Value::Array(vec![Value::String("S256".into())]),
);
let metadata = AuthorizationMetadata { let metadata = AuthorizationMetadata {
authorization_endpoint: format!("http://{}/oauth/authorize", BIND_ADDRESS), authorization_endpoint: format!("http://{}/oauth/authorize", BIND_ADDRESS),
token_endpoint: format!("http://{}/oauth/token", BIND_ADDRESS), token_endpoint: format!("http://{}/oauth/token", BIND_ADDRESS),
scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]), scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]),
registration_endpoint: Some(format!("http://{}/oauth/register", BIND_ADDRESS)), registration_endpoint: Some(format!("http://{}/oauth/register", BIND_ADDRESS)),
response_types_supported: Some(vec!["code".to_string()]), response_types_supported: Some(vec!["code".to_string()]),
code_challenge_methods_supported: Some(vec!["S256".to_string()]),
issuer: Some(BIND_ADDRESS.to_string()), issuer: Some(BIND_ADDRESS.to_string()),
jwks_uri: Some(format!("http://{}/oauth/jwks", BIND_ADDRESS)), jwks_uri: Some(format!("http://{}/oauth/jwks", BIND_ADDRESS)),
additional_fields, additional_fields,