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,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from datetime import datetime, timedelta, timezone
4
- from typing import List, Optional
4
+ from typing import Any, List, Optional, cast
5
5
  from uuid import UUID
6
6
 
7
7
  from fastapi import HTTPException, Query
@@ -62,7 +62,7 @@ def apikey_router():
62
62
  if owner_id != caller_id and not getattr(p.user, "is_superuser", False):
63
63
  raise HTTPException(403, "forbidden")
64
64
 
65
- plaintext, prefix, hashed = ApiKey.make_secret()
65
+ plaintext, prefix, hashed = ApiKey.make_secret() # type: ignore[attr-defined]
66
66
  expires = (
67
67
  (datetime.now(timezone.utc) + timedelta(hours=payload.ttl_hours))
68
68
  if payload.ttl_hours
@@ -98,9 +98,9 @@ def apikey_router():
98
98
  description="List API keys. Non-superusers see only their own keys.",
99
99
  )
100
100
  async def list_keys(sess: SqlSessionDep, p: Identity):
101
- q = select(ApiKey)
101
+ q: Any = select(ApiKey)
102
102
  if not getattr(p.user, "is_superuser", False):
103
- q = q.where(ApiKey.user_id == p.user.id)
103
+ q = q.where(ApiKey.user_id == p.user.id) # type: ignore[attr-defined]
104
104
  rows = (await sess.execute(q)).scalars().all()
105
105
  return [
106
106
  ApiKeyOut(
@@ -124,7 +124,7 @@ def apikey_router():
124
124
  description="Revoke an API key",
125
125
  )
126
126
  async def revoke_key(key_id: str, sess: SqlSessionDep, p: Identity):
127
- row = await sess.get(ApiKey, key_id)
127
+ row = await cast(Any, sess).get(ApiKey, key_id)
128
128
  if not row:
129
129
  raise HTTPException(404, "not_found")
130
130
 
@@ -148,7 +148,7 @@ def apikey_router():
148
148
  p: Identity,
149
149
  force: bool = Query(False, description="Allow deleting an active key if True"),
150
150
  ):
151
- row = await sess.get(ApiKey, key_id)
151
+ row = await cast(Any, sess).get(ApiKey, key_id)
152
152
  if not row:
153
153
  return # 204
154
154
 
@@ -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,9 @@ 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
35
+ from svc_infra.security.models import RefreshToken
36
+ from svc_infra.security.session import issue_session_and_refresh, rotate_session_refresh
31
37
 
32
38
 
33
39
  def _gen_pkce_pair() -> tuple[str, str]:
@@ -38,14 +44,19 @@ def _gen_pkce_pair() -> tuple[str, str]:
38
44
  return verifier, challenge
39
45
 
40
46
 
41
- 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:
42
50
  """Validate that a redirect URL is allowed and secure."""
43
51
  p = urlparse(url)
44
52
  if not p.netloc:
45
53
  return
46
- 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 "")
47
58
  allowed = {h.lower() for h in allow_hosts}
48
- 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:
49
60
  raise HTTPException(400, "redirect_not_allowed")
50
61
  if require_https and p.scheme != "https":
51
62
  raise HTTPException(400, "https_required")
@@ -75,7 +86,11 @@ def _coerce_expires_at(token: dict | None) -> datetime | None:
75
86
  def _cookie_name(st) -> str:
76
87
  """Get the cookie name with appropriate security prefix."""
77
88
  name = getattr(st, "auth_cookie_name", "svc_auth")
78
- 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
+ ):
79
94
  name = "__Host-" + name
80
95
  return name
81
96
 
@@ -86,7 +101,9 @@ def _cookie_domain(st):
86
101
  return d or None
87
102
 
88
103
 
89
- 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:
90
107
  """Register all OAuth providers with the OAuth client."""
91
108
  for name, cfg in providers.items():
92
109
  kind = cfg.get("kind")
@@ -185,7 +202,9 @@ async def _extract_user_info_github(
185
202
  """Extract user information from GitHub provider."""
186
203
  u = (await client.get("user", token=token)).json()
187
204
  emails_resp = (await client.get("user/emails", token=token)).json()
188
- 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
+ )
189
208
 
