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
13 changes: 13 additions & 0 deletions .changeset/feat_add_insufficient_scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
### feat(auth): Add 403 Forbidden `insufficient_scope` support per MCP Auth Spec 2025-11-25 and RFC 6750 (Section 3.1) - @gocamille PR #537

This adds HTTP 403 Forbidden responses with `error="insufficient_scope"` per [MCP Auth Spec 2025-11-25 Section 10: Error Handling](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling) and [RFC 6750 Section 3.1](https://www.rfc-editor.org/rfc/rfc6750.html#section-3.1).

**Changes:**
- `www_authenticate.rs`: Added `BearerError::InsufficientScope` enum and [error](crates/apollo-mcp-server/src/auth/www_authenticate.rs#L135-L149) field to `WWW-Authenticate` header
- `valid_token.rs`: Extract [scope](crates/apollo-mcp-server/src/auth/valid_token.rs#L27-L38)/[scp](crates/apollo-mcp-server/src/auth/valid_token.rs#L503-L509) claims from JWTs (handles both standard OAuth and Azure AD)
- `auth.rs`: Scope validation with fail-closed behaviour—valid tokens lacking required scopes get `403`
- `headers.rs`: Updated tests for new `ValidToken` struct

**Behavior:**
- **401 Unauthorized**: Missing or invalid token
- **403 Forbidden**: Valid token but insufficient scopes (includes `error="insufficient_scope"` in response)
122 changes: 116 additions & 6 deletions crates/apollo-mcp-server/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ mod www_authenticate;
use protected_resource::ProtectedResource;
pub(crate) use valid_token::ValidToken;
use valid_token::ValidateToken;
use www_authenticate::WwwAuthenticate;
use www_authenticate::{BearerError, WwwAuthenticate};

/// Errors that can occur when building a TLS-configured HTTP client
#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -191,11 +191,15 @@ async fn oauth_validate(
) -> Result<Response, (StatusCode, TypedHeader<WwwAuthenticate>)> {
let auth_config = &auth_state.config;

// Consolidated unauthorized error for use with any fallible step in this process
let unauthorized_error = || {
let mut resource = auth_config.resource.clone();
resource.set_path("/.well-known/oauth-protected-resource");
// Helper to construct the resource metadata URL
let resource_metadata_url = || {
let mut url = auth_config.resource.clone();
url.set_path("/.well-known/oauth-protected-resource");
url
};

// Unauthorized error for missing or invalid tokens
let unauthorized_error = || {
let scope = if auth_config.scopes.is_empty() {
None
} else {
Expand All @@ -205,8 +209,21 @@ async fn oauth_validate(
(
StatusCode::UNAUTHORIZED,
TypedHeader(WwwAuthenticate::Bearer {
resource_metadata: resource,
resource_metadata: resource_metadata_url(),
scope,
error: None,
}),
)
};

// Forbidden error for valid tokens with insufficient scopes (RFC 6750 Section 3.1)
let forbidden_error = |required_scopes: &[String]| {
(
StatusCode::FORBIDDEN,
TypedHeader(WwwAuthenticate::Bearer {
resource_metadata: resource_metadata_url(),
scope: Some(required_scopes.join(" ")),
error: Some(BearerError::InsufficientScope),
}),
)
};
Expand All @@ -229,6 +246,27 @@ async fn oauth_validate(
unauthorized_error()
})?;

// Check if token has required scopes (fail-closed: missing scope claim = insufficient)
if !auth_config.scopes.is_empty() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we assume that if users set the transport.auth.scopes option to an empty list, they want to skip scope validation completely?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This (and the one below) is a really great question (and super nuanced @DaleSeo !) -- looking at the specs (both MCP and OAuth), this assumption is correct. So when transport.auth.scopes is set to an empty list the following would happen:

  • Scope validation would be completely skipped
  • The token would only need to pass signature and audience validation
  • The scopes_supported field in the protected resource metadata would be empty (which a client could see as "no specific scopes needed")

This seems a reasonable default for those who want simple "authenticated or not" access control, but (and this is related to the comment below too) we may want to consider adding flexibility / complexity in a few ways:

  • Separating scopes_supported from scopes_required — like you mentioned the MCP spec explicitly says scopes in WWW-Authenticate "may" match scopes_supported, be a subset or superset of it, or an alternative collection, while right now we use one list for enforcement.
  • Supporting per-tool scope requirements (a big one) — The spec mentions "minimum scopes needed for the operation," so different MCP tools might require different scopes.
  • Adding "any-of" logic may also be good here, as the current implementation requires all listed scopes while some use cases might only need at least one from a set.

What do you think of the current "all-or-nothing" approach? Should we enhance the flexibility of this to cover any of the above items?

(If we do stay with the current approach I would update the docs to document this clearly, and possibly add a warning when the scopes list is empty)

Copy link
Copy Markdown
Member

@DaleSeo DaleSeo Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for sharing your thoughts, @gocamille! This reminds me that in PR #535, we introduced the allow_any_audience flag instead of treating audiences: [] as skipping validation. I think we should do the same for scopes, and it might be another case where a CORS-like configuration makes sense. Skipping scope validation reduces security, so I would suggest making it an explicit opt-out.

let missing_scopes: Vec<_> = auth_config
.scopes
.iter()
.filter(|required| !valid_token.scopes.iter().any(|s| s == *required))
Comment thread
DaleSeo marked this conversation as resolved.
.collect();

if !missing_scopes.is_empty() {
tracing::warn!(
required = ?auth_config.scopes,
present = ?valid_token.scopes,
missing = ?missing_scopes,
"Token has insufficient scopes"
);
tracing::Span::current().record("reason", "insufficient_scope");
tracing::Span::current().record("status_code", StatusCode::FORBIDDEN.as_u16());
return Err(forbidden_error(&auth_config.scopes));
}
}
Comment on lines +249 to +268
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scope validation logic lacks test coverage. Consider adding test cases for:

  • A valid token with insufficient scopes should return 403 Forbidden with error="insufficient_scope" in the WWW-Authenticate header
  • A valid token with all required scopes should succeed
  • A valid token with no scopes when scopes are required should return 403 Forbidden
  • A valid token with extra scopes (superset) should succeed

This is critical functionality for the security model and should be thoroughly tested.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like one of those rare times when AI gives a good review. 😃

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented test coverage in 3188f91


// Insert new context to ensure that handlers only use our enforced token verification
// for propagation
request.extensions_mut().insert(valid_token);
Expand Down Expand Up @@ -336,6 +374,78 @@ mod tests {
assert!(!www_auth.contains("scope="));
}

mod scope_validation {
use super::*;

fn scopes_are_sufficient(required: &[String], present: &[String]) -> bool {
required.iter().all(|req| present.contains(req))
}

#[test]
fn insufficient_scopes_fails() {
let required = vec!["read".to_string(), "write".to_string()];
let present = vec!["read".to_string()];
assert!(!scopes_are_sufficient(&required, &present));
}

#[test]
fn all_required_scopes_succeeds() {
let required = vec!["read".to_string(), "write".to_string()];
let present = vec!["read".to_string(), "write".to_string()];
assert!(scopes_are_sufficient(&required, &present));
}

#[test]
fn no_scopes_when_required_fails() {
let required = vec!["read".to_string()];
let present: Vec<String> = vec![];
assert!(!scopes_are_sufficient(&required, &present));
}

#[test]
fn superset_of_scopes_succeeds() {
let required = vec!["read".to_string()];
let present = vec!["read".to_string(), "write".to_string(), "admin".to_string()];
assert!(scopes_are_sufficient(&required, &present));
}

#[test]
fn empty_required_scopes_always_succeeds() {
let required: Vec<String> = vec![];
let present = vec!["read".to_string()];
assert!(scopes_are_sufficient(&required, &present));

let present_empty: Vec<String> = vec![];
assert!(scopes_are_sufficient(&required, &present_empty));
}

#[test]
fn scope_order_does_not_matter() {
let required = vec!["write".to_string(), "read".to_string()];
let present = vec!["read".to_string(), "write".to_string()];
assert!(scopes_are_sufficient(&required, &present));
}

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

let mut values = Vec::new();
headers::Header::encode(&header, &mut values);
let encoded = values.first().unwrap().to_str().unwrap();

assert!(encoded.contains(r#"error="insufficient_scope""#));
assert!(encoded.contains(r#"scope="read write""#));
}
}

mod tls_config {
use super::*;
use std::io::Write;
Expand Down
67 changes: 61 additions & 6 deletions crates/apollo-mcp-server/src/auth/valid_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,28 @@ use url::Url;
/// Note: This is used as a marker to ensure that we have validated this
/// separately from just reading the header itself.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ValidToken(pub(crate) Authorization<Bearer>);
pub(crate) struct ValidToken {
pub(crate) token: Authorization<Bearer>,
pub(crate) scopes: Vec<String>,
}

impl Deref for ValidToken {
type Target = Authorization<Bearer>;

fn deref(&self) -> &Self::Target {
&self.0
&self.token
}
}

/// Extract scopes from JWT claims.
///
/// Scopes are expected as a space-separated string per RFC 6749.
pub(super) fn extract_scopes(scope: Option<&str>) -> Vec<String> {
scope
.map(|s| s.split_whitespace().map(String::from).collect())
.unwrap_or_default()
}

/// Trait to handle validation of tokens
pub(super) trait ValidateToken {
/// Whether to skip audience validation (allow any audience)
Expand Down Expand Up @@ -51,6 +63,10 @@ pub(super) trait ValidateToken {

/// The user who owns this token
pub sub: String,

/// OAuth scope claim (space-separated list per RFC 6749)
#[serde(default)]
pub scope: Option<String>,
}

fn deserialize_audience<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
Expand Down Expand Up @@ -111,8 +127,9 @@ pub(super) trait ValidateToken {
};

match decode::<Claims>(jwt, &jwk.decoding_key, &validation) {
Ok(_) => {
return Some(ValidToken(token));
Ok(token_data) => {
let scopes = extract_scopes(token_data.claims.scope.as_deref());
return Some(ValidToken { token, scopes });
}
Err(e) => warn!("Token failed validation with error: {e}"),
};
Expand Down Expand Up @@ -242,7 +259,7 @@ mod test {
.validate(jwt)
.await
.expect("valid token")
.0
.token
.token(),
token
);
Expand Down Expand Up @@ -407,7 +424,7 @@ mod test {
.validate(jwt)
.await
.expect("valid token")
.0
.token
.token(),
token
);
Expand Down Expand Up @@ -492,4 +509,42 @@ mod test {
.ok_or("Expected warning for validation failure".to_string())
});
}

// Tests for extract_scopes
mod extract_scopes_tests {
use super::super::extract_scopes;

#[test]
fn returns_empty_when_none() {
assert_eq!(extract_scopes(None), Vec::<String>::new());
}

#[test]
fn extracts_from_scope_claim() {
assert_eq!(extract_scopes(Some("read write")), vec!["read", "write"]);
}

#[test]
fn handles_extra_whitespace() {
assert_eq!(
extract_scopes(Some(" read write ")),
vec!["read", "write"]
);
}

#[test]
fn handles_empty_string() {
assert_eq!(extract_scopes(Some("")), Vec::<String>::new());
}

#[test]
fn handles_whitespace_only() {
assert_eq!(extract_scopes(Some(" ")), Vec::<String>::new());
}

#[test]
fn handles_single_scope() {
assert_eq!(extract_scopes(Some("admin")), vec!["admin"]);
}
}
}
35 changes: 35 additions & 0 deletions crates/apollo-mcp-server/src/auth/www_authenticate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@ pub(super) enum WwwAuthenticate {
Bearer {
resource_metadata: Url,
scope: Option<String>,
error: Option<BearerError>,
},
}

/// OAuth 2.0 Bearer Token error codes per RFC 6750 Section 3.1
#[derive(Debug, Clone)]
pub(super) enum BearerError {
/// The request requires higher privileges than provided by the access token.
InsufficientScope,
}

impl Header for WwwAuthenticate {
fn name() -> &'static http::HeaderName {
&WWW_AUTHENTICATE
Expand All @@ -33,11 +41,19 @@ impl Header for WwwAuthenticate {
WwwAuthenticate::Bearer {
resource_metadata,
scope,
error,
} => {
let mut header = format!(
r#"Bearer resource_metadata="{}""#,
resource_metadata.as_str()
);
// Error must come before scope per RFC 6750 examples
if let Some(err) = error {
let error_str = match err {
BearerError::InsufficientScope => "insufficient_scope",
};
header.push_str(&format!(r#", error="{}""#, error_str));
}
if let Some(scope) = scope {
header.push_str(&format!(r#", scope="{}""#, scope));
}
Expand Down Expand Up @@ -73,6 +89,7 @@ mod tests {
resource_metadata: Url::parse("https://test.com/.well-known/oauth-protected-resource")
.unwrap(),
scope: None,
error: None,
};

let encoded = encode_header(&header);
Expand All @@ -90,6 +107,7 @@ mod tests {
)
.unwrap(),
scope: Some("read".to_string()),
error: None,
};

let encoded = encode_header(&header);
Expand All @@ -104,6 +122,7 @@ mod tests {
resource_metadata: Url::parse("https://test.com/.well-known/oauth-protected-resource")
.unwrap(),
scope: Some("read write".to_string()),
error: None,
};

let encoded = encode_header(&header);
Expand All @@ -112,4 +131,20 @@ mod tests {
r#"Bearer resource_metadata="https://test.com/.well-known/oauth-protected-resource", scope="read write""#
);
}

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

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