fix: Per RFC 9728, enable dynamic discovery of OAuth endpoints in MCP using path-aware discovery
This commit is contained in:
+193
-20
@@ -14,6 +14,8 @@ use url::Url;
|
||||
struct ProtectedResourceMetadata {
|
||||
#[serde(default)]
|
||||
authorization_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
scopes_supported: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -187,46 +189,122 @@ async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
|
||||
let base = extract_base_url(server_url)?;
|
||||
let client = Client::new();
|
||||
let mut tried: Vec<String> = Vec::new();
|
||||
|
||||
// RFC 9728: try protected resource metadata first; it points to the auth server
|
||||
let pr_url = format!("{base}/.well-known/oauth-protected-resource");
|
||||
if let Ok(resp) = client.get(&pr_url).send().await
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await
|
||||
&& let Some(auth_server) = pr.authorization_servers.first()
|
||||
{
|
||||
let as_url = format!("{auth_server}/.well-known/oauth-authorization-server");
|
||||
// RFC 9728 @ 5.1: an unauthenticated request should yield a 401 whose
|
||||
// WWW-Authenticate challenge advertises the protected resource metadata URL.
|
||||
let mut pr_urls = Vec::new();
|
||||
if let Some(url) = probe_resource_metadata_url(&client, server_url).await {
|
||||
pr_urls.push(url);
|
||||
}
|
||||
|
||||
// RFC 9728 @ 3.1: path-aware well-known URL, then root as legacy fallback.
|
||||
pr_urls.extend(well_known_urls(server_url, "oauth-protected-resource")?);
|
||||
pr_urls.dedup();
|
||||
|
||||
for pr_url in &pr_urls {
|
||||
tried.push(pr_url.clone());
|
||||
let Ok(resp) = client.get(pr_url).send().await else {
|
||||
continue;
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
continue;
|
||||
}
|
||||
let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await else {
|
||||
continue;
|
||||
};
|
||||
let Some(issuer) = pr.authorization_servers.first() else {
|
||||
continue;
|
||||
};
|
||||
// RFC 8414 @ 3.1: for issuers with a path component the well-known
|
||||
// segment is inserted BEFORE the path (with the legacy appended form
|
||||
// and root as fallbacks).
|
||||
for as_url in well_known_urls(issuer, "oauth-authorization-server")? {
|
||||
tried.push(as_url.clone());
|
||||
if let Ok(resp) = client.get(&as_url).send().await
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(meta) = resp.json::<OAuthServerMetadata>().await
|
||||
&& let Ok(mut meta) = resp.json::<OAuthServerMetadata>().await
|
||||
{
|
||||
// Some auth servers (e.g. GitHub) omit scopes_supported from
|
||||
// their metadata; fall back to the resource's advertised scopes.
|
||||
if meta.scopes_supported.is_empty() {
|
||||
meta.scopes_supported = pr.scopes_supported.clone();
|
||||
}
|
||||
return Ok(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let as_url = format!("{base}/.well-known/oauth-authorization-server");
|
||||
let resp = client
|
||||
.get(&as_url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to reach {as_url}"))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
// Last resort: the MCP server itself may host authorization server metadata.
|
||||
for as_url in well_known_urls(server_url, "oauth-authorization-server")? {
|
||||
tried.push(as_url.clone());
|
||||
if let Ok(resp) = client.get(&as_url).send().await
|
||||
&& resp.status().is_success()
|
||||
{
|
||||
return resp
|
||||
.json::<OAuthServerMetadata>()
|
||||
.await
|
||||
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Could not discover OAuth metadata for '{server_url}'.\n\
|
||||
Tried:\n {pr_url}\n {as_url}\n\
|
||||
Ensure the server supports MCP OAuth discovery, or consult its documentation."
|
||||
Tried:\n {}\n\
|
||||
Ensure the server supports MCP OAuth discovery, or consult its documentation.",
|
||||
tried.join("\n ")
|
||||
))
|
||||
}
|
||||
|
||||
/// Probes the MCP server with an unauthenticated request and extracts the
|
||||
/// `resource_metadata` URL from the 401 `WWW-Authenticate` challenge (RFC 9728 @ 5.1).
|
||||
async fn probe_resource_metadata_url(client: &Client, server_url: &str) -> Option<String> {
|
||||
let resp = client.get(server_url).send().await.ok()?;
|
||||
let header = resp.headers().get(reqwest::header::WWW_AUTHENTICATE)?;
|
||||
|
||||
parse_resource_metadata(header.to_str().ok()?)
|
||||
}
|
||||
|
||||
/// Extracts the `resource_metadata` parameter value from a `WWW-Authenticate`
|
||||
/// challenge, e.g. `Bearer error="...", resource_metadata="https://..."`.
|
||||
fn parse_resource_metadata(challenge: &str) -> Option<String> {
|
||||
let (_, rest) = challenge.split_once("resource_metadata=")?;
|
||||
let rest = rest.trim_start();
|
||||
let value = if let Some(stripped) = rest.strip_prefix('"') {
|
||||
stripped.split('"').next()?
|
||||
} else {
|
||||
rest.split([',', ' ']).next()?
|
||||
};
|
||||
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds candidate well-known metadata URLs for `url`, ordered by spec preference:
|
||||
/// 1. Path-aware (RFC 8414 @ 3.1 / RFC 9728 @ 3.1): `{origin}/.well-known/{suffix}{path}`
|
||||
/// 2. Legacy appended form: `{url}/.well-known/{suffix}`
|
||||
/// 3. Root: `{origin}/.well-known/{suffix}`
|
||||
///
|
||||
/// URLs without a path component yield only the root form.
|
||||
fn well_known_urls(url: &str, suffix: &str) -> Result<Vec<String>> {
|
||||
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
||||
let origin = extract_base_url(url)?;
|
||||
let path = parsed.path().trim_end_matches('/');
|
||||
|
||||
let mut urls = Vec::new();
|
||||
if !path.is_empty() && path != "/" {
|
||||
urls.push(format!("{origin}/.well-known/{suffix}{path}"));
|
||||
urls.push(format!("{origin}{path}/.well-known/{suffix}"));
|
||||
}
|
||||
urls.push(format!("{origin}/.well-known/{suffix}"));
|
||||
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
fn extract_base_url(url: &str) -> Result<String> {
|
||||
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
||||
let scheme = parsed.scheme();
|
||||
@@ -296,6 +374,101 @@ mod tests {
|
||||
assert!(extract_base_url("not-a-url").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_known_urls_path_aware_first_for_url_with_path() {
|
||||
let urls = well_known_urls(
|
||||
"https://api.githubcopilot.com/mcp",
|
||||
"oauth-protected-resource",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec![
|
||||
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp",
|
||||
"https://api.githubcopilot.com/mcp/.well-known/oauth-protected-resource",
|
||||
"https://api.githubcopilot.com/.well-known/oauth-protected-resource",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_known_urls_inserts_before_issuer_path() {
|
||||
let urls = well_known_urls(
|
||||
"https://github.com/login/oauth",
|
||||
"oauth-authorization-server",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
urls[0],
|
||||
"https://github.com/.well-known/oauth-authorization-server/login/oauth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_known_urls_root_only_for_url_without_path() {
|
||||
let urls = well_known_urls("https://mcp.notion.com", "oauth-authorization-server").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec!["https://mcp.notion.com/.well-known/oauth-authorization-server"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_known_urls_ignores_trailing_slash() {
|
||||
let urls = well_known_urls(
|
||||
"https://api.githubcopilot.com/mcp/",
|
||||
"oauth-protected-resource",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
urls[0],
|
||||
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_resource_metadata_extracts_quoted_url() {
|
||||
let challenge = r#"Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp""#;
|
||||
|
||||
let url = parse_resource_metadata(challenge);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
Some(
|
||||
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_resource_metadata_extracts_unquoted_url() {
|
||||
let challenge = "Bearer resource_metadata=https://example.com/.well-known/oauth-protected-resource/mcp, error=\"invalid_token\"";
|
||||
|
||||
let url = parse_resource_metadata(challenge);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
Some("https://example.com/.well-known/oauth-protected-resource/mcp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_resource_metadata_returns_none_when_absent() {
|
||||
assert_eq!(
|
||||
parse_resource_metadata(r#"Bearer error="invalid_token""#),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_resource_metadata(r#"Bearer resource_metadata="""#),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn registered_client_id_roundtrip() {
|
||||
|
||||
Reference in New Issue
Block a user