fastapi-openrpc 0.1.4__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/PKG-INFO +63 -17
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/README.md +47 -4
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/pyproject.toml +6 -6
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/__init__.py +7 -1
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/authentication.py +97 -53
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/authorization.py +17 -5
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/decorator.py +261 -129
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/document.py +130 -108
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/errors.py +51 -5
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/registry.py +10 -3
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/router.py +466 -323
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/types.py +2 -1
- fastapi_openrpc-0.1.4/.env.example +0 -5
- fastapi_openrpc-0.1.4/.gitignore +0 -21
- fastapi_openrpc-0.1.4/.pre-commit-config.yaml +0 -46
- fastapi_openrpc-0.1.4/CLAUDE.md +0 -76
- fastapi_openrpc-0.1.4/Justfile +0 -73
- fastapi_openrpc-0.1.4/README_zh.md +0 -196
- fastapi_openrpc-0.1.4/docs/agents/domain.md +0 -47
- fastapi_openrpc-0.1.4/docs/agents/issue-tracker.md +0 -52
- fastapi_openrpc-0.1.4/docs/agents/triage-labels.md +0 -25
- fastapi_openrpc-0.1.4/docs/brainstorms/2026-04-23-openrpc-components-schemas-requirements.md +0 -127
- fastapi_openrpc-0.1.4/docs/brainstorms/2026-04-23-rpc-context-type-injection-requirements.md +0 -97
- fastapi_openrpc-0.1.4/docs/brainstorms/2026-06-01-replay-protection-requirements.md +0 -102
- fastapi_openrpc-0.1.4/docs/en/index.md +0 -844
- fastapi_openrpc-0.1.4/docs/explanation-concurrency.md +0 -233
- fastapi_openrpc-0.1.4/docs/howto-error-handling.md +0 -465
- fastapi_openrpc-0.1.4/docs/index.md +0 -11
- fastapi_openrpc-0.1.4/docs/plans/2026-04-23-001-refactor-rpc-context-type-injection-plan.md +0 -252
- fastapi_openrpc-0.1.4/docs/plans/2026-04-23-002-refactor-openrpc-components-schemas-plan.md +0 -474
- fastapi_openrpc-0.1.4/docs/plans/2026-06-01-001-feat-replay-protection-plan.md +0 -260
- fastapi_openrpc-0.1.4/docs/plans/2026-06-01-002-fix-code-review-issues-plan.md +0 -353
- fastapi_openrpc-0.1.4/docs/tutorial-pydantic-models.md +0 -353
- fastapi_openrpc-0.1.4/docs/zh/index.md +0 -852
- fastapi_openrpc-0.1.4/examples/hmac_auth_dependency.py +0 -66
- fastapi_openrpc-0.1.4/examples/minimal_app.py +0 -41
- fastapi_openrpc-0.1.4/examples/pydantic_models.py +0 -120
- fastapi_openrpc-0.1.4/tests/http/.httpyac.js +0 -55
- fastapi_openrpc-0.1.4/tests/http/api.http +0 -150
- fastapi_openrpc-0.1.4/tests/http/hmac.http +0 -32
- fastapi_openrpc-0.1.4/tests/http/minimal.http +0 -25
- fastapi_openrpc-0.1.4/tests/http/pydantic.http +0 -38
- fastapi_openrpc-0.1.4/tests/test_authentication.py +0 -821
- fastapi_openrpc-0.1.4/tests/test_authorization.py +0 -179
- fastapi_openrpc-0.1.4/tests/test_decorator.py +0 -434
- fastapi_openrpc-0.1.4/tests/test_document.py +0 -470
- fastapi_openrpc-0.1.4/tests/test_errors.py +0 -126
- fastapi_openrpc-0.1.4/tests/test_registry.py +0 -160
- fastapi_openrpc-0.1.4/tests/test_router.py +0 -496
- fastapi_openrpc-0.1.4/uv.lock +0 -655
- {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/constants.py +0 -0
|
@@ -1,28 +1,31 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: fastapi-openrpc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: FastAPI JSON-RPC 2.0 + OpenRPC 1.0 service discovery plugin
|
|
5
|
+
Keywords: fastapi,json-rpc,openrpc,rpc,discover,ai-agent
|
|
6
|
+
Author: Ye Wenbin
|
|
5
7
|
Author-email: Ye Wenbin <yewenbin@winwin-tech.com>
|
|
6
8
|
License: MIT
|
|
7
|
-
Keywords: ai-agent,discover,fastapi,json-rpc,openrpc,rpc
|
|
8
9
|
Classifier: Framework :: FastAPI
|
|
9
|
-
Classifier: Intended Audience :: Developers
|
|
10
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
11
10
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
11
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
-
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
14
|
Requires-Dist: fastapi>=0.100.0
|
|
15
15
|
Requires-Dist: pydantic>=2.0
|
|
16
16
|
Requires-Dist: typing-extensions>=4.8
|
|
17
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.4 ; extra == 'dev'
|
|
20
|
+
Requires-Dist: mypy>=1.10 ; extra == 'dev'
|
|
21
|
+
Requires-Dist: httpx>=0.27 ; extra == 'dev'
|
|
22
|
+
Requires-Dist: uvicorn[standard]>=0.51.0 ; extra == 'dev'
|
|
23
|
+
Requires-Dist: fakeredis>=2.21 ; extra == 'dev'
|
|
24
|
+
Requires-Dist: redis[hiredis]>=5.0 ; extra == 'redis'
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Project-URL: Source, https://github.com/winwin-inc/fastapi-openrpc
|
|
17
27
|
Provides-Extra: dev
|
|
18
|
-
Requires-Dist: fakeredis>=2.21; extra == 'dev'
|
|
19
|
-
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
20
|
-
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
21
|
-
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
22
|
-
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
23
|
-
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
24
28
|
Provides-Extra: redis
|
|
25
|
-
Requires-Dist: redis[hiredis]>=5.0; extra == 'redis'
|
|
26
29
|
Description-Content-Type: text/markdown
|
|
27
30
|
|
|
28
31
|
# fastapi-openrpc
|
|
@@ -41,6 +44,7 @@ Declarative JSON-RPC 2.0 interface and OpenRPC 1.0 service discovery for FastAPI
|
|
|
41
44
|
- **RpcContext Propagation** — Secure caller context injection via FastAPI `Depends()`
|
|
42
45
|
- **HMAC Authentication** — Built-in signature verification with Nonce replay protection
|
|
43
46
|
- **Role-Based Authorization** — Method-level role declarations with composition semantics
|
|
47
|
+
- **Business Errors** — Structured error codes with `errors` array in OpenRPC document
|
|
44
48
|
|
|
45
49
|
## Installation
|
|
46
50
|
|
|
@@ -108,10 +112,9 @@ auth = HMACAuthentication(
|
|
|
108
112
|
enable_nonce=True,
|
|
109
113
|
redis_url="redis://localhost:6379",
|
|
110
114
|
nonce_ttl=3600,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
cleanup_timeout=2.0, # Redis cleanup timeout (seconds)
|
|
115
|
+
connect_timeout=2.0,
|
|
116
|
+
operation_timeout=5.0,
|
|
117
|
+
cleanup_timeout=2.0,
|
|
115
118
|
)
|
|
116
119
|
```
|
|
117
120
|
|
|
@@ -142,6 +145,8 @@ async def edit_content(ctx: RpcContext) -> dict:
|
|
|
142
145
|
|
|
143
146
|
## Error Handling
|
|
144
147
|
|
|
148
|
+
### Built-in Errors
|
|
149
|
+
|
|
145
150
|
```python
|
|
146
151
|
from fastapi_openrpc import InvalidParams, MethodNotFound
|
|
147
152
|
|
|
@@ -164,6 +169,46 @@ Built-in error codes:
|
|
|
164
169
|
| Forbidden | -32000 |
|
|
165
170
|
| Replay Detected | -32002 |
|
|
166
171
|
|
|
172
|
+
### Business Errors
|
|
173
|
+
|
|
174
|
+
Define custom errors by subclassing `BusinessError`:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from fastapi_openrpc import BusinessError
|
|
178
|
+
|
|
179
|
+
# Define error codes starting from -32100
|
|
180
|
+
class UserNotFoundError(BusinessError):
|
|
181
|
+
code = -32101
|
|
182
|
+
message = "User not found"
|
|
183
|
+
|
|
184
|
+
def __init__(self, user_id: str):
|
|
185
|
+
super().__init__(data={"user_id": user_id})
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Register error classes with methods using the `errors` parameter:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from examples.errors import UserNotFoundError # Your error class
|
|
192
|
+
|
|
193
|
+
@method(name="getUser", errors=[UserNotFoundError])
|
|
194
|
+
async def get_user(ctx: RpcContext, user_id: str) -> dict:
|
|
195
|
+
user = db.get(user_id)
|
|
196
|
+
if not user:
|
|
197
|
+
raise UserNotFoundError(user_id=user_id)
|
|
198
|
+
return user
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The `errors` array appears in the OpenRPC document for that method, enabling SDK auto-generation of typed exceptions:
|
|
202
|
+
|
|
203
|
+
```json
|
|
204
|
+
{
|
|
205
|
+
"name": "getUser",
|
|
206
|
+
"errors": [
|
|
207
|
+
{"code": -32101, "message": "User not found"}
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
167
212
|
Library reserves -32000 ~ -32099, application custom errors start from -32100.
|
|
168
213
|
|
|
169
214
|
## API Reference
|
|
@@ -195,6 +240,7 @@ app.include_router(openrpc_router)
|
|
|
195
240
|
deprecated=False,
|
|
196
241
|
required_roles=["admin"], # Required roles
|
|
197
242
|
require=Require.ALL, # Role composition semantics
|
|
243
|
+
errors=[MyError], # Business error subclasses this method may raise
|
|
198
244
|
)
|
|
199
245
|
async def my_handler(ctx: RpcContext, arg1: str, arg2: int = 10) -> dict:
|
|
200
246
|
return {"result": arg1}
|
|
@@ -14,6 +14,7 @@ Declarative JSON-RPC 2.0 interface and OpenRPC 1.0 service discovery for FastAPI
|
|
|
14
14
|
- **RpcContext Propagation** — Secure caller context injection via FastAPI `Depends()`
|
|
15
15
|
- **HMAC Authentication** — Built-in signature verification with Nonce replay protection
|
|
16
16
|
- **Role-Based Authorization** — Method-level role declarations with composition semantics
|
|
17
|
+
- **Business Errors** — Structured error codes with `errors` array in OpenRPC document
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
@@ -81,10 +82,9 @@ auth = HMACAuthentication(
|
|
|
81
82
|
enable_nonce=True,
|
|
82
83
|
redis_url="redis://localhost:6379",
|
|
83
84
|
nonce_ttl=3600,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
cleanup_timeout=2.0, # Redis cleanup timeout (seconds)
|
|
85
|
+
connect_timeout=2.0,
|
|
86
|
+
operation_timeout=5.0,
|
|
87
|
+
cleanup_timeout=2.0,
|
|
88
88
|
)
|
|
89
89
|
```
|
|
90
90
|
|
|
@@ -115,6 +115,8 @@ async def edit_content(ctx: RpcContext) -> dict:
|
|
|
115
115
|
|
|
116
116
|
## Error Handling
|
|
117
117
|
|
|
118
|
+
### Built-in Errors
|
|
119
|
+
|
|
118
120
|
```python
|
|
119
121
|
from fastapi_openrpc import InvalidParams, MethodNotFound
|
|
120
122
|
|
|
@@ -137,6 +139,46 @@ Built-in error codes:
|
|
|
137
139
|
| Forbidden | -32000 |
|
|
138
140
|
| Replay Detected | -32002 |
|
|
139
141
|
|
|
142
|
+
### Business Errors
|
|
143
|
+
|
|
144
|
+
Define custom errors by subclassing `BusinessError`:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from fastapi_openrpc import BusinessError
|
|
148
|
+
|
|
149
|
+
# Define error codes starting from -32100
|
|
150
|
+
class UserNotFoundError(BusinessError):
|
|
151
|
+
code = -32101
|
|
152
|
+
message = "User not found"
|
|
153
|
+
|
|
154
|
+
def __init__(self, user_id: str):
|
|
155
|
+
super().__init__(data={"user_id": user_id})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Register error classes with methods using the `errors` parameter:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from examples.errors import UserNotFoundError # Your error class
|
|
162
|
+
|
|
163
|
+
@method(name="getUser", errors=[UserNotFoundError])
|
|
164
|
+
async def get_user(ctx: RpcContext, user_id: str) -> dict:
|
|
165
|
+
user = db.get(user_id)
|
|
166
|
+
if not user:
|
|
167
|
+
raise UserNotFoundError(user_id=user_id)
|
|
168
|
+
return user
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The `errors` array appears in the OpenRPC document for that method, enabling SDK auto-generation of typed exceptions:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"name": "getUser",
|
|
176
|
+
"errors": [
|
|
177
|
+
{"code": -32101, "message": "User not found"}
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
140
182
|
Library reserves -32000 ~ -32099, application custom errors start from -32100.
|
|
141
183
|
|
|
142
184
|
## API Reference
|
|
@@ -168,6 +210,7 @@ app.include_router(openrpc_router)
|
|
|
168
210
|
deprecated=False,
|
|
169
211
|
required_roles=["admin"], # Required roles
|
|
170
212
|
require=Require.ALL, # Role composition semantics
|
|
213
|
+
errors=[MyError], # Business error subclasses this method may raise
|
|
171
214
|
)
|
|
172
215
|
async def my_handler(ctx: RpcContext, arg1: str, arg2: int = 10) -> dict:
|
|
173
216
|
return {"result": arg1}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-openrpc"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "FastAPI JSON-RPC 2.0 + OpenRPC 1.0 service discovery plugin"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -17,6 +17,8 @@ classifiers = [
|
|
|
17
17
|
"Intended Audience :: Developers",
|
|
18
18
|
]
|
|
19
19
|
|
|
20
|
+
urls = { Source = "https://github.com/winwin-inc/fastapi-openrpc" }
|
|
21
|
+
|
|
20
22
|
dependencies = [
|
|
21
23
|
"fastapi>=0.100.0",
|
|
22
24
|
"pydantic>=2.0",
|
|
@@ -30,6 +32,7 @@ dev = [
|
|
|
30
32
|
"ruff>=0.4",
|
|
31
33
|
"mypy>=1.10",
|
|
32
34
|
"httpx>=0.27",
|
|
35
|
+
"uvicorn[standard]>=0.51.0",
|
|
33
36
|
"fakeredis>=2.21",
|
|
34
37
|
]
|
|
35
38
|
redis = [
|
|
@@ -37,11 +40,8 @@ redis = [
|
|
|
37
40
|
]
|
|
38
41
|
|
|
39
42
|
[build-system]
|
|
40
|
-
requires = ["
|
|
41
|
-
build-backend = "
|
|
42
|
-
|
|
43
|
-
[tool.hatch.build.targets.wheel]
|
|
44
|
-
packages = ["src/fastapi_openrpc"]
|
|
43
|
+
requires = ["uv_build>=0.11.6,<0.12.0"]
|
|
44
|
+
build-backend = "uv_build"
|
|
45
45
|
|
|
46
46
|
[tool.ruff]
|
|
47
47
|
line-length = 100
|
|
@@ -23,12 +23,14 @@ from __future__ import annotations
|
|
|
23
23
|
|
|
24
24
|
from fastapi_openrpc.authentication import (
|
|
25
25
|
AuthenticationMiddleware,
|
|
26
|
+
HMACAuthConfig,
|
|
26
27
|
HMACAuthentication,
|
|
27
28
|
)
|
|
28
29
|
from fastapi_openrpc.authorization import (
|
|
29
30
|
AuthorizationPolicy,
|
|
30
31
|
DefaultAuthorizationPolicy,
|
|
31
32
|
Require,
|
|
33
|
+
RoleBasedAuthorizationPolicy,
|
|
32
34
|
)
|
|
33
35
|
from fastapi_openrpc.constants import (
|
|
34
36
|
JSONRPC_FORBIDDEN,
|
|
@@ -44,6 +46,7 @@ from fastapi_openrpc.decorator import method
|
|
|
44
46
|
from fastapi_openrpc.document import build_openrpc_document
|
|
45
47
|
from fastapi_openrpc.errors import (
|
|
46
48
|
AuthenticationError,
|
|
49
|
+
BusinessError,
|
|
47
50
|
Forbidden,
|
|
48
51
|
InternalError,
|
|
49
52
|
InvalidParams,
|
|
@@ -58,7 +61,7 @@ from fastapi_openrpc.registry import add, clear, get, list_methods
|
|
|
58
61
|
from fastapi_openrpc.router import OpenRpcRouter
|
|
59
62
|
from fastapi_openrpc.types import MethodMeta, RpcContext
|
|
60
63
|
|
|
61
|
-
__version__ = "0.1.
|
|
64
|
+
__version__ = "0.1.4"
|
|
62
65
|
|
|
63
66
|
__all__ = [
|
|
64
67
|
# Version
|
|
@@ -79,7 +82,9 @@ __all__ = [
|
|
|
79
82
|
"Require",
|
|
80
83
|
"AuthorizationPolicy",
|
|
81
84
|
"DefaultAuthorizationPolicy",
|
|
85
|
+
"RoleBasedAuthorizationPolicy",
|
|
82
86
|
# Error Classes
|
|
87
|
+
"BusinessError",
|
|
83
88
|
"JsonRpcError",
|
|
84
89
|
"ParseError",
|
|
85
90
|
"InvalidRequest",
|
|
@@ -101,4 +106,5 @@ __all__ = [
|
|
|
101
106
|
"AuthenticationMiddleware",
|
|
102
107
|
"AuthenticationError",
|
|
103
108
|
"HMACAuthentication",
|
|
109
|
+
"HMACAuthConfig",
|
|
104
110
|
]
|
|
@@ -8,7 +8,9 @@ import hmac
|
|
|
8
8
|
import json
|
|
9
9
|
import logging
|
|
10
10
|
import re
|
|
11
|
-
from
|
|
11
|
+
from dataclasses import dataclass, field, fields
|
|
12
|
+
from dataclasses import replace as _replace
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
12
14
|
|
|
13
15
|
import redis.asyncio
|
|
14
16
|
from typing_extensions import Protocol, runtime_checkable
|
|
@@ -38,6 +40,26 @@ class AuthenticationMiddleware(Protocol):
|
|
|
38
40
|
...
|
|
39
41
|
|
|
40
42
|
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class HMACAuthConfig:
|
|
45
|
+
"""Configuration for HMACAuthentication.
|
|
46
|
+
|
|
47
|
+
Use as ``HMACAuthentication(config=HMACAuthConfig(...))`` or spread kwargs
|
|
48
|
+
directly: ``HMACAuthentication(roles_header="X-Custom", secret_key="...")``.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
roles_header: str = "X-Forwarded-User-Info"
|
|
52
|
+
signature_header: str = "X-Forwarded-User-Info-Signature"
|
|
53
|
+
secret_key: str | None = None
|
|
54
|
+
secret_keys: list[str] = field(default_factory=list)
|
|
55
|
+
enable_nonce: bool = False
|
|
56
|
+
redis_url: str | None = None
|
|
57
|
+
nonce_ttl: int = 3600
|
|
58
|
+
connect_timeout: float = 2.0
|
|
59
|
+
operation_timeout: float = 5.0
|
|
60
|
+
cleanup_timeout: float = 2.0
|
|
61
|
+
|
|
62
|
+
|
|
41
63
|
class HMACAuthentication:
|
|
42
64
|
"""HMAC-SHA256 signature verification authentication.
|
|
43
65
|
|
|
@@ -56,56 +78,84 @@ class HMACAuthentication:
|
|
|
56
78
|
Optional Nonce replay attack protection:
|
|
57
79
|
- Requires ``enable_nonce=True`` and ``redis_url`` when enabled
|
|
58
80
|
- Uses Redis SETEX NX command for atomic deduplication
|
|
59
|
-
-
|
|
81
|
+
- Redis connection failures result in authentication failure (fail-close)
|
|
60
82
|
"""
|
|
61
83
|
|
|
62
84
|
def __init__(
|
|
63
85
|
self,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
secret_key: str | None = None,
|
|
67
|
-
enable_nonce: bool = False,
|
|
68
|
-
redis_url: str | None = None,
|
|
69
|
-
nonce_ttl: int = 3600,
|
|
70
|
-
redis_required: bool = True,
|
|
71
|
-
connect_timeout: float = 2.0,
|
|
72
|
-
operation_timeout: float = 5.0,
|
|
73
|
-
cleanup_timeout: float = 2.0,
|
|
86
|
+
config: HMACAuthConfig | None = None,
|
|
87
|
+
**kwargs: Any,
|
|
74
88
|
):
|
|
89
|
+
"""Construct HMACAuthentication.
|
|
90
|
+
|
|
91
|
+
Supports two construction styles::
|
|
92
|
+
|
|
93
|
+
# Config object (preferred for new code)
|
|
94
|
+
HMACAuthentication(config=HMACAuthConfig(secret_key="..."))
|
|
95
|
+
|
|
96
|
+
# Keyword arguments (backward-compatible)
|
|
97
|
+
HMACAuthentication(secret_key="...")
|
|
75
98
|
"""
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
# ``config`` may be passed positionally or by keyword; pop it out of
|
|
100
|
+
# kwargs so it doesn't trip the unknown-kwarg check below.
|
|
101
|
+
if "config" in kwargs:
|
|
102
|
+
if config is not None:
|
|
103
|
+
raise TypeError("config specified twice (positional and keyword)")
|
|
104
|
+
config = kwargs.pop("config")
|
|
105
|
+
|
|
106
|
+
if config is None:
|
|
107
|
+
config = HMACAuthConfig()
|
|
108
|
+
elif not isinstance(config, HMACAuthConfig):
|
|
109
|
+
raise TypeError("config must be an HMACAuthConfig instance")
|
|
110
|
+
|
|
111
|
+
# Reject unknown kwargs up front with a clear error. dataclass field
|
|
112
|
+
# names are the single source of truth — adding a new HMACAuthConfig
|
|
113
|
+
# field automatically extends the accepted kwargs.
|
|
114
|
+
valid_fields = {f.name for f in fields(HMACAuthConfig)}
|
|
115
|
+
unknown = set(kwargs) - valid_fields
|
|
116
|
+
if unknown:
|
|
117
|
+
raise TypeError(
|
|
118
|
+
f"Unknown HMACAuthentication kwargs: {sorted(unknown)}. "
|
|
119
|
+
f"Valid fields: {sorted(valid_fields)}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Merge config defaults with explicit kwargs (kwargs override config).
|
|
123
|
+
# ``None`` means "use the config default" (preserves historical contract).
|
|
124
|
+
if kwargs:
|
|
125
|
+
cfg = _replace(config, **{k: v for k, v in kwargs.items() if v is not None})
|
|
126
|
+
else:
|
|
127
|
+
cfg = config
|
|
128
|
+
|
|
129
|
+
if cfg.enable_nonce and not cfg.redis_url:
|
|
95
130
|
raise ValueError(
|
|
96
131
|
"redis_url is required when enable_nonce=True. "
|
|
97
132
|
"Example: redis_url='redis://localhost:6379'"
|
|
98
133
|
)
|
|
99
|
-
self.roles_header = roles_header
|
|
100
|
-
self.signature_header = signature_header
|
|
101
|
-
self.secret_key = secret_key
|
|
102
|
-
self.
|
|
103
|
-
self.
|
|
104
|
-
self.
|
|
105
|
-
self.
|
|
106
|
-
self.connect_timeout = connect_timeout
|
|
107
|
-
self.operation_timeout = operation_timeout
|
|
108
|
-
self.cleanup_timeout = cleanup_timeout
|
|
134
|
+
self.roles_header = cfg.roles_header
|
|
135
|
+
self.signature_header = cfg.signature_header
|
|
136
|
+
self.secret_key = cfg.secret_key
|
|
137
|
+
self.secret_keys = cfg.secret_keys
|
|
138
|
+
self.enable_nonce = cfg.enable_nonce
|
|
139
|
+
self.redis_url = cfg.redis_url
|
|
140
|
+
self.nonce_ttl = cfg.nonce_ttl
|
|
141
|
+
self.connect_timeout = cfg.connect_timeout
|
|
142
|
+
self.operation_timeout = cfg.operation_timeout
|
|
143
|
+
self.cleanup_timeout = cfg.cleanup_timeout
|
|
144
|
+
|
|
145
|
+
def _verify_signature(self, user_info_raw: str | None, signature_raw: str) -> bool:
|
|
146
|
+
"""Try keys in order; return True if any key matches."""
|
|
147
|
+
keys_to_try: list[str | None] = [self.secret_key] + self.secret_keys
|
|
148
|
+
for key in keys_to_try:
|
|
149
|
+
if not key:
|
|
150
|
+
continue
|
|
151
|
+
expected = hmac.new(
|
|
152
|
+
key.encode(),
|
|
153
|
+
(user_info_raw or "").encode(),
|
|
154
|
+
hashlib.sha256,
|
|
155
|
+
).hexdigest()
|
|
156
|
+
if hmac.compare_digest(expected, signature_raw):
|
|
157
|
+
return True
|
|
158
|
+
return False
|
|
109
159
|
|
|
110
160
|
async def authenticate(self, request: Request) -> RpcContext:
|
|
111
161
|
"""Verify HMAC signature and parse role list.
|
|
@@ -123,21 +173,15 @@ class HMACAuthentication:
|
|
|
123
173
|
user_info_raw = request.headers.get(self.roles_header)
|
|
124
174
|
signature_raw = request.headers.get(self.signature_header)
|
|
125
175
|
|
|
126
|
-
#
|
|
127
|
-
if self.secret_key:
|
|
176
|
+
# Verify HMAC signature before trusting header content (fail-fast on tampering)
|
|
177
|
+
if self.secret_key or self.secret_keys:
|
|
128
178
|
if not signature_raw:
|
|
129
179
|
raise AuthenticationError("Missing signature")
|
|
130
|
-
|
|
131
|
-
expected = hmac.new(
|
|
132
|
-
self.secret_key.encode(),
|
|
133
|
-
(user_info_raw or "").encode(),
|
|
134
|
-
hashlib.sha256,
|
|
135
|
-
).hexdigest()
|
|
136
|
-
if not hmac.compare_digest(expected, signature_raw):
|
|
180
|
+
if not self._verify_signature(user_info_raw, signature_raw):
|
|
137
181
|
raise AuthenticationError("Invalid signature")
|
|
138
182
|
|
|
139
|
-
#
|
|
140
|
-
#
|
|
183
|
+
# Parse roles from header content; runs regardless of secret_key to support
|
|
184
|
+
# trusted-network mode (secret_key=None) where headers still carry role info
|
|
141
185
|
user_info_dict: dict | list | None = None
|
|
142
186
|
if user_info_raw:
|
|
143
187
|
try:
|
|
@@ -159,7 +203,7 @@ class HMACAuthentication:
|
|
|
159
203
|
roles = []
|
|
160
204
|
user_id = None
|
|
161
205
|
|
|
162
|
-
#
|
|
206
|
+
# Nonce verification (if enabled)
|
|
163
207
|
if self.enable_nonce:
|
|
164
208
|
nonce: str | None = None
|
|
165
209
|
if isinstance(user_info_dict, dict):
|
|
@@ -26,6 +26,7 @@ class AuthorizationPolicy(Protocol):
|
|
|
26
26
|
self,
|
|
27
27
|
method_name: str,
|
|
28
28
|
required_roles: list[str],
|
|
29
|
+
require: Require,
|
|
29
30
|
ctx: RpcContext,
|
|
30
31
|
) -> bool:
|
|
31
32
|
"""Determine whether the caller is authorized to invoke the specified method.
|
|
@@ -33,7 +34,8 @@ class AuthorizationPolicy(Protocol):
|
|
|
33
34
|
Args:
|
|
34
35
|
method_name: The name of the RPC method being invoked.
|
|
35
36
|
required_roles: List of roles required by the method.
|
|
36
|
-
|
|
37
|
+
require: Role combination semantics (ANY/ALL) declared by the method.
|
|
38
|
+
ctx: Caller context containing roles and user_id fields.
|
|
37
39
|
|
|
38
40
|
Returns:
|
|
39
41
|
True if authorization is granted, False otherwise.
|
|
@@ -41,8 +43,8 @@ class AuthorizationPolicy(Protocol):
|
|
|
41
43
|
...
|
|
42
44
|
|
|
43
45
|
|
|
44
|
-
class
|
|
45
|
-
"""
|
|
46
|
+
class RoleBasedAuthorizationPolicy:
|
|
47
|
+
"""Role-based authorization policy.
|
|
46
48
|
|
|
47
49
|
ANY semantics: True if any required_roles match.
|
|
48
50
|
ALL semantics: True only if all required_roles are present in ctx.roles.
|
|
@@ -55,6 +57,7 @@ class DefaultAuthorizationPolicy:
|
|
|
55
57
|
self,
|
|
56
58
|
method_name: str,
|
|
57
59
|
required_roles: list[str],
|
|
60
|
+
require: Require,
|
|
58
61
|
ctx: RpcContext,
|
|
59
62
|
) -> bool:
|
|
60
63
|
# Empty role list -> skip check (backward compatible, R3)
|
|
@@ -65,7 +68,7 @@ class DefaultAuthorizationPolicy:
|
|
|
65
68
|
ctx_roles = {r.lower() for r in ctx.roles}
|
|
66
69
|
needed = {r.lower() for r in required_roles}
|
|
67
70
|
|
|
68
|
-
if
|
|
71
|
+
if require == Require.ALL:
|
|
69
72
|
return needed <= ctx_roles # All required_roles present in ctx.roles
|
|
70
73
|
else:
|
|
71
74
|
# Require.ANY (default)
|
|
@@ -76,4 +79,13 @@ class DefaultAuthorizationPolicy:
|
|
|
76
79
|
return False
|
|
77
80
|
|
|
78
81
|
|
|
79
|
-
|
|
82
|
+
# Backward-compatible alias
|
|
83
|
+
DefaultAuthorizationPolicy = RoleBasedAuthorizationPolicy
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"Require",
|
|
88
|
+
"AuthorizationPolicy",
|
|
89
|
+
"RoleBasedAuthorizationPolicy",
|
|
90
|
+
"DefaultAuthorizationPolicy", # noqa: E501 backward-compatible alias
|
|
91
|
+
]
|