supython 0.5.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 (188) hide show
  1. supython/__init__.py +8 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +149 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/body_size.py +184 -0
  100. supython/cli.py +1653 -0
  101. supython/client/__init__.py +67 -0
  102. supython/client/_auth.py +249 -0
  103. supython/client/_client.py +145 -0
  104. supython/client/_config.py +92 -0
  105. supython/client/_functions.py +69 -0
  106. supython/client/_storage.py +255 -0
  107. supython/client/py.typed +0 -0
  108. supython/db.py +151 -0
  109. supython/db_admin.py +8 -0
  110. supython/functions/__init__.py +19 -0
  111. supython/functions/context.py +262 -0
  112. supython/functions/loader.py +307 -0
  113. supython/functions/router.py +228 -0
  114. supython/functions/schemas.py +50 -0
  115. supython/gen/__init__.py +5 -0
  116. supython/gen/_introspect.py +137 -0
  117. supython/gen/types_py.py +270 -0
  118. supython/gen/types_ts.py +365 -0
  119. supython/health.py +229 -0
  120. supython/hooks.py +117 -0
  121. supython/jobs/__init__.py +31 -0
  122. supython/jobs/backends.py +97 -0
  123. supython/jobs/context.py +58 -0
  124. supython/jobs/cron.py +152 -0
  125. supython/jobs/cron_inproc.py +118 -0
  126. supython/jobs/decorators.py +76 -0
  127. supython/jobs/registry.py +79 -0
  128. supython/jobs/router.py +136 -0
  129. supython/jobs/schemas.py +92 -0
  130. supython/jobs/service.py +311 -0
  131. supython/jobs/worker.py +219 -0
  132. supython/jwks.py +257 -0
  133. supython/keyset.py +279 -0
  134. supython/logging_config.py +291 -0
  135. supython/mail.py +33 -0
  136. supython/mailer.py +65 -0
  137. supython/migrate.py +81 -0
  138. supython/migrations/0001_extensions_and_roles.sql +46 -0
  139. supython/migrations/0002_auth_schema.sql +66 -0
  140. supython/migrations/0003_demo_todos.sql +42 -0
  141. supython/migrations/0004_auth_v0_2.sql +47 -0
  142. supython/migrations/0005_storage_schema.sql +117 -0
  143. supython/migrations/0006_realtime_schema.sql +206 -0
  144. supython/migrations/0007_jobs_schema.sql +254 -0
  145. supython/migrations/0008_jobs_last_error.sql +56 -0
  146. supython/migrations/0009_auth_rate_limits.sql +33 -0
  147. supython/migrations/0010_worker_heartbeat.sql +14 -0
  148. supython/migrations/0011_admin_schema.sql +45 -0
  149. supython/migrations/0012_auth_banned_until.sql +10 -0
  150. supython/migrations/0013_email_templates.sql +19 -0
  151. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  152. supython/migrations/0015_backups_schema.sql +14 -0
  153. supython/passwords.py +15 -0
  154. supython/realtime/__init__.py +6 -0
  155. supython/realtime/broker.py +814 -0
  156. supython/realtime/protocol.py +234 -0
  157. supython/realtime/router.py +184 -0
  158. supython/realtime/schemas.py +207 -0
  159. supython/realtime/service.py +261 -0
  160. supython/realtime/topics.py +175 -0
  161. supython/realtime/websocket.py +586 -0
  162. supython/scaffold/__init__.py +5 -0
  163. supython/scaffold/init_project.py +133 -0
  164. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  165. supython/scaffold/templates/README.md.tmpl +22 -0
  166. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  167. supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
  168. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  169. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  170. supython/scaffold/templates/env.example.tmpl +149 -0
  171. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  172. supython/scaffold/templates/gitignore.tmpl +14 -0
  173. supython/scaffold/templates/migrations/.gitkeep +0 -0
  174. supython/secretset.py +347 -0
  175. supython/security_headers.py +78 -0
  176. supython/settings.py +198 -0
  177. supython/storage/__init__.py +5 -0
  178. supython/storage/backends.py +392 -0
  179. supython/storage/router.py +341 -0
  180. supython/storage/schemas.py +50 -0
  181. supython/storage/service.py +445 -0
  182. supython/storage/signing.py +119 -0
  183. supython/tokens.py +85 -0
  184. supython-0.5.0.dist-info/METADATA +714 -0
  185. supython-0.5.0.dist-info/RECORD +188 -0
  186. supython-0.5.0.dist-info/WHEEL +4 -0
  187. supython-0.5.0.dist-info/entry_points.txt +2 -0
  188. supython-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,219 @@
