use std::fmt::{self, Debug, Formatter};
use std::str::FromStr;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use bytes::Bytes;
use http_body_util::{BodyExt, Full, Limited};
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use hyper_util::client::legacy::{Client, connect::HttpConnector};
use hyper_util::rt::TokioExecutor;
use jsonwebtoken::jwk::{AlgorithmParameters, EllipticCurve, Jwk, JwkSet, KeyOperations, PublicKeyUse};
use jsonwebtoken::{Algorithm, DecodingKey, TokenData, Validation};
use salvo_core::Depot;
use salvo_core::http::{HeaderValue, header::{AGE, CACHE_CONTROL}, uri::Uri};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use tokio::sync::{Notify, RwLock};
use tokio::time::timeout;
use super::{JwtAuthDecoder, JwtAuthError};
mod cache;
pub use cache::{CachePolicy, CacheState, JwkSetStore, UpdateAction};
pub(super) type HyperClient = Client<HttpsConnector<HttpConnector>, Full<Bytes>>;
const OIDC_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
const OIDC_CONFIG_MAX_BYTES: usize = 64 * 1024;
const OIDC_JWKS_MAX_BYTES: usize = 1024 * 1024;
const OIDC_REVALIDATION_INTERVAL: Duration = Duration::from_secs(1);
fn default_http_client() -> Result<HyperClient, JwtAuthError> {
let https = HttpsConnectorBuilder::new()
.with_provider_and_native_roots(default_rustls_crypto_provider())
.map_err(|error| JwtAuthError::NativeRootCerts(error.to_string()))?
.https_only()
.enable_http1()
.enable_http2()
.build();
Ok(Client::builder(TokioExecutor::new()).build(https))
}
fn default_rustls_crypto_provider() -> Arc<rustls::crypto::CryptoProvider> {
if let Some(provider) = rustls::crypto::CryptoProvider::get_default() {
return Arc::clone(provider);
}
#[cfg(feature = "aws-lc-rs")]
{
Arc::new(rustls::crypto::aws_lc_rs::default_provider())
}
#[cfg(all(not(feature = "aws-lc-rs"), feature = "ring"))]
{
Arc::new(rustls::crypto::ring::default_provider())
}
#[cfg(not(any(feature = "aws-lc-rs", feature = "ring")))]
unreachable!("a crypto provider feature is required")
}
fn resolve_or_else<T, E>(
provided: Option<T>,
build_default: impl FnOnce() -> Result<T, E>,
) -> Result<T, E> {
match provided {
Some(value) => Ok(value),
None => build_default(),
}
}
#[derive(Clone)]
pub struct OidcDecoder {
issuer: String,
http_client: HyperClient,
cache: Arc<RwLock<JwkSetStore>>,
cache_state: Arc<CacheState>,
notifier: Arc<Notify>,
}
impl Debug for OidcDecoder {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("OidcDecoder")
.field("issuer", &self.issuer)
.field("http_client", &self.http_client)
.field("cache_state", &self.cache_state)
.finish()
}
}
impl JwtAuthDecoder for OidcDecoder {
type Error = JwtAuthError;
async fn decode<C>(&self, token: &str, _depot: &mut Depot) -> Result<TokenData<C>, Self::Error>
where
C: DeserializeOwned + Clone,
{
let header = jsonwebtoken::decode_header(token)?;
let kid = header.kid.ok_or(JwtAuthError::MissingKid)?;
let decoding_key = self.get_kid_retry(kid).await?;
let decoded = decoding_key.decode(token)?;
Ok(decoded)
}
}
pub struct DecoderBuilder<T>
where
T: AsRef<str>,
{
pub issuer: T,
pub http_client: Option<HyperClient>,
pub validation: Option<Validation>,
pub audiences: Option<Vec<String>>,
pub allow_symmetric_jwks: bool,
}
impl<T: AsRef<str>> Debug for DecoderBuilder<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("DecoderBuilder")
.field("http_client", &self.http_client)
.field("validation", &self.validation)
.field("audiences", &self.audiences)
.field("allow_symmetric_jwks", &self.allow_symmetric_jwks)
.finish()
}
}
impl<T> DecoderBuilder<T>
where
T: AsRef<str>,
{
#[must_use]
pub fn new(issuer: T) -> Self {
Self {
issuer,
http_client: None,
validation: None,
audiences: None,
allow_symmetric_jwks: false,
}
}
#[must_use]
pub fn http_client(mut self, client: HyperClient) -> Self {
self.http_client = Some(client);
self
}
#[must_use]
pub fn validation(mut self, validation: Validation) -> Self {
self.validation = Some(validation);
self
}
#[must_use]
pub fn audience(mut self, audience: impl Into<String>) -> Self {
self.audiences = Some(vec![audience.into()]);
self
}
#[must_use]
pub fn audiences<I, S>(mut self, audiences: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.audiences = Some(audiences.into_iter().map(Into::into).collect());
self
}
#[must_use]
pub fn allow_symmetric_jwks(mut self, allow: bool) -> Self {
self.allow_symmetric_jwks = allow;
self
}
pub async fn build(self) -> Result<OidcDecoder, JwtAuthError> {
let Self {
issuer,
http_client,
validation,
audiences,
allow_symmetric_jwks,
} = self;
let raw_issuer = issuer.as_ref();
let jwks = JwkSet { keys: Vec::new() };
let validation = configure_oidc_validation(
validation.unwrap_or_else(default_oidc_validation),
raw_issuer,
audiences,
)?;
let issuer = raw_issuer.to_owned();
let cache = Arc::new(RwLock::new(
JwkSetStore::new(jwks, CachePolicy::default(), validation)
.with_allow_symmetric_jwks(allow_symmetric_jwks),
));
let cache_state = Arc::new(CacheState::new());
let http_client = resolve_or_else(http_client, default_http_client)?;
let decoder = OidcDecoder {
issuer,
http_client,
cache,
cache_state,
notifier: Arc::new(Notify::new()),
};
decoder.update_cache().await?;
Ok(decoder)
}
}
impl OidcDecoder {
pub fn new(
issuer: impl AsRef<str>,
audience: impl Into<String>,
) -> impl Future<Output = Result<Self, JwtAuthError>> {
Self::builder(issuer).audience(audience).build()
}
pub fn builder<T>(issuer: T) -> DecoderBuilder<T>
where
T: AsRef<str>,
{
DecoderBuilder::new(issuer)
}
fn config_url(&self) -> String {
format!(
"{}/.well-known/openid-configuration",
self.issuer.trim_end_matches('/')
)
}
async fn get_config(&self) -> Result<OidcConfig, JwtAuthError> {
let res = timeout(
OIDC_HTTP_TIMEOUT,
self.http_client.get(self.config_url().parse::<Uri>()?),
)
.await
.map_err(|_| JwtAuthError::OidcHttpTimeout)??;
if !res.status().is_success() {
return Err(JwtAuthError::OidcHttpStatus(res.status()));
}
let body = collect_limited_body(res.into_body(), OIDC_CONFIG_MAX_BYTES).await?;
let config: OidcConfig = serde_json::from_slice(&body)?;
if config.issuer != self.issuer {
return Err(JwtAuthError::DiscoverError);
}
Ok(config)
}
async fn jwks_uri(&self) -> Result<String, JwtAuthError> {
Ok(self.get_config().await?.jwks_uri)
}
async fn get_jwks(&self) -> Result<JwkSetFetch, JwtAuthError> {
let uri = self.jwks_uri().await?.parse::<Uri>()?;
tracing::debug!("Requesting JWKS From Uri: {uri}");
let request_started = Instant::now();
let res = timeout(OIDC_HTTP_TIMEOUT, self.http_client.get(uri))
.await
.map_err(|_| JwtAuthError::OidcHttpTimeout)??;
if !res.status().is_success() {
return Err(JwtAuthError::OidcHttpStatus(res.status()));
}
let cache_policy = Some(CachePolicy::from_header_values(
res.headers().get_all(CACHE_CONTROL).iter(),
));
let age = response_age(res.headers().get(AGE));
let jwks = collect_limited_body(res.into_body(), OIDC_JWKS_MAX_BYTES).await?;
let age = age.saturating_add(request_started.elapsed().as_secs());
let fetched_at = current_time().saturating_sub(age);
Ok(JwkSetFetch {
jwks: serde_json::from_slice(&jwks)?,
cache_policy,
fetched_at,
})
}
async fn update_cache(&self) -> Result<UpdateAction, JwtAuthError> {
let fetch = self.get_jwks().await;
match fetch {
Ok(fetch) => {
self.cache_state.set_last_update(fetch.fetched_at);
tracing::info!("Set Last update to {:#?}", fetch.fetched_at);
self.cache_state.set_is_error(false);
let read = self.cache.read().await;
if read.jwks == fetch.jwks
&& fetch.cache_policy.unwrap_or(read.cache_policy) == read.cache_policy
{
return Ok(UpdateAction::NoUpdate);
}
drop(read);
let mut write = self.cache.write().await;
Ok(write.update_fetch(fetch))
}
Err(e) => {
self.cache_state.set_is_error(true);
Err(e)
}
}
}
fn revalidate_cache(&self) {
if self
.cache_state
.begin_revalidation_after(OIDC_REVALIDATION_INTERVAL)
{
tracing::info!("Spawning Task to re-validate JWKS");
let decoder = self.clone();
tokio::task::spawn(async move {
let _ = decoder.update_cache().await;
decoder.cache_state.finish_revalidation();
decoder.notifier.notify_waiters();
});
}
}
async fn wait_update(&self) {
loop {
let notified = self.notifier.notified();
if !self.cache_state.is_revalidating() {
break;
}
notified.await;
}
}
#[allow(clippy::future_not_send)]
async fn get_kid_retry(&self, kid: impl AsRef<str>) -> Result<Arc<DecodingInfo>, JwtAuthError> {
let kid = kid.as_ref();
if let Ok(Some(key)) = self.get_kid(kid).await {
Ok(key)
} else {
self.revalidate_cache();
self.wait_update().await;
self.get_kid(kid).await?.ok_or(JwtAuthError::CacheError)
}
}
#[allow(clippy::future_not_send)]
async fn get_kid(&self, kid: &str) -> Result<Option<Arc<DecodingInfo>>, JwtAuthError> {
let read_cache = self.cache.read().await;
let fetched = self.cache_state.last_update();
let max_age_secs = read_cache.cache_policy.max_age.as_secs();
let max_age = fetched + max_age_secs;
let now = current_time();
let cached_key = read_cache.get_key(kid);
if now <= max_age {
return Ok(cached_key);
}
if let Some(swr) = read_cache.cache_policy.stale_while_revalidate {
if now <= swr.as_secs() + max_age {
self.revalidate_cache();
return Ok(cached_key);
}
}
if let Some(swr_err) = read_cache.cache_policy.stale_if_error {
if now <= swr_err.as_secs() + max_age && self.cache_state.is_error() {
self.revalidate_cache();
return Ok(cached_key);
}
}
drop(read_cache);
tracing::info!("Returning None: {now} - {max_age}");
Err(JwtAuthError::CacheError)
}
}
fn default_oidc_validation() -> Validation {
Validation {
algorithms: vec![
Algorithm::HS256,
Algorithm::HS384,
Algorithm::HS512,
Algorithm::ES256,
Algorithm::ES384,
Algorithm::RS256,
Algorithm::RS384,
Algorithm::RS512,
Algorithm::PS256,
Algorithm::PS384,
Algorithm::PS512,
Algorithm::EdDSA,
],
..Validation::default()
}
}
fn configure_oidc_validation(
mut validation: Validation,
issuer: &str,
audiences: Option<Vec<String>>,
) -> Result<Validation, JwtAuthError> {
if validation.iss.is_none() {
validation.set_issuer(&[issuer]);
}
validation.required_spec_claims.insert("iss".to_owned());
if let Some(audiences) = audiences {
if audiences.is_empty() {
return Err(JwtAuthError::MissingOidcAudience);
}
validation.set_audience(&audiences);
validation.validate_aud = true;
}
if validation.validate_aud {
if validation.aud.is_none() {
return Err(JwtAuthError::MissingOidcAudience);
}
validation.required_spec_claims.insert("aud".to_owned());
}
Ok(validation)
}
pub struct DecodingInfo {
key: DecodingKey,
validation: Validation,
}
impl Debug for DecodingInfo {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("DecodingInfo")
.field("validation", &self.validation)
.finish()
}
}
impl DecodingInfo {
fn new(key: DecodingKey, alg: Option<Algorithm>, validation_settings: &Validation) -> Self {
let mut validation = validation_settings.clone();
validation.algorithms.retain(|allowed| alg.is_none_or(|alg| *allowed == alg));
Self {
key,
validation,
}
}
fn decode<T>(&self, token: &str) -> Result<TokenData<T>, JwtAuthError>
where
T: for<'de> serde::de::Deserialize<'de> + Clone,
{
match jsonwebtoken::decode::<T>(token, &self.key, &self.validation) {
Ok(data) => Ok(data),
Err(e) => {
tracing::error!(error = ?e, "error decoding jwt token");
Err(JwtAuthError::from(e))
}
}
}
}
#[derive(Debug)]
pub(crate) struct JwkSetFetch {
jwks: JwkSet,
cache_policy: Option<CachePolicy>,
fetched_at: u64,
}
#[derive(Debug, Deserialize)]
struct OidcConfig {
issuer: String,
jwks_uri: String,
}
async fn collect_limited_body(
body: salvo_core::hyper::body::Incoming,
max_size: usize,
) -> Result<Bytes, JwtAuthError> {
let limited = Limited::new(body, max_size);
let collected = timeout(OIDC_HTTP_TIMEOUT, limited.collect())
.await
.map_err(|_| JwtAuthError::OidcHttpTimeout)?
.map_err(|error| {
if error.is::<http_body_util::LengthLimitError>() {
JwtAuthError::OidcResponseTooLarge(max_size)
} else {
JwtAuthError::OidcBodyRead(error)
}
})?;
Ok(collected.to_bytes())
}
pub(crate) fn decode_jwk(
jwk: &Jwk,
validation: &Validation,
allow_symmetric_jwks: bool,
) -> Result<(String, DecodingInfo), JwtAuthError> {
if jwk
.common
.public_key_use
.as_ref()
.is_some_and(|usage| *usage != PublicKeyUse::Signature)
|| jwk.common.key_operations.as_ref().is_some_and(|operations| {
!operations.contains(&KeyOperations::Verify)
|| operations
.iter()
.any(|operation| !matches!(operation, KeyOperations::Sign | KeyOperations::Verify))
})
{
return Err(JwtAuthError::InvalidJwk);
}
let kid = jwk.common.key_id.clone();
let alg = jwk.common.key_algorithm
.map(|alg| Algorithm::from_str(alg.to_string().as_str()))
.transpose()?;
let mut validation = validation.clone();
validation.algorithms.retain(|algorithm| match (&jwk.algorithm, algorithm) {
(AlgorithmParameters::RSA(_), Algorithm::RS256 | Algorithm::RS384 | Algorithm::RS512
| Algorithm::PS256 | Algorithm::PS384 | Algorithm::PS512) => true,
(AlgorithmParameters::EllipticCurve(params), Algorithm::ES256) => params.curve == EllipticCurve::P256,
(AlgorithmParameters::EllipticCurve(params), Algorithm::ES384) => params.curve == EllipticCurve::P384,
(AlgorithmParameters::OctetKey(_), Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512) => true,
(AlgorithmParameters::OctetKeyPair(params), Algorithm::EdDSA) => params.curve == EllipticCurve::Ed25519,
_ => false,
});
let dec_key = match jwk.algorithm {
jsonwebtoken::jwk::AlgorithmParameters::EllipticCurve(ref params) => {
let x_cmp = b64_decode(¶ms.x)?;
let y_cmp = b64_decode(¶ms.y)?;
let mut public_key = Vec::with_capacity(1 + params.x.len() + params.y.len());
public_key.push(0x04);
public_key.extend_from_slice(&x_cmp);
public_key.extend_from_slice(&y_cmp);
Some(DecodingKey::from_ec_der(&public_key))
}
jsonwebtoken::jwk::AlgorithmParameters::RSA(ref params) => {
DecodingKey::from_rsa_components(¶ms.n, ¶ms.e).ok()
}
jsonwebtoken::jwk::AlgorithmParameters::OctetKey(ref params) => {
if !allow_symmetric_jwks {
tracing::warn!(
kid = ?kid,
"rejecting symmetric (oct) JWK; OIDC JWKS should publish public keys. \
Enable allow_symmetric_jwks(true) on the decoder builder to opt in."
);
return Err(JwtAuthError::InvalidJwk);
}
Some(DecodingKey::from_secret(&b64_decode(¶ms.value)?))
}
jsonwebtoken::jwk::AlgorithmParameters::OctetKeyPair(ref params) => {
let der = b64_decode(¶ms.x)?;
Some(DecodingKey::from_ed_der(&der))
}
_ => None,
};
match (kid, dec_key) {
(Some(kid), Some(dec_key)) => {
let info = DecodingInfo::new(dec_key, alg, &validation);
Ok((kid, info))
}
_ => Err(JwtAuthError::InvalidJwk),
}
}
fn b64_decode<T: AsRef<[u8]>>(input: T) -> Result<Vec<u8>, base64::DecodeError> {
URL_SAFE_NO_PAD.decode(input.as_ref())
}
fn response_age(value: Option<&HeaderValue>) -> u64 {
let Some(value) = value.and_then(|value| value.as_bytes().split(|byte| *byte == b',').next()) else {
return 0;
};
let value = value.trim_ascii();
if value.is_empty() || !value.iter().all(u8::is_ascii_digit) {
return 0;
}
value.iter().fold(0u64, |age, byte| age.saturating_mul(10).saturating_add(u64::from(byte - b'0')))
}
pub(crate) fn current_time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use std::convert::Infallible;
use std::sync::atomic::{AtomicUsize, Ordering};
use hyper_util::rt::TokioIo;
use salvo_core::hyper::{Response, server::conn::http1, service::service_fn};
use serde_json::json;
use super::*;
#[test]
fn symmetric_jwk_uses_unpadded_base64url_key_material() {
let _ = crate::install_crypto_provider();
for (algorithm, secret) in [
(Algorithm::HS256, vec![1; 32]),
(Algorithm::HS384, vec![1; 48]),
(Algorithm::HS512, vec![1; 64]),
(Algorithm::HS256, vec![251; 33]),
(Algorithm::HS256, vec![255; 33]),
] {
let jwk: Jwk = serde_json::from_value(json!({
"kty": "oct", "kid": "local-test", "alg": algorithm,
"k": URL_SAFE_NO_PAD.encode(&secret),
})).unwrap();
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::new(algorithm),
&json!({"sub": "local-user", "exp": current_time() + 300}),
&jsonwebtoken::EncodingKey::from_secret(&secret),
).unwrap();
let validation = Validation::new(algorithm);
assert!(matches!(decode_jwk(&jwk, &validation, false), Err(JwtAuthError::InvalidJwk)));
let (_, info) = decode_jwk(&jwk, &validation, true)
.expect("a standards-compliant symmetric JWK must be accepted when enabled");
assert_eq!(info.decode::<serde_json::Value>(&token).unwrap().claims["sub"], "local-user");
}
}
#[test]
fn symmetric_jwk_rejects_non_base64url_key_material() {
for key in ["A", "AA==", "AA+A", "AA/A", "not base64url"] {
let jwk: Jwk = serde_json::from_value(json!({
"kty": "oct", "kid": "local-test", "alg": "HS256", "k": key,
})).unwrap();
assert!(decode_jwk(&jwk, &Validation::new(Algorithm::HS256), true).is_err());
}
}
fn test_http_client() -> HyperClient {
let tls = rustls::ClientConfig::builder_with_provider(default_rustls_crypto_provider())
.with_safe_default_protocol_versions().unwrap()
.with_root_certificates(rustls::RootCertStore::empty()).with_no_client_auth();
let connector = HttpsConnectorBuilder::new().with_tls_config(tls)
.https_or_http().enable_http1().build();
Client::builder(TokioExecutor::new()).build(connector)
}
#[test]
fn response_age_uses_first_delta_seconds_and_saturates_overflow() {
assert_eq!(response_age(None), 0);
for (value, expected) in [
("0", 0), ("3600", 3600), (" 3599 \t", 3599), ("12, 999", 12),
("+12", 0), ("-12", 0), ("1.2", 0), ("unknown", 0), ("", 0),
("18446744073709551615", u64::MAX),
("18446744073709551616", u64::MAX),
("99999999999999999999999999999999", u64::MAX),
] {
assert_eq!(response_age(Some(&HeaderValue::from_str(value).unwrap())), expected, "{value}");
}
}
#[tokio::test]
async fn later_cache_control_field_limits_failed_refresh_fallback() {
let _ = crate::install_crypto_provider();
for (stale_if_error, allow_stale) in [(0, false), (60, true)] {
timeout(Duration::from_secs(5), async {
struct ServerTask(tokio::task::JoinHandle<()>);
impl Drop for ServerTask {
fn drop(&mut self) {
self.0.abort();
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let issuer = format!("http://{}", listener.local_addr().unwrap());
let discovery = json!({"issuer":issuer,"jwks_uri":format!("{issuer}/jwks")}).to_string();
let unavailable = Arc::new(std::sync::atomic::AtomicBool::new(false));
let fetches = Arc::new(AtomicUsize::new(0));
let (server_unavailable, server_fetches) = (unavailable.clone(), fetches.clone());
let _server = ServerTask(tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let (unavailable, fetches, discovery) = (
server_unavailable.clone(), server_fetches.clone(), discovery.clone(),
);
tokio::spawn(async move {
let service = service_fn(move |req: salvo_core::hyper::Request<_>| {
let jwks = req.uri().path() == "/jwks";
let failed = jwks && unavailable.load(Ordering::SeqCst);
let body = if jwks {
fetches.fetch_add(1, Ordering::SeqCst);
json!({"keys":[{"kty":"oct","kid":"cached","alg":"HS256","k":"c2VjcmV0"}]}).to_string()
} else {
discovery.clone()
};
async move {
Ok::<_, Infallible>(Response::builder()
.status(if failed { 503 } else { 200 })
.header(CACHE_CONTROL, "max-age=1, stale-while-revalidate=0")
.header(CACHE_CONTROL, format!("stale-if-error={stale_if_error}"))
.body(Full::new(Bytes::from(body))).unwrap())
}
});
let _ = http1::Builder::new().serve_connection(TokioIo::new(stream), service).await;
});
}
}));
let decoder = OidcDecoder::builder(issuer)
.audience("test-client")
.allow_symmetric_jwks(true)
.http_client(test_http_client())
.build().await.unwrap();
assert!(decoder.get_kid_retry("cached").await.is_ok());
assert_eq!(fetches.load(Ordering::SeqCst), 1);
unavailable.store(true, Ordering::SeqCst);
decoder.cache_state.set_last_update(current_time() - 3);
let result = decoder.get_kid_retry("cached").await;
assert_eq!(result.is_ok(), allow_stale, "stale-if-error={stale_if_error}");
assert!(decoder.cache_state.is_error());
assert_eq!(fetches.load(Ordering::SeqCst), 2);
assert!(decoder.cache.read().await.get_key("cached").is_some());
}).await.expect("local JWKS fallback test timed out");
}
}
#[tokio::test]
async fn quoted_cache_extensions_do_not_delay_jwks_key_revocation() {
let _ = crate::install_crypto_provider();
struct ServerTask(tokio::task::JoinHandle<()>);
impl Drop for ServerTask {
fn drop(&mut self) {
self.0.abort();
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let issuer = format!("http://{}", listener.local_addr().unwrap());
let discovery = json!({"issuer":issuer,"jwks_uri":format!("{issuer}/jwks")}).to_string();
let revoked = Arc::new(std::sync::atomic::AtomicBool::new(false));
let fetches = Arc::new(AtomicUsize::new(0));
let (server_revoked, server_fetches) = (revoked.clone(), fetches.clone());
let _server = ServerTask(tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let (revoked, fetches, discovery) = (
server_revoked.clone(),
server_fetches.clone(),
discovery.clone(),
);
tokio::spawn(async move {
let service = service_fn(move |req: salvo_core::hyper::Request<_>| {
let body = if req.uri().path() == "/jwks" {
fetches.fetch_add(1, Ordering::SeqCst);
if revoked.load(Ordering::SeqCst) {
json!({"keys":[]})
} else {
json!({"keys":[{"kty":"oct","kid":"initial","alg":"HS256","k":"c2VjcmV0"}]})
}.to_string()
} else {
discovery.clone()
};
async move {
Ok::<_, Infallible>(Response::builder()
.header(CACHE_CONTROL, "max-age=1, stale-while-revalidate=0, extension=\"ignored,max-age=2592000,ignored\"")
.body(Full::new(Bytes::from(body))).unwrap())
}
});
let _ = http1::Builder::new()
.serve_connection(TokioIo::new(stream), service)
.await;
});
}
}));
let mut header = jsonwebtoken::Header::new(Algorithm::HS256);
header.kid = Some("initial".into());
let token = jsonwebtoken::encode(
&header,
&json!({"iss":issuer,"aud":"test-client","exp":current_time()+3600}),
&jsonwebtoken::EncodingKey::from_secret(b"secret"),
)
.unwrap();
let decoder = OidcDecoder::builder(issuer)
.audience("test-client")
.allow_symmetric_jwks(true)
.http_client(test_http_client())
.build()
.await
.unwrap();
assert!(
decoder
.decode::<serde_json::Value>(&token, &mut Depot::new())
.await
.is_ok()
);
assert_eq!(fetches.load(Ordering::SeqCst), 1);
revoked.store(true, Ordering::SeqCst);
tokio::time::sleep(Duration::from_secs(3)).await;
assert!(
decoder
.decode::<serde_json::Value>(&token, &mut Depot::new())
.await
.is_err(),
"revoked key remained usable after the endpoint's max-age"
);
assert_eq!(fetches.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn discovery_issuer_is_required_and_must_match_before_fetching_jwks() {
for mode in ["mismatch", "missing", "missing-slash", "extra-slash", "match", "tenant"] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let configured = match mode {
"missing-slash" => format!("{base}/"),
"tenant" => format!("{base}/tenant/"),
_ => base.clone(),
};
let mut metadata = json!({"jwks_uri": format!("{base}/jwks")});
if mode != "missing" {
metadata["issuer"] = json!(match mode {
"mismatch" => format!("{base}/other-tenant"),
"missing-slash" => base.clone(),
"extra-slash" => format!("{base}/"),
_ => configured.clone(),
});
}
let metadata = metadata.to_string();
let fetches = Arc::new(AtomicUsize::new(0));
let server_fetches = Arc::clone(&fetches);
let server = tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let metadata = metadata.clone();
let fetches = Arc::clone(&server_fetches);
tokio::spawn(async move {
let service = service_fn(move |req: salvo_core::hyper::Request<_>| {
let body = if req.uri().path() == "/jwks" {
fetches.fetch_add(1, Ordering::Relaxed);
r#"{"keys":[]}"#.to_owned()
} else { metadata.clone() };
async move { Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(body)))) }
});
let _ = http1::Builder::new().serve_connection(TokioIo::new(stream), service).await;
});
}
});
let result = OidcDecoder::builder(&configured).audience("test-client")
.http_client(test_http_client()).build().await;
server.abort();
let expected = matches!(mode, "match" | "tenant");
assert_eq!(result.is_ok(), expected, "discovery mode {mode}");
assert_eq!(fetches.load(Ordering::Relaxed), usize::from(expected), "discovery mode {mode}");
}
}
#[test]
fn jwk_rejects_explicit_non_verification_purposes() {
for usage in [
json!({"use":"enc"}), json!({"use":"unknown"}),
json!({"key_ops":[]}), json!({"key_ops":["encrypt"]}),
json!({"key_ops":["sign"]}), json!({"key_ops":["verify", "encrypt"]}),
json!({"use":"enc", "key_ops":["verify"]}),
json!({"use":"sig", "key_ops":["encrypt"]}),
] {
let mut key = json!({"kty":"oct", "kid":"test", "alg":"HS256", "k":"c2VjcmV0"});
key.as_object_mut().unwrap().extend(usage.as_object().unwrap().clone());
let jwk: Jwk = serde_json::from_value(key).unwrap();
assert!(matches!(decode_jwk(&jwk, &Validation::new(Algorithm::HS256), true), Err(JwtAuthError::InvalidJwk)), "purpose {usage}");
}
}
#[test]
fn jwk_accepts_missing_or_consistent_verification_purposes() {
let _ = crate::install_crypto_provider();
let token = jsonwebtoken::encode(&jsonwebtoken::Header::new(Algorithm::HS256),
&json!({"exp": current_time() + 3600}), &jsonwebtoken::EncodingKey::from_secret(b"secret")).unwrap();
for usage in [json!({}), json!({"use":"sig"}), json!({"key_ops":["verify"]}),
json!({"key_ops":["sign", "verify"]}), json!({"use":"sig", "key_ops":["verify"]})] {
let mut key = json!({"kty":"oct", "kid":"test", "alg":"HS256", "k":"c2VjcmV0"});
key.as_object_mut().unwrap().extend(usage.as_object().unwrap().clone());
let jwk: Jwk = serde_json::from_value(key).unwrap();
let (_, info) = decode_jwk(&jwk, &Validation::new(Algorithm::HS256), true).unwrap();
assert!(info.decode::<serde_json::Value>(&token).is_ok(), "purpose {usage}");
}
}
#[test]
fn refreshed_jwks_excludes_encryption_keys_and_removes_revoked_purposes() {
let mut store = JwkSetStore::new(JwkSet { keys: vec![] }, CachePolicy::default(), Validation::new(Algorithm::HS256))
.with_allow_symmetric_jwks(true);
let signing = json!({"kty":"oct", "kid":"signing", "alg":"HS256", "k":"c2VjcmV0", "use":"sig", "key_ops":["verify"]});
let encryption = json!({"kty":"oct", "kid":"encryption", "alg":"HS256", "k":"c2VjcmV0", "use":"enc", "key_ops":["encrypt"]});
store.update_fetch(JwkSetFetch { jwks: serde_json::from_value(json!({"keys":[signing.clone(), encryption]})).unwrap(),
cache_policy: Some(CachePolicy::default()), fetched_at: current_time() });
assert!(store.get_key("signing").is_some());
assert!(store.get_key("encryption").is_none());
let mut revoked = signing;
revoked["use"] = json!("enc");
revoked["key_ops"] = json!(["encrypt"]);
store.update_fetch(JwkSetFetch { jwks: serde_json::from_value(json!({"keys":[revoked]})).unwrap(),
cache_policy: Some(CachePolicy::default()), fetched_at: current_time() });
assert!(store.get_key("signing").is_none());
}
#[test]
fn explicit_algorithm_policy_rejects_an_otherwise_valid_signature() {
let _ = crate::install_crypto_provider();
let secret = b"test-only-secret";
let claims = json!({"exp": current_time() + 3600});
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::new(Algorithm::HS512),
&claims,
&jsonwebtoken::EncodingKey::from_secret(secret),
)
.unwrap();
let denied = DecodingInfo::new(
DecodingKey::from_secret(secret),
Some(Algorithm::HS512),
&Validation::new(Algorithm::HS256),
);
assert!(denied.decode::<serde_json::Value>(&token).is_err());
let allowed = DecodingInfo::new(
DecodingKey::from_secret(secret),
Some(Algorithm::HS512),
&Validation::new(Algorithm::HS512),
);
assert!(allowed.decode::<serde_json::Value>(&token).is_ok());
let default = DecodingInfo::new(
DecodingKey::from_secret(secret),
Some(Algorithm::HS512),
&default_oidc_validation(),
);
assert!(default.decode::<serde_json::Value>(&token).is_ok());
let mut validation = Validation::new(Algorithm::HS512);
validation.algorithms.clear();
let info = DecodingInfo::new(
DecodingKey::from_secret(secret),
Some(Algorithm::HS512),
&validation,
);
assert!(info.decode::<serde_json::Value>(&token).is_err());
}
#[tokio::test]
async fn unknown_kids_share_a_cooldown_and_rotation_remains_possible() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let issuer = format!("http://{}", listener.local_addr().unwrap());
let discovery = format!(r#"{{"issuer":"{issuer}","jwks_uri":"{issuer}/jwks"}}"#);
let keys = Arc::new(std::sync::RwLock::new(
json!({"keys": [{
"kty": "oct", "kid": "initial", "alg": "HS256", "k": "c2VjcmV0"
}]})
.to_string(),
));
let requests = Arc::new(AtomicUsize::new(0));
let server_keys = Arc::clone(&keys);
let server_requests = Arc::clone(&requests);
let server = tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let keys = Arc::clone(&server_keys);
let requests = Arc::clone(&server_requests);
let discovery = discovery.clone();
tokio::spawn(async move {
let service = service_fn(move |req: salvo_core::hyper::Request<_>| {
let body = if req.uri().path() == "/jwks" {
requests.fetch_add(1, Ordering::Relaxed);
keys.read().unwrap().clone()
} else {
discovery.clone()
};
async move {
Ok::<_, Infallible>(
Response::builder()
.status(if body.is_empty() {
salvo_core::http::StatusCode::SERVICE_UNAVAILABLE
} else {
salvo_core::http::StatusCode::OK
})
.header(CACHE_CONTROL, "max-age=3600")
.body(Full::new(Bytes::from(body)))
.unwrap(),
)
}
});
let _ = http1::Builder::new()
.serve_connection(TokioIo::new(stream), service)
.await;
});
}
});
let tls = rustls::ClientConfig::builder_with_provider(default_rustls_crypto_provider())
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(rustls::RootCertStore::empty())
.with_no_client_auth();
let connector = HttpsConnectorBuilder::new()
.with_tls_config(tls)
.https_or_http()
.enable_http1()
.build();
let client = Client::builder(TokioExecutor::new()).build(connector);
let decoder = OidcDecoder::builder(issuer)
.audience("test-client")
.allow_symmetric_jwks(true)
.http_client(client)
.build()
.await
.unwrap();
assert!(decoder.get_kid_retry("initial").await.is_ok());
for kid in ["missing-a", "missing-b", "missing-c"] {
assert!(decoder.get_kid_retry(kid).await.is_err());
}
assert!(decoder.clone().get_kid_retry("missing-d").await.is_err());
assert_eq!(requests.load(Ordering::Relaxed), 2);
*keys.write().unwrap() = json!({"keys": [{
"kty": "oct", "kid": "rotated", "alg": "HS256", "k": "c2VjcmV0"
}]})
.to_string();
tokio::time::sleep(OIDC_REVALIDATION_INTERVAL + Duration::from_millis(20)).await;
assert!(decoder.get_kid_retry("rotated").await.is_ok());
assert_eq!(requests.load(Ordering::Relaxed), 3);
keys.write().unwrap().clear();
tokio::time::sleep(OIDC_REVALIDATION_INTERVAL + Duration::from_millis(20)).await;
for kid in ["missing-e", "missing-f", "missing-g"] {
assert!(decoder.get_kid_retry(kid).await.is_err());
}
assert_eq!(requests.load(Ordering::Relaxed), 4);
assert!(decoder.get_kid_retry("rotated").await.is_ok());
server.abort();
}
#[test]
fn test_uses_provided_http_client_without_building_default_client() {
let resolved =
resolve_or_else(Some("provided-client"), || -> Result<&str, JwtAuthError> {
panic!("default HTTP client should not be built when a custom client is supplied")
})
.unwrap();
assert_eq!(resolved, "provided-client");
}
#[test]
fn test_decode_jwk_invalid_rsa_components() {
let jwk_json = json!({
"kty": "RSA",
"kid": "test-rsa",
"n": "...",
"e": "AQAB"
});
let jwk: Jwk = serde_json::from_value(jwk_json).unwrap();
let validation = Validation::new(Algorithm::RS256);
let result = decode_jwk(&jwk, &validation, false);
assert!(result.is_err());
}
#[test]
fn missing_jwk_algorithm_only_keeps_compatible_policy_algorithms() {
let policy = default_oidc_validation();
for (parameters, expected) in [
(json!({"kty":"RSA", "n":"AQ", "e":"AQAB"}), vec![Algorithm::RS256, Algorithm::RS384, Algorithm::RS512, Algorithm::PS256, Algorithm::PS384, Algorithm::PS512]),
(json!({"kty":"EC", "crv":"P-256", "x":"AQ", "y":"AQ"}), vec![Algorithm::ES256]),
(json!({"kty":"EC", "crv":"P-384", "x":"AQ", "y":"AQ"}), vec![Algorithm::ES384]),
(json!({"kty":"EC", "crv":"P-521", "x":"AQ", "y":"AQ"}), vec![]),
(json!({"kty":"OKP", "crv":"Ed25519", "x":"AQ"}), vec![Algorithm::EdDSA]),
(json!({"kty":"OKP", "crv":"P-256", "x":"AQ"}), vec![]),
(json!({"kty":"oct", "k":"c2VjcmV0"}), vec![Algorithm::HS256, Algorithm::HS384, Algorithm::HS512]),
] {
let mut parameters = parameters;
parameters["kid"] = json!("test");
let jwk: Jwk = serde_json::from_value(parameters).unwrap();
let (_, info) = decode_jwk(&jwk, &policy, true).unwrap();
assert_eq!(info.validation.algorithms, expected);
let mut deny_all = policy.clone();
deny_all.algorithms.clear();
let (_, info) = decode_jwk(&jwk, &deny_all, true).unwrap();
assert!(info.validation.algorithms.is_empty());
}
}
#[test]
fn test_decode_jwk_rejects_symmetric_by_default() {
let jwk_json = json!({
"kty": "oct",
"kid": "test-oct",
"alg": "HS256",
"k": "c2VjcmV0"
});
let jwk: Jwk = serde_json::from_value(jwk_json).unwrap();
let validation = Validation::new(Algorithm::HS256);
let result = decode_jwk(&jwk, &validation, false);
assert!(matches!(result, Err(JwtAuthError::InvalidJwk)));
}
#[test]
fn test_decode_jwk_accepts_symmetric_when_opted_in() {
let jwk_json = json!({
"kty": "oct",
"kid": "test-oct",
"alg": "HS256",
"k": "c2VjcmV0"
});
let jwk: Jwk = serde_json::from_value(jwk_json).unwrap();
let validation = Validation::new(Algorithm::HS256);
let result = decode_jwk(&jwk, &validation, true);
assert!(result.is_ok());
}
#[test]
fn test_decode_jwk_rejects_unknown_key_type() {
let jwk_json = json!({
"kty": "unknown",
"kid": "test-unknown",
"alg": "RS256"
});
let jwk: Jwk = serde_json::from_value(jwk_json).unwrap();
let validation = Validation::new(Algorithm::RS256);
let result = decode_jwk(&jwk, &validation, false);
assert!(matches!(result, Err(JwtAuthError::InvalidJwk)));
}
#[test]
fn test_oidc_validation_requires_audience() {
let result =
configure_oidc_validation(Validation::default(), "https://issuer.example", None);
assert!(matches!(result, Err(JwtAuthError::MissingOidcAudience)));
}
#[test]
fn test_oidc_validation_sets_issuer_and_audience_claims() {
let validation = configure_oidc_validation(
Validation::default(),
"https://issuer.example",
Some(vec!["client-id".to_owned()]),
)
.unwrap();
assert!(
validation
.iss
.as_ref()
.is_some_and(|iss| iss.contains("https://issuer.example"))
);
assert!(
validation
.aud
.as_ref()
.is_some_and(|aud| aud.contains("client-id"))
);
assert!(validation.required_spec_claims.contains("iss"));
assert!(validation.required_spec_claims.contains("aud"));
}
#[test]
fn test_oidc_validation_preserves_trailing_slash_issuer() {
let validation = configure_oidc_validation(
Validation::default(),
"https://issuer.example/",
Some(vec!["client-id".to_owned()]),
)
.unwrap();
assert!(
validation
.iss
.as_ref()
.is_some_and(|iss| iss.contains("https://issuer.example/"))
);
assert!(
validation
.iss
.as_ref()
.is_some_and(|iss| !iss.contains("https://issuer.example"))
);
}
}