From d87e1b5b9ed3dc4137e745ec60a6b36e7c6f867c Mon Sep 17 00:00:00 2001 From: Zelda Hessler Date: Tue, 27 May 2025 13:52:20 -0500 Subject: [PATCH 1/7] add support for JWT audience validation --- .../src/plugins/authentication/error.rs | 4 + .../src/plugins/authentication/jwks.rs | 19 +- .../src/plugins/authentication/mod.rs | 40 +++- .../src/plugins/authentication/tests.rs | 212 +++++++++++++++++- .../plugins/include_subgraph_errors/mod.rs | 3 +- docs/source/routing/security/jwt.mdx | 4 + 6 files changed, 264 insertions(+), 18 deletions(-) diff --git a/apollo-router/src/plugins/authentication/error.rs b/apollo-router/src/plugins/authentication/error.rs index 08d48ddb07..b78c7427b2 100644 --- a/apollo-router/src/plugins/authentication/error.rs +++ b/apollo-router/src/plugins/authentication/error.rs @@ -43,6 +43,9 @@ pub(crate) enum AuthenticationError { /// Invalid issuer: the token's `iss` was '{token}', but signed with a key from JWKS configured to only accept from '{expected}' InvalidIssuer { expected: String, token: String }, + /// Invalid audience: the token's `aud` was '{actual}', but '{expected}' was expected + InvalidAudience { actual: String, expected: String }, + /// Unsupported key algorithm: {0} UnsupportedKeyAlgorithm(KeyAlgorithm), } @@ -98,6 +101,7 @@ impl AuthenticationError { AuthenticationError::CannotFindKID(_) => ("CANNOT_FIND_KID", None), AuthenticationError::CannotFindSuitableKey(_, _) => ("CANNOT_FIND_SUITABLE_KEY", None), AuthenticationError::InvalidIssuer { .. } => ("INVALID_ISSUER", None), + AuthenticationError::InvalidAudience { .. } => ("INVALID_AUDIENCE", None), AuthenticationError::UnsupportedKeyAlgorithm(_) => ("UNSUPPORTED_KEY_ALGORITHM", None), }; diff --git a/apollo-router/src/plugins/authentication/jwks.rs b/apollo-router/src/plugins/authentication/jwks.rs index 1839ac5e3d..4331dce1eb 100644 --- a/apollo-router/src/plugins/authentication/jwks.rs +++ b/apollo-router/src/plugins/authentication/jwks.rs @@ -51,11 +51,13 @@ pub(super) struct JwksManager { } pub(super) type Issuers = HashSet; +pub(super) type Audiences = HashSet; #[derive(Clone)] pub(super) struct JwksConfig { pub(super) url: Url, pub(super) issuers: Option, + pub(super) audiences: Option, pub(super) algorithms: Option>, pub(super) poll_interval: Duration, pub(super) headers: Vec
, @@ -65,6 +67,7 @@ pub(super) struct JwksConfig { pub(super) struct JwkSetInfo { pub(super) jwks: JwkSet, pub(super) issuers: Option, + pub(super) audiences: Option, pub(super) algorithms: Option>, } @@ -266,6 +269,7 @@ impl Iterator for Iter<'_> { return Some(JwkSetInfo { jwks: jwks.clone(), issuers: config.issuers.clone(), + audiences: config.audiences.clone(), algorithms: config.algorithms.clone(), }); } @@ -289,13 +293,14 @@ pub(super) struct JWTCriteria { pub(super) fn search_jwks( jwks_manager: &JwksManager, criteria: &JWTCriteria, -) -> Option, Jwk)>> { +) -> Option, Option, Jwk)>> { const HIGHEST_SCORE: usize = 2; let mut candidates = vec![]; let mut found_highest_score = false; for JwkSetInfo { jwks, issuers, + audiences, algorithms, } in jwks_manager.iter_jwks() { @@ -404,7 +409,7 @@ pub(super) fn search_jwks( found_highest_score = true; } - candidates.push((key_score, (issuers.clone(), key))); + candidates.push((key_score, (issuers.clone(), audiences.clone(), key))); } } @@ -412,7 +417,7 @@ pub(super) fn search_jwks( "jwk candidates: {:?}", candidates .iter() - .map(|(score, (_, candidate))| ( + .map(|(score, (_, _, candidate))| ( score, &candidate.common.key_id, candidate.common.key_algorithm @@ -550,11 +555,11 @@ pub(super) fn extract_jwt<'a, 'b: 'a>( pub(super) fn decode_jwt( jwt: &str, - keys: Vec<(Option, Jwk)>, + keys: Vec<(Option, Option, Jwk)>, criteria: JWTCriteria, -) -> Result<(Option, TokenData), (AuthenticationError, StatusCode)> { +) -> Result<(Option, Option, TokenData), (AuthenticationError, StatusCode)> { let mut error = None; - for (issuers, jwk) in keys.into_iter() { + for (issuers, audiences, jwk) in keys.into_iter() { let decoding_key = match DecodingKey::from_jwk(&jwk) { Ok(k) => k, Err(e) => { @@ -595,7 +600,7 @@ pub(super) fn decode_jwt( validation.validate_aud = false; match decode::(jwt, &decoding_key, &validation) { - Ok(v) => return Ok((issuers, v)), + Ok(v) => return Ok((issuers, audiences, v)), Err(e) => { tracing::trace!("JWT decoding failed with error `{e}`"); error = Some(( diff --git a/apollo-router/src/plugins/authentication/mod.rs b/apollo-router/src/plugins/authentication/mod.rs index 9cdefe87b3..242c72af6d 100644 --- a/apollo-router/src/plugins/authentication/mod.rs +++ b/apollo-router/src/plugins/authentication/mod.rs @@ -36,7 +36,7 @@ use crate::plugin::serde::deserialize_header_name; use crate::plugin::serde::deserialize_header_value; use crate::plugins::authentication::connector::ConnectorAuth; use crate::plugins::authentication::error::ErrorContext; -use crate::plugins::authentication::jwks::Issuers; +use crate::plugins::authentication::jwks::{Audiences, Issuers}; use crate::plugins::authentication::jwks::JwksConfig; use crate::plugins::authentication::subgraph::make_signing_params; use crate::services::APPLICATION_JSON_HEADER_VALUE; @@ -126,7 +126,13 @@ struct JwksConf { #[schemars(with = "String", default = "default_poll_interval")] poll_interval: Duration, /// Expected issuers for tokens verified by that JWKS + /// + /// If not specified, the issuer will not be checked. issuers: Option, + /// Expected audiences for tokens verified by that JWKS + /// + /// If not specified, the audience will not be checked. + audiences: Option, /// List of accepted algorithms. Possible values are `HS256`, `HS384`, `HS512`, `ES256`, `ES384`, `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `EdDSA` #[schemars(with = "Option>", default)] #[serde(default)] @@ -337,6 +343,7 @@ impl AuthenticationPlugin { list.push(JwksConfig { url, issuers: jwks_conf.issuers.clone(), + audiences: jwks_conf.audiences.clone(), algorithms: jwks_conf .algorithms .as_ref() @@ -548,7 +555,7 @@ fn authenticate( // Note: This will search through JWKS in the order in which they are defined // in configuration. if let Some(keys) = jwks::search_jwks(jwks_manager, &criteria) { - let (issuers, token_data) = match jwks::decode_jwt(jwt, keys, criteria) { + let (issuers, audiences, token_data) = match jwks::decode_jwt(jwt, keys, criteria) { Ok(data) => data, Err((auth_error, status_code)) => { return failure_message( @@ -589,6 +596,35 @@ fn authenticate( } } } + + if let Some(configured_audiences) = audiences { + if let Some(token_audience) = token_data + .claims + .as_object() + .and_then(|o| o.get("aud")) + .and_then(|value| value.as_str()) + { + if !configured_audiences.contains(token_audience) { + let mut audiences_for_error: Vec = + configured_audiences.into_iter().collect(); + audiences_for_error.sort(); // done to maintain consistent ordering in error message + return failure_message( + request, + config, + AuthenticationError::InvalidAudience { + expected: audiences_for_error + .iter() + .map(|audience| audience.to_string()) + .collect::>() + .join(", "), + actual: token_audience.to_string(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + source_of_extracted_jwt, + ); + } + } + } if let Err(e) = request .context diff --git a/apollo-router/src/plugins/authentication/tests.rs b/apollo-router/src/plugins/authentication/tests.rs index f35c087b90..fb257657b6 100644 --- a/apollo-router/src/plugins/authentication/tests.rs +++ b/apollo-router/src/plugins/authentication/tests.rs @@ -52,7 +52,7 @@ use crate::assert_snapshot_subscriber; use crate::graphql; use crate::plugin::test; use crate::plugins::authentication::Issuers; -use crate::plugins::authentication::jwks::JWTCriteria; +use crate::plugins::authentication::jwks::{Audiences, JWTCriteria}; use crate::plugins::authentication::jwks::JwksConfig; use crate::plugins::authentication::jwks::JwksManager; use crate::plugins::authentication::jwks::parse_jwks; @@ -1058,6 +1058,7 @@ async fn build_jwks_search_components() -> JwksManager { urls.push(JwksConfig { url, issuers: None, + audiences: None, algorithms: None, poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1076,7 +1077,7 @@ async fn it_finds_key_with_criteria_kid_and_algorithm() { alg: Algorithm::HS256, }; - let (_issuer, key) = search_jwks(&jwks_manager, &criteria) + let (_issuer, _audience, key) = search_jwks(&jwks_manager, &criteria) .expect("found a key") .pop() .expect("list isn't empty"); @@ -1093,7 +1094,7 @@ async fn it_finds_best_matching_key_with_criteria_algorithm() { alg: Algorithm::HS256, }; - let (_issuer, key) = search_jwks(&jwks_manager, &criteria) + let (_issuer, _audience, key) = search_jwks(&jwks_manager, &criteria) .expect("found a key") .pop() .expect("list isn't empty"); @@ -1122,7 +1123,7 @@ async fn it_finds_key_with_criteria_algorithm_ec() { alg: Algorithm::ES256, }; - let (_issuer, key) = search_jwks(&jwks_manager, &criteria) + let (_issuer, _audience, key) = search_jwks(&jwks_manager, &criteria) .expect("found a key") .pop() .expect("list isn't empty"); @@ -1142,7 +1143,7 @@ async fn it_finds_key_with_criteria_algorithm_rsa() { alg: Algorithm::RS256, }; - let (_issuer, key) = search_jwks(&jwks_manager, &criteria) + let (_issuer, _audience, key) = search_jwks(&jwks_manager, &criteria) .expect("found a key") .pop() .expect("list isn't empty"); @@ -1158,9 +1159,10 @@ struct Claims { sub: String, exp: u64, iss: Option, + aud: Option, } -fn make_manager(jwk: &Jwk, issuers: Option) -> JwksManager { +fn make_manager(jwk: &Jwk, issuers: Option, audiences: Option) -> JwksManager { let jwks = JwkSet { keys: vec![jwk.clone()], }; @@ -1169,6 +1171,7 @@ fn make_manager(jwk: &Jwk, issuers: Option) -> JwksManager { let list = vec![JwksConfig { url: url.clone(), issuers, + audiences, algorithms: None, poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1205,6 +1208,7 @@ async fn issuer_check() { let manager = make_manager( &jwk, Some(HashSet::from(["hello".to_string(), "goodbye".to_string()])), + None, ); // No issuer @@ -1214,6 +1218,7 @@ async fn issuer_check() { sub: "test".to_string(), exp: get_current_timestamp(), iss: None, + aud: None, }, &encoding_key, ) @@ -1251,6 +1256,7 @@ async fn issuer_check() { sub: "test".to_string(), exp: get_current_timestamp(), iss: Some("hello".to_string()), + aud: None, }, &encoding_key, ) @@ -1290,6 +1296,7 @@ async fn issuer_check() { sub: "test".to_string(), exp: get_current_timestamp(), iss: Some("AAAA".to_string()), + aud: None, }, &encoding_key, ) @@ -1317,13 +1324,14 @@ async fn issuer_check() { } // no issuer check - let manager = make_manager(&jwk, None); + let manager = make_manager(&jwk, None, None); let token = encode( &jsonwebtoken::Header::new(Algorithm::ES256), &Claims { sub: "test".to_string(), exp: get_current_timestamp(), iss: Some("hello".to_string()), + aud: None, }, &encoding_key, ) @@ -1357,6 +1365,190 @@ async fn issuer_check() { } } +#[tokio::test] +async fn audience_check() { + let signing_key = SigningKey::random(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + + let encoding_key = EncodingKey::from_ec_der(&signing_key.to_pkcs8_der().unwrap().to_bytes()); + + let jwk = Jwk { + common: CommonParameters { + public_key_use: Some(PublicKeyUse::Signature), + key_operations: Some(vec![KeyOperations::Verify]), + key_algorithm: Some(KeyAlgorithm::ES256), + key_id: Some("hello".to_string()), + ..Default::default() + }, + algorithm: AlgorithmParameters::EllipticCurve(EllipticCurveKeyParameters { + key_type: EllipticCurveKeyType::EC, + curve: EllipticCurve::P256, + x: BASE64_URL_SAFE_NO_PAD.encode(point.x().unwrap()), + y: BASE64_URL_SAFE_NO_PAD.encode(point.y().unwrap()), + }), + }; + + let manager = make_manager( + &jwk, + None, + Some(HashSet::from(["hello".to_string(), "goodbye".to_string()])), + ); + + // No audience + let token = encode( + &jsonwebtoken::Header::new(Algorithm::ES256), + &Claims { + sub: "test".to_string(), + exp: get_current_timestamp(), + aud: None, + iss: None, + }, + &encoding_key, + ) + .unwrap(); + + let request = supergraph::Request::canned_builder() + .header(http::header::AUTHORIZATION, format!("Bearer {token}")) + .build() + .unwrap(); + + let mut config = JWTConf::default(); + config.sources.push(Source::Header { + name: super::default_header_name(), + value_prefix: super::default_header_value_prefix(), + }); + match authenticate(&config, &manager, request.try_into().unwrap()) { + ControlFlow::Break(res) => { + panic!("unexpected response: {res:?}"); + } + ControlFlow::Continue(req) => { + println!("got req with audience check"); + let claims: serde_json::Value = req + .context + .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .unwrap() + .unwrap(); + println!("claims: {claims:?}"); + } + } + + // Valid audience + let token = encode( + &jsonwebtoken::Header::new(Algorithm::ES256), + &Claims { + sub: "test".to_string(), + exp: get_current_timestamp(), + aud: Some("hello".to_string()), + iss: None, + }, + &encoding_key, + ) + .unwrap(); + + let request = supergraph::Request::canned_builder() + .header(http::header::AUTHORIZATION, format!("Bearer {token}")) + .build() + .unwrap(); + + match authenticate(&config, &manager, request.try_into().unwrap()) { + ControlFlow::Break(res) => { + let response: graphql::Response = serde_json::from_slice( + &router::body::into_bytes(res.response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(response, graphql::Response::builder() + .errors(vec![graphql::Error::builder().extension_code("AUTH_ERROR").message("Invalid audience: the token's `aud` was 'hallo', but signed with a key from JWKS configured to only accept from 'hello'").build()]).build()); + } + ControlFlow::Continue(req) => { + println!("got req with audience check"); + let claims: serde_json::Value = req + .context + .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .unwrap() + .unwrap(); + println!("claims: {claims:?}"); + } + } + + // Invalid audience + let token = encode( + &jsonwebtoken::Header::new(Algorithm::ES256), + &Claims { + sub: "test".to_string(), + exp: get_current_timestamp(), + aud: Some("AAAA".to_string()), + iss: None, + }, + &encoding_key, + ) + .unwrap(); + + let request = supergraph::Request::canned_builder() + .header(http::header::AUTHORIZATION, format!("Bearer {token}")) + .build() + .unwrap(); + + match authenticate(&config, &manager, request.try_into().unwrap()) { + ControlFlow::Break(res) => { + let response: graphql::Response = serde_json::from_slice( + &router::body::into_bytes(res.response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(response, graphql::Response::builder() + .errors(vec![graphql::Error::builder().extension_code("AUTH_ERROR").message("Invalid audience: the token's `aud` was 'AAAA', but 'goodbye, hello' was expected").build()]).build()); + } + ControlFlow::Continue(_) => { + panic!("audience check should have failed") + } + } + + // no audience check + let manager = make_manager(&jwk, None, None); + let token = encode( + &jsonwebtoken::Header::new(Algorithm::ES256), + &Claims { + sub: "test".to_string(), + exp: get_current_timestamp(), + aud: Some("hello".to_string()), + iss: None, + }, + &encoding_key, + ) + .unwrap(); + + let request = supergraph::Request::canned_builder() + .header(http::header::AUTHORIZATION, format!("Bearer {token}")) + .build() + .unwrap(); + + match authenticate(&config, &manager, request.try_into().unwrap()) { + ControlFlow::Break(res) => { + let response: graphql::Response = serde_json::from_slice( + &router::body::into_bytes(res.response.into_body()) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(response, graphql::Response::builder() + .errors(vec![graphql::Error::builder().extension_code("AUTH_ERROR").message("Invalid audience: the token's `aud` was 'AAAA', but signed with a key from JWKS configured to only accept from 'hello'").build()]).build()); + } + ControlFlow::Continue(req) => { + println!("got req with issuer check"); + let claims: serde_json::Value = req + .context + .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .unwrap() + .unwrap(); + println!("claims: {claims:?}"); + } + } +} + #[tokio::test] async fn it_rejects_key_with_restricted_algorithm() { let mut sets = vec![]; @@ -1371,6 +1563,7 @@ async fn it_rejects_key_with_restricted_algorithm() { urls.push(JwksConfig { url, issuers: None, + audiences: None, algorithms: Some(HashSet::from([Algorithm::RS256])), poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1403,6 +1596,7 @@ async fn it_rejects_and_accepts_keys_with_restricted_algorithms_and_unknown_jwks urls.push(JwksConfig { url, issuers: None, + audiences: None, algorithms: Some(HashSet::from([Algorithm::RS256])), poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1442,6 +1636,7 @@ async fn it_accepts_key_without_use_or_keyops() { urls.push(JwksConfig { url, issuers: None, + audiences: None, algorithms: None, poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1473,6 +1668,7 @@ async fn it_accepts_elliptic_curve_key_without_alg() { urls.push(JwksConfig { url, issuers: None, + audiences: None, algorithms: None, poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1504,6 +1700,7 @@ async fn it_accepts_rsa_key_without_alg() { urls.push(JwksConfig { url, issuers: None, + audiences: None, algorithms: None, poll_interval: Duration::from_secs(60), headers: Vec::new(), @@ -1559,6 +1756,7 @@ async fn jwks_send_headers() { let _jwks_manager = JwksManager::new(vec![JwksConfig { url, issuers: None, + audiences: None, algorithms: Some(HashSet::from([Algorithm::RS256])), poll_interval: Duration::from_secs(60), headers: vec![Header { diff --git a/apollo-router/src/plugins/include_subgraph_errors/mod.rs b/apollo-router/src/plugins/include_subgraph_errors/mod.rs index 9b294a81b1..c9a3e7a7c6 100644 --- a/apollo-router/src/plugins/include_subgraph_errors/mod.rs +++ b/apollo-router/src/plugins/include_subgraph_errors/mod.rs @@ -44,8 +44,7 @@ impl Plugin for IncludeSubgraphErrors { for (name, config) in &init.config.subgraphs { if !matches!(config, SubgraphConfig::Included(_)) { return Err(format!( - "Subgraph '{}' must use boolean config when global config is boolean", - name + "Subgraph '{name}' must use boolean config when global config is boolean", ) .into()); } diff --git a/docs/source/routing/security/jwt.mdx b/docs/source/routing/security/jwt.mdx index 79279beb9e..98c4638a89 100644 --- a/docs/source/routing/security/jwt.mdx +++ b/docs/source/routing/security/jwt.mdx @@ -51,6 +51,9 @@ Otherwise, if you issue JWTs via a popular third-party IdP (Auth0, Okta, PingOne issuers: # optional list of issuers - https://issuer.one - https://issuer.two + audiences: # optional list of audiences + - https://my.api + - https://my.other.api poll_interval: headers: # optional list of static headers added to the HTTP request to the JWKS URL - name: User-Agent @@ -113,6 +116,7 @@ The following configuration options are supported: - **If you use a third-party IdP,** consult its documentation to determine its JWKS URL. - **If you use your own custom IdP,** you need to make its JWKS available at a router-accessible URL if you haven't already. For more information, see [Creating your own JWKS](#creating-your-own-jwks-advanced). - `issuers`: **optional** list of issuers accepted, that will be compared to the `iss` claim in the JWT if present. If none match, the request will be rejected. +- `audiences`: **optional** list of audiences accepted, that will be compared to the `aud` claim in the JWT if present. If none match, the request will be rejected. - `algorithms`: **optional** list of accepted algorithms. Possible values are `HS256`, `HS384`, `HS512`, `ES256`, `ES384`, `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `EdDSA` - `poll_interval`: **optional** interval in human-readable format (e.g. `60s` or `1hour 30s`) at which the JWKS will be polled for changes. If not specified, the JWKS endpoint will be polled every 60 seconds. - `headers`: **optional** a list of headers sent when downloading from the JWKS URL From 4ad273c25543ee082af723982dfa8dec15d9cca9 Mon Sep 17 00:00:00 2001 From: Zelda Hessler Date: Tue, 27 May 2025 13:59:09 -0500 Subject: [PATCH 2/7] update snapshot --- ...uter__configuration__tests__schema_generation.snap | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 7f415c9ed9..43cd9d5a3b 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 @@ -4182,6 +4182,15 @@ expression: "&schema" "nullable": true, "type": "array" }, + "audiences": { + "description": "Expected audiences for tokens verified by that JWKS\n\nIf not specified, the audience will not be checked.", + "items": { + "type": "string" + }, + "nullable": true, + "type": "array", + "uniqueItems": true + }, "headers": { "description": "List of headers to add to the JWKS request", "items": { @@ -4191,7 +4200,7 @@ expression: "&schema" "type": "array" }, "issuers": { - "description": "Expected issuers for tokens verified by that JWKS", + "description": "Expected issuers for tokens verified by that JWKS\n\nIf not specified, the issuer will not be checked.", "items": { "type": "string" }, From 88623f3581baa7c3b617cde357885d9a53971ddb Mon Sep 17 00:00:00 2001 From: Zelda Hessler Date: Fri, 30 May 2025 11:08:29 -0500 Subject: [PATCH 3/7] respond to PR comments --- .../src/plugins/authentication/mod.rs | 50 +++++++++++++++--- .../src/plugins/authentication/tests.rs | 52 +++++++------------ 2 files changed, 64 insertions(+), 38 deletions(-) diff --git a/apollo-router/src/plugins/authentication/mod.rs b/apollo-router/src/plugins/authentication/mod.rs index 242c72af6d..1edaec74f8 100644 --- a/apollo-router/src/plugins/authentication/mod.rs +++ b/apollo-router/src/plugins/authentication/mod.rs @@ -598,11 +598,29 @@ fn authenticate( } if let Some(configured_audiences) = audiences { - if let Some(token_audience) = token_data - .claims - .as_object() - .and_then(|o| o.get("aud")) - .and_then(|value| value.as_str()) + let maybe_token_audiences = token_data + .claims.as_object().and_then(|o| o.get("aud")); + let Some(maybe_token_audiences) = maybe_token_audiences else { + let mut audiences_for_error: Vec = + configured_audiences.into_iter().collect(); + audiences_for_error.sort(); // done to maintain consistent ordering in error message + return failure_message( + request, + config, + AuthenticationError::InvalidAudience { + expected: audiences_for_error + .iter() + .map(|audience| audience.to_string()) + .collect::>() + .join(", "), + actual: "".to_string(), + }, + StatusCode::UNAUTHORIZED, + source_of_extracted_jwt, + ); + }; + + if let Some(token_audience) = maybe_token_audiences.as_str() { if !configured_audiences.contains(token_audience) { let mut audiences_for_error: Vec = @@ -619,10 +637,30 @@ fn authenticate( .join(", "), actual: token_audience.to_string(), }, - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::UNAUTHORIZED, source_of_extracted_jwt, ); } + } else { + // If the token has incorrectly configured audiences, we cannot validate it against + // the configured audiences. + let mut audiences_for_error: Vec = + configured_audiences.into_iter().collect(); + audiences_for_error.sort(); // done to maintain consistent ordering in error message + return failure_message( + request, + config, + AuthenticationError::InvalidAudience { + expected: audiences_for_error + .iter() + .map(|audience| audience.to_string()) + .collect::>() + .join(", "), + actual: maybe_token_audiences.to_string(), + }, + StatusCode::UNAUTHORIZED, + source_of_extracted_jwt, + ); } } diff --git a/apollo-router/src/plugins/authentication/tests.rs b/apollo-router/src/plugins/authentication/tests.rs index fb257657b6..4285a1627d 100644 --- a/apollo-router/src/plugins/authentication/tests.rs +++ b/apollo-router/src/plugins/authentication/tests.rs @@ -15,6 +15,7 @@ use http::HeaderMap; use http::HeaderName; use http::HeaderValue; use http::StatusCode; +use http_body_util::BodyExt; use insta::assert_yaml_snapshot; use jsonwebtoken::Algorithm; use jsonwebtoken::EncodingKey; @@ -1420,16 +1421,13 @@ async fn audience_check() { }); match authenticate(&config, &manager, request.try_into().unwrap()) { ControlFlow::Break(res) => { - panic!("unexpected response: {res:?}"); + assert_eq!(res.response.status(), StatusCode::UNAUTHORIZED); + let body = res.response.into_body().collect().await.unwrap(); + let body = String::from_utf8(body.to_bytes().to_vec()).unwrap(); + assert_eq!(body, "{\"errors\":[{\"message\":\"Invalid audience: the token's `aud` was 'null', but 'goodbye, hello' was expected\",\"extensions\":{\"code\":\"AUTH_ERROR\"}}]}"); } - ControlFlow::Continue(req) => { - println!("got req with audience check"); - let claims: serde_json::Value = req - .context - .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) - .unwrap() - .unwrap(); - println!("claims: {claims:?}"); + ControlFlow::Continue(_req) => { + panic!("expected a rejection for a lack of audience"); } } @@ -1452,24 +1450,17 @@ async fn audience_check() { .unwrap(); match authenticate(&config, &manager, request.try_into().unwrap()) { - ControlFlow::Break(res) => { - let response: graphql::Response = serde_json::from_slice( - &router::body::into_bytes(res.response.into_body()) - .await - .unwrap(), - ) - .unwrap(); - assert_eq!(response, graphql::Response::builder() - .errors(vec![graphql::Error::builder().extension_code("AUTH_ERROR").message("Invalid audience: the token's `aud` was 'hallo', but signed with a key from JWKS configured to only accept from 'hello'").build()]).build()); + ControlFlow::Break(_res) => { + panic!("expected audience to be valid"); } ControlFlow::Continue(req) => { - println!("got req with audience check"); let claims: serde_json::Value = req .context .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) .unwrap() .unwrap(); - println!("claims: {claims:?}"); + + assert_eq!(claims["aud"], "hello"); } } @@ -1500,7 +1491,12 @@ async fn audience_check() { ) .unwrap(); assert_eq!(response, graphql::Response::builder() - .errors(vec![graphql::Error::builder().extension_code("AUTH_ERROR").message("Invalid audience: the token's `aud` was 'AAAA', but 'goodbye, hello' was expected").build()]).build()); + .errors(vec![ + graphql::Error::builder() + .extension_code("AUTH_ERROR") + .message("Invalid audience: the token's `aud` was 'AAAA', but 'goodbye, hello' was expected") + .build() + ]).build()); } ControlFlow::Continue(_) => { panic!("audience check should have failed") @@ -1527,24 +1523,16 @@ async fn audience_check() { .unwrap(); match authenticate(&config, &manager, request.try_into().unwrap()) { - ControlFlow::Break(res) => { - let response: graphql::Response = serde_json::from_slice( - &router::body::into_bytes(res.response.into_body()) - .await - .unwrap(), - ) - .unwrap(); - assert_eq!(response, graphql::Response::builder() - .errors(vec![graphql::Error::builder().extension_code("AUTH_ERROR").message("Invalid audience: the token's `aud` was 'AAAA', but signed with a key from JWKS configured to only accept from 'hello'").build()]).build()); + ControlFlow::Break(_res) => { + panic!("expected audience to be valid"); } ControlFlow::Continue(req) => { - println!("got req with issuer check"); let claims: serde_json::Value = req .context .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) .unwrap() .unwrap(); - println!("claims: {claims:?}"); + assert_eq!(claims["aud"], "hello"); } } } From 87f8b23a8cf3e4dbc0d87a812bbb21fc839e228e Mon Sep 17 00:00:00 2001 From: Zelda Hessler Date: Fri, 30 May 2025 11:37:28 -0500 Subject: [PATCH 4/7] Run formatter resolves #xxx --- .../src/plugins/authentication/jwks.rs | 9 ++++++++- .../src/plugins/authentication/mod.rs | 17 ++++++++--------- .../src/plugins/authentication/tests.rs | 18 +++++++++++------- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/apollo-router/src/plugins/authentication/jwks.rs b/apollo-router/src/plugins/authentication/jwks.rs index 4331dce1eb..7a6ce1149e 100644 --- a/apollo-router/src/plugins/authentication/jwks.rs +++ b/apollo-router/src/plugins/authentication/jwks.rs @@ -557,7 +557,14 @@ pub(super) fn decode_jwt( jwt: &str, keys: Vec<(Option, Option, Jwk)>, criteria: JWTCriteria, -) -> Result<(Option, Option, TokenData), (AuthenticationError, StatusCode)> { +) -> Result< + ( + Option, + Option, + TokenData, + ), + (AuthenticationError, StatusCode), +> { let mut error = None; for (issuers, audiences, jwk) in keys.into_iter() { let decoding_key = match DecodingKey::from_jwk(&jwk) { diff --git a/apollo-router/src/plugins/authentication/mod.rs b/apollo-router/src/plugins/authentication/mod.rs index 1edaec74f8..9ef3ce8772 100644 --- a/apollo-router/src/plugins/authentication/mod.rs +++ b/apollo-router/src/plugins/authentication/mod.rs @@ -36,7 +36,8 @@ use crate::plugin::serde::deserialize_header_name; use crate::plugin::serde::deserialize_header_value; use crate::plugins::authentication::connector::ConnectorAuth; use crate::plugins::authentication::error::ErrorContext; -use crate::plugins::authentication::jwks::{Audiences, Issuers}; +use crate::plugins::authentication::jwks::Audiences; +use crate::plugins::authentication::jwks::Issuers; use crate::plugins::authentication::jwks::JwksConfig; use crate::plugins::authentication::subgraph::make_signing_params; use crate::services::APPLICATION_JSON_HEADER_VALUE; @@ -126,11 +127,11 @@ struct JwksConf { #[schemars(with = "String", default = "default_poll_interval")] poll_interval: Duration, /// Expected issuers for tokens verified by that JWKS - /// + /// /// If not specified, the issuer will not be checked. issuers: Option, /// Expected audiences for tokens verified by that JWKS - /// + /// /// If not specified, the audience will not be checked. audiences: Option, /// List of accepted algorithms. Possible values are `HS256`, `HS384`, `HS512`, `ES256`, `ES384`, `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `EdDSA` @@ -596,10 +597,9 @@ fn authenticate( } } } - + if let Some(configured_audiences) = audiences { - let maybe_token_audiences = token_data - .claims.as_object().and_then(|o| o.get("aud")); + let maybe_token_audiences = token_data.claims.as_object().and_then(|o| o.get("aud")); let Some(maybe_token_audiences) = maybe_token_audiences else { let mut audiences_for_error: Vec = configured_audiences.into_iter().collect(); @@ -620,8 +620,7 @@ fn authenticate( ); }; - if let Some(token_audience) = maybe_token_audiences.as_str() - { + if let Some(token_audience) = maybe_token_audiences.as_str() { if !configured_audiences.contains(token_audience) { let mut audiences_for_error: Vec = configured_audiences.into_iter().collect(); @@ -642,7 +641,7 @@ fn authenticate( ); } } else { - // If the token has incorrectly configured audiences, we cannot validate it against + // If the token has incorrectly configured audiences, we cannot validate it against // the configured audiences. let mut audiences_for_error: Vec = configured_audiences.into_iter().collect(); diff --git a/apollo-router/src/plugins/authentication/tests.rs b/apollo-router/src/plugins/authentication/tests.rs index 4285a1627d..e7fa3955f6 100644 --- a/apollo-router/src/plugins/authentication/tests.rs +++ b/apollo-router/src/plugins/authentication/tests.rs @@ -53,7 +53,8 @@ use crate::assert_snapshot_subscriber; use crate::graphql; use crate::plugin::test; use crate::plugins::authentication::Issuers; -use crate::plugins::authentication::jwks::{Audiences, JWTCriteria}; +use crate::plugins::authentication::jwks::Audiences; +use crate::plugins::authentication::jwks::JWTCriteria; use crate::plugins::authentication::jwks::JwksConfig; use crate::plugins::authentication::jwks::JwksManager; use crate::plugins::authentication::jwks::parse_jwks; @@ -1407,7 +1408,7 @@ async fn audience_check() { }, &encoding_key, ) - .unwrap(); + .unwrap(); let request = supergraph::Request::canned_builder() .header(http::header::AUTHORIZATION, format!("Bearer {token}")) @@ -1424,7 +1425,10 @@ async fn audience_check() { assert_eq!(res.response.status(), StatusCode::UNAUTHORIZED); let body = res.response.into_body().collect().await.unwrap(); let body = String::from_utf8(body.to_bytes().to_vec()).unwrap(); - assert_eq!(body, "{\"errors\":[{\"message\":\"Invalid audience: the token's `aud` was 'null', but 'goodbye, hello' was expected\",\"extensions\":{\"code\":\"AUTH_ERROR\"}}]}"); + assert_eq!( + body, + "{\"errors\":[{\"message\":\"Invalid audience: the token's `aud` was 'null', but 'goodbye, hello' was expected\",\"extensions\":{\"code\":\"AUTH_ERROR\"}}]}" + ); } ControlFlow::Continue(_req) => { panic!("expected a rejection for a lack of audience"); @@ -1442,7 +1446,7 @@ async fn audience_check() { }, &encoding_key, ) - .unwrap(); + .unwrap(); let request = supergraph::Request::canned_builder() .header(http::header::AUTHORIZATION, format!("Bearer {token}")) @@ -1475,7 +1479,7 @@ async fn audience_check() { }, &encoding_key, ) - .unwrap(); + .unwrap(); let request = supergraph::Request::canned_builder() .header(http::header::AUTHORIZATION, format!("Bearer {token}")) @@ -1489,7 +1493,7 @@ async fn audience_check() { .await .unwrap(), ) - .unwrap(); + .unwrap(); assert_eq!(response, graphql::Response::builder() .errors(vec![ graphql::Error::builder() @@ -1515,7 +1519,7 @@ async fn audience_check() { }, &encoding_key, ) - .unwrap(); + .unwrap(); let request = supergraph::Request::canned_builder() .header(http::header::AUTHORIZATION, format!("Bearer {token}")) From 75e1878c1d15894f3a18e7d8b6f172856106aa69 Mon Sep 17 00:00:00 2001 From: Zelda Hessler Date: Fri, 30 May 2025 12:48:59 -0500 Subject: [PATCH 5/7] fix clippy lints --- .../src/plugins/authentication/jwks.rs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/apollo-router/src/plugins/authentication/jwks.rs b/apollo-router/src/plugins/authentication/jwks.rs index 7a6ce1149e..1f23431d1e 100644 --- a/apollo-router/src/plugins/authentication/jwks.rs +++ b/apollo-router/src/plugins/authentication/jwks.rs @@ -285,6 +285,8 @@ pub(super) struct JWTCriteria { pub(super) kid: Option, } +pub(super) type SearchResult = (Option, Option, Jwk); + /// Search the list of JWKS to find a key we can use to decode a JWT. /// /// The search criteria allow us to match a variety of keys depending on which criteria are provided @@ -293,7 +295,7 @@ pub(super) struct JWTCriteria { pub(super) fn search_jwks( jwks_manager: &JwksManager, criteria: &JWTCriteria, -) -> Option, Option, Jwk)>> { +) -> Option> { const HIGHEST_SCORE: usize = 2; let mut candidates = vec![]; let mut found_highest_score = false; @@ -553,18 +555,17 @@ pub(super) fn extract_jwt<'a, 'b: 'a>( } } +pub(super) type DecodedClaims = ( + Option, + Option, + TokenData, +); + pub(super) fn decode_jwt( jwt: &str, - keys: Vec<(Option, Option, Jwk)>, + keys: Vec, criteria: JWTCriteria, -) -> Result< - ( - Option, - Option, - TokenData, - ), - (AuthenticationError, StatusCode), -> { +) -> Result { let mut error = None; for (issuers, audiences, jwk) in keys.into_iter() { let decoding_key = match DecodingKey::from_jwk(&jwk) { From fff5c5e69bf3c1ab0669fc0dc682a25a1cc83f11 Mon Sep 17 00:00:00 2001 From: Zelda Hessler Date: Mon, 2 Jun 2025 08:29:02 -0500 Subject: [PATCH 6/7] add changeset --- .../feat_zelda_add_jwt_audience_validation.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changesets/feat_zelda_add_jwt_audience_validation.md diff --git a/.changesets/feat_zelda_add_jwt_audience_validation.md b/.changesets/feat_zelda_add_jwt_audience_validation.md new file mode 100644 index 0000000000..679b0a0d47 --- /dev/null +++ b/.changesets/feat_zelda_add_jwt_audience_validation.md @@ -0,0 +1,37 @@ +### add support for JWT audience validation ([PR #7578](https://github.com/apollographql/router/pull/7578)) + +Adds support for validating the `aud` (audience) claim in JWTs. This allows the router to ensure that the JWT is intended +for the specific audience it is being used with, enhancing security by preventing token misuse across different audiences. + +#### Example Usage + +```yaml title="router.yaml" +authentication: + router: + jwt: + jwks: # This key is required. + - url: https://dev-zzp5enui.us.auth0.com/.well-known/jwks.json + issuers: # optional list of issuers + - https://issuer.one + - https://issuer.two + audiences: # optional list of audiences + - https://my.api + - https://my.other.api + poll_interval: + headers: # optional list of static headers added to the HTTP request to the JWKS URL + - name: User-Agent + value: router + # These keys are optional. Default values are shown. + header_name: Authorization + header_value_prefix: Bearer + on_error: Error + # array of alternative token sources + sources: + - type: header + name: X-Authorization + value_prefix: Bearer + - type: cookie + name: authz +``` + +By [@Velfi](https://github.com/Velfi) in https://github.com/apollographql/router/pull/7578 From 273f129bd8afda568f2f4fc356ae8ec346cdd341 Mon Sep 17 00:00:00 2001 From: Zelda Hessler Date: Mon, 2 Jun 2025 08:44:43 -0500 Subject: [PATCH 7/7] use JSON value comparison instead of string comparison --- .../src/plugins/authentication/tests.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apollo-router/src/plugins/authentication/tests.rs b/apollo-router/src/plugins/authentication/tests.rs index e7fa3955f6..3e865a7563 100644 --- a/apollo-router/src/plugins/authentication/tests.rs +++ b/apollo-router/src/plugins/authentication/tests.rs @@ -1424,11 +1424,18 @@ async fn audience_check() { ControlFlow::Break(res) => { assert_eq!(res.response.status(), StatusCode::UNAUTHORIZED); let body = res.response.into_body().collect().await.unwrap(); - let body = String::from_utf8(body.to_bytes().to_vec()).unwrap(); - assert_eq!( - body, - "{\"errors\":[{\"message\":\"Invalid audience: the token's `aud` was 'null', but 'goodbye, hello' was expected\",\"extensions\":{\"code\":\"AUTH_ERROR\"}}]}" - ); + let body: serde_json::Value = serde_json::from_slice(&body.to_bytes()).unwrap(); + let expected_body = serde_json::json!({ + "errors": [ + { + "message": "Invalid audience: the token's `aud` was 'null', but 'goodbye, hello' was expected", + "extensions": { + "code": "AUTH_ERROR" + } + } + ] + }); + assert_eq!(body, expected_body); } ControlFlow::Continue(_req) => { panic!("expected a rejection for a lack of audience");