Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

# Add configurable scope enforcement via `scope_mode` to suport flexible matching strategies (`require_any`, `disabled`)
170 changes: 153 additions & 17 deletions crates/apollo-mcp-server/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ pub(crate) use valid_token::ValidToken;
use valid_token::ValidateToken;
use www_authenticate::{BearerError, WwwAuthenticate};

/// Scope enforcement mode for authenticated requests.
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ScopeMode {
/// Skip scope enforcement entirely.
Disabled,
/// Token must have ALL configured scopes (default).
#[default]
RequireAll,
/// Token must have at least ONE configured scope.
RequireAny,
}

/// Errors that can occur when building a TLS-configured HTTP client
#[derive(Debug, thiserror::Error)]
pub enum TlsConfigError {
Expand Down Expand Up @@ -105,6 +118,10 @@ pub struct Config {
/// Supported OAuth scopes by this resource server
pub scopes: Vec<String>,

/// Scope enforcement mode: disabled, require_all (default), or require_any.
#[serde(default)]
pub scope_mode: ScopeMode,

/// Whether to disable the auth token passthrough to upstream API
#[serde(default)]
pub disable_auth_token_passthrough: bool,
Expand Down Expand Up @@ -168,6 +185,12 @@ impl Config {
);
}

if self.scope_mode == ScopeMode::Disabled && !self.scopes.is_empty() {
warn!(
"scope_mode is 'disabled' but scopes are configured - scope enforcement will be skipped"
);
}

/// Simple handler to encode our config into the desired OAuth 2.1 protected
/// resource format
async fn protected_resource(
Expand Down Expand Up @@ -273,23 +296,40 @@ async fn oauth_validate(
unauthorized_error()
})?;

// Check if token has required scopes (fail-closed: missing scope claim = insufficient)
// Scope validation: only applies when scopes are configured
if !auth_config.scopes.is_empty() {
let missing_scopes: Vec<_> = auth_config
.scopes
.iter()
.filter(|required| !valid_token.scopes.iter().any(|s| s == *required))
.collect();
let sufficient = match auth_config.scope_mode {
ScopeMode::Disabled => true,
ScopeMode::RequireAll => auth_config
.scopes
.iter()
.all(|req| valid_token.scopes.contains(req)),
ScopeMode::RequireAny => auth_config
.scopes
.iter()
.any(|req| valid_token.scopes.contains(req)),
};

if !sufficient {
// Compute missing scopes for diagnostic logging
let missing: Vec<_> = auth_config
.scopes
.iter()
.filter(|req| !valid_token.scopes.contains(*req))
.collect();

if !missing_scopes.is_empty() {
tracing::warn!(
required = ?auth_config.scopes,
present = ?valid_token.scopes,
missing = ?missing_scopes,
missing = ?missing,
mode = ?auth_config.scope_mode,
"Token has insufficient scopes"
);
tracing::Span::current().record("reason", "insufficient_scope");
tracing::Span::current().record("status_code", StatusCode::FORBIDDEN.as_u16());
// NOTE: WWW-Authenticate lists all configured scopes per RFC 6750.
// In require_any mode, only one is needed, but the header format
// doesn't distinguish. This matches existing behavior.
return Err(forbidden_error(&auth_config.scopes));
}
}
Expand Down Expand Up @@ -325,6 +365,7 @@ mod tests {
resource: Url::parse("http://localhost:4000").unwrap(),
resource_documentation: None,
scopes: vec!["read".to_string()],
scope_mode: ScopeMode::default(),
disable_auth_token_passthrough: false,
tls: TlsConfig::default(),
discovery_timeout: None,
Expand Down Expand Up @@ -405,53 +446,62 @@ mod tests {
mod scope_validation {
use super::*;

fn scopes_are_sufficient(required: &[String], present: &[String]) -> bool {
required.iter().all(|req| present.contains(req))
// Unified test helper for scope validation logic
fn is_sufficient(mode: ScopeMode, required: &[String], present: &[String]) -> bool {
match mode {
ScopeMode::Disabled => true,
ScopeMode::RequireAll => required.iter().all(|req| present.contains(req)),
ScopeMode::RequireAny => required.iter().any(|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));
assert!(!is_sufficient(ScopeMode::RequireAll, &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));
assert!(is_sufficient(ScopeMode::RequireAll, &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));
assert!(!is_sufficient(ScopeMode::RequireAll, &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));
assert!(is_sufficient(ScopeMode::RequireAll, &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));
assert!(is_sufficient(ScopeMode::RequireAll, &required, &present));

let present_empty: Vec<String> = vec![];
assert!(scopes_are_sufficient(&required, &present_empty));
assert!(is_sufficient(
ScopeMode::RequireAll,
&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));
assert!(is_sufficient(ScopeMode::RequireAll, &required, &present));
}

#[test]
Expand All @@ -472,6 +522,92 @@ mod tests {
assert!(encoded.contains(r#"error="insufficient_scope""#));
assert!(encoded.contains(r#"scope="read write""#));
}

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

#[test]
fn scope_mode_require_all_needs_all_scopes() {
let required = vec!["read".to_string(), "write".to_string()];
let present = vec!["read".to_string()];
assert!(!is_sufficient(ScopeMode::RequireAll, &required, &present));

let present_all = vec!["read".to_string(), "write".to_string()];
assert!(is_sufficient(
ScopeMode::RequireAll,
&required,
&present_all
));
}

#[test]
fn scope_mode_require_any_accepts_one_match() {
let required = vec!["read".to_string(), "write".to_string()];
let present = vec!["read".to_string()];
assert!(is_sufficient(ScopeMode::RequireAny, &required, &present));
}

#[test]
fn scope_mode_require_any_rejects_zero_matches() {
let required = vec!["read".to_string(), "write".to_string()];
let present = vec!["admin".to_string()];
assert!(!is_sufficient(ScopeMode::RequireAny, &required, &present));
}

#[test]
fn scope_mode_empty_scopes_is_sufficient_for_all_modes() {
let required: Vec<String> = vec![];
let present = vec!["anything".to_string()];
assert!(is_sufficient(ScopeMode::RequireAll, &required, &present));
assert!(!is_sufficient(ScopeMode::RequireAny, &required, &present));
assert!(is_sufficient(ScopeMode::Disabled, &required, &present));
}

#[test]
fn scope_mode_token_with_no_scopes_fails_when_required() {
let required = vec!["read".to_string()];
let present: Vec<String> = vec![];
assert!(!is_sufficient(ScopeMode::RequireAll, &required, &present));
assert!(!is_sufficient(ScopeMode::RequireAny, &required, &present));
assert!(is_sufficient(ScopeMode::Disabled, &required, &present));
}

#[test]
fn scope_mode_yaml_deserialization() {
let yaml = r#"
servers:
- http://localhost:1234
audiences:
- test-audience
resource: http://localhost:4000
scopes:
- read
scope_mode: require_any
"#;

let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.scope_mode, ScopeMode::RequireAny);
}

#[test]
fn scope_mode_defaults_to_require_all() {
let yaml = r#"
servers:
- http://localhost:1234
audiences:
- test-audience
resource: http://localhost:4000
scopes:
- read
"#;

let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.scope_mode, ScopeMode::RequireAll);
}
}

mod tls_config {
Expand Down
62 changes: 62 additions & 0 deletions docs/source/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,68 @@

</Caution>

## Configure scope enforcement

Check notice on line 61 in docs/source/auth.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/auth.mdx#L61

Use gerunds for headings in conceptual overview pages. ```suggestion ## Configuring scope enforcement ```

Use the `scope_mode` option to control how the MCP Server validates scopes from OAuth tokens.

Check warning on line 63 in docs/source/auth.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/auth.mdx#L63

Use Apollo Server instead of MCP Server to maintain consistent product naming. ```suggestion Use the `scope_mode` option to control how Apollo Server validates scopes from OAuth tokens. ```

| Mode | Behavior |
|------|----------|
| `require_all` | Token must have all configured scopes (default) |

Check notice on line 67 in docs/source/auth.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/auth.mdx#L67

The word 'all' is used correctly here, but ensure the surrounding documentation uses imperative verbs and active voice. This line is acceptable as a table entry fragment. ```suggestion | `require_all` | Token must have all configured scopes (default) | ```
| `require_any` | Token must have **at least one** of the configured scopes |

Check warning on line 68 in docs/source/auth.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/auth.mdx#L68

Do not use bold for general emphasis. Use bold only for interactive UI elements or specific user roles. ```suggestion | `require_any` | Token must have at least one of the configured scopes | ```
| `disabled` | Skip scope checks entirely |

### Require all scopes (default)

Check notice on line 71 in docs/source/auth.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/auth.mdx#L71

Remove parentheticals from headings. Use the subtitle or body text to indicate default status. ```suggestion ### Require all scopes ```

```yaml title="mcp.yaml"
transport:
type: streamable_http
auth:
servers:
- https://auth.example.com
scopes:
- read
- write
scope_mode: require_all # This is the default
```

The token must have **all** configured scopes. This is the most restrictive mode.

### Require any scope

```yaml title="mcp.yaml"
transport:
type: streamable_http
auth:
servers:
- https://auth.example.com
scopes:
- read
- write
- admin
scope_mode: require_any
```

The token must have **at least one** of the configured scopes. Useful when scopes represent alternative access levels.

### Disable scope enforcement

```yaml title="mcp.yaml"
transport:
type: streamable_http
auth:
servers:
- https://auth.example.com
scope_mode: disabled
```

Skip scope enforcement entirely. The server validates token authenticity (signature and audience) but not scopes.

<Caution>

Use `scope_mode: disabled` only when downstream services, such as subgraphs, handle authorization. Without scope enforcement, any valid token grants full access to the MCP Server.

Check warning on line 119 in docs/source/auth.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/auth.mdx#L119

Do not use 'the' before Apollo Server. Use 'Apollo Server' as a proper product name without an article. ```suggestion Use `scope_mode: disabled` only when downstream services, such as subgraphs, handle authorization. Without scope enforcement, any valid token grants full access to Apollo Server. ```

</Caution>

## Performance considerations

### Discovery timeout
Expand Down
1 change: 1 addition & 0 deletions docs/source/config-file.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@
| `resource` | `string` | | The externally available URL pointing to this MCP server. Can be `localhost` when testing locally. |
| `resource_documentation` | `string` | | Optional link to more documentation relating to this MCP server |
| `scopes` | `List<string>` | | List of queryable OAuth scopes from the upstream OAuth servers |
| `scope_mode` | `string` | `require_all` | Scope enforcement mode: `disabled`, `require_all`, or `require_any` |

Check notice on line 284 in docs/source/config-file.mdx

View check run for this annotation

Apollo Librarian / AI Style Review

docs/source/config-file.mdx#L284

The description uses 'or' which is unopinionated. Prescribe the recommended mode if applicable, or ensure the description remains a simple fragment without ending punctuation. ```suggestion | `scope_mode` | `string` | `require_all` | Scope enforcement mode: `disabled`, `require_all`, or `require_any` | ```
| `disable_auth_token_passthrough` | `bool` | `false` | Optional flag to disable passing validated Authorization header to downstream API |
| `discovery_timeout` | `Duration` | `5s` | Timeout for authorization server metadata discovery requests. Supports human-readable durations (e.g., "5s", "10s", "30s"). |
| `tls.ca_cert` | `string` | | Path to a CA certificate to trust (PEM format). |
Expand Down
Loading