diff --git a/.changesets/feat_tls_config.md b/.changesets/feat_tls_config.md new file mode 100644 index 000000000..eec0d7514 --- /dev/null +++ b/.changesets/feat_tls_config.md @@ -0,0 +1,19 @@ +### Add TLS configuration options for auth - @DaleSeo PR #536 + +Adds TLS configuration options for connecting to OAuth servers during token validation. + +When the MCP server validates OAuth tokens, it connects to upstream OAuth servers to fetch JWKS keys. Previously, this required those servers to have certificates trusted by the system's default CA bundle. This change allows users to trust custom CA certificates or disable validation for development environments. + +```yaml +transport: + streamable_http: + auth: + servers: + - https://auth.example.com + audiences: + - my-audience + resource: https://mcp.example.com/mcp + tls: + ca_cert: /path/to/ca-certificate.pem + danger_accept_invalid_certs: false # Set this to true for development or testing purposes only +``` \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index becba98ba..138069c83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,6 +243,7 @@ dependencies = [ "serde_json", "serde_yaml", "syn 2.0.106", + "tempfile", "thiserror 2.0.17", "tokio", "tokio-util", @@ -641,6 +642,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -1385,9 +1392,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1664,6 +1673,23 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-timeout" version = "0.5.2" @@ -2069,9 +2095,8 @@ dependencies = [ [[package]] name = "jwks" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c940b91cfb3b56645bab00ff7a7919c5decf1bc5c8f9327b6dcff34e2d95c9" +version = "0.5.1" +source = "git+https://github.com/chenhunghan/jwks?tag=v0.5.1#1d006620588c018cb2ab0e227392bc8f0fa28d1f" dependencies = [ "base64", "jsonwebtoken", @@ -2180,6 +2205,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lz-str" version = "0.2.1" @@ -2950,6 +2981,61 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.41" @@ -3148,6 +3234,7 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", @@ -3155,6 +3242,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3162,6 +3251,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3170,6 +3260,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -3369,15 +3460,41 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3755,6 +3872,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -4081,6 +4204,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.47.1" @@ -4122,6 +4260,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -4717,6 +4865,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/crates/apollo-mcp-server/Cargo.toml b/crates/apollo-mcp-server/Cargo.toml index 7ab31fda5..a04efb5e7 100644 --- a/crates/apollo-mcp-server/Cargo.toml +++ b/crates/apollo-mcp-server/Cargo.toml @@ -29,7 +29,7 @@ http = "1.3.1" humantime-serde = "1.1.1" jsonschema = "0.33.0" jsonwebtoken = "9" -jwks = "0.4.0" +jwks = { git = "https://github.com/chenhunghan/jwks", tag = "v0.5.1" } lz-str = "0.2.1" opentelemetry = "0.30.0" opentelemetry-otlp = { version = "0.30.0", features = [ @@ -69,6 +69,7 @@ url.workspace = true [dev-dependencies] assert_fs = "1" +tempfile = "3" chrono = { version = "0.4.41", default-features = false, features = ["now"] } figment = { version = "0.10.19", features = ["test"] } insta.workspace = true diff --git a/crates/apollo-mcp-server/src/auth.rs b/crates/apollo-mcp-server/src/auth.rs index ee7622c97..291690691 100644 --- a/crates/apollo-mcp-server/src/auth.rs +++ b/crates/apollo-mcp-server/src/auth.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use axum::{ Json, Router, extract::{Request, State}, @@ -28,6 +30,53 @@ pub(crate) use valid_token::ValidToken; use valid_token::ValidateToken; use www_authenticate::WwwAuthenticate; +/// Errors that can occur when building a TLS-configured HTTP client +#[derive(Debug, thiserror::Error)] +pub enum TlsConfigError { + #[error("Failed to read CA certificate from {path}: {source}")] + CertificateRead { + path: PathBuf, + source: std::io::Error, + }, + #[error("Failed to parse CA certificate from {path}: invalid PEM format")] + CertificateParse { path: PathBuf }, + #[error("Failed to build HTTP client: {0}")] + ClientBuild(#[from] reqwest::Error), +} + +impl TlsConfig { + /// Build a reqwest client configured with the TLS settings + pub fn build_client(&self) -> Result { + let mut builder = reqwest::Client::builder(); + + // Add custom CA certificate if provided + if let Some(ca_cert_path) = &self.ca_cert { + let cert_bytes = + std::fs::read(ca_cert_path).map_err(|e| TlsConfigError::CertificateRead { + path: ca_cert_path.clone(), + source: e, + })?; + let cert = reqwest::Certificate::from_pem(&cert_bytes).map_err(|_| { + TlsConfigError::CertificateParse { + path: ca_cert_path.clone(), + } + })?; + builder = builder.add_root_certificate(cert); + tracing::debug!("Added custom CA certificate from {:?}", ca_cert_path); + } + + // Accept invalid certs if configured (development only) + if self.danger_accept_invalid_certs { + tracing::warn!( + "TLS certificate validation is disabled. This is insecure and should only be used for development." + ); + builder = builder.danger_accept_invalid_certs(true); + } + + Ok(builder.build()?) + } +} + /// Auth configuration options #[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct Config { @@ -56,10 +105,41 @@ pub struct Config { /// Whether to disable the auth token passthrough to upstream API #[serde(default)] pub disable_auth_token_passthrough: bool, + + /// TLS configuration for connecting to OAuth servers + #[serde(default)] + pub tls: TlsConfig, +} + +/// TLS configuration for OAuth server connections +#[derive(Debug, Clone, Default, Deserialize, JsonSchema)] +pub struct TlsConfig { + /// Path to additional CA certificates to trust (PEM format). + /// Use this when your OAuth server uses a self-signed certificate + /// or a certificate signed by a private CA. + pub ca_cert: Option, + + /// Whether to accept invalid TLS certificates. + /// + /// **WARNING**: This is insecure and should only be used for development/testing. + /// When enabled, the server will accept any certificate, including self-signed + /// and expired certificates, without validation. + #[serde(default)] + pub danger_accept_invalid_certs: bool, +} + +/// Internal state for the auth middleware, containing both config and pre-built HTTP client +#[derive(Clone)] +struct AuthState { + config: Config, + client: reqwest::Client, } impl Config { - pub fn enable_middleware(&self, router: Router) -> Router { + /// Enable auth middleware on the router. + /// + /// Builds the HTTP client at startup to validate TLS configuration eagerly. + pub fn enable_middleware(&self, router: Router) -> Result { if self.allow_any_audience { warn!( "allow_any_audience is enabled - audience validation is disabled. This reduces security." @@ -68,10 +148,19 @@ impl Config { /// Simple handler to encode our config into the desired OAuth 2.1 protected /// resource format - async fn protected_resource(State(auth_config): State) -> Json { - Json(auth_config.into()) + async fn protected_resource( + State(auth_state): State, + ) -> Json { + Json(auth_state.config.into()) } + // Build HTTP client with TLS configuration + let client = self.tls.build_client()?; + let auth_state = AuthState { + config: self.clone(), + client, + }; + // Set up auth routes. NOTE: CORs needs to allow for get requests to the // metadata information paths. let cors = CorsLayer::new() @@ -82,27 +171,26 @@ impl Config { "/.well-known/oauth-protected-resource", get(protected_resource), ) - .with_state(self.clone()) + .with_state(auth_state.clone()) .layer(cors); // Merge with MCP server routes - Router::new() - .merge(auth_router) - .merge(router.layer(axum::middleware::from_fn_with_state( - self.clone(), - oauth_validate, - ))) + Ok(Router::new().merge(auth_router).merge(router.layer( + axum::middleware::from_fn_with_state(auth_state, oauth_validate), + ))) } } /// Validate that requests made have a corresponding bearer JWT token #[tracing::instrument(skip_all, fields(status_code, reason))] async fn oauth_validate( - State(auth_config): State, + State(auth_state): State, token: Option>>, mut request: Request, next: Next, ) -> Result)> { + 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(); @@ -127,6 +215,7 @@ async fn oauth_validate( &auth_config.audiences, auth_config.allow_any_audience, &auth_config.servers, + &auth_state.client, ); let token = token.ok_or_else(|| { tracing::Span::current().record("reason", "missing_token"); @@ -172,13 +261,21 @@ mod tests { resource_documentation: None, scopes: vec!["read".to_string()], disable_auth_token_passthrough: false, + tls: TlsConfig::default(), + } + } + + fn test_auth_state(config: Config) -> AuthState { + AuthState { + config, + client: reqwest::Client::new(), } } fn test_router(config: Config) -> Router { Router::new() .route("/test", get(|| async { "ok" })) - .layer(from_fn_with_state(config, oauth_validate)) + .layer(from_fn_with_state(test_auth_state(config), oauth_validate)) } #[tokio::test] @@ -238,4 +335,105 @@ mod tests { assert!(www_auth.contains("resource_metadata")); assert!(!www_auth.contains("scope=")); } + + mod tls_config { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn default_config_builds_client() { + let config = TlsConfig::default(); + let client = config.build_client(); + assert!(client.is_ok()); + } + + #[test] + fn danger_accept_invalid_certs_builds_client() { + let config = TlsConfig { + ca_cert: None, + danger_accept_invalid_certs: true, + }; + let client = config.build_client(); + assert!(client.is_ok()); + } + + #[test] + fn valid_ca_cert_is_loaded() { + // Create a temporary file with a valid PEM certificate + // This is the ISRG Root X1 certificate (Let's Encrypt root CA) + let mut temp_file = NamedTempFile::new().unwrap(); + let test_cert = r#"-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE-----"#; + temp_file.write_all(test_cert.as_bytes()).unwrap(); + + let config = TlsConfig { + ca_cert: Some(temp_file.path().to_path_buf()), + danger_accept_invalid_certs: false, + }; + let client = config.build_client(); + assert!(client.is_ok()); + } + + #[test] + fn missing_ca_cert_file_returns_error() { + let config = TlsConfig { + ca_cert: Some("/nonexistent/path/to/cert.pem".into()), + danger_accept_invalid_certs: false, + }; + let result = config.build_client(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + TlsConfigError::CertificateRead { .. } + )); + } + + #[test] + fn invalid_pem_returns_error() { + // Create a temporary file with invalid PEM content + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(b"not a valid certificate").unwrap(); + + let config = TlsConfig { + ca_cert: Some(temp_file.path().to_path_buf()), + danger_accept_invalid_certs: false, + }; + let result = config.build_client(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + TlsConfigError::CertificateParse { .. } + )); + } + } } 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 bd8f21a0b..ec6257608 100644 --- a/crates/apollo-mcp-server/src/auth/networked_token_validator.rs +++ b/crates/apollo-mcp-server/src/auth/networked_token_validator.rs @@ -10,14 +10,21 @@ pub(super) struct NetworkedTokenValidator<'a> { audiences: &'a [String], allow_any_audience: bool, upstreams: &'a Vec, + client: &'a reqwest::Client, } impl<'a> NetworkedTokenValidator<'a> { - pub fn new(audiences: &'a [String], allow_any_audience: bool, upstreams: &'a Vec) -> Self { + pub fn new( + audiences: &'a [String], + allow_any_audience: bool, + upstreams: &'a Vec, + client: &'a reqwest::Client, + ) -> Self { Self { audiences, allow_any_audience, upstreams, + client, } } } @@ -49,7 +56,7 @@ impl ValidateToken for NetworkedTokenValidator<'_> { async fn get_key(&self, server: &Url, key_id: &str) -> Option { let oidc_url = build_oidc_url(server); - let jwks = Jwks::from_oidc_url(oidc_url) + let jwks = Jwks::from_oidc_url_with_client(self.client, oidc_url.as_str()) .await .inspect_err(|e| { warn!("could not fetch OIDC information from {server}: {e}"); diff --git a/crates/apollo-mcp-server/src/errors.rs b/crates/apollo-mcp-server/src/errors.rs index 1e86faf76..1f7ad2743 100644 --- a/crates/apollo-mcp-server/src/errors.rs +++ b/crates/apollo-mcp-server/src/errors.rs @@ -106,6 +106,9 @@ pub enum ServerError { #[error("Failed to load apps: {0}")] Apps(String), + + #[error("TLS configuration error: {0}")] + Tls(#[from] crate::auth::TlsConfigError), } /// An MCP tool error diff --git a/crates/apollo-mcp-server/src/server/states/starting.rs b/crates/apollo-mcp-server/src/server/states/starting.rs index 0f4907604..973cbc575 100644 --- a/crates/apollo-mcp-server/src/server/states/starting.rs +++ b/crates/apollo-mcp-server/src/server/states/starting.rs @@ -180,7 +180,13 @@ impl Starting { ($router:expr, $auth:ident) => {{ let mut router = $router; if let Some(auth) = $auth { - router = auth.enable_middleware(router); + match auth.enable_middleware(router) { + Ok(r) => router = r, + Err(e) => { + error!("Failed to enable auth middleware: {}", e); + return Err(e.into()); + } + } } router diff --git a/docs/source/config-file.mdx b/docs/source/config-file.mdx index 1bb8fe010..0c051a175 100644 --- a/docs/source/config-file.mdx +++ b/docs/source/config-file.mdx @@ -15,23 +15,22 @@ All fields are optional. ### Top-level options -| Option | Type | Default | Description | -| :--------------- | :-------------------- | :----------------------- | :--------------------------------------------------------------- | -| `cors` | `Cors` | | CORS configuration | -| `custom_scalars` | `FilePath` | | Path to a [custom scalar map](/apollo-mcp-server/custom-scalars) | -| `endpoint` | `URL` | `http://localhost:4000/` | The target GraphQL endpoint | -| `forward_headers`| `List` | `[]` | Headers to forward from MCP clients to GraphQL API | -| `graphos` | `GraphOS` | | Apollo-specific credential overrides | -| `headers` | `Map` | `{}` | List of hard-coded headers to include in all GraphQL requests | -| `health_check` | `HealthCheck` | | Health check configuration | -| `introspection` | `Introspection` | | Introspection configuration | -| `logging` | `Logging` | | Logging configuration | -| `operations` | `OperationSource` | | Operations configuration | -| `overrides` | `Overrides` | | Overrides for server behavior | -| `schema` | `SchemaSource` | | Schema configuration | -| `transport` | `Transport` | | The type of server transport to use | -| `telemetry` | `Telemetry` | | Configuration to export metrics and traces via OTLP | - +| Option | Type | Default | Description | +| :---------------- | :-------------------- | :----------------------- | :--------------------------------------------------------------- | +| `cors` | `Cors` | | CORS configuration | +| `custom_scalars` | `FilePath` | | Path to a [custom scalar map](/apollo-mcp-server/custom-scalars) | +| `endpoint` | `URL` | `http://localhost:4000/` | The target GraphQL endpoint | +| `forward_headers` | `List` | `[]` | Headers to forward from MCP clients to GraphQL API | +| `graphos` | `GraphOS` | | Apollo-specific credential overrides | +| `headers` | `Map` | `{}` | List of hard-coded headers to include in all GraphQL requests | +| `health_check` | `HealthCheck` | | Health check configuration | +| `introspection` | `Introspection` | | Introspection configuration | +| `logging` | `Logging` | | Logging configuration | +| `operations` | `OperationSource` | | Operations configuration | +| `overrides` | `Overrides` | | Overrides for server behavior | +| `schema` | `SchemaSource` | | Schema configuration | +| `transport` | `Transport` | | The type of server transport to use | +| `telemetry` | `Telemetry` | | Configuration to export metrics and traces via OTLP | ### GraphOS @@ -59,6 +58,7 @@ To forward dynamic header values from the client, use the [`forward_headers` opt The `forward_headers` option allows you to forward specific headers from incoming MCP client requests to your GraphQL API. This is useful for: + - Multi-tenant applications (forwarding tenant IDs) - A/B testing (forwarding experiment IDs) - Geo information (forwarding country codes) @@ -82,14 +82,15 @@ forward_headers: Don't use header forwarding to pass through sensitive credentials such as API keys or access tokens. -

