svc-infra 0.1.506__py3-none-any.whl → 0.1.654__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Iterable, Optional
5
+
6
+ from fastapi import FastAPI
7
+
8
+ from svc_infra.apf_payments.provider.registry import get_provider_registry
9
+ from svc_infra.api.fastapi.apf_payments.router import build_payments_routers
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _maybe_register_default_providers(
15
+ register_defaults: bool, adapters: Optional[Iterable[object]]
16
+ ):
17
+ reg = get_provider_registry()
18
+ if register_defaults:
19
+ # Try Stripe by default; silently skip if not configured
20
+ try:
21
+ from svc_infra.apf_payments.provider.stripe import StripeAdapter
22
+
23
+ reg.register(StripeAdapter())
24
+ except Exception:
25
+ pass
26
+ try:
27
+ from svc_infra.apf_payments.provider.aiydan import AiydanAdapter
28
+
29
+ reg.register(AiydanAdapter())
30
+ except Exception:
31
+ pass
32
+ if adapters:
33
+ for a in adapters:
34
+ reg.register(a) # must implement ProviderAdapter protocol (name, create_intent, etc.)
35
+
36
+
37
+ def add_payments(
38
+ app: FastAPI,
39
+ *,
40
+ prefix: str = "/payments",
41
+ register_default_providers: bool = True,
42
+ adapters: Optional[Iterable[object]] = None,
43
+ include_in_docs: bool | None = None, # None = keep your env-based default visibility
44
+ ) -> None:
45
+ """
46
+ One-call payments installer.
47
+
48
+ - Registers provider adapters (defaults + any provided).
49
+ - Mounts all Payments routers (user/protected/service/public) under `prefix`.
50
+ - Reuses your OpenAPI defaults (security + responses) via DualAPIRouter factories.
51
+ """
52
+ _maybe_register_default_providers(register_default_providers, adapters)
53
+
54
+ for r in build_payments_routers(prefix=prefix):
55
+ app.include_router(
56
+ r, include_in_schema=True if include_in_docs is None else bool(include_in_docs)
57
+ )
58
+
59
+ # Store the startup function to be called by lifespan if needed
60
+ async def _payments_startup_check():
61
+ try:
62
+ reg = get_provider_registry()
63
+ adapter = reg.get() # default provider
64
+ # Try a cheap call (Stripe: read account or key balance; we just access .name)
65
+ _ = adapter.name
66
+ except Exception as e:
67
+ # Log loud; don't crash the whole app by default
68
+ logger.warning(f"[payments] Provider adapter not ready: {e}")
69
+
70
+ # Add to app state for potential lifespan usage
71
+ if not hasattr(app.state, "startup_events"):
72
+ app.state.startup_events = []
73
+ app.state.startup_events.append(_payments_startup_check)
@@ -11,8 +11,9 @@ 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
- from svc_infra.api.fastapi.paths.prefix import AUTH_PREFIX, OAUTH_PREFIX, USER_PREFIX
16
+ from svc_infra.api.fastapi.paths.prefix import AUTH_PREFIX, USER_PREFIX
16
17
  from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
17
18
  from svc_infra.db.sql.apikey import bind_apikey_model
18
19
 
