openkitx403 0.1.1__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,47 @@
1
+ """OpenKitx403 - HTTP-native wallet authentication for Solana"""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .core import (
6
+ Challenge,
7
+ AuthorizationParams,
8
+ VerifyResult,
9
+ ReplayStore,
10
+ InMemoryReplayStore,
11
+ create_challenge,
12
+ verify_authorization,
13
+ base64url_encode,
14
+ base64url_decode,
15
+ generate_nonce,
16
+ current_timestamp,
17
+ parse_authorization_header,
18
+ build_signing_string
19
+ )
20
+
21
+ from .middleware import (
22
+ OpenKit403Config,
23
+ OpenKit403Middleware,
24
+ OpenKit403User,
25
+ require_openkitx403_user
26
+ )
27
+
28
+ __all__ = [
29
+ '__version__',
30
+ 'Challenge',
31
+ 'AuthorizationParams',
32
+ 'VerifyResult',
33
+ 'ReplayStore',
34
+ 'InMemoryReplayStore',
35
+ 'create_challenge',
36
+ 'verify_authorization',
37
+ 'base64url_encode',
38
+ 'base64url_decode',
39
+ 'generate_nonce',
40
+ 'current_timestamp',
41
+ 'parse_authorization_header',
42
+ 'build_signing_string',
43
+ 'OpenKit403Config',
44
+ 'OpenKit403Middleware',
45
+ 'OpenKit403User',
46
+ 'require_openkitx403_user'
47
+ ]
openkitx403/core.py ADDED
@@ -0,0 +1,387 @@
1
+ """OpenKitx403 Python Server SDK - Core Functions"""
2
+ import json
3
+ import base64
4
+ import secrets
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Dict, Optional, Any, Protocol
7
+ from dataclasses import dataclass
8
+ import base58
9
+ from nacl.signing import VerifyKey
10
+ from nacl.exceptions import BadSignatureError
11
+
12
+
13
+ @dataclass
14
+ class Challenge:
15
+ """OpenKitx403 challenge structure"""
16
+ v: int
17
+ alg: str
18
+ nonce: str
19
+ ts: str
20
+ aud: str
21
+ method: str
22
+ path: str
23
+ uaBind: bool
24
+ originBind: bool
25
+ serverId: str
26
+ exp: str
27
+ ext: Dict[str, Any]
28
+
29
+ def to_dict(self) -> Dict[str, Any]:
30
+ return {
31
+ "v": self.v,
32
+ "alg": self.alg,
33
+ "nonce": self.nonce,
34
+ "ts": self.ts,
35
+ "aud": self.aud,
36
+ "method": self.method,
37
+ "path": self.path,
38
+ "uaBind": self.uaBind,
39
+ "originBind": self.originBind,
40
+ "serverId": self.serverId,
41
+ "exp": self.exp,
42
+ "ext": self.ext
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class AuthorizationParams:
48
+ """Parsed authorization header parameters"""
49
+ addr: str
50
+ sig: str
51
+ challenge: str
52
+ ts: str
53
+ nonce: str
54
+ bind: Optional[str] = None
55
+
56
+
57
+ @dataclass
58
+ class VerifyResult:
59
+ """Verification result"""
60
+ ok: bool
61
+ address: Optional[str] = None
62
+ challenge: Optional[Challenge] = None
63
+ error: Optional[str] = None
64
+
65
+
66
+ class ReplayStore(Protocol):
67
+ """Protocol for replay protection stores"""
68
+ async def check(self, key: str, ttl_seconds: int) -> bool:
69
+ """Check if nonce was already used"""
70
+ ...
71
+
72
+ async def store(self, key: str, ttl_seconds: int) -> None:
73
+ """Store nonce to prevent replay"""
74
+ ...
75
+
76
+
77
+ class InMemoryReplayStore:
78
+ """Simple in-memory replay store with LRU-like behavior"""
79
+
80
+ def __init__(self, max_size: int = 10000):
81
+ self._cache: Dict[str, float] = {}
82
+ self._max_size = max_size
83
+
84
+ async def check(self, key: str, ttl_seconds: int) -> bool:
85
+ """Check if key exists and not expired"""
86
+ expiry = self._cache.get(key)
87
+ if not expiry:
88
+ return False
89
+
90
+ if datetime.now(timezone.utc).timestamp() > expiry:
91
+ del self._cache[key]
92
+ return False
93
+
94
+ return True
95
+
96
+ async def store(self, key: str, ttl_seconds: int) -> None:
97
+ """Store key with expiry"""
98
+ # Simple LRU: remove oldest if at capacity
99
+ if len(self._cache) >= self._max_size:
100
+ oldest = next(iter(self._cache))
101
+ del self._cache[oldest]
102
+
103
+ expiry = datetime.now(timezone.utc).timestamp() + ttl_seconds
104
+ self._cache[key] = expiry
105
+
106
+ # Periodic cleanup
107
+ if secrets.randbelow(100) < 1: # 1% chance
108
+ self._cleanup()
109
+
110
+ def _cleanup(self) -> None:
111
+ """Remove expired entries"""
112
+ now = datetime.now(timezone.utc).timestamp()
113
+ expired = [k for k, v in self._cache.items() if now > v]
114
+ for k in expired:
115
+ del self._cache[k]
116
+
117
+
118
+ def base64url_encode(data: bytes | str) -> str:
119
+ """Encode to base64url (no padding)"""
120
+ if isinstance(data, str):
121
+ data = data.encode('utf-8')
122
+
123
+ b64 = base64.urlsafe_b64encode(data).decode('ascii')
124
+ return b64.rstrip('=')
125
+
126
+
127
+ def base64url_decode(s: str) -> str:
128
+ """Decode from base64url"""
129
+ # Add padding
130
+ padding = (4 - len(s) % 4) % 4
131
+ s_padded = s + '=' * padding
132
+
133
+ decoded = base64.urlsafe_b64decode(s_padded)
134
+ return decoded.decode('utf-8')
135
+
136
+
137
+ def generate_nonce() -> str:
138
+ """Generate cryptographically random nonce"""
139
+ return base64url_encode(secrets.token_bytes(16))
140
+
141
+
142
+ def current_timestamp() -> str:
143
+ """Get current UTC timestamp in ISO format"""
144
+ return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
145
+
146
+
147
+ def parse_authorization_header(header: str) -> Optional[AuthorizationParams]:
148
+ """Parse OpenKitx403 authorization header"""
149
+ if not header.startswith('OpenKitx403 '):
150
+ return None
151
+
152
+ params: Dict[str, str] = {}
153
+ import re
154
+
155
+ for match in re.finditer(r'(\w+)="([^"]*)"', header):
156
+ params[match.group(1)] = match.group(2)
157
+
158
+ required = ['addr', 'sig', 'challenge', 'ts', 'nonce']
159
+ if not all(k in params for k in required):
160
+ return None
161
+
162
+ return AuthorizationParams(
163
+ addr=params['addr'],
164
+ sig=params['sig'],
165
+ challenge=params['challenge'],
166
+ ts=params['ts'],
167
+ nonce=params['nonce'],
168
+ bind=params.get('bind')
169
+ )
170
+
171
+
172
+ def build_signing_string(challenge: Challenge) -> str:
173
+ """Build canonical signing string"""
174
+ # Sort keys for deterministic JSON
175
+ payload = json.dumps(challenge.to_dict(), sort_keys=True)
176
+
177
+ lines = [
178
+ 'OpenKitx403 Challenge',
179
+ '',
180
+ f'domain: {challenge.aud}',
181
+ f'server: {challenge.serverId}',
182
+ f'nonce: {challenge.nonce}',
183
+ f'ts: {challenge.ts}',
184
+ f'method: {challenge.method}',
185
+ f'path: {challenge.path}',
186
+ '',
187
+ f'payload: {payload}'
188
+ ]
189
+
190
+ return '\n'.join(lines)
191
+
192
+
193
+ def create_challenge(
194
+ method: str,
195
+ path: str,
196
+ audience: str,
197
+ issuer: str,
198
+ ttl_seconds: int = 60,
199
+ ua_binding: bool = False,
200
+ origin_binding: bool = False,
201
+ ext: Optional[Dict[str, Any]] = None
202
+ ) -> tuple[str, Challenge]:
203
+ """
204
+ Create an OpenKitx403 challenge
205
+
206
+ Returns:
207
+ (header_value, challenge_object)
208
+ """
209
+ now = datetime.now(timezone.utc)
210
+ exp = now + timedelta(seconds=ttl_seconds)
211
+
212
+ challenge = Challenge(
213
+ v=1,
214
+ alg='ed25519-solana',
215
+ nonce=generate_nonce(),
216
+ ts=now.strftime('%Y-%m-%dT%H:%M:%SZ'),
217
+ aud=audience,
218
+ method=method,
219
+ path=path,
220
+ uaBind=ua_binding,
221
+ originBind=origin_binding,
222
+ serverId=issuer,
223
+ exp=exp.strftime('%Y-%m-%dT%H:%M:%SZ'),
224
+ ext=ext or {}
225
+ )
226
+
227
+ challenge_json = json.dumps(challenge.to_dict(), sort_keys=True)
228
+ challenge_encoded = base64url_encode(challenge_json)
229
+
230
+ header_value = f'OpenKitx403 realm="{issuer}", version="1", challenge="{challenge_encoded}"'
231
+
232
+ return header_value, challenge
233
+
234
+
235
+ async def verify_authorization(
236
+ auth_header: str,
237
+ method: str,
238
+ path: str,
239
+ audience: str,
240
+ issuer: str,
241
+ ttl_seconds: int = 60,
242
+ clock_skew_seconds: int = 120,
243
+ bind_method_path: bool = False,
244
+ origin_binding: bool = False,
245
+ ua_binding: bool = False,
246
+ replay_store: Optional[ReplayStore] = None,
247
+ token_gate: Optional[callable] = None,
248
+ headers: Optional[Dict[str, str]] = None
249
+ ) -> VerifyResult:
250
+ """
251
+ Verify OpenKitx403 authorization header
252
+
253
+ Returns:
254
+ VerifyResult with ok=True and address on success
255
+ """
256
+ # Parse header
257
+ params = parse_authorization_header(auth_header)
258
+ if not params:
259
+ return VerifyResult(ok=False, error="Invalid authorization header")
260
+
261
+ # Decode challenge
262
+ try:
263
+ challenge_json = base64url_decode(params.challenge)
264
+ challenge_dict = json.loads(challenge_json)
265
+ challenge = Challenge(**challenge_dict)
266
+ except Exception as e:
267
+ return VerifyResult(ok=False, error=f"Invalid challenge format: {e}")
268
+
269
+ # Check protocol version
270
+ if challenge.v != 1:
271
+ return VerifyResult(ok=False, error="Unsupported protocol version")
272
+
273
+ # Check algorithm
274
+ if challenge.alg != 'ed25519-solana':
275
+ return VerifyResult(ok=False, error="Unsupported algorithm")
276
+
277
+ # Check expiration
278
+ try:
279
+ exp_time = datetime.fromisoformat(challenge.exp.replace('Z', '+00:00'))
280
+ if datetime.now(timezone.utc) > exp_time:
281
+ return VerifyResult(ok=False, error="Challenge expired")
282
+ except Exception:
283
+ return VerifyResult(ok=False, error="Invalid expiration format")
284
+
285
+ # Check audience
286
+ if challenge.aud != audience:
287
+ return VerifyResult(ok=False, error="Invalid audience")
288
+
289
+ # Check server ID
290
+ if challenge.serverId != issuer:
291
+ return VerifyResult(ok=False, error="Invalid server ID")
292
+
293
+ # Check timestamp skew
294
+ try:
295
+ client_ts = datetime.fromisoformat(params.ts.replace('Z', '+00:00'))
296
+ now = datetime.now(timezone.utc)
297
+ diff = abs((now - client_ts).total_seconds())
298
+
299
+ if diff > clock_skew_seconds:
300
+ return VerifyResult(ok=False, error="Timestamp outside allowed skew")
301
+ except Exception:
302
+ return VerifyResult(ok=False, error="Invalid timestamp format")
303
+
304
+ # Check method/path binding
305
+ if bind_method_path or params.bind:
306
+ if challenge.method != method or challenge.path != path:
307
+ return VerifyResult(ok=False, error="Method/path mismatch")
308
+
309
+ if params.bind:
310
+ expected_bind = f"{method}:{path}"
311
+ if params.bind != expected_bind:
312
+ return VerifyResult(ok=False, error="Bind parameter mismatch")
313
+
314
+ # Check origin binding
315
+ if challenge.originBind and headers:
316
+ origin = headers.get('origin') or headers.get('referer')
317
+ if not origin:
318
+ return VerifyResult(ok=False, error="Origin binding required but not provided")
319
+
320
+ try:
321
+ from urllib.parse import urlparse
322
+ origin_parsed = urlparse(origin)
323
+ aud_parsed = urlparse(challenge.aud)
324
+
325
+ if f"{origin_parsed.scheme}://{origin_parsed.netloc}" != f"{aud_parsed.scheme}://{aud_parsed.netloc}":
326
+ return VerifyResult(ok=False, error="Origin binding mismatch")
327
+ except Exception:
328
+ return VerifyResult(ok=False, error="Invalid origin format")
329
+
330
+ # Check UA binding
331
+ if challenge.uaBind and headers:
332
+ if not headers.get('user-agent'):
333
+ return VerifyResult(ok=False, error="User-Agent binding required but not provided")
334
+
335
+ # Check replay
336
+ if replay_store:
337
+ replay_key = f"{params.addr}:{params.nonce}"
338
+ is_replay = await replay_store.check(replay_key, ttl_seconds)
339
+
340
+ if is_replay:
341
+ return VerifyResult(ok=False, error="Nonce already used (replay detected)")
342
+
343
+ await replay_store.store(replay_key, ttl_seconds)
344
+
345
+ # Verify signature
346
+ try:
347
+ public_key_bytes = base58.b58decode(params.addr)
348
+ signature_bytes = base58.b58decode(params.sig)
349
+
350
+ signing_string = build_signing_string(challenge)
351
+ message = signing_string.encode('utf-8')
352
+
353
+ verify_key = VerifyKey(public_key_bytes)
354
+ verify_key.verify(message, signature_bytes)
355
+
356
+ except BadSignatureError:
357
+ return VerifyResult(ok=False, error="Invalid signature")
358
+ except Exception as e:
359
+ return VerifyResult(ok=False, error=f"Signature verification failed: {e}")
360
+
361
+ # Check token gate
362
+ if token_gate:
363
+ try:
364
+ allowed = await token_gate(params.addr)
365
+ if not allowed:
366
+ return VerifyResult(ok=False, error="Token gate check failed")
367
+ except Exception as e:
368
+ return VerifyResult(ok=False, error=f"Token gate error: {e}")
369
+
370
+ return VerifyResult(ok=True, address=params.addr, challenge=challenge)
371
+
372
+
373
+ __all__ = [
374
+ 'Challenge',
375
+ 'AuthorizationParams',
376
+ 'VerifyResult',
377
+ 'ReplayStore',
378
+ 'InMemoryReplayStore',
379
+ 'create_challenge',
380
+ 'verify_authorization',
381
+ 'base64url_encode',
382
+ 'base64url_decode',
383
+ 'generate_nonce',
384
+ 'current_timestamp',
385
+ 'parse_authorization_header',
386
+ 'build_signing_string'
387
+ ]
@@ -0,0 +1,194 @@
1
+ """OpenKitx403 FastAPI Middleware"""
2
+ from typing import Optional, Callable, Dict, Any
3
+ from fastapi import Request, Response, status
4
+ from fastapi.responses import JSONResponse
5
+ from starlette.middleware.base import BaseHTTPMiddleware
6
+ from starlette.types import ASGIApp
7
+
8
+ from .core import (
9
+ create_challenge,
10
+ verify_authorization,
11
+ ReplayStore,
12
+ InMemoryReplayStore,
13
+ VerifyResult
14
+ )
15
+
16
+
17
+ class OpenKit403Config:
18
+ """Configuration for OpenKit403 middleware"""
19
+
20
+ def __init__(
21
+ self,
22
+ audience: str,
23
+ issuer: str,
24
+ ttl_seconds: int = 60,
25
+ clock_skew_seconds: int = 120,
26
+ bind_method_path: bool = False,
27
+ origin_binding: bool = False,
28
+ ua_binding: bool = False,
29
+ replay_store: Optional[ReplayStore] = None,
30
+ token_gate: Optional[Callable[[str], bool]] = None
31
+ ):
32
+ self.audience = audience
33
+ self.issuer = issuer
34
+ self.ttl_seconds = ttl_seconds
35
+ self.clock_skew_seconds = clock_skew_seconds
36
+ self.bind_method_path = bind_method_path
37
+ self.origin_binding = origin_binding
38
+ self.ua_binding = ua_binding
39
+ self.replay_store = replay_store or InMemoryReplayStore()
40
+ self.token_gate = token_gate
41
+
42
+
43
+ class OpenKit403Middleware(BaseHTTPMiddleware):
44
+ """FastAPI middleware for OpenKit403 authentication"""
45
+
46
+ def __init__(
47
+ self,
48
+ app: ASGIApp,
49
+ audience: str,
50
+ issuer: str,
51
+ ttl_seconds: int = 60,
52
+ clock_skew_seconds: int = 120,
53
+ bind_method_path: bool = False,
54
+ origin_binding: bool = False,
55
+ ua_binding: bool = False,
56
+ replay_backend: str = "memory",
57
+ token_gate: Optional[Callable[[str], bool]] = None,
58
+ excluded_paths: Optional[list[str]] = None
59
+ ):
60
+ super().__init__(app)
61
+
62
+ # Setup replay store
63
+ if replay_backend == "memory":
64
+ replay_store = InMemoryReplayStore()
65
+ else:
66
+ replay_store = None # User must provide custom store
67
+
68
+ self.config = OpenKit403Config(
69
+ audience=audience,
70
+ issuer=issuer,
71
+ ttl_seconds=ttl_seconds,
72
+ clock_skew_seconds=clock_skew_seconds,
73
+ bind_method_path=bind_method_path,
74
+ origin_binding=origin_binding,
75
+ ua_binding=ua_binding,
76
+ replay_store=replay_store,
77
+ token_gate=token_gate
78
+ )
79
+
80
+ self.excluded_paths = excluded_paths or []
81
+
82
+ async def dispatch(self, request: Request, call_next):
83
+ """Process request through OpenKit403 authentication"""
84
+
85
+ # Skip excluded paths
86
+ if request.url.path in self.excluded_paths:
87
+ return await call_next(request)
88
+
89
+ auth_header = request.headers.get('authorization')
90
+
91
+ if not auth_header:
92
+ # No auth header - send challenge
93
+ header_value, _ = create_challenge(
94
+ method=request.method,
95
+ path=request.url.path,
96
+ audience=self.config.audience,
97
+ issuer=self.config.issuer,
98
+ ttl_seconds=self.config.ttl_seconds,
99
+ ua_binding=self.config.ua_binding,
100
+ origin_binding=self.config.origin_binding
101
+ )
102
+
103
+ return JSONResponse(
104
+ status_code=status.HTTP_403_FORBIDDEN,
105
+ content={
106
+ "error": "wallet_auth_required",
107
+ "detail": "Sign the challenge using your Solana wallet and resend the request."
108
+ },
109
+ headers={"WWW-Authenticate": header_value}
110
+ )
111
+
112
+ # Verify authorization
113
+ result = await verify_authorization(
114
+ auth_header=auth_header,
115
+ method=request.method,
116
+ path=request.url.path,
117
+ audience=self.config.audience,
118
+ issuer=self.config.issuer,
119
+ ttl_seconds=self.config.ttl_seconds,
120
+ clock_skew_seconds=self.config.clock_skew_seconds,
121
+ bind_method_path=self.config.bind_method_path,
122
+ origin_binding=self.config.origin_binding,
123
+ ua_binding=self.config.ua_binding,
124
+ replay_store=self.config.replay_store,
125
+ token_gate=self.config.token_gate,
126
+ headers=dict(request.headers)
127
+ )
128
+
129
+ if not result.ok:
130
+ # Invalid auth - send new challenge
131
+ header_value, _ = create_challenge(
132
+ method=request.method,
133
+ path=request.url.path,
134
+ audience=self.config.audience,
135
+ issuer=self.config.issuer,
136
+ ttl_seconds=self.config.ttl_seconds,
137
+ ua_binding=self.config.ua_binding,
138
+ origin_binding=self.config.origin_binding
139
+ )
140
+
141
+ return JSONResponse(
142
+ status_code=status.HTTP_403_FORBIDDEN,
143
+ content={
144
+ "error": result.error,
145
+ "detail": "Authentication failed. Please sign the new challenge."
146
+ },
147
+ headers={"WWW-Authenticate": header_value}
148
+ )
149
+
150
+ # Success - attach user to request state
151
+ request.state.openkitx403_user = {
152
+ "address": result.address,
153
+ "challenge": result.challenge
154
+ }
155
+
156
+ response = await call_next(request)
157
+ return response
158
+
159
+
160
+ class OpenKit403User:
161
+ """User information from OpenKit403 authentication"""
162
+
163
+ def __init__(self, address: str, challenge: Optional[Dict[str, Any]] = None):
164
+ self.address = address
165
+ self.challenge = challenge
166
+
167
+
168
+ def require_openkitx403_user(request: Request) -> OpenKit403User:
169
+ """
170
+ Dependency to extract OpenKit403 user from request
171
+
172
+ Usage:
173
+ @app.get("/protected")
174
+ async def protected(user: OpenKit403User = Depends(require_openkitx403_user)):
175
+ return {"address": user.address}
176
+ """
177
+ user_data = getattr(request.state, "openkitx403_user", None)
178
+
179
+ if not user_data:
180
+ # This should not happen if middleware is properly configured
181
+ raise RuntimeError("OpenKit403 user not found in request state")
182
+
183
+ return OpenKit403User(
184
+ address=user_data["address"],
185
+ challenge=user_data.get("challenge")
186
+ )
187
+
188
+
189
+ __all__ = [
190
+ 'OpenKit403Config',
191
+ 'OpenKit403Middleware',
192
+ 'OpenKit403User',
193
+ 'require_openkitx403_user'
194
+ ]
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: openkitx403
3
+ Version: 0.1.1
4
+ Summary: Python server SDK for OpenKitx403 wallet authentication
5
+ License: MIT
6
+ Author: OpenKitx403 Contributors
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: base58 (>=2.1.1,<3.0.0)
15
+ Requires-Dist: fastapi (>=0.104.0,<0.105.0)
16
+ Requires-Dist: pydantic (>=2.5.0,<3.0.0)
17
+ Requires-Dist: pynacl (>=1.5.0,<2.0.0)
18
+ Project-URL: Documentation, https://openkitx403.github.io/openkitx403-docs
19
+ Project-URL: Homepage, https://www.openkitx403.dev
20
+ Project-URL: Repository, https://github.com/openkitx403/openkitx403
21
+ Description-Content-Type: text/markdown
22
+
23
+ # openkitx403 - Python Server SDK
24
+
25
+ FastAPI middleware for OpenKitx403 wallet authentication.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install openkitx403
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ```python
36
+ from fastapi import FastAPI, Depends
37
+ from openkitx403 import OpenKit403Middleware, require_openkitx403_user
38
+
39
+ app = FastAPI()
40
+
41
+ app.add_middleware(
42
+ OpenKit403Middleware,
43
+ audience="https://api.example.com",
44
+ issuer="my-api-v1",
45
+ ttl_seconds=60,
46
+ bind_method_path=True,
47
+ replay_backend="memory"
48
+ )
49
+
50
+ @app.get("/protected")
51
+ async def protected(user = Depends(require_openkitx403_user)):
52
+ return {"wallet": user.address}
53
+ ```
54
+
55
+ ## Documentation
56
+
57
+ See [USAGE_EXAMPLES.md](../../USAGE_EXAMPLES.md#5-python-server-fastapi) for complete examples.
58
+
59
+ ## License
60
+
61
+ MIT
62
+
@@ -0,0 +1,6 @@
1
+ openkitx403/__init__.py,sha256=QQkXku6MSuD4b2PYqAsAc10PjE2FGjKOFvpLP1IMjRg,987
2
+ openkitx403/core.py,sha256=pZURaZZyQ037YKTqR-RNUcW1jXV8qsA2hANUZEl55G8,11596
3
+ openkitx403/middleware.py,sha256=RRzmnd5PdLYDouv6TtPDAZInLzMpfyDPcXELlu16CQo,6491
4
+ openkitx403-0.1.1.dist-info/METADATA,sha256=g9BP22DlMga1Z-w_X8DDGXi1TwMyI66d3fe8Y_tHs_g,1643
5
+ openkitx403-0.1.1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
6
+ openkitx403-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any