Skip to content

Commit e72fb6d

Browse files
feat: expose OAuth metadata discovery provenance (#1037)
1 parent e660b80 commit e72fb6d

1 file changed

Lines changed: 185 additions & 8 deletions

File tree

crates/rmcp/src/transport/auth.rs

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

581+
/// Describes how authorization metadata was obtained during OAuth discovery.
582+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583+
#[non_exhaustive]
584+
pub enum AuthorizationMetadataSource {
585+
/// Protected resource metadata identified the authorization server.
586+
ProtectedResourceMetadata,
587+
/// Authorization server metadata was discovered directly from the base URL.
588+
AuthorizationServerMetadata,
589+
/// No metadata was discovered; endpoints were synthesized for legacy OAuth.
590+
LegacyEndpointFallback,
591+
}
592+
593+
/// Authorization metadata together with the discovery mechanism that produced it.
594+
#[derive(Debug, Clone)]
595+
#[non_exhaustive]
596+
pub struct DiscoveredAuthorizationMetadata {
597+
/// The discovered authorization metadata or synthesized legacy endpoints.
598+
pub metadata: AuthorizationMetadata,
599+
/// Whether the metadata was discovered or synthesized as a legacy fallback.
600+
pub source: AuthorizationMetadataSource,
601+
}
602+
581603
#[derive(Debug, Clone, Deserialize)]
582604
struct ResourceServerMetadata {
583605
resource: Option<String>,
@@ -1320,18 +1342,44 @@ impl AuthorizationManager {
13201342
Ok(())
13211343
}
13221344

1323-
/// discover oauth2 metadata (per SEP-985: Protected Resource Metadata first, then direct OAuth)
1345+
/// Discover OAuth metadata, preserving legacy endpoint fallback compatibility.
1346+
///
1347+
/// Use [`Self::discover_metadata_with_source`] when callers need to
1348+
/// distinguish discovered metadata from synthesized legacy endpoints.
13241349
pub async fn discover_metadata(&self) -> Result<AuthorizationMetadata, AuthError> {
1350+
self.discover_metadata_with_source()
1351+
.await
1352+
.map(|discovered| discovered.metadata)
1353+
}
1354+
1355+
/// Discover OAuth metadata and identify the mechanism that produced it.
1356+
///
1357+
/// Protected resource metadata is tried first, followed by direct
1358+
/// authorization server metadata discovery. If neither succeeds, legacy
1359+
/// endpoints are synthesized for compatibility and explicitly identified
1360+
/// as [`AuthorizationMetadataSource::LegacyEndpointFallback`].
1361+
pub async fn discover_metadata_with_source(
1362+
&self,
1363+
) -> Result<DiscoveredAuthorizationMetadata, AuthError> {
13251364
if let Some(metadata) = self.discover_oauth_server_via_resource_metadata().await? {
1326-
return Ok(metadata);
1365+
return Ok(DiscoveredAuthorizationMetadata {
1366+
metadata,
1367+
source: AuthorizationMetadataSource::ProtectedResourceMetadata,
1368+
});
13271369
}
13281370

13291371
if let Some(metadata) = self.try_discover_oauth_server(&self.base_url).await? {
1330-
return Ok(metadata);
1372+
return Ok(DiscoveredAuthorizationMetadata {
1373+
metadata,
1374+
source: AuthorizationMetadataSource::AuthorizationServerMetadata,
1375+
});
13311376
}
13321377

13331378
debug!("falling back to legacy OAuth endpoints derived from the base URL");
1334-
Ok(Self::legacy_authorization_metadata(&self.base_url))
1379+
Ok(DiscoveredAuthorizationMetadata {
1380+
metadata: Self::legacy_authorization_metadata(&self.base_url),
1381+
source: AuthorizationMetadataSource::LegacyEndpointFallback,
1382+
})
13351383
}
13361384

13371385
fn legacy_authorization_metadata(base_url: &Url) -> AuthorizationMetadata {
@@ -3695,10 +3743,10 @@ mod tests {
36953743

36963744
use super::{
36973745
AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata,
3698-
AuthorizationRequest, AuthorizationSession, CredentialStore, InMemoryCredentialStore,
3699-
InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError,
3700-
OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig,
3701-
StateStore, StoredAuthorizationState, is_https_url,
3746+
AuthorizationMetadataSource, AuthorizationRequest, AuthorizationSession, CredentialStore,
3747+
InMemoryCredentialStore, InMemoryStateStore, OAuthClientConfig, OAuthHttpClient,
3748+
OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest,
3749+
ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url,
37023750
};
37033751
use crate::transport::auth::VendorExtraTokenFields;
37043752

@@ -3830,6 +3878,97 @@ mod tests {
38303878
);
38313879
}
38323880

3881+
#[tokio::test]
3882+
async fn discover_metadata_with_source_identifies_protected_resource_metadata() {
3883+
let challenge = oauth2::http::Response::builder()
3884+
.status(401)
3885+
.header(
3886+
"www-authenticate",
3887+
r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#,
3888+
)
3889+
.body(Vec::new())
3890+
.unwrap();
3891+
let client = RecordingOAuthHttpClient::with_responses(vec![
3892+
challenge,
3893+
http_response(
3894+
200,
3895+
serde_json::json!({
3896+
"resource": "https://mcp.example.com/mcp",
3897+
"authorization_servers": ["https://auth.example.com"]
3898+
}),
3899+
),
3900+
http_response(
3901+
200,
3902+
serde_json::json!({
3903+
"issuer": "https://auth.example.com",
3904+
"authorization_endpoint": "https://auth.example.com/authorize",
3905+
"token_endpoint": "https://auth.example.com/token"
3906+
}),
3907+
),
3908+
]);
3909+
let manager = AuthorizationManager::new_with_oauth_http_client(
3910+
"https://mcp.example.com/mcp",
3911+
Arc::new(client),
3912+
)
3913+
.await
3914+
.unwrap();
3915+
3916+
let discovered = manager.discover_metadata_with_source().await.unwrap();
3917+
3918+
assert_eq!(
3919+
(
3920+
discovered.source,
3921+
discovered.metadata.issuer.as_deref(),
3922+
discovered.metadata.token_endpoint.as_str(),
3923+
),
3924+
(
3925+
AuthorizationMetadataSource::ProtectedResourceMetadata,
3926+
Some("https://auth.example.com"),
3927+
"https://auth.example.com/token",
3928+
)
3929+
);
3930+
}
3931+
3932+
#[tokio::test]
3933+
async fn discover_metadata_with_source_identifies_authorization_server_metadata() {
3934+
let client = RecordingOAuthHttpClient::with_responses(vec![
3935+
empty_response(404),
3936+
empty_response(404),
3937+
empty_response(404),
3938+
http_response(
3939+
200,
3940+
serde_json::json!({
3941+
"issuer": "https://mcp.example.com",
3942+
"authorization_endpoint": "https://mcp.example.com/authorize",
3943+
"token_endpoint": "https://mcp.example.com/token"
3944+
}),
3945+
),
3946+
]);
3947+
let manager = AuthorizationManager::new_with_oauth_http_client(
3948+
"https://mcp.example.com/",
3949+
Arc::new(client.clone()),
3950+
)
3951+
.await
3952+
.unwrap();
3953+
3954+
let discovered = manager.discover_metadata_with_source().await.unwrap();
3955+
3956+
assert_eq!(
3957+
(
3958+
discovered.source,
3959+
discovered.metadata.issuer.as_deref(),
3960+
discovered.metadata.token_endpoint.as_str(),
3961+
client.requests().len(),
3962+
),
3963+
(
3964+
AuthorizationMetadataSource::AuthorizationServerMetadata,
3965+
Some("https://mcp.example.com"),
3966+
"https://mcp.example.com/token",
3967+
4,
3968+
)
3969+
);
3970+
}
3971+
38333972
#[tokio::test]
38343973
async fn protected_resource_metadata_supports_authorization_server_path_insertion() {
38353974
let client = RecordingOAuthHttpClient::with_responses(vec![
@@ -4124,6 +4263,44 @@ mod tests {
41244263
);
41254264
}
41264265

4266+
#[tokio::test]
4267+
async fn discover_metadata_with_source_identifies_legacy_endpoint_fallback() {
4268+
let client = RecordingOAuthHttpClient::with_responses(vec![
4269+
empty_response(404),
4270+
empty_response(404),
4271+
empty_response(404),
4272+
empty_response(404),
4273+
empty_response(404),
4274+
]);
4275+
let manager = AuthorizationManager::new_with_oauth_http_client(
4276+
"https://public.example.com/",
4277+
Arc::new(client.clone()),
4278+
)
4279+
.await
4280+
.unwrap();
4281+
4282+
let discovered = manager.discover_metadata_with_source().await.unwrap();
4283+
4284+
assert_eq!(
4285+
(
4286+
discovered.source,
4287+
discovered.metadata.authorization_endpoint.as_str(),
4288+
discovered.metadata.token_endpoint.as_str(),
4289+
discovered.metadata.registration_endpoint.as_deref(),
4290+
discovered.metadata.issuer.as_deref(),
4291+
client.requests().len(),
4292+
),
4293+
(
4294+
AuthorizationMetadataSource::LegacyEndpointFallback,
4295+
"https://public.example.com/authorize",
4296+
"https://public.example.com/token",
4297+
Some("https://public.example.com/register"),
4298+
None,
4299+
5,
4300+
)
4301+
);
4302+
}
4303+
41274304
fn preregistered_as_metadata_response() -> HttpResponse {
41284305
http_response(
41294306
200,

0 commit comments

Comments
 (0)