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