190
209
  if not primary:
191
210
  raise HTTPException(400, "unverified_email")
@@ -193,7 +212,9 @@ async def _extract_user_info_github(
193
212
  email = primary["email"]
194
213
  email_verified = True
195
214
  full_name = u.get("name") or u.get("login")
196
- 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
+ )
197
218
 
198
219
  return email, full_name, provider_user_id, email_verified, {"user": u}
199
220
 
@@ -208,7 +229,9 @@ async def _extract_user_info_linkedin(
208
229
  )
209
230
 
210
231
  em = (
211
- 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
+ )
212
235
  ).json()
213
236
 
214
237
  email = None
@@ -247,9 +270,15 @@ async def _extract_user_info_from_provider(
247
270
  raise HTTPException(400, "Unsupported provider kind")
248
271
 
249
272
 
250
- 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:
251
276
  """Find existing user by email or create a new one."""
252
- 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
+ )
253
282
 
254
283
  if existing:
255
284
  return existing
@@ -261,11 +290,14 @@ async def _find_or_create_user(session, user_model, email: str, full_name: str |
261
290
  is_verified=True,
262
291
  )
263
292
 
264
- # 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)
265
297
  if hasattr(user, "hashed_password"):
266
- user.hashed_password = PasswordHelper().hash("!oauth!")
298
+ user.hashed_password = PasswordHelper().hash(random_password)
267
299
  elif hasattr(user, "password_hash"):
268
- user.password_hash = PasswordHelper().hash("!oauth!")
300
+ user.password_hash = PasswordHelper().hash(random_password)
269
301
 
270
302
  if full_name and hasattr(user, "full_name"):
271
303
  setattr(user, "full_name", full_name)
@@ -352,10 +384,18 @@ async def _update_provider_account(
352
384
  else:
353
385
  # Update existing link if values have changed
354
386
  dirty = False
355
- 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
+ ):
356
392
  link.access_token = access_token
357
393
  dirty = True
358
- 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
+ ):
359
399
  link.refresh_token = refresh_token
360
400
  dirty = True
361
401
  if hasattr(link, "expires_at") and expires_at and link.expires_at != expires_at:
@@ -368,19 +408,24 @@ async def _update_provider_account(
368
408
  await session.flush()
369
409
 
370
410
 
371
- 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:
372
414
  """Determine the final redirect URL after successful authentication."""
373
415
  st = get_auth_settings()
374
- redirect_url = str(
375
- 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)
376
420
  )
377
- allow_hosts = parse_redirect_allow_hosts(getattr(st, "redirect_allow_hosts_raw", None))
378
421
  require_https = bool(getattr(st, "session_cookie_secure", False))
379
422
 
380
423
  _validate_redirect(redirect_url, allow_hosts, require_https=require_https)
381
424
 
382
425
  # Prefer ?next or the stashed value from /login
383
- 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
+ )
384
429
  if nxt:
385
430
  try:
386
431
  _validate_redirect(nxt, allow_hosts, require_https=require_https)
@@ -391,7 +436,9 @@ def _determine_final_redirect_url(request: Request, provider: str, post_login_re
391
436
  return redirect_url
392
437
 
393
438
 
394
- 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]:
395
442
  """Validate OAuth state and extract session values."""
396
443
  provided_state = request.query_params.get("state")
397
444
  expected_state = request.session.pop(f"oauth:{provider}:state", None)
@@ -404,7 +451,9 @@ async def _validate_oauth_state(request: Request, provider: str) -> tuple[str |
404
451
  return verifier, nonce
405
452
 
406
453
 
407
- 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
+ ):
408
457
  """Exchange OAuth authorization code for access token."""
409
458
  try:
410
459
  return await client.authorize_access_token(request, code_verifier=verifier)
