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,587 @@
1
+ """Auth business logic: signup, password grant, refresh rotation, logout."""
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ import secrets
7
+ from datetime import UTC, datetime, timedelta
8
+ from typing import Any
9
+ from uuid import UUID
10
+
11
+ import asyncpg
12
+ from itsdangerous import BadSignature, URLSafeTimedSerializer
13
+
14
+ from .. import mail, passwords, tokens
15
+ from ..mailer import EmailMessage
16
+ from ..settings import get_settings
17
+ from .schemas import UserResponse
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class AuthError(Exception):
23
+ def __init__(self, code: str, message: str, status: int = 400) -> None:
24
+ super().__init__(message)
25
+ self.code = code
26
+ self.message = message
27
+ self.status = status
28
+
29
+
30
+ def _row_to_user(row: asyncpg.Record) -> UserResponse:
31
+ return UserResponse(
32
+ id=row["id"],
33
+ email=row["email"],
34
+ created_at=row["created_at"],
35
+ )
36
+
37
+ def _sha256(value: str) -> str:
38
+ return hashlib.sha256(value.encode()).hexdigest()
39
+
40
+
41
+ async def _audit_log(
42
+ conn: asyncpg.Connection,
43
+ user_id: UUID,
44
+ event: str,
45
+ *,
46
+ ip: str | None = None,
47
+ ua: str | None = None,
48
+ payload: dict[str, Any] | None = None,
49
+ ) -> None:
50
+ await conn.execute(
51
+ """
52
+ insert into auth.audit_log (user_id, event, ip, ua, payload)
53
+ values ($1, $2, $3::inet, $4, $5::jsonb)
54
+ """,
55
+ user_id,
56
+ event,
57
+ ip,
58
+ ua,
59
+ json.dumps(payload or {}),
60
+ )
61
+
62
+
63
+ async def _issue_pair(
64
+ conn: asyncpg.Connection, user: UserResponse
65
+ ) -> tuple[str, str, int]:
66
+ access, ttl = tokens.issue_access_token(user.id, user.email)
67
+ refresh = tokens.issue_refresh_token()
68
+ await conn.execute(
69
+ "insert into auth.refresh_tokens (user_id, token) values ($1, $2)",
70
+ user.id,
71
+ refresh,
72
+ )
73
+ return access, refresh, ttl
74
+
75
+
76
+ async def _store_one_time_token(
77
+ conn: asyncpg.Connection,
78
+ user_id: UUID,
79
+ token_type: str,
80
+ ttl_seconds: int,
81
+ ) -> str:
82
+ """Generate, sha256-hash, and store a one-time token. Returns the raw token."""
83
+ raw = secrets.token_urlsafe(32)
84
+ expires_at = datetime.now(UTC) + timedelta(seconds=ttl_seconds)
85
+ await conn.execute(
86
+ """
87
+ insert into auth.one_time_tokens (user_id, type, token_hash, expires_at)
88
+ values ($1, $2, $3, $4)
89
+ """,
90
+ user_id,
91
+ token_type,
92
+ _sha256(raw),
93
+ expires_at,
94
+ )
95
+ return raw
96
+
97
+
98
+ async def _verify_one_time_token(
99
+ conn: asyncpg.Connection,
100
+ token: str,
101
+ token_type: str,
102
+ email: str | None = None,
103
+ ) -> asyncpg.Record:
104
+ """Return a valid, unexpired, unused token row. Raises AuthError if not found."""
105
+ token_hash = _sha256(token)
106
+ if email is not None:
107
+ row = await conn.fetchrow(
108
+ """
109
+ select ott.id as ott_id, u.id, u.email, u.created_at
110
+ from auth.one_time_tokens ott
111
+ join auth.users u on u.id = ott.user_id
112
+ where ott.token_hash = $1
113
+ and ott.type = $2
114
+ and ott.used_at is null
115
+ and ott.expires_at > now()
116
+ and u.email = $3
117
+ """,
118
+ token_hash,
119
+ token_type,
120
+ email,
121
+ )
122
+ else:
123
+ row = await conn.fetchrow(
124
+ """
125
+ select ott.id as ott_id, u.id, u.email, u.created_at
126
+ from auth.one_time_tokens ott
127
+ join auth.users u on u.id = ott.user_id
128
+ where ott.token_hash = $1
129
+ and ott.type = $2
130
+ and ott.used_at is null
131
+ and ott.expires_at > now()
132
+ """,
133
+ token_hash,
134
+ token_type,
135
+ )
136
+ if not row:
137
+ logger.warning("auth.verify_one_time_token: invalid or expired %s token", token_type)
138
+ raise AuthError("invalid_token", "Token is invalid or expired", 400)
139
+ return row
140
+
141
+
142
+ async def signup(
143
+ conn: asyncpg.Connection, email: str, password: str
144
+ ) -> tuple[UserResponse, str, str, int]:
145
+ existing = await conn.fetchval(
146
+ "select 1 from auth.users where email = $1", email
147
+ )
148
+ if existing:
149
+ raise AuthError("email_taken", "Email already registered", 409)
150
+
151
+ pw_hash = passwords.hash_password(password)
152
+ row = await conn.fetchrow(
153
+ """
154
+ insert into auth.users (email, encrypted_password, email_confirmed_at)
155
+ values ($1, $2, now())
156
+ returning id, email, created_at
157
+ """,
158
+ email,
159
+ pw_hash,
160
+ )
161
+ user = _row_to_user(row)
162
+ access, refresh, ttl = await _issue_pair(conn, user)
163
+
164
+ try:
165
+ from .. import db as _db
166
+ from ..hooks import build_hook_ctx, fire
167
+
168
+ synth_claims = {"sub": str(user.id), "email": user.email, "role": "authenticated"}
169
+ async with _db.as_role("authenticated", synth_claims) as role_conn:
170
+ ctx = build_hook_ctx(conn=role_conn)
171
+ await fire("signup", user, ctx)
172
+ except Exception:
173
+ logger.warning("hooks: signup hook failed", exc_info=True)
174
+
175
+ return user, access, refresh, ttl
176
+
177
+
178
+ async def password_grant(
179
+ conn: asyncpg.Connection, email: str, password: str
180
+ ) -> tuple[UserResponse, str, str, int]:
181
+ row = await conn.fetchrow(
182
+ """
183
+ select id, email, encrypted_password, created_at
184
+ from auth.users
185
+ where email = $1
186
+ """,
187
+ email,
188
+ )
189
+ if not row or not row["encrypted_password"]:
190
+ logger.warning("auth.password_grant: unknown email %s", email)
191
+ raise AuthError("invalid_credentials", "Invalid email or password", 401)
192
+ if not passwords.verify_password(row["encrypted_password"], password):
193
+ logger.warning("auth.password_grant: bad password for %s", email)
194
+ raise AuthError("invalid_credentials", "Invalid email or password", 401)
195
+
196
+ await conn.execute(
197
+ "update auth.users set last_sign_in_at = now() where id = $1",
198
+ row["id"],
199
+ )
200
+ user = _row_to_user(row)
201
+ access, refresh, ttl = await _issue_pair(conn, user)
202
+ return user, access, refresh, ttl
203
+
204
+
205
+ async def refresh_grant(
206
+ conn: asyncpg.Connection,
207
+ refresh_token: str,
208
+ *,
209
+ ip: str | None = None,
210
+ ua: str | None = None,
211
+ ) -> tuple[UserResponse, str, str, int]:
212
+ row = await conn.fetchrow(
213
+ """
214
+ select rt.id as rt_id,
215
+ rt.revoked as rt_revoked,
216
+ u.id as id,
217
+ u.email as email,
218
+ u.created_at as created_at
219
+ from auth.refresh_tokens rt
220
+ join auth.users u on u.id = rt.user_id
221
+ where rt.token = $1
222
+ """,
223
+ refresh_token,
224
+ )
225
+ if not row:
226
+ logger.warning("auth.refresh_grant: unknown refresh token")
227
+ raise AuthError("invalid_refresh_token", "Refresh token is invalid", 401)
228
+
229
+ if row["rt_revoked"]:
230
+ # A revoked token was presented — this is a reuse attack.
231
+ # Walk the full descendant chain and revoke every live child token,
232
+ # then record the incident before refusing the request.
233
+ async with conn.transaction():
234
+ await conn.execute(
235
+ """
236
+ with recursive descendants as (
237
+ select id, token
238
+ from auth.refresh_tokens
239
+ where parent = $1
240
+ union all
241
+ select rt.id, rt.token
242
+ from auth.refresh_tokens rt
243
+ join descendants d on rt.parent = d.token
244
+ )
245
+ update auth.refresh_tokens
246
+ set revoked = true
247
+ where id in (select id from descendants)
248
+ """,
249
+ refresh_token,
250
+ )
251
+ await _audit_log(
252
+ conn, row["id"], "refresh_token_reuse",
253
+ ip=ip, ua=ua,
254
+ payload={"token_id": str(row["rt_id"])},
255
+ )
256
+ raise AuthError(
257
+ "token_reuse_detected",
258
+ "Token reuse detected — all sessions have been invalidated",
259
+ 401,
260
+ )
261
+
262
+ user = _row_to_user(row)
263
+ new_refresh = tokens.issue_refresh_token()
264
+ async with conn.transaction():
265
+ await conn.execute(
266
+ "update auth.refresh_tokens set revoked = true where id = $1",
267
+ row["rt_id"],
268
+ )
269
+ await conn.execute(
270
+ "insert into auth.refresh_tokens (user_id, token, parent) "
271
+ "values ($1, $2, $3)",
272
+ user.id,
273
+ new_refresh,
274
+ refresh_token,
275
+ )
276
+ access, ttl = tokens.issue_access_token(user.id, user.email)
277
+ return user, access, new_refresh, ttl
278
+
279
+
280
+ async def logout(conn: asyncpg.Connection, refresh_token: str) -> None:
281
+ await conn.execute(
282
+ "update auth.refresh_tokens set revoked = true where token = $1",
283
+ refresh_token,
284
+ )
285
+
286
+
287
+ async def get_user(
288
+ conn: asyncpg.Connection, user_id: UUID
289
+ ) -> UserResponse | None:
290
+ row = await conn.fetchrow(
291
+ "select id, email, created_at from auth.users where id = $1",
292
+ user_id,
293
+ )
294
+ return _row_to_user(row) if row else None
295
+
296
+
297
+ async def request_recover(
298
+ conn: asyncpg.Connection, email: str
299
+ ) -> None:
300
+ row = await conn.fetchrow(
301
+ "select id from auth.users where email = $1", email
302
+ )
303
+ if not row:
304
+ return
305
+ s = get_settings()
306
+ async with conn.transaction():
307
+ raw = await _store_one_time_token(conn, row["id"], "recover", s.recover_token_ttl)
308
+ await mail.dispatch(
309
+ conn,
310
+ EmailMessage(
311
+ to=[email],
312
+ subject="Reset your password",
313
+ text=f"Use this token to reset your password: {raw}",
314
+ ),
315
+ job_name="send_auth_email",
316
+ )
317
+
318
+
319
+ async def verify_recover(
320
+ conn: asyncpg.Connection,
321
+ email: str,
322
+ token: str,
323
+ new_password: str,
324
+ *,
325
+ ip: str | None = None,
326
+ ua: str | None = None,
327
+ ) -> tuple[UserResponse, str, str, int]:
328
+ row = await _verify_one_time_token(conn, token, "recover", email)
329
+ pw_hash = passwords.hash_password(new_password)
330
+ user = _row_to_user(row)
331
+ async with conn.transaction():
332
+ await conn.execute(
333
+ "update auth.one_time_tokens set used_at = now() where id = $1",
334
+ row["ott_id"],
335
+ )
336
+ await conn.execute(
337
+ "update auth.users set encrypted_password = $1 where id = $2",
338
+ pw_hash,
339
+ row["id"],
340
+ )
341
+ await _audit_log(
342
+ conn, user.id, "password_change",
343
+ ip=ip, ua=ua,
344
+ payload={"via": "recover"},
345
+ )
346
+ access, refresh, ttl = await _issue_pair(conn, user)
347
+ return user, access, refresh, ttl
348
+
349
+
350
+ async def request_magic_link(
351
+ conn: asyncpg.Connection, email: str
352
+ ) -> None:
353
+ row = await conn.fetchrow(
354
+ "select id from auth.users where email = $1", email
355
+ )
356
+ if not row:
357
+ return
358
+ s = get_settings()
359
+ async with conn.transaction():
360
+ raw = await _store_one_time_token(conn, row["id"], "magic_link", s.magic_link_token_ttl)
361
+ verify_url = f"{s.site_url}/auth/v1/magiclink/verify?token={raw}"
362
+ await mail.dispatch(
363
+ conn,
364
+ EmailMessage(
365
+ to=[email],
366
+ subject="Sign in to your account",
367
+ text=f"Click the link to sign in: {verify_url}",
368
+ ),
369
+ job_name="send_auth_email",
370
+ )
371
+
372
+
373
+ async def verify_magic_link(
374
+ conn: asyncpg.Connection, token: str
375
+ ) -> tuple[UserResponse, str, str, int]:
376
+ row = await _verify_one_time_token(conn, token, "magic_link")
377
+ user = _row_to_user(row)
378
+ async with conn.transaction():
379
+ await conn.execute(
380
+ "update auth.one_time_tokens set used_at = now() where id = $1",
381
+ row["ott_id"],
382
+ )
383
+ access, refresh, ttl = await _issue_pair(conn, user)
384
+ return user, access, refresh, ttl
385
+
386
+
387
+ async def request_otp(
388
+ conn: asyncpg.Connection, email: str
389
+ ) -> None:
390
+ row = await conn.fetchrow(
391
+ "select id from auth.users where email = $1", email
392
+ )
393
+ if not row:
394
+ return
395
+ s = get_settings()
396
+ async with conn.transaction():
397
+ raw = await _store_one_time_token(conn, row["id"], "otp", s.otp_token_ttl)
398
+ await mail.dispatch(
399
+ conn,
400
+ EmailMessage(
401
+ to=[email],
402
+ subject="Your one-time password",
403
+ text=f"Your OTP is: {raw}",
404
+ ),
405
+ job_name="send_auth_email",
406
+ )
407
+
408
+
409
+ async def verify_otp(
410
+ conn: asyncpg.Connection, email: str, token: str
411
+ ) -> tuple[UserResponse, str, str, int]:
412
+ row = await _verify_one_time_token(conn, token, "otp", email)
413
+ user = _row_to_user(row)
414
+ async with conn.transaction():
415
+ await conn.execute(
416
+ "update auth.one_time_tokens set used_at = now() where id = $1",
417
+ row["ott_id"],
418
+ )
419
+ access, refresh, ttl = await _issue_pair(conn, user)
420
+ return user, access, refresh, ttl
421
+
422
+
423
+ # ---------------------------------------------------------------------------
424
+ # OAuth
425
+ # ---------------------------------------------------------------------------
426
+ s = get_settings()
427
+
428
+ _OAUTH_STATE_MAX_AGE = s.oauth_state_max_age # seconds — state cookie lifetime
429
+
430
+
431
+ def _state_signer() -> URLSafeTimedSerializer:
432
+ from ..secretset import load_signing_secret
433
+
434
+ manifest_secret = load_signing_secret("oauth_state")
435
+ if manifest_secret is not None:
436
+ return URLSafeTimedSerializer(manifest_secret)
437
+ legacy = get_settings().oauth_state_secret
438
+ if legacy is None:
439
+ raise RuntimeError(
440
+ "no OAuth state secret configured; run "
441
+ "`supython secret rotate oauth` or set OAUTH_STATE_SECRET"
442
+ )
443
+ return URLSafeTimedSerializer(legacy)
444
+
445
+
446
+ async def oauth_start(provider_name: str, redirect_uri: str) -> str:
447
+ """Return the provider's authorization URL with a signed, time-limited state."""
448
+ from .providers.registry import get_provider
449
+
450
+ try:
451
+ provider = get_provider(provider_name)
452
+ except KeyError as exc:
453
+ raise AuthError("unknown_provider", str(exc), 400) from exc
454
+
455
+ code_verifier = secrets.token_urlsafe(32)
456
+ state = _state_signer().dumps(
457
+ {"redirect_uri": redirect_uri, "p": provider_name, "v": code_verifier}
458
+ )
459
+ return await provider.authorize_url(state, redirect_uri, code_verifier)
460
+
461
+
462
+ async def oauth_finish(
463
+ conn: asyncpg.Connection,
464
+ provider_name: str,
465
+ code: str,
466
+ state: str,
467
+ *,
468
+ ip: str | None = None,
469
+ ua: str | None = None,
470
+ ) -> tuple[UserResponse, str, str, int, str]:
471
+ """Exchange an OAuth code for our own token pair.
472
+
473
+ Returns (user, access_token, refresh_token, ttl, redirect_uri) where
474
+ redirect_uri was embedded in the signed state at authorize time.
475
+ """
476
+ from ..secretset import load_verification_secrets
477
+ from .providers.registry import get_provider
478
+
479
+ secrets_list = load_verification_secrets("oauth_state")
480
+ if not secrets_list:
481
+ legacy = get_settings().oauth_state_secret
482
+ if legacy is None:
483
+ raise RuntimeError(
484
+ "no OAuth state secret configured; run "
485
+ "`supython secret rotate oauth` or set OAUTH_STATE_SECRET"
486
+ )
487
+ secrets_list = [(legacy, None)]
488
+
489
+ state_data: dict | None = None
490
+ last_error: Exception | None = None
491
+ for value, _kid in secrets_list:
492
+ signer = URLSafeTimedSerializer(value)
493
+ try:
494
+ state_data = signer.loads(state, max_age=_OAUTH_STATE_MAX_AGE)
495
+ break
496
+ except BadSignature as exc:
497
+ last_error = exc
498
+ continue
499
+ if state_data is None:
500
+ raise AuthError(
501
+ "invalid_state", "OAuth state is invalid or expired", 400
502
+ ) from last_error
503
+
504
+ redirect_uri: str = state_data.get("redirect_uri", "")
505
+ code_verifier: str | None = state_data.get("v")
506
+
507
+ try:
508
+ provider = get_provider(provider_name)
509
+ except KeyError as exc:
510
+ raise AuthError("unknown_provider", str(exc), 400) from exc
511
+
512
+ try:
513
+ profile = await provider.exchange(code, redirect_uri, code_verifier)
514
+ except Exception as exc:
515
+ logger.warning("OAuth exchange failed for %s: %s", provider_name, exc)
516
+ raise AuthError("oauth_exchange_failed", "Provider exchange failed", 400) from exc
517
+
518
+ if not profile.email:
519
+ raise AuthError(
520
+ "no_email",
521
+ "The OAuth provider did not return an email address. "
522
+ "Make sure your account has a public primary email set.",
523
+ 400,
524
+ )
525
+
526
+ identity_row = await conn.fetchrow(
527
+ """
528
+ select i.user_id, u.email, u.created_at
529
+ from auth.identities i
530
+ join auth.users u on u.id = i.user_id
531
+ where i.provider = $1 and i.provider_user_id = $2
532
+ """,
533
+ provider_name,
534
+ profile.provider_user_id,
535
+ )
536
+
537
+ if identity_row:
538
+ user = UserResponse(
539
+ id=identity_row["user_id"],
540
+ email=identity_row["email"],
541
+ created_at=identity_row["created_at"],
542
+ )
543
+ await conn.execute(
544
+ "update auth.users set last_sign_in_at = now() where id = $1",
545
+ user.id,
546
+ )
547
+ else:
548
+ async with conn.transaction():
549
+ user_row = await conn.fetchrow(
550
+ "select id, email, created_at from auth.users where email = $1",
551
+ profile.email,
552
+ )
553
+ if not user_row:
554
+ user_row = await conn.fetchrow(
555
+ """
556
+ insert into auth.users (email, email_confirmed_at)
557
+ values ($1, now())
558
+ returning id, email, created_at
559
+ """,
560
+ profile.email,
561
+ )
562
+ user = _row_to_user(user_row)
563
+ identity_id = await conn.fetchval(
564
+ """
565
+ insert into auth.identities
566
+ (user_id, provider, provider_user_id, identity_data)
567
+ values ($1, $2, $3, $4::jsonb)
568
+ on conflict (provider, provider_user_id) do nothing
569
+ returning id
570
+ """,
571
+ user.id,
572
+ provider_name,
573
+ profile.provider_user_id,
574
+ json.dumps(profile.raw),
575
+ )
576
+ if identity_id is not None:
577
+ await _audit_log(
578
+ conn, user.id, "oauth_identity_linked",
579
+ ip=ip, ua=ua,
580
+ payload={
581
+ "provider": provider_name,
582
+ "provider_user_id": profile.provider_user_id,
583
+ },
584
+ )
585
+
586
+ access, refresh, ttl = await _issue_pair(conn, user)
587
+ return user, access, refresh, ttl, redirect_uri