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
@@ -10,9 +10,9 @@ from urllib.parse import urlencode, urlparse
10
10
  import jwt
11
11
  from authlib.integrations.base_client.errors import OAuthError
12
12
  from authlib.integrations.starlette_client import OAuth
13
- from fastapi import APIRouter, HTTPException, Request
13
+ from fastapi import APIRouter, Depends, HTTPException, Request
14
14
  from fastapi.responses import RedirectResponse
15
- from fastapi_users.authentication import AuthenticationBackend
15
+ from fastapi_users.authentication import AuthenticationBackend, Strategy
16
16
  from fastapi_users.password import PasswordHelper
17
17
  from sqlalchemy import select
18
18
  from starlette import status
@@ -20,7 +20,10 @@ from starlette.responses import Response
20
20
 
21
21
  from svc_infra.api.fastapi.auth.mfa.pre_auth import get_mfa_pre_jwt_writer
22
22
  from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
23
- from svc_infra.api.fastapi.auth.settings import get_auth_settings, parse_redirect_allow_hosts
23
+ from svc_infra.api.fastapi.auth.settings import (
24
+ get_auth_settings,
25
+ parse_redirect_allow_hosts,
26
+ )
24
27
  from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
25
28
  from svc_infra.api.fastapi.dual.public import public_router
