Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 10 additions & 52 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<u8>, Vec<(String, String)>);
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -754,8 +748,9 @@ pub async fn run_rsgi(
};
let mut claims_val: Option<JsonValue> = 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,
Expand All @@ -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,
Expand Down Expand Up @@ -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::<JsonValue>(&token, &dk, &val) {
match decode::<JsonValue>(&token, dk.as_ref(), val.as_ref()) {
Ok(data) => {
claims_val = Some(data.claims);
}
Expand Down
39 changes: 25 additions & 14 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -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),
Expand Down
11 changes: 3 additions & 8 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,11 @@ pub struct RouteEntry {
pub handler: Py<PyAny>,
pub is_async: bool,
pub require_jwt: bool,
pub jwt_secret: Option<String>,
pub algs: Arc<[jsonwebtoken::Algorithm]>,
/// `None` in Python → no issuer check; else `set_issuer` in jsonwebtoken.
pub jwt_issuer: Option<String>,
/// `None` in Python → `validate_aud` disabled for this route.
pub jwt_audience: Option<String>,
/// 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<String>,
/// Prebuilt at registration when `require_jwt` (issue #109); hot path reuses these.
pub jwt_decoding_key: Option<Arc<jsonwebtoken::DecodingKey>>,
pub jwt_validation: Option<Arc<jsonwebtoken::Validation>>,
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,
Expand Down
66 changes: 66 additions & 0 deletions src/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::<JsonValue>(&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!(
Expand Down
Loading