svc-infra 0.1.595__py3-none-any.whl → 0.1.706__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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -2,16 +2,19 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  from datetime import datetime, timezone
5
+ from typing import Any
5
6
 
6
7
  from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
7
8
  from fastapi.responses import JSONResponse
8
9
  from fastapi_users import FastAPIUsers
9
- from fastapi_users.authentication import AuthenticationBackend
10
+ from fastapi_users.authentication import AuthenticationBackend, Strategy
10
11
  from fastapi_users.password import PasswordHelper
12
+ from starlette.datastructures import FormData
11
13
 
12
14
  from svc_infra.api.fastapi.auth._cookies import compute_cookie_params
13
15
  from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
14
16
  from svc_infra.api.fastapi.auth.settings import get_auth_settings
17
+ from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
15
18
  from svc_infra.api.fastapi.dual.public import public_router
16
19
 
17
20
  _pwd = PasswordHelper()
@@ -30,28 +33,38 @@ async def login_client_gaurd(request: Request):
30
33
 
31
34
  # only enforce on the login endpoint (form-encoded)
32
35
  if request.method.upper() == "POST" and request.url.path.endswith("/login"):
36
+ form: FormData | dict[str, Any]
33
37
  try:
34
38
  form = await request.form()
35
39
  except Exception:
36
40
  form = {}
37
41
 
38
- client_id = (form.get("client_id") or "").strip()
39
- client_secret = (form.get("client_secret") or "").strip()
42
+ client_id_raw = form.get("client_id")
43
+ client_secret_raw = form.get("client_secret")
44
+ client_id = client_id_raw.strip() if isinstance(client_id_raw, str) else ""
45
+ client_secret = (
46
+ client_secret_raw.strip() if isinstance(client_secret_raw, str) else ""
47
+ )
40
48
  if not client_id or not client_secret:
41
49
  raise HTTPException(
42
- status_code=status.HTTP_401_UNAUTHORIZED, detail="client_credentials_required"
50
+ status_code=status.HTTP_401_UNAUTHORIZED,
51
+ detail="client_credentials_required",
43
52
  )
44
53
 
45
54
  # validate against configured clients
46
55
  ok = False
47
56
  for pc in getattr(st, "password_clients", []) or []:
48
- if pc.client_id == client_id and pc.client_secret.get_secret_value() == client_secret:
57
+ if (
58
+ pc.client_id == client_id
59
+ and pc.client_secret.get_secret_value() == client_secret
60
+ ):
49
61
  ok = True
50
62
  break
51
63
 
52
64
  if not ok:
53
65
  raise HTTPException(
54
- status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_client_credentials"
66
+ status_code=status.HTTP_401_UNAUTHORIZED,
67
+ detail="invalid_client_credentials",
55
68
  )
56
69
 
57
70
 
