Skip to content
Merged
Show file tree
Hide file tree
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
7 changes: 7 additions & 0 deletions .changesets/feat_add_scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### Add scope parameter to WWW-Authenticate header - @DaleSeo PR #523

Add support for optional `scope` parameter in the `WWW-Authenticate` header per [MCP Auth Spec 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements).

When returning 401 Unauthorized responses, the server now includes the configured scopes to guide clients on appropriate scopes to request during authorization.

This PR extends the `WwwAuthenticate::Bearer` variant with an optional scope field. When scopes are configured, they are space-separated and included in 401 responses. When no scopes are configured, the parameter is omitted.
Comment thread
andrewmcgivery marked this conversation as resolved.
35 changes: 35 additions & 0 deletions crates/apollo-mcp-server/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,17 @@ async fn oauth_validate(
let mut resource = auth_config.resource.clone();
resource.set_path("/.well-known/oauth-protected-resource");

let scope = if auth_config.scopes.is_empty() {
None
} else {
Some(auth_config.scopes.join(" "))
};

(
StatusCode::UNAUTHORIZED,
TypedHeader(WwwAuthenticate::Bearer {
resource_metadata: resource,
scope,
}),
)
};
Expand Down Expand Up @@ -186,4 +193,32 @@ mod tests {
assert!(www_auth.contains("Bearer"));
assert!(www_auth.contains("resource_metadata"));
}

#[tokio::test]
async fn missing_token_with_multiple_scopes() {
let mut config = test_config();
config.scopes = vec!["read".to_string(), "write".to_string()];
let app = test_router(config);
let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let headers = res.headers();
let www_auth = headers.get(WWW_AUTHENTICATE).unwrap().to_str().unwrap();
assert!(www_auth.contains(r#"scope="read write""#));
}

#[tokio::test]
async fn missing_token_without_scopes_omits_scope_parameter() {
let mut config = test_config();
config.scopes = vec![];
let app = test_router(config);
let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let headers = res.headers();
let www_auth = headers.get(WWW_AUTHENTICATE).unwrap().to_str().unwrap();
assert!(www_auth.contains("Bearer"));
assert!(www_auth.contains("resource_metadata"));
assert!(!www_auth.contains("scope="));
}
}
83 changes: 78 additions & 5 deletions crates/apollo-mcp-server/src/auth/www_authenticate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ use tracing::warn;
use url::Url;

pub(super) enum WwwAuthenticate {
Bearer { resource_metadata: Url },
Bearer {
resource_metadata: Url,
scope: Option<String>,
},
}

impl Header for WwwAuthenticate {
Expand All @@ -27,10 +30,19 @@ impl Header for WwwAuthenticate {

fn encode<E: Extend<http::HeaderValue>>(&self, values: &mut E) {
let encoded = match &self {
WwwAuthenticate::Bearer { resource_metadata } => format!(
r#"Bearer resource_metadata="{}""#,
resource_metadata.as_str()
),
WwwAuthenticate::Bearer {
resource_metadata,
scope,
} => {
let mut header = format!(
r#"Bearer resource_metadata="{}""#,
resource_metadata.as_str()
);
if let Some(scope) = scope {
header.push_str(&format!(r#", scope="{}""#, scope));
}
header
}
};

// TODO: This shouldn't error, but it can so we might need to do something else here
Expand All @@ -40,3 +52,64 @@ impl Header for WwwAuthenticate {
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use headers::Header;

fn encode_header(header: &WwwAuthenticate) -> String {
let mut values = Vec::new();
header.encode(&mut values);
values
.first()
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default()
}

#[test]
fn encode_bearer_without_scope() {
let header = WwwAuthenticate::Bearer {
resource_metadata: Url::parse("https://test.com/.well-known/oauth-protected-resource")
.unwrap(),
scope: None,
};

let encoded = encode_header(&header);
assert_eq!(
encoded,
r#"Bearer resource_metadata="https://test.com/.well-known/oauth-protected-resource""#
);
}

#[test]
fn encode_bearer_with_single_scope() {
let header = WwwAuthenticate::Bearer {
resource_metadata: Url::parse(
"https://mcp.test.com/.well-known/oauth-protected-resource",
)
.unwrap(),
scope: Some("read".to_string()),
};

let encoded = encode_header(&header);
assert!(encoded.contains("Bearer"));
assert!(encoded.contains("resource_metadata="));
assert!(encoded.contains(r#"scope="read""#));
}

#[test]
fn encode_bearer_with_multiple_scopes() {
let header = WwwAuthenticate::Bearer {
resource_metadata: Url::parse("https://test.com/.well-known/oauth-protected-resource")
.unwrap(),
scope: Some("read write".to_string()),
};

let encoded = encode_header(&header);
assert_eq!(
encoded,
r#"Bearer resource_metadata="https://test.com/.well-known/oauth-protected-resource", scope="read write""#
);
}
}