1
+ """Job worker — polls the queue and dispatches handlers.
2
+
3
+ Worst-case time-to-retry after a rolling deploy is
4
+ jobs_drain_timeout_s + jobs_visibility_timeout_s
5
+ (~5.5 min on defaults), because a dying worker drains in-flight jobs before
6
+ exiting, and any that were mid-flight become zombies reclaimable after the
7
+ visibility timeout.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ import uuid
15
+ from typing import TYPE_CHECKING, Any
16
+ from uuid import UUID
17
+
18
+ from .. import db
19
+ from ..settings import Settings
20
+ from .context import build_job_ctx
21
+ from .registry import get_registry
22
+ from .service import (
23
+ claim_next,
24
+ mark_failed_final,
25
+ mark_failed_retry,
26
+ mark_succeeded,
27
+ )
28
+
29
+ if TYPE_CHECKING: # pragma: no cover
30
+ from .backends import JobBackend
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def _build_claims(record, defn) -> dict[str, Any] | None:
36
+ if defn.role == "service_role":
37
+ return None
38
+ claims: dict[str, Any] = {"role": defn.role, "aud": "authenticated"}
39
+ if defn.claims_from:
40
+ value = getattr(record, defn.claims_from, None)
41
+ if value is not None:
42
+ claims["sub"] = str(value)
43
+ return claims
44
+
45
+
46
+ def _db_ctx(record, defn):
47
+ claims = _build_claims(record, defn)
48
+ if claims is None:
49
+ return db.as_service_role()
50
+ logger.info(
51
+ "jobs.dispatch.role_switch",
52
+ extra={
53
+ "job_id": str(record.id),
54
+ "job_name": record.name,
55
+ "role": defn.role,
56
+ "claims_from": defn.claims_from,
57
+ },
58
+ )
59
+ return db.as_role(defn.role, claims)
60
+
61
+
62
+ class Worker:
63
+ def __init__(self, settings: Settings) -> None:
64
+ self.settings = settings
65
+ self.worker_id = f"worker-{uuid.uuid4().hex[:8]}"
66
+ self._inflight: set[asyncio.Task] = set()
67
+ self._stopping = False
68
+ self._backend: JobBackend | None = None
69
+ self._sem = asyncio.Semaphore(settings.jobs_concurrency)
70
+
71
+ @property
72
+ def stopping(self) -> bool:
73
+ return self._stopping
74
+
75
+ async def start(self) -> None:
76
+ from .backends import get_backend
77
+
78
+ self._backend = get_backend(self.settings)
79
+ logger.info(
80
+ "jobs.worker.start",
81
+ extra={"worker_id": self.worker_id, "backend": "pg"},
82
+ )
83
+ poll_interval = self.settings.jobs_poll_interval_s
84
+ while not self._stopping:
85
+ try:
86
+ jobs = await self._poll()
87
+ except Exception:
88
+ logger.exception("jobs.worker.poll_error")
89
+ await asyncio.sleep(poll_interval)
90
+ continue
91
+ for record in jobs:
92
+ await self._sem.acquire()
93
+ task = asyncio.create_task(self._dispatch_with_release(record))
94
+ self._inflight.add(task)
95
+ task.add_done_callback(self._inflight.discard)
96
+ await self._heartbeat(len(self._inflight))
97
+ if not jobs:
98
+ await asyncio.sleep(poll_interval)
99
+
100
+ async def stop(self) -> None:
101
+ self._stopping = True
102
+ logger.info("jobs.worker.stopping", extra={"worker_id": self.worker_id})
103
+ if self._inflight:
104
+ done, pending = await asyncio.wait(
105
+ self._inflight, timeout=self.settings.jobs_drain_timeout_s
106
+ )
107
+ for t in pending:
108
+ t.cancel()
109
+ await asyncio.gather(*pending, return_exceptions=True)
110
+ logger.info("jobs.worker.stopped", extra={"worker_id": self.worker_id})
111
+
112
+ async def _heartbeat(self, inflight: int) -> None:
113
+ """Upsert heartbeat row so /readyz can check worker freshness."""
114
+ try:
115
+ async with db.as_service_role() as conn:
116
+ await conn.execute(
117
+ """
118
+ insert into jobs.worker_heartbeats (worker_id, last_heartbeat, inflight)
119
+ values ($1, now(), $2)
120
+ on conflict (worker_id) do update set
121
+ last_heartbeat = now(),
122
+ inflight = excluded.inflight
123
+ """,
124
+ self.worker_id,
125
+ inflight,
126
+ )
127
+ except Exception:
128
+ logger.debug("jobs.worker.heartbeat_failed", exc_info=True)
129
+
130
+ async def _poll(self):
131
+ async with db.as_service_role() as conn:
132
+ return await claim_next(
133
+ conn,
134
+ queue=self.settings.jobs_queue_default,
135
+ worker_id=self.worker_id,
136
+ visibility_timeout_ms=int(self.settings.jobs_visibility_timeout_s * 1000),
137
+ zombie_batch=self.settings.jobs_visibility_reclaim_batch,
138
+ )
139
+
140
+ async def _dispatch_with_release(self, record) -> None:
141
+ try:
142
+ await self._dispatch(record)
143
+ finally:
144
+ self._sem.release()
145
+
146
+ async def _dispatch(self, record) -> None:
147
+ job_id: UUID = record.id
148
+ name: str = record.name
149
+ attempt: int = record.attempts
150
+
151
+ logger.info(
152
+ "jobs.dispatch.start",
153
+ extra={"job_id": str(job_id), "job_name": name, "attempt": attempt},
154
+ )
155
+
156
+ registry = get_registry()
157
+ defn = registry.get(name, record.version)
158
+ if defn is None:
159
+ defn = registry.get_latest(name)
160
+ if defn is None:
161
+ async with db.as_service_role() as conn:
162
+ await mark_failed_final(
163
+ conn, job_id, last_error=f"unknown job: {name}"
164
+ )
165
+ logger.error(
166
+ "jobs.dispatch.failed_final",
167
+ extra={"job_id": str(job_id), "job_name": name, "reason": "unknown_job"},
168
+ )
169
+ return
170
+
171
+ try:
172
+ async with _db_ctx(record, defn) as conn:
173
+ ctx = build_job_ctx(
174
+ conn=conn,
175
+ job_id=job_id,
176
+ attempt=attempt,
177
+ name=name,
178
+ )
179
+ if defn.accepts_payload:
180
+ await defn.handler(ctx, record.payload or {})
181
+ else:
182
+ await defn.handler(ctx)
183
+ await mark_succeeded(conn, job_id)
184
+ logger.info(
185
+ "jobs.dispatch.ok",
186
+ extra={"job_id": str(job_id), "job_name": name, "attempt": attempt},
187
+ )
188
+ except Exception as exc:
189
+ error_msg = str(exc)
190
+ logger.warning(
191
+ "jobs.dispatch.retry",
192
+ extra={
193
+ "job_id": str(job_id),
194
+ "job_name": name,
195
+ "attempt": attempt,
196
+ "error": error_msg,
197
+ },
198
+ )
199
+ async with db.as_service_role() as conn:
200
+ if attempt >= record.max_attempts:
201
+ await mark_failed_final(conn, job_id, last_error=error_msg)
202
+ logger.error(
203
+ "jobs.dispatch.failed_final",
204
+ extra={
205
+ "job_id": str(job_id),
206
+ "job_name": name,
207
+ "reason": "max_attempts_exceeded",
208
+ },
209
+ )
210
+ else:
211
+ await mark_failed_retry(
212
+ conn,
213
+ job_id,
214
+ attempts=attempt,
215
+ backoff=record.backoff,
216
+ backoff_base_s=record.backoff_base_s,
217
+ backoff_max_s=record.backoff_max_s,
218
+ last_error=error_msg,
219
+ )
supython/jwks.py ADDED
@@ -0,0 +1,257 @@
1
+ """JWT key material loader. No I/O happens until a loader is called."""
2
+
3
+ import functools
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import os
8
+ from base64 import urlsafe_b64encode
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Literal
12
+
13
+ import jwt
14
+ from cryptography.hazmat.primitives import serialization
15
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa
16
+ from jwt import PyJWK
17
+ from jwt.algorithms import ECAlgorithm, RSAAlgorithm
18
+ from jwt.utils import to_base64url_uint
19
+
20
+ from .settings import get_settings
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _MIN_RSA_BITS = 2048
25
+
26
+ _PRIVATE_JWK_MEMBERS = frozenset({"d", "p", "q", "dp", "dq", "qi"})
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SigningKey:
31
+ alg: str
32
+ kid: str
33
+ key: Any
34
+
35
+
36
+ def _mtime(path: Path | None) -> float:
37
+ return path.stat().st_mtime if path and path.exists() else 0.0
38
+
39
+
40
+ def _dir_signature(path: Path | None) -> tuple:
41
+ if path is None or not path.exists() or not path.is_dir():
42
+ return ()
43
+ entries = sorted(p.name for p in path.iterdir() if p.suffix == ".pem")
44
+ return tuple((name, (path / name).stat().st_mtime) for name in entries)
45
+
46
+
47
+ def _cache_key() -> tuple:
48
+ s = get_settings()
49
+ from . import keyset
50
+
51
+ return (
52
+ s.jwt_alg,
53
+ s.jwt_kid,
54
+ s.jwt_private_key,
55
+ _mtime(s.jwt_private_key_path),
56
+ _mtime(s.jwt_keyset_manifest_path),
57
+ _dir_signature(keyset.keys_dir()),
58
+ )
59
+
60
+
61
+ def _thumbprint_kid(members: dict[str, str]) -> str:
62
+ canonical = json.dumps(members, sort_keys=True, separators=(",", ":")).encode()
63
+ digest = hashlib.sha256(canonical).digest()
64
+ return urlsafe_b64encode(digest).rstrip(b"=").decode()
65
+
66
+
67
+ def _derive_kid(key: Any, alg: str) -> str:
68
+ if alg == "RS256":
69
+ pub = key.public_key()
70
+ numbers = pub.public_numbers()
71
+ members = {
72
+ "e": to_base64url_uint(numbers.e).decode(),
73
+ "kty": "RSA",
74
+ "n": to_base64url_uint(numbers.n).decode(),
75
+ }
76
+ return _thumbprint_kid(members)
77
+ if alg == "ES256":
78
+ pub = key.public_key()
79
+ numbers = pub.public_numbers()
80
+ members = {
81
+ "crv": "P-256",
82
+ "kty": "EC",
83
+ "x": to_base64url_uint(numbers.x).decode(),
84
+ "y": to_base64url_uint(numbers.y).decode(),
85
+ }
86
+ return _thumbprint_kid(members)
87
+ raise ValueError(f"Unsupported algorithm for thumbprint: {alg}")
88
+
89
+
90
+ def _load_pem_bytes(s: Any) -> bytes:
91
+ if s.jwt_private_key:
92
+ return s.jwt_private_key.encode()
93
+ if s.jwt_private_key_path and s.jwt_private_key_path.name:
94
+ return s.jwt_private_key_path.read_bytes()
95
+ raise RuntimeError(
96
+ f"{s.jwt_alg} requires JWT_PRIVATE_KEY or JWT_PRIVATE_KEY_PATH to be set"
97
+ )
98
+
99
+
100
+ def generate_private_key(alg: Literal["RS256", "ES256"]) -> Any:
101
+ if alg == "RS256":
102
+ return rsa.generate_private_key(public_exponent=65537, key_size=_MIN_RSA_BITS)
103
+ if alg == "ES256":
104
+ return ec.generate_private_key(ec.SECP256R1())
105
+ raise ValueError(f"unsupported JWT algorithm for key generation: {alg}")
106
+
107
+
108
+ def private_key_to_pem(key: Any) -> bytes:
109
+ return key.private_bytes(
110
+ serialization.Encoding.PEM,
111
+ serialization.PrivateFormat.PKCS8,
112
+ serialization.NoEncryption(),
113
+ )
114
+
115
+
116
+ def signing_key_from_private_key(
117
+ key: Any,
118
+ alg: Literal["RS256", "ES256"],
119
+ kid: str | None = None,
120
+ ) -> SigningKey:
121
+ if alg == "RS256":
122
+ if not isinstance(key, rsa.RSAPrivateKey):
123
+ raise ValueError(f"RS256 requires an RSA private key, got {type(key).__name__}")
124
+ if key.key_size < _MIN_RSA_BITS:
125
+ raise ValueError(
126
+ f"RS256 requires RSA key size >= {_MIN_RSA_BITS} bits, got {key.key_size}"
127
+ )
128
+ elif alg == "ES256":
129
+ if not isinstance(key, ec.EllipticCurvePrivateKey):
130
+ raise ValueError(f"ES256 requires an EC private key, got {type(key).__name__}")
131
+ if not isinstance(key.curve, ec.SECP256R1):
132
+ raise ValueError(f"ES256 requires SECP256R1 curve, got {key.curve.name}")
133
+ else:
134
+ raise ValueError(f"unsupported JWT algorithm for signing key: {alg}")
135
+
136
+ return SigningKey(alg, kid if kid is not None else _derive_kid(key, alg), key)
137
+
138
+
139
+ def _load_signer_from_keyset_entry(entry: Any) -> SigningKey:
140
+ pem = entry.pem_path.read_bytes()
141
+ key = serialization.load_pem_private_key(pem, password=None)
142
+ return signing_key_from_private_key(key, entry.alg, entry.kid)
143
+
144
+
145
+ @functools.lru_cache(maxsize=1)
146
+ def load_signing_key() -> SigningKey:
147
+ s = get_settings()
148
+ from . import keyset
149
+
150
+ if keyset.has_manifest():
151
+ kid = keyset.active_kid()
152
+ if kid is None:
153
+ raise RuntimeError(
154
+ f"keyset manifest at {keyset.manifest_path()} has no active kid; "
155
+ "run `supython keygen activate <kid>`"
156
+ )
157
+ entry = keyset.get_entry(kid)
158
+ if entry is None:
159
+ raise RuntimeError(
160
+ f"active kid {kid!r} not present in keyset manifest "
161
+ f"{keyset.manifest_path()}"
162
+ )
163
+ return _load_signer_from_keyset_entry(entry)
164
+
165
+ pem = _load_pem_bytes(s)
166
+ key = serialization.load_pem_private_key(pem, password=None)
167
+ return signing_key_from_private_key(key, s.jwt_alg, s.jwt_kid)
168
+
169
+
170
+ def derive_public_jwk(signing_key: SigningKey) -> dict[str, Any]:
171
+ if signing_key.alg == "RS256":
172
+ jwk_str = RSAAlgorithm.to_jwk(signing_key.key.public_key())
173
+ elif signing_key.alg == "ES256":
174
+ jwk_str = ECAlgorithm.to_jwk(signing_key.key.public_key())
175
+ else:
176
+ raise ValueError(
177
+ f"derive_public_jwk does not apply to {signing_key.alg}"
178
+ )
179
+ jwk = json.loads(jwk_str) if isinstance(jwk_str, str) else jwk_str
180
+ jwk = {k: v for k, v in jwk.items() if k not in _PRIVATE_JWK_MEMBERS}
181
+ jwk.update({"use": "sig", "alg": signing_key.alg, "kid": signing_key.kid})
182
+ return jwk
183
+
184
+
185
+ def jwks_for_signing_key(signing_key: SigningKey) -> dict[str, list[dict[str, Any]]]:
186
+ return {"keys": [derive_public_jwk(signing_key)]}
187
+
188
+
189
+ def write_private_key_pem(path: Path, pem: bytes, *, force: bool = False) -> None:
190
+ path.parent.mkdir(parents=True, exist_ok=True)
191
+ flags = os.O_WRONLY | os.O_CREAT
192
+ flags |= os.O_TRUNC if force else os.O_EXCL
193
+ fd = os.open(path, flags, 0o600)
194
+ try:
195
+ with os.fdopen(fd, "wb") as f:
196
+ f.write(pem)
197
+ fd = -1
198
+ finally:
199
+ if fd >= 0:
200
+ os.close(fd)
201
+ if os.name == "posix":
202
+ path.chmod(0o600)
203
+
204
+
205
+ @functools.lru_cache(maxsize=1)
206
+ def load_verification_keyset() -> dict[str, PyJWK]:
207
+ from . import keyset as keyset_mod
208
+
209
+ out: dict[str, PyJWK] = {}
210
+ if keyset_mod.has_manifest():
211
+ for entry in keyset_mod.list_keys():
212
+ sk = _load_signer_from_keyset_entry(entry)
213
+ jwk = derive_public_jwk(sk)
214
+ out[sk.kid] = PyJWK(jwk, algorithm=sk.alg)
215
+ if out:
216
+ return out
217
+
218
+ sk = load_signing_key()
219
+ jwk = derive_public_jwk(sk)
220
+ out[sk.kid] = PyJWK(jwk, algorithm=sk.alg)
221
+ return out
222
+
223
+
224
+ def dump_jwks(keyset: dict[str, PyJWK]) -> dict[str, list[dict[str, Any]]]:
225
+ keys: list[dict[str, Any]] = []
226
+ for kid, pyjwk in keyset.items():
227
+ if pyjwk.algorithm_name == "RS256":
228
+ jwk_str = RSAAlgorithm.to_jwk(pyjwk.key)
229
+ elif pyjwk.algorithm_name == "ES256":
230
+ jwk_str = ECAlgorithm.to_jwk(pyjwk.key)
231
+ else:
232
+ continue
233
+ jwk = json.loads(jwk_str) if isinstance(jwk_str, str) else jwk_str
234
+ jwk = {k: v for k, v in jwk.items() if k not in _PRIVATE_JWK_MEMBERS}
235
+ jwk.update({"use": "sig", "alg": pyjwk.algorithm_name, "kid": kid})
236
+ keys.append(jwk)
237
+ return {"keys": keys}
238
+
239
+
240
+ def write_jwks_file(path: Path, jwks_doc: dict[str, Any]) -> None:
241
+ path.parent.mkdir(parents=True, exist_ok=True)
242
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
243
+ tmp_path.write_text(json.dumps(jwks_doc, sort_keys=True) + "\n")
244
+ os.replace(tmp_path, path)
245
+
246
+
247
+ def write_current_jwks(path: Path | None = None) -> dict[str, Any]:
248
+ s = get_settings()
249
+ target = path if path is not None else s.jwt_jwks_path
250
+ jwks_doc = dump_jwks(load_verification_keyset())
251
+ write_jwks_file(target, jwks_doc)
252
+ return jwks_doc
253
+
254
+
255
+ def clear_cache() -> None:
256
+ load_signing_key.cache_clear()
257
+ load_verification_keyset.cache_clear()