mdb-engine 0.4.14__tar.gz → 0.5.1__tar.gz
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-0.4.14/mdb_engine.egg-info → mdb_engine-0.5.1}/PKG-INFO +1 -1
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/__init__.py +7 -4
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/cookie_utils.py +13 -3
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/csrf.py +57 -4
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/routing/websockets.py +104 -81
- {mdb_engine-0.4.14 → mdb_engine-0.5.1/mdb_engine.egg-info}/PKG-INFO +1 -1
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/pyproject.toml +1 -1
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/setup.py +1 -1
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/LICENSE +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/MANIFEST.in +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/README.md +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/README.md +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/ARCHITECTURE.md +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/README.md +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/audit.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/base.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/casbin_factory.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/casbin_models.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/config_defaults.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/config_helpers.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/decorators.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/dependencies.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/helpers.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/integration.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/jwt.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/middleware.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/oso_factory.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/provider.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/rate_limiter.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/restrictions.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/session_manager.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/shared_middleware.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/shared_users.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/token_lifecycle.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/token_store.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/users.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/auth/utils.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/cli/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/cli/commands/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/cli/commands/generate.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/cli/commands/migrate.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/cli/commands/show.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/cli/commands/validate.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/cli/main.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/cli/utils.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/config.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/constants.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/README.md +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/app_registration.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/app_secrets.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/connection.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/encryption.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/engine.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/index_management.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/manifest.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/ray_integration.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/seeding.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/service_initialization.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/core/types.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/database/README.md +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/database/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/database/abstraction.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/database/connection.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/database/query_validator.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/database/resource_limiter.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/database/scoped_wrapper.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/dependencies.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/di/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/di/container.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/di/providers.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/di/scopes.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/embeddings/README.md +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/embeddings/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/embeddings/dependencies.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/embeddings/service.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/exceptions.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/indexes/README.md +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/indexes/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/indexes/helpers.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/indexes/manager.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/memory/README.md +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/memory/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/memory/service.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/observability/README.md +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/observability/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/observability/health.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/observability/logging.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/observability/metrics.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/repositories/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/repositories/base.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/repositories/mongo.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/repositories/unit_of_work.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/routing/README.md +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/routing/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/utils/__init__.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine/utils/mongo.py +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine.egg-info/SOURCES.txt +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine.egg-info/dependency_links.txt +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine.egg-info/entry_points.txt +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine.egg-info/requires.txt +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/mdb_engine.egg-info/top_level.txt +0 -0
- {mdb_engine-0.4.14 → mdb_engine-0.5.1}/setup.cfg +0 -0
|
@@ -82,12 +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.
|
|
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
|
|
86
88
|
# - Comprehensive security guide (WEBSOCKET_SECURITY_MULTI_APP_SSO.md)
|
|
87
|
-
# - Updated all documentation to reflect
|
|
88
|
-
# -
|
|
89
|
-
# -
|
|
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
|
|
90
92
|
# - Complete test coverage for WebSocket security scenarios
|
|
93
|
+
# - Multi-app SSO compatibility with path="/" cookies
|
|
91
94
|
)
|
|
92
95
|
|
|
93
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",
|
|
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
|
-
|
|
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(
|
|
162
|
+
response.delete_cookie(
|
|
163
|
+
key="refresh_token", httponly=True, secure=secure, samesite=samesite, path="/"
|
|
164
|
+
)
|
|
@@ -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
|
|
302
|
+
# WebSocket upgrades use cookie-based authentication and require CSRF validation
|
|
303
303
|
if self._is_websocket_upgrade(request):
|
|
304
|
-
#
|
|
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,61 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|
|
312
312
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
313
313
|
content={"detail": "Invalid origin for WebSocket connection"},
|
|
314
314
|
)
|
|
315
|
-
|
|
316
|
-
#
|
|
315
|
+
|
|
316
|
+
# Cookie-based authentication requires CSRF protection
|
|
317
|
+
# Check if authentication token cookie is present
|
|
318
|
+
# Use same cookie name as SharedAuthMiddleware for consistency
|
|
319
|
+
from .shared_middleware import AUTH_COOKIE_NAME
|
|
320
|
+
|
|
321
|
+
auth_token_cookie = request.cookies.get(AUTH_COOKIE_NAME)
|
|
322
|
+
if auth_token_cookie:
|
|
323
|
+
# For WebSocket upgrades, CSRF protection relies on:
|
|
324
|
+
# 1. Origin validation (already done above) - primary defense
|
|
325
|
+
# 2. SameSite cookies - prevents cross-site cookie sending
|
|
326
|
+
# 3. CSRF cookie presence - ensures session is established
|
|
327
|
+
#
|
|
328
|
+
# Note: JavaScript WebSocket API cannot set custom headers,
|
|
329
|
+
# so we cannot use double-submit cookie pattern (cookie + header).
|
|
330
|
+
# Instead, we rely on Origin validation + SameSite cookies for CSRF protection.
|
|
331
|
+
csrf_cookie_token = request.cookies.get(self.cookie_name)
|
|
332
|
+
if not csrf_cookie_token:
|
|
333
|
+
logger.warning(f"WebSocket upgrade missing CSRF cookie for {path}")
|
|
334
|
+
return JSONResponse(
|
|
335
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
336
|
+
content={"detail": "CSRF token missing for WebSocket authentication"},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Validate CSRF token signature if secret is used
|
|
340
|
+
if self.secret and not validate_csrf_token(
|
|
341
|
+
csrf_cookie_token, self.secret, self.token_ttl
|
|
342
|
+
):
|
|
343
|
+
logger.warning(f"WebSocket CSRF token validation failed for {path}")
|
|
344
|
+
return JSONResponse(
|
|
345
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
346
|
+
content={
|
|
347
|
+
"detail": "CSRF token expired or invalid for WebSocket connection"
|
|
348
|
+
},
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Optional: If CSRF header is provided, validate it matches cookie
|
|
352
|
+
# (Some clients may send it, but it's not required for WebSocket upgrades)
|
|
353
|
+
header_token = request.headers.get(self.header_name)
|
|
354
|
+
if header_token:
|
|
355
|
+
# If header is provided, validate it matches cookie (double-submit pattern)
|
|
356
|
+
if not hmac.compare_digest(csrf_cookie_token, header_token):
|
|
357
|
+
logger.warning(f"WebSocket CSRF token mismatch for {path}")
|
|
358
|
+
return JSONResponse(
|
|
359
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
360
|
+
content={"detail": "CSRF token invalid for WebSocket connection"},
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
logger.debug(
|
|
364
|
+
f"WebSocket upgrade CSRF validation passed for {path} "
|
|
365
|
+
f"(Origin validated, CSRF cookie present)"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Origin validated (and CSRF validated if authenticated)
|
|
369
|
+
# Allow WebSocket upgrade to proceed
|
|
317
370
|
return await call_next(request)
|
|
318
371
|
|
|
319
372
|
if self._is_exempt(path):
|
|
@@ -307,16 +307,70 @@ 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,
|
|
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 via
|
|
368
|
+
Authenticate a WebSocket connection via httpOnly cookies.
|
|
315
369
|
|
|
316
|
-
Uses
|
|
317
|
-
-
|
|
318
|
-
-
|
|
319
|
-
-
|
|
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
|
|
320
374
|
|
|
321
375
|
Args:
|
|
322
376
|
websocket: FastAPI WebSocket instance (can access headers before accept)
|
|
@@ -340,56 +394,28 @@ async def authenticate_websocket(
|
|
|
340
394
|
return None, None
|
|
341
395
|
|
|
342
396
|
try:
|
|
343
|
-
token
|
|
344
|
-
|
|
397
|
+
# Extract token from httpOnly cookie
|
|
398
|
+
# Use same cookie name as SharedAuthMiddleware for consistency
|
|
399
|
+
from ..auth.shared_middleware import AUTH_COOKIE_NAME
|
|
345
400
|
|
|
346
|
-
|
|
347
|
-
#
|
|
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}")
|
|
401
|
+
cookies = _get_cookies_from_websocket(websocket)
|
|
402
|
+
token = cookies.get(AUTH_COOKIE_NAME) # Use mdb_auth_token (same as shared middleware)
|
|
382
403
|
|
|
383
404
|
if not token:
|
|
384
|
-
logger.
|
|
385
|
-
f"No token found
|
|
386
|
-
f"
|
|
387
|
-
f"
|
|
405
|
+
logger.error(
|
|
406
|
+
f"❌ No token cookie found for WebSocket connection to app '{app_slug}' "
|
|
407
|
+
f"(require_auth={require_auth}). "
|
|
408
|
+
f"Available cookies: {list(cookies.keys()) if cookies else 'none'}. "
|
|
409
|
+
f"Ensure httpOnly cookie is set during authentication."
|
|
388
410
|
)
|
|
389
411
|
if require_auth:
|
|
390
412
|
return None, None # Signal auth failure
|
|
391
413
|
return None, None
|
|
392
414
|
|
|
415
|
+
logger.info(
|
|
416
|
+
f"WebSocket token found in cookie for app '{app_slug}' " "(cookie-based authentication)"
|
|
417
|
+
)
|
|
418
|
+
|
|
393
419
|
# Decode and validate token
|
|
394
420
|
import jwt
|
|
395
421
|
|
|
@@ -401,17 +427,16 @@ async def authenticate_websocket(
|
|
|
401
427
|
user_id = payload.get("sub") or payload.get("user_id")
|
|
402
428
|
user_email = payload.get("email")
|
|
403
429
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
websocket.scope["_selected_subprotocol"] = selected_subprotocol
|
|
409
|
-
logger.debug(f"Stored subprotocol '{selected_subprotocol}' for WebSocket accept")
|
|
410
|
-
|
|
411
|
-
logger.info(f"WebSocket authenticated successfully for app '{app_slug}': {user_email}")
|
|
430
|
+
logger.info(
|
|
431
|
+
f"WebSocket authenticated successfully for app '{app_slug}': {user_email} "
|
|
432
|
+
f"(method: cookie)"
|
|
433
|
+
)
|
|
412
434
|
return user_id, user_email
|
|
413
|
-
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError)
|
|
414
|
-
logger.
|
|
435
|
+
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
|
436
|
+
logger.exception(
|
|
437
|
+
f"❌ JWT decode error for app '{app_slug}'. "
|
|
438
|
+
f"Token present: {bool(token)}, Token length: {len(token) if token else 0}"
|
|
439
|
+
)
|
|
415
440
|
raise
|
|
416
441
|
|
|
417
442
|
except WebSocketDisconnect:
|
|
@@ -480,29 +505,13 @@ def get_message_handler(
|
|
|
480
505
|
|
|
481
506
|
async def _accept_websocket_connection(websocket: Any, app_slug: str) -> None:
|
|
482
507
|
"""
|
|
483
|
-
Accept WebSocket connection
|
|
508
|
+
Accept WebSocket connection.
|
|
484
509
|
|
|
485
|
-
|
|
486
|
-
we must accept with that specific subprotocol or the browser will reject
|
|
487
|
-
the connection.
|
|
510
|
+
Cookie-based authentication uses httpOnly cookies automatically sent by the browser.
|
|
488
511
|
"""
|
|
489
512
|
try:
|
|
490
|
-
|
|
491
|
-
|
|
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}'")
|
|
513
|
+
await websocket.accept()
|
|
514
|
+
logger.info(f"✅ WebSocket accepted for app '{app_slug}'")
|
|
506
515
|
|
|
507
516
|
print(f"✅ [WEBSOCKET ACCEPTED] App: '{app_slug}'")
|
|
508
517
|
except (RuntimeError, ConnectionError, OSError) as accept_error:
|
|
@@ -687,19 +696,33 @@ def create_websocket_endpoint(
|
|
|
687
696
|
# CRITICAL: Authenticate BEFORE accepting connection
|
|
688
697
|
# This prevents CSRF middleware from rejecting established connections
|
|
689
698
|
# We can access headers/query_params before accept() is called
|
|
699
|
+
|
|
700
|
+
# Debug: Log cookies before authentication
|
|
701
|
+
try:
|
|
702
|
+
cookies = _get_cookies_from_websocket(websocket)
|
|
703
|
+
cookie_names = list(cookies.keys()) if cookies else []
|
|
704
|
+
logger.info(
|
|
705
|
+
f"🔍 WebSocket cookies for app '{app_slug}': {cookie_names} "
|
|
706
|
+
f"(require_auth={require_auth})"
|
|
707
|
+
)
|
|
708
|
+
except (AttributeError, TypeError, KeyError, RuntimeError) as cookie_error:
|
|
709
|
+
logger.warning(f"Could not extract cookies for debugging: {cookie_error}")
|
|
710
|
+
|
|
690
711
|
user_id, user_email = await authenticate_websocket(websocket, app_slug, require_auth)
|
|
691
712
|
|
|
692
713
|
# Handle authentication failure
|
|
693
714
|
if require_auth and not user_id:
|
|
694
|
-
logger.
|
|
695
|
-
f"WebSocket authentication
|
|
715
|
+
logger.error(
|
|
716
|
+
f"❌ WebSocket authentication FAILED for app '{app_slug}' - "
|
|
717
|
+
f"rejecting connection. require_auth={require_auth}, "
|
|
718
|
+
f"user_id={user_id}, user_email={user_email}"
|
|
696
719
|
)
|
|
697
720
|
# Reject without accepting - FastAPI will send 403 if accept() not called
|
|
698
721
|
# We can't call websocket.close() before accept(), so we just return
|
|
699
722
|
# The connection will be rejected by the server
|
|
700
723
|
return
|
|
701
724
|
|
|
702
|
-
# Accept connection
|
|
725
|
+
# Accept connection
|
|
703
726
|
await _accept_websocket_connection(websocket, app_slug)
|
|
704
727
|
|
|
705
728
|
# Connect with metadata (websocket already accepted)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|