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
3 changes: 3 additions & 0 deletions docs/jwt.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ The HTTP verb decorators on `App` accept:
- `require_jwt: bool` — if true, a valid `Authorization: Bearer <token>` 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.

Expand Down
69 changes: 65 additions & 4 deletions oxyroute/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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)

Expand All @@ -143,6 +201,9 @@ def wrap(handler: F) -> F:
algorithms,
read_json_body,
dlist,
jwt_issuer,
jwt_audience,
jwt_leeway,
)
return handler

Expand Down
17 changes: 17 additions & 0 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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::<JsonValue>(&token, &dk, &val) {
Ok(data) => {
Expand Down
8 changes: 7 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -110,6 +110,9 @@ impl App {
algorithms: Option<Bound<'_, PyList>>,
read_json_body: bool,
dependencies: Option<Bound<'_, PyList>>,
jwt_issuer: Option<String>,
jwt_audience: Option<String>,
jwt_leeway: Option<u64>,
) -> PyResult<()> {
let st = self.state.lock().map_err(|e| lock_err(e))?;
if st.frozen {
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ pub struct RouteEntry {
pub require_jwt: bool,
pub jwt_secret: Option<String>,
pub algs: Vec<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,
pub read_json_body: bool,
/// Dependency `name` -> factory callable (linear order; resolved in order, then user handler).
pub dep_names: Vec<String>,
Expand Down
100 changes: 100 additions & 0 deletions tests/test_jwt_route_iss_aud.py
Original file line number Diff line number Diff line change
@@ -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())
Loading