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.
Files changed (51) hide show
  1. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/PKG-INFO +63 -17
  2. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/README.md +47 -4
  3. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/pyproject.toml +6 -6
  4. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/__init__.py +7 -1
  5. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/authentication.py +97 -53
  6. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/authorization.py +17 -5
  7. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/decorator.py +261 -129
  8. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/document.py +130 -108
  9. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/errors.py +51 -5
  10. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/registry.py +10 -3
  11. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/router.py +466 -323
  12. {fastapi_openrpc-0.1.4 → fastapi_openrpc-0.2.0}/src/fastapi_openrpc/types.py +2 -1
  13. fastapi_openrpc-0.1.4/.env.example +0 -5
  14. fastapi_openrpc-0.1.4/.gitignore +0 -21
  15. fastapi_openrpc-0.1.4/.pre-commit-config.yaml +0 -46
  16. fastapi_openrpc-0.1.4/CLAUDE.md +0 -76
  17. fastapi_openrpc-0.1.4/Justfile +0 -73
  18. fastapi_openrpc-0.1.4/README_zh.md +0 -196
  19. fastapi_openrpc-0.1.4/docs/agents/domain.md +0 -47
  20. fastapi_openrpc-0.1.4/docs/agents/issue-tracker.md +0 -52
  21. fastapi_openrpc-0.1.4/docs/agents/triage-labels.md +0 -25
  22. fastapi_openrpc-0.1.4/docs/brainstorms/2026-04-23-openrpc-components-schemas-requirements.md +0 -127
  23. fastapi_openrpc-0.1.4/docs/brainstorms/2026-04-23-rpc-context-type-injection-requirements.md +0 -97
  24. fastapi_openrpc-0.1.4/docs/brainstorms/2026-06-01-replay-protection-requirements.md +0 -102
  25. fastapi_openrpc-0.1.4/docs/en/index.md +0 -844
  26. fastapi_openrpc-0.1.4/docs/explanation-concurrency.md +0 -233
  27. fastapi_openrpc-0.1.4/docs/howto-error-handling.md +0 -465
  28. fastapi_openrpc-0.1.4/docs/index.md +0 -11
  29. fastapi_openrpc-0.1.4/docs/plans/2026-04-23-001-refactor-rpc-context-type-injection-plan.md +0 -252
  30. fastapi_openrpc-0.1.4/docs/plans/2026-04-23-002-refactor-openrpc-components-schemas-plan.md +0 -474
  31. fastapi_openrpc-0.1.4/docs/plans/2026-06-01-001-feat-replay-protection-plan.md +0 -260
  32. fastapi_openrpc-0.1.4/docs/plans/2026-06-01-002-fix-code-review-issues-plan.md +0 -353
  33. fastapi_openrpc-0.1.4/docs/tutorial-pydantic-models.md +0 -353
  34. fastapi_openrpc-0.1.4/docs/zh/index.md +0 -852
  35. fastapi_openrpc-0.1.4/examples/hmac_auth_dependency.py +0 -66
  36. fastapi_openrpc-0.1.4/examples/minimal_app.py +0 -41
  37. fastapi_openrpc-0.1.4/examples/pydantic_models.py +0 -120
  38. fastapi_openrpc-0.1.4/tests/http/.httpyac.js +0 -55
  39. fastapi_openrpc-0.1.4/tests/http/api.http +0 -150
  40. fastapi_openrpc-0.1.4/tests/http/hmac.http +0 -32
  41. fastapi_openrpc-0.1.4/tests/http/minimal.http +0 -25
  42. fastapi_openrpc-0.1.4/tests/http/pydantic.http +0 -38
  43. fastapi_openrpc-0.1.4/tests/test_authentication.py +0 -821
  44. fastapi_openrpc-0.1.4/tests/test_authorization.py +0 -179
  45. fastapi_openrpc-0.1.4/tests/test_decorator.py +0 -434
  46. fastapi_openrpc-0.1.4/tests/test_document.py +0 -470
  47. fastapi_openrpc-0.1.4/tests/test_errors.py +0 -126
  48. fastapi_openrpc-0.1.4/tests/test_registry.py +0 -160
  49. fastapi_openrpc-0.1.4/tests/test_router.py +0 -496
  50. fastapi_openrpc-0.1.4/uv.lock +0 -655
  51. {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.4
1
+ Metadata-Version: 2.3
2
2
  Name: fastapi-openrpc
3
- Version: 0.1.4
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
- Requires-Python: >=3.11
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
- redis_required=True, # Reject requests when Redis is unavailable
112
- connect_timeout=2.0, # Redis connection timeout (seconds)
113
- operation_timeout=5.0, # Redis operation timeout (seconds)
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
- redis_required=True, # Reject requests when Redis is unavailable
85
- connect_timeout=2.0, # Redis connection timeout (seconds)
86
- operation_timeout=5.0, # Redis operation timeout (seconds)
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.1.4"
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 = ["hatchling"]
41
- build-backend = "hatchling.build"
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.3"
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 typing import TYPE_CHECKING
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
- - Degrades according to ``redis_required`` config when Redis is unavailable
81
+ - Redis connection failures result in authentication failure (fail-close)
60
82
  """
61
83
 
62
84
  def __init__(
63
85
  self,
64
- roles_header: str = "X-Forwarded-User-Info",
65
- signature_header: str = "X-Forwarded-User-Info-Signature",
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
- Args:
77
- roles_header: Header name for user info, defaults to ``X-Forwarded-User-Info``.
78
- Header value can be JSON object (``{"roles": [...]}``) or JSON array.
79
- signature_header: Header name for signature, defaults to
80
- ``X-Forwarded-User-Info-Signature``.
81
- secret_key: HMAC signature key. ``None`` skips signature verification.
82
- enable_nonce: Enable Nonce replay attack protection. Defaults to ``False``.
83
- redis_url: Redis connection URL (e.g., ``redis://localhost:6379``).
84
- Required when ``enable_nonce=True``.
85
- nonce_ttl: Nonce TTL in Redis (seconds). Defaults to 3600 (1 hour).
86
- redis_required: Whether to reject requests when Redis is unavailable.
87
- When True (default) or False: Redis errors always result in
88
- request rejection (fail-close). This parameter is kept for
89
- behavioral configuration; Redis exceptions reject regardless.
90
- connect_timeout: Redis connection timeout (seconds). Defaults to 2.
91
- operation_timeout: Redis operation timeout (seconds). Defaults to 5.
92
- cleanup_timeout: Redis connection cleanup timeout (seconds). Defaults to 2.
93
- """
94
- if enable_nonce and not redis_url:
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.enable_nonce = enable_nonce
103
- self.redis_url = redis_url
104
- self.nonce_ttl = nonce_ttl
105
- self.redis_required = redis_required
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
- # Step 1: Verify signature (if secret_key is configured)
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
- # user_info_raw may be None; use empty string to avoid .encode() TypeError
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
- # Step 2: Parse roles and user_id (runs even when secret_key=None;
140
- # independent of signature verification)
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
- # Step 3: Nonce verification (if enabled)
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
- ctx: Caller context containing roles and require fields.
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 DefaultAuthorizationPolicy:
45
- """Default authorization policy.
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 ctx.require == Require.ALL:
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
- __all__ = ["Require", "AuthorizationPolicy", "DefaultAuthorizationPolicy"]
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
+ ]