diff --git a/.changesets/feat_igni_subgraph_auth.md b/.changesets/feat_igni_subgraph_auth.md new file mode 100644 index 0000000000..d4f4898df7 --- /dev/null +++ b/.changesets/feat_igni_subgraph_auth.md @@ -0,0 +1,34 @@ +### Configure AWS sigv4 authentication for subgraph requests ([PR #3365](https://github.com/apollographql/router/pull/3365)) + +Secure your router to subgraph communication on AWS using [Signature Version 4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) (Sigv4)! +This changeset provides you with a way to set up hardcoded credentials, as well as a default provider chain. +We recommend using the default provider chain configuration. + +Full use example: + +```yaml + authentication: + subgraph: + all: # configuration that will apply to all subgraphs + aws_sig_v4: + default_chain: + profile_name: "my-test-profile" # https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile + region: "us-east-1" # https://docs.aws.amazon.com/general/latest/gr/rande.html + service_name: "lambda" # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-services-that-work-with-iam.html + assume_role: # https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html + role_arn: "test-arn" + session_name: "test-session" + external_id: "test-id" + subgraphs: + products: + aws_sig_v4: + hardcoded: # Not recommended, prefer using default_chain as shown above + access_key_id: "my-access-key" + secret_access_key: "my-secret-access-key" + region: "us-east-1" + service_name: "vpc-lattice-svcs" # "s3", "lambda" etc. +``` + +The full documentation can be found in the [router documentation](https://www.apollographql.com/docs/router/configuration/authn-subgraph). + +By [@o0Ignition0o](https://github.com/o0Ignition0o) and [@BlenderDude](https://github.com/BlenderDude) in https://github.com/apollographql/router/pull/3365 diff --git a/Cargo.lock b/Cargo.lock index b73aef9f3d..056968aec5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,6 +283,10 @@ dependencies = [ "async-compression", "async-trait", "atty", + "aws-config", + "aws-credential-types", + "aws-sigv4", + "aws-types", "axum", "base64 0.20.0", "brotli", @@ -638,6 +642,296 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "aws-config" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcdcf0d683fe9c23d32cf5b53c9918ea0a500375a9fb20109802552658e576c9" +dependencies = [ + "aws-credential-types", + "aws-http", + "aws-sdk-sso", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-client", + "aws-smithy-http", + "aws-smithy-http-tower", + "aws-smithy-json", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http", + "hyper", + "ring", + "time", + "tokio", + "tower", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fcdb2f7acbc076ff5ad05e7864bdb191ca70a6fd07668dc3a1a8bcd051de5ae" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "fastrand", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-endpoint" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cce1c41a6cfaa726adee9ebb9a56fcd2bbfd8be49fd8a04c5e20fd968330b04" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "aws-types", + "http", + "regex", + "tracing", +] + +[[package]] +name = "aws-http" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aadbc44e7a8f3e71c8b374e03ecd972869eb91dd2bc89ed018954a52ba84bc44" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-types", + "aws-types", + "bytes", + "http", + "http-body", + "lazy_static", + "percent-encoding", + "pin-project-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8b812340d86d4a766b2ca73f740dfd47a97c2dff0c06c8517a16d88241957e4" +dependencies = [ + "aws-credential-types", + "aws-endpoint", + "aws-http", + "aws-sig-auth", + "aws-smithy-async", + "aws-smithy-client", + "aws-smithy-http", + "aws-smithy-http-tower", + "aws-smithy-json", + "aws-smithy-types", + "aws-types", + "bytes", + "http", + "regex", + "tokio-stream", + "tower", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "265fac131fbfc188e5c3d96652ea90ecc676a934e3174eaaee523c6cec040b3b" +dependencies = [ + "aws-credential-types", + "aws-endpoint", + "aws-http", + "aws-sig-auth", + "aws-smithy-async", + "aws-smithy-client", + "aws-smithy-http", + "aws-smithy-http-tower", + "aws-smithy-json", + "aws-smithy-query", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "http", + "regex", + "tower", + "tracing", +] + +[[package]] +name = "aws-sig-auth" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b94acb10af0c879ecd5c7bdf51cda6679a0a4f4643ce630905a77673bfa3c61" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-http", + "aws-types", + "http", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2ce6f507be68e968a33485ced670111d1cbad161ddbbab1e313c03d37d8f4c" +dependencies = [ + "aws-smithy-http", + "form_urlencoded", + "hex", + "hmac", + "http", + "once_cell", + "percent-encoding", + "regex", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bda3996044c202d75b91afeb11a9afae9db9a721c6a7a427410018e286b880" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", + "tokio-stream", +] + +[[package]] +name = "aws-smithy-client" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a86aa6e21e86c4252ad6a0e3e74da9617295d8d6e374d552be7d3059c41cedd" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-tower", + "aws-smithy-types", + "bytes", + "fastrand", + "http", + "http-body", + "hyper", + "hyper-rustls 0.23.2", + "lazy_static", + "pin-project-lite", + "rustls 0.20.8", + "tokio", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-http" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3b693869133551f135e1f2c77cb0b8277d9e3e17feaf2213f735857c4f0d28" +dependencies = [ + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "http", + "http-body", + "hyper", + "once_cell", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-tower" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae4f6c5798a247fac98a867698197d9ac22643596dc3777f0c76b91917616b9" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f9f42fbfa96d095194a632fbac19f60077748eba536eb0b9fecc28659807f8" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-query" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98819eb0b04020a1c791903533b638534ae6c12e2aceda3e6e6fba015608d51d" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-types" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a3d0bf4f324f4ef9793b86a1701d9700fbcdbd12a846da45eed104c634c6e8" +dependencies = [ + "base64-simd", + "itoa", + "num-integer", + "ryu", + "time", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b9d12875731bd07e767be7baad95700c3137b56730ec9ddeedb52a5e5ca63b" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "0.55.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd209616cc8d7bfb82f87811a5c655dc97537f592689b18743bddf5dc5c4829" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-client", + "aws-smithy-http", + "aws-smithy-types", + "http", + "rustc_version 0.4.0", + "tracing", +] + [[package]] name = "axum" version = "0.6.19" @@ -7267,6 +7561,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xmlparser" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d25c75bf9ea12c4040a97f829154768bbbce366287e2dc044af160cd79a13fd" + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/apollo-router/Cargo.toml b/apollo-router/Cargo.toml index 6e6c85acb6..13f90ce337 100644 --- a/apollo-router/Cargo.toml +++ b/apollo-router/Cargo.toml @@ -226,6 +226,11 @@ brotli = "3.3.4" zstd = "0.12.3" zstd-safe = "6.0.5" rand_core = "0.6.4" +# note: AWS dependencies should always use the same version +aws-sigv4 = "0.55.3" +aws-credential-types = "0.55.3" +aws-config = "0.55.3" +aws-types = "0.55.3" [target.'cfg(macos)'.dependencies] uname = "0.1.1" diff --git a/apollo-router/src/configuration/migrations/0010-auth-jwt.yaml b/apollo-router/src/configuration/migrations/0010-auth-jwt.yaml new file mode 100644 index 0000000000..953c84aec4 --- /dev/null +++ b/apollo-router/src/configuration/migrations/0010-auth-jwt.yaml @@ -0,0 +1,5 @@ +description: Move jwt configuration to authentication.router +actions: + - type: move + from: authentication.jwt + to: authentication.router.jwt diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap index 79ad5128a8..bf7d0901f1 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap @@ -151,60 +151,363 @@ expression: "&schema" "authentication": { "description": "Authentication", "type": "object", - "required": [ - "jwt" - ], "properties": { - "jwt": { - "description": "The JWT configuration", + "router": { + "description": "Router configuration", "type": "object", "required": [ - "jwks" + "jwt" ], "properties": { - "header_name": { - "description": "HTTP header expected to contain JWT", - "default": "authorization", - "type": "string" - }, - "header_value_prefix": { - "description": "Header value prefix", - "default": "Bearer", - "type": "string" - }, - "jwks": { - "description": "List of JWKS used to verify tokens", - "type": "array", - "items": { - "type": "object", - "required": [ - "url" - ], - "properties": { - "algorithms": { - "description": "List of accepted algorithms. Possible values are `HS256`, `HS384`, `HS512`, `ES256`, `ES384`, `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `EdDSA`", - "default": null, - "type": "array", - "items": { - "type": "string" + "jwt": { + "description": "The JWT configuration", + "type": "object", + "required": [ + "jwks" + ], + "properties": { + "header_name": { + "description": "HTTP header expected to contain JWT", + "default": "authorization", + "type": "string" + }, + "header_value_prefix": { + "description": "Header value prefix", + "default": "Bearer", + "type": "string" + }, + "jwks": { + "description": "List of JWKS used to verify tokens", + "type": "array", + "items": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "algorithms": { + "description": "List of accepted algorithms. Possible values are `HS256`, `HS384`, `HS512`, `ES256`, `ES384`, `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `EdDSA`", + "default": null, + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "issuer": { + "description": "Expected issuer for tokens verified by that JWKS", + "type": "string", + "nullable": true + }, + "url": { + "description": "Retrieve the JWK Set", + "type": "string" + } }, - "nullable": true - }, - "issuer": { - "description": "Expected issuer for tokens verified by that JWKS", - "type": "string", - "nullable": true - }, - "url": { - "description": "Retrieve the JWK Set", - "type": "string" + "additionalProperties": false } } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "nullable": true + }, + "subgraph": { + "description": "Subgraph configuration", + "type": "object", + "properties": { + "all": { + "description": "Configuration that will apply to all subgraphs.", + "oneOf": [ + { + "type": "object", + "required": [ + "aws_sig_v4" + ], + "properties": { + "aws_sig_v4": { + "description": "Configure AWS sigv4 auth.", + "oneOf": [ + { + "type": "object", + "required": [ + "hardcoded" + ], + "properties": { + "hardcoded": { + "description": "Hardcoded Config using access_key and secret. Prefer using DefaultChain instead.", + "type": "object", + "required": [ + "access_key_id", + "region", + "secret_access_key", + "service_name" + ], + "properties": { + "access_key_id": { + "description": "The ID for this access key.", + "type": "string" + }, + "assume_role": { + "description": "Specify assumed role configuration.", + "type": "object", + "required": [ + "role_arn", + "session_name" + ], + "properties": { + "external_id": { + "description": "Unique identifier that might be required when you assume a role in another account.", + "type": "string", + "nullable": true + }, + "role_arn": { + "description": "Amazon Resource Name (ARN) for the role assumed when making requests", + "type": "string" + }, + "session_name": { + "description": "Uniquely identify a session when the same role is assumed by different principals or for different reasons.", + "type": "string" + } + }, + "additionalProperties": false, + "nullable": true + }, + "region": { + "description": "The AWS region this chain applies to.", + "type": "string" + }, + "secret_access_key": { + "description": "The secret key used to sign requests.", + "type": "string" + }, + "service_name": { + "description": "The service you're trying to access, eg: \"s3\", \"vpc-lattice-svcs\", etc.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "default_chain" + ], + "properties": { + "default_chain": { + "description": "Configuration of the DefaultChainProvider", + "type": "object", + "required": [ + "region", + "service_name" + ], + "properties": { + "assume_role": { + "description": "Specify assumed role configuration.", + "type": "object", + "required": [ + "role_arn", + "session_name" + ], + "properties": { + "external_id": { + "description": "Unique identifier that might be required when you assume a role in another account.", + "type": "string", + "nullable": true + }, + "role_arn": { + "description": "Amazon Resource Name (ARN) for the role assumed when making requests", + "type": "string" + }, + "session_name": { + "description": "Uniquely identify a session when the same role is assumed by different principals or for different reasons.", + "type": "string" + } + }, + "additionalProperties": false, + "nullable": true + }, + "profile_name": { + "description": "The profile name used by this provider", + "type": "string", + "nullable": true + }, + "region": { + "description": "The AWS region this chain applies to.", + "type": "string" + }, + "service_name": { + "description": "The service you're trying to access, eg: \"s3\", \"vpc-lattice-svcs\", etc.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + ], + "nullable": true + }, + "subgraphs": { + "description": "Create a configuration that will apply only to a specific subgraph.", + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "required": [ + "aws_sig_v4" + ], + "properties": { + "aws_sig_v4": { + "description": "Configure AWS sigv4 auth.", + "oneOf": [ + { + "type": "object", + "required": [ + "hardcoded" + ], + "properties": { + "hardcoded": { + "description": "Hardcoded Config using access_key and secret. Prefer using DefaultChain instead.", + "type": "object", + "required": [ + "access_key_id", + "region", + "secret_access_key", + "service_name" + ], + "properties": { + "access_key_id": { + "description": "The ID for this access key.", + "type": "string" + }, + "assume_role": { + "description": "Specify assumed role configuration.", + "type": "object", + "required": [ + "role_arn", + "session_name" + ], + "properties": { + "external_id": { + "description": "Unique identifier that might be required when you assume a role in another account.", + "type": "string", + "nullable": true + }, + "role_arn": { + "description": "Amazon Resource Name (ARN) for the role assumed when making requests", + "type": "string" + }, + "session_name": { + "description": "Uniquely identify a session when the same role is assumed by different principals or for different reasons.", + "type": "string" + } + }, + "additionalProperties": false, + "nullable": true + }, + "region": { + "description": "The AWS region this chain applies to.", + "type": "string" + }, + "secret_access_key": { + "description": "The secret key used to sign requests.", + "type": "string" + }, + "service_name": { + "description": "The service you're trying to access, eg: \"s3\", \"vpc-lattice-svcs\", etc.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "default_chain" + ], + "properties": { + "default_chain": { + "description": "Configuration of the DefaultChainProvider", + "type": "object", + "required": [ + "region", + "service_name" + ], + "properties": { + "assume_role": { + "description": "Specify assumed role configuration.", + "type": "object", + "required": [ + "role_arn", + "session_name" + ], + "properties": { + "external_id": { + "description": "Unique identifier that might be required when you assume a role in another account.", + "type": "string", + "nullable": true + }, + "role_arn": { + "description": "Amazon Resource Name (ARN) for the role assumed when making requests", + "type": "string" + }, + "session_name": { + "description": "Uniquely identify a session when the same role is assumed by different principals or for different reasons.", + "type": "string" + } + }, + "additionalProperties": false, + "nullable": true + }, + "profile_name": { + "description": "The profile name used by this provider", + "type": "string", + "nullable": true + }, + "region": { + "description": "The AWS region this chain applies to.", + "type": "string" + }, + "service_name": { + "description": "The service you're trying to access, eg: \"s3\", \"vpc-lattice-svcs\", etc.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + ] } } - } + }, + "additionalProperties": false, + "nullable": true } - } + }, + "additionalProperties": false }, "authorization": { "description": "Authorization plugin", diff --git a/apollo-router/src/plugins/authentication/mod.rs b/apollo-router/src/plugins/authentication/mod.rs index a7811e83ff..000df28b53 100644 --- a/apollo-router/src/plugins/authentication/mod.rs +++ b/apollo-router/src/plugins/authentication/mod.rs @@ -1,5 +1,6 @@ //! Authentication plugin +use std::collections::HashMap; use std::ops::ControlFlow; use std::str::FromStr; use std::time::Duration; @@ -29,6 +30,9 @@ use tower::ServiceExt; use url::Url; use self::jwks::JwksManager; +use self::subgraph::SigningParams; +use self::subgraph::SigningParamsConfig; +use self::subgraph::SubgraphAuth; use crate::graphql; use crate::layers::ServiceBuilderExt; use crate::plugin::Plugin; @@ -40,6 +44,8 @@ use crate::services::router; use crate::Context; mod jwks; +mod subgraph; + #[cfg(test)] mod tests; @@ -94,12 +100,18 @@ pub(crate) enum Error { BadHeaderValuePrefix, } -struct AuthenticationPlugin { +struct Router { configuration: JWTConf, jwks_manager: JwksManager, } +struct AuthenticationPlugin { + router: Option, + subgraph: Option, +} + #[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] struct JWTConf { /// List of JWKS used to verify tokens jwks: Vec, @@ -112,6 +124,7 @@ struct JWTConf { } #[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] struct JwksConf { /// Retrieve the JWK Set url: String, @@ -133,12 +146,22 @@ impl Default for JWTConf { } } +/// Authentication +#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct Conf { + /// Router configuration + router: Option, + /// Subgraph configuration + subgraph: Option, +} + // We may support additional authentication mechanisms in future, so all // configuration (which is currently JWT specific) is isolated to the // JWTConf structure. -/// Authentication #[derive(Clone, Debug, Default, Deserialize, JsonSchema)] -struct Conf { +#[serde(deny_unknown_fields)] +struct RouterConf { /// The JWT configuration jwt: JWTConf, } @@ -327,60 +350,103 @@ impl Plugin for AuthenticationPlugin { type Config = Conf; async fn new(init: PluginInit) -> Result { - if init - .config - .jwt - .header_value_prefix - .as_bytes() - .iter() - .any(u8::is_ascii_whitespace) - { - return Err(Error::BadHeaderValuePrefix.into()); - } - let mut list = vec![]; - for jwks_conf in &init.config.jwt.jwks { - let url: Url = Url::from_str(jwks_conf.url.as_str())?; - list.push(JwksConfig { - url, - issuer: jwks_conf.issuer.clone(), - algorithms: jwks_conf - .algorithms - .as_ref() - .map(|algs| algs.iter().cloned().collect()), - }); - } + let subgraph = if let Some(config) = init.config.subgraph { + let all = if let Some(config) = &config.all { + Some(subgraph::make_signing_params(config, "all").await?) + } else { + None + }; - tracing::info!(jwks=?init.config.jwt.jwks, "JWT authentication using JWKSets from"); + let mut subgraphs: HashMap = Default::default(); + for (subgraph_name, config) in &config.subgraphs { + subgraphs.insert( + subgraph_name.clone(), + subgraph::make_signing_params(config, subgraph_name.as_str()).await?, + ); + } - let jwks_manager = JwksManager::new(list).await?; + Some(SubgraphAuth { + signing_params: { SigningParams { all, subgraphs } }, + }) + } else { + None + }; + + let router = if let Some(router_conf) = init.config.router { + if router_conf + .jwt + .header_value_prefix + .as_bytes() + .iter() + .any(u8::is_ascii_whitespace) + { + return Err(Error::BadHeaderValuePrefix.into()); + } + let mut list = vec![]; + for jwks_conf in &router_conf.jwt.jwks { + let url: Url = Url::from_str(jwks_conf.url.as_str())?; + list.push(JwksConfig { + url, + issuer: jwks_conf.issuer.clone(), + algorithms: jwks_conf + .algorithms + .as_ref() + .map(|algs| algs.iter().cloned().collect()), + }); + } + + tracing::info!(jwks=?router_conf.jwt.jwks, "JWT authentication using JWKSets from"); + + let jwks_manager = JwksManager::new(list).await?; - Ok(AuthenticationPlugin { - configuration: init.config.jwt, - jwks_manager, - }) + Some(Router { + configuration: router_conf.jwt, + jwks_manager, + }) + } else { + None + }; + + Ok(Self { router, subgraph }) } fn router_service(&self, service: router::BoxService) -> router::BoxService { - let request_full_config = self.configuration.clone(); - let jwks_manager = self.jwks_manager.clone(); - - fn authentication_service_span() -> impl Fn(&router::Request) -> tracing::Span + Clone { - move |_request: &router::Request| { - tracing::info_span!( - AUTHENTICATION_SPAN_NAME, - "authentication service" = stringify!(router::Request), - "otel.kind" = "INTERNAL" - ) + if let Some(config) = &self.router { + let jwks_manager = config.jwks_manager.clone(); + let configuration = config.configuration.clone(); + + fn authentication_service_span() -> impl Fn(&router::Request) -> tracing::Span + Clone { + move |_request: &router::Request| { + tracing::info_span!( + AUTHENTICATION_SPAN_NAME, + "authentication service" = stringify!(router::Request), + "otel.kind" = "INTERNAL" + ) + } } + + ServiceBuilder::new() + .instrument(authentication_service_span()) + .checkpoint(move |request: router::Request| { + authenticate(&configuration, &jwks_manager, request) + }) + .service(service) + .boxed() + } else { + service } + } - ServiceBuilder::new() - .instrument(authentication_service_span()) - .checkpoint(move |request: router::Request| { - authenticate(&request_full_config, &jwks_manager, request) - }) - .service(service) - .boxed() + fn subgraph_service( + &self, + name: &str, + service: crate::services::subgraph::BoxService, + ) -> crate::services::subgraph::BoxService { + if let Some(auth) = &self.subgraph { + auth.subgraph_service(name, service) + } else { + service + } } } diff --git a/apollo-router/src/plugins/authentication/subgraph.rs b/apollo-router/src/plugins/authentication/subgraph.rs new file mode 100644 index 0000000000..7839d1628d --- /dev/null +++ b/apollo-router/src/plugins/authentication/subgraph.rs @@ -0,0 +1,589 @@ +use core::ops::ControlFlow; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::SystemTime; + +use aws_credential_types::provider::ProvideCredentials; +use aws_credential_types::Credentials; +use aws_sigv4::http_request; +use aws_sigv4::http_request::sign; +use aws_sigv4::http_request::PayloadChecksumKind; +use aws_sigv4::http_request::SignableBody; +use aws_sigv4::http_request::SignableRequest; +use aws_sigv4::http_request::SigningSettings; +use aws_types::region::Region; +use schemars::JsonSchema; +use serde::Deserialize; +use tower::BoxError; +use tower::ServiceBuilder; +use tower::ServiceExt; + +use crate::layers::ServiceBuilderExt; +use crate::services::SubgraphRequest; + +/// Hardcoded Config using access_key and secret. +/// Prefer using DefaultChain instead. +#[derive(Clone, JsonSchema, Deserialize, Debug)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub(crate) struct AWSSigV4HardcodedConfig { + /// The ID for this access key. + access_key_id: String, + /// The secret key used to sign requests. + secret_access_key: String, + /// The AWS region this chain applies to. + region: String, + /// The service you're trying to access, eg: "s3", "vpc-lattice-svcs", etc. + service_name: String, + /// Specify assumed role configuration. + assume_role: Option, +} + +impl ProvideCredentials for AWSSigV4HardcodedConfig { + fn provide_credentials<'a>( + &'a self, + ) -> aws_credential_types::provider::future::ProvideCredentials<'a> + where + Self: 'a, + { + aws_credential_types::provider::future::ProvideCredentials::ready(Ok(Credentials::new( + self.access_key_id.clone(), + self.secret_access_key.clone(), + None, + None, + "apollo-router", + ))) + } +} + +/// Configuration of the DefaultChainProvider +#[derive(Clone, JsonSchema, Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub(crate) struct DefaultChainConfig { + /// The AWS region this chain applies to. + region: String, + /// The profile name used by this provider + profile_name: Option, + /// The service you're trying to access, eg: "s3", "vpc-lattice-svcs", etc. + service_name: String, + /// Specify assumed role configuration. + assume_role: Option, +} + +/// Specify assumed role configuration. +#[derive(Clone, JsonSchema, Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub(crate) struct AssumeRoleProvider { + /// Amazon Resource Name (ARN) + /// for the role assumed when making requests + role_arn: String, + /// Uniquely identify a session when the same role is assumed by different principals or for different reasons. + session_name: String, + /// Unique identifier that might be required when you assume a role in another account. + external_id: Option, +} + +/// Configure AWS sigv4 auth. +#[derive(Clone, JsonSchema, Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AWSSigV4Config { + Hardcoded(AWSSigV4HardcodedConfig), + DefaultChain(DefaultChainConfig), +} + +impl AWSSigV4Config { + async fn get_credentials_provider(&self) -> Arc { + let region = self.region(); + + let role_provider_builder = self.assume_role().map(|assume_role_provider| { + let rp = + aws_config::sts::AssumeRoleProvider::builder(assume_role_provider.role_arn.clone()) + .session_name(assume_role_provider.session_name.clone()) + .region(region.clone()); + if let Some(external_id) = &assume_role_provider.external_id { + rp.external_id(external_id.as_str()) + } else { + rp + } + }); + + match self { + Self::DefaultChain(config) => { + let aws_config = + aws_config::default_provider::credentials::DefaultCredentialsChain::builder() + .region(region.clone()); + + let aws_config = if let Some(profile_name) = &config.profile_name { + aws_config.profile_name(profile_name.as_str()) + } else { + aws_config + }; + + let chain = aws_config.build().await; + if let Some(assume_role_provider) = role_provider_builder { + Arc::new(assume_role_provider.build(chain)) + } else { + Arc::new(chain) + } + } + Self::Hardcoded(config) => { + let chain = + aws_config::default_provider::credentials::DefaultCredentialsChain::builder() + .build() + .await; + if let Some(assume_role_provider) = role_provider_builder { + Arc::new(assume_role_provider.build(chain)) + } else { + Arc::new(config.clone()) + } + } + } + } + + fn region(&self) -> Region { + let region = match self { + Self::DefaultChain(config) => config.region.clone(), + Self::Hardcoded(config) => config.region.clone(), + }; + aws_types::region::Region::new(region) + } + + fn service_name(&self) -> String { + match self { + Self::DefaultChain(config) => config.service_name.clone(), + Self::Hardcoded(config) => config.service_name.clone(), + } + } + + fn assume_role(&self) -> Option { + match self { + Self::DefaultChain(config) => config.assume_role.clone(), + Self::Hardcoded(config) => config.assume_role.clone(), + } + } +} + +#[derive(Clone, Debug, JsonSchema, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) enum AuthConfig { + #[serde(rename = "aws_sig_v4")] + AWSSigV4(AWSSigV4Config), +} + +/// Configure subgraph authentication +#[derive(Clone, Debug, Default, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub(crate) struct Config { + /// Configuration that will apply to all subgraphs. + #[serde(default)] + pub(crate) all: Option, + #[serde(default)] + /// Create a configuration that will apply only to a specific subgraph. + pub(crate) subgraphs: HashMap, +} + +#[allow(dead_code)] +#[derive(Clone, Default)] +pub(crate) struct SigningParams { + pub(crate) all: Option, + pub(crate) subgraphs: HashMap, +} + +#[derive(Clone)] +pub(crate) struct SigningParamsConfig { + credentials_provider: Arc, + region: Region, + service_name: String, +} + +fn increment_success_counter(subgraph_name: &str) { + tracing::info!( + monotonic_counter.apollo.router.operations.authentication.aws.sigv4 = 1u64, + authentication.aws.sigv4.failed = false, + subgraph.service.name = %subgraph_name, + ); +} +fn increment_failure_counter(subgraph_name: &str) { + tracing::info!( + monotonic_counter.apollo.router.operations.authentication.aws.sigv4 = 1u64, + authentication.aws.sigv4.failed = true, + subgraph.service.name = %subgraph_name, + ); +} + +pub(super) async fn make_signing_params( + config: &AuthConfig, + subgraph_name: &str, +) -> Result { + match config { + AuthConfig::AWSSigV4(config) => { + let credentials_provider = config.get_credentials_provider().await; + if let Err(e) = credentials_provider.provide_credentials().await { + let error_subgraph_name = if subgraph_name == "all" { + "all subgraphs".to_string() + } else { + format!("{} subgraph", subgraph_name) + }; + return Err(format!( + "auth: {}: couldn't get credentials from provider: {}", + error_subgraph_name, e, + ) + .into()); + } + + Ok(SigningParamsConfig { + region: config.region(), + service_name: config.service_name(), + credentials_provider, + }) + } + } +} + +/// There are three possible cases +/// https://github.com/awslabs/aws-sdk-rust/blob/9c3168dafa4fd8885ce4e1fd41cec55ce982a33c/sdk/aws-sigv4/src/http_request/sign.rs#L264C1-L271C6 +fn get_signing_settings(signing_params: &SigningParamsConfig) -> SigningSettings { + let mut settings = SigningSettings::default(); + settings.payload_checksum_kind = match signing_params.service_name.as_str() { + "s3" | "vpc-lattice-svcs" => PayloadChecksumKind::XAmzSha256, + _ => PayloadChecksumKind::NoHeader, + }; + settings +} + +pub(super) struct SubgraphAuth { + pub(super) signing_params: SigningParams, +} + +impl SubgraphAuth { + pub(super) fn subgraph_service( + &self, + name: &str, + service: crate::services::subgraph::BoxService, + ) -> crate::services::subgraph::BoxService { + if let Some(signing_params) = self.params_for_service(name) { + let name = name.to_string(); + ServiceBuilder::new() + .checkpoint_async(move |mut req: SubgraphRequest| { + let signing_params = signing_params.clone(); + let name = name.clone(); + async move { + let credentials = signing_params + .credentials_provider + .provide_credentials() + .await + .map_err(|err| { + increment_failure_counter(name.as_str()); + let error = format!( + "failed to get credentials for AWS SigV4 signing: {}", + err + ); + tracing::error!("{}", error); + error + })?; + + let settings = get_signing_settings(&signing_params); + let mut builder = http_request::SigningParams::builder() + .access_key(credentials.access_key_id()) + .secret_key(credentials.secret_access_key()) + .region(signing_params.region.as_ref()) + .service_name(&signing_params.service_name) + .time(SystemTime::now()) + .settings(settings); + builder.set_security_token(credentials.session_token()); + let body_bytes = + serde_json::to_vec(&req.subgraph_request.body()).map_err(|err| { + increment_failure_counter(name.as_str()); + let error = format!( + "failed to serialize GraphQL body for AWS SigV4 signing: {}", + err + ); + tracing::error!("{}", error); + error + })?; + + // UnsignedPayload only applies to lattice + let signable_request = SignableRequest::new( + req.subgraph_request.method(), + req.subgraph_request.uri(), + req.subgraph_request.headers(), + match signing_params.service_name.as_str() { + "vpc-lattice-svcs" => SignableBody::UnsignedPayload, + _ => SignableBody::Bytes(&body_bytes), + }, + ); + + let signing_params = builder.build().expect("all required fields set"); + + let (signing_instructions, _signature) = + sign(signable_request, &signing_params) + .map_err(|err| { + increment_failure_counter(name.as_str()); + let error = format!( + "failed to sign GraphQL body for AWS SigV4: {}", + err + ); + tracing::error!("{}", error); + error + })? + .into_parts(); + signing_instructions.apply_to_request(&mut req.subgraph_request); + increment_success_counter(name.as_str()); + Ok(ControlFlow::Continue(req)) + } + }) + .buffered() + .service(service) + .boxed() + } else { + service + } + } +} + +impl SubgraphAuth { + fn params_for_service(&self, service_name: &str) -> Option { + self.signing_params + .subgraphs + .get(service_name) + .cloned() + .or_else(|| self.signing_params.all.clone()) + } +} + +#[cfg(test)] +mod test { + use std::sync::Arc; + + use http::header::CONTENT_LENGTH; + use http::header::CONTENT_TYPE; + use http::header::HOST; + use regex::Regex; + use tower::Service; + + use super::*; + use crate::graphql::Request; + use crate::plugin::test::MockSubgraphService; + use crate::query_planner::fetch::OperationKind; + use crate::services::SubgraphRequest; + use crate::services::SubgraphResponse; + use crate::Context; + + async fn test_signing_settings(service_name: &str) -> SigningSettings { + let params: SigningParamsConfig = make_signing_params( + &AuthConfig::AWSSigV4(AWSSigV4Config::Hardcoded(AWSSigV4HardcodedConfig { + access_key_id: "id".to_string(), + secret_access_key: "secret".to_string(), + region: "us-east-1".to_string(), + service_name: service_name.to_string(), + assume_role: None, + })), + "all", + ) + .await + .unwrap(); + get_signing_settings(¶ms) + } + + #[tokio::test] + async fn test_get_signing_settings() { + assert_eq!( + PayloadChecksumKind::XAmzSha256, + test_signing_settings("s3").await.payload_checksum_kind + ); + assert_eq!( + PayloadChecksumKind::XAmzSha256, + test_signing_settings("vpc-lattice-svcs") + .await + .payload_checksum_kind + ); + assert_eq!( + PayloadChecksumKind::NoHeader, + test_signing_settings("something-else") + .await + .payload_checksum_kind + ); + } + + #[test] + fn test_all_aws_sig_v4_hardcoded_config() { + serde_yaml::from_str::( + r#" + all: + aws_sig_v4: + hardcoded: + access_key_id: "test" + secret_access_key: "test" + region: "us-east-1" + service_name: "lambda" + "#, + ) + .unwrap(); + } + + #[test] + fn test_subgraph_aws_sig_v4_hardcoded_config() { + serde_yaml::from_str::( + r#" + subgraphs: + products: + aws_sig_v4: + hardcoded: + access_key_id: "test" + secret_access_key: "test" + region: "us-east-1" + service_name: "test_service" + "#, + ) + .unwrap(); + } + + #[test] + fn test_aws_sig_v4_default_chain_assume_role_config() { + serde_yaml::from_str::( + r#" + all: + aws_sig_v4: + default_chain: + profile_name: "my-test-profile" + region: "us-east-1" + service_name: "lambda" + assume_role: + role_arn: "test-arn" + session_name: "test-session" + external_id: "test-id" + "#, + ) + .unwrap(); + } + + #[tokio::test] + async fn test_lattice_body_payload_should_be_unsigned() -> Result<(), BoxError> { + let subgraph_request = example_request(); + + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + assert_eq!( + "UNSIGNED-PAYLOAD", + request + .subgraph_request + .headers() + .get("x-amz-content-sha256") + .unwrap() + .to_str() + .unwrap() + ); + true + }) + .returning(example_response); + + let mut service = SubgraphAuth { + signing_params: SigningParams { + all: make_signing_params( + &AuthConfig::AWSSigV4(AWSSigV4Config::Hardcoded(AWSSigV4HardcodedConfig { + access_key_id: "id".to_string(), + secret_access_key: "secret".to_string(), + region: "us-east-1".to_string(), + service_name: "vpc-lattice-svcs".to_string(), + assume_role: None, + })), + "all", + ) + .await + .ok(), + subgraphs: Default::default(), + }, + } + .subgraph_service("test_subgraph", mock.boxed()); + + service.ready().await?.call(subgraph_request).await?; + Ok(()) + } + + #[tokio::test] + async fn test_aws_sig_v4_headers() -> Result<(), BoxError> { + let subgraph_request = example_request(); + + let mut mock = MockSubgraphService::new(); + mock.expect_call() + .times(1) + .withf(|request| { + let authorization_regex = Regex::new(r"AWS4-HMAC-SHA256 Credential=id/\d{8}/us-east-1/s3/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date, Signature=[a-f0-9]{64}").unwrap(); + let authorization_header_str = request.subgraph_request.headers().get("authorization").unwrap().to_str().unwrap(); + assert_eq!(match authorization_regex.find(authorization_header_str) { + Some(m) => m.as_str(), + None => "no match" + }, authorization_header_str); + + let x_amz_date_regex = Regex::new(r"\d{8}T\d{6}Z").unwrap(); + let x_amz_date_header_str = request.subgraph_request.headers().get("x-amz-date").unwrap().to_str().unwrap(); + assert_eq!(match x_amz_date_regex.find(x_amz_date_header_str) { + Some(m) => m.as_str(), + None => "no match" + }, x_amz_date_header_str); + + assert_eq!(request.subgraph_request.headers().get("x-amz-content-sha256").unwrap(), "255959b4c6e11c1080f61ce0d75eb1b565c1772173335a7828ba9c13c25c0d8c"); + + true + }) + .returning(example_response); + + let mut service = SubgraphAuth { + signing_params: SigningParams { + all: make_signing_params( + &AuthConfig::AWSSigV4(AWSSigV4Config::Hardcoded(AWSSigV4HardcodedConfig { + access_key_id: "id".to_string(), + secret_access_key: "secret".to_string(), + region: "us-east-1".to_string(), + service_name: "s3".to_string(), + assume_role: None, + })), + "all", + ) + .await + .ok(), + subgraphs: Default::default(), + }, + } + .subgraph_service("test_subgraph", mock.boxed()); + + service.ready().await?.call(subgraph_request).await?; + Ok(()) + } + + fn example_response(_: SubgraphRequest) -> Result { + Ok(SubgraphResponse::new_from_response( + http::Response::default(), + Context::new(), + )) + } + + fn example_request() -> SubgraphRequest { + SubgraphRequest::builder() + .supergraph_request(Arc::new( + http::Request::builder() + .header(HOST, "host") + .header(CONTENT_LENGTH, "2") + .header(CONTENT_TYPE, "graphql") + .body( + Request::builder() + .query("query") + .operation_name("my_operation_name") + .build(), + ) + .expect("expecting valid request"), + )) + .subgraph_request( + http::Request::builder() + .header(HOST, "rhost") + .header(CONTENT_LENGTH, "22") + .header(CONTENT_TYPE, "graphql") + .body(Request::builder().query("query").build()) + .expect("expecting valid request"), + ) + .operation_kind(OperationKind::Query) + .context(Context::new()) + .build() + } +} diff --git a/apollo-router/src/plugins/authentication/tests.rs b/apollo-router/src/plugins/authentication/tests.rs index 4cd63d06ce..bc91aec480 100644 --- a/apollo-router/src/plugins/authentication/tests.rs +++ b/apollo-router/src/plugins/authentication/tests.rs @@ -65,38 +65,43 @@ async fn build_a_test_harness( let mut config = if multiple_jwks { serde_json::json!({ "authentication": { - "jwt" : { - "jwks": [ - { - "url": &jwks_url - }, - { - "url": &jwks_url - } - ] + "router": { + "jwt": { + "jwks": [ + { + "url": &jwks_url + }, + { + "url": &jwks_url + } + ] + } } } }) } else { serde_json::json!({ "authentication": { - "jwt" : { - "jwks": [ - { - "url": &jwks_url - } - ] + "router": { + "jwt" : { + "jwks": [ + { + "url": &jwks_url + } + ] + } } } }) }; if let Some(hn) = header_name { - config["authentication"]["jwt"]["header_name"] = serde_json::Value::String(hn); + config["authentication"]["router"]["jwt"]["header_name"] = serde_json::Value::String(hn); } if let Some(hp) = header_value_prefix { - config["authentication"]["jwt"]["header_value_prefix"] = serde_json::Value::String(hp); + config["authentication"]["router"]["jwt"]["header_value_prefix"] = + serde_json::Value::String(hp); } crate::TestHarness::builder() @@ -126,12 +131,14 @@ async fn it_rejects_when_there_is_no_auth_header() { let config = serde_json::json!({ "authentication": { - "jwt" : { - "jwks": [ - { - "url": &jwks_url - } - ] + "router": { + "jwt" : { + "jwks": [ + { + "url": &jwks_url + } + ] + } } }, "rhai": { diff --git a/apollo-router/src/uplink/license_enforcement.rs b/apollo-router/src/uplink/license_enforcement.rs index 202543f329..e8d12a9efd 100644 --- a/apollo-router/src/uplink/license_enforcement.rs +++ b/apollo-router/src/uplink/license_enforcement.rs @@ -131,7 +131,7 @@ impl LicenseEnforcementReport { .name("Restricted") .build(), ConfigurationRestriction::builder() - .path("$.authentication") + .path("$.authentication.router") .name("Authentication plugin") .build(), ConfigurationRestriction::builder() diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config.snap index 34dde1c3ed..d2049fcc0a 100644 --- a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config.snap +++ b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_features_via_config.snap @@ -7,7 +7,7 @@ Configuration yaml: .plugins.['experimental.restricted'].enabled * Authentication plugin - .authentication + .authentication.router * Coprocessor plugin .coprocessor diff --git a/apollo-router/src/uplink/testdata/restricted.router.yaml b/apollo-router/src/uplink/testdata/restricted.router.yaml index 106e6bcca8..95fe533670 100644 --- a/apollo-router/src/uplink/testdata/restricted.router.yaml +++ b/apollo-router/src/uplink/testdata/restricted.router.yaml @@ -4,9 +4,10 @@ homepage: enabled: true authentication: - jwt: - jwks: - - url: https://example.com + router: + jwt: + jwks: + - url: https://example.com coprocessor: url: https://example.com diff --git a/apollo-router/tests/fixtures/subgraph_auth.router.yaml b/apollo-router/tests/fixtures/subgraph_auth.router.yaml new file mode 100644 index 0000000000..6abe171ad7 --- /dev/null +++ b/apollo-router/tests/fixtures/subgraph_auth.router.yaml @@ -0,0 +1,34 @@ +telemetry: + metrics: + prometheus: + listen: 127.0.0.1:4000 + enabled: true + path: /metrics + common: + attributes: + subgraph: + all: + request: + header: + - named: "x-custom-header" + rename: "custom_header" + default: "unknown" +headers: + all: + request: + - insert: + name: "x-custom-header" + value: "test_custom" +override_subgraph_url: + products: http://localhost:4005 +include_subgraph_errors: + all: true +authentication: + subgraph: + all: + aws_sig_v4: + hardcoded: + access_key_id: "test" + secret_access_key: "test" + region: "us-east-1" + service_name: "test_service" diff --git a/apollo-router/tests/metrics_tests.rs b/apollo-router/tests/metrics_tests.rs index be58864d1e..ff089008fd 100644 --- a/apollo-router/tests/metrics_tests.rs +++ b/apollo-router/tests/metrics_tests.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use serde_json::json; use tower::BoxError; use crate::common::IntegrationTest; @@ -7,6 +8,7 @@ use crate::common::IntegrationTest; mod common; const PROMETHEUS_CONFIG: &str = include_str!("fixtures/prometheus.router.yaml"); +const SUBGRAPH_AUTH_CONFIG: &str = include_str!("fixtures/subgraph_auth.router.yaml"); #[tokio::test(flavor = "multi_thread")] async fn test_metrics_reloading() -> Result<(), BoxError> { @@ -69,3 +71,43 @@ async fn test_metrics_reloading() -> Result<(), BoxError> { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn test_subgraph_auth_metrics() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config(SUBGRAPH_AUTH_CONFIG) + .build() + .await; + + router.start().await; + router.assert_started().await; + + router.execute_default_query().await; + router.execute_default_query().await; + + // Remove auth + router.update_config(PROMETHEUS_CONFIG).await; + router.assert_reloaded().await; + // This one will not be signed, counters shouldn't increment. + router + .execute_query(&json! {{ "query": "query { me { name } }"}}) + .await; + + // Get Prometheus metrics. + let metrics_response = router.get_metrics_response().await.unwrap(); + + // Validate metric headers. + let metrics_headers = metrics_response.headers(); + assert!( + "text/plain; version=0.0.4" + == metrics_headers + .get(http::header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap() + ); + + router.assert_metrics_contains(r#"apollo_router_operations_authentication_aws_sigv4_total{authentication_aws_sigv4_failed="false",service_name="apollo-router",subgraph_service_name="products",otel_scope_name="apollo/router",otel_scope_version=""} 2"#, None).await; + + Ok(()) +} diff --git a/docs/source/config.json b/docs/source/config.json index ae2e27627c..43621dbc2a 100644 --- a/docs/source/config.json +++ b/docs/source/config.json @@ -41,7 +41,8 @@ [ "enterprise" ] - ], + ], + "Subgraph Authentication": "/configuration/authn-subgraph", "Operation limits": [ "/configuration/operation-limits", [ diff --git a/docs/source/configuration/authn-jwt.mdx b/docs/source/configuration/authn-jwt.mdx index a01c5c40dc..4ba9918094 100644 --- a/docs/source/configuration/authn-jwt.mdx +++ b/docs/source/configuration/authn-jwt.mdx @@ -43,14 +43,15 @@ You enable JWT authentication for your router with the following steps: ```yaml title="router.yaml" authentication: - jwt: - jwks: # This key is required. - - url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json - issuer: - - # These keys are optional. Default values are shown. - header_name: Authorization - header_value_prefix: Bearer + router: + jwt: + jwks: # This key is required. + - url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json + issuer: + + # These keys are optional. Default values are shown. + header_name: Authorization + header_value_prefix: Bearer ``` These options are documented [below](#configuration-options). @@ -275,9 +276,10 @@ If the router is configured with: ```yaml title="router.yaml" authentication: - jwt: - jwks: - - url: "file:///etc/router/jwks.json" + router: + jwt: + jwks: + - url: "file:///etc/router/jwks.json" coprocessor: url: http://127.0.0.1:8081 diff --git a/docs/source/configuration/authn-subgraph.mdx b/docs/source/configuration/authn-subgraph.mdx new file mode 100644 index 0000000000..91a3935e77 --- /dev/null +++ b/docs/source/configuration/authn-subgraph.mdx @@ -0,0 +1,61 @@ +--- +title: Subgraph Authentication in the Apollo Router +minVersion: 1.27.0 +--- + +The Apollo Router supports subgraph request authentication and key rotation via [AWS Signature Version 4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) (SigV4). + +This allows you to secure communication to AWS subgraphs by making sure a subgraph request was made by the Apollo Router, and the payload hasn't been tampered with. + +**To use this feature:** + +To use this feature, your AWS hosted subgraphs must be configured with IAM to accept [signed requests](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-signing.html). + +## How it works + +Subgraph requests are signed using [HTTP Authorization headers](https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-auth-using-authorization-header.html), refer to the upstream documentation for more details. + +### Configuration example + + + +The example below shows how to use a default credentials chain for all subgraphs, except for the `products` subgraph, which uses hardcoded credentials: + + ```yaml title="router.yaml" + authentication: + subgraph: + all: # configuration that will apply to all subgraphs + aws_sig_v4: + default_chain: + profile_name: "my-test-profile" # https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile + region: "us-east-1" # https://docs.aws.amazon.com/general/latest/gr/rande.html + service_name: "lambda" # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-services-that-work-with-iam.html + assume_role: # https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html + role_arn: "test-arn" + session_name: "test-session" + external_id: "test-id" + subgraphs: + products: + aws_sig_v4: + hardcoded: # Not recommended, prefer using default_chain as shown above + access_key_id: "my-access-key" + secret_access_key: "my-secret-access-key" + region: "us-east-1" + service_name: "vpc-lattice-svcs" # "s3", "lambda" etc. + ``` + + +### Default chain authentication + +The default chain authentication method tries to resolve credentials in the following order, starting with environment variables: + +| Credential Type | Examples | +|----------------------------------|------------------------------------------------------------------------------------------------------| +| Environment variables | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` or `SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_ROLE_ARN`, `AWS_IAM_ROLE_SESSION_NAME`| +| Shared configurations | `~/.aws/config`, `~/.aws/credentials`, configured with `AWS_CONFIG_FILE` and `AWS_SHARED_CREDENTIALS_FILE` environment variables | +| Web identity tokens | Possibly configured with the `AWS_WEB_IDENTITY_TOKEN_FILE` environment variable | `AWS_WEB_IDENTITY_TOKEN_FILE` +| Elastic Container Service (ECS) | Configured with the `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` or `AWS_CONTAINER_CREDENTIALS_FULL_URI`, and `AWS_CONTAINER_AUTHORIZATION_TOKEN` environment variables | + +#### Assume Role: + +Both authentication methods allow you to use the `assume_role` key to use [IAM Roles](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html) for given credentials (recommended). diff --git a/examples/jwt-claims/rhai/router.yaml b/examples/jwt-claims/rhai/router.yaml index 6c2b07b6fb..1948cb50f2 100644 --- a/examples/jwt-claims/rhai/router.yaml +++ b/examples/jwt-claims/rhai/router.yaml @@ -2,9 +2,10 @@ # NOTE: a file URL cannot contain a relative path, so you must # specify an absolute path to make this example work. authentication: - jwt: - jwks: - - url: file:///Users/garypen/dev/router/examples/jwt-claims/rhai/jwks.json + router: + jwt: + jwks: + - url: file:///Users/garypen/dev/router/examples/jwt-claims/rhai/jwks.json rhai: scripts: src diff --git a/examples/jwt-claims/rhai/src/main.rs b/examples/jwt-claims/rhai/src/main.rs index 1afdc1fc31..0c0f328be6 100644 --- a/examples/jwt-claims/rhai/src/main.rs +++ b/examples/jwt-claims/rhai/src/main.rs @@ -87,12 +87,14 @@ mod tests { let jwks_url = format!("file://{}", jwks_file.display()); let config = serde_json::json!({ "authentication": { - "jwt": { - "jwks": [ - { - "url": &jwks_url - } - ] + "router": { + "jwt": { + "jwks": [ + { + "url": &jwks_url + } + ] + } } } });