From 1203163b0c5376621d3aee07c695f8ff8e9d04de Mon Sep 17 00:00:00 2001 From: cduvray Date: Wed, 25 Jan 2023 08:38:09 +0100 Subject: [PATCH] feat: refresh configuration --- CHANGELOG.md | 6 +- README.md | 7 +- demo-server/src/main.rs | 6 +- demo-server/src/oidc_provider/mod.rs | 7 - jwt-authorizer/docs/README.md | 20 ++- jwt-authorizer/src/authorizer.rs | 10 +- jwt-authorizer/src/jwks/key_store_manager.rs | 151 ++++++++++++++----- jwt-authorizer/src/layer.rs | 39 ++++- jwt-authorizer/src/lib.rs | 1 + 9 files changed, 189 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7b3582..751b309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - JwtAuthorizer creation simplified: - - JwtAuthorizer::from_* creates an instance, new() is not necessary anymore +- with_check() renamed to check() + +### Added + +- jwks store refresh configuration ### Fixed diff --git a/README.md b/README.md index c3c83a1..80ebdba 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ JWT authorizer Layer for Axum. ## Features -- JWT token verification (Bearer) +- JWT token verification (Bearer) + - Algoritms: ECDSA, RSA, EdDSA, HS +- JWKS endpoint support + - Configurable refresh - Claims extraction -- JWKS endpoint support (with refresh) -- Algoritms: ECDSA, RSA, EdDSA, HS - Claims checker ## Usage diff --git a/demo-server/src/main.rs b/demo-server/src/main.rs index a135189..a9968c3 100644 --- a/demo-server/src/main.rs +++ b/demo-server/src/main.rs @@ -2,7 +2,7 @@ use axum::{ routing::{get, post}, Router, }; -use jwt_authorizer::{AuthError, JwtAuthorizer, JwtClaims}; +use jwt_authorizer::{AuthError, JwtAuthorizer, JwtClaims, Refresh, RefreshStrategy}; use serde::Deserialize; use std::{fmt::Display, net::SocketAddr}; use tower_http::trace::TraceLayer; @@ -30,7 +30,9 @@ async fn main() { // User is a struct deserializable from JWT claims representing the authorized user let jwt_auth: JwtAuthorizer = JwtAuthorizer:: from_jwks_url("http://localhost:3000/oidc/jwks") - .with_check(claim_checker); + // .no_refresh() + .refresh(Refresh {strategy: RefreshStrategy::Interval, ..Default::default()}) + .check(claim_checker); let oidc = Router::new() .route("/authorize", post(oidc_provider::authorize)) diff --git a/demo-server/src/oidc_provider/mod.rs b/demo-server/src/oidc_provider/mod.rs index 919842b..0d545b7 100644 --- a/demo-server/src/oidc_provider/mod.rs +++ b/demo-server/src/oidc_provider/mod.rs @@ -29,13 +29,6 @@ pub struct Keys { } impl Keys { - fn new(secret: &[u8]) -> Self { - Self { - alg: Algorithm::HS256, - encoding: EncodingKey::from_secret(secret), - decoding: DecodingKey::from_secret(secret), - } - } fn load_rsa() -> Self { Self { alg: Algorithm::RS256, diff --git a/jwt-authorizer/docs/README.md b/jwt-authorizer/docs/README.md index f97ae42..a51d67a 100644 --- a/jwt-authorizer/docs/README.md +++ b/jwt-authorizer/docs/README.md @@ -2,7 +2,17 @@ JWT authoriser Layer for Axum. -Example: +## Features + +- JWT token verification (Bearer) + - Algoritms: ECDSA, RSA, EdDSA, HS +- JWKS endpoint support + - Configurable refresh +- Claims extraction +- Claims checker + + +## Usage Example ```rust use jwt_authorizer::{AuthError, JwtAuthorizer, JwtClaims}; @@ -55,8 +65,14 @@ Example: } let authorizer = JwtAuthorizer::from_rsa_pem("../config/jwtRS256.key.pub") - .with_check( + .check( |claims: &User| claims.sub.contains('@') // must be an email ); ``` +## JWKS Refresh + +By default the jwks keys are reloaded when a request token is signed with a key (`kid` jwt header) that is not present in the store (a minimal intervale between 2 reloads is 10s by default, can be configured). + +- `JwtAuthorizer::no_refresh()` configures one and unique reload of jwks keys +- `JwtAuthorizer::refresh(refresh_configuration)` allows to define a finer configuration for jwks refreshing, for more details see the documentation of `Refresh` struct. \ No newline at end of file diff --git a/jwt-authorizer/src/authorizer.rs b/jwt-authorizer/src/authorizer.rs index 34d7acf..f1d72fe 100644 --- a/jwt-authorizer/src/authorizer.rs +++ b/jwt-authorizer/src/authorizer.rs @@ -1,11 +1,11 @@ -use std::{io::Read, time::Duration}; +use std::io::Read; use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, TokenData, Validation}; use serde::de::DeserializeOwned; use crate::{ error::{AuthError, InitError}, - jwks::{key_store_manager::KeyStoreManager, KeySource}, + jwks::{key_store_manager::KeyStoreManager, KeySource}, Refresh, }; pub trait ClaimsChecker { @@ -66,7 +66,7 @@ where }) } - pub fn from(key_source_type: &KeySourceType, claims_checker: Option>) -> Result, InitError> { + pub(crate) fn from(key_source_type: &KeySourceType, claims_checker: Option>) -> Result, InitError> { let key = match key_source_type { KeySourceType::RSA(path) => DecodingKey::from_rsa_pem(&read_data(path.as_str())?)?, KeySourceType::EC(path) => DecodingKey::from_ec_der(&read_data(path.as_str())?), @@ -81,8 +81,8 @@ where }) } - pub fn from_jwks_url(url: &str, claims_checker: Option>) -> Result, InitError> { - let key_store_manager = KeyStoreManager::with_refresh_interval(url, Duration::from_secs(60)); + pub(crate) fn from_jwks_url(url: &str, claims_checker: Option>, refresh: Refresh) -> Result, InitError> { + let key_store_manager = KeyStoreManager::new(url, refresh); Ok(Authorizer { key_source: KeySource::KeyStoreSource(key_store_manager), claims_checker, diff --git a/jwt-authorizer/src/jwks/key_store_manager.rs b/jwt-authorizer/src/jwks/key_store_manager.rs index 4388270..85fa186 100644 --- a/jwt-authorizer/src/jwks/key_store_manager.rs +++ b/jwt-authorizer/src/jwks/key_store_manager.rs @@ -10,21 +10,47 @@ use tokio::sync::Mutex; use crate::error::AuthError; -#[derive(Clone)] +#[derive(Clone, Copy)] pub enum RefreshStrategy { + /// refresh periodicaly - Interval(Duration), + Interval, + /// when kid not found in the store KeyNotFound, - // other strategies? KeyNotFoundOrInterval(Duration), Once, + + /// load once triggered by the first use + NoRefresh, +} + +/// JWKS Refresh configuration +#[derive(Clone, Copy)] +pub struct Refresh { + pub strategy: RefreshStrategy, + // after the interval the store will be refreshed (before getting a new key - lazy behaviour) + pub refresh_interval: Duration, + // don't refresh before (counting from the last refresh, when the kid not found) + pub minimal_refresh_interval: Duration, + // don't refresh before (after an error or jwks unawailable) + pub retry_interval: Duration, +} + +impl Default for Refresh { + fn default() -> Self { + Self { + strategy: RefreshStrategy::KeyNotFound, + refresh_interval: Duration::from_secs(600), + minimal_refresh_interval: Duration::from_secs(30), + retry_interval: Duration::from_secs(10), + } + } } #[derive(Clone)] pub struct KeyStoreManager { key_url: String, - refresh: RefreshStrategy, /// in case of fail loading (error or key not found), minimal interval - minimal_refresh_interval: Duration, + refresh: Refresh, keystore: Arc>, } @@ -38,11 +64,10 @@ pub struct KeyStore { } impl KeyStoreManager { - pub(crate) fn new(url: &str, refresh: RefreshStrategy) -> KeyStoreManager { + pub(crate) fn new(url: &str, refresh: Refresh) -> KeyStoreManager { KeyStoreManager { key_url: url.to_owned(), refresh, - minimal_refresh_interval: Duration::from_secs(5), // TODO: make configurable keystore: Arc::new(Mutex::new(KeyStore { jwks: JwkSet { keys: vec![] }, load_time: None, @@ -51,20 +76,12 @@ impl KeyStoreManager { } } - pub(crate) fn with_refresh(url: &str) -> KeyStoreManager { - KeyStoreManager::new(url, RefreshStrategy::KeyNotFound) - } - - pub(crate) fn with_refresh_interval(url: &str, interval: Duration) -> KeyStoreManager { - KeyStoreManager::new(url, RefreshStrategy::Interval(interval)) - } - pub(crate) async fn get_key(&self, header: &jsonwebtoken::Header) -> Result { let kstore = self.keystore.clone(); let mut ks_gard = kstore.lock().await; - let key = match self.refresh { - RefreshStrategy::Interval(refresh_interval) => { - if ks_gard.should_refresh(refresh_interval) && ks_gard.can_refresh(self.minimal_refresh_interval) { + let key = match self.refresh.strategy { + RefreshStrategy::Interval => { + if ks_gard.should_refresh(self.refresh.refresh_interval) && ks_gard.can_refresh(self.refresh.minimal_refresh_interval, self.refresh.retry_interval) { ks_gard.refresh(&self.key_url, &[]).await?; } if let Some(ref kid) = header.kid { @@ -82,12 +99,13 @@ impl KeyStoreManager { let jwk_opt = ks_gard.find_kid(kid); if let Some(jwk) = jwk_opt { jwk - } else if ks_gard.can_refresh(self.minimal_refresh_interval) { + } else if ks_gard.can_refresh(self.refresh.minimal_refresh_interval, self.refresh.retry_interval) { ks_gard.refresh(&self.key_url, &[("kid", kid)]).await?; ks_gard .find_kid(kid) .ok_or_else(|| AuthError::InvalidKid(kid.to_owned()))? } else { + return Err(AuthError::InvalidKid(kid.to_owned())); } } else { @@ -95,7 +113,7 @@ impl KeyStoreManager { // .ok_or(AuthError::InvalidKeyAlg(header.alg))? if let Some(jwk) = jwk_opt { jwk - } else if ks_gard.can_refresh(self.minimal_refresh_interval) { + } else if ks_gard.can_refresh(self.refresh.minimal_refresh_interval, self.refresh.retry_interval) { ks_gard .refresh( &self.key_url, @@ -113,6 +131,20 @@ impl KeyStoreManager { return Err(AuthError::InvalidKeyAlg(header.alg)); } } + }, + RefreshStrategy::NoRefresh => { + if ks_gard.load_time.is_none() { + ks_gard.refresh(&self.key_url, &[]).await?; + } + if let Some(ref kid) = header.kid { + ks_gard + .find_kid(kid) + .ok_or_else(|| AuthError::InvalidKid(kid.to_owned()))? + } else { + ks_gard + .find_alg(&header.alg) + .ok_or(AuthError::InvalidKeyAlg(header.alg))? + } } }; @@ -129,15 +161,15 @@ impl KeyStore { } } - fn can_refresh(&self, minimal_refresh_interval: Duration) -> bool { - if let Some(ft) = self.fail_time { - if let Some(lt) = self.load_time { - ft.elapsed() > minimal_refresh_interval && lt.elapsed() > minimal_refresh_interval + fn can_refresh(&self, minimal_refresh_interval: Duration, minimal_retry: Duration) -> bool { + if let Some(fail_tm) = self.fail_time { + if let Some(load_tm) = self.load_time { + fail_tm.elapsed() > minimal_retry && load_tm.elapsed() > minimal_refresh_interval } else { - ft.elapsed() > minimal_refresh_interval + fail_tm.elapsed() > minimal_retry } - } else if let Some(lt) = self.load_time { - lt.elapsed() > minimal_refresh_interval + } else if let Some(load_tm) = self.load_time { + load_tm.elapsed() > minimal_refresh_interval } else { true } @@ -196,6 +228,7 @@ mod tests { Mock, MockServer, ResponseTemplate, }; + use crate::{RefreshStrategy, Refresh}; use crate::jwks::key_store_manager::{KeyStore, KeyStoreManager}; #[test] @@ -219,21 +252,34 @@ mod tests { #[test] fn keystore_can_refresh() { + + // FAIL, NO LOAD let ks = KeyStore { jwks: jsonwebtoken::jwk::JwkSet { keys: vec![] }, fail_time: Some(Instant::now() - Duration::from_secs(5)), load_time: None, }; - assert!(ks.can_refresh(Duration::from_secs(4))); - assert!(!ks.can_refresh(Duration::from_secs(6))); + assert!(ks.can_refresh(Duration::from_secs(4), Duration::from_secs(4))); + assert!(ks.can_refresh(Duration::from_secs(6), Duration::from_secs(4))); + assert!(!ks.can_refresh(Duration::from_secs(6), Duration::from_secs(6))); + // NO FAIL, LOAD let ks = KeyStore { jwks: jsonwebtoken::jwk::JwkSet { keys: vec![] }, fail_time: None, load_time: Some(Instant::now() - Duration::from_secs(5)), }; - assert!(ks.can_refresh(Duration::from_secs(4))); - assert!(!ks.can_refresh(Duration::from_secs(6))); + assert!(ks.can_refresh(Duration::from_secs(4), Duration::from_secs(4))); + assert!(!ks.can_refresh(Duration::from_secs(6), Duration::from_secs(6))); + + // FAIL, LOAD + let ks = KeyStore { + jwks: jsonwebtoken::jwk::JwkSet { keys: vec![] }, + fail_time: Some(Instant::now() - Duration::from_secs(5)), + load_time: Some(Instant::now() - Duration::from_secs(10)), + }; + assert!(ks.can_refresh(Duration::from_secs(6), Duration::from_secs(4))); + assert!(!ks.can_refresh(Duration::from_secs(6), Duration::from_secs(6))); } #[test] @@ -295,7 +341,10 @@ mod tests { ) .await; - let ksm = KeyStoreManager::with_refresh_interval(&mock_server.uri(), Duration::from_secs(3000)); + let ksm = KeyStoreManager::new( + &mock_server.uri(), + Refresh {strategy: RefreshStrategy::Interval, refresh_interval: Duration::from_secs(3000), ..Default::default()} + ); let r = ksm.get_key(&Header::new(Algorithm::EdDSA)).await; assert!(r.is_ok()); mock_server.verify().await; @@ -317,7 +366,9 @@ mod tests { ) .await; - let mut ksm = KeyStoreManager::with_refresh(&mock_server.uri()); + let mut ksm = KeyStoreManager::new( + &mock_server.uri(), + Refresh {strategy: RefreshStrategy::KeyNotFound, ..Default::default()}); // STEP 1: initial (lazy) reloading let r = ksm.get_key(&build_header("key-ed", Algorithm::EdDSA)).await; @@ -341,7 +392,7 @@ mod tests { let h = build_header("key-ed02", Algorithm::EdDSA); assert!(ksm.get_key(&h).await.is_err()); - ksm.minimal_refresh_interval = Duration::from_millis(100); + ksm.refresh.minimal_refresh_interval = Duration::from_millis(100); tokio::time::sleep(Duration::from_millis(101)).await; assert!(ksm.get_key(&h).await.is_ok()); @@ -370,4 +421,36 @@ mod tests { mock_server.verify().await; } + + #[tokio::test] + async fn keystore_manager_find_key_with_no_refresh() { + let mock_server = MockServer::start().await; + mock_jwks_response_once( + &mock_server, + r#"{ + "kty": "OKP", + "use": "sig", + "crv": "Ed25519", + "x": "uWtSkE-I9aTMYTTvuTE1rtu0rNdxp3DU33cJ_ksL1Gk", + "kid": "key-ed", + "alg": "EdDSA" + }"#, + ) + .await; + + let ksm = KeyStoreManager::new( + &mock_server.uri(), + Refresh {strategy: RefreshStrategy::NoRefresh, ..Default::default()}); + + // STEP 1: initial (lazy) reloading + let r = ksm.get_key(&build_header("key-ed", Algorithm::EdDSA)).await; + assert!(r.is_ok()); + mock_server.verify().await; + + // STEP2: new kid -> reloading ksm + let h = build_header("key-ed02", Algorithm::EdDSA); + assert!(ksm.get_key(&h).await.is_err()); + + mock_server.verify().await; + } } diff --git a/jwt-authorizer/src/layer.rs b/jwt-authorizer/src/layer.rs index 2b770f0..cf85c8d 100644 --- a/jwt-authorizer/src/layer.rs +++ b/jwt-authorizer/src/layer.rs @@ -14,9 +14,10 @@ use std::task::{Context, Poll}; use tower_layer::Layer; use tower_service::Service; -use crate::AuthError; +use crate::{AuthError, RefreshStrategy}; use crate::authorizer::{Authorizer, FnClaimsChecker, KeySourceType}; use crate::error::InitError; +use crate::jwks::key_store_manager::Refresh; /// Authorizer Layer builder /// @@ -27,6 +28,7 @@ where C: Clone + DeserializeOwned, { key_source_type: Option, + refresh: Option, claims_checker: Option>, } @@ -39,6 +41,7 @@ where pub fn from_jwks_url(url: &'static str) -> JwtAuthorizer { JwtAuthorizer { key_source_type: Some(KeySourceType::Jwks(url.to_owned())), + refresh: Default::default(), claims_checker: None, } } @@ -47,6 +50,7 @@ where pub fn from_rsa_pem(path: &'static str) -> JwtAuthorizer { JwtAuthorizer { key_source_type: Some(KeySourceType::RSA(path.to_owned())), + refresh: Default::default(), claims_checker: None, } } @@ -55,6 +59,7 @@ where pub fn from_ec_pem(path: &'static str) -> JwtAuthorizer { JwtAuthorizer { key_source_type: Some(KeySourceType::EC(path.to_owned())), + refresh: Default::default(), claims_checker: None, } } @@ -63,6 +68,7 @@ where pub fn from_ed_pem(path: &'static str) -> JwtAuthorizer { JwtAuthorizer { key_source_type: Some(KeySourceType::ED(path.to_owned())), + refresh: Default::default(), claims_checker: None, } } @@ -71,12 +77,35 @@ where pub fn from_secret(secret: &'static str) -> JwtAuthorizer { JwtAuthorizer { key_source_type: Some(KeySourceType::Secret(secret)), + refresh: Default::default(), claims_checker: None, } } - /// layer that checks token validity and claim constraints (custom function) - pub fn with_check(mut self, checker_fn: fn(&C) -> bool) -> JwtAuthorizer { + /// refresh configuration for jwk store + pub fn refresh(mut self, refresh: Refresh) -> JwtAuthorizer { + if self.refresh.is_some() { + tracing::warn!("More than one refresh configuration found!"); + } + self.refresh = Some(refresh); + self + } + + /// no refresh, jwks will be loaded juste once + pub fn no_refresh(mut self) -> JwtAuthorizer { + if self.refresh.is_some() { + tracing::warn!("More than one refresh configuration found!"); + } + self.refresh = Some(Refresh { + strategy: RefreshStrategy::NoRefresh, + ..Default::default() + }); + self + } + + /// configures token content check (custom function), if false a 403 will be sent. + /// (AuthError::InvalidClaims()) + pub fn check(mut self, checker_fn: fn(&C) -> bool) -> JwtAuthorizer { self.claims_checker = Some(FnClaimsChecker { checker_fn }); self @@ -90,7 +119,9 @@ where Arc::new(Authorizer::from(key_source_type, self.claims_checker.clone())?) } KeySourceType::Jwks(url) => { - Arc::new(Authorizer::from_jwks_url(url.as_str(), self.claims_checker.clone())?) + Arc::new( + Authorizer::from_jwks_url( + url.as_str(), self.claims_checker.clone(), self.refresh.unwrap_or_default())?) } } } else { diff --git a/jwt-authorizer/src/lib.rs b/jwt-authorizer/src/lib.rs index 637d078..ee29874 100644 --- a/jwt-authorizer/src/lib.rs +++ b/jwt-authorizer/src/lib.rs @@ -7,6 +7,7 @@ use serde::de::DeserializeOwned; pub use self::error::AuthError; pub use layer::JwtAuthorizer; +pub use jwks::key_store_manager::{Refresh, RefreshStrategy}; pub mod authorizer; pub mod error;