use jsonwebtoken::{
Algorithm,
jwk::{
AlgorithmParameters, EllipticCurve, Jwk, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse,
},
};
use std::{
collections::HashMap,
fmt::{Debug, Formatter},
sync::{Arc, RwLock},
time::{Duration, Instant},
};
use tokio::sync::Mutex;
use volga_oauth_client::{ClientConfig, ClientError, DiscoveryClient};
use crate::error::Error;
pub const DEFAULT_REFRESH_COOLDOWN: Duration = Duration::from_secs(60);
pub const DEFAULT_MAX_KEY_AGE: Duration = Duration::from_secs(15 * 60);
pub struct OAuthConfig {
pub(crate) issuer: Option<String>,
client_config: ClientConfig,
refresh_cooldown: Duration,
max_key_age: Duration,
}
impl Default for OAuthConfig {
#[inline]
fn default() -> Self {
Self {
issuer: None,
client_config: ClientConfig::new(),
refresh_cooldown: DEFAULT_REFRESH_COOLDOWN,
max_key_age: DEFAULT_MAX_KEY_AGE,
}
}
}
impl Debug for OAuthConfig {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthConfig")
.field("issuer", &self.issuer)
.field("client_config", &self.client_config)
.field("refresh_cooldown", &self.refresh_cooldown)
.field("max_key_age", &self.max_key_age)
.finish()
}
}
impl OAuthConfig {
pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
self.issuer = Some(issuer.into());
self
}
pub fn with_client_config<F>(mut self, config: F) -> Self
where
F: FnOnce(ClientConfig) -> ClientConfig,
{
self.client_config = config(self.client_config);
self
}
pub fn with_refresh_cooldown(mut self, cooldown: Duration) -> Self {
self.refresh_cooldown = cooldown;
self
}
pub fn with_max_key_age(mut self, max_age: Duration) -> Self {
self.max_key_age = max_age;
self
}
#[cfg(all(test, feature = "config"))]
pub(crate) fn refresh_cooldown(&self) -> Duration {
self.refresh_cooldown
}
#[cfg(all(test, feature = "config"))]
pub(crate) fn max_key_age(&self) -> Duration {
self.max_key_age
}
#[cfg(all(test, feature = "config"))]
pub(crate) fn client_config(&self) -> &ClientConfig {
&self.client_config
}
pub(crate) fn into_store(self) -> JwksStore {
JwksStore {
issuer: self.issuer.expect("OAuth issuer is not configured"),
client: DiscoveryClient::with_config(self.client_config),
refresh_cooldown: self.refresh_cooldown,
max_key_age: self.max_key_age,
keys: RwLock::new(None),
refresh: Mutex::new(None),
}
}
}
#[derive(Clone)]
pub(crate) struct KeyEntry {
pub(crate) key: jsonwebtoken::DecodingKey,
pub(crate) alg: Algorithm,
}
struct Keys {
by_kid: HashMap<String, KeyEntry>,
single: Option<KeyEntry>,
fetched_at: Instant,
}
impl Keys {
fn from_set(set: &JwkSet) -> Self {
let entries: Vec<(Option<String>, KeyEntry)> = set
.keys
.iter()
.filter_map(|jwk| entry_from_jwk(jwk).map(|e| (jwk.common.key_id.clone(), e)))
.collect();
let single = match entries.as_slice() {
[(_, entry)] => Some(entry.clone()),
_ => None,
};
let by_kid = entries
.into_iter()
.filter_map(|(kid, entry)| kid.map(|kid| (kid, entry)))
.collect();
Self {
by_kid,
single,
fetched_at: Instant::now(),
}
}
fn lookup(&self, kid: Option<&str>) -> Option<KeyEntry> {
match kid {
Some(kid) => self
.by_kid
.get(kid)
.cloned()
.or_else(|| {
self.by_kid
.is_empty()
.then(|| self.single.clone())
.flatten()
}),
None => self.single.clone(),
}
}
}
fn entry_from_jwk(jwk: &Jwk) -> Option<KeyEntry> {
if let Some(key_use) = &jwk.common.public_key_use
&& !matches!(key_use, PublicKeyUse::Signature)
{
return None;
}
if let Some(ops) = &jwk.common.key_operations
&& !ops.contains(&KeyOperations::Verify)
{
return None;
}
if matches!(jwk.algorithm, AlgorithmParameters::OctetKey(_)) {
return None;
}
let alg = match jwk.common.key_algorithm {
Some(alg) => signing_algorithm(alg)?,
None => default_alg(&jwk.algorithm)?,
};
let key = jsonwebtoken::DecodingKey::from_jwk(jwk).ok()?;
Some(KeyEntry { key, alg })
}
fn signing_algorithm(alg: KeyAlgorithm) -> Option<Algorithm> {
match alg {
KeyAlgorithm::ES256 => Some(Algorithm::ES256),
KeyAlgorithm::ES384 => Some(Algorithm::ES384),
KeyAlgorithm::RS256 => Some(Algorithm::RS256),
KeyAlgorithm::RS384 => Some(Algorithm::RS384),
KeyAlgorithm::RS512 => Some(Algorithm::RS512),
KeyAlgorithm::PS256 => Some(Algorithm::PS256),
KeyAlgorithm::PS384 => Some(Algorithm::PS384),
KeyAlgorithm::PS512 => Some(Algorithm::PS512),
KeyAlgorithm::EdDSA => Some(Algorithm::EdDSA),
_ => None,
}
}
fn default_alg(params: &AlgorithmParameters) -> Option<Algorithm> {
match params {
AlgorithmParameters::RSA(_) => Some(Algorithm::RS256),
AlgorithmParameters::EllipticCurve(ec) => match ec.curve {
EllipticCurve::P256 => Some(Algorithm::ES256),
EllipticCurve::P384 => Some(Algorithm::ES384),
_ => None,
},
AlgorithmParameters::OctetKeyPair(okp) => match okp.curve {
EllipticCurve::Ed25519 => Some(Algorithm::EdDSA),
_ => None,
},
AlgorithmParameters::OctetKey(_) => None,
}
}
pub(crate) struct JwksStore {
issuer: String,
client: DiscoveryClient,
refresh_cooldown: Duration,
max_key_age: Duration,
keys: RwLock<Option<Arc<Keys>>>,
refresh: Mutex<Option<Instant>>,
}
impl Debug for JwksStore {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JwksStore")
.field("issuer", &self.issuer)
.field("refresh_cooldown", &self.refresh_cooldown)
.finish_non_exhaustive()
}
}
impl JwksStore {
#[cfg(test)]
pub(crate) fn issuer(&self) -> &str {
&self.issuer
}
pub(crate) async fn key_for(&self, kid: Option<&str>) -> Result<KeyEntry, Error> {
if let Some(keys) = self.current()
&& let Some(entry) = keys.lookup(kid)
{
if keys.fetched_at.elapsed() < self.max_key_age {
return Ok(entry);
}
if let Err(_err) = self.refresh().await {
#[cfg(feature = "tracing")]
tracing::warn!(
issuer = %self.issuer,
"JWKS refresh failed; serving keys older than the configured max age: {_err:#}"
);
}
return self
.current()
.and_then(|keys| keys.lookup(kid))
.ok_or_else(|| {
Error::from_jwt_error(jsonwebtoken::errors::ErrorKind::InvalidToken.into())
});
}
self.refresh().await?;
match self.current() {
Some(keys) => keys.lookup(kid).ok_or_else(|| {
Error::from_jwt_error(jsonwebtoken::errors::ErrorKind::InvalidToken.into())
}),
None => Err(Error::server_error(
"OAuth issuer keys are not available yet",
)),
}
}
fn current(&self) -> Option<Arc<Keys>> {
self.keys.read().expect("JWKS lock poisoned").clone()
}
async fn refresh(&self) -> Result<(), Error> {
let mut last_attempt = self.refresh.lock().await;
if let Some(at) = *last_attempt
&& at.elapsed() < self.refresh_cooldown
{
return Ok(());
}
*last_attempt = Some(Instant::now());
let result = self.fetch_latest().await;
if result.is_err() && self.current().is_none() {
*last_attempt = None;
}
result
}
async fn fetch_latest(&self) -> Result<(), Error> {
let metadata = match self.client.fetch_server_metadata(&self.issuer).await {
Err(ClientError::Http(status)) if status.as_u16() == 404 => {
self.client.fetch_oidc_metadata(&self.issuer).await
}
other => other,
}
.map_err(discovery_error)?;
let document = self
.client
.fetch_jwks(&metadata)
.await
.map_err(discovery_error)?;
let set: JwkSet = serde_json::from_value(document).map_err(|err| {
Error::server_error(format!("OAuth issuer served an invalid JWKS: {err}"))
})?;
*self.keys.write().expect("JWKS lock poisoned") = Some(Arc::new(Keys::from_set(&set)));
Ok(())
}
}
fn discovery_error(err: ClientError) -> Error {
Error::server_error(format!("OAuth issuer discovery failed: {err}"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn rsa_jwk(kid: Option<&str>, alg: Option<&str>) -> serde_json::Value {
let mut jwk = json!({
"kty": "RSA",
"n": "q1ma_MoK5uWwsPxUNsVH1e-ybz_TzUGiFqUKbYkLTpXr9kpXi0i5SZOkGXHnLz1ch4gmOMuvvoLNwRyBzZGkOOd8IoLZAe4OAdmpQ2T0pY6szvUCK3WpIa06P7n20msOuc8bzm6CFM9fJU5_vHzeLGAj4Vi2GoFz4Lm3zUlZcY2zQWu2kdJZt6HbAM4s-nv1m3gqX-m5gTOjBP7oxEdNsOGZnl5v8h8uZ_U-CP2emvr67HW-Pph8OjVvXbyhBNGAbEljoXjJMLcqB5ULxXC4AspE-EfAZD5pCQO2ssUVPjw07qLNFd6gTJ7q41k2bNrS_SmYqWMeWttwEGS5Tjm3Xw",
"e": "AQAB"
});
if let Some(kid) = kid {
jwk["kid"] = kid.into();
}
if let Some(alg) = alg {
jwk["alg"] = alg.into();
}
jwk
}
fn set_from(keys: Vec<serde_json::Value>) -> JwkSet {
serde_json::from_value(json!({ "keys": keys })).unwrap()
}
#[test]
fn it_defaults_and_builds_config() {
let config = OAuthConfig::default();
assert!(config.issuer.is_none());
assert_eq!(config.refresh_cooldown, DEFAULT_REFRESH_COOLDOWN);
assert_eq!(config.max_key_age, DEFAULT_MAX_KEY_AGE);
let config = OAuthConfig::default()
.with_issuer("https://auth.example.com")
.with_client_config(|client| client.require_https(false))
.with_refresh_cooldown(Duration::from_secs(5))
.with_max_key_age(Duration::from_secs(120));
assert_eq!(config.issuer.as_deref(), Some("https://auth.example.com"));
assert_eq!(config.refresh_cooldown, Duration::from_secs(5));
assert_eq!(config.max_key_age, Duration::from_secs(120));
let store = config.into_store();
assert_eq!(store.issuer(), "https://auth.example.com");
assert!(format!("{store:?}").contains("auth.example.com"));
}
#[test]
#[should_panic(expected = "OAuth issuer is not configured")]
fn it_panics_building_a_store_without_issuer() {
let _ = OAuthConfig::default().into_store();
}
#[test]
fn it_indexes_keys_by_kid() {
let keys = Keys::from_set(&set_from(vec![
rsa_jwk(Some("a"), Some("RS256")),
rsa_jwk(Some("b"), Some("RS384")),
]));
assert_eq!(keys.lookup(Some("a")).unwrap().alg, Algorithm::RS256);
assert_eq!(keys.lookup(Some("b")).unwrap().alg, Algorithm::RS384);
assert!(keys.lookup(Some("c")).is_none());
assert!(keys.lookup(None).is_none());
}
#[test]
fn it_serves_a_single_key_set_without_kids() {
let keys = Keys::from_set(&set_from(vec![rsa_jwk(None, Some("RS256"))]));
assert!(keys.lookup(None).is_some());
assert!(keys.lookup(Some("whatever")).is_some());
}
#[test]
fn it_infers_algorithms_for_keys_without_alg() {
let keys = Keys::from_set(&set_from(vec![rsa_jwk(Some("a"), None)]));
assert_eq!(keys.lookup(Some("a")).unwrap().alg, Algorithm::RS256);
}
#[test]
fn it_skips_unusable_keys() {
let mut encryption_key = rsa_jwk(Some("enc"), Some("RS256"));
encryption_key["use"] = "enc".into();
let symmetric = json!({ "kty": "oct", "kid": "sym", "k": "c2VjcmV0" });
let symmetric_hs256 =
json!({ "kty": "oct", "kid": "sym-hs", "k": "c2VjcmV0", "alg": "HS256" });
let oaep = rsa_jwk(Some("oaep"), Some("RSA-OAEP"));
let mut ops_encrypt = rsa_jwk(Some("ops-enc"), Some("RS256"));
ops_encrypt["key_ops"] = json!(["encrypt"]);
let keys = Keys::from_set(&set_from(vec![
encryption_key,
symmetric,
symmetric_hs256,
oaep,
ops_encrypt,
]));
assert!(keys.lookup(Some("enc")).is_none());
assert!(keys.lookup(Some("sym")).is_none());
assert!(keys.lookup(Some("sym-hs")).is_none());
assert!(keys.lookup(Some("oaep")).is_none());
assert!(keys.lookup(Some("ops-enc")).is_none());
assert!(keys.lookup(None).is_none());
}
#[test]
fn it_accepts_keys_declaring_the_verify_operation() {
let mut jwk = rsa_jwk(Some("ops-verify"), Some("RS256"));
jwk["key_ops"] = json!(["verify"]);
let keys = Keys::from_set(&set_from(vec![jwk]));
assert!(keys.lookup(Some("ops-verify")).is_some());
}
}