From 212d07654d4739e1964728d32dd65fc634ef6cb2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 17:25:50 +0300 Subject: [PATCH] feat: JWT route options iss, aud, leeway (P1 #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RouteEntry and add_route: jwt_issuer, jwt_audience, optional jwt_leeway (default 60s) - dispatch: Validation leeway, set_issuer, set_audience; validate_aud off when no audience - App decorators: jwt_issuer, jwt_audience, jwt_leeway - tests: oxyjwt ASGI tokens good/wrong iss and aud → 200/401 - docs/jwt.md --- docs/jwt.md | 3 + oxyroute/app.py | 69 ++++++++++++++++++++-- src/dispatch.rs | 17 ++++++ src/lib.rs | 8 ++- src/state.rs | 6 ++ tests/test_jwt_route_iss_aud.py | 100 ++++++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 tests/test_jwt_route_iss_aud.py diff --git a/docs/jwt.md b/docs/jwt.md index e8f06f8..1ba7410 100644 --- a/docs/jwt.md +++ b/docs/jwt.md @@ -11,6 +11,9 @@ The HTTP verb decorators on `App` accept: - `require_jwt: bool` — if true, a valid `Authorization: Bearer ` header and successful verification are required before the handler runs - `jwt_secret: str | None` — required for the default HMAC flow when `require_jwt` is set - `algorithms: list[str] | None` — e.g. `["HS256"]` (defaults in code if omitted) +- `jwt_issuer: str | None` — if set, the `iss` claim must match (via [`jsonwebtoken`](https://crates.io/crates/jsonwebtoken) validation) +- `jwt_audience: str | None` — if set, the `aud` claim is validated against this value. If **omitted**, audience checking is **disabled** for that route (so tokens with an `aud` claim are not rejected for audience mismatch; opt in by passing `jwt_audience`) +- `jwt_leeway: int | None` — clock skew in **seconds** for `exp` / `nbf` (default when omitted: **60**, matching the `jsonwebtoken` crate default) If `require_jwt` is set but the secret is missing for an all-HMAC algorithm set, **registration** fails with a clear error. diff --git a/oxyroute/app.py b/oxyroute/app.py index 4f0d5d7..3530ab8 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -55,10 +55,22 @@ def get( require_jwt: bool = False, jwt_secret: Optional[str] = None, algorithms: Optional[List[str]] = None, + jwt_issuer: Optional[str] = None, + jwt_audience: Optional[str] = None, + jwt_leeway: Optional[int] = None, dependencies: Optional[List[Tuple[str, Dep]]] = None, ) -> Callable[[F], F]: return self._route( - "GET", path, require_jwt, jwt_secret, algorithms, read_json_body=False, dependencies=dependencies + "GET", + path, + require_jwt, + jwt_secret, + algorithms, + read_json_body=False, + dependencies=dependencies, + jwt_issuer=jwt_issuer, + jwt_audience=jwt_audience, + jwt_leeway=jwt_leeway, ) def post( @@ -69,6 +81,9 @@ def post( jwt_secret: Optional[str] = None, algorithms: Optional[List[str]] = None, read_json_body: bool = True, + jwt_issuer: Optional[str] = None, + jwt_audience: Optional[str] = None, + jwt_leeway: Optional[int] = None, dependencies: Optional[List[Tuple[str, Dep]]] = None, ) -> Callable[[F], F]: return self._route( @@ -79,6 +94,9 @@ def post( algorithms, read_json_body, dependencies=dependencies, + jwt_issuer=jwt_issuer, + jwt_audience=jwt_audience, + jwt_leeway=jwt_leeway, ) def put( @@ -88,10 +106,22 @@ def put( require_jwt: bool = False, jwt_secret: Optional[str] = None, algorithms: Optional[List[str]] = None, + jwt_issuer: Optional[str] = None, + jwt_audience: Optional[str] = None, + jwt_leeway: Optional[int] = None, dependencies: Optional[List[Tuple[str, Dep]]] = None, ) -> Callable[[F], F]: return self._route( - "PUT", path, require_jwt, jwt_secret, algorithms, read_json_body=True, dependencies=dependencies + "PUT", + path, + require_jwt, + jwt_secret, + algorithms, + read_json_body=True, + dependencies=dependencies, + jwt_issuer=jwt_issuer, + jwt_audience=jwt_audience, + jwt_leeway=jwt_leeway, ) def patch( @@ -102,10 +132,22 @@ def patch( jwt_secret: Optional[str] = None, algorithms: Optional[List[str]] = None, read_json_body: bool = True, + jwt_issuer: Optional[str] = None, + jwt_audience: Optional[str] = None, + jwt_leeway: Optional[int] = None, dependencies: Optional[List[Tuple[str, Dep]]] = None, ) -> Callable[[F], F]: return self._route( - "PATCH", path, require_jwt, jwt_secret, algorithms, read_json_body, dependencies=dependencies + "PATCH", + path, + require_jwt, + jwt_secret, + algorithms, + read_json_body, + dependencies=dependencies, + jwt_issuer=jwt_issuer, + jwt_audience=jwt_audience, + jwt_leeway=jwt_leeway, ) def delete( @@ -115,10 +157,22 @@ def delete( require_jwt: bool = False, jwt_secret: Optional[str] = None, algorithms: Optional[List[str]] = None, + jwt_issuer: Optional[str] = None, + jwt_audience: Optional[str] = None, + jwt_leeway: Optional[int] = None, dependencies: Optional[List[Tuple[str, Dep]]] = None, ) -> Callable[[F], F]: return self._route( - "DELETE", path, require_jwt, jwt_secret, algorithms, read_json_body=False, dependencies=dependencies + "DELETE", + path, + require_jwt, + jwt_secret, + algorithms, + read_json_body=False, + dependencies=dependencies, + jwt_issuer=jwt_issuer, + jwt_audience=jwt_audience, + jwt_leeway=jwt_leeway, ) def _route( @@ -130,6 +184,10 @@ def _route( algorithms: Optional[List[str]], read_json_body: bool, dependencies: Optional[List[Tuple[str, Dep]]], + *, + jwt_issuer: Optional[str] = None, + jwt_audience: Optional[str] = None, + jwt_leeway: Optional[int] = None, ) -> Callable[[F], F]: dlist = _norm_dependencies(dependencies) @@ -143,6 +201,9 @@ def wrap(handler: F) -> F: algorithms, read_json_body, dlist, + jwt_issuer, + jwt_audience, + jwt_leeway, ) return handler diff --git a/src/dispatch.rs b/src/dispatch.rs index 7489039..897bbb8 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -146,6 +146,9 @@ pub async fn run_rsgi( require_jwt, jwt_secret, algs, + jwt_issuer, + jwt_audience, + jwt_leeway, read_json_body, dep_names, dep_factories, @@ -162,6 +165,9 @@ pub async fn run_rsgi( e.require_jwt, e.jwt_secret.clone(), e.algs.clone(), + e.jwt_issuer.clone(), + e.jwt_audience.clone(), + e.jwt_leeway, e.read_json_body, e.dep_names.clone(), e.dep_factories.clone(), @@ -207,6 +213,17 @@ pub async fn run_rsgi( }; val.algorithms = algs; 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 = DecodingKey::from_secret(key.as_bytes()); match decode::(&token, &dk, &val) { Ok(data) => { diff --git a/src/lib.rs b/src/lib.rs index bec2637..703230f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -96,7 +96,7 @@ impl App { /// Paths use **matchit 0.7** style: `/user/:id`. Pass `dependencies=[("x", get_x), ...]`. #[pyo3( - signature = (method, path, handler, require_jwt=false, jwt_secret=None, algorithms=None, read_json_body=true, dependencies=None) + signature = (method, path, handler, require_jwt=false, jwt_secret=None, algorithms=None, read_json_body=true, dependencies=None, jwt_issuer=None, jwt_audience=None, jwt_leeway=None) )] #[allow(clippy::too_many_arguments)] fn add_route( @@ -110,6 +110,9 @@ impl App { algorithms: Option>, read_json_body: bool, dependencies: Option>, + jwt_issuer: Option, + jwt_audience: Option, + jwt_leeway: Option, ) -> PyResult<()> { let st = self.state.lock().map_err(|e| lock_err(e))?; if st.frozen { @@ -168,6 +171,9 @@ impl App { require_jwt, jwt_secret, algs: algs.clone(), + jwt_issuer, + jwt_audience, + jwt_leeway: jwt_leeway.unwrap_or(60), read_json_body, dep_names, dep_factories, diff --git a/src/state.rs b/src/state.rs index fc0519e..75f2da4 100644 --- a/src/state.rs +++ b/src/state.rs @@ -9,6 +9,12 @@ pub struct RouteEntry { pub require_jwt: bool, pub jwt_secret: Option, pub algs: Vec, + /// `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, pub read_json_body: bool, /// Dependency `name` -> factory callable (linear order; resolved in order, then user handler). pub dep_names: Vec, diff --git a/tests/test_jwt_route_iss_aud.py b/tests/test_jwt_route_iss_aud.py new file mode 100644 index 0000000..e7de57b --- /dev/null +++ b/tests/test_jwt_route_iss_aud.py @@ -0,0 +1,100 @@ +"""Route-level JWT: issuer, audience, leeway (P1 #6).""" + +from __future__ import annotations + +import asyncio +import time + +import httpx +import pytest + +from oxyroute import App + +oxyjwt = pytest.importorskip("oxyjwt") + +SECRET = "test-secret-iss-aud" +ISS = "https://issuer.example" +AUD = "expected-aud" + + +def _token(**claims: object) -> str: + return oxyjwt.encode(claims, SECRET, algorithm="HS256") # type: ignore[arg-type] + + +def test_jwt_route_iss_and_aud_match() -> None: + now = int(time.time()) + app = App() + + @app.get( + "/ok", + require_jwt=True, + jwt_secret=SECRET, + algorithms=["HS256"], + jwt_issuer=ISS, + jwt_audience=AUD, + ) + def ok(claims: dict) -> str: # noqa: ARG001 + return "yes" + + tok = _token(sub="u1", iss=ISS, aud=AUD, exp=now + 3600) + + async def run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/ok", headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 200 + assert r.text == "yes" + + asyncio.run(run()) + + +def test_jwt_route_wrong_iss_401() -> None: + now = int(time.time()) + app = App() + + @app.get( + "/p", + require_jwt=True, + jwt_secret=SECRET, + algorithms=["HS256"], + jwt_issuer=ISS, + jwt_audience=AUD, + ) + def p(claims: dict) -> str: # noqa: ARG001 + return "x" + + tok = _token(sub="u1", iss="https://other", aud=AUD, exp=now + 3600) + + async def run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/p", headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 401 + + asyncio.run(run()) + + +def test_jwt_route_wrong_aud_401() -> None: + now = int(time.time()) + app = App() + + @app.get( + "/p", + require_jwt=True, + jwt_secret=SECRET, + algorithms=["HS256"], + jwt_issuer=ISS, + jwt_audience=AUD, + ) + def p(claims: dict) -> str: # noqa: ARG001 + return "x" + + tok = _token(sub="u1", iss=ISS, aud="other-aud", exp=now + 3600) + + async def run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/p", headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 401 + + asyncio.run(run())