mdb-engine 0.4.11__py3-none-any.whl → 0.4.14__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 +6 -7
- mdb_engine/core/engine.py +28 -5
- mdb_engine/routing/websockets.py +95 -55
- {mdb_engine-0.4.11.dist-info → mdb_engine-0.4.14.dist-info}/METADATA +1 -1
- {mdb_engine-0.4.11.dist-info → mdb_engine-0.4.14.dist-info}/RECORD +9 -9
- {mdb_engine-0.4.11.dist-info → mdb_engine-0.4.14.dist-info}/WHEEL +0 -0
- {mdb_engine-0.4.11.dist-info → mdb_engine-0.4.14.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.4.11.dist-info → mdb_engine-0.4.14.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.4.11.dist-info → mdb_engine-0.4.14.dist-info}/top_level.txt +0 -0
mdb_engine/__init__.py
CHANGED
|
@@ -82,13 +82,12 @@ from .repositories import Entity, MongoRepository, Repository, UnitOfWork
|
|
|
82
82
|
from .utils import clean_mongo_doc, clean_mongo_docs
|
|
83
83
|
|
|
84
84
|
__version__ = (
|
|
85
|
-
"0.4.
|
|
86
|
-
# -
|
|
87
|
-
# -
|
|
88
|
-
# -
|
|
89
|
-
# - Enhanced
|
|
90
|
-
# -
|
|
91
|
-
# - All WebSocket multi-app SSO features now work automatically
|
|
85
|
+
"0.4.14" # WebSocket security documentation and test updates
|
|
86
|
+
# - Comprehensive security guide (WEBSOCKET_SECURITY_MULTI_APP_SSO.md)
|
|
87
|
+
# - Updated all documentation to reflect subprotocol-only authentication
|
|
88
|
+
# - Added integration tests for subprotocol authentication in multi-app SSO
|
|
89
|
+
# - Enhanced unit test documentation with security-focused explanations
|
|
90
|
+
# - Complete test coverage for WebSocket security scenarios
|
|
92
91
|
)
|
|
93
92
|
|
|
94
93
|
__all__ = [
|
mdb_engine/core/engine.py
CHANGED
|
@@ -1499,8 +1499,9 @@ class MongoDBEngine:
|
|
|
1499
1499
|
|
|
1500
1500
|
# Add CSRF middleware (after auth - auto-enabled for shared mode)
|
|
1501
1501
|
# CSRF protection is enabled by default for shared auth mode
|
|
1502
|
+
# SKIP for sub-apps in multi-app setups - parent app handles CSRF
|
|
1502
1503
|
csrf_config = auth_config.get("csrf_protection", True if auth_mode == "shared" else False)
|
|
1503
|
-
if csrf_config:
|
|
1504
|
+
if csrf_config and not is_sub_app: # Don't add CSRF to child apps
|
|
1504
1505
|
from ..auth.csrf import create_csrf_middleware
|
|
1505
1506
|
|
|
1506
1507
|
csrf_middleware = create_csrf_middleware(
|
|
@@ -1508,6 +1509,11 @@ class MongoDBEngine:
|
|
|
1508
1509
|
)
|
|
1509
1510
|
app.add_middleware(csrf_middleware)
|
|
1510
1511
|
logger.info(f"CSRFMiddleware added for '{slug}'")
|
|
1512
|
+
elif csrf_config and is_sub_app:
|
|
1513
|
+
logger.debug(
|
|
1514
|
+
f"CSRFMiddleware skipped for child app '{slug}' - "
|
|
1515
|
+
f"parent app handles CSRF protection for WebSocket routes"
|
|
1516
|
+
)
|
|
1511
1517
|
|
|
1512
1518
|
# Add security middleware (HSTS, headers)
|
|
1513
1519
|
security_config = auth_config.get("security", {})
|
|
@@ -2127,17 +2133,33 @@ class MongoDBEngine:
|
|
|
2127
2133
|
"Path prefix validation failed:\n" + "\n".join(f" - {e}" for e in errors)
|
|
2128
2134
|
)
|
|
2129
2135
|
|
|
2130
|
-
# Check if any app uses shared auth
|
|
2136
|
+
# Check if any app uses shared auth and collect public routes for CSRF exemption
|
|
2131
2137
|
has_shared_auth = False
|
|
2138
|
+
all_public_routes = [
|
|
2139
|
+
"/health",
|
|
2140
|
+
"/docs",
|
|
2141
|
+
"/openapi.json",
|
|
2142
|
+
"/_mdb/routes",
|
|
2143
|
+
] # Base exempt routes
|
|
2132
2144
|
for app_config in apps:
|
|
2133
2145
|
try:
|
|
2134
2146
|
manifest_path = app_config["manifest"]
|
|
2147
|
+
path_prefix = app_config.get("path_prefix", f"/{app_config.get('slug')}")
|
|
2135
2148
|
with open(manifest_path) as f:
|
|
2136
2149
|
app_manifest_pre = json.load(f)
|
|
2137
2150
|
auth_config = app_manifest_pre.get("auth", {})
|
|
2138
2151
|
if auth_config.get("mode") == "shared":
|
|
2139
2152
|
has_shared_auth = True
|
|
2140
|
-
|
|
2153
|
+
# Collect public routes with path prefix for CSRF exemption
|
|
2154
|
+
child_public_routes = auth_config.get("public_routes", [])
|
|
2155
|
+
for route in child_public_routes:
|
|
2156
|
+
# Add path prefix to make route absolute on parent app
|
|
2157
|
+
if route.startswith("/"):
|
|
2158
|
+
prefixed_route = f"{path_prefix.rstrip('/')}{route}"
|
|
2159
|
+
else:
|
|
2160
|
+
prefixed_route = f"{path_prefix.rstrip('/')}/{route}"
|
|
2161
|
+
if prefixed_route not in all_public_routes:
|
|
2162
|
+
all_public_routes.append(prefixed_route)
|
|
2141
2163
|
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
|
2142
2164
|
logger.warning(f"Could not check auth mode for app '{app_config.get('slug')}': {e}")
|
|
2143
2165
|
|
|
@@ -2812,10 +2834,11 @@ class MongoDBEngine:
|
|
|
2812
2834
|
from ..auth.csrf import create_csrf_middleware
|
|
2813
2835
|
|
|
2814
2836
|
# Create CSRF middleware with default config (will use parent app's CORS config)
|
|
2815
|
-
# Exempt routes that don't need CSRF (health checks,
|
|
2837
|
+
# Exempt routes that don't need CSRF (health checks, public routes from child apps)
|
|
2838
|
+
# all_public_routes includes base routes + child app public routes with path prefixes
|
|
2816
2839
|
parent_csrf_config = {
|
|
2817
2840
|
"csrf_protection": True,
|
|
2818
|
-
"public_routes":
|
|
2841
|
+
"public_routes": all_public_routes,
|
|
2819
2842
|
}
|
|
2820
2843
|
csrf_middleware = create_csrf_middleware(parent_csrf_config)
|
|
2821
2844
|
parent_app.add_middleware(csrf_middleware)
|
mdb_engine/routing/websockets.py
CHANGED
|
@@ -311,10 +311,15 @@ async def authenticate_websocket(
|
|
|
311
311
|
websocket: Any, app_slug: str, require_auth: bool = True
|
|
312
312
|
) -> tuple[str | None, str | None]:
|
|
313
313
|
"""
|
|
314
|
-
Authenticate a WebSocket connection.
|
|
314
|
+
Authenticate a WebSocket connection via Sec-WebSocket-Protocol header.
|
|
315
|
+
|
|
316
|
+
Uses subprotocol tunneling to pass JWT tokens securely:
|
|
317
|
+
- Client: new WebSocket(url, [token])
|
|
318
|
+
- Server: Extracts token from sec-websocket-protocol header
|
|
319
|
+
- Bypasses CSRF issues and avoids URL logging risks
|
|
315
320
|
|
|
316
321
|
Args:
|
|
317
|
-
websocket: FastAPI WebSocket instance
|
|
322
|
+
websocket: FastAPI WebSocket instance (can access headers before accept)
|
|
318
323
|
app_slug: App slug for context
|
|
319
324
|
require_auth: Whether authentication is required
|
|
320
325
|
|
|
@@ -335,59 +340,57 @@ async def authenticate_websocket(
|
|
|
335
340
|
return None, None
|
|
336
341
|
|
|
337
342
|
try:
|
|
338
|
-
# Try to get token from query params or cookies
|
|
339
343
|
token = None
|
|
340
|
-
|
|
341
|
-
cookie_token = None
|
|
342
|
-
|
|
343
|
-
# Check query parameters
|
|
344
|
-
# FastAPI WebSocket query params are accessed via websocket.query_params
|
|
345
|
-
if hasattr(websocket, "query_params"):
|
|
346
|
-
if websocket.query_params:
|
|
347
|
-
query_token = websocket.query_params.get("token")
|
|
348
|
-
logger.info(
|
|
349
|
-
f"WebSocket query_params for app '{app_slug}': {dict(websocket.query_params)}"
|
|
350
|
-
)
|
|
351
|
-
if query_token:
|
|
352
|
-
token = query_token
|
|
353
|
-
logger.info(
|
|
354
|
-
f"WebSocket token found in query params for app "
|
|
355
|
-
f"'{app_slug}' (length: {len(query_token)})"
|
|
356
|
-
)
|
|
357
|
-
else:
|
|
358
|
-
logger.info(f"WebSocket query_params is empty for app '{app_slug}'")
|
|
359
|
-
else:
|
|
360
|
-
logger.warning(f"WebSocket has no query_params attribute for app '{app_slug}'")
|
|
344
|
+
selected_subprotocol = None
|
|
361
345
|
|
|
362
|
-
#
|
|
363
|
-
#
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
346
|
+
# Sec-WebSocket-Protocol (Subprotocol Tunneling)
|
|
347
|
+
# Browsers allow: new WebSocket(url, ["token", "THE_JWT_STRING"])
|
|
348
|
+
# This bypasses CSRF issues and avoids URL logging risks
|
|
349
|
+
try:
|
|
350
|
+
protocol_header = None
|
|
351
|
+
# Try multiple ways to access headers (FastAPI WebSocket compatibility)
|
|
352
|
+
if hasattr(websocket, "headers") and websocket.headers is not None:
|
|
353
|
+
# FastAPI WebSocket.headers is case-insensitive dict-like
|
|
354
|
+
protocol_header = websocket.headers.get("sec-websocket-protocol")
|
|
355
|
+
|
|
356
|
+
# Fallback: access via scope (ASGI standard) if headers not available
|
|
357
|
+
if not protocol_header and hasattr(websocket, "scope") and "headers" in websocket.scope:
|
|
358
|
+
headers_dict = dict(websocket.scope["headers"])
|
|
359
|
+
# Headers are bytes in ASGI, need to decode
|
|
360
|
+
protocol_header_bytes = headers_dict.get(b"sec-websocket-protocol")
|
|
361
|
+
if protocol_header_bytes:
|
|
362
|
+
protocol_header = protocol_header_bytes.decode("utf-8")
|
|
363
|
+
|
|
364
|
+
if protocol_header:
|
|
365
|
+
# Header format: "protocol1, protocol2, ..." or just "token"
|
|
366
|
+
protocols = [p.strip() for p in protocol_header.split(",")]
|
|
367
|
+
logger.debug(f"WebSocket subprotocols for app '{app_slug}': {protocols}")
|
|
368
|
+
|
|
369
|
+
# Look for a JWT-like token in the protocols
|
|
370
|
+
# JWTs are typically long (>20 chars) and don't contain spaces
|
|
371
|
+
for protocol in protocols:
|
|
372
|
+
if len(protocol) > 20 and " " not in protocol:
|
|
373
|
+
token = protocol
|
|
374
|
+
selected_subprotocol = protocol
|
|
375
|
+
logger.info(
|
|
376
|
+
f"WebSocket token found in subprotocol for app '{app_slug}' "
|
|
377
|
+
f"(length: {len(protocol)})"
|
|
378
|
+
)
|
|
379
|
+
break
|
|
380
|
+
except (AttributeError, TypeError, KeyError, UnicodeDecodeError) as e:
|
|
381
|
+
logger.debug(f"Could not access WebSocket headers for subprotocol check: {e}")
|
|
379
382
|
|
|
380
383
|
if not token:
|
|
381
384
|
logger.warning(
|
|
382
|
-
f"No token found for WebSocket connection to app
|
|
383
|
-
f"(require_auth={require_auth})"
|
|
385
|
+
f"No token found in subprotocol header for WebSocket connection to app "
|
|
386
|
+
f"'{app_slug}' (require_auth={require_auth}). "
|
|
387
|
+
f"Use: new WebSocket(url, [token]) to pass JWT token as subprotocol."
|
|
384
388
|
)
|
|
385
389
|
if require_auth:
|
|
386
|
-
# Don't close before accepting - return error info instead
|
|
387
|
-
# The caller will handle closing after accept
|
|
388
390
|
return None, None # Signal auth failure
|
|
389
391
|
return None, None
|
|
390
392
|
|
|
393
|
+
# Decode and validate token
|
|
391
394
|
import jwt
|
|
392
395
|
|
|
393
396
|
from ..auth.dependencies import SECRET_KEY
|
|
@@ -398,6 +401,13 @@ async def authenticate_websocket(
|
|
|
398
401
|
user_id = payload.get("sub") or payload.get("user_id")
|
|
399
402
|
user_email = payload.get("email")
|
|
400
403
|
|
|
404
|
+
# Store selected subprotocol on websocket scope for accept() to use
|
|
405
|
+
if selected_subprotocol:
|
|
406
|
+
# Store in scope so _accept_websocket_connection can access it
|
|
407
|
+
if hasattr(websocket, "scope"):
|
|
408
|
+
websocket.scope["_selected_subprotocol"] = selected_subprotocol
|
|
409
|
+
logger.debug(f"Stored subprotocol '{selected_subprotocol}' for WebSocket accept")
|
|
410
|
+
|
|
401
411
|
logger.info(f"WebSocket authenticated successfully for app '{app_slug}': {user_email}")
|
|
402
412
|
return user_id, user_email
|
|
403
413
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError) as decode_error:
|
|
@@ -409,7 +419,6 @@ async def authenticate_websocket(
|
|
|
409
419
|
except (ValueError, TypeError, AttributeError, KeyError, RuntimeError) as e:
|
|
410
420
|
logger.error(f"WebSocket authentication failed for app '{app_slug}': {e}", exc_info=True)
|
|
411
421
|
if require_auth:
|
|
412
|
-
# Don't close before accepting - return error info instead
|
|
413
422
|
return None, None # Signal auth failure
|
|
414
423
|
return None, None
|
|
415
424
|
|
|
@@ -470,11 +479,32 @@ def get_message_handler(
|
|
|
470
479
|
|
|
471
480
|
|
|
472
481
|
async def _accept_websocket_connection(websocket: Any, app_slug: str) -> None:
|
|
473
|
-
"""
|
|
482
|
+
"""
|
|
483
|
+
Accept WebSocket connection with subprotocol support.
|
|
484
|
+
|
|
485
|
+
If authentication found a token in the Sec-WebSocket-Protocol header,
|
|
486
|
+
we must accept with that specific subprotocol or the browser will reject
|
|
487
|
+
the connection.
|
|
488
|
+
"""
|
|
474
489
|
try:
|
|
475
|
-
|
|
490
|
+
# Check if auth stored a selected subprotocol
|
|
491
|
+
selected_subprotocol = None
|
|
492
|
+
if hasattr(websocket, "scope"):
|
|
493
|
+
selected_subprotocol = websocket.scope.get("_selected_subprotocol")
|
|
494
|
+
|
|
495
|
+
if selected_subprotocol:
|
|
496
|
+
# Accept with the specific subprotocol the client requested
|
|
497
|
+
await websocket.accept(subprotocol=selected_subprotocol)
|
|
498
|
+
logger.info(
|
|
499
|
+
f"✅ WebSocket accepted for app '{app_slug}' "
|
|
500
|
+
f"with subprotocol '{selected_subprotocol}'"
|
|
501
|
+
)
|
|
502
|
+
else:
|
|
503
|
+
# Standard accept without subprotocol
|
|
504
|
+
await websocket.accept()
|
|
505
|
+
logger.info(f"✅ WebSocket accepted for app '{app_slug}'")
|
|
506
|
+
|
|
476
507
|
print(f"✅ [WEBSOCKET ACCEPTED] App: '{app_slug}'")
|
|
477
|
-
logger.info(f"✅ WebSocket accepted for app '{app_slug}'")
|
|
478
508
|
except (RuntimeError, ConnectionError, OSError) as accept_error:
|
|
479
509
|
print(f"❌ [WEBSOCKET ACCEPT FAILED] App: '{app_slug}', Error: {accept_error}")
|
|
480
510
|
logger.error(
|
|
@@ -654,13 +684,23 @@ def create_websocket_endpoint(
|
|
|
654
684
|
f"(require_auth={require_auth}, query_params={query_str})"
|
|
655
685
|
)
|
|
656
686
|
|
|
657
|
-
#
|
|
658
|
-
|
|
687
|
+
# CRITICAL: Authenticate BEFORE accepting connection
|
|
688
|
+
# This prevents CSRF middleware from rejecting established connections
|
|
689
|
+
# We can access headers/query_params before accept() is called
|
|
690
|
+
user_id, user_email = await authenticate_websocket(websocket, app_slug, require_auth)
|
|
659
691
|
|
|
660
|
-
#
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
692
|
+
# Handle authentication failure
|
|
693
|
+
if require_auth and not user_id:
|
|
694
|
+
logger.warning(
|
|
695
|
+
f"WebSocket authentication failed for app '{app_slug}' - rejecting connection"
|
|
696
|
+
)
|
|
697
|
+
# Reject without accepting - FastAPI will send 403 if accept() not called
|
|
698
|
+
# We can't call websocket.close() before accept(), so we just return
|
|
699
|
+
# The connection will be rejected by the server
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
# Accept connection (with subprotocol if token was in protocol header)
|
|
703
|
+
await _accept_websocket_connection(websocket, app_slug)
|
|
664
704
|
|
|
665
705
|
# Connect with metadata (websocket already accepted)
|
|
666
706
|
connection = await manager.connect(websocket, user_id=user_id, user_email=user_email)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
mdb_engine/README.md,sha256=T3EFGcPopY9LslYW3lxgG3hohWkAOmBNbYG0FDMUJiY,3502
|
|
2
|
-
mdb_engine/__init__.py,sha256=
|
|
2
|
+
mdb_engine/__init__.py,sha256=wfZJ6njS5_immp5ErJ2BzevHJUeZtkXG_xytdni0mH0,3531
|
|
3
3
|
mdb_engine/config.py,sha256=DTAyxfKB8ogyI0v5QR9Y-SJOgXQr_eDBCKxNBSqEyLc,7269
|
|
4
4
|
mdb_engine/constants.py,sha256=eaotvW57TVOg7rRbLziGrVNoP7adgw_G9iVByHezc_A,7837
|
|
5
5
|
mdb_engine/dependencies.py,sha256=MJuYQhZ9ZGzXlip1ha5zba9Rvn04HDPWahJFJH81Q2s,14107
|
|
@@ -46,7 +46,7 @@ mdb_engine/core/app_registration.py,sha256=7szt2a7aBkpSppjmhdkkPPYMKGKo0MkLKZeEe
|
|
|
46
46
|
mdb_engine/core/app_secrets.py,sha256=bo-syg9UUATibNyXEZs-0TTYWG-JaY-2S0yNSGA12n0,10524
|
|
47
47
|
mdb_engine/core/connection.py,sha256=XnwuPG34pJ7kJGJ84T0mhj1UZ6_CLz_9qZf6NRYGIS8,8346
|
|
48
48
|
mdb_engine/core/encryption.py,sha256=RZ5LPF5g28E3ZBn6v1IMw_oas7u9YGFtBcEj8lTi9LM,7515
|
|
49
|
-
mdb_engine/core/engine.py,sha256=
|
|
49
|
+
mdb_engine/core/engine.py,sha256=ydYt2z53scf4GzufD8rY5X6gSJEF80VSDm1QDR2pwWQ,151507
|
|
50
50
|
mdb_engine/core/index_management.py,sha256=9-r7MIy3JnjQ35sGqsbj8K_I07vAUWtAVgSWC99lJcE,5555
|
|
51
51
|
mdb_engine/core/manifest.py,sha256=jguhjVPAHMZGxOJcdSGouv9_XiKmxUjDmyjn2yXHCj4,139205
|
|
52
52
|
mdb_engine/core/ray_integration.py,sha256=vexYOzztscvRYje1xTNmXJbi99oJxCaVJAwKfTNTF_E,13610
|
|
@@ -86,12 +86,12 @@ mdb_engine/repositories/mongo.py,sha256=Wg32_6v0KHAHumhz5z8QkoqJRWAMJFA7Y2lYIJ7L
|
|
|
86
86
|
mdb_engine/repositories/unit_of_work.py,sha256=XvmwGOspEDj4hsfOULPsQKjB1QZqh83TJo6vGV4tiqU,5118
|
|
87
87
|
mdb_engine/routing/README.md,sha256=WVvTQXDq0amryrjkCu0wP_piOEwFjLukjmPz2mroWHY,13658
|
|
88
88
|
mdb_engine/routing/__init__.py,sha256=reupjHi_RTc2ZBA4AH5XzobAmqy4EQIsfSUcTkFknUM,2438
|
|
89
|
-
mdb_engine/routing/websockets.py,sha256=
|
|
89
|
+
mdb_engine/routing/websockets.py,sha256=vMpfnGzMrMvqF2ZBwWazS-8cL73nwO1rHoR2XZJTI9k,31667
|
|
90
90
|
mdb_engine/utils/__init__.py,sha256=lDxQSGqkV4fVw5TWIk6FA6_eey_ZnEtMY0fir3cpAe8,236
|
|
91
91
|
mdb_engine/utils/mongo.py,sha256=Oqtv4tQdpiiZzrilGLEYQPo8Vmh8WsTQypxQs8Of53s,3369
|
|
92
|
-
mdb_engine-0.4.
|
|
93
|
-
mdb_engine-0.4.
|
|
94
|
-
mdb_engine-0.4.
|
|
95
|
-
mdb_engine-0.4.
|
|
96
|
-
mdb_engine-0.4.
|
|
97
|
-
mdb_engine-0.4.
|
|
92
|
+
mdb_engine-0.4.14.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
93
|
+
mdb_engine-0.4.14.dist-info/METADATA,sha256=e6T3Bsq0hTdDSLDQHwb5MyJpsL5-HY2pP9iYev5oAm0,15811
|
|
94
|
+
mdb_engine-0.4.14.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
95
|
+
mdb_engine-0.4.14.dist-info/entry_points.txt,sha256=INCbYdFbBzJalwPwxliEzLmPfR57IvQ7RAXG_pn8cL8,48
|
|
96
|
+
mdb_engine-0.4.14.dist-info/top_level.txt,sha256=PH0UEBwTtgkm2vWvC9He_EOMn7hVn_Wg_Jyc0SmeO8k,11
|
|
97
|
+
mdb_engine-0.4.14.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|