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
17 changes: 17 additions & 0 deletions .changesets/feat_allow_any_audience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
### Allow opting out of audience validation - @DaleSeo PR #535

Added an explicit `allow_any_audience` configuration option that follows the same pattern as CORS's `allow_any_origin`. When set to `true`, audience validation is skipped entirely.

```yaml
auth:
servers:
- https://auth.example.com

# Validate specific audiences (default)
audiences: ["my-api"]
allow_any_audience: false

# Or skip audience validation entirely
audiences: []
allow_any_audience: true## Changes
```
19 changes: 18 additions & 1 deletion crates/apollo-mcp-server/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use networked_token_validator::NetworkedTokenValidator;
use schemars::JsonSchema;
use serde::Deserialize;
use tower_http::cors::{Any, CorsLayer};
use tracing::warn;
use url::Url;

mod networked_token_validator;
Expand All @@ -34,8 +35,13 @@ pub struct Config {
pub servers: Vec<Url>,

/// List of accepted audiences for the OAuth tokens
#[serde(default)]
pub audiences: Vec<String>,
Comment thread
gocamille marked this conversation as resolved.

/// Allow any audience (skip validation) - use with caution
#[serde(default)]
pub allow_any_audience: bool,

/// The resource to protect.
///
/// Note: This is usually the publicly accessible URL of this running MCP server
Expand All @@ -54,6 +60,12 @@ pub struct Config {

impl Config {
pub fn enable_middleware(&self, router: Router) -> Router {
if self.allow_any_audience {
warn!(
"allow_any_audience is enabled - audience validation is disabled. This reduces security."
);
}

/// Simple handler to encode our config into the desired OAuth 2.1 protected
/// resource format
async fn protected_resource(State(auth_config): State<Config>) -> Json<ProtectedResource> {
Expand Down Expand Up @@ -111,7 +123,11 @@ async fn oauth_validate(
)
};

let validator = NetworkedTokenValidator::new(&auth_config.audiences, &auth_config.servers);
let validator = NetworkedTokenValidator::new(
&auth_config.audiences,
auth_config.allow_any_audience,
&auth_config.servers,
);
let token = token.ok_or_else(|| {
tracing::Span::current().record("reason", "missing_token");
tracing::Span::current().record("status_code", StatusCode::UNAUTHORIZED.as_u16());
Expand Down Expand Up @@ -151,6 +167,7 @@ mod tests {
Config {
servers: vec![Url::parse("http://localhost:1234").unwrap()],
audiences: vec!["test-audience".to_string()],
allow_any_audience: false,
resource: Url::parse("http://localhost:4000").unwrap(),
resource_documentation: None,
scopes: vec!["read".to_string()],
Expand Down
12 changes: 9 additions & 3 deletions crates/apollo-mcp-server/src/auth/networked_token_validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ use super::valid_token::ValidateToken;
/// Implementation of the `ValidateToken` trait which fetches key information
/// from the network.
pub(super) struct NetworkedTokenValidator<'a> {
audiences: &'a Vec<String>,
audiences: &'a [String],
allow_any_audience: bool,
upstreams: &'a Vec<Url>,
}

impl<'a> NetworkedTokenValidator<'a> {
pub fn new(audiences: &'a Vec<String>, upstreams: &'a Vec<Url>) -> Self {
pub fn new(audiences: &'a [String], allow_any_audience: bool, upstreams: &'a Vec<Url>) -> Self {
Self {
audiences,
allow_any_audience,
upstreams,
}
}
Expand All @@ -32,7 +34,11 @@ fn build_oidc_url(oauth_server: &Url) -> Url {
}

impl ValidateToken for NetworkedTokenValidator<'_> {
fn get_audiences(&self) -> &Vec<String> {
fn allow_any_audience(&self) -> bool {
Comment thread
gocamille marked this conversation as resolved.
self.allow_any_audience
}

fn get_audiences(&self) -> &[String] {
self.audiences
}

Expand Down
54 changes: 50 additions & 4 deletions crates/apollo-mcp-server/src/auth/valid_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ impl Deref for ValidToken {

/// Trait to handle validation of tokens
pub(super) trait ValidateToken {
/// Get the intended audiences
fn get_audiences(&self) -> &Vec<String>;
/// Whether to skip audience validation (allow any audience)
fn allow_any_audience(&self) -> bool;

/// Get the list of allowed audiences
fn get_audiences(&self) -> &[String];

/// Get the available upstream servers
fn get_servers(&self) -> &Vec<Url>;
Expand Down Expand Up @@ -98,7 +101,11 @@ pub(super) trait ValidateToken {
continue;
}
});
val.set_audience(self.get_audiences());
if self.allow_any_audience() {
val.validate_aud = false;
} else {
Comment thread
gocamille marked this conversation as resolved.
val.set_audience(self.get_audiences());
Comment thread
gocamille marked this conversation as resolved.
}

val
};
Expand Down Expand Up @@ -131,12 +138,17 @@ mod test {

struct TestTokenValidator {
audiences: Vec<String>,
allow_any_audience: bool,
key_pair: (String, Jwk),
servers: Vec<Url>,
}

impl ValidateToken for TestTokenValidator {
fn get_audiences(&self) -> &Vec<String> {
fn allow_any_audience(&self) -> bool {
self.allow_any_audience
}

fn get_audiences(&self) -> &[String] {
&self.audiences
}

Expand Down Expand Up @@ -219,6 +231,7 @@ mod test {

let test_validator = TestTokenValidator {
audiences: vec![audience],
allow_any_audience: false,
key_pair: (key_id, jwk),
servers: vec![server],
};
Expand Down Expand Up @@ -260,6 +273,7 @@ mod test {

let test_validator = TestTokenValidator {
audiences: vec![audience],
allow_any_audience: false,
key_pair: (key_id, jwk),
servers: vec![server],
};
Expand Down Expand Up @@ -295,6 +309,7 @@ mod test {

let test_validator = TestTokenValidator {
audiences: vec![audience],
allow_any_audience: false,
key_pair: (key_id, jwk),
servers: vec![server],
};
Expand Down Expand Up @@ -331,6 +346,7 @@ mod test {

let test_validator = TestTokenValidator {
audiences: vec![audience],
allow_any_audience: false,
key_pair: (key_id, jwk),
servers: vec![server],
};
Expand Down Expand Up @@ -381,6 +397,7 @@ mod test {

let test_validator = TestTokenValidator {
audiences: vec![audience],
allow_any_audience: false,
key_pair: (key_id, jwk),
servers: vec![server],
};
Expand All @@ -396,6 +413,34 @@ mod test {
);
}

#[tokio::test]
async fn it_validates_jwt_with_allow_any_audience() {
let key_id = "some-example-id".to_string();
let (encode_key, decode_key) = create_key("DEADBEEF");
let jwk = Jwk {
alg: KeyAlgorithm::HS512,
decoding_key: decode_key,
};

let audience = "any-audience".to_string();
let in_the_future = chrono::Utc::now().timestamp() + 1000;
let jwt = create_jwt(key_id.clone(), encode_key, audience, in_the_future);

let server =
Url::from_str("https://auth.example.com").expect("should parse a valid example server");

// allow_any_audience should skip audience validation entirely
let test_validator = TestTokenValidator {
audiences: vec![],
allow_any_audience: true,
key_pair: (key_id, jwk),
servers: vec![server],
};

let token = jwt.token().to_string();
assert_eq!(test_validator.validate(jwt).await.unwrap().0.token(), token);
}

#[traced_test]
#[tokio::test]
async fn it_rejects_array_audience_with_no_matches() {
Expand Down Expand Up @@ -431,6 +476,7 @@ mod test {

let test_validator = TestTokenValidator {
audiences: vec![expected_audience],
allow_any_audience: false,
key_pair: (key_id, jwk),
servers: vec![server],
};
Expand Down
40 changes: 39 additions & 1 deletion docs/source/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,42 @@ To implement authorization, you need an [OAuth 2.1-compliant](https://oauth.net/

Then, you [configure the MCP server with `auth` settings](/apollo-mcp-server/config-file#auth) and the [GraphOS Router for JWT authentication](/graphos/routing/security/jwt) using those IdP values.

For an example of how to configure Apollo MCP Server with Auth0, see [Authorization with Auth0](/apollo-mcp-server/guides/auth-auth0).
For an example of how to configure Apollo MCP Server with Auth0, see [Authorization with Auth0](/apollo-mcp-server/guides/auth-auth0).

## Configuring allowed audiences

You can specify which JWT audiences are allowed to access your MCP Server.

### Specific audiences

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

Set `audiences` to a list of accepted audience values. The JWT's `aud` claim must match one of these values for the token to be considered valid.

### Allow any audience

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

If you set `allow_any_audience` to `true` (the default is `false`), Apollo MCP Server will skip audience validation entirely. This means tokens with _any_ audience claim will be accepted.

<Caution>

Skipping audience validation reduces security. Only use `allow_any_audience: true` when you trust all tokens issued by your configured OAuth servers, regardless of their intended audience.

</Caution>
7 changes: 6 additions & 1 deletion docs/source/config-file.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ These fields are under the top-level `transport` key, nested under the `auth` ke
| Option | Type | Default | Description |
| :------------------------------- | :------------- | :------ | :------------------------------------------------------------------------------------------------- |
| `servers` | `List<URL>` | | List of upstream delegated OAuth servers (must support OIDC metadata discovery endpoint) |
| `audiences` | `List<string>` | | List of accepted audiences from upstream signed JWTs |
| `audiences` | `List<string>` | `[]` | List of accepted audiences from upstream signed JWTs (ignored if `allow_any_audience` is `true`) |
| `allow_any_audience` | `bool` | `false` | Set to `true` to skip audience validation entirely (use with caution) |
| `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 |
Expand All @@ -263,10 +264,14 @@ transport:
- https://auth.example.com

# List of accepted audiences from upstream signed JWTs
# (Ignored if allow_any_audience is set to true)
# See: https://www.ory.sh/docs/hydra/guides/audiences
audiences:
- mcp.example.audience

# Set to true to skip audience validation (use with caution)
allow_any_audience: false

# The externally available URL pointing to this MCP server. Can be `localhost`
# when testing locally.
# Note: Subpaths must be preserved here as well. So append `/mcp` if using
Expand Down