From 841a9955cea6302dce30ba4f9b9c620274463f63 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Mon, 5 Jan 2026 10:51:23 -0500 Subject: [PATCH 1/5] fix: allow empty audiences array to skip audience validation --- .../apollo-mcp-server/src/auth/valid_token.rs | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/apollo-mcp-server/src/auth/valid_token.rs b/crates/apollo-mcp-server/src/auth/valid_token.rs index f880f29e0..559753922 100644 --- a/crates/apollo-mcp-server/src/auth/valid_token.rs +++ b/crates/apollo-mcp-server/src/auth/valid_token.rs @@ -98,7 +98,14 @@ pub(super) trait ValidateToken { continue; } }); - val.set_audience(self.get_audiences()); + // Only validate audience if audiences are configured. + // An empty audiences list means skip audience validation entirely. + let audiences = self.get_audiences(); + if audiences.is_empty() { + val.validate_aud = false; + } else { + val.set_audience(audiences); + } val }; @@ -396,6 +403,41 @@ mod test { ); } + #[tokio::test] + async fn it_validates_jwt_with_empty_audiences_config() { + 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"); + + // Empty audiences should skip audience validation entirely + let test_validator = TestTokenValidator { + audiences: vec![], + key_pair: (key_id, jwk), + servers: vec![server], + }; + + let token = jwt.token().to_string(); + assert_eq!( + test_validator + .validate(jwt) + .await + .expect("valid token with empty audiences config") + .0 + .token(), + token + ); + } + #[traced_test] #[tokio::test] async fn it_rejects_array_audience_with_no_matches() { From bf879ed09321c596af58642a6aca6f8077ce0cd5 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Mon, 5 Jan 2026 11:29:44 -0500 Subject: [PATCH 2/5] feat: add allow_any_audience option to skip audience validation --- crates/apollo-mcp-server/src/auth.rs | 11 ++++- .../src/auth/networked_token_validator.rs | 12 ++++-- .../apollo-mcp-server/src/auth/valid_token.rs | 42 ++++++++++--------- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/crates/apollo-mcp-server/src/auth.rs b/crates/apollo-mcp-server/src/auth.rs index 25654b180..048214fc4 100644 --- a/crates/apollo-mcp-server/src/auth.rs +++ b/crates/apollo-mcp-server/src/auth.rs @@ -36,6 +36,10 @@ pub struct Config { /// List of accepted audiences for the OAuth tokens pub audiences: Vec, + /// 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 @@ -111,7 +115,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()); @@ -151,6 +159,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()], diff --git a/crates/apollo-mcp-server/src/auth/networked_token_validator.rs b/crates/apollo-mcp-server/src/auth/networked_token_validator.rs index 6c041fae3..bd8f21a0b 100644 --- a/crates/apollo-mcp-server/src/auth/networked_token_validator.rs +++ b/crates/apollo-mcp-server/src/auth/networked_token_validator.rs @@ -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, + audiences: &'a [String], + allow_any_audience: bool, upstreams: &'a Vec, } impl<'a> NetworkedTokenValidator<'a> { - pub fn new(audiences: &'a Vec, upstreams: &'a Vec) -> Self { + pub fn new(audiences: &'a [String], allow_any_audience: bool, upstreams: &'a Vec) -> Self { Self { audiences, + allow_any_audience, upstreams, } } @@ -32,7 +34,11 @@ fn build_oidc_url(oauth_server: &Url) -> Url { } impl ValidateToken for NetworkedTokenValidator<'_> { - fn get_audiences(&self) -> &Vec { + fn allow_any_audience(&self) -> bool { + self.allow_any_audience + } + + fn get_audiences(&self) -> &[String] { self.audiences } diff --git a/crates/apollo-mcp-server/src/auth/valid_token.rs b/crates/apollo-mcp-server/src/auth/valid_token.rs index 559753922..02b2ae938 100644 --- a/crates/apollo-mcp-server/src/auth/valid_token.rs +++ b/crates/apollo-mcp-server/src/auth/valid_token.rs @@ -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; + /// 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; @@ -98,13 +101,10 @@ pub(super) trait ValidateToken { continue; } }); - // Only validate audience if audiences are configured. - // An empty audiences list means skip audience validation entirely. - let audiences = self.get_audiences(); - if audiences.is_empty() { + if self.allow_any_audience() { val.validate_aud = false; } else { - val.set_audience(audiences); + val.set_audience(self.get_audiences()); } val @@ -138,12 +138,17 @@ mod test { struct TestTokenValidator { audiences: Vec, + allow_any_audience: bool, key_pair: (String, Jwk), servers: Vec, } impl ValidateToken for TestTokenValidator { - fn get_audiences(&self) -> &Vec { + fn allow_any_audience(&self) -> bool { + self.allow_any_audience + } + + fn get_audiences(&self) -> &[String] { &self.audiences } @@ -226,6 +231,7 @@ mod test { let test_validator = TestTokenValidator { audiences: vec![audience], + allow_any_audience: false, key_pair: (key_id, jwk), servers: vec![server], }; @@ -267,6 +273,7 @@ mod test { let test_validator = TestTokenValidator { audiences: vec![audience], + allow_any_audience: false, key_pair: (key_id, jwk), servers: vec![server], }; @@ -302,6 +309,7 @@ mod test { let test_validator = TestTokenValidator { audiences: vec![audience], + allow_any_audience: false, key_pair: (key_id, jwk), servers: vec![server], }; @@ -338,6 +346,7 @@ mod test { let test_validator = TestTokenValidator { audiences: vec![audience], + allow_any_audience: false, key_pair: (key_id, jwk), servers: vec![server], }; @@ -388,6 +397,7 @@ mod test { let test_validator = TestTokenValidator { audiences: vec![audience], + allow_any_audience: false, key_pair: (key_id, jwk), servers: vec![server], }; @@ -404,7 +414,7 @@ mod test { } #[tokio::test] - async fn it_validates_jwt_with_empty_audiences_config() { + 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 { @@ -419,23 +429,16 @@ mod test { let server = Url::from_str("https://auth.example.com").expect("should parse a valid example server"); - // Empty audiences should skip audience validation entirely + // 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 - .expect("valid token with empty audiences config") - .0 - .token(), - token - ); + assert_eq!(test_validator.validate(jwt).await.unwrap().0.token(), token); } #[traced_test] @@ -473,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], }; From b52078929b81366f36d9b14b8b8935f774581384 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Mon, 5 Jan 2026 11:42:07 -0500 Subject: [PATCH 3/5] docs: expand authorization documentation with audience configuration details --- docs/source/auth.mdx | 40 ++++++++++++++++++++++++++++++++++++- docs/source/config-file.mdx | 7 ++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/source/auth.mdx b/docs/source/auth.mdx index 96bc4697f..85f1f8039 100644 --- a/docs/source/auth.mdx +++ b/docs/source/auth.mdx @@ -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). \ No newline at end of file +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. + + + +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. + + \ No newline at end of file diff --git a/docs/source/config-file.mdx b/docs/source/config-file.mdx index 3c44ef529..3c4cc03ad 100644 --- a/docs/source/config-file.mdx +++ b/docs/source/config-file.mdx @@ -245,7 +245,8 @@ These fields are under the top-level `transport` key, nested under the `auth` ke | Option | Type | Default | Description | | :------------------------------- | :------------- | :------ | :------------------------------------------------------------------------------------------------- | | `servers` | `List` | | List of upstream delegated OAuth servers (must support OIDC metadata discovery endpoint) | -| `audiences` | `List` | | List of accepted audiences from upstream signed JWTs | +| `audiences` | `List` | | 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` | | List of queryable OAuth scopes from the upstream OAuth servers | @@ -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 From 93c692c6cb43e1d5a0ab980b4670d2054490f8d9 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Mon, 5 Jan 2026 11:46:43 -0500 Subject: [PATCH 4/5] chore: changeset --- .changesets/feat_allow_any_audience.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changesets/feat_allow_any_audience.md diff --git a/.changesets/feat_allow_any_audience.md b/.changesets/feat_allow_any_audience.md new file mode 100644 index 000000000..2a30ea578 --- /dev/null +++ b/.changesets/feat_allow_any_audience.md @@ -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 +``` From 2c19d01c2b144c73cef2fc85a0eab1959cad5d5b Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Tue, 6 Jan 2026 09:57:41 -0500 Subject: [PATCH 5/5] feat: add default value for audiences and log warning for allow_any_audience --- crates/apollo-mcp-server/src/auth.rs | 8 ++++++++ docs/source/config-file.mdx | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/apollo-mcp-server/src/auth.rs b/crates/apollo-mcp-server/src/auth.rs index 048214fc4..ee7622c97 100644 --- a/crates/apollo-mcp-server/src/auth.rs +++ b/crates/apollo-mcp-server/src/auth.rs @@ -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; @@ -34,6 +35,7 @@ pub struct Config { pub servers: Vec, /// List of accepted audiences for the OAuth tokens + #[serde(default)] pub audiences: Vec, /// Allow any audience (skip validation) - use with caution @@ -58,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) -> Json { diff --git a/docs/source/config-file.mdx b/docs/source/config-file.mdx index 3c4cc03ad..1bb8fe010 100644 --- a/docs/source/config-file.mdx +++ b/docs/source/config-file.mdx @@ -245,7 +245,7 @@ These fields are under the top-level `transport` key, nested under the `auth` ke | Option | Type | Default | Description | | :------------------------------- | :------------- | :------ | :------------------------------------------------------------------------------------------------- | | `servers` | `List` | | List of upstream delegated OAuth servers (must support OIDC metadata discovery endpoint) | -| `audiences` | `List` | | List of accepted audiences from upstream signed JWTs (ignored if `allow_any_audience` is `true`) | +| `audiences` | `List` | `[]` | 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 |