diff --git a/CHANGELOG.md b/CHANGELOG.md index a674e30..2ab4491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - OpenAPI path keys use `{param}` form (breaking for consumers that asserted matchit `:param` strings in `openapi_json()`). +- JWT hot path reuses prebuilt `DecodingKey` and `Validation` per route (no per-request + rebuild) ([#109](https://github.com/QueryaHub/OxyRoute/issues/109)). ## [0.3.0] - 2026-04-27 diff --git a/src/dispatch.rs b/src/dispatch.rs index b47d29e..95929bd 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use parking_lot::RwLock; +use jsonwebtoken::decode; use jsonwebtoken::errors::ErrorKind; -use jsonwebtoken::{decode, Validation}; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyList, PyString, PyTuple}; use pyo3::IntoPyObjectExt; @@ -19,7 +19,7 @@ use crate::state::{ match_route_compiled, match_ws_route_compiled, methods_matching_path_compiled, route_is_trivial_sync, AppState, CompiledRouters, HotSnapshot, RouteEntry, }; -use crate::token::{build_decoding_key, extract_bearer, extract_cookie_value}; +use crate::token::{extract_bearer, extract_cookie_value}; use crate::websocket::WebSocket; type HttpExceptionPayload = (u16, Vec, Vec<(String, String)>); @@ -660,12 +660,9 @@ pub async fn run_rsgi( handler, is_async, require_jwt, - jwt_secret, - algs, - jwt_issuer, - jwt_audience, - jwt_leeway, jwt_cookie, + jwt_decoding_key, + jwt_validation, read_json_body, read_form_body, dep_names, @@ -683,12 +680,9 @@ pub async fn run_rsgi( e.handler.clone(), e.is_async, e.require_jwt, - e.jwt_secret.clone(), - Arc::clone(&e.algs), - e.jwt_issuer.clone(), - e.jwt_audience.clone(), - e.jwt_leeway, e.jwt_cookie.clone(), + e.jwt_decoding_key.clone(), + e.jwt_validation.clone(), e.read_json_body, e.read_form_body, Arc::clone(&e.dep_names), @@ -754,8 +748,9 @@ pub async fn run_rsgi( }; let mut claims_val: Option = None; if require_jwt { - let key = match jwt_secret { - None => { + let (dk, val) = match (jwt_decoding_key.as_ref(), jwt_validation.as_ref()) { + (Some(dk), Some(val)) => (dk, val), + _ => { return response::send_text( &protocol, 401, @@ -764,7 +759,6 @@ pub async fn run_rsgi( ) .await } - Some(s) => s, }; let token: String = match extract_bearer(auth.as_deref()).filter(|s| !s.is_empty()) { Some(t) => t, @@ -792,43 +786,7 @@ pub async fn run_rsgi( } }, }; - let mut val = if let Some(f) = algs.first() { - Validation::new(*f) - } else { - return response::send_text( - &protocol, - 401, - "Unauthorized", - "text/plain; charset=utf-8", - ) - .await; - }; - val.algorithms = algs.to_vec(); - val.validate_nbf = true; - val.leeway = jwt_leeway; - if let Some(ref iss) = jwt_issuer { - val.set_issuer(&[iss]); - } - if let Some(ref aud) = jwt_audience { - val.set_audience(&[aud]); - } else { - // jsonwebtoken 9: with validate_aud + aud=None, a token that includes `aud` fails - // (InvalidAudience). Disable unless the route opts in to an expected audience. - val.validate_aud = false; - } - let dk = match build_decoding_key(&key, &algs) { - Ok(d) => d, - Err(_) => { - return response::send_text( - &protocol, - 401, - "Unauthorized", - "text/plain; charset=utf-8", - ) - .await; - } - }; - match decode::(&token, &dk, &val) { + match decode::(&token, dk.as_ref(), val.as_ref()) { Ok(data) => { claims_val = Some(data.claims); } diff --git a/src/lib.rs b/src/lib.rs index c21308b..657cf78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -295,15 +295,29 @@ impl App { "require_jwt needs jwt_secret (HMAC shared secret, or public key PEM for RS*/PS*/ES*/EdDSA)", )); } - if require_jwt { - if let Some(k) = jwt_secret.as_deref() { - crate::token::build_decoding_key(k, &algs).map_err(|e| { - pyo3::exceptions::PyValueError::new_err(format!( - "jwt_secret and algorithms are incompatible: {e}" - )) - })?; - } - } + let jwt_leeway_v = jwt_leeway.unwrap_or(60); + let (jwt_decoding_key, jwt_validation) = if require_jwt { + let k = jwt_secret.as_deref().ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "require_jwt needs jwt_secret (HMAC shared secret, or public key PEM for RS*/PS*/ES*/EdDSA)", + ) + })?; + let (dk, val) = crate::token::build_route_jwt_state( + k, + &algs, + jwt_issuer.as_deref(), + jwt_audience.as_deref(), + jwt_leeway_v, + ) + .map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!( + "jwt_secret and algorithms are incompatible: {e}" + )) + })?; + (Some(Arc::new(dk)), Some(Arc::new(val))) + } else { + (None, None) + }; let (dep_names, dep_factories, dep_is_async, dep_wants_request) = if let Some(d) = dependencies { parse_dependencies(py, &d)? @@ -330,12 +344,9 @@ impl App { handler, is_async, require_jwt, - jwt_secret, - algs: Arc::<[jsonwebtoken::Algorithm]>::from(algs.clone()), - jwt_issuer, - jwt_audience, - jwt_leeway: jwt_leeway.unwrap_or(60), jwt_cookie, + jwt_decoding_key, + jwt_validation, read_json_body, read_form_body, dep_names: Arc::<[String]>::from(dep_names), diff --git a/src/state.rs b/src/state.rs index cb63c1e..68d61f9 100644 --- a/src/state.rs +++ b/src/state.rs @@ -42,16 +42,11 @@ pub struct RouteEntry { pub handler: Py, pub is_async: bool, pub require_jwt: bool, - pub jwt_secret: Option, - pub algs: Arc<[jsonwebtoken::Algorithm]>, - /// `None` in Python → no issuer check; else `set_issuer` in jsonwebtoken. - pub jwt_issuer: Option, - /// `None` in Python → `validate_aud` disabled for this route. - pub jwt_audience: Option, - /// Clock skew (seconds); Python `None` uses default 60 (jsonwebtoken default). - pub jwt_leeway: u64, /// If set, read JWT from the `Cookie` header when `Authorization: Bearer` is missing. pub jwt_cookie: Option, + /// Prebuilt at registration when `require_jwt` (issue #109); hot path reuses these. + pub jwt_decoding_key: Option>, + pub jwt_validation: Option>, pub read_json_body: bool, /// When set, body is parsed as form data (``application/x-www-form-urlencoded`` or ``multipart/form-data``), not JSON. pub read_form_body: bool, diff --git a/src/token.rs b/src/token.rs index 9202dd4..9afd1e8 100644 --- a/src/token.rs +++ b/src/token.rs @@ -94,6 +94,40 @@ pub fn build_decoding_key( } } +/// Prebuild decoding key + validation template for a route (issue #109). +/// +/// Matches the former per-request setup in `dispatch`: algorithms, nbf, leeway, +/// optional issuer/audience (audience check disabled when unset). +pub fn build_route_jwt_state( + key_material: &str, + algs: &[Algorithm], + jwt_issuer: Option<&str>, + jwt_audience: Option<&str>, + jwt_leeway: u64, +) -> jsonwebtoken::errors::Result<(DecodingKey, Validation)> { + if algs.is_empty() { + return Err(jsonwebtoken::errors::Error::from( + jsonwebtoken::errors::ErrorKind::InvalidAlgorithm, + )); + } + let dk = build_decoding_key(key_material, algs)?; + let mut val = Validation::new(algs[0]); + val.algorithms = algs.to_vec(); + val.validate_nbf = true; + val.leeway = jwt_leeway; + if let Some(iss) = jwt_issuer { + val.set_issuer(&[iss]); + } + if let Some(aud) = jwt_audience { + val.set_audience(&[aud]); + } else { + // jsonwebtoken 9: with validate_aud + aud=None, a token that includes `aud` fails + // (InvalidAudience). Disable unless the route opts in to an expected audience. + val.validate_aud = false; + } + Ok((dk, val)) +} + /// Used by the request path and for golden tests against `oxyjwt.decode`. pub fn decode_hs_claims( token: &str, @@ -165,6 +199,38 @@ mod tests { use jsonwebtoken::{encode, Header}; use serde_json::json; + #[test] + fn build_route_jwt_state_hs256_roundtrip() { + let dk_val = + build_route_jwt_state("secret", &[Algorithm::HS256], None, None, 60).expect("state"); + let (dk, val) = dk_val; + let token = encode( + &Header::new(Algorithm::HS256), + &json!({ "sub": "u1", "exp": 4_000_000_000_i64 }), + &jsonwebtoken::EncodingKey::from_secret(b"secret"), + ) + .expect("encode"); + let claims = decode::(&token, &dk, &val) + .expect("decode") + .claims; + assert_eq!(claims.get("sub"), Some(&json!("u1"))); + assert!(!val.validate_aud); + } + + #[test] + fn build_route_jwt_state_sets_issuer_audience() { + let (_, val) = build_route_jwt_state( + "secret", + &[Algorithm::HS256], + Some("issuer-a"), + Some("aud-b"), + 30, + ) + .expect("state"); + assert_eq!(val.leeway, 30); + assert!(val.validate_aud); + } + #[test] fn build_decoding_key_rs256_verifies() { let priv_pem = include_str!(concat!(