pyweb-stack 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.
Files changed (54) hide show
  1. pyweb/__init__.py +71 -0
  2. pyweb/app.py +37 -0
  3. pyweb/app_loader.py +139 -0
  4. pyweb/asgi.py +71 -0
  5. pyweb/auth.py +701 -0
  6. pyweb/bench.py +101 -0
  7. pyweb/browser.py +232 -0
  8. pyweb/build.py +295 -0
  9. pyweb/cache.py +201 -0
  10. pyweb/cli/__init__.py +465 -0
  11. pyweb/cli/__main__.py +3 -0
  12. pyweb/compiler/__init__.py +5 -0
  13. pyweb/compiler/ast.py +45 -0
  14. pyweb/compiler/codegen/__init__.py +9 -0
  15. pyweb/compiler/codegen/ir.py +50 -0
  16. pyweb/compiler/errors.py +29 -0
  17. pyweb/compiler/lower.py +733 -0
  18. pyweb/compiler/parser.py +532 -0
  19. pyweb/compiler/pipeline.py +158 -0
  20. pyweb/compiler/pyjs.py +1087 -0
  21. pyweb/compiler/rpc.py +35 -0
  22. pyweb/context.py +193 -0
  23. pyweb/css.py +115 -0
  24. pyweb/db/__init__.py +652 -0
  25. pyweb/db/migrate.py +143 -0
  26. pyweb/decorators.py +49 -0
  27. pyweb/deploy.py +102 -0
  28. pyweb/forms.py +108 -0
  29. pyweb/hosting.py +139 -0
  30. pyweb/jobs.py +248 -0
  31. pyweb/live.py +88 -0
  32. pyweb/lsp.py +214 -0
  33. pyweb/models.py +269 -0
  34. pyweb/npm.py +270 -0
  35. pyweb/observability.py +313 -0
  36. pyweb/platform.py +69 -0
  37. pyweb/plugins.py +112 -0
  38. pyweb/py.typed +0 -0
  39. pyweb/realtime.py +189 -0
  40. pyweb/rpc.py +137 -0
  41. pyweb/runtime/browser/runtime.js +1151 -0
  42. pyweb/runtime/server/__init__.py +445 -0
  43. pyweb/security.py +162 -0
  44. pyweb/serve.py +335 -0
  45. pyweb/ssr.py +260 -0
  46. pyweb/sync.py +118 -0
  47. pyweb/testing.py +154 -0
  48. pyweb/uploads.py +94 -0
  49. pyweb_stack-0.1.0.dist-info/METADATA +198 -0
  50. pyweb_stack-0.1.0.dist-info/RECORD +54 -0
  51. pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
  52. pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
  53. pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. pyweb_stack-0.1.0.dist-info/top_level.txt +1 -0
