svc-infra 0.1.589__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 (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- from typing import Iterable, Optional
4
+ from typing import TYPE_CHECKING, Iterable, Optional, cast
5
5
 
6
6
  from fastapi import FastAPI
7
7
 
8
8
  from svc_infra.apf_payments.provider.registry import get_provider_registry
9
9
  from svc_infra.api.fastapi.apf_payments.router import build_payments_routers
10
- from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
10
+
11
+ if TYPE_CHECKING:
12
+ from svc_infra.apf_payments.provider.base import ProviderAdapter
11
13
 
12
14
  logger = logging.getLogger(__name__)
13
15
 
@@ -24,9 +26,17 @@ def _maybe_register_default_providers(
24
26
  reg.register(StripeAdapter())
25
27
  except Exception:
26
28
  pass
29
+ try:
30
+ from svc_infra.apf_payments.provider.aiydan import AiydanAdapter
31
+
32
+ reg.register(AiydanAdapter())
33
+ except Exception:
34
+ pass
27
35
  if adapters:
28
36
  for a in adapters:
29
- reg.register(a) # must implement ProviderAdapter protocol (name, create_intent, etc.)
37
+ reg.register(
38
+ cast("ProviderAdapter", a)
39
+ ) # must implement ProviderAdapter protocol
30
40
 
31
41
 
32
42
  def add_payments(
@@ -35,7 +45,8 @@ def add_payments(
35
45
  prefix: str = "/payments",
36
46
  register_default_providers: bool = True,
37
47
  adapters: Optional[Iterable[object]] = None,
38
- include_in_docs: bool | None = None, # None = keep your env-based default visibility
48
+ include_in_docs: bool
49
+ | None = None, # None = keep your env-based default visibility
39
50
  ) -> None:
40
51
  """
41
52
  One-call payments installer.
@@ -45,14 +56,16 @@ def add_payments(
45
56
  - Reuses your OpenAPI defaults (security + responses) via DualAPIRouter factories.
46
57
  """
47
58
  _maybe_register_default_providers(register_default_providers, adapters)
48
- add_prefixed_docs(app, prefix=prefix, title="Payments")
49
59
 
50
60
  for r in build_payments_routers(prefix=prefix):
51
61
  app.include_router(
52
- r, include_in_schema=True if include_in_docs is None else bool(include_in_docs)
62
+ r,
63
+ include_in_schema=True
64
+ if include_in_docs is None
65
+ else bool(include_in_docs),
53
66
  )
54
67
 
55
- @app.on_event("startup")
68
+ # Store the startup function to be called by lifespan if needed
56
69
  async def _payments_startup_check():
57
70
  try:
58
71
  reg = get_provider_registry()
@@ -60,5 +73,10 @@ def add_payments(
60
73
  # Try a cheap call (Stripe: read account or key balance; we just access .name)
61
74
  _ = adapter.name
62
75
  except Exception as e:
63
- # Log loud; dont crash the whole app by default
76
+ # Log loud; don't crash the whole app by default
64
77
  logger.warning(f"[payments] Provider adapter not ready: {e}")
78
+
79
+ # Add to app state for potential lifespan usage
80
+ if not hasattr(app.state, "startup_events"):
81
+ app.state.startup_events = []
82
+ app.state.startup_events.append(_payments_startup_check)
@@ -0,0 +1,65 @@
1
+ """Authentication module for svc-infra.
2
+
3
+ Provides user authentication, authorization, and security primitives.
4
+
5
+ Key exports:
6
+ - add_auth_users: Add authentication routes to FastAPI app
7
+ - Identity, OptionalIdentity: Annotated dependencies for auth
8
+ - RequireUser, RequireRoles, RequireScopes: Authorization guards
9
+ - Principal: Unified identity (user via JWT/cookie or service via API key)
10
+ - AuthSettings, get_auth_settings: Auth configuration
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ # These imports are safe (no circular dependency)
18
+ from .policy import AuthPolicy, DefaultAuthPolicy
19
+ from .security import (
20
+ Identity,
21
+ OptionalIdentity,
22
+ Principal,
23
+ RequireAnyScope,
24
+ RequireIdentity,
25
+ RequireRoles,
26
+ RequireScopes,
27
+ RequireService,
28
+ RequireUser,
29
+ )
30
+ from .settings import AuthSettings, get_auth_settings, JWTSettings, OIDCProvider
31
+
32
+ if TYPE_CHECKING:
33
+ from .add import add_auth_users as add_auth_users
34
+
35
+ __all__ = [
36
+ # Main setup
37
+ "add_auth_users",
38
+ # Identity/Auth guards
39
+ "Identity",
40
+ "OptionalIdentity",
41
+ "Principal",
42
+ "RequireIdentity",
43
+ "RequireUser",
44
+ "RequireService",
45
+ "RequireRoles",
46
+ "RequireScopes",
47
+ "RequireAnyScope",
48
+ # Policy
49
+ "AuthPolicy",
50
+ "DefaultAuthPolicy",
51
+ # Settings
52
+ "AuthSettings",
53
+ "get_auth_settings",
54
+ "JWTSettings",
55
+ "OIDCProvider",
56
+ ]
57
+
58
+
59
+ def __getattr__(name: str):
60
+ """Lazy import for add_auth_users to avoid circular import."""
61
+ if name == "add_auth_users":
62
+ from .add import add_auth_users
63
+
64
+ return add_auth_users
65
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -16,7 +16,9 @@ def _is_local_host(host: str) -> bool:
16
16
 
17
17
  def _is_https(request: Request) -> bool:
18
18
  proto = (
19
- (request.headers.get("x-forwarded-proto") or request.url.scheme or "").split(",")[0].strip()
19
+ (request.headers.get("x-forwarded-proto") or request.url.scheme or "")
20
+ .split(",")[0]
21
+ .strip()
20
22
  )
21
23
  return proto.lower() == "https"
22
24
 
@@ -31,7 +33,9 @@ def compute_cookie_params(request: Request, *, name: str) -> dict:
31
33
 
32
34
  explicit_secure = getattr(st, "session_cookie_secure", None)
33
35
  secure = (
34
- bool(explicit_secure) if explicit_secure is not None else (_is_https(request) or IS_PROD)
36
+ bool(explicit_secure)
37
+ if explicit_secure is not None
38
+ else (_is_https(request) or IS_PROD)
35
39
  )
36
40
 
37
41
  samesite = str(getattr(st, "session_cookie_samesite", "lax")).lower()
@@ -11,12 +11,12 @@ from svc_infra.api.fastapi.auth.mfa.router import mfa_router
11
11
  from svc_infra.api.fastapi.auth.routers.account import account_router
12
12
  from svc_infra.api.fastapi.auth.routers.apikey_router import apikey_router
13
13
  from svc_infra.api.fastapi.auth.routers.oauth_router import oauth_router_with_backend
14
+ from svc_infra.api.fastapi.auth.routers.session_router import build_session_router
14
15
  from svc_infra.api.fastapi.db.sql.users import get_fastapi_users
15
16
  from svc_infra.api.fastapi.paths.prefix import AUTH_PREFIX, USER_PREFIX
16
- from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
17
+ from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV, require_secret
17
18
  from svc_infra.db.sql.apikey import bind_apikey_model
18
19
 
19
- from ..docs.scoped import add_prefixed_docs
20
20
  from .policy import AuthPolicy, DefaultAuthPolicy
21
21
  from .providers import providers_from_settings
22
22
  from .settings import get_auth_settings
@@ -73,6 +73,15 @@ def install_user_routers(
73
73
  include_in_schema=include_in_docs,
74
74
  dependencies=[Depends(login_client_gaurd)],
75
75
  )
76
+ # Session/device listing & revocation endpoints (AuthSession model)
77
+ # Mounted under the user prefix so final paths become /{user_prefix}/sessions/... (e.g., /users/sessions/me)
78
+ # The router itself has a /sessions prefix.
79
+ app.include_router(
80
+ build_session_router(),
81
+ prefix=user_prefix,
82
+ tags=["Session Management"],
83
+ include_in_schema=include_in_docs,
84
+ )
76
85
  app.include_router(
77
86
  register_router,
78
87
  prefix=user_prefix,
@@ -126,12 +135,14 @@ def setup_oauth_authentication(
126
135
  if not providers:
127
136
  return
128
137
 
138
+ redirect_url = (
139
+ post_login_redirect or getattr(settings_obj, "post_login_redirect", None) or "/"
140
+ )
129
141
  oauth_router_instance = oauth_router_with_backend(
130
142
  user_model=user_model,
131
143
  auth_backend=auth_backend,
132
144
  providers=providers,
133
- post_login_redirect=post_login_redirect
134
- or getattr(settings_obj, "post_login_redirect", "/"),
145
+ post_login_redirect=redirect_url,
135
146
  provider_account_model=provider_account_model,
136
147
  auth_policy=auth_policy,
137
148
  )
@@ -257,19 +268,24 @@ def add_auth_users(
257
268
  )
258
269
 
259
270
  # Make the boot-time strategy and model available to resolvers
260
- set_auth_state(user_model=user_model, get_strategy=get_strategy, auth_prefix=auth_prefix)
271
+ set_auth_state(
272
+ user_model=user_model, get_strategy=get_strategy, auth_prefix=auth_prefix
273
+ )
261
274
 
262
275
  settings_obj = get_auth_settings()
263
276
  policy = auth_policy or DefaultAuthPolicy(settings_obj)
264
277
  include_in_docs = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
265
278
 
266
- if not any(m.cls.__name__ == "SessionMiddleware" for m in app.user_middleware):
279
+ if not any(m.cls.__name__ == "SessionMiddleware" for m in app.user_middleware): # type: ignore[attr-defined]
267
280
  jwt_block = getattr(settings_obj, "jwt", None)
268
- secret = (
269
- jwt_block.secret.get_secret_value()
270
- if jwt_block and getattr(jwt_block, "secret", None)
271
- else "svc-dev-secret-change-me"
272
- )
281
+ if jwt_block and getattr(jwt_block, "secret", None):
282
+ secret = jwt_block.secret.get_secret_value()
283
+ else:
284
+ secret = require_secret(
285
+ None,
286
+ "JWT_SECRET (via auth settings jwt.secret for SessionMiddleware)",
287
+ dev_default="dev-only-session-jwt-secret-not-for-production",
288
+ )
273
289
  same_site_lit = cast(
274
290
  Literal["lax", "strict", "none"],
275
291
  str(getattr(settings_obj, "session_cookie_samesite", "lax")).lower(),
@@ -283,9 +299,6 @@ def add_auth_users(
283
299
  https_only=bool(getattr(settings_obj, "session_cookie_secure", False)),
284
300
  )
285
301
 
286
- add_prefixed_docs(app, prefix=user_prefix, title="Users")
287
- add_prefixed_docs(app, prefix=auth_prefix, title="Auth")
288
-
289
302
  if enable_password:
290
303
  setup_password_authentication(
291
304
  app,
@@ -1,16 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  from datetime import datetime, timezone
5
+ from typing import Any
4
6
 
5
7
  from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
6
8
  from fastapi.responses import JSONResponse
7
9
  from fastapi_users import FastAPIUsers
8
- from fastapi_users.authentication import AuthenticationBackend
10
+ from fastapi_users.authentication import AuthenticationBackend, Strategy
9
11
  from fastapi_users.password import PasswordHelper
12
+ from starlette.datastructures import FormData
10
13
 
11
14
  from svc_infra.api.fastapi.auth._cookies import compute_cookie_params
12
15
  from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
13
16
  from svc_infra.api.fastapi.auth.settings import get_auth_settings
17
+ from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
14
18
  from svc_infra.api.fastapi.dual.public import public_router
15
19
 
16
20
  _pwd = PasswordHelper()
@@ -29,28 +33,38 @@ async def login_client_gaurd(request: Request):
29
33
 
30
34
  # only enforce on the login endpoint (form-encoded)
31
35
  if request.method.upper() == "POST" and request.url.path.endswith("/login"):
36
+ form: FormData | dict[str, Any]
32
37
  try:
33
38
  form = await request.form()
34
39
  except Exception:
35
40
  form = {}
36
41
 
37
- client_id = (form.get("client_id") or "").strip()
38
- 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
+ )
39
48
  if not client_id or not client_secret:
40
49
  raise HTTPException(
41
- status_code=status.HTTP_401_UNAUTHORIZED, detail="client_credentials_required"
50
+ status_code=status.HTTP_401_UNAUTHORIZED,
51
+ detail="client_credentials_required",
42
52
  )
43
53
 
44
54
  # validate against configured clients
45
55
  ok = False
46
56
  for pc in getattr(st, "password_clients", []) or []:
47
- 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
+ ):
48
61
  ok = True
49
62
  break
50
63
 
51
64
  if not ok:
52
65
  raise HTTPException(
53
- status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_client_credentials"
66
+ status_code=status.HTTP_401_UNAUTHORIZED,
67
+ detail="invalid_client_credentials",
54
68
  )
55
69
 
56
70
 
@@ -65,36 +79,103 @@ def auth_session_router(
65
79
  router = public_router()
66
80
  policy = auth_policy or DefaultAuthPolicy(get_auth_settings())
67
81
 
82
+ from svc_infra.security.lockout import get_lockout_status, record_attempt
83
+
68
84
  @router.post("/login", name="auth:jwt.login")
69
85
  async def login(
70
86
  request: Request,
87
+ session: SqlSessionDep,
71
88
  username: str = Form(...),
72
89
  password: str = Form(...),
73
90
  scope: str = Form(""),
74
91
  client_id: str | None = Form(None),
75
92
  client_secret: str | None = Form(None),
93
+ strategy: Strategy[Any, Any] = Depends(auth_backend.get_strategy),
76
94
  user_manager=Depends(fapi.get_user_manager),
77
95
  ):
78
- # 1) lookup user (normalize email)
79
- strategy = auth_backend.get_strategy()
80
-
81
96
  email = username.strip().lower()
97
+ # Compute IP hash for lockout correlation
98
+ client_ip = getattr(request.client, "host", None)
99
+ ip_hash = hashlib.sha256(client_ip.encode()).hexdigest() if client_ip else None
100
+
101
+ # Pre-check lockout by IP to avoid enumeration
102
+ try:
103
+ status_lo = await get_lockout_status(session, user_id=None, ip_hash=ip_hash)
104
+ if status_lo.locked and status_lo.next_allowed_at:
105
+ retry = int(
106
+ (
107
+ status_lo.next_allowed_at - datetime.now(timezone.utc)
108
+ ).total_seconds()
109
+ )
110
+ raise HTTPException(
111
+ status_code=429,
112
+ detail="account_locked",
113
+ headers={"Retry-After": str(max(0, retry))},
114
+ )
115
+ except Exception:
116
+ pass
117
+
118
+ # Lookup user
82
119
  user = await user_manager.user_db.get_by_email(email)
83
120
  if not user:
84
121
  _, _ = _pwd.verify_and_update(password, _DUMMY_BCRYPT)
122
+ try:
123
+ await record_attempt(
124
+ session, user_id=None, ip_hash=ip_hash, success=False
125
+ )
126
+ except Exception:
127
+ pass
85
128
  raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
86
129
 
87
- # 2) verify status + password
130
+ # Status checks
88
131
  if not getattr(user, "is_active", True):
89
132
  raise HTTPException(401, "account_disabled")
90
133
 
91
- 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
+ )
92
137
  if not hashed:
93
- # No password set (likely OAuth-only account)
138
+ try:
139
+ await record_attempt(
140
+ session,
141
+ user_id=getattr(user, "id", None),
142
+ ip_hash=ip_hash,
143
+ success=False,
144
+ )
145
+ except Exception:
146
+ pass
94
147
  raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
95
148
 
149
+ # Check lockout for this user + IP before verifying password
150
+ try:
151
+ status_user = await get_lockout_status(
152
+ session, user_id=getattr(user, "id", None), ip_hash=ip_hash
153
+ )
154
+ if status_user.locked and status_user.next_allowed_at:
155
+ retry = int(
156
+ (
157
+ status_user.next_allowed_at - datetime.now(timezone.utc)
158
+ ).total_seconds()
159
+ )
160
+ raise HTTPException(
161
+ status_code=429,
162
+ detail="account_locked",
163
+ headers={"Retry-After": str(max(0, retry))},
164
+ )
165
+ except Exception:
166
+ pass
167
+
96
168
  ok, new_hash = _pwd.verify_and_update(password, hashed)
97
169
  if not ok:
170
+ try:
171
+ await record_attempt(
172
+ session,
173
+ user_id=getattr(user, "id", None),
174
+ ip_hash=ip_hash,
175
+ success=False,
176
+ )
177
+ except Exception:
178
+ pass
98
179
  raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
99
180
 
100
181
  # If the hash needs upgrading, persist it (optional but recommended)
@@ -106,7 +187,6 @@ def auth_session_router(
106
187
  try:
107
188
  await user_manager.user_db.update(user)
108
189
  except Exception:
109
- # don't block login if updating hash fails; log if you have logging here
110
190
  pass
111
191
 
112
192
  if getattr(user, "is_verified") is False:
@@ -130,6 +210,17 @@ def auth_session_router(
130
210
  # don’t block login if this write fails
131
211
  pass
132
212
 
213
+ # Record successful attempt (for audit)
214
+ try:
215
+ await record_attempt(
216
+ session,
217
+ user_id=getattr(user, "id", None),
218
+ ip_hash=ip_hash,
219
+ success=True,
220
+ )
221
+ except Exception:
222
+ pass
223
+
133
224
  # 5) mint token and set cookie
134
225
  token = await strategy.write_token(user)
135
226
  st = get_auth_settings()
@@ -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",