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 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" }, 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..1f23431d1e 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(), }); } @@ -281,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 @@ -289,13 +295,14 @@ pub(super) struct JWTCriteria { pub(super) fn search_jwks( jwks_manager: &JwksManager, criteria: &JWTCriteria, -) -> Option, Jwk)>> { +) -> Option> { 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 +411,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 +419,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 @@ -548,13 +555,19 @@ 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, Jwk)>, + keys: Vec, criteria: JWTCriteria, -) -> Result<(Option, TokenData), (AuthenticationError, StatusCode)> { +) -> Result { 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 +608,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..9ef3ce8772 100644 --- a/apollo-router/src/plugins/authentication/mod.rs +++ b/apollo-router/src/plugins/authentication/mod.rs @@ -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; @@ -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, + /// 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 +344,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 +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( @@ -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 = + 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 = + 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::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, + ); + } + } + if let Err(e) = request .context .insert(APOLLO_AUTHENTICATION_JWT_CLAIMS, token_data.claims.clone()) diff --git a/apollo-router/src/plugins/authentication/tests.rs b/apollo-router/src/plugins/authentication/tests.rs index f35c087b90..3e865a7563 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; @@ -52,6 +53,7 @@ use crate::assert_snapshot_subscriber; use crate::graphql; use crate::plugin::test; use crate::plugins::authentication::Issuers; +use crate::plugins::authentication::jwks::Audiences; use crate::plugins::authentication::jwks::JWTCriteria; use crate::plugins::authentication::jwks::JwksConfig; use crate::plugins::authentication::jwks::JwksManager; @@ -1058,6 +1060,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 +1079,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 +1096,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 +1125,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 +1145,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 +1161,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 +1173,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 +1210,7 @@ async fn issuer_check() { let manager = make_manager( &jwk, Some(HashSet::from(["hello".to_string(), "goodbye".to_string()])), + None, ); // No issuer @@ -1214,6 +1220,7 @@ async fn issuer_check() { sub: "test".to_string(), exp: get_current_timestamp(), iss: None, + aud: None, }, &encoding_key, ) @@ -1251,6 +1258,7 @@ async fn issuer_check() { sub: "test".to_string(), exp: get_current_timestamp(), iss: Some("hello".to_string()), + aud: None, }, &encoding_key, ) @@ -1290,6 +1298,7 @@ async fn issuer_check() { sub: "test".to_string(), exp: get_current_timestamp(), iss: Some("AAAA".to_string()), + aud: None, }, &encoding_key, ) @@ -1317,13 +1326,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 +1367,187 @@ 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) => { + assert_eq!(res.response.status(), StatusCode::UNAUTHORIZED); + let body = res.response.into_body().collect().await.unwrap(); + 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"); + } + } + + // 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) => { + panic!("expected audience to be valid"); + } + ControlFlow::Continue(req) => { + let claims: serde_json::Value = req + .context + .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .unwrap() + .unwrap(); + + assert_eq!(claims["aud"], "hello"); + } + } + + // 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) => { + panic!("expected audience to be valid"); + } + ControlFlow::Continue(req) => { + let claims: serde_json::Value = req + .context + .get(APOLLO_AUTHENTICATION_JWT_CLAIMS) + .unwrap() + .unwrap(); + assert_eq!(claims["aud"], "hello"); + } + } +} + #[tokio::test] async fn it_rejects_key_with_restricted_algorithm() { let mut sets = vec![]; @@ -1371,6 +1562,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 +1595,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 +1635,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 +1667,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 +1699,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 +1755,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