fix: tolerate encryption keys in a guest JWKS set

jsonwebtoken 8.3 models a JWK's `alg` as its signing `Algorithm` enum, so a set
carrying an encryption key (`alg: "RSA-OAEP"`, absent from that enum) alongside a
signing key failed whole-set deserialization and the whole JWKS was rejected,
which real issuers publish. Parse each key on its own and skip one that does not
model as a JWT key, keeping the usable signing keys. Unit-tested with a mixed set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VF3v6LA9399gNphmZaHYG3
This commit is contained in:
Ruben Fiszel
2026-09-03 23:59:20 +02:00
co-authored by Claude Opus 4.8
parent e2c6e659ea
commit 1845a9c10f
+34 -6
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use jsonwebtoken::{
jwk::{AlgorithmParameters, Jwk, JwkSet, PublicKeyUse},
jwk::{AlgorithmParameters, Jwk, PublicKeyUse},
Algorithm, DecodingKey, Validation,
};
use quick_cache::sync::Cache;
@@ -249,11 +249,23 @@ pub async fn fetch_jwks(url: &str) -> Result<HashMap<String, Jwk>> {
}
body.extend_from_slice(&chunk);
}
let set: JwkSet = serde_json::from_slice(&body)
.map_err(|e| Error::BadRequest(format!("JWKS is not a JSON Web Key Set: {e}")))?;
let keys: HashMap<String, Jwk> = set
.keys
.into_iter()
parse_jwks_keys(&body)
}
/// The usable signing keys in a JWKS body, by `kid`. Each key is parsed on its own and
/// one that does not model as a JWT key is skipped, not fatal: a set may legitimately
/// carry an encryption key (say `alg: "RSA-OAEP"`, which is not in jsonwebtoken's signing
/// `Algorithm` enum and would fail whole-set deserialization) beside its signing keys.
fn parse_jwks_keys(body: &[u8]) -> Result<HashMap<String, Jwk>> {
let set: serde_json::Value = serde_json::from_slice(body)
.map_err(|e| Error::BadRequest(format!("JWKS is not JSON: {e}")))?;
let entries = set
.get("keys")
.and_then(|k| k.as_array())
.ok_or_else(|| Error::BadRequest("JWKS has no `keys` array".to_string()))?;
let keys: HashMap<String, Jwk> = entries
.iter()
.filter_map(|entry| serde_json::from_value::<Jwk>(entry.clone()).ok())
.filter(|jwk| jwk_algorithms(jwk).is_some())
.filter_map(|jwk| jwk.common.key_id.clone().map(|kid| (kid, jwk)))
.collect();
@@ -444,6 +456,22 @@ mod tests {
assert_eq!(jwk_algorithms(&k), None);
}
#[test]
fn a_mixed_use_jwks_keeps_only_the_signing_keys() {
// An encryption key (RSA-OAEP is not in jsonwebtoken's signing Algorithm enum)
// beside a signing key must not fail the whole set.
let body = serde_json::json!({
"keys": [
{"kty":"RSA","alg":"RSA-OAEP","kid":"enc","use":"enc","n":"aa","e":"AQAB"},
{"kty":"EC","crv":"P-256","kid":"sig","x":"aa","y":"bb"}
]
})
.to_string();
let keys = parse_jwks_keys(body.as_bytes()).expect("the signing key survives");
assert!(keys.contains_key("sig"));
assert!(!keys.contains_key("enc"));
}
#[test]
fn key_ops_without_verify_is_refused() {
let enc = jwk(serde_json::json!({"kty":"RSA","key_ops":["encrypt"],"n":"aa","e":"AQAB"}));