+
+
According to [MCP security best practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#token-passthrough) and the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#access-token-privilege-restriction), token passthrough introduces serious security risks: - **Audience confusion**: If the MCP Server accepts tokens not intended for it, it can violate OAuth's trust boundaries. - **Confused deputy problem**: If an unvalidated token is passed downstream, a downstream API may incorrectly trust it as though it were validated by the MCP Server. -
+
Apollo MCP Server supports OAuth 2.1 authentication that follows best practices and aligns with the MCP authorization model. See our [authorization guide](/apollo-mcp-server/auth) for implementation details and how to use the [auth configuration](#auth). @@ -216,25 +217,25 @@ transport: The available fields depend on the value of the nested `type` key. The default type is `stdio`: -| Option | Description | -| :-------------------- | :--------------------------------------------------------------------------------------------------------------- | -| `"stdio"` | Use standard IO for communication between the server and client | -| `"streamable_http"` | Host the MCP server on the configuration, using streamable HTTP messages | -| `"sse"` | Host the MCP server on the supplied config, using SSE for communication. Deprecated in favor of `StreamableHTTP` | +| Option | Description | +| :------------------ | :--------------------------------------------------------------------------------------------------------------- | +| `"stdio"` | Use standard IO for communication between the server and client | +| `"streamable_http"` | Host the MCP server on the configuration, using streamable HTTP messages | +| `"sse"` | Host the MCP server on the supplied config, using SSE for communication. Deprecated in favor of `StreamableHTTP` | ##### Transport Type Specific options Some transport types support further configuration. For both `streamable_http` and `sse`, you can set the `address` and `port`. For `streamable_http`, you can also set `stateful_mode`. -| Option | Type | Default | Description | -| :-------------- | :--------- | :---------- | :----------------------------------------------------------------------------------- | -| `address` | `IpAddr` | `127.0.0.1` | The IP address to bind to | -| `port` | `u16` | `8000` | The port to bind to | -| `stateful_mode` | `bool` | `true` | Flag to enable or disable stateful mode and session management. Not supported by SSE | +| Option | Type | Default | Description | +| :-------------- | :------- | :---------- | :----------------------------------------------------------------------------------- | +| `address` | `IpAddr` | `127.0.0.1` | The IP address to bind to | +| `port` | `u16` | `8000` | The port to bind to | +| `stateful_mode` | `bool` | `true` | Flag to enable or disable stateful mode and session management. Not supported by SSE | -For Apollo MCP Server `≤v1.0.0`, the default `port` value is `5000`. In `v1.1.0`, the default `port` option was changed to `8000` to avoid conflicts with common development tools and services that typically use port 5000 (such as macOS AirPlay, Flask development servers, and other local services). +For Apollo MCP Server `≤v1.0.0`, the default `port` value is `5000`. In `v1.1.0`, the default `port` option was changed to `8000` to avoid conflicts with common development tools and services that typically use port 5000 (such as macOS AirPlay, Flask development servers, and other local services). @@ -242,15 +243,17 @@ For Apollo MCP Server `≤v1.0.0`, the default `port` value is `5000`. In `v1.1. These fields are under the top-level `transport` key, nested under the `auth` key. Learn more about [authorization and authentication](/apollo-mcp-server/auth). -| 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`) | -| `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 | -| `disable_auth_token_passthrough` | `bool` | `false` | Optional flag to disable passing validated Authorization header to downstream API | +| 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`) | +| `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 | +| `disable_auth_token_passthrough` | `bool` | `false` | Optional flag to disable passing validated Authorization header to downstream API | +| `tls.ca_cert` | `string` | | Path to a CA certificate to trust (PEM format). | +| `tls.danger_accept_invalid_certs` | `bool` | `false` | Accepts invalid TLS certificates. Set this to `true` for development or testing purposes only. | Below is an example configuration using `StreamableHTTP` transport with authentication: @@ -286,41 +289,47 @@ transport: - read - mcp - profile + + # Optional TLS configuration for connecting to OAuth servers + tls: + # Path to CA certificate for private CA or self-signed OAuth server certs + # Use when your OAuth server uses a self-signed certificate or a certificate signed by a private CA + ca_cert: /path/to/ca-certificate.pem + # Set this to true for development or testing purposes only + # danger_accept_invalid_certs: false ``` ### Telemetry -| Option | Type | Default | Description | -| :-------------- | :---------- | :-------------------------- | :--------------------------------------- | -| `service_name` | `string` | "apollo-mcp-server" | The service name in telemetry data. | -| `version` | `string` | Current crate version | The service version in telemetry data. | -| `exporters` | `Exporters` | `null` (Telemetry disabled) | Configuration for telemetry exporters. | +| Option | Type | Default | Description | +| :------------- | :---------- | :-------------------------- | :------------------------------------- | +| `service_name` | `string` | "apollo-mcp-server" | The service name in telemetry data. | +| `version` | `string` | Current crate version | The service version in telemetry data. | +| `exporters` | `Exporters` | `null` (Telemetry disabled) | Configuration for telemetry exporters. | #### Exporters -| Option | Type | Default | Description | -| :--------- | :---------- | :-------------------------- | :--------------------------------------- | -| `metrics` | `Metrics` | `null` (Metrics disabled) | Configuration for exporting metrics. | -| `tracing` | `Tracing` | `null` (Tracing disabled) | Configuration for exporting traces. | - +| Option | Type | Default | Description | +| :-------- | :-------- | :------------------------ | :----------------------------------- | +| `metrics` | `Metrics` | `null` (Metrics disabled) | Configuration for exporting metrics. | +| `tracing` | `Tracing` | `null` (Tracing disabled) | Configuration for exporting traces. | #### Metrics -| Option | Type | Default | Description | -| :-------------------- | :---------------------- | :-------------------------- | :--------------------------------------------- | -| `otlp` | `OTLP Metric Exporter` | `null` (Exporting disabled) | Configuration for exporting metrics via OTLP. | -| `omitted_attributes` | `List` | | List of attributes to be omitted from metrics. | - +| Option | Type | Default | Description | +| :------------------- | :--------------------- | :-------------------------- | :--------------------------------------------- | +| `otlp` | `OTLP Metric Exporter` | `null` (Exporting disabled) | Configuration for exporting metrics via OTLP. | +| `omitted_attributes` | `List` | | List of attributes to be omitted from metrics. | #### OTLP Metrics Exporter -| Option | Type | Default | Description | -|:--------------|:-----------------------------------------------|:------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `endpoint` | `URL` | `http://localhost:4317` | URL to export data to. Requires full path. | -| `protocol` | `string` | `grpc` | Specifies the export protocol. Supported values are `grpc` and `http/protobuf`. | -| `temporality` | `MetricTemporality` | `Cumulative` | Specifies how additive quantities are expressed over time. `Cumulative` means reported values include previous measurements, and `Delta` means they don't. | -| `metadata` | Key-value pairs | | Key-value pairs for metadata. This field applies only when `protocol` is `grpc`. | -| `headers` | Key-value pairs | | Key-value pairs for headers. This field applies only when `protocol` is `http/protobuf`. | +| Option | Type | Default | Description | +| :------------ | :------------------ | :---------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `endpoint` | `URL` | `http://localhost:4317` | URL to export data to. Requires full path. | +| `protocol` | `string` | `grpc` | Specifies the export protocol. Supported values are `grpc` and `http/protobuf`. | +| `temporality` | `MetricTemporality` | `Cumulative` | Specifies how additive quantities are expressed over time. `Cumulative` means reported values include previous measurements, and `Delta` means they don't. | +| `metadata` | Key-value pairs | | Key-value pairs for metadata. This field applies only when `protocol` is `grpc`. | +| `headers` | Key-value pairs | | Key-value pairs for headers. This field applies only when `protocol` is `http/protobuf`. | @@ -363,20 +372,20 @@ export APOLLO_MCP_TELEMETRY__EXPORTERS__METRICS__OTLP__METADATA__API_KEY="${YOUR #### Traces -| Option | Type | Default | Description | -| :-------------------- | :--------------------- | :-------------------------- | :--------------------------------------------- | -| `otlp` | `OTLP Trace Exporter` | `null` (Exporting disabled) | Configuration for exporting traces via OTLP. | -| `sampler` | `SamplerOption` | `ALWAYS_ON` | Configuration to control sampling of traces. | -| `omitted_attributes` | `List` | | List of attributes to be omitted from traces. | +| Option | Type | Default | Description | +| :------------------- | :-------------------- | :-------------------------- | :-------------------------------------------- | +| `otlp` | `OTLP Trace Exporter` | `null` (Exporting disabled) | Configuration for exporting traces via OTLP. | +| `sampler` | `SamplerOption` | `ALWAYS_ON` | Configuration to control sampling of traces. | +| `omitted_attributes` | `List` | | List of attributes to be omitted from traces. | #### OTLP Trace Exporter -| Option | Type | Default | Description | -|:-------------|:---------------------------------------------|:------------------------|:-----------------------------------------------------------------------------------------| -| `endpoint` | `URL` | `http://localhost:4317` | URL to export data to. Requires full path. | -| `protocol` | `string` | `grpc` | Specifies the export protocol. Supported values are `grpc` and `http/protobuf`. | -| `metadata` | Key-value pairs | | Key-value pairs for metadata. This field applies only when `protocol` is `grpc`. | -| `headers` | Key-value pairs | | Key-value pairs for headers. This field applies only when `protocol` is `http/protobuf`. | +| Option | Type | Default | Description | +| :--------- | :-------------- | :---------------------- | :--------------------------------------------------------------------------------------- | +| `endpoint` | `URL` | `http://localhost:4317` | URL to export data to. Requires full path. | +| `protocol` | `string` | `grpc` | Specifies the export protocol. Supported values are `grpc` and `http/protobuf`. | +| `metadata` | Key-value pairs | | Key-value pairs for metadata. This field applies only when `protocol` is `grpc`. | +| `headers` | Key-value pairs | | Key-value pairs for headers. This field applies only when `protocol` is `http/protobuf`. | @@ -398,19 +407,19 @@ telemetry: #### MetricTemporality -| Option | Type | Description | -|:-------------|:------------|:-------------------------------| -| `cumulative` | `string` | Cumulative temporality option. | -| `delta` | `string` | Delta temporality option. | -| `lowmemory` | `string` | Low memory temporality option. | +| Option | Type | Description | +| :----------- | :------- | :----------------------------- | +| `cumulative` | `string` | Cumulative temporality option. | +| `delta` | `string` | Delta temporality option. | +| `lowmemory` | `string` | Low memory temporality option. | #### SamplerOption -| Option | Type | Description | -| :----------- | :-------- | :------------------------------------------------------- | -| `always_on` | `string` | All traces will be exported. | -| `always_off` | `string` | Sampling is turned off, no traces will be exported. | -| `0.0-1.0` | `f64` | Percentage of traces to export. | +| Option | Type | Description | +| :----------- | :------- | :-------------------------------------------------- | +| `always_on` | `string` | All traces will be exported. | +| `always_off` | `string` | Sampling is turned off, no traces will be exported. | +| `0.0-1.0` | `f64` | Percentage of traces to export. | ## Example config files