Skip to content

Commit c493017

Browse files
committed
feat: expose oauth discovery metadata source
1 parent 7698802 commit c493017

1 file changed

Lines changed: 199 additions & 7 deletions

File tree

crates/rmcp/src/transport/auth.rs

Lines changed: 199 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,38 @@ pub struct AuthorizationMetadata {
578578
pub additional_fields: HashMap<String, serde_json::Value>,
579579
}
580580

581+
/// How [`AuthorizationMetadata`] was obtained during discovery.
582+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583+
#[non_exhaustive]
584+
pub enum AuthorizationMetadataSource {
585+
/// Discovered through RFC 9728 protected resource metadata.
586+
ProtectedResourceMetadata,
587+
/// Discovered through RFC 8414 / OpenID Connect metadata at the server's
588+
/// base URL.
589+
AuthorizationServerMetadata,
590+
/// Nothing was discovered; the endpoints were synthesized from the base
591+
/// URL (`/authorize`, `/token`, `/register`) for compatibility with the
592+
/// 2025-03-26 MCP spec's default-endpoint fallback. The server gave no
593+
/// evidence that it supports OAuth.
594+
LegacyEndpointFallback,
595+
}
596+
597+
impl AuthorizationMetadataSource {
598+
/// Whether the metadata was actually published by the server, as opposed
599+
/// to synthesized by the client as a legacy compatibility fallback.
600+
pub fn is_discovered(self) -> bool {
601+
!matches!(self, Self::LegacyEndpointFallback)
602+
}
603+
}
604+
605+
/// [`AuthorizationMetadata`] together with its discovery provenance.
606+
#[derive(Debug, Clone)]
607+
#[non_exhaustive]
608+
pub struct DiscoveredAuthorizationMetadata {
609+
pub metadata: AuthorizationMetadata,
610+
pub source: AuthorizationMetadataSource,
611+
}
612+
581613
#[derive(Debug, Clone, Deserialize)]
582614
struct ResourceServerMetadata {
583615
resource: Option<String>,
@@ -1321,17 +1353,45 @@ impl AuthorizationManager {
13211353
}
13221354

13231355
/// discover oauth2 metadata (per SEP-985: Protected Resource Metadata first, then direct OAuth)
1356+
///
1357+
/// When no metadata can be discovered, this falls back to legacy default
1358+
/// endpoints derived from the base URL rather than returning an error, so
1359+
/// a successful result does not prove the server supports OAuth. Callers
1360+
/// that need to tell verified discovery apart from the synthesized
1361+
/// fallback should use [`Self::discover_metadata_with_source`] instead.
13241362
pub async fn discover_metadata(&self) -> Result<AuthorizationMetadata, AuthError> {
1363+
Ok(self.discover_metadata_with_source().await?.metadata)
1364+
}
1365+
1366+
/// Discover oauth2 metadata along with how it was obtained.
1367+
///
1368+
/// Unlike [`Self::discover_metadata`], the returned
1369+
/// [`AuthorizationMetadataSource`] lets callers distinguish metadata the
1370+
/// server actually published from the legacy default-endpoint fallback
1371+
/// that is synthesized when discovery finds nothing
1372+
/// ([`AuthorizationMetadataSource::LegacyEndpointFallback`]).
1373+
pub async fn discover_metadata_with_source(
1374+
&self,
1375+
) -> Result<DiscoveredAuthorizationMetadata, AuthError> {
13251376
if let Some(metadata) = self.discover_oauth_server_via_resource_metadata().await? {
1326-
return Ok(metadata);
1377+
return Ok(DiscoveredAuthorizationMetadata {
1378+
metadata,
1379+
source: AuthorizationMetadataSource::ProtectedResourceMetadata,
1380+
});
13271381
}
13281382

13291383
if let Some(metadata) = self.try_discover_oauth_server(&self.base_url).await? {
1330-
return Ok(metadata);
1384+
return Ok(DiscoveredAuthorizationMetadata {
1385+
metadata,
1386+
source: AuthorizationMetadataSource::AuthorizationServerMetadata,
1387+
});
13311388
}
13321389

13331390
debug!("falling back to legacy OAuth endpoints derived from the base URL");
1334-
Ok(Self::legacy_authorization_metadata(&self.base_url))
1391+
Ok(DiscoveredAuthorizationMetadata {
1392+
metadata: Self::legacy_authorization_metadata(&self.base_url),
1393+
source: AuthorizationMetadataSource::LegacyEndpointFallback,
1394+
})
13351395
}
13361396

13371397
fn legacy_authorization_metadata(base_url: &Url) -> AuthorizationMetadata {
@@ -3691,10 +3751,10 @@ mod tests {
36913751

36923752
use super::{
36933753
AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata,
3694-
AuthorizationRequest, AuthorizationSession, CredentialStore, InMemoryCredentialStore,
3695-
InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError,
3696-
OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig,
3697-
StateStore, StoredAuthorizationState, is_https_url,
3754+
AuthorizationMetadataSource, AuthorizationRequest, AuthorizationSession, CredentialStore,
3755+
InMemoryCredentialStore, InMemoryStateStore, OAuthClientConfig, OAuthHttpClient,
3756+
OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest,
3757+
ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url,
36983758
};
36993759
use crate::transport::auth::VendorExtraTokenFields;
37003760

@@ -4120,6 +4180,138 @@ mod tests {
41204180
);
41214181
}
41224182

4183+
#[tokio::test]
4184+
async fn discover_metadata_with_source_reports_legacy_fallback_when_nothing_is_discovered() {
4185+
let client = RecordingOAuthHttpClient::with_responses(vec![
4186+
empty_response(404),
4187+
empty_response(404),
4188+
empty_response(404),
4189+
empty_response(404),
4190+
empty_response(404),
4191+
]);
4192+
let manager = AuthorizationManager::new_with_oauth_http_client(
4193+
"https://legacy.example.com/",
4194+
Arc::new(client),
4195+
)
4196+
.await
4197+
.unwrap();
4198+
4199+
let discovered = manager.discover_metadata_with_source().await.unwrap();
4200+
4201+
assert_eq!(
4202+
(
4203+
discovered.source,
4204+
discovered.metadata.authorization_endpoint.as_str(),
4205+
),
4206+
(
4207+
AuthorizationMetadataSource::LegacyEndpointFallback,
4208+
"https://legacy.example.com/authorize",
4209+
)
4210+
);
4211+
}
4212+
4213+
#[tokio::test]
4214+
async fn discover_metadata_with_source_reports_protected_resource_metadata() {
4215+
let challenge = oauth2::http::Response::builder()
4216+
.status(401)
4217+
.header(
4218+
"www-authenticate",
4219+
r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#,
4220+
)
4221+
.body(Vec::new())
4222+
.unwrap();
4223+
let client = RecordingOAuthHttpClient::with_responses(vec![
4224+
challenge,
4225+
http_response(
4226+
200,
4227+
serde_json::json!({
4228+
"resource": "https://mcp.example.com/mcp",
4229+
"authorization_servers": ["https://auth.example.com"]
4230+
}),
4231+
),
4232+
http_response(
4233+
200,
4234+
serde_json::json!({
4235+
"issuer": "https://auth.example.com",
4236+
"authorization_endpoint": "https://auth.example.com/authorize",
4237+
"token_endpoint": "https://auth.example.com/token"
4238+
}),
4239+
),
4240+
]);
4241+
let manager = AuthorizationManager::new_with_oauth_http_client(
4242+
"https://mcp.example.com/mcp",
4243+
Arc::new(client),
4244+
)
4245+
.await
4246+
.unwrap();
4247+
4248+
let discovered = manager.discover_metadata_with_source().await.unwrap();
4249+
4250+
assert_eq!(
4251+
(
4252+
discovered.source,
4253+
discovered.metadata.token_endpoint.as_str(),
4254+
),
4255+
(
4256+
AuthorizationMetadataSource::ProtectedResourceMetadata,
4257+
"https://auth.example.com/token",
4258+
)
4259+
);
4260+
}
4261+
4262+
#[tokio::test]
4263+
async fn discover_metadata_with_source_reports_authorization_server_metadata() {
4264+
let client = RecordingOAuthHttpClient::with_responses(vec![
4265+
empty_response(404),
4266+
empty_response(404),
4267+
empty_response(404),
4268+
http_response(
4269+
200,
4270+
serde_json::json!({
4271+
"issuer": "https://mcp.example.com",
4272+
"authorization_endpoint": "https://mcp.example.com/oauth/authorize",
4273+
"token_endpoint": "https://mcp.example.com/oauth/token"
4274+
}),
4275+
),
4276+
]);
4277+
let manager = AuthorizationManager::new_with_oauth_http_client(
4278+
"https://mcp.example.com/",
4279+
Arc::new(client),
4280+
)
4281+
.await
4282+
.unwrap();
4283+
4284+
let discovered = manager.discover_metadata_with_source().await.unwrap();
4285+
4286+
assert_eq!(
4287+
(
4288+
discovered.source,
4289+
discovered.metadata.token_endpoint.as_str(),
4290+
),
4291+
(
4292+
AuthorizationMetadataSource::AuthorizationServerMetadata,
4293+
"https://mcp.example.com/oauth/token",
4294+
)
4295+
);
4296+
}
4297+
4298+
#[rstest]
4299+
#[case::protected_resource_metadata(
4300+
AuthorizationMetadataSource::ProtectedResourceMetadata,
4301+
true
4302+
)]
4303+
#[case::authorization_server_metadata(
4304+
AuthorizationMetadataSource::AuthorizationServerMetadata,
4305+
true
4306+
)]
4307+
#[case::legacy_endpoint_fallback(AuthorizationMetadataSource::LegacyEndpointFallback, false)]
4308+
fn is_discovered_is_false_only_for_the_legacy_fallback(
4309+
#[case] source: AuthorizationMetadataSource,
4310+
#[case] expected: bool,
4311+
) {
4312+
assert_eq!(source.is_discovered(), expected);
4313+
}
4314+
41234315
fn preregistered_as_metadata_response() -> HttpResponse {
41244316
http_response(
41254317
200,

0 commit comments

Comments
 (0)