@@ -435,7 +484,13 @@ async def _process_user_authentication(
435
484
 
436
485
  # Ensure provider link exists
437
486
  await _update_provider_account(
438
- 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,
439
494
  )
440
495
 
441
496
  return user
@@ -444,11 +499,18 @@ async def _process_user_authentication(
444
499
  async def _validate_and_decode_jwt_token(raw_token: str) -> str:
445
500
  """Validate and decode JWT token to extract user ID."""
446
501
  st = get_auth_settings()
447
- secret = (
448
- st.jwt.secret.get_secret_value()
449
- if getattr(st, "jwt", None) and getattr(st.jwt, "secret", None)
450
- 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
451
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
+ )
452
514
 
453
515
  try:
454
516
  payload = jwt.decode(
@@ -460,23 +522,31 @@ async def _validate_and_decode_jwt_token(raw_token: str) -> str:
460
522
  user_id = payload.get("sub")
461
523
  if not user_id:
462
524
  raise HTTPException(401, "invalid_token")
463
- return user_id
525
+ return cast(str, user_id)
464
526
  except Exception:
465
527
  raise HTTPException(401, "invalid_token")
466
528
 
467
529
 
468
530
  async def _set_cookie_on_response(
469
- resp: Response, auth_backend: AuthenticationBackend, user: Any
531
+ resp: Response,
532
+ strategy: Strategy[Any, Any],
533
+ user: Any,
534
+ *,
535
+ refresh_raw: str,
470
536
  ) -> None:
471
- """Set authentication cookie on response."""
537
+ """Set authentication (JWT) and refresh cookies on response."""
472
538
  st = get_auth_settings()
473
- strategy = auth_backend.get_strategy()
474
539
  jwt_token = await strategy.write_token(user)
475
540
 
476
- 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
+ )
477
544
  if same_site_lit == "none" and not bool(st.session_cookie_secure):
478
- 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
+ )
479
548
 
549
+ # Access/Auth cookie (short-lived JWT)
480
550
  resp.set_cookie(
481
551
  key=_cookie_name(st),
482
552
  value=jwt_token,
@@ -488,6 +558,18 @@ async def _set_cookie_on_response(
488
558
  path="/",
489
559
  )
490
560
 
561
+ # Refresh cookie (opaque token, longer lived)
562
+ resp.set_cookie(
563
+ key=getattr(st, "session_cookie_name", "svc_session"),
564
+ value=refresh_raw,
565
+ max_age=60 * 60 * 24 * 7, # 7 days default
566
+ httponly=True,
567
+ secure=bool(st.session_cookie_secure),
568
+ samesite=same_site_lit,
569
+ domain=_cookie_domain(st),
570
+ path="/",
571
+ )
572
+
491
573
 
492
574
  def _clean_oauth_session_state(request: Request, provider: str) -> None:
493
575
  """Clean up transient OAuth session state."""
@@ -504,7 +586,9 @@ async def _handle_mfa_redirect(
504
586
 
505
587
  pre = await get_mfa_pre_jwt_writer().write(user)
506
588
  qs = urlencode({"mfa": "required", "pre_token": pre})
507
- 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
+ )
508
592
 
509
593
 
510
594
  def oauth_router_with_backend(
@@ -581,7 +665,12 @@ def _create_oauth_router(
581
665
  responses={302: {"description": "Redirect to app (or MFA redirect)."}},
582
666
  description="OAuth callback endpoint.",
583
667
  )
584
- 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
+ ):
585
674
  """Handle OAuth callback and complete authentication."""
586
675
  # Handle provider-side errors up front
587
676
  if err := request.query_params.get("error"):
@@ -602,14 +691,22 @@ def _create_oauth_router(
602
691
 
603
692
  # Extract user information from provider
604
693
  cfg = providers.get(provider, {})
605
- email, full_name, provider_user_id, email_verified, raw_claims = (
606
- 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
607
702
  )
608
703
 
609
704
  if email_verified is False:
610
705
  raise HTTPException(400, "unverified_email")
611
706
  if not email:
612
707
  raise HTTPException(400, "No email from provider")
708
+ if not provider_user_id:
709
+ raise HTTPException(400, "No user ID from provider")
613
710
 
614
711
  # Process user authentication
615
712
  user = await _process_user_authentication(
@@ -629,7 +726,9 @@ def _create_oauth_router(
629
726
  raise HTTPException(401, "account_disabled")
630
727
 
631
728
  # Determine final redirect URL
632
- 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
+ )
633
732
 
634
733
  # Handle MFA if required (do NOT set last_login yet; do it after MFA)
635
734
  mfa_response = await _handle_mfa_redirect(policy, user, redirect_url)
@@ -641,9 +740,33 @@ def _create_oauth_router(
641
740
  user.last_login = datetime.now(timezone.utc)
642
741
  await session.commit()
643
742
 
644
- # Create response with auth cookie
743
+ # Create session + initial refresh token
744
+ raw_refresh, _rt = await issue_session_and_refresh(
745
+ session,
746
+ user_id=user.id,
747
+ tenant_id=getattr(user, "tenant_id", None),
748
+ user_agent=str(request.headers.get("user-agent", ""))[:512],
749
+ ip_hash=None,
750
+ )
751
+
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)
645
768
  resp = RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
646
- await _set_cookie_on_response(resp, auth_backend, user)
769
+ await _set_cookie_on_response(resp, strategy, user, refresh_raw=raw_refresh)
647
770
 
648
771
  # Clean up session state
649
772
  _clean_oauth_session_state(request, provider)
@@ -663,48 +786,63 @@ def _create_oauth_router(
663
786
  responses={204: {"description": "Cookie refreshed"}},
664
787
  description="Refresh authentication token.",
665
788
  )
666
- 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
+ ):
667
794
  """Refresh authentication token."""
668
795
  st = get_auth_settings()
669
796
 
670
- # Read and validate cookie
671
- name = _cookie_name(st)
672
- raw = request.cookies.get(name)
673
- if not raw:
797
+ # Read and validate auth JWT cookie
798
+ name_auth = _cookie_name(st)
799
+ raw_auth = request.cookies.get(name_auth)
800
+ if not raw_auth:
674
801
  raise HTTPException(401, "missing_token")
675
802
 
676
- # Validate and decode JWT token
677
- user_id = await _validate_and_decode_jwt_token(raw)
803
+ # Validate and decode JWT token to get user id
804
+ user_id = await _validate_and_decode_jwt_token(raw_auth)
678
805
 
679
806
  # Load user
680
- user = await session.get(user_model, user_id)
807
+ user = await cast(Any, session).get(user_model, user_id)
681
808
  if not user:
682
809
  raise HTTPException(401, "invalid_token")
683
810
 
684
- # Handle MFA if required
685
- if await policy.should_require_mfa(user):
686
- pre = await get_mfa_pre_jwt_writer().write(user)
687
- redirect_url = str(getattr(st, "post_login_redirect", "/"))
688
- allow_hosts = parse_redirect_allow_hosts(getattr(st, "redirect_allow_hosts_raw", None))
689
- require_https = bool(getattr(st, "session_cookie_secure", False))
690
- _validate_redirect(redirect_url, allow_hosts, require_https=require_https)
691
-
692
- nxt = request.query_params.get("next")
693
- if nxt:
694
- try:
695
- _validate_redirect(nxt, allow_hosts, require_https=require_https)
696
- redirect_url = nxt
697
- except HTTPException:
698
- pass
699
-
700
- qs = urlencode({"mfa": "required", "pre_token": pre})
701
- return RedirectResponse(url=f"{redirect_url}?{qs}", status_code=status.HTTP_302_FOUND)
702
-
703
- # Create response with new token
704
- resp = Response(status_code=204)
705
- await _set_cookie_on_response(resp, auth_backend, user)
706
-
707
- # Optional: notify policy hook
811
+ # Obtain refresh cookie
812
+ refresh_cookie_name = getattr(st, "session_cookie_name", "svc_session")
813
+ raw_refresh = request.cookies.get(refresh_cookie_name)
814
+ if not raw_refresh:
815
+ raise HTTPException(401, "missing_refresh_token")
816
+
817
+ # Lookup refresh token row by hash
818
+ from sqlalchemy import select
819
+
820
+ from svc_infra.security.models import hash_refresh_token
821
+
822
+ token_hash = hash_refresh_token(raw_refresh)
823
+ found: RefreshToken | None = (
824
+ (
825
+ await session.execute(
826
+ select(RefreshToken).where(RefreshToken.token_hash == token_hash)
827
+ )
828
+ )
829
+ .scalars()
830
+ .first()
831
+ )
832
+ if (
833
+ not found
834
+ or found.revoked_at
835
+ or (found.expires_at and found.expires_at < datetime.now(timezone.utc))
836
+ ):
837
+ raise HTTPException(401, "invalid_refresh_token")
838
+
839
+ # Rotate refresh token
840
+ new_raw, _new_rt = await rotate_session_refresh(session, current=found)
841
+
842
+ # Write response (204) with new cookies
843
+ resp = Response(status_code=status.HTTP_204_NO_CONTENT)
844
+ await _set_cookie_on_response(resp, strategy, user, refresh_raw=new_raw)
845
+ # Policy hook: trigger after successful rotation; suppress hook errors
708
846
  if hasattr(policy, "on_token_refresh"):
709
847
  try:
710
848
  await policy.on_token_refresh(user)
@@ -713,4 +851,5 @@ def _create_oauth_router(
713
851
 
714
852
  return resp
715
853
 
854
+ # Return router at end of factory
716
855
  return router
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import List
5
+
6
+ from fastapi import APIRouter, HTTPException
7
+ from sqlalchemy import select
8
+
9
+ from svc_infra.api.fastapi.auth.security import Identity
10
+ from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
11
+ from svc_infra.security.models import AuthSession
12
+ from svc_infra.security.permissions import RequirePermission
13
+
14
+
15
+ def build_session_router() -> APIRouter:
16
+ router = APIRouter(prefix="/sessions", tags=["sessions"])
17
+
18
+ @router.get(
19
+ "/me",
20
+ response_model=list[dict],
21
+ dependencies=[RequirePermission("security.session.list")],
22
+ )
23
+ async def list_my_sessions(
24
+ identity: Identity, session: SqlSessionDep
25
+ ) -> List[dict]:
26
+ stmt = select(AuthSession).where(AuthSession.user_id == identity.user.id)
27
+ rows = (await session.execute(stmt)).scalars().all()
28
+ return [
29
+ {
30
+ "id": str(r.id),
31
+ "user_agent": r.user_agent,
32
+ "ip_hash": r.ip_hash,
33
+ "revoked": bool(r.revoked_at),
34
+ "last_seen_at": r.last_seen_at.isoformat() if r.last_seen_at else None,
35
+ "created_at": r.created_at.isoformat() if r.created_at else None,
36
+ }
37
+ for r in rows
38
+ ]
39
+
40
+ @router.post(
41
+ "/{session_id}/revoke",
42
+ status_code=204,
43
+ dependencies=[RequirePermission("security.session.revoke")],
44
+ )
45
+ async def revoke_session(session_id: str, identity: Identity, db: SqlSessionDep):
46
+ # Load session and ensure it belongs to the user (non-admin users cannot revoke others)
47
+ s = await db.get(AuthSession, session_id)
48
+ if not s:
49
+ raise HTTPException(404, "session_not_found")
50
+ # Basic ownership check; could extend for admin bypass later
51
+ if s.user_id != identity.user.id:
52
+ raise HTTPException(403, "forbidden")
53
+ if s.revoked_at:
54
+ return # already revoked
55
+ s.revoked_at = datetime.now(timezone.utc)
56
+ s.revoke_reason = "user_revoked"
57
+ # Revoke all refresh tokens for this session
58
+ for rt in s.refresh_tokens:
59
+ if not rt.revoked_at:
60
+ rt.revoked_at = s.revoked_at
61
+ rt.revoke_reason = "session_revoked"
62
+ await db.flush()
63
+
64
+ return router
65
+
66
+
67
+ __all__ = ["build_session_router"]