lenapi 0.1.0__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.
lenapi/security.py ADDED
@@ -0,0 +1,665 @@
1
+ """Small HS256 JWT authentication helper for protected lenApi routes."""
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import hmac
7
+ import inspect
8
+ import json
9
+ import re
10
+ import secrets
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from typing import Any
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from .request import Request
19
+ from .response import HTTPException, Response
20
+
21
+
22
+ _B64URL = re.compile(r"^[A-Za-z0-9_-]+$")
23
+
24
+
25
+ def _encode_segment(value: bytes) -> str:
26
+ return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
27
+
28
+
29
+ def _decode_segment(value: str) -> bytes:
30
+ if not value or not _B64URL.fullmatch(value):
31
+ raise ValueError("Malformed token segment")
32
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
33
+
34
+
35
+ def hash_password(password: str) -> str:
36
+ """Hash a password in Django's ``pbkdf2_sha256`` encoded format."""
37
+ if not isinstance(password, str) or not password:
38
+ raise ValueError("password must be a non-empty string")
39
+ alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
40
+ salt = "".join(secrets.choice(alphabet) for _ in range(22))
41
+ iterations = 1_800_000
42
+ digest = hashlib.pbkdf2_hmac(
43
+ "sha256", password.encode("utf-8"), salt.encode("ascii"), iterations
44
+ )
45
+ encoded = base64.b64encode(digest).decode("ascii").strip()
46
+ return f"pbkdf2_sha256${iterations}${salt}${encoded}"
47
+
48
+
49
+ def verify_password(password: str, encoded_password: str) -> bool:
50
+ """Verify a password against lenApi or Django PBKDF2 encoded storage."""
51
+ if not isinstance(password, str) or not isinstance(encoded_password, str):
52
+ return False
53
+ try:
54
+ # Verify hashes from the earlier lenApi scrypt helper during upgrades.
55
+ if encoded_password.startswith("scrypt$"):
56
+ algorithm, n, r, p, salt_text, digest_text = encoded_password.split("$")
57
+ if (algorithm, int(n), int(r), int(p)) != ("scrypt", 16384, 8, 1):
58
+ return False
59
+ salt, expected = _decode_segment(salt_text), _decode_segment(digest_text)
60
+ if len(salt) != 16 or len(expected) != 64:
61
+ return False
62
+ actual = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=64)
63
+ return hmac.compare_digest(actual, expected)
64
+ algorithm, iterations_text, salt, expected = encoded_password.split("$", 3)
65
+ iterations = int(iterations_text)
66
+ if algorithm != "pbkdf2_sha256" or not 1 <= iterations <= 5_000_000:
67
+ return False
68
+ if not salt.isalnum():
69
+ return False
70
+ actual = hashlib.pbkdf2_hmac(
71
+ "sha256", password.encode("utf-8"), salt.encode("ascii"), iterations
72
+ )
73
+ actual_encoded = base64.b64encode(actual).decode("ascii").strip()
74
+ return hmac.compare_digest(actual_encoded, expected)
75
+ except (ValueError, TypeError, UnicodeEncodeError):
76
+ return False
77
+
78
+
79
+ def make_password(password: str) -> str:
80
+ """Create a Django-compatible one-way password hash."""
81
+ return hash_password(password)
82
+
83
+
84
+ def check_password(password: str, encoded_password: str) -> bool:
85
+ """Return whether a raw password matches its stored password hash."""
86
+ return verify_password(password, encoded_password)
87
+
88
+
89
+ def make_api_key() -> str:
90
+ """Generate a hyphen-free UUIDv5 API key using a random UUIDv4 seed."""
91
+ return uuid.uuid5(uuid.NAMESPACE_DNS, str(uuid.uuid4())).hex
92
+
93
+
94
+ def hash_api_key(api_key: str) -> str:
95
+ """Return a deterministic SHA-256 digest for storing an API key safely."""
96
+ if not isinstance(api_key, str) or not api_key:
97
+ raise ValueError("api_key must be a non-empty string")
98
+ return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
99
+
100
+
101
+ class JWTAuth:
102
+ """Create HS256 access tokens and use the instance as a Bearer dependency.
103
+
104
+ ``secret_key`` should be a high-entropy secret read from an environment
105
+ variable. This helper only supports HS256 and validates signatures and
106
+ expiration claims; it does not authenticate user credentials itself.
107
+ """
108
+
109
+ algorithm = "HS256"
110
+
111
+ def __init__(
112
+ self,
113
+ secret_key: str | bytes,
114
+ *,
115
+ token_lifetime_seconds: int = 3600,
116
+ refresh_token_lifetime_seconds: int = 60 * 60 * 24 * 30,
117
+ issuer: str | None = None,
118
+ ) -> None:
119
+ key = secret_key.encode("utf-8") if isinstance(secret_key, str) else secret_key
120
+ if not isinstance(key, bytes) or len(key) < 32:
121
+ raise ValueError("HS256 secret_key must be at least 32 bytes")
122
+ if token_lifetime_seconds <= 0:
123
+ raise ValueError("token_lifetime_seconds must be positive")
124
+ if refresh_token_lifetime_seconds <= 0:
125
+ raise ValueError("refresh_token_lifetime_seconds must be positive")
126
+ self.secret_key = key
127
+ self.token_lifetime_seconds = token_lifetime_seconds
128
+ self.refresh_token_lifetime_seconds = refresh_token_lifetime_seconds
129
+ self.issuer = issuer
130
+
131
+ def create_access_token(
132
+ self,
133
+ subject: str,
134
+ *,
135
+ claims: dict[str, Any] | None = None,
136
+ expires_in: int | None = None,
137
+ ) -> str:
138
+ """Sign a token for a subject, adding issued-at and expiration claims."""
139
+ if not isinstance(subject, str) or not subject:
140
+ raise ValueError("subject must be a non-empty string")
141
+ lifetime = self.token_lifetime_seconds if expires_in is None else expires_in
142
+ if lifetime <= 0:
143
+ raise ValueError("expires_in must be positive")
144
+ payload = dict(claims or {})
145
+ reserved = {"sub", "iat", "exp", "iss", "token_use"}
146
+ if reserved.intersection(payload):
147
+ raise ValueError("claims cannot override reserved token claims")
148
+ return self._create_token(subject, payload, lifetime, token_use="access")
149
+
150
+ def create_refresh_token(
151
+ self,
152
+ subject: str,
153
+ *,
154
+ claims: dict[str, Any] | None = None,
155
+ expires_in: int | None = None,
156
+ ) -> str:
157
+ """Create a refresh token that cannot be used as a bearer access token."""
158
+ if not isinstance(subject, str) or not subject:
159
+ raise ValueError("subject must be a non-empty string")
160
+ lifetime = self.refresh_token_lifetime_seconds if expires_in is None else expires_in
161
+ if lifetime <= 0:
162
+ raise ValueError("expires_in must be positive")
163
+ payload = dict(claims or {})
164
+ reserved = {"sub", "iat", "exp", "iss", "token_use"}
165
+ if reserved.intersection(payload):
166
+ raise ValueError("claims cannot override reserved token claims")
167
+ return self._create_token(subject, payload, lifetime, token_use="refresh")
168
+
169
+ def _create_token(self, subject: str, payload: dict[str, Any], lifetime: int, *, token_use: str) -> str:
170
+ now = int(time.time())
171
+ payload.update({"sub": subject, "iat": now, "exp": now + lifetime, "token_use": token_use})
172
+ if self.issuer is not None:
173
+ payload["iss"] = self.issuer
174
+ header = {"alg": self.algorithm, "typ": "JWT"}
175
+ head = _encode_segment(json.dumps(header, separators=(",", ":")).encode("utf-8"))
176
+ body = _encode_segment(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
177
+ signing_input = f"{head}.{body}".encode("ascii")
178
+ signature = hmac.new(self.secret_key, signing_input, hashlib.sha256).digest()
179
+ return f"{head}.{body}.{_encode_segment(signature)}"
180
+
181
+ def refresh_access_token(self, refresh_token: str) -> str:
182
+ """Verify a refresh token and issue a new access token for its subject."""
183
+ payload = self.decode_token(refresh_token, allow_refresh=True)
184
+ if payload.get("token_use") != "refresh":
185
+ raise ValueError("A refresh token is required")
186
+ claims = {key: value for key, value in payload.items()
187
+ if key not in {"sub", "iat", "exp", "iss", "token_use"}}
188
+ return self.create_access_token(payload["sub"], claims=claims)
189
+
190
+ def router(self, authenticate):
191
+ """Build `/auth/token` and `/auth/refresh` routes for an app.
192
+
193
+ `authenticate(username, password)` must return the user's string ID, or
194
+ `(string_id, claims)`, when credentials are valid; otherwise return None.
195
+ """
196
+ from .application import Router
197
+
198
+ router = Router(prefix="/auth")
199
+
200
+ @router.post("/token")
201
+ async def issue_token(credentials: _Credentials):
202
+ user = authenticate(credentials.username, credentials.password)
203
+ if inspect.isawaitable(user):
204
+ user = await user
205
+ if user is None or user is False:
206
+ raise HTTPException(401, "Invalid username or password")
207
+ if isinstance(user, tuple) and len(user) == 2:
208
+ subject, claims = user
209
+ else:
210
+ subject, claims = user, None
211
+ if not isinstance(subject, str) or not subject:
212
+ raise TypeError("authenticate must return a non-empty string user ID")
213
+ return {
214
+ "access_token": self.create_access_token(subject, claims=claims),
215
+ "refresh_token": self.create_refresh_token(subject, claims=claims),
216
+ "token_type": "bearer",
217
+ "expires_in": self.token_lifetime_seconds,
218
+ }
219
+
220
+ @router.post("/refresh")
221
+ def refresh_token(body: _RefreshRequest):
222
+ try:
223
+ access_token = self.refresh_access_token(body.refresh_token)
224
+ payload = self.decode_token(body.refresh_token, allow_refresh=True)
225
+ except ValueError as exc:
226
+ raise HTTPException(401, "Invalid or expired refresh token") from exc
227
+ claims = {key: value for key, value in payload.items()
228
+ if key not in {"sub", "iat", "exp", "iss", "token_use"}}
229
+ return {
230
+ "access_token": access_token,
231
+ "refresh_token": self.create_refresh_token(payload["sub"], claims=claims),
232
+ "token_type": "bearer",
233
+ "expires_in": self.token_lifetime_seconds,
234
+ }
235
+
236
+ return router
237
+
238
+ def decode_token(self, token: str, *, allow_refresh: bool = False) -> dict[str, Any]:
239
+ """Verify and decode a token, raising ValueError if it is invalid."""
240
+ try:
241
+ header_segment, payload_segment, signature_segment = token.split(".")
242
+ header = json.loads(_decode_segment(header_segment))
243
+ payload = json.loads(_decode_segment(payload_segment))
244
+ signature = _decode_segment(signature_segment)
245
+ if not isinstance(header, dict) or header.get("alg") != self.algorithm:
246
+ raise ValueError("Unsupported token algorithm")
247
+ if not isinstance(payload, dict):
248
+ raise ValueError("Token payload must be an object")
249
+ signing_input = f"{header_segment}.{payload_segment}".encode("ascii")
250
+ expected = hmac.new(self.secret_key, signing_input, hashlib.sha256).digest()
251
+ if not hmac.compare_digest(signature, expected):
252
+ raise ValueError("Invalid token signature")
253
+ expiration = payload.get("exp")
254
+ if isinstance(expiration, bool) or not isinstance(expiration, (int, float)):
255
+ raise ValueError("Token must have a numeric exp claim")
256
+ if expiration <= time.time():
257
+ raise ValueError("Token has expired")
258
+ not_before = payload.get("nbf")
259
+ if not_before is not None:
260
+ if isinstance(not_before, bool) or not isinstance(not_before, (int, float)):
261
+ raise ValueError("Token nbf claim must be numeric")
262
+ if not_before > time.time():
263
+ raise ValueError("Token is not active yet")
264
+ if self.issuer is not None and payload.get("iss") != self.issuer:
265
+ raise ValueError("Invalid token issuer")
266
+ if payload.get("token_use") == "refresh" and not allow_refresh:
267
+ raise ValueError("Refresh token cannot be used as an access token")
268
+ if not isinstance(payload.get("sub"), str) or not payload["sub"]:
269
+ raise ValueError("Token must have a non-empty sub claim")
270
+ return payload
271
+ except (KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError) as exc:
272
+ raise ValueError("Malformed token") from exc
273
+
274
+ def __call__(self, request: Request) -> dict[str, Any]:
275
+ """Resolve a verified Bearer token for use with ``Depends``."""
276
+ authorization = request.headers.get("authorization", "")
277
+ scheme, separator, token = authorization.partition(" ")
278
+ if not separator or scheme.lower() != "bearer" or not token.strip():
279
+ raise HTTPException(401, "Bearer token required", headers={"WWW-Authenticate": "Bearer"})
280
+ try:
281
+ return self.decode_token(token.strip())
282
+ except ValueError as exc:
283
+ raise HTTPException(
284
+ 401,
285
+ "Invalid or expired bearer token",
286
+ headers={"WWW-Authenticate": "Bearer"},
287
+ ) from exc
288
+
289
+
290
+ class _Credentials(BaseModel):
291
+ username: str = Field(min_length=1)
292
+ password: str = Field(min_length=1)
293
+
294
+
295
+ class _RefreshRequest(BaseModel):
296
+ refresh_token: str = Field(min_length=1)
297
+
298
+
299
+ class APIKeyAuth:
300
+ """Check a shared API key or resolve a user-specific key from a callback."""
301
+
302
+ def __init__(self, api_key: str | None = None, *, header_name: str = "x-api-key", authenticate=None) -> None:
303
+ if authenticate is None and (not isinstance(api_key, str) or len(api_key.encode("utf-8")) < 32):
304
+ raise ValueError("API key must be at least 32 bytes")
305
+ if not header_name or any(character.isspace() for character in header_name):
306
+ raise ValueError("header_name must be a valid header name")
307
+ self.api_key = api_key.encode("utf-8") if api_key is not None else None
308
+ self.authenticate = authenticate
309
+ self.header_name = header_name.lower()
310
+
311
+ async def __call__(self, request: Request) -> Any:
312
+ supplied = request.headers.get(self.header_name, "")
313
+ if self.authenticate is not None:
314
+ identity = self.authenticate(supplied)
315
+ if inspect.isawaitable(identity):
316
+ identity = await identity
317
+ if identity is not None and identity is not False:
318
+ return identity
319
+ elif supplied and hmac.compare_digest(supplied.encode("utf-8"), self.api_key):
320
+ return {"auth_method": "api_key"}
321
+ raise HTTPException(401, "Invalid or missing API key", headers={"WWW-Authenticate": "ApiKey"})
322
+
323
+
324
+ class BasicAuth:
325
+ """Verify HTTP Basic credentials with an app-provided callback."""
326
+
327
+ def __init__(self, authenticate) -> None:
328
+ self.authenticate = authenticate
329
+
330
+ async def __call__(self, request: Request) -> Any:
331
+ authorization = request.headers.get("authorization", "")
332
+ scheme, separator, value = authorization.partition(" ")
333
+ if not separator or scheme.lower() != "basic":
334
+ raise HTTPException(401, "Basic credentials required", headers={"WWW-Authenticate": 'Basic realm="lenApi"'})
335
+ try:
336
+ decoded = base64.b64decode(value, validate=True).decode("utf-8")
337
+ username, password = decoded.split(":", 1)
338
+ except (binascii.Error, UnicodeDecodeError, ValueError) as exc:
339
+ raise HTTPException(401, "Invalid Basic credentials", headers={"WWW-Authenticate": 'Basic realm="lenApi"'}) from exc
340
+ if not username or not password:
341
+ raise HTTPException(401, "Invalid Basic credentials", headers={"WWW-Authenticate": 'Basic realm="lenApi"'})
342
+ user = self.authenticate(username, password)
343
+ if inspect.isawaitable(user):
344
+ user = await user
345
+ if user is None or user is False:
346
+ raise HTTPException(401, "Invalid username or password", headers={"WWW-Authenticate": 'Basic realm="lenApi"'})
347
+ return user
348
+
349
+
350
+ class SessionAuth:
351
+ """Resolve an HttpOnly session cookie with an application-provided store lookup."""
352
+
353
+ def __init__(self, lookup=None, *, store=None, cookie_name: str = "lenapi_session") -> None:
354
+ if lookup is None and store is None:
355
+ raise ValueError("SessionAuth requires a lookup callback or security store")
356
+ self.lookup = lookup
357
+ self.store = store
358
+ self.cookie_name = cookie_name
359
+
360
+ async def __call__(self, request: Request) -> Any:
361
+ session_id = request.cookies.get(self.cookie_name)
362
+ if session_id and self.store is not None:
363
+ serialized = self.store.get(f"session:{session_id}")
364
+ if inspect.isawaitable(serialized):
365
+ serialized = await serialized
366
+ try:
367
+ identity = json.loads(serialized) if serialized is not None else None
368
+ except (TypeError, json.JSONDecodeError):
369
+ identity = None
370
+ else:
371
+ identity = self.lookup(session_id) if session_id and self.lookup is not None else None
372
+ if inspect.isawaitable(identity):
373
+ identity = await identity
374
+ if identity is None or identity is False:
375
+ raise HTTPException(401, "Valid session required")
376
+ return identity
377
+
378
+
379
+ class OpaqueBearerAuth:
380
+ """Resolve an opaque bearer token through an application-provided token store."""
381
+
382
+ def __init__(self, lookup=None, *, store=None) -> None:
383
+ if lookup is None and store is None:
384
+ raise ValueError("OpaqueBearerAuth requires a lookup callback or security store")
385
+ self.lookup = lookup
386
+ self.store = store
387
+
388
+ async def __call__(self, request: Request) -> Any:
389
+ authorization = request.headers.get("authorization", "")
390
+ scheme, separator, token = authorization.partition(" ")
391
+ identity = None
392
+ if separator and scheme.lower() == "bearer" and token.strip():
393
+ if self.store is not None:
394
+ serialized = self.store.get(f"opaque_bearer:{token.strip()}")
395
+ if inspect.isawaitable(serialized):
396
+ serialized = await serialized
397
+ try:
398
+ identity = json.loads(serialized) if serialized is not None else None
399
+ except (TypeError, json.JSONDecodeError):
400
+ identity = None
401
+ else:
402
+ identity = self.lookup(token.strip())
403
+ if inspect.isawaitable(identity):
404
+ identity = await identity
405
+ if identity is None or identity is False:
406
+ raise HTTPException(401, "Valid bearer token required", headers={"WWW-Authenticate": "Bearer"})
407
+ return identity
408
+
409
+
410
+ class OIDCAuth(OpaqueBearerAuth):
411
+ """Validate an OIDC access token through a provider SDK or token introspector."""
412
+
413
+
414
+ class WebhookAuth:
415
+ """Verify a raw-body HMAC webhook signature and optionally deduplicate events."""
416
+
417
+ def __init__(
418
+ self,
419
+ secret: str | bytes,
420
+ *,
421
+ security_store=None,
422
+ provider: str = "default",
423
+ signature_header: str = "x-webhook-signature",
424
+ event_id_header: str | None = "x-webhook-id",
425
+ timestamp_header: str | None = None,
426
+ tolerance_seconds: int = 300,
427
+ deduplication_ttl: int = 7 * 24 * 60 * 60,
428
+ ) -> None:
429
+ self.secret = secret.encode("utf-8") if isinstance(secret, str) else secret
430
+ if len(self.secret) < 32:
431
+ raise ValueError("Webhook secret must be at least 32 bytes")
432
+ if tolerance_seconds <= 0 or deduplication_ttl <= 0:
433
+ raise ValueError("Webhook time limits must be positive")
434
+ self.security_store = security_store
435
+ self.provider = provider
436
+ self.signature_header = signature_header.lower()
437
+ self.event_id_header = event_id_header.lower() if event_id_header else None
438
+ self.timestamp_header = timestamp_header.lower() if timestamp_header else None
439
+ self.tolerance_seconds = tolerance_seconds
440
+ self.deduplication_ttl = deduplication_ttl
441
+
442
+ async def __call__(self, request: Request) -> dict[str, str]:
443
+ headers = request.headers
444
+ supplied = headers.get(self.signature_header, "")
445
+ signature = supplied.removeprefix("sha256=").strip()
446
+ body = await request.body()
447
+ signed_content = body
448
+ if self.timestamp_header:
449
+ timestamp = headers.get(self.timestamp_header, "")
450
+ if not timestamp.isascii():
451
+ raise HTTPException(401, "Invalid webhook timestamp")
452
+ try:
453
+ event_time = int(timestamp)
454
+ except ValueError as exc:
455
+ raise HTTPException(401, "Invalid webhook timestamp") from exc
456
+ if abs(time.time() - event_time) > self.tolerance_seconds:
457
+ raise HTTPException(401, "Webhook signature has expired")
458
+ signed_content = timestamp.encode("ascii") + b"." + body
459
+ expected = hmac.new(self.secret, signed_content, hashlib.sha256).hexdigest()
460
+ if not signature or not hmac.compare_digest(signature, expected):
461
+ raise HTTPException(401, "Invalid webhook signature")
462
+
463
+ event_id = headers.get(self.event_id_header, "") if self.event_id_header else ""
464
+ if self.security_store is not None and self.event_id_header:
465
+ if not event_id:
466
+ raise HTTPException(400, "Webhook event ID required")
467
+ duplicate = await self.security_store.claim(
468
+ f"webhook:{self.provider}:{event_id}", self.deduplication_ttl
469
+ )
470
+ if duplicate:
471
+ # Acknowledge retries so providers stop redelivering an event
472
+ # that this application has already accepted.
473
+ raise HTTPException(200, "Webhook event already received")
474
+ return {"auth_method": "webhook", "event_id": event_id}
475
+
476
+
477
+ def session_auth(lookup=None, *, store=None, cookie_name: str = "lenapi_session") -> SessionAuth:
478
+ """Create a session-cookie dependency with a callback or shared security store."""
479
+ return SessionAuth(lookup, store=store, cookie_name=cookie_name)
480
+
481
+
482
+ def opaque_bearer_auth(token_lookup=None, *, store=None) -> OpaqueBearerAuth:
483
+ """Create an opaque bearer dependency with a callback or shared security store."""
484
+ return OpaqueBearerAuth(token_lookup, store=store)
485
+
486
+
487
+ def oidc_auth(token_lookup) -> OIDCAuth:
488
+ """Create an optional per-route OIDC token authentication dependency."""
489
+ return OIDCAuth(token_lookup)
490
+
491
+
492
+ def webhook_auth(secret: str | bytes, *, security_store=None, **options) -> WebhookAuth:
493
+ """Create a webhook signature and idempotency dependency."""
494
+ return WebhookAuth(secret, security_store=security_store, **options)
495
+
496
+
497
+ class CSRFProtect:
498
+ """Signed double-submit CSRF protection for cookie-authenticated routes."""
499
+
500
+ SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
501
+
502
+ def __init__(
503
+ self,
504
+ secret_key: str | bytes,
505
+ *,
506
+ cookie_name: str = "lenapi_csrf",
507
+ header_name: str = "x-csrf-token",
508
+ secure: bool = True,
509
+ samesite: str = "lax",
510
+ ) -> None:
511
+ self.secret = secret_key.encode("utf-8") if isinstance(secret_key, str) else secret_key
512
+ if len(self.secret) < 32:
513
+ raise ValueError("CSRF secret_key must be at least 32 bytes")
514
+ self.cookie_name = cookie_name
515
+ self.header_name = header_name.lower()
516
+ self.secure = secure
517
+ self.samesite = samesite
518
+
519
+ def generate_token(self) -> str:
520
+ nonce = secrets.token_urlsafe(32)
521
+ signature = hmac.new(self.secret, nonce.encode("ascii"), hashlib.sha256).hexdigest()
522
+ return f"{nonce}.{signature}"
523
+
524
+ def set_cookie(self, response: Response, *, max_age: int = 3600) -> str:
525
+ """Set a browser-readable CSRF cookie and return its token for clients."""
526
+ token = self.generate_token()
527
+ response.set_cookie(
528
+ self.cookie_name,
529
+ token,
530
+ max_age=max_age,
531
+ secure=self.secure,
532
+ httponly=False,
533
+ samesite=self.samesite,
534
+ path="/",
535
+ )
536
+ return token
537
+
538
+ async def __call__(self, request: Request) -> bool:
539
+ if request.method.upper() in self.SAFE_METHODS:
540
+ return True
541
+ cookie_token = request.cookies.get(self.cookie_name, "")
542
+ header_token = request.headers.get(self.header_name, "")
543
+ nonce, separator, supplied_signature = cookie_token.rpartition(".")
544
+ if not separator or not nonce or not header_token:
545
+ raise HTTPException(403, "CSRF token required")
546
+ expected_signature = hmac.new(self.secret, nonce.encode("ascii"), hashlib.sha256).hexdigest()
547
+ if (
548
+ not hmac.compare_digest(cookie_token, header_token)
549
+ or not hmac.compare_digest(supplied_signature, expected_signature)
550
+ ):
551
+ raise HTTPException(403, "Invalid CSRF token")
552
+ return True
553
+
554
+
555
+ def hmac_request_signature(
556
+ secret: str | bytes, method: str, path: str, timestamp: str, body: bytes, *, nonce: str = ""
557
+ ) -> str:
558
+ """Create a hex HMAC-SHA256 signature for a lenApi signed request."""
559
+ key = secret.encode("utf-8") if isinstance(secret, str) else secret
560
+ body_digest = hashlib.sha256(body).hexdigest()
561
+ message = f"{timestamp}\n{method.upper()}\n{path}\n{body_digest}\n{nonce}".encode("utf-8")
562
+ return hmac.new(key, message, hashlib.sha256).hexdigest()
563
+
564
+
565
+ class HMACAuth:
566
+ """Authenticate timestamped, body-signed requests from trusted clients."""
567
+
568
+ def __init__(self, secret: str | bytes, *, tolerance_seconds: int = 300, nonce_store=None) -> None:
569
+ self.secret = secret.encode("utf-8") if isinstance(secret, str) else secret
570
+ if len(self.secret) < 32:
571
+ raise ValueError("HMAC secret must be at least 32 bytes")
572
+ if tolerance_seconds <= 0:
573
+ raise ValueError("tolerance_seconds must be positive")
574
+ self.tolerance_seconds = tolerance_seconds
575
+ self.nonce_store = nonce_store
576
+ self._seen_nonces: dict[str, int] = {}
577
+ self._nonce_lock = threading.Lock()
578
+
579
+ async def __call__(self, request: Request) -> dict[str, str]:
580
+ headers = request.headers
581
+ timestamp = headers.get("x-lenapi-timestamp", "")
582
+ supplied = headers.get("x-lenapi-signature", "")
583
+ nonce = headers.get("x-lenapi-nonce", "")
584
+ if not nonce or len(nonce) > 128:
585
+ raise HTTPException(401, "Invalid signed request", headers={"WWW-Authenticate": "HMAC"})
586
+ try:
587
+ request_time = int(timestamp)
588
+ except ValueError as exc:
589
+ raise HTTPException(401, "Invalid signed request", headers={"WWW-Authenticate": "HMAC"}) from exc
590
+ if abs(time.time() - request_time) > self.tolerance_seconds:
591
+ raise HTTPException(401, "Signed request has expired", headers={"WWW-Authenticate": "HMAC"})
592
+ body = await request.body()
593
+ query = request.scope.get("query_string", b"").decode("latin-1")
594
+ path = request.path + (f"?{query}" if query else "")
595
+ expected = hmac_request_signature(self.secret, request.method, path, timestamp, body, nonce=nonce)
596
+ if not hmac.compare_digest(supplied, expected):
597
+ raise HTTPException(401, "Invalid signed request", headers={"WWW-Authenticate": "HMAC"})
598
+ if self.nonce_store is not None:
599
+ used = self.nonce_store.claim(f"hmac_nonce:{nonce}", self.tolerance_seconds)
600
+ if inspect.isawaitable(used):
601
+ used = await used
602
+ if used:
603
+ raise HTTPException(401, "Signed request has already been used", headers={"WWW-Authenticate": "HMAC"})
604
+ else:
605
+ with self._nonce_lock:
606
+ self._seen_nonces = {
607
+ seen_nonce: seen_at for seen_nonce, seen_at in self._seen_nonces.items()
608
+ if request_time - seen_at <= self.tolerance_seconds
609
+ }
610
+ if nonce in self._seen_nonces:
611
+ raise HTTPException(401, "Signed request has already been used", headers={"WWW-Authenticate": "HMAC"})
612
+ self._seen_nonces[nonce] = request_time
613
+ return {"auth_method": "hmac"}
614
+
615
+
616
+ def hmac_auth(secret: str | bytes, *, tolerance_seconds: int = 300, nonce_store=None) -> HMACAuth:
617
+ """Create an optional per-route HMAC request authentication dependency."""
618
+ return HMACAuth(secret, tolerance_seconds=tolerance_seconds, nonce_store=nonce_store)
619
+
620
+
621
+ def create_authenticator(
622
+ settings, *, authenticate=None, authenticate_api_key=None, session_lookup=None,
623
+ token_lookup=None, oidc_token_lookup=None, security_store=None,
624
+ ):
625
+ """Create the authentication dependency selected in a project settings module."""
626
+ mode = getattr(settings, "AUTHENTICATION", "jwt").lower()
627
+ if mode == "jwt":
628
+ token = settings.TOKEN
629
+ return JWTAuth(
630
+ token["SECRET_KEY"],
631
+ token_lifetime_seconds=token.get("LIFETIME_SECONDS", 3600),
632
+ refresh_token_lifetime_seconds=token.get("REFRESH_LIFETIME_SECONDS", 60 * 60 * 24 * 30),
633
+ issuer=token.get("ISSUER"),
634
+ )
635
+ if mode == "api_key":
636
+ return APIKeyAuth(getattr(settings, "API_KEY", None), authenticate=authenticate_api_key)
637
+ if mode == "basic_login":
638
+ if authenticate is None:
639
+ raise ValueError("basic_login requires an authenticate(username, password) callback")
640
+ return BasicAuth(authenticate)
641
+ if mode == "session":
642
+ session = getattr(settings, "SESSION", {})
643
+ lookup = session_lookup or getattr(settings, "SESSION_LOOKUP", None)
644
+ if lookup is None and security_store is None:
645
+ raise ValueError("session authentication requires security_store or session_lookup")
646
+ return SessionAuth(
647
+ lookup, store=security_store if lookup is None else None,
648
+ cookie_name=session.get("COOKIE_NAME", "lenapi_session"),
649
+ )
650
+ if mode == "opaque_bearer":
651
+ lookup = token_lookup or getattr(settings, "TOKEN_LOOKUP", None)
652
+ if lookup is None and security_store is None:
653
+ raise ValueError("opaque_bearer authentication requires security_store or token_lookup")
654
+ return OpaqueBearerAuth(lookup, store=security_store if lookup is None else None)
655
+ if mode == "hmac":
656
+ secret = getattr(settings, "HMAC_SECRET", None)
657
+ if not secret:
658
+ raise ValueError("hmac authentication requires HMAC_SECRET")
659
+ return HMACAuth(secret, tolerance_seconds=getattr(settings, "HMAC_TOLERANCE_SECONDS", 300))
660
+ if mode in {"oidc", "oauth2_oidc"}:
661
+ lookup = oidc_token_lookup or getattr(settings, "OIDC_TOKEN_LOOKUP", None)
662
+ if lookup is None:
663
+ raise ValueError("oidc authentication requires oidc_token_lookup")
664
+ return OIDCAuth(lookup)
665
+ raise ValueError("Unknown AUTHENTICATION mode")