Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changesets/feat_zelda_add_jwt_audience_validation.md
Original file line number Diff line number Diff line change
@@ -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: <optional 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
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
},
Expand Down
4 changes: 4 additions & 0 deletions apollo-router/src/plugins/authentication/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down Expand Up @@ -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),
};

Expand Down
27 changes: 20 additions & 7 deletions apollo-router/src/plugins/authentication/jwks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ pub(super) struct JwksManager {
}

pub(super) type Issuers = HashSet<String>;
pub(super) type Audiences = HashSet<String>;

#[derive(Clone)]
pub(super) struct JwksConfig {
pub(super) url: Url,
pub(super) issuers: Option<Issuers>,
pub(super) audiences: Option<Audiences>,
pub(super) algorithms: Option<HashSet<Algorithm>>,
pub(super) poll_interval: Duration,
pub(super) headers: Vec<Header>,
Expand All @@ -65,6 +67,7 @@ pub(super) struct JwksConfig {
pub(super) struct JwkSetInfo {
pub(super) jwks: JwkSet,
pub(super) issuers: Option<Issuers>,
pub(super) audiences: Option<Audiences>,
pub(super) algorithms: Option<HashSet<Algorithm>>,
}

Expand Down Expand Up @@ -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(),
});
}
Expand All @@ -281,6 +285,8 @@ pub(super) struct JWTCriteria {
pub(super) kid: Option<String>,
}

pub(super) type SearchResult = (Option<Issuers>, Option<Audiences>, 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
Expand All @@ -289,13 +295,14 @@ pub(super) struct JWTCriteria {
pub(super) fn search_jwks(
jwks_manager: &JwksManager,
criteria: &JWTCriteria,
) -> Option<Vec<(Option<Issuers>, Jwk)>> {
) -> Option<Vec<SearchResult>> {
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()
{
Expand Down Expand Up @@ -404,15 +411,15 @@ 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)));
}
}

tracing::debug!(
"jwk candidates: {:?}",
candidates
.iter()
.map(|(score, (_, candidate))| (
.map(|(score, (_, _, candidate))| (
score,
&candidate.common.key_id,
candidate.common.key_algorithm
Expand Down Expand Up @@ -548,13 +555,19 @@ pub(super) fn extract_jwt<'a, 'b: 'a>(
}
}

pub(super) type DecodedClaims = (
Option<Issuers>,
Option<Audiences>,
TokenData<serde_json::Value>,
);

pub(super) fn decode_jwt(
jwt: &str,
keys: Vec<(Option<Issuers>, Jwk)>,
keys: Vec<SearchResult>,
criteria: JWTCriteria,
) -> Result<(Option<Issuers>, TokenData<serde_json::Value>), (AuthenticationError, StatusCode)> {
) -> Result<DecodedClaims, (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) => {
Expand Down Expand Up @@ -595,7 +608,7 @@ pub(super) fn decode_jwt(
validation.validate_aud = false;

match decode::<serde_json::Value>(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((
Expand Down
75 changes: 74 additions & 1 deletion apollo-router/src/plugins/authentication/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +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::Audiences;
use crate::plugins::authentication::jwks::Issuers;
use crate::plugins::authentication::jwks::JwksConfig;
use crate::plugins::authentication::subgraph::make_signing_params;
Expand Down Expand Up @@ -126,7 +127,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<Issuers>,
/// Expected audiences for tokens verified by that JWKS
///
/// If not specified, the audience will not be checked.
audiences: Option<Audiences>,
/// List of accepted algorithms. Possible values are `HS256`, `HS384`, `HS512`, `ES256`, `ES384`, `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `EdDSA`
#[schemars(with = "Option<Vec<String>>", default)]
#[serde(default)]
Expand Down Expand Up @@ -337,6 +344,7 @@ impl AuthenticationPlugin {
list.push(JwksConfig {
url,
issuers: jwks_conf.issuers.clone(),
audiences: jwks_conf.audiences.clone(),
algorithms: jwks_conf
.algorithms
.as_ref()
Expand Down Expand Up @@ -548,7 +556,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(
Expand Down Expand Up @@ -590,6 +598,71 @@ fn authenticate(
}
}

if let Some(configured_audiences) = audiences {
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<String> =
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::<Vec<_>>()
.join(", "),
actual: "<none>".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<String> =
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::<Vec<_>>()
.join(", "),
actual: token_audience.to_string(),
},
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<String> =
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::<Vec<_>>()
.join(", "),
actual: maybe_token_audiences.to_string(),
},
StatusCode::UNAUTHORIZED,
source_of_extracted_jwt,
);
}
}

if let Err(e) = request
.context
.insert(APOLLO_AUTHENTICATION_JWT_CLAIMS, token_data.claims.clone())
Expand Down
Loading