@@ -66,21 +79,20 @@ def auth_session_router(
66
79
  router = public_router()
67
80
  policy = auth_policy or DefaultAuthPolicy(get_auth_settings())
68
81
 
69
- from svc_infra.api.fastapi.db.sql import SqlSessionDep
70
82
  from svc_infra.security.lockout import get_lockout_status, record_attempt
71
83
 
72
84
  @router.post("/login", name="auth:jwt.login")
73
85
  async def login(
74
86
  request: Request,
87
+ session: SqlSessionDep,
75
88
  username: str = Form(...),
76
89
  password: str = Form(...),
77
90
  scope: str = Form(""),
78
91
  client_id: str | None = Form(None),
79
92
  client_secret: str | None = Form(None),
93
+ strategy: Strategy[Any, Any] = Depends(auth_backend.get_strategy),
80
94
  user_manager=Depends(fapi.get_user_manager),
81
- session: SqlSessionDep = Depends(),
82
95
  ):
83
- strategy = auth_backend.get_strategy()
84
96
  email = username.strip().lower()
85
97
  # Compute IP hash for lockout correlation
86
98
  client_ip = getattr(request.client, "host", None)
@@ -91,7 +103,9 @@ def auth_session_router(
91
103
  status_lo = await get_lockout_status(session, user_id=None, ip_hash=ip_hash)
92
104
  if status_lo.locked and status_lo.next_allowed_at:
93
105
  retry = int(
94
- (status_lo.next_allowed_at - datetime.now(timezone.utc)).total_seconds()
106
+ (
107
+ status_lo.next_allowed_at - datetime.now(timezone.utc)
108
+ ).total_seconds()
95
109
  )
96
110
  raise HTTPException(
97
111
  status_code=429,
@@ -106,7 +120,9 @@ def auth_session_router(
106
120
  if not user:
107
121
  _, _ = _pwd.verify_and_update(password, _DUMMY_BCRYPT)
108
122
  try:
109
- await record_attempt(session, user_id=None, ip_hash=ip_hash, success=False)
123
+ await record_attempt(
124
+ session, user_id=None, ip_hash=ip_hash, success=False
125
+ )
110
126
  except Exception:
111
127
  pass
112
128
  raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
@@ -115,11 +131,16 @@ def auth_session_router(
115
131
  if not getattr(user, "is_active", True):
116
132
  raise HTTPException(401, "account_disabled")
117
133
 
118
- hashed = getattr(user, "hashed_password", None) or getattr(user, "password_hash", None)
134
+ hashed = getattr(user, "hashed_password", None) or getattr(
135
+ user, "password_hash", None
136
+ )
119
137
  if not hashed:
120
138
  try:
121
139
  await record_attempt(
122
- session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=False
140
+ session,
141
+ user_id=getattr(user, "id", None),
142
+ ip_hash=ip_hash,
143
+ success=False,
123
144
  )
124
145
  except Exception:
125
146
  pass
@@ -132,7 +153,9 @@ def auth_session_router(
132
153
  )
133
154
  if status_user.locked and status_user.next_allowed_at:
134
155
  retry = int(
135
- (status_user.next_allowed_at - datetime.now(timezone.utc)).total_seconds()
156
+ (
157
+ status_user.next_allowed_at - datetime.now(timezone.utc)
158
+ ).total_seconds()
136
159
  )
137
160
  raise HTTPException(
138
161
  status_code=429,
@@ -146,7 +169,10 @@ def auth_session_router(
146
169
  if not ok:
147
170
  try:
148
171
  await record_attempt(
149
- session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=False
172
+ session,
173
+ user_id=getattr(user, "id", None),
174
+ ip_hash=ip_hash,
175
+ success=False,
150
176
  )
151
177
  except Exception:
152
178
  pass
@@ -187,7 +213,10 @@ def auth_session_router(
187
213
  # Record successful attempt (for audit)
188
214
  try:
189
215
  await record_attempt(
190
- session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=True
216
+ session,
217
+ user_id=getattr(user, "id", None),
218
+ ip_hash=ip_hash,
219
+ success=True,
191
220
  )
192
221
  except Exception:
193
222
  pass
@@ -4,7 +4,9 @@ from typing import Optional
4
4
  from pydantic import BaseModel
5
5
 
6
6
  # --- Email OTP store (replace with Redis in prod) ---
7
- EMAIL_OTP_STORE: dict[str, dict] = {} # key = uid (or jti), value={hash,exp,attempts,next_send}
7
+ EMAIL_OTP_STORE: dict[
8
+ str, dict
9
+ ] = {} # key = uid (or jti), value={hash,exp,attempts,next_send}
8
10
 
9
11
 
10
12
  class StartSetupOut(BaseModel):
@@ -1,18 +1,22 @@
1
1
  from datetime import datetime, timezone
2
2
 
3
3
  from svc_infra.api.fastapi.auth.settings import get_auth_settings
4
+ from svc_infra.app.env import require_secret
4
5
 
5
6
 
6
7
  def get_mfa_pre_jwt_writer():
7
8
  st = get_auth_settings()
8
9
  jwt_block = getattr(st, "jwt", None)
9
10
 
10
- # Force to plain string
11
- secret = (
12
- jwt_block.secret.get_secret_value()
13
- if jwt_block and getattr(jwt_block, "secret", None)
14
- else "svc-dev-secret-change-me"
15
- )
11
+ # Force to plain string - use require_secret to ensure it's set in production
12
+ if jwt_block and getattr(jwt_block, "secret", None):
13
+ secret = jwt_block.secret.get_secret_value()
14
+ else:
15
+ secret = require_secret(
16
+ None,
17
+ "JWT_SECRET (via auth settings jwt.secret for MFA)",
18
+ dev_default="dev-only-mfa-jwt-secret-not-for-production",
19
+ )
16
20
  secret = str(secret)
17
21
 
18
22
  lifetime = int(getattr(st, "mfa_pre_token_lifetime_seconds", 300))
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from datetime import datetime, timezone
4
+ from typing import Any, cast
4
5
 
5
6
  import pyotp
6
7
  from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
@@ -79,7 +80,7 @@ def mfa_router(
79
80
  raise HTTPException(401, "Invalid token")
80
81
 
81
82
  # IMPORTANT: rehydrate into *your* session
82
- db_user = await session.get(user_model, user.id)
83
+ db_user = await cast(Any, session).get(user_model, user.id)
83
84
  if not db_user:
84
85
  raise HTTPException(401, "Invalid token")
85
86
 
@@ -113,7 +114,9 @@ def mfa_router(
113
114
  # )).scalar_one()
114
115
  # assert fresh_secret == secret
115
116
 
116
- return StartSetupOut(otpauth_url=uri, secret=secret, qr_svg=_qr_svg_from_uri(uri))
117
+ return StartSetupOut(
118
+ otpauth_url=uri, secret=secret, qr_svg=_qr_svg_from_uri(uri)
119
+ )
117
120
 
118
121
  @u.post(
119
122
  MFA_CONFIRM_PATH,
@@ -126,7 +129,7 @@ def mfa_router(
126
129
 
127
130
  # RELOAD from DB to avoid stale state
128
131
  user = (
129
- await session.execute(select(user_model).where(user_model.id == user.id))
132
+ await session.execute(select(user_model).where(user_model.id == user.id)) # type: ignore[attr-defined]
130
133
  ).scalar_one()
131
134
 
132
135
  if not getattr(user, "mfa_secret", None):
@@ -198,7 +201,7 @@ def mfa_router(
198
201
  raise HTTPException(401, "Invalid pre-auth token")
199
202
 
200
203
  # 2) load user
201
- user = await session.get(user_model, uid)
204
+ user = await cast(Any, session).get(user_model, uid)
202
205
  if not user:
203
206
  raise HTTPException(401, "Invalid pre-auth token")
204
207
 
@@ -206,7 +209,9 @@ def mfa_router(
206
209
  if not getattr(user, "is_active", True):
207
210
  raise HTTPException(401, "account_disabled")
208
211
 
209
- if (not getattr(user, "mfa_enabled", False)) or (not getattr(user, "mfa_secret", None)):
212
+ if (not getattr(user, "mfa_enabled", False)) or (
213
+ not getattr(user, "mfa_secret", None)
214
+ ):
210
215
  raise HTTPException(401, "MFA not enabled")
211
216
 
212
217
  # 3) verify TOTP or fallback
@@ -248,7 +253,9 @@ def mfa_router(
248
253
  # 4) mint normal JWT and set cookie
249
254
  token = await strategy.write_token(user)
250
255
  resp = JSONResponse({"access_token": token, "token_type": "bearer"})
251
- cp = compute_cookie_params(request, name=st.auth_cookie_name) # <-- pass Request here
256
+ cp = compute_cookie_params(
257
+ request, name=st.auth_cookie_name
258
+ ) # <-- pass Request here
252
259
  resp.set_cookie(**cp, value=token)
253
260
  return resp
254
261
 
@@ -271,7 +278,7 @@ def mfa_router(
271
278
  raise HTTPException(401, "Invalid pre-auth token")
272
279
 
273
280
  # 1b) Load user to get their email
274
- user = await session.get(user_model, uid)
281
+ user = await cast(Any, session).get(user_model, uid)
275
282
  if not user or not getattr(user, "email", None):
276
283
  # (optionally also check user.mfa_enabled here)
277
284
  raise HTTPException(401, "Invalid pre-auth token")
@@ -326,7 +333,7 @@ def mfa_router(
326
333
  # Email OTP is always offered in your flow at verify-time
327
334
  methods.append("email")
328
335
 
329
- def _mask(email: str) -> str:
336
+ def _mask(email: str) -> str | None:
330
337
  if not email or "@" not in email:
331
338
  return None
332
339
  name, domain = email.split("@", 1)
@@ -5,8 +5,7 @@ from fastapi import Body, Depends, HTTPException, Query
5
5
  from svc_infra.api.fastapi.auth.security import Identity
6
6
  from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
7
7
 
8
- from .models import MFAProof
9
- from .verify import verify_mfa_for_user
8
+ from .verify import MFAProof, verify_mfa_for_user
10
9
 
11
10
 
12
11
  def RequireMFAIfEnabled(body_field: str = "mfa"):
@@ -29,7 +29,8 @@ def _gen_recovery_codes(n: int, length: int) -> list[str]:
29
29
  def _gen_numeric_code(n: int = 6) -> str:
30
30
  import random
31
31
 
32
- return "".join(str(random.randrange(10)) for _ in range(n))
32
+ code = "".join(str(random.randrange(10)) for _ in range(n))
33
+ return code
33
34
 
34
35
 
35
36
  def _hash(s: str) -> str:
@@ -73,11 +73,18 @@ async def verify_mfa_for_user(
73
73
  now = _now_utc_ts()
74
74
  if rec:
75
75
  attempts_left = rec.get("attempts_left")
76
- if now <= rec["exp"] and attempts_left and attempts_left > 0 and rec["hash"] == dig:
76
+ if (
77
+ now <= rec["exp"]
78
+ and attempts_left
79
+ and attempts_left > 0
80
+ and rec["hash"] == dig
81
+ ):
77
82
  EMAIL_OTP_STORE.pop(uid, None) # burn on success
78
83
  return MFAResult(ok=True, method="email", attempts_left=None)
79
84
  # decrement on failure
80
85
  rec["attempts_left"] = max(0, (attempts_left or 0) - 1)
81
- return MFAResult(ok=False, method="email", attempts_left=rec["attempts_left"])
86
+ return MFAResult(
87
+ ok=False, method="email", attempts_left=rec["attempts_left"]
88
+ )
82
89
 
83
90
  return MFAResult(ok=False, method="none", attempts_left=None)
@@ -4,7 +4,6 @@ from typing import Any, Protocol
4
4
 
5
5
 
6
6
  class AuthPolicy(Protocol):
7
-
8
7
  async def should_require_mfa(self, user: Any) -> bool:
9
8
  pass
10
9
 
@@ -64,7 +64,9 @@ def providers_from_settings(settings: Any) -> Dict[str, Dict[str, Any]]:
64
64
  }
65
65
 
66
66
  # LinkedIn (non-OIDC)
67
- if getattr(settings, "li_client_id", None) and getattr(settings, "li_client_secret", None):
67
+ if getattr(settings, "li_client_id", None) and getattr(
68
+ settings, "li_client_secret", None
69
+ ):
68
70
  reg["linkedin"] = {
69
71
  "kind": "linkedin",
70
72
  "authorize_url": "https://www.linkedin.com/oauth/v2/authorization",
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from datetime import datetime, timedelta, timezone
4
- from typing import List, Optional
4
+ from typing import Any, List, Optional, cast
5
5
  from uuid import UUID
6
6
 
7
7
  from fastapi import HTTPException, Query
@@ -62,7 +62,7 @@ def apikey_router():
62
62
  if owner_id != caller_id and not getattr(p.user, "is_superuser", False):
63
63
  raise HTTPException(403, "forbidden")
64
64
 
65
- plaintext, prefix, hashed = ApiKey.make_secret()
65
+ plaintext, prefix, hashed = ApiKey.make_secret() # type: ignore[attr-defined]
66
66
  expires = (
67
67
  (datetime.now(timezone.utc) + timedelta(hours=payload.ttl_hours))
68
68
  if payload.ttl_hours
@@ -98,9 +98,9 @@ def apikey_router():
98
98
  description="List API keys. Non-superusers see only their own keys.",
99
99
  )
100
100
  async def list_keys(sess: SqlSessionDep, p: Identity):
101
- q = select(ApiKey)
101
+ q: Any = select(ApiKey)
102
102
  if not getattr(p.user, "is_superuser", False):
103
- q = q.where(ApiKey.user_id == p.user.id)
103
+ q = q.where(ApiKey.user_id == p.user.id) # type: ignore[attr-defined]
104
104
  rows = (await sess.execute(q)).scalars().all()
105
105
  return [
106
106
  ApiKeyOut(
@@ -124,7 +124,7 @@ def apikey_router():
124
124
  description="Revoke an API key",
125
125
  )
126
126
  async def revoke_key(key_id: str, sess: SqlSessionDep, p: Identity):
127
- row = await sess.get(ApiKey, key_id)
127
+ row = await cast(Any, sess).get(ApiKey, key_id)
128
128
  if not row:
129
129
  raise HTTPException(404, "not_found")
130
130
 
@@ -148,7 +148,7 @@ def apikey_router():
148
148
  p: Identity,
149
149
  force: bool = Query(False, description="Allow deleting an active key if True"),
150
150
  ):
151
- row = await sess.get(ApiKey, key_id)
151
+ row = await cast(Any, sess).get(ApiKey, key_id)
152
152
  if not row:
153
153
  return # 204
154
154