use chrono::Utc;
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::Duration,
};
use thiserror::Error;
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
use crate::http_utils::Backoff;
use crate::{
JwtConfig, LogLevel,
async_sleep::sleep,
common::{issuer_utils::IssClaim, policy_store::TrustedIssuer},
http::HttpClient,
jwt::{
GetFromUrl, IssuerConfig, IssuerIndex, JwtLogEntry, JwtServiceInitError, KeyService,
OpenIdConfig, TokenCache,
key_service::KeyServiceError,
loading_state::TrustedIssuerLoadingState,
status_list::{InitForIssArgs, StatusListCache},
validation::JwtValidatorCache,
},
jwt_config::{DEFAULT_JWKS_REFRESH_INTERVAL_SECS, TrustedIssuerLoaderConfig},
log::{BaseLogEntry, LogEntry, LogWriter, Logger},
};
use crate::http::spawn_task;
#[derive(Error, Debug)]
pub(crate) enum TrustedIssuerLoaderError {
#[error(
"failed to acquire semaphore permit for concurrent issuer loading - this indicates a serious concurrency issue or resource exhaustion"
)]
SemaphoreAcquire,
#[error(
"failed to extract errors Arc - other references may still exist indicating concurrent access during issuer loading"
)]
ErrorsArcExtraction,
#[error(
"failed to extract errors from Mutex - mutex may be poisoned due to panic while holding lock"
)]
ErrorsMutexExtraction,
}
#[derive(Clone)]
pub(super) struct TrustedIssuerLoader {
pub(super) jwt_config: JwtConfig,
pub(super) status_lists: StatusListCache,
pub(super) issuer_configs: Arc<IssuerIndex>,
pub(super) validators: Arc<JwtValidatorCache>,
pub(super) key_service: Arc<KeyService>,
pub(super) token_cache: TokenCache,
pub(super) logger: Option<Logger>,
pub(super) loading_state: Arc<TrustedIssuerLoadingState>,
pub(super) http_client: HttpClient,
pub(super) jwks_refresh_notifiers: Arc<Mutex<HashMap<IssClaim, Arc<Notify>>>>,
pub(super) jwks_cancel_token: CancellationToken,
}
impl TrustedIssuerLoader {
pub(super) async fn load_trusted_issuers(
&self,
trusted_issuers: HashMap<String, TrustedIssuer>,
) -> Result<(), JwtServiceInitError> {
match self.jwt_config.trusted_issuer_loader {
TrustedIssuerLoaderConfig::Sync { workers } => {
load_trusted_issuers(self, trusted_issuers, workers.get())
.await
.inspect_err(|e| {
log_load_trusted_issuers_error(self.logger.as_ref(), e);
})
},
TrustedIssuerLoaderConfig::Async { workers } => {
let loader = self.clone();
spawn_task(async move {
let _ = load_trusted_issuers(&loader, trusted_issuers, workers.get())
.await
.inspect_err(|e| {
log_load_trusted_issuers_error(loader.logger.as_ref(), e);
});
});
Ok(())
},
}
}
pub(crate) fn check_keys_loaded(&self) {
let signed_authz_available = self.key_service.has_keys();
if !signed_authz_available && self.jwt_config.jwt_sig_validation {
self.logger.log_any(JwtLogEntry::new(
"signed authorization is unavailable because no trusted issuers or JWKS were configured".to_string(),
Some(LogLevel::WARN),
));
}
}
}
async fn load_trusted_issuers(
loader: &TrustedIssuerLoader,
trusted_issuers: HashMap<String, TrustedIssuer>,
workers: usize,
) -> Result<(), JwtServiceInitError> {
let semaphore = Arc::new(tokio::sync::Semaphore::new(workers));
let mut handles = Vec::new();
let errors = Arc::new(Mutex::new(Vec::new()));
if loader.jwt_config.jwt_sig_validation
&& let Some(jwks) = loader.jwt_config.jwks.as_ref()
{
loader
.key_service
.insert_keys_from_str(jwks)
.map_err(JwtServiceInitError::PrepareKeys)?;
}
for (issuer_id, iss) in trusted_issuers {
let permit = semaphore
.clone()
.acquire_owned()
.await
.map_err(|_| TrustedIssuerLoaderError::SemaphoreAcquire)?;
let loader_clone = loader.clone();
let errors_clone = errors.clone();
let http_client_clone = loader.http_client.clone();
let handle = spawn_task(async move {
let result =
load_trusted_issuer(&loader_clone, issuer_id.clone(), iss, http_client_clone).await;
drop(permit);
if let Err(error) = result {
loader_clone.logger.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::WARN,
None,
))
.set_message(format!("Could not load trusted issuer: {issuer_id}"))
.set_error(error.to_string()),
);
if let Ok(mut guard) = errors_clone.lock() {
guard.push(error);
}
loader_clone
.loading_state
.add_trusted_issuer_failed(issuer_id.clone());
}
});
handles.push(handle);
}
for handle in handles {
handle.await_result().await;
}
loader.check_keys_loaded();
let errors = Arc::into_inner(errors)
.ok_or(TrustedIssuerLoaderError::ErrorsArcExtraction)?
.into_inner()
.map_err(|_| TrustedIssuerLoaderError::ErrorsMutexExtraction)?;
let aggregated_error: Vec<_> = errors.into_iter().collect();
if !aggregated_error.is_empty() {
return Err(JwtServiceInitError::LoadTrustedIssuersAggregate(
aggregated_error.into(),
));
}
Ok(())
}
pub(super) async fn load_trusted_issuer(
loader: &TrustedIssuerLoader,
issuer_id: String,
iss: TrustedIssuer,
http_client: HttpClient,
) -> Result<(), JwtServiceInitError> {
let mut iss_claim = iss.iss_claim();
let mut iss_config = IssuerConfig {
issuer_id,
policy: Arc::new(iss),
openid_config: None,
};
if loader.jwt_config.jwt_sig_validation || loader.jwt_config.jwt_status_validation {
iss_claim =
update_openid_config(&mut iss_config, loader.logger.as_ref(), http_client.clone())
.await?;
}
insert_keys(
&loader.key_service,
&loader.jwt_config,
&iss_config,
loader.logger.as_ref(),
http_client.clone(),
)
.await?;
loader.validators.init_for_iss(
&iss_config,
&loader.jwt_config,
&loader.status_lists,
loader.logger.as_ref(),
);
if loader.jwt_config.jwt_status_validation {
loader
.status_lists
.init_for_iss(
&iss_config,
InitForIssArgs {
validators: &loader.validators,
key_service: &loader.key_service,
token_cache: loader.token_cache.clone(),
logger: loader.logger.clone(),
http_client,
refresh_interval_max: std::time::Duration::from_secs(
loader.jwt_config.status_list_refresh_interval_max,
),
cancel_tkn: loader.jwks_cancel_token.clone(),
},
)
.await?;
}
if loader.jwt_config.jwt_sig_validation
&& let Some(openid_config) = iss_config.openid_config.clone()
{
let notify = Arc::new(Notify::new());
let initial_interval = Duration::from_secs(
loader
.jwt_config
.jwks_refresh_interval
.unwrap_or(DEFAULT_JWKS_REFRESH_INTERVAL_SECS),
);
let min_interval = Duration::from_secs(loader.jwt_config.jwks_refresh_min_interval);
let config_override = loader.jwt_config.jwks_refresh_interval;
{
let mut notifiers = loader
.jwks_refresh_notifiers
.lock()
.expect("acquire jwks_refresh_notifiers lock");
notifiers.insert(iss_claim.clone(), notify.clone());
}
let key_service = loader.key_service.clone();
let logger = loader.logger.clone();
let cancel_tkn = loader.jwks_cancel_token.clone();
spawn_task(keep_jwks_updated(JwksRefreshParams {
key_service,
openid_config,
initial_interval,
min_interval,
config_override,
notify,
logger,
cancel_tkn,
http_client: loader.http_client.clone(),
}));
}
loader.issuer_configs.insert(iss_claim, iss_config);
Ok(())
}
async fn update_openid_config(
iss_config: &mut IssuerConfig,
logger: Option<&Logger>,
http_client: HttpClient,
) -> Result<IssClaim, JwtServiceInitError> {
let openid_config =
OpenIdConfig::get_from_url(iss_config.policy.get_oidc_endpoint(), &http_client)
.await
.inspect_err(|e| {
logger.log_any(JwtLogEntry::new(
format!(
"failed to get openid configuration for trusted issuer: '{}': {}",
iss_config.issuer_id, e
),
Some(LogLevel::ERROR),
));
})?;
let iss_claim = openid_config.issuer.clone();
iss_config.openid_config = Some(openid_config);
Ok(iss_claim)
}
async fn insert_keys(
key_service: &KeyService,
jwt_config: &JwtConfig,
iss_config: &IssuerConfig,
logger: Option<&Logger>,
http_client: HttpClient,
) -> Result<(), KeyServiceError> {
if !jwt_config.jwt_sig_validation {
return Ok(());
}
if let Some(openid_config) = iss_config.openid_config.as_ref() {
key_service
.get_keys_using_oidc(openid_config, logger, http_client)
.await?;
}
Ok(())
}
fn log_load_trusted_issuers_error(logger: Option<&Logger>, error: &JwtServiceInitError) {
logger.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::FATAL,
None,
))
.set_error(error.to_string())
.set_message("Error happened on load_trusted_issuers, it is critical".to_string()),
);
}
struct JwksRefreshParams {
key_service: Arc<KeyService>,
openid_config: OpenIdConfig,
initial_interval: Duration,
min_interval: Duration,
config_override: Option<u64>,
notify: Arc<Notify>,
logger: Option<Logger>,
cancel_tkn: CancellationToken,
http_client: HttpClient,
}
async fn keep_jwks_updated(params: JwksRefreshParams) {
let JwksRefreshParams {
key_service,
openid_config,
initial_interval,
min_interval,
config_override,
notify,
logger,
cancel_tkn,
http_client,
} = params;
let mut interval = initial_interval;
let mut last_refresh = Utc::now() - interval;
let mut backoff = Backoff::default_fixed();
loop {
tokio::select! {
() = sleep(interval) => {},
() = notify.notified() => {
let elapsed = Utc::now().signed_duration_since(last_refresh).num_seconds().cast_unsigned();
if elapsed < min_interval.as_secs() {
sleep(Duration::from_secs(min_interval.as_secs() - elapsed)).await;
}
},
() = cancel_tkn.cancelled() => { break; },
}
logger.log_any(JwtLogEntry::new(
format!(
"refreshing JWKS for issuer '{}'",
openid_config.issuer.as_str()
),
Some(LogLevel::DEBUG),
));
match key_service
.refresh_keys_using_oidc(&openid_config, logger.as_ref(), &http_client)
.await
{
Ok(max_age) => {
last_refresh = Utc::now();
backoff.reset();
if let Some(config_secs) = config_override {
interval = Duration::from_secs(config_secs);
} else if let Some(max_age_secs) = max_age {
interval = if max_age_secs == 0 {
min_interval
} else {
Duration::from_secs(max_age_secs)
};
}
logger.log_any(JwtLogEntry::new(
format!(
"refreshed JWKS for issuer '{}', next refresh in {}s",
openid_config.issuer.as_str(),
interval.as_secs(),
),
Some(LogLevel::INFO),
));
},
Err(err) => {
logger.log_any(JwtLogEntry::new(
format!(
"failed to refresh JWKS for issuer '{}': {}",
openid_config.issuer.as_str(),
err,
),
Some(LogLevel::ERROR),
));
tokio::select! {
_ = backoff.snooze() => {},
() = cancel_tkn.cancelled() => { break; },
}
},
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::http::HttpClientConfig;
use crate::jwt::key_service::DecodingKeyInfo;
use crate::jwt::test_utils::{
MockServer, create_failing_trusted_issuer, create_unreachable_trusted_issuer,
};
use crate::{common::issuer_utils::IssClaim, jwt_config::WorkersCount};
use jsonwebtoken::Algorithm;
use mockito::Server;
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use tokio::time::{Duration, sleep};
use url::Url;
static HTTP_CLIENT: LazyLock<HttpClient> = LazyLock::new(|| {
HttpClient::new(HttpClientConfig {
max_retries: 0,
retry_delay: Duration::from_millis(3),
request_timeout: Duration::from_millis(500),
max_response_size_bytes: None,
})
.expect("http client should be constructed")
});
async fn retry_assert<F, Fut>(mut assertion: F, max_retries: u32, delay_ms: u64, message: &str)
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = bool>,
{
for i in 0..max_retries {
if i != 0 && i < max_retries - 1 {
sleep(Duration::from_millis(delay_ms)).await;
}
if assertion().await {
return;
}
}
panic!("{message} (failed after {max_retries} retries)");
}
fn create_loader(jwt_config: JwtConfig, issuer_count: usize) -> TrustedIssuerLoader {
TrustedIssuerLoader {
jwt_config,
status_lists: StatusListCache::default(),
issuer_configs: Arc::new(IssuerIndex::new()),
validators: Arc::new(JwtValidatorCache::default()),
key_service: Arc::new(KeyService::new()),
token_cache: TokenCache::default(),
logger: None,
loading_state: Arc::new(TrustedIssuerLoadingState::new(issuer_count)),
http_client: HTTP_CLIENT.clone(),
jwks_refresh_notifiers: Arc::new(Mutex::new(HashMap::new())),
jwks_cancel_token: CancellationToken::new(),
}
}
#[tokio::test]
async fn load_single_trusted_issuer_sync() {
let server = MockServer::new_with_defaults().await.unwrap();
let trusted_issuer = server.trusted_issuer();
let jwt_config = JwtConfig {
jwt_sig_validation: false,
jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::default(),
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("test_issuer".to_string(), trusted_issuer);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect("should load single trusted issuer successfully in sync mode");
let issuer_claim = server.issuer();
let trusted_issuer_from_index = loader.issuer_configs.get_trusted_issuer(&issuer_claim);
assert!(
trusted_issuer_from_index.is_some(),
"trusted issuer should be inserted into issuer index"
);
assert!(
!loader.key_service.has_keys(),
"key service should have no keys when signature validation is disabled"
);
}
#[tokio::test]
async fn load_multiple_trusted_issuers_async() {
let server1 = MockServer::new_with_defaults().await.unwrap();
let server2 = MockServer::new_with_defaults().await.unwrap();
let trusted_issuer_1 = server1.trusted_issuer();
let trusted_issuer_2 = server2.trusted_issuer();
let jwt_config = JwtConfig {
jwt_sig_validation: false,
jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::Async {
workers: WorkersCount::new(2),
},
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("issuer1".to_string(), trusted_issuer_1);
trusted_issuers.insert("issuer2".to_string(), trusted_issuer_2);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect("should load multiple trusted issuers successfully in async mode");
let issuer_claim1 = server1.issuer();
let issuer_claim2 = server2.issuer();
retry_assert(
|| async {
loader
.issuer_configs
.get_trusted_issuer(&issuer_claim1)
.is_some()
},
20,
10,
"first trusted issuer should be inserted into issuer index",
)
.await;
retry_assert(
|| async {
loader
.issuer_configs
.get_trusted_issuer(&issuer_claim2)
.is_some()
},
20,
10,
"second trusted issuer should be inserted into issuer index",
)
.await;
assert!(
!loader.key_service.has_keys(),
"key service should have no keys when signature validation is disabled"
);
}
#[tokio::test]
async fn load_trusted_issuer_with_failing_openid_config_sync() {
let server = Server::new_async().await;
let mut trusted_issuer = TrustedIssuer::default();
trusted_issuer.set_oidc_endpoint(
Url::parse(&(server.url() + "/.well-known/openid-configuration")).unwrap(),
);
let jwt_config = JwtConfig {
jwt_sig_validation: true, jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::default(),
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("failing_issuer".to_string(), trusted_issuer);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect_err(
"should fail to load trusted issuer when OpenID endpoint is unreachable in sync mode",
);
}
#[tokio::test]
async fn load_trusted_issuer_with_failing_openid_config_async() {
let server = Server::new_async().await;
let mut trusted_issuer = TrustedIssuer::default();
trusted_issuer.set_oidc_endpoint(
Url::parse(&(server.url() + "/.well-known/openid-configuration")).unwrap(),
);
let jwt_config = JwtConfig {
jwt_sig_validation: true,
jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::Async {
workers: WorkersCount::new(1),
},
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("failing_issuer".to_string(), trusted_issuer);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect("async mode should not fail entire load process when individual issuers cannot be loaded");
}
#[test]
fn check_keys_loaded_warning() {
let jwt_config = JwtConfig {
jwt_sig_validation: true,
jwt_status_validation: false,
..Default::default()
};
let loader = create_loader(jwt_config, 0);
loader.check_keys_loaded();
let key_service_with_keys = KeyService::new();
let jwks =
r#"{"test_issuer":[{"kty":"RSA","kid":"test","alg":"RS256","e":"AQAB","n":"test"}]}"#;
key_service_with_keys.insert_keys_from_str(jwks).unwrap();
let loader_with_keys = TrustedIssuerLoader {
jwt_config: JwtConfig {
jwt_sig_validation: true,
jwt_status_validation: false,
..Default::default()
},
status_lists: StatusListCache::default(),
issuer_configs: Arc::new(IssuerIndex::new()),
validators: Arc::new(JwtValidatorCache::default()),
key_service: Arc::new(key_service_with_keys),
token_cache: TokenCache::default(),
logger: None,
loading_state: Arc::new(TrustedIssuerLoadingState::new(0)),
http_client: HTTP_CLIENT.clone(),
jwks_refresh_notifiers: Arc::new(Mutex::new(HashMap::new())),
jwks_cancel_token: CancellationToken::new(),
};
loader_with_keys.check_keys_loaded();
}
#[tokio::test]
async fn load_with_jwks_config() {
let server = MockServer::new_with_defaults().await.unwrap();
let trusted_issuer = server.trusted_issuer();
let jwks =
r#"{"test_issuer":[{"kty":"RSA","kid":"test","alg":"RS256","e":"AQAB","n":"test"}]}"#;
let jwt_config = JwtConfig {
jwks: Some(jwks.to_string()),
jwt_sig_validation: true,
jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::default(),
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("test_issuer".to_string(), trusted_issuer);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect(
"should load trusted issuer successfully when JWKS is provided in configuration",
);
assert!(
loader.key_service.has_keys(),
"key service should have keys after loading JWKS from configuration"
);
let jwks_key_info = DecodingKeyInfo {
issuer: Some(IssClaim::new("test_issuer")),
kid: Some("test".to_string()),
algorithm: Algorithm::RS256,
};
assert!(
loader.key_service.get_key(&jwks_key_info).is_some(),
"JWKS key from configuration should be stored in key service"
);
let openid_key_info = DecodingKeyInfo {
issuer: Some(server.issuer()),
kid: Some("some_hs256_key".to_string()),
algorithm: Algorithm::HS256,
};
assert!(
loader.key_service.get_key(&openid_key_info).is_some(),
"OpenID config key from mock server should be stored in key service"
);
}
#[tokio::test]
async fn load_trusted_issuer_with_signature_validation() {
let server = MockServer::new_with_defaults().await.unwrap();
let trusted_issuer = server.trusted_issuer();
let jwt_config = JwtConfig {
jwt_sig_validation: true,
jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::default(),
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("test_issuer".to_string(), trusted_issuer);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect("should load trusted issuer successfully with signature validation enabled");
let issuer_claim = server.issuer();
let trusted_issuer_from_index = loader.issuer_configs.get_trusted_issuer(&issuer_claim);
assert!(
trusted_issuer_from_index.is_some(),
"trusted issuer should be inserted into issuer index"
);
assert!(
loader.key_service.has_keys(),
"key service should have keys after loading OpenID configuration"
);
let key_info = DecodingKeyInfo {
issuer: Some(server.issuer()),
kid: Some("some_hs256_key".to_string()),
algorithm: Algorithm::HS256,
};
assert!(
loader.key_service.get_key(&key_info).is_some(),
"HS256 key from OpenID configuration should be stored in key service"
);
}
#[tokio::test]
async fn load_trusted_issuer_with_status_validation_sync() {
let mut server = MockServer::new_with_defaults().await.unwrap();
let client = reqwest::Client::new();
let oidc_url = server.openid_config_endpoint().unwrap();
let _ = client.get(oidc_url.as_str()).send().await.unwrap();
server.generate_status_list_endpoint(1u8.try_into().unwrap(), &[0b1111_1110], None);
server.update_openid_config_with_status_list_endpoint();
let trusted_issuer = server.trusted_issuer();
let jwt_config = JwtConfig {
jwt_sig_validation: true,
jwt_status_validation: true,
trusted_issuer_loader: TrustedIssuerLoaderConfig::default(),
signature_algorithms_supported: std::collections::HashSet::from([Algorithm::HS256]),
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("test_issuer".to_string(), trusted_issuer);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect("should load trusted issuer successfully with status validation enabled");
let issuer_claim = server.issuer();
let trusted_issuer_from_index = loader.issuer_configs.get_trusted_issuer(&issuer_claim);
assert!(
trusted_issuer_from_index.is_some(),
"trusted issuer should be inserted into issuer index"
);
let status_list_uri = server.status_list_endpoint().unwrap().to_string();
let status_lists = loader.status_lists.status_lists.read().unwrap();
assert!(
status_lists.contains_key(&status_list_uri),
"status list cache should contain the status list endpoint"
);
}
#[tokio::test]
async fn load_multiple_trusted_issuers_async_with_signature_validation() {
let server1 = MockServer::new_with_defaults().await.unwrap();
let server2 = MockServer::new_with_defaults().await.unwrap();
let trusted_issuer_1 = server1.trusted_issuer();
let trusted_issuer_2 = server2.trusted_issuer();
let jwt_config = JwtConfig {
jwt_sig_validation: true,
jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::Async {
workers: WorkersCount::new(2),
},
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("issuer1".to_string(), trusted_issuer_1);
trusted_issuers.insert("issuer2".to_string(), trusted_issuer_2);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect("should load multiple trusted issuers successfully in async mode with signature validation");
let issuer_claim1 = server1.issuer();
let issuer_claim2 = server2.issuer();
retry_assert(
|| async {
loader
.issuer_configs
.get_trusted_issuer(&issuer_claim1)
.is_some()
},
20,
10,
"first trusted issuer should be inserted into issuer index",
)
.await;
retry_assert(
|| async {
loader
.issuer_configs
.get_trusted_issuer(&issuer_claim2)
.is_some()
},
20,
10,
"second trusted issuer should be inserted into issuer index",
)
.await;
retry_assert(
|| async { loader.key_service.has_keys() },
20,
10,
"key service should have keys after loading OpenID configurations in async mode",
)
.await;
let key_info1 = DecodingKeyInfo {
issuer: Some(server1.issuer()),
kid: Some("some_hs256_key".to_string()),
algorithm: Algorithm::HS256,
};
retry_assert(
|| async { loader.key_service.get_key(&key_info1).is_some() },
20,
10,
"HS256 key from first OpenID configuration should be stored in key service",
)
.await;
let key_info2 = DecodingKeyInfo {
issuer: Some(server2.issuer()),
kid: Some("some_hs256_key".to_string()),
algorithm: Algorithm::HS256,
};
retry_assert(
|| async { loader.key_service.get_key(&key_info2).is_some() },
20,
10,
"HS256 key from second OpenID configuration should be stored in key service",
)
.await;
}
#[tokio::test]
async fn load_max_workers_concurrent() {
const NUM_ISSUERS: usize = 10;
let mut servers = Vec::new();
for _ in 0..NUM_ISSUERS {
servers.push(MockServer::new_with_defaults().await.unwrap());
}
let jwt_config = JwtConfig {
jwt_sig_validation: true,
jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::Async {
workers: WorkersCount::new(NUM_ISSUERS),
},
..Default::default()
};
let mut trusted_issuers = HashMap::new();
for (i, server) in servers.iter().enumerate() {
trusted_issuers.insert(format!("issuer{i}"), server.trusted_issuer());
}
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect("should load multiple trusted issuers successfully with max workers");
for (i, server) in servers.iter().enumerate() {
let issuer_claim = server.issuer();
retry_assert(
|| async {
loader
.issuer_configs
.get_trusted_issuer(&issuer_claim)
.is_some()
},
20,
10,
&format!("trusted issuer {i} should be inserted into issuer index"),
)
.await;
}
assert!(
loader.key_service.has_keys(),
"key service should have keys when signature validation is enabled"
);
}
#[tokio::test]
async fn load_single_worker_many_issuers() {
const NUM_ISSUERS: usize = 5;
let mut servers = Vec::new();
for _ in 0..NUM_ISSUERS {
servers.push(MockServer::new_with_defaults().await.unwrap());
}
let jwt_config = JwtConfig {
jwt_sig_validation: false,
jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::Sync {
workers: WorkersCount::new(1),
},
..Default::default()
};
let mut trusted_issuers = HashMap::new();
for (i, server) in servers.iter().enumerate() {
trusted_issuers.insert(format!("issuer{i}"), server.trusted_issuer());
}
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect("should load many trusted issuers successfully with single worker");
for (i, server) in servers.iter().enumerate() {
let issuer_claim = server.issuer();
assert!(
loader
.issuer_configs
.get_trusted_issuer(&issuer_claim)
.is_some(),
"trusted issuer {i} should be inserted into issuer index"
);
}
assert!(
!loader.key_service.has_keys(),
"key service should have no keys when signature validation is disabled"
);
}
#[tokio::test]
async fn async_loading_returns_immediately() {
let server = MockServer::new_with_defaults().await.unwrap();
let trusted_issuer = server.trusted_issuer();
let jwt_config = JwtConfig {
jwt_sig_validation: false,
jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::Async {
workers: WorkersCount::new(1),
},
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("test_issuer".to_string(), trusted_issuer);
let loader = create_loader(jwt_config, trusted_issuers.len());
let start = tokio::time::Instant::now();
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect("async loading should return Ok immediately");
let expected_start_delay = Duration::from_millis(1);
let elapsed = tokio::time::Instant::now().duration_since(start);
assert!(
elapsed < expected_start_delay,
"Async loading should return quickly (took {}ms), indicating non-blocking behavior",
elapsed.as_millis()
);
let issuer_claim = server.issuer();
retry_assert(
|| async {
loader
.issuer_configs
.get_trusted_issuer(&issuer_claim)
.is_some()
},
20,
10,
"trusted issuer should eventually be inserted into issuer index via async loading",
)
.await;
}
#[tokio::test]
async fn load_trusted_issuer_with_invalid_url_sync() {
let valid_server = MockServer::new_with_defaults().await.unwrap();
let valid_issuer = valid_server.trusted_issuer();
let mut invalid_issuer = TrustedIssuer::default();
invalid_issuer.set_oidc_endpoint(
Url::parse("invalid://example.com/.well-known/openid-configuration").unwrap(),
);
let jwt_config = JwtConfig {
jwt_sig_validation: true, jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::default(),
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("valid_issuer".to_string(), valid_issuer);
trusted_issuers.insert("invalid_issuer".to_string(), invalid_issuer);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect_err("sync mode should return error when an issuer has invalid URL");
let valid_issuer_claim = valid_server.issuer();
assert!(
loader
.issuer_configs
.get_trusted_issuer(&valid_issuer_claim)
.is_some(),
"valid issuer should be loaded even when another issuer fails in sync mode"
);
}
#[tokio::test]
async fn load_trusted_issuer_with_invalid_url_async() {
let valid_server1 = MockServer::new_with_defaults().await.unwrap();
let valid_server2 = MockServer::new_with_defaults().await.unwrap();
let valid_issuer1 = valid_server1.trusted_issuer();
let valid_issuer2 = valid_server2.trusted_issuer();
let mut invalid_issuer = TrustedIssuer::default();
invalid_issuer.set_oidc_endpoint(
Url::parse("invalid://example.com/.well-known/openid-configuration").unwrap(),
);
let jwt_config = JwtConfig {
jwt_sig_validation: true,
jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::Async {
workers: WorkersCount::new(2),
},
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("valid_issuer1".to_string(), valid_issuer1);
trusted_issuers.insert("valid_issuer2".to_string(), valid_issuer2);
trusted_issuers.insert("invalid_issuer".to_string(), invalid_issuer);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect(
"async mode should not fail entire load process when an issuer has invalid URL",
);
let valid_issuer_claim1 = valid_server1.issuer();
let valid_issuer_claim2 = valid_server2.issuer();
retry_assert(
|| async {
loader
.issuer_configs
.get_trusted_issuer(&valid_issuer_claim1)
.is_some()
},
20,
10,
"first valid issuer should be inserted into issuer index",
)
.await;
retry_assert(
|| async {
loader
.issuer_configs
.get_trusted_issuer(&valid_issuer_claim2)
.is_some()
},
20,
10,
"second valid issuer should be inserted into issuer index",
)
.await;
}
#[tokio::test]
async fn failed_trusted_issuer_ids_tracks_failures() {
let valid_server = MockServer::new_with_defaults().await.unwrap();
let valid_issuer = valid_server.trusted_issuer();
let failing_server = MockServer::new_with_failing_oidc().await.unwrap();
let failing_issuer = failing_server.trusted_issuer();
let unreachable_issuer = create_unreachable_trusted_issuer("unreachable_issuer");
let jwt_config = JwtConfig {
jwt_sig_validation: true, jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::default(),
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("valid_issuer".to_string(), valid_issuer);
trusted_issuers.insert("failing_issuer".to_string(), failing_issuer);
trusted_issuers.insert("unreachable_issuer".to_string(), unreachable_issuer);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect_err("sync mode should return error when issuers fail");
let valid_issuer_claim = valid_server.issuer();
assert!(
loader
.issuer_configs
.get_trusted_issuer(&valid_issuer_claim)
.is_some(),
"valid issuer should be loaded even when other issuers fail"
);
let failed_ids = loader.loading_state.failed_issuers();
assert_eq!(
failed_ids.len(),
2,
"should have 2 failed issuers (failing_issuer and unreachable_issuer)"
);
assert!(
failed_ids.contains("failing_issuer"),
"failed_issuers should contain 'failing_issuer'"
);
assert!(
failed_ids.contains("unreachable_issuer"),
"failed_issuers should contain 'unreachable_issuer'"
);
assert!(
!failed_ids.contains("valid_issuer"),
"failed_issuers should not contain 'valid_issuer'"
);
assert_eq!(
loader.loading_state.total_issuers(),
3,
"loading state should track total issuers"
);
assert_eq!(
loader.loading_state.failed_issuers().len(),
2,
"loading state should have 1 failed issuer"
);
}
#[tokio::test]
async fn failed_trusted_issuer_ids_async_mode() {
let valid_server = MockServer::new_with_defaults().await.unwrap();
let valid_issuer = valid_server.trusted_issuer();
let failing_issuer = create_failing_trusted_issuer("failing_issuer");
let jwt_config = JwtConfig {
jwt_sig_validation: true,
jwt_status_validation: false,
trusted_issuer_loader: TrustedIssuerLoaderConfig::Async {
workers: WorkersCount::new(2),
},
..Default::default()
};
let mut trusted_issuers = HashMap::new();
trusted_issuers.insert("valid_issuer".to_string(), valid_issuer);
trusted_issuers.insert("failing_issuer".to_string(), failing_issuer);
let loader = create_loader(jwt_config, trusted_issuers.len());
let result = loader.load_trusted_issuers(trusted_issuers).await;
result.expect("async mode should not fail entire load process");
let valid_issuer_claim = valid_server.issuer();
retry_assert(
|| async {
loader
.issuer_configs
.get_trusted_issuer(&valid_issuer_claim)
.is_some()
},
20,
10,
"valid issuer should eventually be loaded via async loading",
)
.await;
let failed_ids = loader.loading_state.failed_issuers();
assert_eq!(
failed_ids.len(),
1,
"should have 1 failed issuer (failing_issuer)"
);
assert!(
failed_ids.contains("failing_issuer"),
"failed_issuers should contain 'failing_issuer'"
);
assert!(
!failed_ids.contains("valid_issuer"),
"failed_issuers should not contain 'valid_issuer'"
);
assert_eq!(
loader.loading_state.total_issuers(),
2,
"loading state should track total issuers"
);
assert_eq!(
loader.loading_state.failed_issuers().len(),
1,
"loading state should have 1 failed issuer"
);
}
}