✨ feat: new auth module — Rust + Python bindings

swadhinbiswas committed 3 months ago
87b0fee
1 parent 987fd04
commit 87b0fee
3 files changed
+436 -0
▼crates/justapi-py/python/justapi/auth.py
+222-0
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
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
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