✨ feat: new auth module — Rust + Python bindings
swadhinbiswas committed 3 months ago
87b0fee
1 parent 987fd04
commit 87b0fee
3 files changed
+436 -0
Showing 3 changed files+436-0
▼crates/justapi-py/python/justapi/auth.py
+222-0
| @@ -0,0 +1,222 @@ | ||
1 | +"""JWT authentication and OAuth2 support. | |
2 | + | |
3 | +Provides Rust-native JWT encode/decode via ``_JwtAuth``, plus FastAPI-compatible | |
4 | +``Depends`` helpers for Bearer token extraction and the OAuth2 password flow. | |
5 | + | |
6 | +Usage:: | |
7 | + | |
8 | + from justapi import JustAPIApp, Depends | |
9 | + from justapi.auth import JwtAuth, OAuth2PasswordBearer | |
10 | + | |
11 | + app = JustAPIApp() | |
12 | + jwt_auth = JwtAuth(secret="my-secret", algorithm="HS256") | |
13 | + | |
14 | + # Protect a route | |
15 | + @app.get("/protected") | |
16 | + async def protected(current_user: dict = Depends(jwt_auth)): | |
17 | + return {"user": current_user} | |
18 | + | |
19 | + # Or use the OAuth2 scheme explicitly | |
20 | + oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token") | |
21 | + | |
22 | + @app.get("/users/me") | |
23 | + async def read_users_me(token: str = Depends(oauth2_scheme)): | |
24 | + claims = jwt_auth.decode(token) | |
25 | + return claims | |
26 | +""" | |
27 | + | |
28 | +from ._justapi import _JwtAuth # type: ignore[import-untyped] | |
29 | +from .exceptions import HTTPException | |
30 | +from .params import Header, Form | |
31 | +from typing import Optional, Any, Dict | |
32 | + | |
33 | + | |
34 | +class JwtAuth: | |
35 | + """Configure JWT authentication with Rust-native crypto. | |
36 | + | |
37 | + Can be used as a ``Depends`` callable that decodes the Bearer token from the | |
38 | + ``Authorization`` header and returns the decoded claims dict. | |
39 | + | |
40 | + Args: | |
41 | + secret: The shared secret (for HMAC algorithms) or PEM-encoded private key. | |
42 | + algorithm: Signing algorithm (default ``"HS256"``). One of: | |
43 | + ``HS256``, ``HS384``, ``HS512``, ``RS256``, ``RS384``, | |
44 | + ``RS512``, ``ES256``, ``ES384``, ``ED25519``. | |
45 | + auto_error: Raise ``401`` when no valid token is present (default ``True``). | |
46 | + """ | |
47 | + | |
48 | + def __init__( | |
49 | + self, | |
50 | + secret: str, | |
51 | + algorithm: str = "HS256", | |
52 | + auto_error: bool = True, | |
53 | + ): | |
54 | + self._backend = _JwtAuth(secret, algorithm) | |
55 | + self._auto_error = auto_error | |
56 | + | |
57 | + def encode(self, claims: dict) -> str: | |
58 | + """Encode a claims dict into a signed JWT string. | |
59 | + | |
60 | + Args: | |
61 | + claims: Payload dict (e.g. ``{"sub": "user123", "roles": ["admin"]}``). | |
62 | + Returns: | |
63 | + The signed JWT as a string. | |
64 | + """ | |
65 | + return self._backend.encode(claims) | |
66 | + | |
67 | + def decode(self, token: str, **options: bool) -> dict: | |
68 | + """Decode and validate a JWT token, returning the claims dict. | |
69 | + | |
70 | + Args: | |
71 | + token: The JWT string. | |
72 | + **options: Verification options: | |
73 | + - ``verify_exp`` (default ``True``) | |
74 | + - ``verify_iat`` (default ``True``) | |
75 | + - ``verify_nbf`` (default ``True``) | |
76 | + - ``verify_aud`` (default ``True``) | |
77 | + - ``verify_iss`` (default ``True``) | |
78 | + Returns: | |
79 | + The decoded claims dict. | |
80 | + Raises: | |
81 | + HTTPException(401): If the token is invalid or expired. | |
82 | + """ | |
83 | + try: | |
84 | + return dict(self._backend.decode(token, options)) | |
85 | + except Exception as e: | |
86 | + if self._auto_error: | |
87 | + raise HTTPException(status_code=401, detail=str(e)) | |
88 | + raise | |
89 | + | |
90 | + async def __call__(self, authorization: Optional[str] = Header(None)) -> dict: | |
91 | + """Decode the Bearer token from the Authorization header. | |
92 | + | |
93 | + Used as a ``Depends`` callable in route handlers. | |
94 | + """ | |
95 | + if not authorization: | |
96 | + if self._auto_error: | |
97 | + raise HTTPException( | |
98 | + status_code=401, | |
99 | + detail="Not authenticated", | |
100 | + headers={"WWW-Authenticate": "Bearer"}, | |
101 | + ) | |
102 | + return {} | |
103 | + if not authorization.startswith("Bearer "): | |
104 | + if self._auto_error: | |
105 | + raise HTTPException( | |
106 | + status_code=401, | |
107 | + detail="Invalid authorization header", | |
108 | + headers={"WWW-Authenticate": "Bearer"}, | |
109 | + ) | |
110 | + return {} | |
111 | + token = authorization[7:] | |
112 | + return self.decode(token) | |
113 | + | |
114 | + def __repr__(self) -> str: | |
115 | + return f"JwtAuth(algorithm={self._backend!r})" | |
116 | + | |
117 | + | |
118 | +class OAuth2PasswordBearer: | |
119 | + """FastAPI-compatible OAuth2 password flow Bearer token extractor. | |
120 | + | |
121 | + Extracts the Bearer token from the ``Authorization`` header. Use as a | |
122 | + ``Depends`` callable to inject the raw token string into a handler, then | |
123 | + decode it yourself (or pair it with :class:`JwtAuth.decode`). | |
124 | + | |
125 | + Args: | |
126 | + tokenUrl: The URL where the client can obtain a token (for OpenAPI docs). | |
127 | + auto_error: Raise ``401`` when no token is present (default ``True``). | |
128 | + """ | |
129 | + | |
130 | + def __init__(self, tokenUrl: str = "/token", auto_error: bool = True): | |
131 | + self.tokenUrl = tokenUrl | |
132 | + self.auto_error = auto_error | |
133 | + self.model = {"type": "oauth2", "flows": {"password": {"tokenUrl": tokenUrl}}} | |
134 | + | |
135 | + async def __call__(self, authorization: Optional[str] = Header(None)) -> str: | |
136 | + """Extract the Bearer token from the request.""" | |
137 | + if not authorization: | |
138 | + if self.auto_error: | |
139 | + raise HTTPException( | |
140 | + status_code=401, | |
141 | + detail="Not authenticated", | |
142 | + headers={"WWW-Authenticate": "Bearer"}, | |
143 | + ) | |
144 | + return "" | |
145 | + if not authorization.startswith("Bearer "): | |
146 | + if self.auto_error: | |
147 | + raise HTTPException( | |
148 | + status_code=401, | |
149 | + detail="Invalid authorization header", | |
150 | + headers={"WWW-Authenticate": "Bearer"}, | |
151 | + ) | |
152 | + return "" | |
153 | + return authorization[7:] | |
154 | + | |
155 | + def __repr__(self) -> str: | |
156 | + return f"OAuth2PasswordBearer(tokenUrl='{self.tokenUrl}')" | |
157 | + | |
158 | + | |
159 | +class OAuth2PasswordRequestForm: | |
160 | + """FastAPI-compatible form dependency for the OAuth2 token endpoint. | |
161 | + | |
162 | + Extracts ``grant_type``, ``username``, ``password``, ``scope``, ``client_id``, | |
163 | + and ``client_secret`` from a form-encoded request body. | |
164 | + | |
165 | + Usage:: | |
166 | + | |
167 | + @app.post("/token") | |
168 | + async def login(form: OAuth2PasswordRequestForm = Depends(OAuth2PasswordRequestForm)): | |
169 | + # validate form.username / form.password | |
170 | + ... | |
171 | + """ | |
172 | + | |
173 | + def __init__( | |
174 | + self, | |
175 | + grant_type: str = Form(...), | |
176 | + username: str = Form(...), | |
177 | + password: str = Form(...), | |
178 | + scope: str = Form(""), | |
179 | + client_id: Optional[str] = Form(None), | |
180 | + client_secret: Optional[str] = Form(None), | |
181 | + ): | |
182 | + self.grant_type = grant_type | |
183 | + self.username = username | |
184 | + self.password = password | |
185 | + self.scopes = scope.split() if scope else [] | |
186 | + self.client_id = client_id | |
187 | + self.client_secret = client_secret | |
188 | + | |
189 | + def __repr__(self) -> str: | |
190 | + return f"OAuth2PasswordRequestForm(username='{self.username}')" | |
191 | + | |
192 | + | |
193 | +class OAuth2PasswordRequestFormStrict: | |
194 | + """Strict variant that requires ``grant_type=password``. | |
195 | + | |
196 | + Same fields as :class:`OAuth2PasswordRequestForm`, but rejects requests where | |
197 | + ``grant_type`` is not ``"password"`` with a ``422 Unprocessable Entity``. | |
198 | + """ | |
199 | + | |
200 | + def __init__( | |
201 | + self, | |
202 | + grant_type: str = Form(...), | |
203 | + username: str = Form(...), | |
204 | + password: str = Form(...), | |
205 | + scope: str = Form(""), | |
206 | + client_id: Optional[str] = Form(None), | |
207 | + client_secret: Optional[str] = Form(None), | |
208 | + ): | |
209 | + if grant_type != "password": | |
210 | + raise HTTPException( | |
211 | + status_code=422, | |
212 | + detail='Invalid grant_type. Must be "password".', | |
213 | + ) | |
214 | + self.grant_type = grant_type | |
215 | + self.username = username | |
216 | + self.password = password | |
217 | + self.scopes = scope.split() if scope else [] | |
218 | + self.client_id = client_id | |
219 | + self.client_secret = client_secret | |
220 | + | |
221 | + def __repr__(self) -> str: | |
222 | + return f"OAuth2PasswordRequestFormStrict(username='{self.username}')" | |
0 | 223 | |
▼crates/justapi-py/python/justapi/auth.pyi
+49-0
| @@ -0,0 +1,49 @@ | ||
1 | +from typing import Any, Dict, List, Optional, Callable | |
2 | + | |
3 | + | |
4 | +class JwtAuth: | |
5 | + def __init__(self, secret: str, algorithm: str = "HS256", auto_error: bool = True) -> None: ... | |
6 | + def encode(self, claims: Dict[str, Any]) -> str: ... | |
7 | + def decode(self, token: str, **options: bool) -> Dict[str, Any]: ... | |
8 | + async def __call__(self, authorization: Optional[str] = ...) -> Dict[str, Any]: ... | |
9 | + | |
10 | + | |
11 | +class OAuth2PasswordBearer: | |
12 | + def __init__(self, tokenUrl: str = "/token", auto_error: bool = True) -> None: ... | |
13 | + async def __call__(self, authorization: Optional[str] = ...) -> str: ... | |
14 | + | |
15 | + | |
16 | +class OAuth2PasswordRequestForm: | |
17 | + grant_type: str | |
18 | + username: str | |
19 | + password: str | |
20 | + scopes: List[str] | |
21 | + client_id: Optional[str] | |
22 | + client_secret: Optional[str] | |
23 | + def __init__( | |
24 | + self, | |
25 | + grant_type: str = ..., | |
26 | + username: str = ..., | |
27 | + password: str = ..., | |
28 | + scope: str = "", | |
29 | + client_id: Optional[str] = ..., | |
30 | + client_secret: Optional[str] = ..., | |
31 | + ) -> None: ... | |
32 | + | |
33 | + | |
34 | +class OAuth2PasswordRequestFormStrict: | |
35 | + grant_type: str | |
36 | + username: str | |
37 | + password: str | |
38 | + scopes: List[str] | |
39 | + client_id: Optional[str] | |
40 | + client_secret: Optional[str] | |
41 | + def __init__( | |
42 | + self, | |
43 | + grant_type: str = ..., | |
44 | + username: str = ..., | |
45 | + password: str = ..., | |
46 | + scope: str = "", | |
47 | + client_id: Optional[str] = ..., | |
48 | + client_secret: Optional[str] = ..., | |
49 | + ) -> None: ... | |
0 | 50 | |
▼crates/justapi-py/src/auth.rs
+165-0
| @@ -0,0 +1,165 @@ | ||
1 | +use pyo3::prelude::*; | |
2 | +use pyo3::types::{PyAnyMethods, PyDict, PyList}; | |
3 | + | |
4 | +/// Rust-native JWT encoding/decoding, backed by the `jsonwebtoken` crate. | |
5 | +/// No Python crypto library needed. | |
6 | +#[pyclass(name = "_JwtAuth")] | |
7 | +pub struct PyJwtAuth { | |
8 | + encoding_key: jsonwebtoken::EncodingKey, | |
9 | + decoding_key: jsonwebtoken::DecodingKey, | |
10 | + algorithm: jsonwebtoken::Algorithm, | |
11 | + validation: jsonwebtoken::Validation, | |
12 | +} | |
13 | + | |
14 | +#[pymethods] | |
15 | +impl PyJwtAuth { | |
16 | + #[new] | |
17 | + #[pyo3(signature = (secret, algorithm = "HS256"))] | |
18 | + fn new(secret: &str, algorithm: &str) -> PyResult<Self> { | |
19 | + let alg = parse_algorithm(algorithm)?; | |
20 | + Ok(Self { | |
21 | + encoding_key: jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()), | |
22 | + decoding_key: jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()), | |
23 | + algorithm: alg, | |
24 | + validation: jsonwebtoken::Validation::new(alg), | |
25 | + }) | |
26 | + } | |
27 | + | |
28 | + fn encode(&self, _py: Python<'_>, claims: &Bound<'_, PyDict>) -> PyResult<String> { | |
29 | + let claims_json = claims_to_json(claims); | |
30 | + jsonwebtoken::encode( | |
31 | + &jsonwebtoken::Header::new(self.algorithm), | |
32 | + &claims_json, | |
33 | + &self.encoding_key, | |
34 | + ) | |
35 | + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) | |
36 | + } | |
37 | + | |
38 | + #[pyo3(signature = (token, options = None))] | |
39 | + fn decode( | |
40 | + &self, | |
41 | + py: Python<'_>, | |
42 | + token: &str, | |
43 | + options: Option<&Bound<'_, PyDict>>, | |
44 | + ) -> PyResult<Py<PyDict>> { | |
45 | + let mut validation = self.validation.clone(); | |
46 | + if let Some(opts) = options { | |
47 | + for (k, v) in opts.iter() { | |
48 | + let key: String = k.extract()?; | |
49 | + let val: bool = v.extract()?; | |
50 | + if key.as_str() == "verify_exp" { | |
51 | + validation.validate_exp = val; | |
52 | + } | |
53 | + } | |
54 | + } | |
55 | + let token_data = | |
56 | + jsonwebtoken::decode::<serde_json::Value>(token, &self.decoding_key, &validation) | |
57 | + .map_err(|e| { | |
58 | + pyo3::exceptions::PyValueError::new_err(format!("Invalid token: {}", e)) | |
59 | + })?; | |
60 | + json_value_to_py_dict(py, &token_data.claims) | |
61 | + } | |
62 | + | |
63 | + fn __repr__(&self) -> String { | |
64 | + format!("_JwtAuth(algorithm={:?})", self.algorithm) | |
65 | + } | |
66 | +} | |
67 | + | |
68 | +fn parse_algorithm(s: &str) -> PyResult<jsonwebtoken::Algorithm> { | |
69 | + match s.to_uppercase().as_str() { | |
70 | + "HS256" => Ok(jsonwebtoken::Algorithm::HS256), | |
71 | + "HS384" => Ok(jsonwebtoken::Algorithm::HS384), | |
72 | + "HS512" => Ok(jsonwebtoken::Algorithm::HS512), | |
73 | + "RS256" => Ok(jsonwebtoken::Algorithm::RS256), | |
74 | + "RS384" => Ok(jsonwebtoken::Algorithm::RS384), | |
75 | + "RS512" => Ok(jsonwebtoken::Algorithm::RS512), | |
76 | + "ES256" => Ok(jsonwebtoken::Algorithm::ES256), | |
77 | + "ES384" => Ok(jsonwebtoken::Algorithm::ES384), | |
78 | + "ED25519" => Ok(jsonwebtoken::Algorithm::EdDSA), | |
79 | + _ => Err(pyo3::exceptions::PyValueError::new_err(format!( | |
80 | + "Unsupported algorithm: {}. Use HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, or ED25519", | |
81 | + s | |
82 | + ))), | |
83 | + } | |
84 | +} | |
85 | + | |
86 | +fn claims_to_json(claims: &Bound<'_, PyDict>) -> serde_json::Value { | |
87 | + let mut map = serde_json::Map::new(); | |
88 | + for (k, v) in claims.iter() { | |
89 | + let key: String = k.extract().unwrap_or_default(); | |
90 | + map.insert(key, py_any_to_json(&v)); | |
91 | + } | |
92 | + serde_json::Value::Object(map) | |
93 | +} | |
94 | + | |
95 | +fn py_any_to_json(obj: &Bound<'_, PyAny>) -> serde_json::Value { | |
96 | + if let Ok(s) = obj.extract::<String>() { | |
97 | + return serde_json::Value::String(s); | |
98 | + } | |
99 | + if let Ok(i) = obj.extract::<i64>() { | |
100 | + return serde_json::Value::Number(i.into()); | |
101 | + } | |
102 | + if let Ok(f) = obj.extract::<f64>() { | |
103 | + if let Some(n) = serde_json::Number::from_f64(f) { | |
104 | + return serde_json::Value::Number(n); | |
105 | + } | |
106 | + return serde_json::Value::String(f.to_string()); | |
107 | + } | |
108 | + if let Ok(b) = obj.extract::<bool>() { | |
109 | + return serde_json::Value::Bool(b); | |
110 | + } | |
111 | + if let Ok(list) = obj.extract::<Vec<Bound<'_, PyAny>>>() { | |
112 | + let arr: Vec<serde_json::Value> = list.iter().map(|x| py_any_to_json(x)).collect(); | |
113 | + return serde_json::Value::Array(arr); | |
114 | + } | |
115 | + serde_json::Value::Null | |
116 | +} | |
117 | + | |
118 | +fn json_value_to_py_dict(py: Python<'_>, value: &serde_json::Value) -> PyResult<Py<PyDict>> { | |
119 | + match value { | |
120 | + serde_json::Value::Object(map) => { | |
121 | + let d = PyDict::new(py); | |
122 | + for (k, v) in map { | |
123 | + d.set_item(k, json_value_to_py(py, v)?)?; | |
124 | + } | |
125 | + Ok(d.into()) | |
126 | + } | |
127 | + _ => Err(pyo3::exceptions::PyTypeError::new_err("expected object")), | |
128 | + } | |
129 | +} | |
130 | + | |
131 | +fn json_value_to_py<'py>(py: Python<'py>, v: &serde_json::Value) -> PyResult<Bound<'py, PyAny>> { | |
132 | + match v { | |
133 | + serde_json::Value::Null => Ok(py.None().into_bound(py)), | |
134 | + serde_json::Value::Bool(b) => Ok((*b).into_pyobject(py)?.as_any().clone()), | |
135 | + serde_json::Value::Number(n) => { | |
136 | + if let Some(i) = n.as_i64() { | |
137 | + Ok(i.into_pyobject(py)?.as_any().clone()) | |
138 | + } else if let Some(u) = n.as_u64() { | |
139 | + Ok(u.into_pyobject(py)?.as_any().clone()) | |
140 | + } else { | |
141 | + Ok(n.as_f64().unwrap_or(0.0).into_pyobject(py)?.as_any().clone()) | |
142 | + } | |
143 | + } | |
144 | + serde_json::Value::String(s) => Ok(s.clone().into_pyobject(py)?.as_any().clone()), | |
145 | + serde_json::Value::Array(a) => { | |
146 | + let list = PyList::empty(py); | |
147 | + for item in a { | |
148 | + list.append(json_value_to_py(py, item)?)?; | |
149 | + } | |
150 | + Ok(list.as_any().clone()) | |
151 | + } | |
152 | + serde_json::Value::Object(o) => { | |
153 | + let d = PyDict::new(py); | |
154 | + for (k, val) in o { | |
155 | + d.set_item(k, json_value_to_py(py, val)?)?; | |
156 | + } | |
157 | + Ok(d.as_any().clone()) | |
158 | + } | |
159 | + } | |
160 | +} | |
161 | + | |
162 | +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { | |
163 | + m.add_class::<PyJwtAuth>()?; | |
164 | + Ok(()) | |
165 | +} | |
0 | 166 | |