fastapi-openrpc 0.1.3__py3-none-any.whl

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.
@@ -0,0 +1,104 @@
1
+ """fastapi-openrpc: FastAPI JSON-RPC 2.0 + OpenRPC 1.0 plugin.
2
+
3
+ Quick start::
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi_openrpc import OpenRpcRouter, method, RpcContext
7
+
8
+ app = FastAPI()
9
+
10
+ openrpc_router = OpenRpcRouter(
11
+ title="My API",
12
+ version="1.0.0",
13
+ )
14
+
15
+ @method(name="getUser")
16
+ async def get_user(ctx: RpcContext, user_id: str) -> dict:
17
+ return {"id": user_id, "name": "Alice"}
18
+
19
+ app.include_router(openrpc_router)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from fastapi_openrpc.authentication import (
25
+ AuthenticationMiddleware,
26
+ HMACAuthentication,
27
+ )
28
+ from fastapi_openrpc.authorization import (
29
+ AuthorizationPolicy,
30
+ DefaultAuthorizationPolicy,
31
+ Require,
32
+ )
33
+ from fastapi_openrpc.constants import (
34
+ JSONRPC_FORBIDDEN,
35
+ JSONRPC_INTERNAL_ERROR,
36
+ JSONRPC_INVALID_PARAMS,
37
+ JSONRPC_INVALID_REQUEST,
38
+ JSONRPC_METHOD_NOT_FOUND,
39
+ JSONRPC_PARSE_ERROR,
40
+ JSONRPC_REPLAY_DETECTED,
41
+ OPENRPC_VERSION,
42
+ )
43
+ from fastapi_openrpc.decorator import method
44
+ from fastapi_openrpc.document import build_openrpc_document
45
+ from fastapi_openrpc.errors import (
46
+ AuthenticationError,
47
+ Forbidden,
48
+ InternalError,
49
+ InvalidParams,
50
+ InvalidRequest,
51
+ JsonRpcError,
52
+ MethodNotFound,
53
+ ParseError,
54
+ ReplayDetected,
55
+ RpcError,
56
+ )
57
+ from fastapi_openrpc.registry import add, clear, get, list_methods
58
+ from fastapi_openrpc.router import OpenRpcRouter
59
+ from fastapi_openrpc.types import MethodMeta, RpcContext
60
+
61
+ __version__ = "0.1.3"
62
+
63
+ __all__ = [
64
+ # Version
65
+ "__version__",
66
+ "OPENRPC_VERSION",
67
+ # Core API
68
+ "method",
69
+ "OpenRpcRouter",
70
+ "build_openrpc_document",
71
+ # Functional Registration API
72
+ "add",
73
+ "get",
74
+ "list_methods",
75
+ "clear",
76
+ # Types
77
+ "MethodMeta",
78
+ "RpcContext",
79
+ "Require",
80
+ "AuthorizationPolicy",
81
+ "DefaultAuthorizationPolicy",
82
+ # Error Classes
83
+ "JsonRpcError",
84
+ "ParseError",
85
+ "InvalidRequest",
86
+ "MethodNotFound",
87
+ "InvalidParams",
88
+ "InternalError",
89
+ "Forbidden",
90
+ "ReplayDetected",
91
+ "RpcError",
92
+ # Constants
93
+ "JSONRPC_PARSE_ERROR",
94
+ "JSONRPC_INVALID_REQUEST",
95
+ "JSONRPC_METHOD_NOT_FOUND",
96
+ "JSONRPC_INVALID_PARAMS",
97
+ "JSONRPC_INTERNAL_ERROR",
98
+ "JSONRPC_FORBIDDEN",
99
+ "JSONRPC_REPLAY_DETECTED",
100
+ # Authentication
101
+ "AuthenticationMiddleware",
102
+ "AuthenticationError",
103
+ "HMACAuthentication",
104
+ ]
@@ -0,0 +1,248 @@
1
+ """fastapi-openrpc authentication component."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import logging
10
+ import re
11
+ from typing import TYPE_CHECKING
12
+
13
+ import redis.asyncio
14
+ from typing_extensions import Protocol, runtime_checkable
15
+
16
+ from fastapi_openrpc.errors import AuthenticationError, ReplayDetected
17
+ from fastapi_openrpc.types import RpcContext
18
+
19
+ if TYPE_CHECKING:
20
+ from starlette.requests import Request
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @runtime_checkable
26
+ class AuthenticationMiddleware(Protocol):
27
+ """Authentication middleware Protocol.
28
+
29
+ Applications can implement custom authentication logic and inject it via
30
+ OpenRpcRouter(authentication=...).
31
+ """
32
+
33
+ async def authenticate(self, request: Request) -> RpcContext:
34
+ """Authenticate the requester.
35
+
36
+ Raises AuthenticationError on failure, returns RpcContext on success.
37
+ """
38
+ ...
39
+
40
+
41
+ class HMACAuthentication:
42
+ """HMAC-SHA256 signature verification authentication.
43
+
44
+ Suitable for scenarios where requests are forwarded through a reverse proxy
45
+ (e.g., winwin-proxy). Supports two header formats:
46
+
47
+ - JSON object: ``{"user_id": "...", "roles": ["admin"]}``
48
+ - JSON array (direct): ``["admin", "developer"]``
49
+
50
+ Behavior:
51
+ - If ``secret_key`` is configured: verifies HMAC-SHA256 signature integrity;
52
+ raises AuthenticationError if signature is missing or invalid
53
+ - If ``secret_key=None``: skips signature verification and directly parses
54
+ header content (suitable for trusted internal network scenarios)
55
+
56
+ Optional Nonce replay attack protection:
57
+ - Requires ``enable_nonce=True`` and ``redis_url`` when enabled
58
+ - Uses Redis SETEX NX command for atomic deduplication
59
+ - Degrades according to ``redis_required`` config when Redis is unavailable
60
+ """
61
+
62
+ def __init__(
63
+ 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,
74
+ ):
75
+ """
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:
95
+ raise ValueError(
96
+ "redis_url is required when enable_nonce=True. "
97
+ "Example: redis_url='redis://localhost:6379'"
98
+ )
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
109
+
110
+ async def authenticate(self, request: Request) -> RpcContext:
111
+ """Verify HMAC signature and parse role list.
112
+
113
+ Args:
114
+ request: FastAPI/Starlette request object.
115
+
116
+ Returns:
117
+ RpcContext containing the parsed role list.
118
+
119
+ Raises:
120
+ AuthenticationError: Signature verification failed, invalid role format,
121
+ or Nonce replay.
122
+ """
123
+ user_info_raw = request.headers.get(self.roles_header)
124
+ signature_raw = request.headers.get(self.signature_header)
125
+
126
+ # Step 1: Verify signature (if secret_key is configured)
127
+ if self.secret_key:
128
+ if not signature_raw:
129
+ 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):
137
+ raise AuthenticationError("Invalid signature")
138
+
139
+ # Step 2: Parse roles and user_id (runs even when secret_key=None;
140
+ # independent of signature verification)
141
+ user_info_dict: dict | list | None = None
142
+ if user_info_raw:
143
+ try:
144
+ user_info_dict = json.loads(user_info_raw)
145
+ except json.JSONDecodeError as err:
146
+ raise AuthenticationError("Invalid user info format") from err
147
+ # Supports two formats: JSON object ({"roles": [...], "user_id": "..."})
148
+ # or direct JSON array ([...])
149
+ if isinstance(user_info_dict, dict):
150
+ roles = user_info_dict.get("roles", [])
151
+ user_id = user_info_dict.get("user_id")
152
+ elif isinstance(user_info_dict, list):
153
+ roles = user_info_dict
154
+ user_id = None
155
+ else:
156
+ roles = []
157
+ user_id = None
158
+ else:
159
+ roles = []
160
+ user_id = None
161
+
162
+ # Step 3: Nonce verification (if enabled)
163
+ if self.enable_nonce:
164
+ nonce: str | None = None
165
+ if isinstance(user_info_dict, dict):
166
+ nonce = user_info_dict.get("nonce")
167
+
168
+ if not nonce:
169
+ raise AuthenticationError(
170
+ "Missing nonce. When enable_nonce=True, "
171
+ "the user_info header must contain a 'nonce' field. "
172
+ 'Example: {"roles": ["admin"], "nonce": "uuid-here"}'
173
+ )
174
+
175
+ await self._verify_nonce(nonce)
176
+
177
+ return RpcContext(roles=roles, user_id=user_id)
178
+
179
+ async def _verify_nonce(self, nonce: str) -> None:
180
+ """Verify whether the Nonce has already been used.
181
+
182
+ Uses Redis SET NX EX command for atomic operations:
183
+ - If nonce does not exist: SET succeeds, returns True, continue processing
184
+ - If nonce already exists: SET returns None, raises AuthenticationError
185
+
186
+ Args:
187
+ nonce: Client-provided unique identifier.
188
+ Must be 8-128 hexadecimal characters, may contain dashes.
189
+
190
+ Raises:
191
+ AuthenticationError: Nonce already used (replay attack) or Redis unavailable.
192
+ """
193
+ # Nonce validation: length check first (cheap check first)
194
+ if len(nonce) < 8:
195
+ raise AuthenticationError("Nonce too short (minimum 8 characters)")
196
+ if len(nonce) > 128:
197
+ raise AuthenticationError("Nonce too long (maximum 128 characters)")
198
+ # Format validation: hexadecimal character set, supports UUID with or without dashes
199
+ if not re.match(r"^[a-fA-F0-9-]+$", nonce):
200
+ raise AuthenticationError(
201
+ "Invalid nonce format (must be hexadecimal with optional dashes)"
202
+ )
203
+
204
+ redis_client = None
205
+ try:
206
+ # redis_url already validated in __init__; must exist when enable_nonce=True
207
+ assert self.redis_url is not None
208
+ redis_client = redis.asyncio.from_url(
209
+ self.redis_url,
210
+ socket_connect_timeout=self.connect_timeout,
211
+ socket_timeout=self.operation_timeout,
212
+ )
213
+ key = f"fastapi_openrpc:nonce:{nonce}"
214
+ # SET key value EX seconds NX: set only if key does not exist, with expiration
215
+ result = await redis_client.set(key, "1", ex=self.nonce_ttl, nx=True)
216
+ if result is None:
217
+ raise ReplayDetected("Replay detected: nonce has already been used")
218
+ except redis.asyncio.RedisError as err:
219
+ logger.error(
220
+ "Redis error during nonce verification: %s",
221
+ err,
222
+ )
223
+ # fail-close: reject request when Redis is unavailable
224
+ raise AuthenticationError("Redis unavailable, cannot verify nonce") from err
225
+ finally:
226
+ # Always clean up Redis connection, whether success or failure
227
+ await self._cleanup_redis(redis_client, nonce)
228
+
229
+ async def _cleanup_redis(self, redis_client: redis.asyncio.Redis | None, nonce: str) -> None:
230
+ """Clean up Redis connection with timeout protection."""
231
+ if redis_client is None:
232
+ return
233
+ try:
234
+ await asyncio.wait_for(redis_client.aclose(), timeout=self.cleanup_timeout)
235
+ except TimeoutError:
236
+ logger.warning(
237
+ "Redis aclose() timed out after %ss for key: fastapi_openrpc:nonce:%s",
238
+ self.cleanup_timeout,
239
+ nonce[:8] + "...",
240
+ )
241
+
242
+
243
+ __all__ = [
244
+ "AuthenticationError",
245
+ "AuthenticationMiddleware",
246
+ "HMACAuthentication",
247
+ "ReplayDetected",
248
+ ]
@@ -0,0 +1,79 @@
1
+ """Authorization abstraction and default implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
7
+
8
+ from fastapi_openrpc.types import Require
9
+
10
+ if TYPE_CHECKING:
11
+ from fastapi_openrpc.types import RpcContext
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @runtime_checkable
18
+ class AuthorizationPolicy(Protocol):
19
+ """Authorization policy interface.
20
+
21
+ Applications can implement a custom Policy and inject it via
22
+ OpenRpcRouter(authorization_policy=...).
23
+ """
24
+
25
+ def authorize(
26
+ self,
27
+ method_name: str,
28
+ required_roles: list[str],
29
+ ctx: RpcContext,
30
+ ) -> bool:
31
+ """Determine whether the caller is authorized to invoke the specified method.
32
+
33
+ Args:
34
+ method_name: The name of the RPC method being invoked.
35
+ required_roles: List of roles required by the method.
36
+ ctx: Caller context containing roles and require fields.
37
+
38
+ Returns:
39
+ True if authorization is granted, False otherwise.
40
+ """
41
+ ...
42
+
43
+
44
+ class DefaultAuthorizationPolicy:
45
+ """Default authorization policy.
46
+
47
+ ANY semantics: True if any required_roles match.
48
+ ALL semantics: True only if all required_roles are present in ctx.roles.
49
+ Role comparison is case-insensitive (normalized to lowercase).
50
+ Policy exceptions are treated as authorization denial (fail-close).
51
+ Returns True when required_roles is empty (no authorization check).
52
+ """
53
+
54
+ def authorize(
55
+ self,
56
+ method_name: str,
57
+ required_roles: list[str],
58
+ ctx: RpcContext,
59
+ ) -> bool:
60
+ # Empty role list -> skip check (backward compatible, R3)
61
+ if not required_roles:
62
+ return True
63
+
64
+ try:
65
+ ctx_roles = {r.lower() for r in ctx.roles}
66
+ needed = {r.lower() for r in required_roles}
67
+
68
+ if ctx.require == Require.ALL:
69
+ return needed <= ctx_roles # All required_roles present in ctx.roles
70
+ else:
71
+ # Require.ANY (default)
72
+ return bool(ctx_roles & needed) # Any match == True
73
+ except Exception:
74
+ # Policy exception -> fail-close
75
+ logger.exception("Authorization policy error for %s", method_name)
76
+ return False
77
+
78
+
79
+ __all__ = ["Require", "AuthorizationPolicy", "DefaultAuthorizationPolicy"]
@@ -0,0 +1,43 @@
1
+ """JSON-RPC 2.0 error code constants.
2
+
3
+ Standard error codes (JSON-RPC 2.0 spec):
4
+ -32700 Parse error
5
+ -32600 Invalid Request
6
+ -32601 Method not found
7
+ -32602 Invalid params
8
+ -32603 Internal error
9
+
10
+ Library-reserved application error codes (-32000 ~ -32099).
11
+ Custom application error codes should start from -32100.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ # ----------------------------------------------------------------------
17
+ # JSON-RPC 2.0 Standard Error Codes
18
+ # ----------------------------------------------------------------------
19
+ JSONRPC_PARSE_ERROR = -32700
20
+ JSONRPC_INVALID_REQUEST = -32600
21
+ JSONRPC_METHOD_NOT_FOUND = -32601
22
+ JSONRPC_INVALID_PARAMS = -32602
23
+ JSONRPC_INTERNAL_ERROR = -32603
24
+
25
+ # ----------------------------------------------------------------------
26
+ # Library-reserved Application Error Codes (-32000 ~ -32099)
27
+ # ----------------------------------------------------------------------
28
+ # 0x7D00 = -32000: Forbidden / insufficient permissions
29
+ JSONRPC_FORBIDDEN = -32000
30
+ # 0x7D01 = -32001: Reserved (formerly used for insufficient permissions)
31
+ # 0x7D02 = -32002: Replay attack detected
32
+ JSONRPC_REPLAY_DETECTED = -32002
33
+
34
+ # ----------------------------------------------------------------------
35
+ # Security Limit Constants
36
+ # ----------------------------------------------------------------------
37
+ MAX_BATCH_SIZE = 50
38
+ MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MB
39
+
40
+ # ----------------------------------------------------------------------
41
+ # OpenRPC Version
42
+ # ----------------------------------------------------------------------
43
+ OPENRPC_VERSION = "1.0.0"