mdb-engine 0.1.6__py3-none-any.whl → 0.4.12__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.
- mdb_engine/__init__.py +116 -11
- mdb_engine/auth/ARCHITECTURE.md +112 -0
- mdb_engine/auth/README.md +654 -11
- mdb_engine/auth/__init__.py +136 -29
- mdb_engine/auth/audit.py +592 -0
- mdb_engine/auth/base.py +252 -0
- mdb_engine/auth/casbin_factory.py +265 -70
- mdb_engine/auth/config_defaults.py +5 -5
- mdb_engine/auth/config_helpers.py +19 -18
- mdb_engine/auth/cookie_utils.py +12 -16
- mdb_engine/auth/csrf.py +483 -0
- mdb_engine/auth/decorators.py +10 -16
- mdb_engine/auth/dependencies.py +69 -71
- mdb_engine/auth/helpers.py +3 -3
- mdb_engine/auth/integration.py +61 -88
- mdb_engine/auth/jwt.py +11 -15
- mdb_engine/auth/middleware.py +79 -35
- mdb_engine/auth/oso_factory.py +21 -41
- mdb_engine/auth/provider.py +270 -171
- mdb_engine/auth/rate_limiter.py +505 -0
- mdb_engine/auth/restrictions.py +21 -36
- mdb_engine/auth/session_manager.py +24 -41
- mdb_engine/auth/shared_middleware.py +977 -0
- mdb_engine/auth/shared_users.py +775 -0
- mdb_engine/auth/token_lifecycle.py +10 -12
- mdb_engine/auth/token_store.py +17 -32
- mdb_engine/auth/users.py +99 -159
- mdb_engine/auth/utils.py +236 -42
- mdb_engine/cli/commands/generate.py +546 -10
- mdb_engine/cli/commands/validate.py +3 -7
- mdb_engine/cli/utils.py +7 -7
- mdb_engine/config.py +13 -28
- mdb_engine/constants.py +65 -0
- mdb_engine/core/README.md +117 -6
- mdb_engine/core/__init__.py +39 -7
- mdb_engine/core/app_registration.py +31 -50
- mdb_engine/core/app_secrets.py +289 -0
- mdb_engine/core/connection.py +20 -12
- mdb_engine/core/encryption.py +222 -0
- mdb_engine/core/engine.py +2862 -115
- mdb_engine/core/index_management.py +12 -16
- mdb_engine/core/manifest.py +628 -204
- mdb_engine/core/ray_integration.py +436 -0
- mdb_engine/core/seeding.py +13 -21
- mdb_engine/core/service_initialization.py +20 -30
- mdb_engine/core/types.py +40 -43
- mdb_engine/database/README.md +140 -17
- mdb_engine/database/__init__.py +17 -6
- mdb_engine/database/abstraction.py +37 -50
- mdb_engine/database/connection.py +51 -30
- mdb_engine/database/query_validator.py +367 -0
- mdb_engine/database/resource_limiter.py +204 -0
- mdb_engine/database/scoped_wrapper.py +747 -237
- mdb_engine/dependencies.py +427 -0
- mdb_engine/di/__init__.py +34 -0
- mdb_engine/di/container.py +247 -0
- mdb_engine/di/providers.py +206 -0
- mdb_engine/di/scopes.py +139 -0
- mdb_engine/embeddings/README.md +54 -24
- mdb_engine/embeddings/__init__.py +31 -24
- mdb_engine/embeddings/dependencies.py +38 -155
- mdb_engine/embeddings/service.py +78 -75
- mdb_engine/exceptions.py +104 -12
- mdb_engine/indexes/README.md +30 -13
- mdb_engine/indexes/__init__.py +1 -0
- mdb_engine/indexes/helpers.py +11 -11
- mdb_engine/indexes/manager.py +59 -123
- mdb_engine/memory/README.md +95 -4
- mdb_engine/memory/__init__.py +1 -2
- mdb_engine/memory/service.py +363 -1168
- mdb_engine/observability/README.md +4 -2
- mdb_engine/observability/__init__.py +26 -9
- mdb_engine/observability/health.py +17 -17
- mdb_engine/observability/logging.py +10 -10
- mdb_engine/observability/metrics.py +40 -19
- mdb_engine/repositories/__init__.py +34 -0
- mdb_engine/repositories/base.py +325 -0
- mdb_engine/repositories/mongo.py +233 -0
- mdb_engine/repositories/unit_of_work.py +166 -0
- mdb_engine/routing/README.md +1 -1
- mdb_engine/routing/__init__.py +1 -3
- mdb_engine/routing/websockets.py +41 -75
- mdb_engine/utils/__init__.py +3 -1
- mdb_engine/utils/mongo.py +117 -0
- mdb_engine-0.4.12.dist-info/METADATA +492 -0
- mdb_engine-0.4.12.dist-info/RECORD +97 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/WHEEL +1 -1
- mdb_engine-0.1.6.dist-info/METADATA +0 -213
- mdb_engine-0.1.6.dist-info/RECORD +0 -75
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared Auth Middleware for Multi-App SSO
|
|
3
|
+
|
|
4
|
+
ASGI middleware that handles authentication for apps using "shared" auth mode.
|
|
5
|
+
Automatically validates JWT tokens and populates request.state with user info.
|
|
6
|
+
|
|
7
|
+
This module is part of MDB_ENGINE - MongoDB Engine.
|
|
8
|
+
|
|
9
|
+
Usage (auto-configured by engine.create_app() when auth.mode="shared"):
|
|
10
|
+
# Middleware is automatically added when manifest has auth.mode="shared"
|
|
11
|
+
|
|
12
|
+
# Access user in route handlers:
|
|
13
|
+
@app.get("/protected")
|
|
14
|
+
async def protected(request: Request):
|
|
15
|
+
user = request.state.user # None if not authenticated
|
|
16
|
+
if not user:
|
|
17
|
+
raise HTTPException(status_code=401)
|
|
18
|
+
return {"email": user["email"]}
|
|
19
|
+
|
|
20
|
+
Manual usage:
|
|
21
|
+
from mdb_engine.auth import SharedAuthMiddleware, SharedUserPool
|
|
22
|
+
|
|
23
|
+
pool = SharedUserPool(db)
|
|
24
|
+
app.add_middleware(
|
|
25
|
+
SharedAuthMiddleware,
|
|
26
|
+
user_pool=pool,
|
|
27
|
+
require_role="viewer",
|
|
28
|
+
public_routes=["/health", "/api/public/*"],
|
|
29
|
+
)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import fnmatch
|
|
33
|
+
import hashlib
|
|
34
|
+
import logging
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
import jwt
|
|
39
|
+
from pymongo.errors import PyMongoError
|
|
40
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
41
|
+
from starlette.requests import Request
|
|
42
|
+
from starlette.responses import JSONResponse, Response
|
|
43
|
+
|
|
44
|
+
from .shared_users import SharedUserPool
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
# Cookie and header names for JWT token
|
|
49
|
+
AUTH_COOKIE_NAME = "mdb_auth_token"
|
|
50
|
+
AUTH_HEADER_NAME = "Authorization"
|
|
51
|
+
AUTH_HEADER_PREFIX = "Bearer "
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_client_ip(request: Request) -> str | None:
|
|
55
|
+
"""Extract client IP address from request, handling proxies."""
|
|
56
|
+
# Check X-Forwarded-For header (behind load balancer/proxy)
|
|
57
|
+
forwarded_for = request.headers.get("x-forwarded-for")
|
|
58
|
+
if forwarded_for:
|
|
59
|
+
# Take the first IP (original client)
|
|
60
|
+
return forwarded_for.split(",")[0].strip()
|
|
61
|
+
|
|
62
|
+
# Check X-Real-IP header
|
|
63
|
+
real_ip = request.headers.get("x-real-ip")
|
|
64
|
+
if real_ip:
|
|
65
|
+
return real_ip
|
|
66
|
+
|
|
67
|
+
# Fall back to direct client
|
|
68
|
+
if request.client:
|
|
69
|
+
return request.client.host
|
|
70
|
+
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _compute_fingerprint(request: Request) -> str:
|
|
75
|
+
"""Compute a device fingerprint from request characteristics."""
|
|
76
|
+
components = [
|
|
77
|
+
request.headers.get("user-agent", ""),
|
|
78
|
+
request.headers.get("accept-language", ""),
|
|
79
|
+
request.headers.get("accept-encoding", ""),
|
|
80
|
+
]
|
|
81
|
+
fingerprint_string = "|".join(components)
|
|
82
|
+
return hashlib.sha256(fingerprint_string.encode()).hexdigest()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_request_path(request: Request) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Get the request path relative to the mount point.
|
|
88
|
+
|
|
89
|
+
For mounted apps (via create_multi_app), strips the path prefix from
|
|
90
|
+
request.url.path using request.state.app_base_path. For non-mounted apps,
|
|
91
|
+
uses request.scope["path"] if available, otherwise falls back to request.url.path.
|
|
92
|
+
|
|
93
|
+
This ensures public routes in manifests (which are relative paths like "/")
|
|
94
|
+
match correctly when apps are mounted at prefixes like "/auth-hub".
|
|
95
|
+
|
|
96
|
+
SECURITY: Normalizes and validates paths to prevent path traversal attacks.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
# Check if this is a mounted app with a path prefix
|
|
100
|
+
app_base_path = getattr(request.state, "app_base_path", None)
|
|
101
|
+
# Ensure app_base_path is a string (not a MagicMock in tests)
|
|
102
|
+
if app_base_path and isinstance(app_base_path, str):
|
|
103
|
+
# Ensure request.url.path is a string before calling startswith
|
|
104
|
+
url_path = str(request.url.path) if hasattr(request.url, "path") else None
|
|
105
|
+
if url_path and url_path.startswith(app_base_path):
|
|
106
|
+
# Strip the path prefix to get relative path
|
|
107
|
+
relative_path = url_path[len(app_base_path) :]
|
|
108
|
+
# Normalize and sanitize path to prevent traversal attacks
|
|
109
|
+
relative_path = _normalize_path(relative_path)
|
|
110
|
+
# Ensure path starts with / (handle case where prefix is entire path)
|
|
111
|
+
return relative_path if relative_path else "/"
|
|
112
|
+
|
|
113
|
+
# Fall back to scope["path"] for mounted apps (if available)
|
|
114
|
+
# This handles cases where Starlette/FastAPI sets it correctly
|
|
115
|
+
if "path" in request.scope:
|
|
116
|
+
return _normalize_path(request.scope["path"])
|
|
117
|
+
|
|
118
|
+
# Default to url.path for non-mounted apps
|
|
119
|
+
# Ensure we return a string
|
|
120
|
+
if hasattr(request.url, "path"):
|
|
121
|
+
return _normalize_path(str(request.url.path))
|
|
122
|
+
return "/"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _normalize_path(path: str) -> str:
|
|
126
|
+
"""
|
|
127
|
+
Normalize and sanitize a path to prevent path traversal attacks.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
path: Raw path string
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Normalized path starting with /
|
|
134
|
+
"""
|
|
135
|
+
from pathlib import PurePath
|
|
136
|
+
from urllib.parse import unquote
|
|
137
|
+
|
|
138
|
+
if not path:
|
|
139
|
+
return "/"
|
|
140
|
+
|
|
141
|
+
# Preserve trailing slash (except for root)
|
|
142
|
+
has_trailing_slash = path.endswith("/") and path != "/"
|
|
143
|
+
|
|
144
|
+
# Decode URL encoding
|
|
145
|
+
try:
|
|
146
|
+
decoded = unquote(path)
|
|
147
|
+
except (ValueError, UnicodeDecodeError):
|
|
148
|
+
# If decoding fails, use original path
|
|
149
|
+
decoded = path
|
|
150
|
+
|
|
151
|
+
# Normalize path separators and resolve relative components
|
|
152
|
+
try:
|
|
153
|
+
# Use PurePath to normalize without accessing filesystem
|
|
154
|
+
normalized = PurePath(decoded).as_posix()
|
|
155
|
+
except (ValueError, TypeError):
|
|
156
|
+
# If normalization fails, use decoded path
|
|
157
|
+
normalized = decoded
|
|
158
|
+
|
|
159
|
+
# Reject path traversal attempts
|
|
160
|
+
if ".." in normalized or normalized.startswith("/") and normalized != "/":
|
|
161
|
+
# Check if it's a legitimate absolute path (starts with /)
|
|
162
|
+
if normalized.startswith("/") and ".." not in normalized:
|
|
163
|
+
# Valid absolute path
|
|
164
|
+
pass
|
|
165
|
+
else:
|
|
166
|
+
logger.warning(f"Path traversal attempt detected: {path} -> {normalized}")
|
|
167
|
+
return "/" # Return root path for safety
|
|
168
|
+
|
|
169
|
+
# Ensure path starts with /
|
|
170
|
+
if not normalized.startswith("/"):
|
|
171
|
+
normalized = "/" + normalized
|
|
172
|
+
|
|
173
|
+
# Restore trailing slash if it was present (except for root)
|
|
174
|
+
if has_trailing_slash and normalized != "/" and not normalized.endswith("/"):
|
|
175
|
+
normalized = normalized + "/"
|
|
176
|
+
|
|
177
|
+
return normalized
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
181
|
+
"""
|
|
182
|
+
Middleware for shared authentication across multi-app deployments.
|
|
183
|
+
|
|
184
|
+
Features:
|
|
185
|
+
- Reads JWT from cookie or Authorization header
|
|
186
|
+
- Validates token and populates request.state.user
|
|
187
|
+
- Checks role requirements if configured
|
|
188
|
+
- Skips authentication for public routes
|
|
189
|
+
- Returns 401/403 JSON responses for auth failures
|
|
190
|
+
|
|
191
|
+
The middleware sets:
|
|
192
|
+
- request.state.user: Dict with user info (or None if not authenticated)
|
|
193
|
+
- request.state.user_roles: List of user's roles for current app
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
app: Callable,
|
|
199
|
+
user_pool: SharedUserPool | None,
|
|
200
|
+
app_slug: str,
|
|
201
|
+
require_role: str | None = None,
|
|
202
|
+
public_routes: list[str] | None = None,
|
|
203
|
+
role_hierarchy: dict[str, list[str]] | None = None,
|
|
204
|
+
session_binding: dict[str, Any] | None = None,
|
|
205
|
+
auto_assign_default_role: bool = False,
|
|
206
|
+
cookie_name: str = AUTH_COOKIE_NAME,
|
|
207
|
+
header_name: str = AUTH_HEADER_NAME,
|
|
208
|
+
header_prefix: str = AUTH_HEADER_PREFIX,
|
|
209
|
+
):
|
|
210
|
+
"""
|
|
211
|
+
Initialize shared auth middleware.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
app: ASGI application
|
|
215
|
+
user_pool: SharedUserPool instance (optional for lazy loading)
|
|
216
|
+
app_slug: Current app's slug (for role checking)
|
|
217
|
+
require_role: Role required to access this app (None = no role check)
|
|
218
|
+
public_routes: List of route patterns that don't require auth.
|
|
219
|
+
Supports wildcards, e.g., ["/health", "/api/public/*"]
|
|
220
|
+
role_hierarchy: Optional role hierarchy for inheritance
|
|
221
|
+
session_binding: Session binding configuration:
|
|
222
|
+
- bind_ip: Strict - reject if IP changes
|
|
223
|
+
- bind_fingerprint: Soft - log warning if fingerprint changes
|
|
224
|
+
- allow_ip_change_with_reauth: Allow IP change on re-authentication
|
|
225
|
+
auto_assign_default_role: If True, automatically assign require_role to users
|
|
226
|
+
with no roles for this app (default: False).
|
|
227
|
+
SECURITY: Only enable if explicitly needed - requires
|
|
228
|
+
default_role in manifest to match require_role.
|
|
229
|
+
cookie_name: Name of auth cookie (default: mdb_auth_token)
|
|
230
|
+
header_name: Name of auth header (default: Authorization)
|
|
231
|
+
header_prefix: Prefix for header value (default: "Bearer ")
|
|
232
|
+
"""
|
|
233
|
+
super().__init__(app)
|
|
234
|
+
self._user_pool = user_pool
|
|
235
|
+
self._app_slug = app_slug
|
|
236
|
+
self._require_role = require_role
|
|
237
|
+
self._public_routes = public_routes or []
|
|
238
|
+
self._role_hierarchy = role_hierarchy
|
|
239
|
+
self._session_binding = session_binding or {}
|
|
240
|
+
self._auto_assign_default_role = auto_assign_default_role
|
|
241
|
+
self._cookie_name = cookie_name
|
|
242
|
+
self._header_name = header_name
|
|
243
|
+
self._header_prefix = header_prefix
|
|
244
|
+
|
|
245
|
+
logger.info(
|
|
246
|
+
f"SharedAuthMiddleware initialized for '{app_slug}' "
|
|
247
|
+
f"(require_role={require_role}, public_routes={len(self._public_routes)}, "
|
|
248
|
+
f"session_binding={bool(self._session_binding)})"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def get_user_pool(self, request: Request) -> SharedUserPool | None:
|
|
252
|
+
"""Get the user pool instance. Override in subclasses for lazy loading."""
|
|
253
|
+
return self._user_pool
|
|
254
|
+
|
|
255
|
+
async def dispatch(
|
|
256
|
+
self,
|
|
257
|
+
request: Request,
|
|
258
|
+
call_next: Callable[[Request], Response],
|
|
259
|
+
) -> Response:
|
|
260
|
+
"""Process request through auth middleware."""
|
|
261
|
+
# Initialize request state
|
|
262
|
+
request.state.user = None
|
|
263
|
+
request.state.user_roles = []
|
|
264
|
+
|
|
265
|
+
# Get user pool
|
|
266
|
+
user_pool = self.get_user_pool(request)
|
|
267
|
+
if not user_pool:
|
|
268
|
+
# User pool not available (e.g., lazy loading failed), skip auth if not strict
|
|
269
|
+
# But here we default to skipping for robustness if pool is missing
|
|
270
|
+
# However, for Lazy middleware, we want to skip if not initialized yet
|
|
271
|
+
return await call_next(request)
|
|
272
|
+
|
|
273
|
+
is_public = self._is_public_route(_get_request_path(request))
|
|
274
|
+
|
|
275
|
+
# Extract token from cookie or header
|
|
276
|
+
token = self._extract_token(request)
|
|
277
|
+
|
|
278
|
+
if not token:
|
|
279
|
+
# No token provided
|
|
280
|
+
if not is_public and self._require_role:
|
|
281
|
+
return self._unauthorized_response("Authentication required")
|
|
282
|
+
# No role required or public route, continue without user
|
|
283
|
+
return await call_next(request)
|
|
284
|
+
|
|
285
|
+
# Validate token and get user
|
|
286
|
+
user = await user_pool.validate_token(token)
|
|
287
|
+
|
|
288
|
+
if not user:
|
|
289
|
+
# Invalid token - for public routes, continue without user
|
|
290
|
+
if is_public:
|
|
291
|
+
return await call_next(request)
|
|
292
|
+
return self._unauthorized_response("Invalid or expired token")
|
|
293
|
+
|
|
294
|
+
# Validate session binding if configured
|
|
295
|
+
binding_error = await self._validate_session_binding(request, token)
|
|
296
|
+
if binding_error:
|
|
297
|
+
if is_public:
|
|
298
|
+
# For public routes, log but continue
|
|
299
|
+
logger.warning(f"Session binding mismatch on public route: {binding_error}")
|
|
300
|
+
else:
|
|
301
|
+
return self._forbidden_response(binding_error)
|
|
302
|
+
|
|
303
|
+
# Set user on request state
|
|
304
|
+
request.state.user = user
|
|
305
|
+
request.state.user_roles = SharedUserPool.get_user_roles_for_app(user, self._app_slug)
|
|
306
|
+
|
|
307
|
+
# Check role requirement (only for non-public routes)
|
|
308
|
+
if not is_public and self._require_role:
|
|
309
|
+
user_roles = request.state.user_roles
|
|
310
|
+
has_required_role = SharedUserPool.user_has_role(
|
|
311
|
+
user,
|
|
312
|
+
self._app_slug,
|
|
313
|
+
self._require_role,
|
|
314
|
+
self._role_hierarchy,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if not has_required_role:
|
|
318
|
+
# Auto-assign required role ONLY if explicitly enabled and user has no roles
|
|
319
|
+
# SECURITY: This is opt-in to prevent privilege escalation. Only enable if
|
|
320
|
+
# explicitly needed and default_role matches require_role in manifest.
|
|
321
|
+
if not user_roles and self._auto_assign_default_role:
|
|
322
|
+
user_email = user.get("email")
|
|
323
|
+
if user_email:
|
|
324
|
+
try:
|
|
325
|
+
# Auto-assign the required role
|
|
326
|
+
success = await user_pool.update_user_roles(
|
|
327
|
+
user_email, self._app_slug, [self._require_role]
|
|
328
|
+
)
|
|
329
|
+
if success:
|
|
330
|
+
# Refresh user data to include new role
|
|
331
|
+
user = await user_pool.get_user_by_email(user_email)
|
|
332
|
+
if user:
|
|
333
|
+
request.state.user = user
|
|
334
|
+
request.state.user_roles = [self._require_role]
|
|
335
|
+
logger.info(
|
|
336
|
+
f"Auto-assigned role '{self._require_role}' to user "
|
|
337
|
+
f"{user_email} for app '{self._app_slug}' "
|
|
338
|
+
f"(auto_assign_default_role enabled)"
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
logger.warning(
|
|
342
|
+
f"Failed to refresh user after auto-assigning role: "
|
|
343
|
+
f"{user_email}"
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
logger.warning(
|
|
347
|
+
f"Failed to auto-assign role '{self._require_role}' to "
|
|
348
|
+
f"user {user_email} for app '{self._app_slug}'"
|
|
349
|
+
)
|
|
350
|
+
except (PyMongoError, ValueError, AttributeError) as e:
|
|
351
|
+
logger.error(
|
|
352
|
+
f"Error auto-assigning role to user {user_email}: {e}",
|
|
353
|
+
exc_info=True,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Check again after potential auto-assignment
|
|
357
|
+
if not SharedUserPool.user_has_role(
|
|
358
|
+
user,
|
|
359
|
+
self._app_slug,
|
|
360
|
+
self._require_role,
|
|
361
|
+
self._role_hierarchy,
|
|
362
|
+
):
|
|
363
|
+
return self._forbidden_response(
|
|
364
|
+
f"Role '{self._require_role}' required for this app"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return await call_next(request)
|
|
368
|
+
|
|
369
|
+
async def _validate_session_binding(
|
|
370
|
+
self,
|
|
371
|
+
request: Request,
|
|
372
|
+
token: str,
|
|
373
|
+
) -> str | None:
|
|
374
|
+
"""
|
|
375
|
+
Validate session binding claims in token.
|
|
376
|
+
|
|
377
|
+
Returns error message if validation fails, None if OK.
|
|
378
|
+
"""
|
|
379
|
+
if not self._session_binding:
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
# Decode token without verification to get claims
|
|
384
|
+
# (verification already done in validate_token)
|
|
385
|
+
payload = jwt.decode(token, options={"verify_signature": False})
|
|
386
|
+
|
|
387
|
+
# Check IP binding
|
|
388
|
+
if self._session_binding.get("bind_ip", False):
|
|
389
|
+
token_ip = payload.get("ip")
|
|
390
|
+
if token_ip:
|
|
391
|
+
client_ip = _get_client_ip(request)
|
|
392
|
+
if client_ip and client_ip != token_ip:
|
|
393
|
+
logger.warning(f"Session IP mismatch: token={token_ip}, client={client_ip}")
|
|
394
|
+
return "Session bound to different IP address"
|
|
395
|
+
|
|
396
|
+
# Check fingerprint binding (strict by default for security)
|
|
397
|
+
bind_fingerprint = self._session_binding.get("bind_fingerprint", True)
|
|
398
|
+
strict_fingerprint = self._session_binding.get(
|
|
399
|
+
"strict_fingerprint", True
|
|
400
|
+
) # Default: strict
|
|
401
|
+
if bind_fingerprint:
|
|
402
|
+
token_fp = payload.get("fp")
|
|
403
|
+
if token_fp:
|
|
404
|
+
client_fp = _compute_fingerprint(request)
|
|
405
|
+
if client_fp != token_fp:
|
|
406
|
+
if strict_fingerprint:
|
|
407
|
+
logger.warning(
|
|
408
|
+
f"Session fingerprint mismatch for user {payload.get('email')} - "
|
|
409
|
+
f"rejecting request (strict_fingerprint=True)"
|
|
410
|
+
)
|
|
411
|
+
return "Session bound to different device/fingerprint"
|
|
412
|
+
else:
|
|
413
|
+
logger.warning(
|
|
414
|
+
f"Session fingerprint mismatch for user {payload.get('email')} - "
|
|
415
|
+
f"allowing (strict_fingerprint=False)"
|
|
416
|
+
)
|
|
417
|
+
# Soft check - don't reject, just log
|
|
418
|
+
# Could be legitimate (browser update, different device)
|
|
419
|
+
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
except jwt.InvalidTokenError as e:
|
|
423
|
+
logger.warning(f"Error validating session binding: {e}")
|
|
424
|
+
return None # Don't reject for binding check errors
|
|
425
|
+
|
|
426
|
+
def _extract_token(self, request: Request) -> str | None:
|
|
427
|
+
"""Extract JWT token from cookie or header."""
|
|
428
|
+
# Try cookie first
|
|
429
|
+
token = request.cookies.get(self._cookie_name)
|
|
430
|
+
if token:
|
|
431
|
+
return token
|
|
432
|
+
|
|
433
|
+
# Try Authorization header
|
|
434
|
+
auth_header = request.headers.get(self._header_name)
|
|
435
|
+
if auth_header and auth_header.startswith(self._header_prefix):
|
|
436
|
+
return auth_header[len(self._header_prefix) :]
|
|
437
|
+
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
def _is_public_route(self, path: str) -> bool:
|
|
441
|
+
"""Check if path matches any public route pattern."""
|
|
442
|
+
for pattern in self._public_routes:
|
|
443
|
+
# Normalize pattern for fnmatch
|
|
444
|
+
if not pattern.startswith("/"):
|
|
445
|
+
pattern = "/" + pattern
|
|
446
|
+
|
|
447
|
+
# Check exact match
|
|
448
|
+
if path == pattern:
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
# Check wildcard match
|
|
452
|
+
if fnmatch.fnmatch(path, pattern):
|
|
453
|
+
return True
|
|
454
|
+
|
|
455
|
+
# Check prefix match for patterns ending with /*
|
|
456
|
+
if pattern.endswith("/*"):
|
|
457
|
+
prefix = pattern[:-2]
|
|
458
|
+
if path.startswith(prefix):
|
|
459
|
+
return True
|
|
460
|
+
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
@staticmethod
|
|
464
|
+
def _unauthorized_response(detail: str) -> JSONResponse:
|
|
465
|
+
"""Return 401 Unauthorized response."""
|
|
466
|
+
return JSONResponse(
|
|
467
|
+
status_code=401,
|
|
468
|
+
content={"detail": detail, "error": "unauthorized"},
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
@staticmethod
|
|
472
|
+
def _forbidden_response(detail: str) -> JSONResponse:
|
|
473
|
+
"""Return 403 Forbidden response."""
|
|
474
|
+
return JSONResponse(
|
|
475
|
+
status_code=403,
|
|
476
|
+
content={"detail": detail, "error": "forbidden"},
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def create_shared_auth_middleware(
|
|
481
|
+
user_pool: SharedUserPool,
|
|
482
|
+
app_slug: str,
|
|
483
|
+
manifest_auth: dict[str, Any],
|
|
484
|
+
) -> type:
|
|
485
|
+
"""
|
|
486
|
+
Factory function to create SharedAuthMiddleware configured from manifest.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
user_pool: SharedUserPool instance
|
|
490
|
+
app_slug: Current app's slug
|
|
491
|
+
manifest_auth: Auth section from manifest
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Configured middleware class ready to add to FastAPI app
|
|
495
|
+
|
|
496
|
+
Usage:
|
|
497
|
+
middleware_class = create_shared_auth_middleware(pool, "my_app", manifest["auth"])
|
|
498
|
+
app.add_middleware(middleware_class)
|
|
499
|
+
"""
|
|
500
|
+
require_role = manifest_auth.get("require_role")
|
|
501
|
+
public_routes = manifest_auth.get("public_routes", [])
|
|
502
|
+
auto_assign_default_role = manifest_auth.get("auto_assign_default_role", False)
|
|
503
|
+
default_role = manifest_auth.get("default_role")
|
|
504
|
+
|
|
505
|
+
# Security: Only allow auto-assignment if default_role matches require_role
|
|
506
|
+
if auto_assign_default_role and require_role and default_role != require_role:
|
|
507
|
+
logger.warning(
|
|
508
|
+
f"Security: auto_assign_default_role enabled but default_role '{default_role}' "
|
|
509
|
+
f"does not match require_role '{require_role}' for app '{app_slug}'. "
|
|
510
|
+
f"Auto-assignment disabled for security."
|
|
511
|
+
)
|
|
512
|
+
auto_assign_default_role = False
|
|
513
|
+
|
|
514
|
+
# Build role hierarchy from manifest if available
|
|
515
|
+
role_hierarchy = None
|
|
516
|
+
roles = manifest_auth.get("roles", [])
|
|
517
|
+
if roles and len(roles) > 1:
|
|
518
|
+
# Auto-generate hierarchy: each role inherits from roles below it
|
|
519
|
+
# e.g., roles=["viewer", "editor", "admin"] -> admin > editor > viewer
|
|
520
|
+
role_hierarchy = {}
|
|
521
|
+
for i, role in enumerate(roles):
|
|
522
|
+
if i > 0:
|
|
523
|
+
role_hierarchy[role] = roles[:i]
|
|
524
|
+
|
|
525
|
+
# Create a wrapper class with the configuration baked in
|
|
526
|
+
class ConfiguredSharedAuthMiddleware(SharedAuthMiddleware):
|
|
527
|
+
def __init__(self, app: Callable):
|
|
528
|
+
super().__init__(
|
|
529
|
+
app=app,
|
|
530
|
+
user_pool=user_pool,
|
|
531
|
+
app_slug=app_slug,
|
|
532
|
+
require_role=require_role,
|
|
533
|
+
public_routes=public_routes,
|
|
534
|
+
role_hierarchy=role_hierarchy,
|
|
535
|
+
auto_assign_default_role=auto_assign_default_role,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
return ConfiguredSharedAuthMiddleware
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _build_role_hierarchy(manifest_auth: dict[str, Any]) -> dict[str, list[str]] | None:
|
|
542
|
+
"""Build role hierarchy from manifest roles."""
|
|
543
|
+
roles = manifest_auth.get("roles", [])
|
|
544
|
+
if not roles or len(roles) <= 1:
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
# Auto-generate hierarchy: each role inherits from roles below it
|
|
548
|
+
role_hierarchy = {}
|
|
549
|
+
for i, role in enumerate(roles):
|
|
550
|
+
if i > 0:
|
|
551
|
+
role_hierarchy[role] = roles[:i]
|
|
552
|
+
return role_hierarchy
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _is_public_route_helper(path: str, public_routes: list[str]) -> bool:
|
|
556
|
+
"""Check if path matches any public route pattern."""
|
|
557
|
+
for pattern in public_routes:
|
|
558
|
+
# Normalize pattern for fnmatch
|
|
559
|
+
if not pattern.startswith("/"):
|
|
560
|
+
pattern = "/" + pattern
|
|
561
|
+
|
|
562
|
+
# Check exact match
|
|
563
|
+
if path == pattern:
|
|
564
|
+
return True
|
|
565
|
+
|
|
566
|
+
# Check wildcard match
|
|
567
|
+
if fnmatch.fnmatch(path, pattern):
|
|
568
|
+
return True
|
|
569
|
+
|
|
570
|
+
# Check prefix match for patterns ending with /*
|
|
571
|
+
if pattern.endswith("/*"):
|
|
572
|
+
prefix = pattern[:-2]
|
|
573
|
+
if path.startswith(prefix):
|
|
574
|
+
return True
|
|
575
|
+
|
|
576
|
+
return False
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _extract_token_helper(
|
|
580
|
+
request: Request, cookie_name: str, header_name: str, header_prefix: str
|
|
581
|
+
) -> str | None:
|
|
582
|
+
"""Extract JWT token from cookie or header."""
|
|
583
|
+
# Try cookie first
|
|
584
|
+
token = request.cookies.get(cookie_name)
|
|
585
|
+
if token:
|
|
586
|
+
return token
|
|
587
|
+
|
|
588
|
+
# Try Authorization header
|
|
589
|
+
auth_header = request.headers.get(header_name)
|
|
590
|
+
if auth_header and auth_header.startswith(header_prefix):
|
|
591
|
+
return auth_header[len(header_prefix) :]
|
|
592
|
+
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _create_lazy_middleware_class( # noqa: C901
|
|
597
|
+
app_slug: str,
|
|
598
|
+
require_role: str | None,
|
|
599
|
+
public_routes: list[str],
|
|
600
|
+
role_hierarchy: dict[str, list[str]] | None,
|
|
601
|
+
session_binding: dict[str, Any],
|
|
602
|
+
auto_assign_default_role: bool = False,
|
|
603
|
+
) -> type:
|
|
604
|
+
"""Create the LazySharedAuthMiddleware class with configuration."""
|
|
605
|
+
|
|
606
|
+
class LazySharedAuthMiddleware(BaseHTTPMiddleware):
|
|
607
|
+
"""
|
|
608
|
+
Lazy version of SharedAuthMiddleware that gets user_pool from app.state.
|
|
609
|
+
|
|
610
|
+
This enables adding middleware at app creation time while deferring
|
|
611
|
+
the actual user pool initialization to the lifespan startup.
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
def __init__(self, app: Callable):
|
|
615
|
+
super().__init__(app)
|
|
616
|
+
self._app_slug = app_slug
|
|
617
|
+
self._require_role = require_role
|
|
618
|
+
self._public_routes = public_routes
|
|
619
|
+
self._role_hierarchy = role_hierarchy
|
|
620
|
+
self._session_binding = session_binding
|
|
621
|
+
self._auto_assign_default_role = auto_assign_default_role
|
|
622
|
+
self._cookie_name = AUTH_COOKIE_NAME
|
|
623
|
+
self._header_name = AUTH_HEADER_NAME
|
|
624
|
+
self._header_prefix = AUTH_HEADER_PREFIX
|
|
625
|
+
|
|
626
|
+
logger.info(
|
|
627
|
+
f"LazySharedAuthMiddleware initialized for '{app_slug}' "
|
|
628
|
+
f"(require_role={require_role}, public_routes={len(self._public_routes)}, "
|
|
629
|
+
f"session_binding={bool(self._session_binding)})"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
async def dispatch(
|
|
633
|
+
self,
|
|
634
|
+
request: Request,
|
|
635
|
+
call_next: Callable[[Request], Response],
|
|
636
|
+
) -> Response:
|
|
637
|
+
"""Process request through auth middleware."""
|
|
638
|
+
# Initialize request state
|
|
639
|
+
request.state.user = None
|
|
640
|
+
request.state.user_roles = []
|
|
641
|
+
|
|
642
|
+
# Get user_pool from app.state (set during lifespan)
|
|
643
|
+
user_pool: SharedUserPool | None = getattr(request.app.state, "user_pool", None)
|
|
644
|
+
|
|
645
|
+
if user_pool is None:
|
|
646
|
+
# User pool not initialized yet, skip auth
|
|
647
|
+
logger.warning(
|
|
648
|
+
f"LazySharedAuthMiddleware: user_pool not found on app.state for '{app_slug}'"
|
|
649
|
+
)
|
|
650
|
+
return await call_next(request)
|
|
651
|
+
|
|
652
|
+
is_public = _is_public_route_helper(_get_request_path(request), self._public_routes)
|
|
653
|
+
token = _extract_token_helper(
|
|
654
|
+
request, self._cookie_name, self._header_name, self._header_prefix
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
# Handle unauthenticated requests
|
|
658
|
+
if not token:
|
|
659
|
+
return await self._handle_no_token(is_public, request, call_next)
|
|
660
|
+
|
|
661
|
+
# Authenticate and authorize user
|
|
662
|
+
auth_result = await self._authenticate_and_authorize(
|
|
663
|
+
request, user_pool, token, is_public, call_next
|
|
664
|
+
)
|
|
665
|
+
if auth_result is not None:
|
|
666
|
+
return auth_result
|
|
667
|
+
|
|
668
|
+
return await call_next(request)
|
|
669
|
+
|
|
670
|
+
async def _authenticate_and_authorize(
|
|
671
|
+
self,
|
|
672
|
+
request: Request,
|
|
673
|
+
user_pool: SharedUserPool,
|
|
674
|
+
token: str,
|
|
675
|
+
is_public: bool,
|
|
676
|
+
call_next: Callable[[Request], Response],
|
|
677
|
+
) -> Response | None:
|
|
678
|
+
"""Authenticate user and check authorization."""
|
|
679
|
+
# Validate token and get user
|
|
680
|
+
user = await user_pool.validate_token(token)
|
|
681
|
+
if not user:
|
|
682
|
+
return await self._handle_invalid_token(is_public, request, call_next)
|
|
683
|
+
|
|
684
|
+
# Validate session binding if configured
|
|
685
|
+
binding_error = await self._validate_session_binding(request, token)
|
|
686
|
+
if binding_error:
|
|
687
|
+
return await self._handle_binding_error(
|
|
688
|
+
binding_error, is_public, request, call_next
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
# Set user on request state
|
|
692
|
+
request.state.user = user
|
|
693
|
+
request.state.user_roles = SharedUserPool.get_user_roles_for_app(user, self._app_slug)
|
|
694
|
+
|
|
695
|
+
# Check role requirement (only for non-public routes)
|
|
696
|
+
if not is_public and self._require_role:
|
|
697
|
+
role_check_result = await self._check_and_assign_role(user, user_pool, request)
|
|
698
|
+
if role_check_result is not None:
|
|
699
|
+
return role_check_result
|
|
700
|
+
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
async def _handle_no_token(
|
|
704
|
+
self,
|
|
705
|
+
is_public: bool,
|
|
706
|
+
request: Request,
|
|
707
|
+
call_next: Callable[[Request], Response],
|
|
708
|
+
) -> Response:
|
|
709
|
+
"""Handle request with no token."""
|
|
710
|
+
if not is_public and self._require_role:
|
|
711
|
+
return self._unauthorized_response("Authentication required")
|
|
712
|
+
return await call_next(request)
|
|
713
|
+
|
|
714
|
+
async def _handle_invalid_token(
|
|
715
|
+
self,
|
|
716
|
+
is_public: bool,
|
|
717
|
+
request: Request,
|
|
718
|
+
call_next: Callable[[Request], Response],
|
|
719
|
+
) -> Response:
|
|
720
|
+
"""Handle request with invalid token."""
|
|
721
|
+
if is_public:
|
|
722
|
+
return await call_next(request)
|
|
723
|
+
return self._unauthorized_response("Invalid or expired token")
|
|
724
|
+
|
|
725
|
+
async def _handle_binding_error(
|
|
726
|
+
self,
|
|
727
|
+
binding_error: str,
|
|
728
|
+
is_public: bool,
|
|
729
|
+
request: Request,
|
|
730
|
+
call_next: Callable[[Request], Response],
|
|
731
|
+
) -> Response:
|
|
732
|
+
"""Handle session binding validation error."""
|
|
733
|
+
if is_public:
|
|
734
|
+
logger.warning(f"Session binding mismatch on public route: {binding_error}")
|
|
735
|
+
return await call_next(request)
|
|
736
|
+
return self._forbidden_response(binding_error)
|
|
737
|
+
|
|
738
|
+
@staticmethod
|
|
739
|
+
def _unauthorized_response(detail: str) -> JSONResponse:
|
|
740
|
+
"""Return 401 Unauthorized response."""
|
|
741
|
+
return JSONResponse(
|
|
742
|
+
status_code=401,
|
|
743
|
+
content={"detail": detail, "error": "unauthorized"},
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
@staticmethod
|
|
747
|
+
def _forbidden_response(detail: str) -> JSONResponse:
|
|
748
|
+
"""Return 403 Forbidden response."""
|
|
749
|
+
return JSONResponse(
|
|
750
|
+
status_code=403,
|
|
751
|
+
content={"detail": detail, "error": "forbidden"},
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
async def _check_and_assign_role(
|
|
755
|
+
self,
|
|
756
|
+
user: dict[str, Any],
|
|
757
|
+
user_pool: SharedUserPool,
|
|
758
|
+
request: Request,
|
|
759
|
+
) -> Response | None:
|
|
760
|
+
"""
|
|
761
|
+
Check if user has required role and auto-assign if needed.
|
|
762
|
+
|
|
763
|
+
Returns Response if access should be denied, None if OK.
|
|
764
|
+
"""
|
|
765
|
+
user_roles = request.state.user_roles
|
|
766
|
+
has_required_role = SharedUserPool.user_has_role(
|
|
767
|
+
user,
|
|
768
|
+
self._app_slug,
|
|
769
|
+
self._require_role,
|
|
770
|
+
self._role_hierarchy,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
if has_required_role:
|
|
774
|
+
return None
|
|
775
|
+
|
|
776
|
+
# Auto-assign required role ONLY if explicitly enabled and user has no roles
|
|
777
|
+
# SECURITY: This is opt-in to prevent privilege escalation
|
|
778
|
+
if not user_roles and self._auto_assign_default_role:
|
|
779
|
+
await self._try_auto_assign_role(user, user_pool, request)
|
|
780
|
+
|
|
781
|
+
# Check again after potential auto-assignment
|
|
782
|
+
if not SharedUserPool.user_has_role(
|
|
783
|
+
user,
|
|
784
|
+
self._app_slug,
|
|
785
|
+
self._require_role,
|
|
786
|
+
self._role_hierarchy,
|
|
787
|
+
):
|
|
788
|
+
return self._forbidden_response(
|
|
789
|
+
f"Role '{self._require_role}' required for this app"
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
return None
|
|
793
|
+
|
|
794
|
+
async def _try_auto_assign_role(
|
|
795
|
+
self,
|
|
796
|
+
user: dict[str, Any],
|
|
797
|
+
user_pool: SharedUserPool,
|
|
798
|
+
request: Request,
|
|
799
|
+
) -> None:
|
|
800
|
+
"""
|
|
801
|
+
Attempt to auto-assign required role to user.
|
|
802
|
+
|
|
803
|
+
SECURITY: Only called if auto_assign_default_role is enabled and user has
|
|
804
|
+
no roles. This prevents privilege escalation.
|
|
805
|
+
"""
|
|
806
|
+
user_email = user.get("email")
|
|
807
|
+
if not user_email:
|
|
808
|
+
return
|
|
809
|
+
|
|
810
|
+
try:
|
|
811
|
+
# Auto-assign the required role
|
|
812
|
+
success = await user_pool.update_user_roles(
|
|
813
|
+
user_email, self._app_slug, [self._require_role]
|
|
814
|
+
)
|
|
815
|
+
if success:
|
|
816
|
+
# Refresh user data to include new role
|
|
817
|
+
updated_user = await user_pool.get_user_by_email(user_email)
|
|
818
|
+
if updated_user:
|
|
819
|
+
request.state.user = updated_user
|
|
820
|
+
request.state.user_roles = [self._require_role]
|
|
821
|
+
logger.info(
|
|
822
|
+
f"Auto-assigned role '{self._require_role}' to user "
|
|
823
|
+
f"{user_email} for app '{self._app_slug}' "
|
|
824
|
+
f"(auto_assign_default_role enabled)"
|
|
825
|
+
)
|
|
826
|
+
else:
|
|
827
|
+
logger.warning(
|
|
828
|
+
f"Failed to refresh user after auto-assigning role: " f"{user_email}"
|
|
829
|
+
)
|
|
830
|
+
else:
|
|
831
|
+
logger.warning(
|
|
832
|
+
f"Failed to auto-assign role '{self._require_role}' to "
|
|
833
|
+
f"user {user_email} for app '{self._app_slug}'"
|
|
834
|
+
)
|
|
835
|
+
except (PyMongoError, ValueError, AttributeError) as e:
|
|
836
|
+
logger.error(
|
|
837
|
+
f"Error auto-assigning role to user {user_email}: {e}",
|
|
838
|
+
exc_info=True,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
async def _validate_session_binding(
|
|
842
|
+
self,
|
|
843
|
+
request: Request,
|
|
844
|
+
token: str,
|
|
845
|
+
) -> str | None:
|
|
846
|
+
"""
|
|
847
|
+
Validate session binding claims in token.
|
|
848
|
+
|
|
849
|
+
Returns error message if validation fails, None if OK.
|
|
850
|
+
"""
|
|
851
|
+
if not self._session_binding:
|
|
852
|
+
return None
|
|
853
|
+
|
|
854
|
+
try:
|
|
855
|
+
# Decode token without verification to get claims
|
|
856
|
+
# (verification already done in validate_token)
|
|
857
|
+
payload = jwt.decode(token, options={"verify_signature": False})
|
|
858
|
+
|
|
859
|
+
# Check IP binding
|
|
860
|
+
ip_error = self._check_ip_binding(request, payload)
|
|
861
|
+
if ip_error:
|
|
862
|
+
return ip_error
|
|
863
|
+
|
|
864
|
+
# Check fingerprint binding (strict by default)
|
|
865
|
+
fingerprint_error = await self._check_fingerprint_binding(request, payload)
|
|
866
|
+
if fingerprint_error:
|
|
867
|
+
return fingerprint_error
|
|
868
|
+
|
|
869
|
+
return None
|
|
870
|
+
|
|
871
|
+
except jwt.InvalidTokenError as e:
|
|
872
|
+
logger.warning(f"Error validating session binding: {e}")
|
|
873
|
+
return None # Don't reject for binding check errors
|
|
874
|
+
|
|
875
|
+
def _check_ip_binding(self, request: Request, payload: dict) -> str | None:
|
|
876
|
+
"""Check IP binding from token payload."""
|
|
877
|
+
if not self._session_binding.get("bind_ip", False):
|
|
878
|
+
return None
|
|
879
|
+
|
|
880
|
+
token_ip = payload.get("ip")
|
|
881
|
+
if not token_ip:
|
|
882
|
+
return None
|
|
883
|
+
|
|
884
|
+
client_ip = _get_client_ip(request)
|
|
885
|
+
if client_ip and client_ip != token_ip:
|
|
886
|
+
logger.warning(f"Session IP mismatch: token={token_ip}, client={client_ip}")
|
|
887
|
+
return "Session bound to different IP address"
|
|
888
|
+
|
|
889
|
+
return None
|
|
890
|
+
|
|
891
|
+
async def _check_fingerprint_binding(self, request: Request, payload: dict) -> str | None:
|
|
892
|
+
"""
|
|
893
|
+
Check fingerprint binding from token payload.
|
|
894
|
+
|
|
895
|
+
Returns error message if validation fails, None if OK.
|
|
896
|
+
"""
|
|
897
|
+
if not self._session_binding.get("bind_fingerprint", True):
|
|
898
|
+
return None
|
|
899
|
+
|
|
900
|
+
token_fp = payload.get("fp")
|
|
901
|
+
if not token_fp:
|
|
902
|
+
return None
|
|
903
|
+
|
|
904
|
+
strict_fingerprint = self._session_binding.get(
|
|
905
|
+
"strict_fingerprint", True
|
|
906
|
+
) # Default: strict
|
|
907
|
+
client_fp = _compute_fingerprint(request)
|
|
908
|
+
if client_fp != token_fp:
|
|
909
|
+
if strict_fingerprint:
|
|
910
|
+
logger.warning(
|
|
911
|
+
f"Session fingerprint mismatch for user {payload.get('email')} - "
|
|
912
|
+
f"rejecting request (strict_fingerprint=True)"
|
|
913
|
+
)
|
|
914
|
+
return "Session bound to different device/fingerprint"
|
|
915
|
+
else:
|
|
916
|
+
logger.warning(
|
|
917
|
+
f"Session fingerprint mismatch for user {payload.get('email')} - "
|
|
918
|
+
f"allowing (strict_fingerprint=False)"
|
|
919
|
+
)
|
|
920
|
+
# Soft check - don't reject, just log
|
|
921
|
+
return None
|
|
922
|
+
return None
|
|
923
|
+
|
|
924
|
+
return LazySharedAuthMiddleware
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def create_shared_auth_middleware_lazy(
|
|
928
|
+
app_slug: str,
|
|
929
|
+
manifest_auth: dict[str, Any],
|
|
930
|
+
) -> type:
|
|
931
|
+
"""
|
|
932
|
+
Factory function to create a lazy SharedAuthMiddleware that reads user_pool from app.state.
|
|
933
|
+
|
|
934
|
+
This allows middleware to be added at app creation time (before startup),
|
|
935
|
+
while the actual SharedUserPool is initialized during the lifespan.
|
|
936
|
+
The middleware accesses `request.app.state.user_pool` at request time.
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
app_slug: Current app's slug
|
|
940
|
+
manifest_auth: Auth section from manifest
|
|
941
|
+
|
|
942
|
+
Returns:
|
|
943
|
+
Configured middleware class ready to add to FastAPI app
|
|
944
|
+
|
|
945
|
+
Usage:
|
|
946
|
+
# At app creation time:
|
|
947
|
+
middleware_class = create_shared_auth_middleware_lazy("my_app", manifest["auth"])
|
|
948
|
+
app.add_middleware(middleware_class)
|
|
949
|
+
|
|
950
|
+
# During lifespan startup:
|
|
951
|
+
app.state.user_pool = SharedUserPool(db)
|
|
952
|
+
"""
|
|
953
|
+
require_role = manifest_auth.get("require_role")
|
|
954
|
+
public_routes = manifest_auth.get("public_routes", [])
|
|
955
|
+
auto_assign_default_role = manifest_auth.get("auto_assign_default_role", False)
|
|
956
|
+
default_role = manifest_auth.get("default_role")
|
|
957
|
+
|
|
958
|
+
# Security: Only allow auto-assignment if default_role matches require_role
|
|
959
|
+
if auto_assign_default_role and require_role and default_role != require_role:
|
|
960
|
+
logger.warning(
|
|
961
|
+
f"Security: auto_assign_default_role enabled but default_role '{default_role}' "
|
|
962
|
+
f"does not match require_role '{require_role}' for app '{app_slug}'. "
|
|
963
|
+
f"Auto-assignment disabled for security."
|
|
964
|
+
)
|
|
965
|
+
auto_assign_default_role = False
|
|
966
|
+
|
|
967
|
+
role_hierarchy = _build_role_hierarchy(manifest_auth)
|
|
968
|
+
session_binding = manifest_auth.get("session_binding", {})
|
|
969
|
+
|
|
970
|
+
return _create_lazy_middleware_class(
|
|
971
|
+
app_slug,
|
|
972
|
+
require_role,
|
|
973
|
+
public_routes,
|
|
974
|
+
role_hierarchy,
|
|
975
|
+
session_binding,
|
|
976
|
+
auto_assign_default_role,
|
|
977
|
+
)
|