26
29
  from svc_infra.api.fastapi.paths.auth import (
@@ -28,6 +31,7 @@ from svc_infra.api.fastapi.paths.auth import (
28
31
  OAUTH_LOGIN_PATH,
29
32
  OAUTH_REFRESH_PATH,
30
33
  )
34
+ from svc_infra.app.env import require_secret
31
35
  from svc_infra.security.models import RefreshToken
32
36
  from svc_infra.security.session import issue_session_and_refresh, rotate_session_refresh
33
37
 
@@ -40,14 +44,19 @@ def _gen_pkce_pair() -> tuple[str, str]:
40
44
  return verifier, challenge
41
45
 
42
46
 
43
- def _validate_redirect(url: str, allow_hosts: list[str], *, require_https: bool) -> None:
47
+ def _validate_redirect(
48
+ url: str, allow_hosts: list[str], *, require_https: bool
49
+ ) -> None:
44
50
  """Validate that a redirect URL is allowed and secure."""
45
51
  p = urlparse(url)
46
52
  if not p.netloc:
47
53
  return
48
- host_port = p.hostname.lower() + (f":{p.port}" if p.port else "")
54
+ if not p.hostname:
55
+ raise HTTPException(400, "redirect_not_allowed")
56
+ hostname = p.hostname
57
+ host_port = hostname.lower() + (f":{p.port}" if p.port else "")
49
58
  allowed = {h.lower() for h in allow_hosts}
50
- if host_port not in allowed and p.hostname.lower() not in allowed:
59
+ if host_port not in allowed and hostname.lower() not in allowed:
51
60
  raise HTTPException(400, "redirect_not_allowed")
52
61
  if require_https and p.scheme != "https":
53
62
  raise HTTPException(400, "https_required")
@@ -77,7 +86,11 @@ def _coerce_expires_at(token: dict | None) -> datetime | None:
77
86
  def _cookie_name(st) -> str:
78
87
  """Get the cookie name with appropriate security prefix."""
79
88
  name = getattr(st, "auth_cookie_name", "svc_auth")
80
- if st.session_cookie_secure and not st.session_cookie_domain and not name.startswith("__Host-"):
89
+ if (
90
+ st.session_cookie_secure
91
+ and not st.session_cookie_domain
92
+ and not name.startswith("__Host-")
93
+ ):
81
94
  name = "__Host-" + name
82
95
  return name
83
96
 
@@ -88,7 +101,9 @@ def _cookie_domain(st):
88
101
  return d or None
89
102
 
90
103
 
91
- def _register_oauth_providers(oauth: OAuth, providers: Dict[str, Dict[str, Any]]) -> None:
104
+ def _register_oauth_providers(
105
+ oauth: OAuth, providers: Dict[str, Dict[str, Any]]
106
+ ) -> None:
92
107
  """Register all OAuth providers with the OAuth client."""
93
108
  for name, cfg in providers.items():
94
109
  kind = cfg.get("kind")
@@ -187,7 +202,9 @@ async def _extract_user_info_github(
187
202
  """Extract user information from GitHub provider."""
188
203
  u = (await client.get("user", token=token)).json()
189
204
  emails_resp = (await client.get("user/emails", token=token)).json()
190
- primary = next((e for e in emails_resp if e.get("primary") and e.get("verified")), None)
205
+ primary = next(
206
+ (e for e in emails_resp if e.get("primary") and e.get("verified")), None
207
+ )
191
208
 
192
209
  if not primary:
193
210
  raise HTTPException(400, "unverified_email")
@@ -195,7 +212,9 @@ async def _extract_user_info_github(
195
212
  email = primary["email"]
196
213
  email_verified = True
197
214
  full_name = u.get("name") or u.get("login")
198
- provider_user_id = str(u.get("id")) if isinstance(u, dict) and u.get("id") is not None else None
215
+ provider_user_id = (
216
+ str(u.get("id")) if isinstance(u, dict) and u.get("id") is not None else None
217
+ )
199
218
 
200
219
  return email, full_name, provider_user_id, email_verified, {"user": u}
201
220
 
@@ -210,7 +229,9 @@ async def _extract_user_info_linkedin(
210
229
  )
211
230
 
212
231
  em = (
213
- await client.get("emailAddress?q=members&projection=(elements*(handle~))", token=token)
232
+ await client.get(
233
+ "emailAddress?q=members&projection=(elements*(handle~))", token=token
234
+ )
214
235
  ).json()
215
236
 
216
237
  email = None
@@ -249,9 +270,15 @@ async def _extract_user_info_from_provider(
249
270
  raise HTTPException(400, "Unsupported provider kind")
250
271
 
251
272
 
252
- async def _find_or_create_user(session, user_model, email: str, full_name: str | None) -> Any:
273
+ async def _find_or_create_user(
274
+ session, user_model, email: str, full_name: str | None
275
+ ) -> Any:
253
276
  """Find existing user by email or create a new one."""
254
- existing = (await session.execute(select(user_model).filter_by(email=email))).scalars().first()
277
+ existing = (
278
+ (await session.execute(select(user_model).filter_by(email=email)))
279
+ .scalars()
280
+ .first()
281
+ )
255
282
 
256
283
  if existing:
257
284
  return existing
@@ -263,11 +290,14 @@ async def _find_or_create_user(session, user_model, email: str, full_name: str |
263
290
  is_verified=True,
264
291
  )
265
292
 
266
- # Set hashed password for OAuth users
293
+ # Set hashed password for OAuth users - use cryptographically random password
294
+ # OAuth users authenticate via provider, not password, so this is never used
295
+ # but must be unpredictable to prevent password-based login attacks
296
+ random_password = secrets.token_urlsafe(32)
267
297
  if hasattr(user, "hashed_password"):
268
- user.hashed_password = PasswordHelper().hash("!oauth!")
298
+ user.hashed_password = PasswordHelper().hash(random_password)
269
299
  elif hasattr(user, "password_hash"):
270
- user.password_hash = PasswordHelper().hash("!oauth!")
300
+ user.password_hash = PasswordHelper().hash(random_password)
271
301
 
272
302
  if full_name and hasattr(user, "full_name"):
273
303
  setattr(user, "full_name", full_name)
@@ -354,10 +384,18 @@ async def _update_provider_account(
354
384
  else:
355
385
  # Update existing link if values have changed
356
386
  dirty = False
357
- if hasattr(link, "access_token") and access_token and link.access_token != access_token:
387
+ if (
388
+ hasattr(link, "access_token")
389
+ and access_token
390
+ and link.access_token != access_token
391
+ ):
358
392
  link.access_token = access_token
359
393
  dirty = True
360
- if hasattr(link, "refresh_token") and refresh_token and link.refresh_token != refresh_token:
394
+ if (
395
+ hasattr(link, "refresh_token")
396
+ and refresh_token
397
+ and link.refresh_token != refresh_token
398
+ ):
361
399
  link.refresh_token = refresh_token
362
400
  dirty = True
363
401
  if hasattr(link, "expires_at") and expires_at and link.expires_at != expires_at:
@@ -370,19 +408,24 @@ async def _update_provider_account(
370
408
  await session.flush()
371
409
 
372
410
 
373
- def _determine_final_redirect_url(request: Request, provider: str, post_login_redirect: str) -> str:
411
+ def _determine_final_redirect_url(
412
+ request: Request, provider: str, post_login_redirect: str
413
+ ) -> str:
374
414
  """Determine the final redirect URL after successful authentication."""
375
415
  st = get_auth_settings()
376
- redirect_url = str(
377
- getattr(st, "post_login_redirect", post_login_redirect) or post_login_redirect
416
+ # Prioritize the parameter passed to the router over settings
417
+ redirect_url = str(post_login_redirect or getattr(st, "post_login_redirect", "/"))
418
+ allow_hosts = parse_redirect_allow_hosts(
419
+ getattr(st, "redirect_allow_hosts_raw", None)
378
420
  )
379
- allow_hosts = parse_redirect_allow_hosts(getattr(st, "redirect_allow_hosts_raw", None))
380
421
  require_https = bool(getattr(st, "session_cookie_secure", False))
381
422
 
382
423
  _validate_redirect(redirect_url, allow_hosts, require_https=require_https)
383
424
 
384
425
  # Prefer ?next or the stashed value from /login
385
- nxt = request.query_params.get("next") or request.session.pop(f"oauth:{provider}:next", None)
426
+ nxt = request.query_params.get("next") or request.session.pop(
427
+ f"oauth:{provider}:next", None
428
+ )
386
429
  if nxt:
387
430
  try:
388
431
  _validate_redirect(nxt, allow_hosts, require_https=require_https)
@@ -393,7 +436,9 @@ def _determine_final_redirect_url(request: Request, provider: str, post_login_re
393
436
  return redirect_url
394
437
 
395
438
 
396
- async def _validate_oauth_state(request: Request, provider: str) -> tuple[str | None, str | None]:
439
+ async def _validate_oauth_state(
440
+ request: Request, provider: str
441
+ ) -> tuple[str | None, str | None]:
397
442
  """Validate OAuth state and extract session values."""
398
443
  provided_state = request.query_params.get("state")
399
444
  expected_state = request.session.pop(f"oauth:{provider}:state", None)
@@ -406,7 +451,9 @@ async def _validate_oauth_state(request: Request, provider: str) -> tuple[str |
406
451
  return verifier, nonce
407
452
 
408
453
 
409
- async def _exchange_code_for_token(client, request: Request, verifier: str | None, provider: str):
454
+ async def _exchange_code_for_token(
455
+ client, request: Request, verifier: str | None, provider: str
456
+ ):
410
457
  """Exchange OAuth authorization code for access token."""
411
458
  try:
412
459
  return await client.authorize_access_token(request, code_verifier=verifier)
@@ -437,7 +484,13 @@ async def _process_user_authentication(
437
484
 
438
485
  # Ensure provider link exists
439
486
  await _update_provider_account(
440
- session, provider_account_model, user, provider, provider_user_id, token, raw_claims
487
+ session,
488
+ provider_account_model,
489
+ user,
490
+ provider,
491
+ provider_user_id,
492
+ token,
493
+ raw_claims,
441
494
  )
442
495
 
443
496
  return user
@@ -446,11 +499,18 @@ async def _process_user_authentication(
446
499
  async def _validate_and_decode_jwt_token(raw_token: str) -> str:
447
500
  """Validate and decode JWT token to extract user ID."""
448
501
  st = get_auth_settings()
449
- secret = (
450
- st.jwt.secret.get_secret_value()
451
- if getattr(st, "jwt", None) and getattr(st.jwt, "secret", None)
452
- else "dev-change-me"
502
+ jwt_settings = getattr(st, "jwt", None)
503
+ jwt_secret = (
504
+ getattr(jwt_settings, "secret", None) if jwt_settings is not None else None
453
505
  )
506
+ if jwt_secret:
507
+ secret = jwt_secret.get_secret_value()
508
+ else:
509
+ secret = require_secret(
510
+ None,
511
+ "JWT_SECRET (via auth settings jwt.secret for token validation)",
512
+ dev_default="dev-only-jwt-validation-secret-not-for-production",
513
+ )
454
514
 
455
515
  try:
456
516
  payload = jwt.decode(
@@ -462,26 +522,29 @@ async def _validate_and_decode_jwt_token(raw_token: str) -> str:
462
522
  user_id = payload.get("sub")
463
523
  if not user_id:
464
524
  raise HTTPException(401, "invalid_token")
465
- return user_id
525
+ return cast(str, user_id)
466
526
  except Exception:
467
527
  raise HTTPException(401, "invalid_token")
468
528
 
469
529
 
470
530
  async def _set_cookie_on_response(
471
531
  resp: Response,
472
- auth_backend: AuthenticationBackend,
532
+ strategy: Strategy[Any, Any],
473
533
  user: Any,
474
534
  *,
475
535
  refresh_raw: str,
476
536
  ) -> None:
477
537
  """Set authentication (JWT) and refresh cookies on response."""
478
538
  st = get_auth_settings()
479
- strategy = auth_backend.get_strategy()
480
539
  jwt_token = await strategy.write_token(user)
481
540
 
482
- same_site_lit = cast(Literal["lax", "strict", "none"], str(st.session_cookie_samesite).lower())
541
+ same_site_lit = cast(
542
+ Literal["lax", "strict", "none"], str(st.session_cookie_samesite).lower()
543
+ )
483
544
  if same_site_lit == "none" and not bool(st.session_cookie_secure):
484
- raise HTTPException(500, "session_cookie_samesite=None requires session_cookie_secure=True")
545
+ raise HTTPException(
546
+ 500, "session_cookie_samesite=None requires session_cookie_secure=True"
547
+ )
485
548
 
486
549
  # Access/Auth cookie (short-lived JWT)
487
550
  resp.set_cookie(
@@ -523,7 +586,9 @@ async def _handle_mfa_redirect(
523
586
 
524
587
  pre = await get_mfa_pre_jwt_writer().write(user)
525
588
  qs = urlencode({"mfa": "required", "pre_token": pre})
526
- return RedirectResponse(url=f"{redirect_url}?{qs}", status_code=status.HTTP_302_FOUND)
589
+ return RedirectResponse(
590
+ url=f"{redirect_url}?{qs}", status_code=status.HTTP_302_FOUND
591
+ )
527
592
 
528
593
 
529
594
  def oauth_router_with_backend(
@@ -600,7 +665,12 @@ def _create_oauth_router(
600
665
  responses={302: {"description": "Redirect to app (or MFA redirect)."}},
601
666
  description="OAuth callback endpoint.",
602
667
  )
603
- async def oauth_callback(request: Request, provider: str, session: SqlSessionDep):
668
+ async def oauth_callback(
669
+ request: Request,
670
+ provider: str,
671
+ session: SqlSessionDep,
672
+ strategy: Strategy[Any, Any] = Depends(auth_backend.get_strategy),
673
+ ):
604
674
  """Handle OAuth callback and complete authentication."""
605
675
  # Handle provider-side errors up front
606
676
  if err := request.query_params.get("error"):
@@ -621,14 +691,22 @@ def _create_oauth_router(
621
691
 
622
692
  # Extract user information from provider
623
693
  cfg = providers.get(provider, {})
624
- email, full_name, provider_user_id, email_verified, raw_claims = (
625
- await _extract_user_info_from_provider(request, client, token, provider, cfg, nonce)
694
+ (
695
+ email,
696
+ full_name,
697
+ provider_user_id,
698
+ email_verified,
699
+ raw_claims,
700
+ ) = await _extract_user_info_from_provider(
701
+ request, client, token, provider, cfg, nonce
626
702
  )
627
703
 
628
704
  if email_verified is False:
629
705
  raise HTTPException(400, "unverified_email")
630
706
  if not email:
631
707
  raise HTTPException(400, "No email from provider")
708
+ if not provider_user_id:
709
+ raise HTTPException(400, "No user ID from provider")
632
710
 
633
711
  # Process user authentication
634
712
  user = await _process_user_authentication(
@@ -648,7 +726,9 @@ def _create_oauth_router(
648
726
  raise HTTPException(401, "account_disabled")
649
727
 
650
728
  # Determine final redirect URL
651
- redirect_url = _determine_final_redirect_url(request, provider, post_login_redirect)
729
+ redirect_url = _determine_final_redirect_url(
730
+ request, provider, post_login_redirect
731
+ )
652
732
 
653
733
  # Handle MFA if required (do NOT set last_login yet; do it after MFA)
654
734
  mfa_response = await _handle_mfa_redirect(policy, user, redirect_url)
@@ -669,9 +749,24 @@ def _create_oauth_router(
669
749
  ip_hash=None,
670
750
  )
671
751
 
672
- # Create response with auth + refresh cookies
752
+ # Generate JWT token for the response
753
+ jwt_token = await strategy.write_token(user)
754
+
755
+ # If redirecting to a different origin, append token as URL fragment for frontend to extract
756
+ # This handles cross-port scenarios like localhost:8000 -> localhost:3000
757
+ parsed_redirect = urlparse(redirect_url)
758
+ request_origin = f"{request.url.scheme}://{request.url.netloc}"
759
+ redirect_origin = f"{parsed_redirect.scheme}://{parsed_redirect.netloc}"
760
+
761
+ if redirect_origin and redirect_origin != request_origin:
762
+ # Cross-origin redirect: append token as URL fragment
763
+ # Fragment is not sent to server, only accessible to client-side JS
764
+ separator = "#" if not parsed_redirect.fragment else "&"
765
+ redirect_url = f"{redirect_url}{separator}access_token={jwt_token}"
766
+
767
+ # Create response with auth + refresh cookies (for same-origin requests)
673
768
  resp = RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
674
- await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=raw_refresh)
769
+ await _set_cookie_on_response(resp, strategy, user, refresh_raw=raw_refresh)
675
770
 
676
771
  # Clean up session state
677
772
  _clean_oauth_session_state(request, provider)
@@ -691,7 +786,11 @@ def _create_oauth_router(
691
786
  responses={204: {"description": "Cookie refreshed"}},
692
787
  description="Refresh authentication token.",
693
788
  )
694
- async def refresh(request: Request, session: SqlSessionDep):
789
+ async def refresh(
790
+ request: Request,
791
+ session: SqlSessionDep,
792
+ strategy: Strategy[Any, Any] = Depends(auth_backend.get_strategy),
793
+ ):
695
794
  """Refresh authentication token."""
696
795
  st = get_auth_settings()
697
796
 
@@ -705,7 +804,7 @@ def _create_oauth_router(
705
804
  user_id = await _validate_and_decode_jwt_token(raw_auth)
706
805
 
707
806
  # Load user
708
- user = await session.get(user_model, user_id)
807
+ user = await cast(Any, session).get(user_model, user_id)
709
808
  if not user:
710
809
  raise HTTPException(401, "invalid_token")
711
810
 
@@ -738,17 +837,12 @@ def _create_oauth_router(
738
837
  raise HTTPException(401, "invalid_refresh_token")
739
838
 
740
839
  # Rotate refresh token
741
- try:
742
- new_raw, _new_rt = await rotate_session_refresh(session, current=found)
743
- except ValueError:
744
- # Token expired between validation and rotation; treat as invalid
745
- raise HTTPException(401, "invalid_refresh_token") from None
840
+ new_raw, _new_rt = await rotate_session_refresh(session, current=found)
746
841
 
747
842
  # Write response (204) with new cookies
748
843
  resp = Response(status_code=status.HTTP_204_NO_CONTENT)
749
- await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=new_raw)
750
-
751
- # Dead code removed: MFA branch handled earlier in login flow, refresh returns 204 above.
844
+ await _set_cookie_on_response(resp, strategy, user, refresh_raw=new_raw)
845
+ # Policy hook: trigger after successful rotation; suppress hook errors
752
846
  if hasattr(policy, "on_token_refresh"):
753
847
  try:
754
848
  await policy.on_token_refresh(user)
@@ -16,9 +16,13 @@ def build_session_router() -> APIRouter:
16
16
  router = APIRouter(prefix="/sessions", tags=["sessions"])
17
17
 
18
18
  @router.get(
19
- "/me", response_model=list[dict], dependencies=[RequirePermission("security.session.list")]
19
+ "/me",
20
+ response_model=list[dict],
21
+ dependencies=[RequirePermission("security.session.list")],
20
22
  )
21
- async def list_my_sessions(identity: Identity, session: SqlSessionDep) -> List[dict]:
23
+ async def list_my_sessions(
24
+ identity: Identity, session: SqlSessionDep
25
+ ) -> List[dict]:
22
26
  stmt = select(AuthSession).where(AuthSession.user_id == identity.user.id)
23
27
  rows = (await session.execute(stmt)).scalars().all()
24
28
  return [
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from datetime import datetime, timezone
4
- from typing import Annotated, Any, Callable, Optional
4
+ from typing import Annotated, Any, Callable, Optional, cast
5
5
 
6
6
  from fastapi import Depends, HTTPException, Request
7
7
  from fastapi.security import APIKeyCookie, APIKeyHeader, OAuth2PasswordBearer
@@ -16,8 +16,12 @@ from svc_infra.db.sql.apikey import get_apikey_model
16
16
 
17
17
  # ---------- OpenAPI security schemes (appear in docs) ----------
18
18
  auth_login_path = USER_PREFIX + LOGIN_PATH
19
- oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl=auth_login_path, auto_error=False)
20
- cookie_auth_optional = APIKeyCookie(name=get_auth_settings().auth_cookie_name, auto_error=False)
19
+ oauth2_scheme_optional = OAuth2PasswordBearer(
20
+ tokenUrl=auth_login_path, auto_error=False
21
+ )
22
+ cookie_auth_optional = APIKeyCookie(
23
+ name=get_auth_settings().auth_cookie_name, auto_error=False
24
+ )
21
25
  api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
22
26
 
23
27
 
@@ -26,7 +30,12 @@ class Principal:
26
30
  """Unified identity: user via JWT/cookie or service via API key."""
27
31
 
28
32
  def __init__(
29
- self, *, user=None, scopes: list[str] | None = None, via: str = "jwt", api_key=None
33
+ self,
34
+ *,
35
+ user=None,
36
+ scopes: list[str] | None = None,
37
+ via: str = "jwt",
38
+ api_key=None,
30
39
  ):
31
40
  self.user = user
32
41
  self.scopes = scopes or []
@@ -51,7 +60,11 @@ async def resolve_api_key(
51
60
  apikey = None
52
61
  if prefix:
53
62
  apikey = (
54
- (await session.execute(select(ApiKey).where(ApiKey.key_prefix == prefix)))
63
+ (
64
+ await session.execute(
65
+ select(ApiKey).where(ApiKey.key_prefix == prefix) # type: ignore[attr-defined]
66
+ )
67
+ )
55
68
  .scalars()
56
69
  .first()
57
70
  )
@@ -69,7 +82,9 @@ async def resolve_api_key(
69
82
 
70
83
  apikey.mark_used()
71
84
  await session.flush()
72
- return Principal(user=apikey.user, scopes=apikey.scopes, via="api_key", api_key=apikey)
85
+ return Principal(
86
+ user=apikey.user, scopes=apikey.scopes, via="api_key", api_key=apikey
87
+ )
73
88
 
74
89
 
75
90
  async def resolve_bearer_or_cookie_principal(
@@ -77,7 +92,11 @@ async def resolve_bearer_or_cookie_principal(
77
92
  ) -> Optional[Principal]:
78
93
  st = get_auth_settings()
79
94
  raw_auth = (request.headers.get("authorization") or "").strip()
80
- token = raw_auth.split(" ", 1)[1].strip() if raw_auth.lower().startswith("bearer ") else ""
95
+ token = (
96
+ raw_auth.split(" ", 1)[1].strip()
97
+ if raw_auth.lower().startswith("bearer ")
98
+ else ""
99
+ )
81
100
  if not token:
82
101
  token = (request.cookies.get(st.auth_cookie_name) or "").strip()
83
102
  if not token:
@@ -89,7 +108,7 @@ async def resolve_bearer_or_cookie_principal(
89
108
  from fastapi_users.manager import BaseUserManager, UUIDIDMixin
90
109
  from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
91
110
 
92
- user_db = SQLAlchemyUserDatabase(session, UserModel)
111
+ user_db: Any = SQLAlchemyUserDatabase(session, UserModel)
93
112
 
94
113
  class _ShimManager(UUIDIDMixin, BaseUserManager[Any, Any]):
95
114
  reset_password_token_secret = "unused"
@@ -107,7 +126,7 @@ async def resolve_bearer_or_cookie_principal(
107
126
  if not user:
108
127
  return None
109
128
 
110
- db_user = await session.get(UserModel, user.id)
129
+ db_user = await cast(Any, session).get(UserModel, user.id)
111
130
  if not db_user:
112
131
  return None
113
132
  if not getattr(db_user, "is_active", True):
@@ -154,7 +173,9 @@ AllowIdentity = Depends(_optional_principal) # same, but optional
154
173
  # ---------- DX: small guard factories ----------
155
174
  def RequireRoles(*roles: str, resolver: Callable[[Any], list[str]] | None = None):
156
175
  async def _guard(p: Identity):
157
- have = set((resolver(p.user) if resolver else getattr(p.user, "roles", []) or []))
176
+ have = set(
177
+ (resolver(p.user) if resolver else getattr(p.user, "roles", []) or [])
178
+ )
158
179
  if not set(roles).issubset(have):
159
180
  raise HTTPException(403, "forbidden")
160
181
  return p
@@ -20,7 +20,9 @@ class ConsoleSender:
20
20
 
21
21
 
22
22
  class SMTPSender:
23
- def __init__(self, host: str, port: int, username: str, password: str, from_addr: str) -> None:
23
+ def __init__(
24
+ self, host: str, port: int, username: str, password: str, from_addr: str
25
+ ) -> None:
24
26
  self.host = host
25
27
  self.port = port
26
28
  self.username = username
@@ -59,4 +61,9 @@ def get_sender() -> Sender:
59
61
  if not configured:
60
62
  return ConsoleSender()
61
63
 
64
+ # At this point, all values must be set
65
+ assert host is not None
66
+ assert user is not None
67
+ assert pw is not None
68
+ assert frm is not None
62
69
  return SMTPSender(host, st.smtp_port, user, pw, frm)
@@ -19,7 +19,9 @@ def set_auth_state(
19
19
 
20
20
  def get_auth_state() -> tuple[type, Callable[[], Any], str]:
21
21
  if _UserModel is None or _GetStrategy is None:
22
- raise RuntimeError("Auth state not initialized; call set_auth_state() in add_auth_users().")
22
+ raise RuntimeError(
23
+ "Auth state not initialized; call set_auth_state() in add_auth_users()."
24
+ )
23
25
  return _UserModel, _GetStrategy, _AuthPrefix
24
26
 
25
27