supython 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.
- supython/__init__.py +24 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +162 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/backups/__init__.py +24 -0
- supython/backups/_backup_job.py +170 -0
- supython/backups/schemas.py +18 -0
- supython/backups/service.py +217 -0
- supython/body_size.py +184 -0
- supython/cli.py +1663 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/extensions.py +36 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +119 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +144 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/apps_hooks.py.tmpl +11 -0
- supython/scaffold/templates/apps_jobs.py.tmpl +8 -0
- supython/scaffold/templates/asgi.py.tmpl +14 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +45 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +168 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/manage.py.tmpl +11 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/scaffold/templates/package_init.py.tmpl +1 -0
- supython/scaffold/templates/settings.py.tmpl +31 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +244 -0
- supython/settings_module.py +117 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.1.0.dist-info/METADATA +756 -0
- supython-0.1.0.dist-info/RECORD +200 -0
- supython-0.1.0.dist-info/WHEEL +4 -0
- supython-0.1.0.dist-info/entry_points.txt +2 -0
- supython-0.1.0.dist-info/licenses/LICENSE +21 -0
supython/jobs/worker.py
ADDED
|
@@ -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()
|