Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 86 additions & 3 deletions crates/rmcp/src/transport/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,8 @@ pub struct AuthorizationManager {
www_auth_scopes: RwLock<Vec<String>>,
/// scopes_supported from protected resource metadata (RFC 9728)
resource_scopes: RwLock<Vec<String>>,
/// Explicit resource indicator supplied by the application (RFC 8707).
configured_resource: Option<String>,
/// resource indicator from protected resource metadata, used for RFC 8707 `resource`
discovered_resource: RwLock<Option<String>>,
/// OIDC Dynamic Client Registration `application_type` (SEP-837)
Expand Down Expand Up @@ -1278,6 +1280,7 @@ impl AuthorizationManager {
scope_upgrade_config: ScopeUpgradeConfig::default(),
www_auth_scopes: RwLock::new(Vec::new()),
resource_scopes: RwLock::new(Vec::new()),
configured_resource: None,
discovered_resource: RwLock::new(None),
application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()),
allow_missing_issuer: false,
Expand All @@ -1302,6 +1305,33 @@ impl AuthorizationManager {
self.allow_missing_issuer = allow;
}

/// Set an explicit OAuth resource indicator for authorization and token requests.
///
/// The value takes precedence over protected-resource metadata and the MCP
/// endpoint. Configure it before starting authorization or restoring cached
/// credentials so every request uses the same resource audience.
///
/// Passing `None` restores the normal discovered-resource selection.
pub fn set_resource(&mut self, resource: Option<&str>) -> Result<(), AuthError> {
let Some(resource) = resource else {
self.configured_resource = None;
return Ok(());
};

let parsed = Url::parse(resource).map_err(|error| {
AuthError::MetadataError(format!("Invalid OAuth resource indicator: {error}"))
})?;
if !Self::is_http_url(&parsed) || parsed.fragment().is_some() {
return Err(AuthError::MetadataError(
"OAuth resource indicator must be an absolute HTTP(S) URL without a fragment"
.to_string(),
));
}

self.configured_resource = Some(resource.to_string());
Ok(())
}

/// Set a custom credential store
///
/// This allows you to provide your own implementation of credential storage,
Expand Down Expand Up @@ -1787,6 +1817,10 @@ impl AuthorizationManager {
}

async fn oauth_resource(&self) -> String {
if let Some(resource) = &self.configured_resource {
return resource.clone();
}

self.discovered_resource
.read()
.await
Expand Down Expand Up @@ -4154,8 +4188,17 @@ mod tests {
);
}

#[rstest]
#[case::discovered(None, "https://mcp.example.com")]
#[case::configured(
Some("https://api.example.com/tenant-a"),
"https://api.example.com/tenant-a"
)]
#[tokio::test]
async fn refresh_token_uses_discovered_protected_resource() {
async fn refresh_token_uses_discovered_protected_resource(
#[case] configured_resource: Option<&str>,
#[case] expected_resource: &str,
) {
let challenge = oauth2::http::Response::builder()
.status(401)
.header(
Expand Down Expand Up @@ -4208,6 +4251,7 @@ mod tests {
)
.await
.unwrap();
manager.set_resource(configured_resource).unwrap();

let resolution = manager.resolve_metadata().await.unwrap();
manager.set_metadata(resolution.metadata);
Expand Down Expand Up @@ -4253,11 +4297,50 @@ mod tests {
token_requests[0].get("resource").map(String::as_str),
token_requests[1].get("resource").map(String::as_str),
],
[Some("https://mcp.example.com"); 3],
"authorization, code exchange, and refresh must use the discovered resource audience"
[Some(expected_resource); 3],
"authorization, code exchange, and refresh must use the same resource audience"
);
}

#[rstest]
#[case::relative("api/resource")]
#[case::non_http("file:///tmp/resource")]
#[case::fragment("https://api.example.com/resource#fragment")]
#[case::missing_host("https://")]
#[tokio::test]
async fn configured_resource_rejects_invalid_indicators(#[case] resource: &str) {
let mut manager = AuthorizationManager::new("https://mcp.example.com/mcp")
.await
.unwrap();
manager
.set_resource(Some("https://api.example.com/tenant-a"))
.unwrap();

let error = manager.set_resource(Some(resource)).unwrap_err();

assert!(matches!(error, AuthError::MetadataError(_)));
assert_eq!(
manager.oauth_resource().await,
"https://api.example.com/tenant-a",
"invalid input must not replace the previously configured resource"
);
}

#[tokio::test]
async fn clearing_configured_resource_restores_discovered_resource() {
let mut manager = AuthorizationManager::new("https://mcp.example.com/mcp")
.await
.unwrap();
*manager.discovered_resource.write().await = Some("https://mcp.example.com".to_string());
manager
.set_resource(Some("https://api.example.com/tenant-a"))
.unwrap();

manager.set_resource(None).unwrap();

assert_eq!(manager.oauth_resource().await, "https://mcp.example.com");
}

#[tokio::test]
async fn protected_resource_metadata_supports_authorization_server_path_insertion() {
let client = RecordingOAuthHttpClient::with_responses(vec![
Expand Down