pyweb/auth.py ADDED
@@ -0,0 +1,701 @@
1
+ """Auth: password hashing, signed sessions, RBAC/permissions, CSRF,
2
+ OAuth, magic links, TOTP."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import functools
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import secrets
12
+ import time
13
+ import urllib.parse
14
+ import urllib.request
15
+
16
+ _HASH_ALGO = "pbkdf2_sha256"
17
+ _ITERATIONS = 260_000
18
+
19
+
20
+ class AuthError(Exception):
21
+ """Base class for auth failures."""
22
+
23
+
24
+ class NotAuthenticated(AuthError):
25
+ """Raised when a request has no usable session."""
26
+
27
+
28
+ class Forbidden(AuthError):
29
+ """Raised when a session lacks the required role/permission."""
30
+
31
+
32
+ def _b64e(raw: bytes) -> str:
33
+ return base64.urlsafe_b64encode(raw).decode().rstrip("=")
34
+
35
+
36
+ def _b64d(data: str) -> bytes:
37
+ return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
38
+
39
+
40
+ def hash_password(password, *, salt=None, rounds=None, iterations=_ITERATIONS):
41
+ if salt is None:
42
+ salt_bytes = secrets.token_bytes(16)
43
+ dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt_bytes, iterations)
44
+ return f"{_HASH_ALGO}${iterations}${_b64e(salt_bytes)}${_b64e(dk)}"
45
+ salt_bytes = salt if isinstance(salt, bytes) else bytes.fromhex(salt)
46
+ n = rounds if rounds is not None else iterations
47
+ dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt_bytes, n)
48
+ return f"pbkdf2${n}${salt_bytes.hex()}${dk.hex()}"
49
+
50
+
51
+ def verify_password(password, stored):
52
+ try:
53
+ parts = stored.split("$")
54
+ if len(parts) != 4:
55
+ return False
56
+ algo, iters, salt_part, dk_part = parts
57
+ iters_i = int(iters)
58
+ if algo == _HASH_ALGO:
59
+ salt = _b64d(salt_part)
60
+ expected = _b64d(dk_part)
61
+ else:
62
+ salt = bytes.fromhex(salt_part)
63
+ expected = bytes.fromhex(dk_part)
64
+ except (ValueError, base64.binascii.Error):
65
+ return False
66
+ candidate = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iters_i)
67
+ if algo == _HASH_ALGO:
68
+ return hmac.compare_digest(candidate, expected)
69
+ return hmac.compare_digest(candidate.hex(), expected.hex() if isinstance(expected, bytes) else dk_part)
70
+
71
+
72
+ class SessionStore:
73
+ def __init__(self, secret, *, ttl=86400):
74
+ self.secret = secret.encode() if isinstance(secret, str) else secret
75
+ self.ttl = ttl
76
+ self._data: dict[str, dict] = {}
77
+
78
+ def _sign(self, sid):
79
+ return hmac.new(self.secret, sid.encode(), hashlib.sha256).hexdigest()
80
+
81
+ def create(self, user_id, extra=None):
82
+ sid = secrets.token_urlsafe(24)
83
+ self._data[sid] = {"user_id": user_id, "created": time.time(), **(extra or {})}
84
+ return f"{sid}.{self._sign(sid)}"
85
+
86
+ def get(self, token):
87
+ try:
88
+ sid, sig = token.split(".")
89
+ except (ValueError, AttributeError):
90
+ return None
91
+ if not hmac.compare_digest(sig, self._sign(sid)):
92
+ return None
93
+ sess = self._data.get(sid)
94
+ if not sess or time.time() - sess["created"] > self.ttl:
95
+ self._data.pop(sid, None)
96
+ return None
97
+ return sess
98
+
99
+ def destroy(self, token):
100
+ sid = (token or "").split(".")[0]
101
+ self._data.pop(sid, None)
102
+
103
+
104
+ def _session_of(args, kwargs):
105
+ if "session" in kwargs:
106
+ return kwargs["session"]
107
+ return args[0] if args and isinstance(args[0], dict) else None
108
+
109
+
110
+ def required(fn):
111
+ fn.__pyweb_auth__ = "required"
112
+
113
+ @functools.wraps(fn)
114
+ def wrapper(*args, **kwargs):
115
+ session = _session_of(args, kwargs)
116
+ if session is None and not getattr(fn, "__pyweb_auth_enforce__", True):
117
+ return fn(*args, **kwargs)
118
+ if not isinstance(session, dict) or "sub" not in session:
119
+ if "session" in kwargs or args:
120
+ raise NotAuthenticated("login required")
121
+ return fn(*args, **kwargs)
122
+ return fn(*args, **kwargs)
123
+
124
+ wrapper.__pyweb_auth__ = "required"
125
+ return wrapper
126
+
127
+
128
+ def permission(name):
129
+ def deco(fn):
130
+ perms = getattr(fn, "__pyweb_permissions__", [])
131
+ fn.__pyweb_permissions__ = [*perms, name]
132
+
133
+ @functools.wraps(fn)
134
+ def wrapper(*args, **kwargs):
135
+ session = _session_of(args, kwargs)
136
+ if session is not None:
137
+ if not isinstance(session, dict) or "sub" not in session:
138
+ raise NotAuthenticated("login required")
139
+ if name not in session.get("roles", []):
140
+ raise Forbidden(f"requires role {name!r}")
141
+ return fn(*args, **kwargs)
142
+
143
+ wrapper.__pyweb_permissions__ = fn.__pyweb_permissions__
144
+ return wrapper
145
+ return deco
146
+
147
+
148
+ def can(user_roles, fn):
149
+ need = getattr(fn, "__pyweb_permissions__", [])
150
+ return all(p in (user_roles or []) for p in need)
151
+
152
+
153
+ SESSION_COOKIE = "pyweb_session"
154
+ LOGIN_URL = "/login"
155
+
156
+
157
+ def session_from_request(req, secret: str, max_age: int = 3600):
158
+ """Extract + verify the session from a ``Request``'s cookies/headers.
159
+
160
+ Accepts the ``pyweb_session`` cookie or ``Authorization: Bearer``.
161
+ Returns the payload dict, or ``None`` when absent/invalid/expired.
162
+ """
163
+ raw = ""
164
+ if getattr(req, "cookies", None) and req.cookies.get(SESSION_COOKIE):
165
+ raw = req.cookies[SESSION_COOKIE]
166
+ else:
167
+ auth = (getattr(req, "headers", None) or {}).get("Authorization", "")
168
+ if auth.startswith("Bearer "):
169
+ raw = auth[len("Bearer "):]
170
+ if not raw:
171
+ return None
172
+ return verify_session(raw, secret, max_age)
173
+
174
+
175
+ def require_session(req, secret: str, max_age: int = 3600):
176
+ """Return ``(session, None)`` when authenticated, else ``(None, redirect)``.
177
+
178
+ The redirect is a ``302`` to ``/login?next=<path>`` per the QA-006
179
+ contract: unauthenticated page access redirects; API/RPC callers
180
+ distinguish the ``Location: /login`` response instead of a 200.
181
+ """
182
+ from pyweb.runtime.server import Response
183
+ session = session_from_request(req, secret, max_age)
184
+ if session is not None and "sub" in session:
185
+ return session, None
186
+ return None, Response(302, "", {"Location": f"{LOGIN_URL}?next={req.path}"})
187
+
188
+
189
+ def login_response(user_id, secret: str, *, extra=None, next_url="/",
190
+ max_age: int = 3600, secure: bool = False):
191
+ """``302`` to ``next_url`` with a ``Set-Cookie: pyweb_session=...``.
192
+
193
+ The cookie carries ``Max-Age`` matching the server-side session TTL so
194
+ browsers discard stale tokens instead of hoarding them. ``secure=True``
195
+ adds ``Secure`` — always set it in production behind HTTPS; it stays
196
+ off by default only so localhost dev over plain HTTP keeps working.
197
+ """
198
+ from pyweb.runtime.server import Response
199
+ token = issue_session({"sub": user_id, **(extra or {})}, secret, max_age)
200
+ flags = (f"HttpOnly; Path=/; Max-Age={int(max_age)}; SameSite=Lax"
201
+ + ("; Secure" if secure else ""))
202
+ return Response(302, "", {
203
+ "Location": next_url,
204
+ "Set-Cookie": f"{SESSION_COOKIE}={token}; {flags}",
205
+ })
206
+
207
+
208
+ def warn_if_insecure_cookies(secure: bool, *, host: str = "") -> str | None:
209
+ """Return a warning when session cookies would go out without ``Secure``
210
+ on a non-local host. ``pyweb serve`` logs it at startup."""
211
+ if secure:
212
+ return None
213
+ if (host or "").split(":")[0] in ("127.0.0.1", "localhost", "::1", ""):
214
+ return None
215
+ return ("auth cookies lack the Secure flag on a non-local host "
216
+ f"({host!r}): pass secure=True (login_response) and serve "
217
+ "behind HTTPS, or sessions are exposed to network sniffing")
218
+
219
+
220
+ def logout_response(next_url="/", *, secure: bool = False):
221
+ """Clear the session cookie and redirect to ``next_url``."""
222
+ from pyweb.runtime.server import Response
223
+ flags = ("HttpOnly; Path=/; Max-Age=0; SameSite=Lax"
224
+ + ("; Secure" if secure else ""))
225
+ return Response(302, "", {
226
+ "Location": next_url,
227
+ "Set-Cookie": f"{SESSION_COOKIE}=; {flags}",
228
+ })
229
+
230
+
231
+ def _sign(payload_b64: str, secret: str) -> str:
232
+ return hmac.new(secret.encode(), payload_b64.encode(), hashlib.sha256).hexdigest()
233
+
234
+
235
+ def issue_session(data: dict, secret: str, max_age: int = 3600) -> str:
236
+ """Issue a ``base64url(payload).hexhmac`` stateless session cookie value."""
237
+ payload = dict(data)
238
+ payload.setdefault("iat", int(time.time()))
239
+ payload_b64 = _b64e(json.dumps(payload, separators=(",", ":")).encode())
240
+ return f"{payload_b64}.{_sign(payload_b64, secret)}"
241
+
242
+
243
+ def verify_session(cookie: str, secret: str, max_age: int = 3600):
244
+ """Verify a stateless session cookie; return payload dict or ``None``."""
245
+ try:
246
+ payload_b64, sig = cookie.rsplit(".", 1)
247
+ except ValueError:
248
+ return None
249
+ if not hmac.compare_digest(_sign(payload_b64, secret), sig):
250
+ return None
251
+ try:
252
+ payload = json.loads(_b64d(payload_b64))
253
+ except (ValueError, base64.binascii.Error, json.JSONDecodeError):
254
+ return None
255
+ if not isinstance(payload, dict):
256
+ return None
257
+ iat = payload.get("iat")
258
+ if not isinstance(iat, int) or time.time() - iat > max_age:
259
+ return None
260
+ return payload
261
+
262
+
263
+ _PROVIDERS = {
264
+ "google": {
265
+ "authorize": "https://accounts.google.com/o/oauth2/v2/auth",
266
+ "token": "https://oauth2.googleapis.com/token",
267
+ },
268
+ "github": {
269
+ "authorize": "https://github.com/login/oauth/authorize",
270
+ "token": "https://github.com/login/oauth/access_token",
271
+ },
272
+ }
273
+
274
+
275
+ class OAuthClient:
276
+ """OAuth authorization-code client using only stdlib urllib."""
277
+
278
+ def __init__(self, provider, client_id, client_secret, redirect_uri, scope=None):
279
+ if provider not in _PROVIDERS:
280
+ raise ValueError(f"unknown provider {provider!r}")
281
+ self.provider = provider
282
+ self.client_id = client_id
283
+ self.client_secret = client_secret
284
+ self.redirect_uri = redirect_uri
285
+ self.scope = scope or (["openid", "email", "profile"] if provider == "google" else ["read:user"])
286
+
287
+ def authorize_url(self, state):
288
+ params = urllib.parse.urlencode({
289
+ "client_id": self.client_id,
290
+ "redirect_uri": self.redirect_uri,
291
+ "response_type": "code",
292
+ "code": "code",
293
+ "scope": " ".join(self.scope),
294
+ "state": state,
295
+ })
296
+ return f"{_PROVIDERS[self.provider]['authorize']}?{params}"
297
+
298
+ def exchange_code(self, code, timeout=10):
299
+ data = urllib.parse.urlencode({
300
+ "client_id": self.client_id,
301
+ "client_secret": self.client_secret,
302
+ "code": code,
303
+ "grant_type": "authorization_code",
304
+ "redirect_uri": self.redirect_uri,
305
+ }).encode()
306
+ req = urllib.request.Request(_PROVIDERS[self.provider]["token"], data=data,
307
+ headers={"Accept": "application/json"})
308
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
309
+ return json.loads(resp.read().decode())
310
+
311
+
312
+ def issue_magic_token(secret: str, email: str, ttl: int = 900) -> str:
313
+ payload = {"email": email, "exp": int(time.time()) + ttl}
314
+ payload_b64 = _b64e(json.dumps(payload, separators=(",", ":")).encode())
315
+ return f"{payload_b64}.{_sign(payload_b64, secret)}"
316
+
317
+
318
+ def verify_magic_token(secret: str, token: str, max_age: int = 900):
319
+ try:
320
+ payload_b64, sig = token.rsplit(".", 1)
321
+ except ValueError:
322
+ return None
323
+ if not hmac.compare_digest(_sign(payload_b64, secret), sig):
324
+ return None
325
+ try:
326
+ payload = json.loads(_b64d(payload_b64))
327
+ except (ValueError, base64.binascii.Error, json.JSONDecodeError):
328
+ return None
329
+ if time.time() > payload.get("exp", 0):
330
+ return None
331
+ return payload.get("email")
332
+
333
+
334
+ _TOTP_STEP = 30
335
+ _TOTP_DIGITS = 6
336
+
337
+
338
+ def _totp_counter(for_time: int) -> int:
339
+ return int(for_time) // _TOTP_STEP
340
+
341
+
342
+ def totp(secret: bytes, for_time: int | None = None, digits=_TOTP_DIGITS) -> str:
343
+ import struct
344
+ counter = _totp_counter(int(time.time()) if for_time is None else for_time)
345
+ mac = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest()
346
+ offset = mac[-1] & 0x0F
347
+ code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
348
+ return str(code % (10 ** digits)).zfill(digits)
349
+
350
+
351
+ def verify_totp(secret: bytes, code: str, for_time: int | None = None, window: int = 1) -> bool:
352
+ now = int(time.time()) if for_time is None else for_time
353
+ for skew in range(-window, window + 1):
354
+ if hmac.compare_digest(totp(secret, now + skew * _TOTP_STEP), str(code)):
355
+ return True
356
+ return False
357
+
358
+
359
+ def csrf_token(secret, session_id):
360
+ return hmac.new(secret.encode() if isinstance(secret, str) else secret,
361
+ session_id.encode(), hashlib.sha256).hexdigest()
362
+
363
+
364
+ def verify_csrf(secret, session_id, token):
365
+ return hmac.compare_digest(csrf_token(secret, session_id), token or "")
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Policy-based authorization (RBAC v1)
370
+ #
371
+ # Why policies, not just role lists: ``@permission("admin")`` checks a
372
+ # single role, but real apps need rules like "authors can edit their own
373
+ # posts". :class:`Policy` lets teams declare named rules once and reuse
374
+ # them from both server functions and RPC gates.
375
+ # ---------------------------------------------------------------------------
376
+
377
+ class Policy:
378
+ """Named authorization rule set.
379
+
380
+ ``Policy("post:edit").allow("admin").allow("author", owner_field="author_id")``
381
+ """
382
+
383
+ def __init__(self, name):
384
+ self.name = name
385
+ self._rules: list[tuple] = []
386
+
387
+ def allow(self, role, *, owner_field=None):
388
+ self._rules.append((role, owner_field))
389
+ return self
390
+
391
+ def check(self, session, resource=None):
392
+ if not isinstance(session, dict) or "sub" not in session:
393
+ raise NotAuthenticated("login required")
394
+ roles = set(session.get("roles", []))
395
+ for role, owner_field in self._rules:
396
+ if role not in roles:
397
+ continue
398
+ if owner_field is None:
399
+ return True
400
+ if resource is not None:
401
+ owner = (resource.get(owner_field)
402
+ if isinstance(resource, dict)
403
+ else getattr(resource, owner_field, None))
404
+ if owner == session.get("sub"):
405
+ return True
406
+ raise Forbidden(f"policy {self.name!r} denied")
407
+
408
+ def __call__(self, fn):
409
+ policy = self
410
+
411
+ @functools.wraps(fn)
412
+ def wrapper(*args, **kwargs):
413
+ session = _session_of(args, kwargs)
414
+ resource = kwargs.get("resource", args[1] if len(args) > 1 else None)
415
+ policy.check(session, resource)
416
+ return fn(*args, **kwargs)
417
+
418
+ wrapper.__pyweb_permissions__ = [*getattr(fn, "__pyweb_permissions__", []),
419
+ f"policy:{self.name}"]
420
+ return wrapper
421
+
422
+
423
+ def rotate_session(store: SessionStore, old_token, *, extra=None):
424
+ """Sliding expiration: destroy the old token, issue a fresh one.
425
+
426
+ Call on each authenticated request (or every N minutes) to bound the
427
+ lifetime of a stolen cookie. Preserves user_id + extra claims.
428
+ """
429
+ sess = store.get(old_token)
430
+ if sess is None:
431
+ return None
432
+ store.destroy(old_token)
433
+ user_id = sess.pop("user_id", None)
434
+ merged = {**sess, **(extra or {})}
435
+ return store.create(user_id, extra=merged)
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # WebAuthn (passkeys) — verification side, stdlib-only.
440
+ #
441
+ # Scope: verify assertion responses (login). Registration (attestation)
442
+ # verification is intentionally out of scope for v1 — teams enroll via
443
+ # the platform authenticator and import the credential public key.
444
+ # ES256 (COSE -7) over SHA-256, matching what iCloud/Google password
445
+ # managers and platform authenticators emit.
446
+ # ---------------------------------------------------------------------------
447
+
448
+ def _cbor_first(data: bytes):
449
+ """Minimal CBOR head decode: returns (value, rest) for int/bytes/text.
450
+
451
+ Only what WebAuthn authData parsing needs — not a general decoder.
452
+ """
453
+ if not data:
454
+ raise ValueError("empty cbor")
455
+ ib, rest = data[0], data[1:]
456
+ major, info = ib >> 5, ib & 0x1F
457
+ if info < 24:
458
+ num, tail = info, rest
459
+ elif info == 24:
460
+ num, tail = rest[0], rest[1:]
461
+ elif info == 25:
462
+ num = int.from_bytes(rest[:2], "big")
463
+ tail = rest[2:]
464
+ elif info == 26:
465
+ num = int.from_bytes(rest[:4], "big")
466
+ tail = rest[4:]
467
+ else:
468
+ raise ValueError(f"unsupported cbor info {info}")
469
+ if major == 0:
470
+ return num, tail
471
+ if major == 1:
472
+ return -1 - num, tail
473
+ if major == 2:
474
+ return tail[:num], tail[num:]
475
+ if major == 3:
476
+ return tail[:num].decode("utf-8"), tail[num:]
477
+ raise ValueError(f"unsupported cbor major {major}")
478
+
479
+
480
+ def parse_auth_data(auth_data: bytes) -> dict:
481
+ """Parse WebAuthn ``authenticatorData`` into its fields."""
482
+ if len(auth_data) < 37:
483
+ raise ValueError("authenticatorData too short")
484
+ return {
485
+ "rp_id_hash": auth_data[:32],
486
+ "flags": auth_data[32],
487
+ "sign_count": int.from_bytes(auth_data[33:37], "big"),
488
+ "attested": auth_data[37:],
489
+ "user_present": bool(auth_data[32] & 0x01),
490
+ "user_verified": bool(auth_data[32] & 0x04),
491
+ }
492
+
493
+
494
+ def _b64url_decode(data) -> bytes:
495
+ if isinstance(data, bytes):
496
+ return data
497
+ return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
498
+
499
+
500
+ def _der_to_rs(sig: bytes):
501
+ """Parse an ASN.1 DER ``ECDSA-Sig-Value`` into ``(r, s)``."""
502
+ def read_len(buf, i):
503
+ n = buf[i]
504
+ i += 1
505
+ if n < 0x80:
506
+ return n, i
507
+ k = n & 0x7F
508
+ if k == 0 or k > 2:
509
+ raise ValueError("bad DER length")
510
+ return int.from_bytes(buf[i:i + k], "big"), i + k
511
+
512
+ if len(sig) < 8 or sig[0] != 0x30:
513
+ raise ValueError("not a DER sequence")
514
+ total, i = read_len(sig, 1)
515
+ if i + total != len(sig):
516
+ raise ValueError("DER length mismatch")
517
+ out = []
518
+ for _ in range(2):
519
+ if sig[i] != 0x02:
520
+ raise ValueError("expected DER integer")
521
+ n, i = read_len(sig, i + 1)
522
+ out.append(int.from_bytes(sig[i:i + n], "big"))
523
+ i += n
524
+ if i != len(sig):
525
+ raise ValueError("trailing DER bytes")
526
+ return out[0], out[1]
527
+
528
+
529
+ def verify_webauthn_assertion(*, credential_public_key: bytes,
530
+ auth_data: bytes, client_data_json: bytes,
531
+ signature: bytes, rp_id: str,
532
+ expected_challenge, expected_origin,
533
+ require_user_verification=False,
534
+ prior_sign_count=None) -> dict:
535
+ """Verify a WebAuthn (passkey) login assertion. ES256 / P-256 only.
536
+
537
+ * ``credential_public_key``: raw uncompressed P-256 point
538
+ (``0x04 || x || y``) stored at registration
539
+ (:func:`cose_to_raw_point` converts COSE keys).
540
+ * ``signature``: as sent by the browser (ASN.1 DER); raw 64-byte
541
+ ``r || s`` is accepted too.
542
+ * ``expected_challenge``: the challenge you issued for this login
543
+ (bytes, or base64url text) — prevents replaying old assertions.
544
+ * ``expected_origin``: e.g. ``"https://example.com"`` (or a list).
545
+ * ``prior_sign_count``: the stored counter; a non-increasing counter
546
+ signals a cloned authenticator and is rejected.
547
+
548
+ Returns the parsed authenticator data; raises :class:`AuthError`.
549
+ The signature is checked with the ``cryptography`` package when it is
550
+ installed (``pip install "pyweb-stack[crypto]"``) and with a pure-Python
551
+ fallback otherwise.
552
+ """
553
+ parsed = parse_auth_data(auth_data)
554
+ if not hmac.compare_digest(parsed["rp_id_hash"], hashlib.sha256(rp_id.encode()).digest()):
555
+ raise AuthError("rpId hash mismatch")
556
+ if not parsed["user_present"]:
557
+ raise AuthError("user presence flag not set")
558
+ if require_user_verification and not parsed["user_verified"]:
559
+ raise AuthError("user verification required")
560
+ if len(credential_public_key) != 65 or credential_public_key[0] != 0x04:
561
+ raise AuthError("unsupported credential key (need raw P-256)")
562
+ try:
563
+ client = json.loads(client_data_json)
564
+ except ValueError as exc:
565
+ raise AuthError("invalid clientDataJSON") from exc
566
+ if not isinstance(client, dict) or client.get("type") != "webauthn.get":
567
+ raise AuthError("wrong ceremony type")
568
+ try:
569
+ got_challenge = _b64url_decode(client.get("challenge") or "")
570
+ except (ValueError, TypeError) as exc:
571
+ raise AuthError("malformed challenge") from exc
572
+ if not expected_challenge or not hmac.compare_digest(got_challenge, _b64url_decode(expected_challenge)):
573
+ raise AuthError("challenge mismatch")
574
+ origins = [expected_origin] if isinstance(expected_origin, str) else list(expected_origin or [])
575
+ if client.get("origin") not in origins:
576
+ raise AuthError("origin mismatch")
577
+ if prior_sign_count and parsed["sign_count"] and parsed["sign_count"] <= prior_sign_count:
578
+ raise AuthError("sign count did not increase (possible cloned authenticator)")
579
+ msg = auth_data + hashlib.sha256(client_data_json).digest()
580
+ try:
581
+ if len(signature) == 64:
582
+ r, s_ = int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")
583
+ else:
584
+ r, s_ = _der_to_rs(signature)
585
+ except (ValueError, IndexError) as exc:
586
+ raise AuthError("malformed signature") from exc
587
+ x = int.from_bytes(credential_public_key[1:33], "big")
588
+ y = int.from_bytes(credential_public_key[33:65], "big")
589
+ if not _ecdsa_p256_verify_rs(x, y, msg, r, s_):
590
+ raise AuthError("invalid assertion signature")
591
+ return parsed
592
+
593
+
594
+ def cose_to_raw_point(cose_key: bytes) -> bytes:
595
+ """Convert a COSE_Key (ES256, kty EC2) to a raw 65-byte P-256 point."""
596
+ if not cose_key or cose_key[0] != 0xA5:
597
+ raise ValueError("expected COSE map(5)")
598
+ rest = cose_key[1:]
599
+ vals = {}
600
+ for _ in range(5):
601
+ k, rest = _cbor_first(rest)
602
+ v, rest = _cbor_first(rest)
603
+ vals[k] = v
604
+ if vals.get(1) != 2 or vals.get(3) != -7:
605
+ raise ValueError("only COSE EC2/ES256 supported")
606
+ x, y = vals.get(-2), vals.get(-3)
607
+ if not (isinstance(x, bytes) and isinstance(y, bytes)
608
+ and len(x) == 32 and len(y) == 32):
609
+ raise ValueError("bad COSE x/y coordinates")
610
+ return b"\x04" + x + y
611
+
612
+
613
+ _P256 = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
614
+ _P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
615
+ _P256_GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
616
+ _P256_GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
617
+ _P256_N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
618
+
619
+
620
+ def _p256_point_add(p, q):
621
+ # Curve: y^2 = x^3 - 3x + b (NIST P-256, a = -3).
622
+ if p is None:
623
+ return q
624
+ if q is None:
625
+ return p
626
+ x1, y1 = p
627
+ x2, y2 = q
628
+ if x1 == x2 and (y1 + y2) % _P256 == 0:
629
+ return None
630
+ if p == q:
631
+ lam = (3 * x1 * x1 - 3) * pow(2 * y1, -1, _P256) % _P256
632
+ else:
633
+ lam = ((y2 - y1) * pow((x2 - x1) % _P256, -1, _P256)) % _P256
634
+ x3 = (lam * lam - x1 - x2) % _P256
635
+ return (x3, (lam * (x1 - x3) - y1) % _P256)
636
+
637
+
638
+ def _p256_on_curve(x, y) -> bool:
639
+ return (y * y - (x * x * x - 3 * x + _P256_B)) % _P256 == 0
640
+
641
+
642
+ def _p256_scalar_mult(k, point):
643
+ res = None
644
+ add = point
645
+ while k:
646
+ if k & 1:
647
+ res = _p256_point_add(res, add)
648
+ add = _p256_point_add(add, add)
649
+ k >>= 1
650
+ return res
651
+
652
+
653
+ def _ecdsa_p256_verify(x, y, msg, signature: bytes) -> bool:
654
+ """Verify a raw 64-byte ``r || s`` ECDSA P-256/SHA-256 signature."""
655
+ if len(signature) != 64:
656
+ return False
657
+ return _ecdsa_p256_verify_rs(x, y, msg, int.from_bytes(signature[:32], "big"),
658
+ int.from_bytes(signature[32:], "big"))
659
+
660
+
661
+ def _ecdsa_p256_verify_rs(x, y, msg, r, s) -> bool:
662
+ if not (0 < x < _P256 and 0 < y < _P256) or not _p256_on_curve(x, y):
663
+ return False
664
+ if not (0 < r < _P256_N and 0 < s < _P256_N):
665
+ return False
666
+ try:
667
+ from cryptography.exceptions import InvalidSignature
668
+ from cryptography.hazmat.primitives import hashes
669
+ from cryptography.hazmat.primitives.asymmetric import ec
670
+ from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
671
+ except ImportError:
672
+ return _ecdsa_p256_verify_pure(x, y, msg, r, s)
673
+ key = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key()
674
+ try:
675
+ key.verify(encode_dss_signature(r, s), msg, ec.ECDSA(hashes.SHA256()))
676
+ return True
677
+ except InvalidSignature:
678
+ return False
679
+
680
+
681
+ def _ecdsa_p256_verify_pure(x, y, msg, r, s) -> bool:
682
+ """Pure-Python fallback. Verification uses only public data, so timing
683
+ side channels do not leak secrets; it is slow (~50/s), fine for logins."""
684
+ e = int.from_bytes(hashlib.sha256(msg).digest(), "big")
685
+ w = pow(s, -1, _P256_N)
686
+ u1 = (e * w) % _P256_N
687
+ u2 = (r * w) % _P256_N
688
+ pt = _p256_point_add(_p256_scalar_mult(u1, (_P256_GX, _P256_GY)),
689
+ _p256_scalar_mult(u2, (x, y)))
690
+ if pt is None:
691
+ return False
692
+ return pt[0] % _P256_N == r
693
+
694
+
695
+ def oidc_userinfo(endpoint: str, access_token: str, timeout=10) -> dict:
696
+ """Fetch OIDC ``userinfo`` claims with a bearer token (stdlib)."""
697
+ req = urllib.request.Request(endpoint, headers={
698
+ "Authorization": f"Bearer {access_token}",
699
+ "Accept": "application/json"})
700
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
701
+ return json.loads(resp.read().decode())