@@ -72,6 +73,15 @@ def install_user_routers(
72
73
  include_in_schema=include_in_docs,
73
74
  dependencies=[Depends(login_client_gaurd)],
74
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
+ )
75
85
  app.include_router(
76
86
  register_router,
77
87
  prefix=user_prefix,
@@ -114,7 +124,7 @@ def setup_oauth_authentication(
114
124
  user_model,
115
125
  auth_backend,
116
126
  settings_obj,
117
- oauth_prefix: str,
127
+ auth_prefix: str,
118
128
  post_login_redirect: str | None,
119
129
  provider_account_model=None,
120
130
  auth_policy: AuthPolicy,
@@ -138,7 +148,7 @@ def setup_oauth_authentication(
138
148
  # Install oauth prefix routers
139
149
  install_oauth_routers(
140
150
  app,
141
- oauth_prefix=oauth_prefix,
151
+ oauth_prefix=auth_prefix + "/oauth",
142
152
  oauth_router_instance=oauth_router_instance,
143
153
  include_in_docs=include_in_docs,
144
154
  )
@@ -221,7 +231,7 @@ def install_oauth_routers(
221
231
  )
222
232
 
223
233
 
224
- def add_auth(
234
+ def add_auth_users(
225
235
  app: FastAPI,
226
236
  *,
227
237
  user_model,
@@ -230,7 +240,6 @@ def add_auth(
230
240
  schema_update,
231
241
  post_login_redirect: str | None = None,
232
242
  auth_prefix: str = AUTH_PREFIX,
233
- oauth_prefix: str = OAUTH_PREFIX,
234
243
  user_prefix: str = USER_PREFIX,
235
244
  enable_password: bool = True,
236
245
  enable_oauth: bool = True,
@@ -308,7 +317,7 @@ def add_auth(
308
317
  user_model=user_model,
309
318
  auth_backend=auth_backend,
310
319
  settings_obj=settings_obj,
311
- oauth_prefix=oauth_prefix,
320
+ auth_prefix=auth_prefix,
312
321
  post_login_redirect=post_login_redirect,
313
322
  provider_account_model=provider_account_model,
314
323
  auth_policy=policy,
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  from datetime import datetime, timezone
4
5
 
5
6
  from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
@@ -11,6 +12,7 @@ from fastapi_users.password import PasswordHelper
11
12
  from svc_infra.api.fastapi.auth._cookies import compute_cookie_params
12
13
  from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
13
14
  from svc_infra.api.fastapi.auth.settings import get_auth_settings
15
+ from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
14
16
  from svc_infra.api.fastapi.dual.public import public_router
15
17
 
16
18
  _pwd = PasswordHelper()
@@ -65,9 +67,12 @@ def auth_session_router(
65
67
  router = public_router()
66
68
  policy = auth_policy or DefaultAuthPolicy(get_auth_settings())
67
69
 
70
+ from svc_infra.security.lockout import get_lockout_status, record_attempt
71
+
68
72
  @router.post("/login", name="auth:jwt.login")
69
73
  async def login(
70
74
  request: Request,
75
+ session: SqlSessionDep,
71
76
  username: str = Form(...),
72
77
  password: str = Form(...),
73
78
  scope: str = Form(""),
@@ -75,26 +80,76 @@ def auth_session_router(
75
80
  client_secret: str | None = Form(None),
76
81
  user_manager=Depends(fapi.get_user_manager),
77
82
  ):
78
- # 1) lookup user (normalize email)
79
83
  strategy = auth_backend.get_strategy()
80
-
81
84
  email = username.strip().lower()
85
+ # Compute IP hash for lockout correlation
86
+ client_ip = getattr(request.client, "host", None)
87
+ ip_hash = hashlib.sha256(client_ip.encode()).hexdigest() if client_ip else None
88
+
89
+ # Pre-check lockout by IP to avoid enumeration
90
+ try:
91
+ status_lo = await get_lockout_status(session, user_id=None, ip_hash=ip_hash)
92
+ if status_lo.locked and status_lo.next_allowed_at:
93
+ retry = int(
94
+ (status_lo.next_allowed_at - datetime.now(timezone.utc)).total_seconds()
95
+ )
96
+ raise HTTPException(
97
+ status_code=429,
98
+ detail="account_locked",
99
+ headers={"Retry-After": str(max(0, retry))},
100
+ )
101
+ except Exception:
102
+ pass
103
+
104
+ # Lookup user
82
105
  user = await user_manager.user_db.get_by_email(email)
83
106
  if not user:
84
107
  _, _ = _pwd.verify_and_update(password, _DUMMY_BCRYPT)
108
+ try:
109
+ await record_attempt(session, user_id=None, ip_hash=ip_hash, success=False)
110
+ except Exception:
111
+ pass
85
112
  raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
86
113
 
87
- # 2) verify status + password
114
+ # Status checks
88
115
  if not getattr(user, "is_active", True):
89
116
  raise HTTPException(401, "account_disabled")
90
117
 
91
118
  hashed = getattr(user, "hashed_password", None) or getattr(user, "password_hash", None)
92
119
  if not hashed:
93
- # No password set (likely OAuth-only account)
120
+ try:
121
+ await record_attempt(
122
+ session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=False
123
+ )
124
+ except Exception:
125
+ pass
94
126
  raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
95
127
 
128
+ # Check lockout for this user + IP before verifying password
129
+ try:
130
+ status_user = await get_lockout_status(
131
+ session, user_id=getattr(user, "id", None), ip_hash=ip_hash
132
+ )
133
+ if status_user.locked and status_user.next_allowed_at:
134
+ retry = int(
135
+ (status_user.next_allowed_at - datetime.now(timezone.utc)).total_seconds()
136
+ )
137
+ raise HTTPException(
138
+ status_code=429,
139
+ detail="account_locked",
140
+ headers={"Retry-After": str(max(0, retry))},
141
+ )
142
+ except Exception:
143
+ pass
144
+
96
145
  ok, new_hash = _pwd.verify_and_update(password, hashed)
97
146
  if not ok:
147
+ try:
148
+ await record_attempt(
149
+ session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=False
150
+ )
151
+ except Exception:
152
+ pass
98
153
  raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
99
154
 
100
155
  # If the hash needs upgrading, persist it (optional but recommended)
@@ -106,7 +161,6 @@ def auth_session_router(
106
161
  try:
107
162
  await user_manager.user_db.update(user)
108
163
  except Exception:
109
- # don't block login if updating hash fails; log if you have logging here
110
164
  pass
111
165
 
112
166
  if getattr(user, "is_verified") is False:
@@ -130,6 +184,14 @@ def auth_session_router(
130
184
  # don’t block login if this write fails
131
185
  pass
132
186
 
187
+ # Record successful attempt (for audit)
188
+ try:
189
+ await record_attempt(
190
+ session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=True
191
+ )
192
+ except Exception:
193
+ pass
194
+
133
195
  # 5) mint token and set cookie
134
196
  token = await strategy.write_token(user)
135
197
  st = get_auth_settings()
@@ -17,6 +17,15 @@ from svc_infra.api.fastapi.dual.protected import user_router
17
17
  from svc_infra.api.fastapi.dual.public import public_router
18
18
  from svc_infra.api.fastapi.dual.router import DualAPIRouter
19
19
 
20
+ from ...paths.auth import (
21
+ MFA_CONFIRM_PATH,
22
+ MFA_DISABLE_PATH,
23
+ MFA_REGENERATE_RECOVERY_PATH,
24
+ MFA_SEND_CODE_PATH,
25
+ MFA_START_PATH,
26
+ MFA_STATUS_PATH,
27
+ MFA_VERIFY_PATH,
28
+ )
20
29
  from .models import (
21
30
  EMAIL_OTP_STORE,
22
31
  ConfirmSetupIn,
@@ -45,8 +54,8 @@ def mfa_router(
45
54
  get_strategy, # from get_fastapi_users()
46
55
  fapi: FastAPIUsers,
47
56
  ) -> APIRouter:
48
- u = user_router(prefix="/mfa")
49
- p = public_router(prefix="/mfa")
57
+ u = user_router()
58
+ p = public_router()
50
59
 
51
60
  # Resolve current user via cookie OR bearer, using fastapi-users v10 strategy.read_token(..., user_manager)
52
61
  async def _get_user_and_session(
@@ -77,7 +86,7 @@ def mfa_router(
77
86
  return db_user, session
78
87
 
79
88
  @u.post(
80
- "/start",
89
+ MFA_START_PATH,
81
90
  response_model=StartSetupOut,
82
91
  )
83
92
  async def start_setup(user_sess=Depends(_get_user_and_session)):
@@ -107,7 +116,7 @@ def mfa_router(
107
116
  return StartSetupOut(otpauth_url=uri, secret=secret, qr_svg=_qr_svg_from_uri(uri))
108
117
 
109
118
  @u.post(
110
- "/confirm",
119
+ MFA_CONFIRM_PATH,
111
120
  response_model=RecoveryCodesOut,
112
121
  )
113
122
  async def confirm_setup(
@@ -138,7 +147,7 @@ def mfa_router(
138
147
  return RecoveryCodesOut(codes=codes)
139
148
 
140
149
  @u.post(
141
- "/disable",
150
+ MFA_DISABLE_PATH,
142
151
  status_code=status.HTTP_204_NO_CONTENT,
143
152
  )
144
153
  async def disable_mfa(
@@ -170,7 +179,7 @@ def mfa_router(
170
179
  await session.commit()
171
180
  return JSONResponse(status_code=204, content={})
172
181
 
173
- @p.post("/verify")
182
+ @p.post(MFA_VERIFY_PATH)
174
183
  async def verify_mfa(
175
184
  request: Request,
176
185
  session: SqlSessionDep,
@@ -244,7 +253,7 @@ def mfa_router(
244
253
  return resp
245
254
 
246
255
  @p.post(
247
- "/send_code",
256
+ MFA_SEND_CODE_PATH,
248
257
  response_model=SendEmailCodeOut,
249
258
  description="Sends a 6-digit email OTP tied to the `pre_token`. Returns a resend cooldown.",
250
259
  )
@@ -302,7 +311,7 @@ def mfa_router(
302
311
  return SendEmailCodeOut(sent=True, cooldown_seconds=cooldown)
303
312
 
304
313
  @u.get(
305
- "/status",
314
+ MFA_STATUS_PATH,
306
315
  response_model=MFAStatusOut,
307
316
  )
308
317
  async def mfa_status(user_sess=Depends(_get_user_and_session)):
@@ -340,7 +349,7 @@ def mfa_router(
340
349
  )
341
350
 
342
351
  @u.post(
343
- "/recovery/regenerate",
352
+ MFA_REGENERATE_RECOVERY_PATH,
344
353
  response_model=RecoveryCodesOut,
345
354
  )
346
355
  async def regenerate_recovery_codes(user_sess=Depends(_get_user_and_session)):
@@ -6,6 +6,7 @@ from svc_infra.api.fastapi.auth.mfa.models import DisableAccountIn
6
6
  from svc_infra.api.fastapi.auth.security import Identity
7
7
  from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
8
8
  from svc_infra.api.fastapi.dual.protected import user_router
9
+ from svc_infra.api.fastapi.paths.user import DELETE_ACCOUNT_PATH, DISABLE_ACCOUNT_PATH
9
10
 
10
11
 
11
12
  # ---------- Router ----------
@@ -13,7 +14,7 @@ def account_router(*, user_model: type) -> APIRouter:
13
14
  r = user_router()
14
15
 
15
16
  @r.patch(
16
- "/status",
17
+ DISABLE_ACCOUNT_PATH,
17
18
  response_model=dict,
18
19
  description="Get account status (active/disabled)",
19
20
  )
@@ -29,7 +30,7 @@ def account_router(*, user_model: type) -> APIRouter:
29
30
  return {"ok": True, "status": "disabled"}
30
31
 
31
32
  @r.delete(
32
- "/delete",
33
+ DELETE_ACCOUNT_PATH,
33
34
  status_code=204,
34
35
  description="Delete account (soft by default, hard if specified)",
35
36
  )
@@ -12,6 +12,12 @@ from svc_infra.api.fastapi.auth.security import Identity
12
12
  from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
13
13
  from svc_infra.api.fastapi.dual.protected import user_router
14
14
  from svc_infra.api.fastapi.openapi.responses import CONFLICT, NOT_FOUND
15
+ from svc_infra.api.fastapi.paths.auth import (
16
+ CREATE_KEY_PATH,
17
+ DELETE_KEY_PATH,
18
+ LIST_KEYS_PATH,
19
+ REVOKE_KEY_PATH,
20
+ )
15
21
  from svc_infra.db.sql.apikey import get_apikey_model
16
22
 
17
23
 
@@ -39,11 +45,11 @@ def _to_uuid(val):
39
45
 
40
46
 
41
47
  def apikey_router():
42
- r = user_router(prefix="/keys")
48
+ r = user_router()
43
49
  ApiKey = get_apikey_model()
44
50
 
45
51
  @r.post(
46
- "",
52
+ CREATE_KEY_PATH,
47
53
  response_model=ApiKeyOut,
48
54
  status_code=201,
49
55
  responses={409: CONFLICT},
@@ -87,7 +93,7 @@ def apikey_router():
87
93
  )
88
94
 
89
95
  @r.get(
90
- "",
96
+ LIST_KEYS_PATH,
91
97
  response_model=list[ApiKeyOut],
92
98
  description="List API keys. Non-superusers see only their own keys.",
93
99
  )
@@ -112,7 +118,7 @@ def apikey_router():
112
118
  ]
113
119
 
114
120
  @r.post(
115
- "/{key_id}/revoke",
121
+ REVOKE_KEY_PATH,
116
122
  status_code=204,
117
123
  responses={404: NOT_FOUND},
118
124
  description="Revoke an API key",
@@ -131,7 +137,7 @@ def apikey_router():
131
137
  return # 204
132
138
 
133
139
  @r.delete(
134
- "/{key_id}",
140
+ DELETE_KEY_PATH,
135
141
  status_code=204,
136
142
  responses={404: NOT_FOUND},
137
143
  description="Delete an API key. If the key is active, you must first revoke it or pass force=true.",
@@ -23,6 +23,13 @@ from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
23
23
  from svc_infra.api.fastapi.auth.settings import get_auth_settings, parse_redirect_allow_hosts
24
24
  from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
25
25
  from svc_infra.api.fastapi.dual.public import public_router
26
+ from svc_infra.api.fastapi.paths.auth import (
27
+ OAUTH_CALLBACK_PATH,
28
+ OAUTH_LOGIN_PATH,
29
+ OAUTH_REFRESH_PATH,
30
+ )
31
+ from svc_infra.security.models import RefreshToken
32
+ from svc_infra.security.session import issue_session_and_refresh, rotate_session_refresh
26
33
 
27
34
 
28
35
  def _gen_pkce_pair() -> tuple[str, str]:
@@ -461,9 +468,13 @@ async def _validate_and_decode_jwt_token(raw_token: str) -> str:
461
468
 
462
469
 
463
470
  async def _set_cookie_on_response(
464
- resp: Response, auth_backend: AuthenticationBackend, user: Any
471
+ resp: Response,
472
+ auth_backend: AuthenticationBackend,
473
+ user: Any,
474
+ *,
475
+ refresh_raw: str,
465
476
  ) -> None:
466
- """Set authentication cookie on response."""
477
+ """Set authentication (JWT) and refresh cookies on response."""
467
478
  st = get_auth_settings()
468
479
  strategy = auth_backend.get_strategy()
469
480
  jwt_token = await strategy.write_token(user)
@@ -472,6 +483,7 @@ async def _set_cookie_on_response(
472
483
  if same_site_lit == "none" and not bool(st.session_cookie_secure):
473
484
  raise HTTPException(500, "session_cookie_samesite=None requires session_cookie_secure=True")
474
485
 
486
+ # Access/Auth cookie (short-lived JWT)
475
487
  resp.set_cookie(
476
488
  key=_cookie_name(st),
477
489
  value=jwt_token,
@@ -483,6 +495,18 @@ async def _set_cookie_on_response(
483
495
  path="/",
484
496
  )
485
497
 
498
+ # Refresh cookie (opaque token, longer lived)
499
+ resp.set_cookie(
500
+ key=getattr(st, "session_cookie_name", "svc_session"),
501
+ value=refresh_raw,
502
+ max_age=60 * 60 * 24 * 7, # 7 days default
503
+ httponly=True,
504
+ secure=bool(st.session_cookie_secure),
505
+ samesite=same_site_lit,
506
+ domain=_cookie_domain(st),
507
+ path="/",
508
+ )
509
+
486
510
 
487
511
  def _clean_oauth_session_state(request: Request, provider: str) -> None:
488
512
  """Clean up transient OAuth session state."""
@@ -538,7 +562,7 @@ def _create_oauth_router(
538
562
  router = public_router()
539
563
 
540
564
  @router.get(
541
- "/{provider}/login",
565
+ OAUTH_LOGIN_PATH,
542
566
  description="Login with OAuth provider",
543
567
  )
544
568
  async def oauth_login(request: Request, provider: str):
@@ -571,7 +595,7 @@ def _create_oauth_router(
571
595
  )
572
596
 
573
597
  @router.get(
574
- "/{provider}/callback",
598
+ OAUTH_CALLBACK_PATH,
575
599
  name="oauth_callback",
576
600
  responses={302: {"description": "Redirect to app (or MFA redirect)."}},
577
601
  description="OAuth callback endpoint.",
@@ -636,9 +660,18 @@ def _create_oauth_router(
636
660
  user.last_login = datetime.now(timezone.utc)
637
661
  await session.commit()
638
662
 
639
- # Create response with auth cookie
663
+ # Create session + initial refresh token
664
+ raw_refresh, _rt = await issue_session_and_refresh(
665
+ session,
666
+ user_id=user.id,
667
+ tenant_id=getattr(user, "tenant_id", None),
668
+ user_agent=str(request.headers.get("user-agent", ""))[:512],
669
+ ip_hash=None,
670
+ )
671
+
672
+ # Create response with auth + refresh cookies
640
673
  resp = RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
641
- await _set_cookie_on_response(resp, auth_backend, user)
674
+ await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=raw_refresh)
642
675
 
643
676
  # Clean up session state
644
677
  _clean_oauth_session_state(request, provider)
@@ -653,7 +686,7 @@ def _create_oauth_router(
653
686
  return resp
654
687
 
655
688
  @router.post(
656
- "/refresh",
689
+ OAUTH_REFRESH_PATH,
657
690
  status_code=status.HTTP_204_NO_CONTENT,
658
691
  responses={204: {"description": "Cookie refreshed"}},
659
692
  description="Refresh authentication token.",
@@ -662,44 +695,55 @@ def _create_oauth_router(
662
695
  """Refresh authentication token."""
663
696
  st = get_auth_settings()
664
697
 
665
- # Read and validate cookie
666
- name = _cookie_name(st)
667
- raw = request.cookies.get(name)
668
- if not raw:
698
+ # Read and validate auth JWT cookie
699
+ name_auth = _cookie_name(st)
700
+ raw_auth = request.cookies.get(name_auth)
701
+ if not raw_auth:
669
702
  raise HTTPException(401, "missing_token")
670
703
 
671
- # Validate and decode JWT token
672
- user_id = await _validate_and_decode_jwt_token(raw)
704
+ # Validate and decode JWT token to get user id
705
+ user_id = await _validate_and_decode_jwt_token(raw_auth)
673
706
 
674
707
  # Load user
675
708
  user = await session.get(user_model, user_id)
676
709
  if not user:
677
710
  raise HTTPException(401, "invalid_token")
678
711
 
679
- # Handle MFA if required
680
- if await policy.should_require_mfa(user):
681
- pre = await get_mfa_pre_jwt_writer().write(user)
682
- redirect_url = str(getattr(st, "post_login_redirect", "/"))
683
- allow_hosts = parse_redirect_allow_hosts(getattr(st, "redirect_allow_hosts_raw", None))
684
- require_https = bool(getattr(st, "session_cookie_secure", False))
685
- _validate_redirect(redirect_url, allow_hosts, require_https=require_https)
686
-
687
- nxt = request.query_params.get("next")
688
- if nxt:
689
- try:
690
- _validate_redirect(nxt, allow_hosts, require_https=require_https)
691
- redirect_url = nxt
692
- except HTTPException:
693
- pass
694
-
695
- qs = urlencode({"mfa": "required", "pre_token": pre})
696
- return RedirectResponse(url=f"{redirect_url}?{qs}", status_code=status.HTTP_302_FOUND)
697
-
698
- # Create response with new token
699
- resp = Response(status_code=204)
700
- await _set_cookie_on_response(resp, auth_backend, user)
701
-
702
- # Optional: notify policy hook
712
+ # Obtain refresh cookie
713
+ refresh_cookie_name = getattr(st, "session_cookie_name", "svc_session")
714
+ raw_refresh = request.cookies.get(refresh_cookie_name)
715
+ if not raw_refresh:
716
+ raise HTTPException(401, "missing_refresh_token")
717
+
718
+ # Lookup refresh token row by hash
719
+ from sqlalchemy import select
720
+
721
+ from svc_infra.security.models import hash_refresh_token
722
+
723
+ token_hash = hash_refresh_token(raw_refresh)
724
+ found: RefreshToken | None = (
725
+ (
726
+ await session.execute(
727
+ select(RefreshToken).where(RefreshToken.token_hash == token_hash)
728
+ )
729
+ )
730
+ .scalars()
731
+ .first()
732
+ )
733
+ if (
734
+ not found
735
+ or found.revoked_at
736
+ or (found.expires_at and found.expires_at < datetime.now(timezone.utc))
737
+ ):
738
+ raise HTTPException(401, "invalid_refresh_token")
739
+
740
+ # Rotate refresh token
741
+ new_raw, _new_rt = await rotate_session_refresh(session, current=found)
742
+
743
+ # Write response (204) with new cookies
744
+ resp = Response(status_code=status.HTTP_204_NO_CONTENT)
745
+ await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=new_raw)
746
+ # Policy hook: trigger after successful rotation; suppress hook errors
703
747
  if hasattr(policy, "on_token_refresh"):
704
748
  try:
705
749
  await policy.on_token_refresh(user)
@@ -708,4 +752,5 @@ def _create_oauth_router(
708
752
 
709
753
  return resp
710
754
 
755
+ # Return router at end of factory
711
756
  return router