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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- 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 +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- 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 +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- 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 +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- 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 +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- 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 +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- 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 -56
- 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/models_schemas/auth/schemas.py.tmpl +1 -1
- 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 +52 -0
- 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 +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- 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.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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 = (
|
|
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(
|
|
298
|
+
user.hashed_password = PasswordHelper().hash(random_password)
|
|
267
299
|
elif hasattr(user, "password_hash"):
|
|
268
|
-
user.password_hash = PasswordHelper().hash(
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
375
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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,
|
|
531
|
+
resp: Response,
|
|
532
|
+
strategy: Strategy[Any, Any],
|
|
533
|
+
user: Any,
|
|
534
|
+
*,
|
|
535
|
+
refresh_raw: str,
|
|
470
536
|
) -> None:
|
|
471
|
-
"""Set authentication
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
606
|
-
|
|
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(
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
if not
|
|
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(
|
|
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
|
-
#
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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"]
|