mdb-engine 0.4.12__py3-none-any.whl → 0.5.0__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 CHANGED
@@ -82,11 +82,15 @@ 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.12" # Fix CSRF middleware rejecting WebSocket connections
86
- # - Skip CSRF middleware on child apps in multi-app setups (parent handles it)
87
- # - Merge child app public routes into parent CSRF exempt list
88
- # - WebSocket connections now work correctly in multi-app SSO setups
89
- # - Security maintained: parent app CSRF middleware protects all routes
85
+ "0.5.0" # Major WebSocket security overhaul - cookie-based authentication
86
+ # - BREAKING CHANGE: Removed subprotocol tunneling support
87
+ # - NEW: Exclusive httpOnly cookie-based WebSocket authentication
88
+ # - Comprehensive security guide (WEBSOCKET_SECURITY_MULTI_APP_SSO.md)
89
+ # - Updated all documentation to reflect cookie-based authentication
90
+ # - Enhanced CSRF protection for WebSocket upgrades
91
+ # - Added integration tests for cookie-based WebSocket authentication
92
+ # - Complete test coverage for WebSocket security scenarios
93
+ # - Multi-app SSO compatibility with path="/" cookies
90
94
  )
91
95
 
92
96
  __all__ = [
@@ -116,8 +116,14 @@ def set_auth_cookies(
116
116
  refresh_token_ttl = 604800 # Default 7 days
117
117
 
118
118
  # Set access token cookie
119
+ # CRITICAL: path="/" ensures cookie is available to all mounted sub-apps
120
+ # Without explicit path, cookies default to request path and won't work with app.mount()
119
121
  response.set_cookie(
120
- key="token", value=access_token, max_age=access_token_ttl, **cookie_settings
122
+ key="token",
123
+ value=access_token,
124
+ max_age=access_token_ttl,
125
+ path="/", # Required for FastAPI mounted apps
126
+ **cookie_settings,
121
127
  )
122
128
 
123
129
  # Set refresh token cookie if provided
@@ -126,6 +132,7 @@ def set_auth_cookies(
126
132
  key="refresh_token",
127
133
  value=refresh_token,
128
134
  max_age=refresh_token_ttl,
135
+ path="/", # Required for FastAPI mounted apps
129
136
  **cookie_settings,
130
137
  )
131
138
 
@@ -148,7 +155,10 @@ def clear_auth_cookies(response, request: Request | None = None):
148
155
  secure = os.getenv("G_NOME_ENV") == "production"
149
156
 
150
157
  # Delete access token cookie
151
- response.delete_cookie(key="token", httponly=True, secure=secure, samesite=samesite)
158
+ # CRITICAL: path="/" ensures cookie deletion works across all mounted sub-apps
159
+ response.delete_cookie(key="token", httponly=True, secure=secure, samesite=samesite, path="/")
152
160
 
153
161
  # Delete refresh token cookie
154
- response.delete_cookie(key="refresh_token", httponly=True, secure=secure, samesite=samesite)
162
+ response.delete_cookie(
163
+ key="refresh_token", httponly=True, secure=secure, samesite=samesite, path="/"
164
+ )
mdb_engine/auth/csrf.py CHANGED
@@ -299,9 +299,9 @@ class CSRFMiddleware(BaseHTTPMiddleware):
299
299
  method = request.method
300
300
 
301
301
  # CRITICAL: Handle WebSocket upgrade requests BEFORE other CSRF checks
302
- # WebSocket upgrades don't use CSRF tokens, but need origin validation
302
+ # WebSocket upgrades use cookie-based authentication and require CSRF validation
303
303
  if self._is_websocket_upgrade(request):
304
- # Validate origin for WebSocket connections (CSWSH protection)
304
+ # Always validate origin for WebSocket connections (CSWSH protection)
305
305
  if not self._validate_websocket_origin(request):
306
306
  logger.warning(
307
307
  f"WebSocket origin validation failed for {path}: "
@@ -312,8 +312,58 @@ class CSRFMiddleware(BaseHTTPMiddleware):
312
312
  status_code=status.HTTP_403_FORBIDDEN,
313
313
  content={"detail": "Invalid origin for WebSocket connection"},
314
314
  )
315
- # Origin validated - allow WebSocket upgrade to proceed
316
- # No CSRF token check needed for WebSocket upgrades
315
+
316
+ # Cookie-based authentication requires CSRF protection
317
+ # Check if authentication token cookie is present
318
+ auth_token_cookie = request.cookies.get("token")
319
+ if auth_token_cookie:
320
+ # For WebSocket upgrades, CSRF protection relies on:
321
+ # 1. Origin validation (already done above) - primary defense
322
+ # 2. SameSite cookies - prevents cross-site cookie sending
323
+ # 3. CSRF cookie presence - ensures session is established
324
+ #
325
+ # Note: JavaScript WebSocket API cannot set custom headers,
326
+ # so we cannot use double-submit cookie pattern (cookie + header).
327
+ # Instead, we rely on Origin validation + SameSite cookies for CSRF protection.
328
+ csrf_cookie_token = request.cookies.get(self.cookie_name)
329
+ if not csrf_cookie_token:
330
+ logger.warning(f"WebSocket upgrade missing CSRF cookie for {path}")
331
+ return JSONResponse(
332
+ status_code=status.HTTP_403_FORBIDDEN,
333
+ content={"detail": "CSRF token missing for WebSocket authentication"},
334
+ )
335
+
336
+ # Validate CSRF token signature if secret is used
337
+ if self.secret and not validate_csrf_token(
338
+ csrf_cookie_token, self.secret, self.token_ttl
339
+ ):
340
+ logger.warning(f"WebSocket CSRF token validation failed for {path}")
341
+ return JSONResponse(
342
+ status_code=status.HTTP_403_FORBIDDEN,
343
+ content={
344
+ "detail": "CSRF token expired or invalid for WebSocket connection"
345
+ },
346
+ )
347
+
348
+ # Optional: If CSRF header is provided, validate it matches cookie
349
+ # (Some clients may send it, but it's not required for WebSocket upgrades)
350
+ header_token = request.headers.get(self.header_name)
351
+ if header_token:
352
+ # If header is provided, validate it matches cookie (double-submit pattern)
353
+ if not hmac.compare_digest(csrf_cookie_token, header_token):
354
+ logger.warning(f"WebSocket CSRF token mismatch for {path}")
355
+ return JSONResponse(
356
+ status_code=status.HTTP_403_FORBIDDEN,
357
+ content={"detail": "CSRF token invalid for WebSocket connection"},
358
+ )
359
+
360
+ logger.debug(
361
+ f"WebSocket upgrade CSRF validation passed for {path} "
362
+ f"(Origin validated, CSRF cookie present)"
363
+ )
364
+
365
+ # Origin validated (and CSRF validated if authenticated)
366
+ # Allow WebSocket upgrade to proceed
317
367
  return await call_next(request)
318
368
 
319
369
  if self._is_exempt(path):
@@ -307,14 +307,73 @@ def get_websocket_manager_sync(app_slug: str) -> WebSocketConnectionManager:
307
307
  return _websocket_managers[app_slug]
308
308
 
309
309
 
310
+ def _get_cookies_from_websocket(websocket: Any) -> dict[str, str]:
311
+ """
312
+ Extract cookies from WebSocket request.
313
+
314
+ Supports both FastAPI WebSocket .cookies attribute and ASGI scope Cookie header.
315
+
316
+ Args:
317
+ websocket: FastAPI WebSocket instance
318
+
319
+ Returns:
320
+ Dictionary of cookie name -> cookie value
321
+ """
322
+ cookies: dict[str, str] = {}
323
+
324
+ try:
325
+ # Try FastAPI WebSocket .cookies attribute (if available)
326
+ if hasattr(websocket, "cookies") and websocket.cookies is not None:
327
+ # FastAPI WebSocket.cookies is a dict-like object
328
+ cookies = dict(websocket.cookies)
329
+ logger.debug(f"Extracted {len(cookies)} cookies from WebSocket.cookies attribute")
330
+ return cookies
331
+ except (AttributeError, TypeError) as e:
332
+ logger.debug(f"Could not access WebSocket.cookies attribute: {e}")
333
+
334
+ try:
335
+ # Fallback: Extract from Cookie header in ASGI scope
336
+ if hasattr(websocket, "scope") and "headers" in websocket.scope:
337
+ headers_dict = dict(websocket.scope["headers"])
338
+ cookie_header_bytes = headers_dict.get(b"cookie")
339
+ if not cookie_header_bytes:
340
+ # Try case-insensitive lookup
341
+ for key, value in headers_dict.items():
342
+ if isinstance(key, bytes) and key.lower() == b"cookie":
343
+ cookie_header_bytes = value
344
+ break
345
+
346
+ if cookie_header_bytes:
347
+ cookie_header = cookie_header_bytes.decode("utf-8")
348
+ # Parse cookie string: "name1=value1; name2=value2"
349
+ for cookie_pair in cookie_header.split(";"):
350
+ cookie_pair = cookie_pair.strip()
351
+ if "=" in cookie_pair:
352
+ name, value = cookie_pair.split("=", 1)
353
+ cookies[name.strip()] = value.strip()
354
+ logger.debug(f"Extracted {len(cookies)} cookies from Cookie header in ASGI scope")
355
+ return cookies
356
+ except (AttributeError, TypeError, KeyError, UnicodeDecodeError, ValueError) as e:
357
+ logger.debug(f"Could not extract cookies from ASGI scope: {e}")
358
+
359
+ return cookies
360
+
361
+
310
362
  async def authenticate_websocket(
311
- websocket: Any, app_slug: str, require_auth: bool = True
363
+ websocket: Any,
364
+ app_slug: str,
365
+ require_auth: bool = True,
312
366
  ) -> tuple[str | None, str | None]:
313
367
  """
314
- Authenticate a WebSocket connection.
368
+ Authenticate a WebSocket connection via httpOnly cookies.
369
+
370
+ Uses cookie-based authentication with CSRF protection:
371
+ - Token stored in httpOnly cookie (not accessible to JavaScript)
372
+ - CSRF token validated via double-submit cookie pattern
373
+ - Origin validation provides additional protection
315
374
 
316
375
  Args:
317
- websocket: FastAPI WebSocket instance
376
+ websocket: FastAPI WebSocket instance (can access headers before accept)
318
377
  app_slug: App slug for context
319
378
  require_auth: Whether authentication is required
320
379
 
@@ -335,59 +394,25 @@ async def authenticate_websocket(
335
394
  return None, None
336
395
 
337
396
  try:
338
- # Try to get token from query params or cookies
339
- token = None
340
- query_token = None
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}'")
361
-
362
- # If no token in query, try to get from cookies (if available)
363
- # Check both ws_token (non-httponly, for JS access) and token (httponly)
364
- if not token:
365
- if hasattr(websocket, "cookies"):
366
- cookie_token = websocket.cookies.get("ws_token") or websocket.cookies.get("token")
367
- if cookie_token:
368
- token = cookie_token
369
- logger.debug(f"WebSocket token found in cookies for app '{app_slug}'")
370
- else:
371
- logger.debug(f"WebSocket has no cookies attribute for app '{app_slug}'")
372
-
373
- logger.info(
374
- f"WebSocket auth check for app '{app_slug}': "
375
- f"query_token={bool(query_token)}, "
376
- f"cookie_token={bool(cookie_token)}, "
377
- f"final_token={bool(token)}, require_auth={require_auth}"
378
- )
397
+ # Extract token from httpOnly cookie
398
+ cookies = _get_cookies_from_websocket(websocket)
399
+ token = cookies.get("token") # Standard auth token cookie name
379
400
 
380
401
  if not token:
381
402
  logger.warning(
382
- f"No token found for WebSocket connection to app '{app_slug}' "
383
- f"(require_auth={require_auth})"
403
+ f"No token cookie found for WebSocket connection to app '{app_slug}' "
404
+ f"(require_auth={require_auth}). "
405
+ f"Ensure httpOnly cookie is set during authentication."
384
406
  )
385
407
  if require_auth:
386
- # Don't close before accepting - return error info instead
387
- # The caller will handle closing after accept
388
408
  return None, None # Signal auth failure
389
409
  return None, None
390
410
 
411
+ logger.info(
412
+ f"WebSocket token found in cookie for app '{app_slug}' " "(cookie-based authentication)"
413
+ )
414
+
415
+ # Decode and validate token
391
416
  import jwt
392
417
 
393
418
  from ..auth.dependencies import SECRET_KEY
@@ -398,7 +423,10 @@ async def authenticate_websocket(
398
423
  user_id = payload.get("sub") or payload.get("user_id")
399
424
  user_email = payload.get("email")
400
425
 
401
- logger.info(f"WebSocket authenticated successfully for app '{app_slug}': {user_email}")
426
+ logger.info(
427
+ f"WebSocket authenticated successfully for app '{app_slug}': {user_email} "
428
+ f"(method: cookie)"
429
+ )
402
430
  return user_id, user_email
403
431
  except (jwt.ExpiredSignatureError, jwt.InvalidTokenError) as decode_error:
404
432
  logger.error(f"JWT decode error for app '{app_slug}': {decode_error}", exc_info=True)
@@ -409,7 +437,6 @@ async def authenticate_websocket(
409
437
  except (ValueError, TypeError, AttributeError, KeyError, RuntimeError) as e:
410
438
  logger.error(f"WebSocket authentication failed for app '{app_slug}': {e}", exc_info=True)
411
439
  if require_auth:
412
- # Don't close before accepting - return error info instead
413
440
  return None, None # Signal auth failure
414
441
  return None, None
415
442
 
@@ -470,11 +497,16 @@ def get_message_handler(
470
497
 
471
498
 
472
499
  async def _accept_websocket_connection(websocket: Any, app_slug: str) -> None:
473
- """Accept WebSocket connection with error handling."""
500
+ """
501
+ Accept WebSocket connection.
502
+
503
+ Cookie-based authentication uses httpOnly cookies automatically sent by the browser.
504
+ """
474
505
  try:
475
506
  await websocket.accept()
476
- print(f"✅ [WEBSOCKET ACCEPTED] App: '{app_slug}'")
477
507
  logger.info(f"✅ WebSocket accepted for app '{app_slug}'")
508
+
509
+ print(f"✅ [WEBSOCKET ACCEPTED] App: '{app_slug}'")
478
510
  except (RuntimeError, ConnectionError, OSError) as accept_error:
479
511
  print(f"❌ [WEBSOCKET ACCEPT FAILED] App: '{app_slug}', Error: {accept_error}")
480
512
  logger.error(
@@ -654,13 +686,23 @@ def create_websocket_endpoint(
654
686
  f"(require_auth={require_auth}, query_params={query_str})"
655
687
  )
656
688
 
657
- # Accept connection FIRST (required before we can do anything)
658
- await _accept_websocket_connection(websocket, app_slug)
689
+ # CRITICAL: Authenticate BEFORE accepting connection
690
+ # This prevents CSRF middleware from rejecting established connections
691
+ # We can access headers/query_params before accept() is called
692
+ user_id, user_email = await authenticate_websocket(websocket, app_slug, require_auth)
659
693
 
660
- # Authenticate connection (after accept, so we can close properly if needed)
661
- user_id, user_email = await _authenticate_websocket_connection(
662
- websocket, app_slug, require_auth
663
- )
694
+ # Handle authentication failure
695
+ if require_auth and not user_id:
696
+ logger.warning(
697
+ f"WebSocket authentication failed for app '{app_slug}' - rejecting connection"
698
+ )
699
+ # Reject without accepting - FastAPI will send 403 if accept() not called
700
+ # We can't call websocket.close() before accept(), so we just return
701
+ # The connection will be rejected by the server
702
+ return
703
+
704
+ # Accept connection
705
+ await _accept_websocket_connection(websocket, app_slug)
664
706
 
665
707
  # Connect with metadata (websocket already accepted)
666
708
  connection = await manager.connect(websocket, user_id=user_id, user_email=user_email)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdb-engine
3
- Version: 0.4.12
3
+ Version: 0.5.0
4
4
  Summary: MongoDB Engine
5
5
  Home-page: https://github.com/ranfysvalle02/mdb-engine
6
6
  Author: Fabian Valle
@@ -1,5 +1,5 @@
1
1
  mdb_engine/README.md,sha256=T3EFGcPopY9LslYW3lxgG3hohWkAOmBNbYG0FDMUJiY,3502
2
- mdb_engine/__init__.py,sha256=Egwc1COlKqmKB-qVUHAjWfJFmEg6iYT4DKE6m5hgpso,3460
2
+ mdb_engine/__init__.py,sha256=Uz23q_g7sJjuMs5TzIZbS34wMO0oAtS1SWa6cqBdhqw,3705
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
@@ -13,8 +13,8 @@ mdb_engine/auth/casbin_factory.py,sha256=oZLHIPyVA1hiho__ledRVDoBisaZgo96WZIKa0-
13
13
  mdb_engine/auth/casbin_models.py,sha256=7XtFmRBhhjw1nKprnluvjyJoTj5fzdPeQwVvo6fI-r0,955
14
14
  mdb_engine/auth/config_defaults.py,sha256=1YI_hIHuTiEXpkEYMcufNHdLr1oxPiJylg3CKrJCSGY,2012
15
15
  mdb_engine/auth/config_helpers.py,sha256=Qharb2YagLOKDGtE7XhYRDbBoQ_KGykrcIKrsOwWIJ4,6303
16
- mdb_engine/auth/cookie_utils.py,sha256=j04qXq5GiJrnnJUAP5Z_N1CAFbx9CZiyF5u9xIiQ3vo,4876
17
- mdb_engine/auth/csrf.py,sha256=7zLiX9IEvgBXp3DjVBmcaYATS6cTelQReUYYkiVas6M,17626
16
+ mdb_engine/auth/cookie_utils.py,sha256=glsSocSmy-_wRTLro0xy17s84oBk3HPDPL-FVXl7Rv8,5302
17
+ mdb_engine/auth/csrf.py,sha256=1MQuLI1gtWtm6ce0NhARRkD7bk6JfUaLUR0YCL7RL4Q,20393
18
18
  mdb_engine/auth/decorators.py,sha256=LkVVEuRrT0Iz8EwctN14BEi3fSV-xtN6DaGXgtbiYYo,12287
19
19
  mdb_engine/auth/dependencies.py,sha256=JB1iYvZJgTR6gcaiGe_GJFCS6NdUKMxWBZRv6vVxnzw,27112
20
20
  mdb_engine/auth/helpers.py,sha256=BCrid985cYh-3h5ZMUV9TES0q40uJXio4oYKQZta7KA,1970
@@ -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=3X4OjQv_Nln4UmeifJky0gFhMG8A6alR77I8g1iIOLY,29311
89
+ mdb_engine/routing/websockets.py,sha256=WBdJui0VMi5n30suXa8RPGkGgeHON4x3FyjbdsqHudY,30860
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.12.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
93
- mdb_engine-0.4.12.dist-info/METADATA,sha256=Jrn7XyZ1ZgCzyN5F5oVDXbo4xngGUCEUjPCW6dvYJyc,15811
94
- mdb_engine-0.4.12.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
95
- mdb_engine-0.4.12.dist-info/entry_points.txt,sha256=INCbYdFbBzJalwPwxliEzLmPfR57IvQ7RAXG_pn8cL8,48
96
- mdb_engine-0.4.12.dist-info/top_level.txt,sha256=PH0UEBwTtgkm2vWvC9He_EOMn7hVn_Wg_Jyc0SmeO8k,11
97
- mdb_engine-0.4.12.dist-info/RECORD,,
92
+ mdb_engine-0.5.0.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
93
+ mdb_engine-0.5.0.dist-info/METADATA,sha256=15KsimjJFGd_mmGzVJG9S4PIV-gI9m4yHuGmqAev7dM,15810
94
+ mdb_engine-0.5.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
95
+ mdb_engine-0.5.0.dist-info/entry_points.txt,sha256=INCbYdFbBzJalwPwxliEzLmPfR57IvQ7RAXG_pn8cL8,48
96
+ mdb_engine-0.5.0.dist-info/top_level.txt,sha256=PH0UEBwTtgkm2vWvC9He_EOMn7hVn_Wg_Jyc0SmeO8k,11
97
+ mdb_engine-0.5.0.dist-info/RECORD,,