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.
- openkitx403/__init__.py +47 -0
- openkitx403/core.py +387 -0
- openkitx403/middleware.py +194 -0
- openkitx403-0.1.1.dist-info/METADATA +62 -0
- openkitx403-0.1.1.dist-info/RECORD +6 -0
- openkitx403-0.1.1.dist-info/WHEEL +4 -0
openkitx403/__init__.py
ADDED
|
@@ -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,,
|