mdb-engine 0.5.0__py3-none-any.whl → 0.6.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.
@@ -0,0 +1,433 @@
1
+ """
2
+ WebSocket Session Manager with Envelope Encryption
3
+
4
+ Manages WebSocket session keys using envelope encryption and private collections.
5
+ Provides secure-by-default WebSocket authentication without relying on CSRF cookies.
6
+
7
+ This module is part of MDB_ENGINE - MongoDB Engine.
8
+
9
+ Security Model:
10
+ - Session keys generated on authentication
11
+ - Stored encrypted in _mdb_engine_websocket_sessions collection
12
+ - Validated during WebSocket upgrade
13
+ - Uses envelope encryption (same as app secrets)
14
+ - Security by default: CSRF always required
15
+ """
16
+
17
+ import base64
18
+ import logging
19
+ import secrets
20
+ from collections.abc import Callable
21
+ from datetime import datetime, timedelta
22
+ from typing import Any
23
+
24
+ from motor.motor_asyncio import AsyncIOMotorDatabase
25
+ from pymongo.errors import OperationFailure, PyMongoError
26
+
27
+ from ..core.encryption import EnvelopeEncryptionService
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Collection name for storing encrypted WebSocket session keys
32
+ WEBSOCKET_SESSIONS_COLLECTION_NAME = "_mdb_engine_websocket_sessions"
33
+
34
+ # Session key configuration
35
+ SESSION_KEY_SIZE = 32 # 256 bits
36
+ SESSION_TTL_HOURS = 24 # Sessions expire after 24 hours
37
+
38
+
39
+ class WebSocketSessionManager:
40
+ """
41
+ Manages WebSocket session keys using envelope encryption.
42
+
43
+ Session keys are:
44
+ - Generated on user authentication
45
+ - Encrypted using envelope encryption
46
+ - Stored in private collection (_mdb_engine_websocket_sessions)
47
+ - Validated during WebSocket upgrade
48
+ - Automatically expired after TTL
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ mongo_db: AsyncIOMotorDatabase,
54
+ encryption_service: EnvelopeEncryptionService,
55
+ ):
56
+ """
57
+ Initialize the WebSocket session manager.
58
+
59
+ Args:
60
+ mongo_db: MongoDB database instance (raw, not scoped)
61
+ encryption_service: Envelope encryption service instance
62
+ """
63
+ self._mongo_db = mongo_db
64
+ self._encryption_service = encryption_service
65
+ self._sessions_collection = mongo_db[WEBSOCKET_SESSIONS_COLLECTION_NAME]
66
+
67
+ @staticmethod
68
+ def generate_session_key() -> str:
69
+ """
70
+ Generate a random WebSocket session key.
71
+
72
+ Returns:
73
+ Base64-encoded session key string
74
+ """
75
+ key_bytes = secrets.token_bytes(SESSION_KEY_SIZE)
76
+ return base64.urlsafe_b64encode(key_bytes).decode().rstrip("=")
77
+
78
+ async def create_session(
79
+ self,
80
+ user_id: str,
81
+ user_email: str | None = None,
82
+ app_slug: str | None = None,
83
+ ) -> str:
84
+ """
85
+ Create a new WebSocket session with encrypted session key.
86
+
87
+ Args:
88
+ user_id: User ID
89
+ user_email: Optional user email
90
+ app_slug: Optional app slug for scoping
91
+
92
+ Returns:
93
+ Plaintext session key (to be sent to client)
94
+
95
+ Raises:
96
+ OperationFailure: If MongoDB operation fails
97
+ """
98
+ try:
99
+ # Generate session key
100
+ session_key = self.generate_session_key()
101
+
102
+ # Encrypt session key using envelope encryption
103
+ encrypted_key, encrypted_dek = self._encryption_service.encrypt_secret(session_key)
104
+
105
+ # Encode as base64 for storage
106
+ encrypted_key_b64 = base64.b64encode(encrypted_key).decode()
107
+ encrypted_dek_b64 = base64.b64encode(encrypted_dek).decode()
108
+
109
+ # Calculate expiration
110
+ expires_at = datetime.utcnow() + timedelta(hours=SESSION_TTL_HOURS)
111
+
112
+ # Prepare document
113
+ document = {
114
+ "_id": session_key, # Use session key as ID for fast lookup
115
+ "user_id": user_id,
116
+ "user_email": user_email,
117
+ "app_slug": app_slug,
118
+ "encrypted_key": encrypted_key_b64,
119
+ "encrypted_dek": encrypted_dek_b64,
120
+ "algorithm": "AES-256-GCM",
121
+ "created_at": datetime.utcnow(),
122
+ "expires_at": expires_at,
123
+ }
124
+
125
+ # Store in private collection
126
+ await self._sessions_collection.insert_one(document)
127
+
128
+ logger.info(
129
+ f"Created WebSocket session for user '{user_id}' "
130
+ f"(app: {app_slug}, expires: {expires_at})"
131
+ )
132
+
133
+ return session_key
134
+
135
+ except (OperationFailure, PyMongoError):
136
+ logger.exception("Failed to create WebSocket session")
137
+ raise
138
+
139
+ async def validate_session(
140
+ self,
141
+ session_key: str,
142
+ user_id: str | None = None,
143
+ ) -> dict[str, Any] | None:
144
+ """
145
+ Validate a WebSocket session key.
146
+
147
+ Args:
148
+ session_key: Session key to validate
149
+ user_id: Optional user ID for additional validation
150
+
151
+ Returns:
152
+ Session document if valid, None otherwise
153
+
154
+ Raises:
155
+ OperationFailure: If MongoDB operation fails
156
+ """
157
+ try:
158
+ # Find session by key
159
+ session_doc = await self._sessions_collection.find_one({"_id": session_key})
160
+
161
+ if not session_doc:
162
+ logger.warning(f"WebSocket session not found: {session_key[:16]}...")
163
+ return None
164
+
165
+ # Check expiration
166
+ expires_at = session_doc.get("expires_at")
167
+ if expires_at and expires_at < datetime.utcnow():
168
+ logger.warning(
169
+ f"WebSocket session expired: {session_key[:16]}... " f"(expired: {expires_at})"
170
+ )
171
+ # Clean up expired session
172
+ await self._sessions_collection.delete_one({"_id": session_key})
173
+ return None
174
+
175
+ # Optional: Validate user_id matches
176
+ if user_id and session_doc.get("user_id") != user_id:
177
+ logger.warning(
178
+ f"WebSocket session user mismatch: "
179
+ f"session_user={session_doc.get('user_id')}, "
180
+ f"provided_user={user_id}"
181
+ )
182
+ return None
183
+
184
+ # Decrypt session key to verify it's valid
185
+ try:
186
+ encrypted_key = base64.b64decode(session_doc["encrypted_key"])
187
+ encrypted_dek = base64.b64decode(session_doc["encrypted_dek"])
188
+ decrypted_key = self._encryption_service.decrypt_secret(
189
+ encrypted_key, encrypted_dek
190
+ )
191
+
192
+ # Verify decrypted key matches session_key
193
+ if decrypted_key != session_key:
194
+ logger.error(
195
+ f"WebSocket session key decryption mismatch: "
196
+ f"session_key={session_key[:16]}..."
197
+ )
198
+ return None
199
+
200
+ except (ValueError, TypeError, AttributeError, KeyError):
201
+ logger.exception("Failed to decrypt WebSocket session key")
202
+ return None
203
+
204
+ logger.debug(
205
+ f"Validated WebSocket session for user '{session_doc.get('user_id')}' "
206
+ f"(app: {session_doc.get('app_slug')})"
207
+ )
208
+
209
+ return {
210
+ "user_id": session_doc.get("user_id"),
211
+ "user_email": session_doc.get("user_email"),
212
+ "app_slug": session_doc.get("app_slug"),
213
+ "created_at": session_doc.get("created_at"),
214
+ "expires_at": session_doc.get("expires_at"),
215
+ }
216
+
217
+ except (OperationFailure, PyMongoError):
218
+ logger.exception("Failed to validate WebSocket session")
219
+ raise
220
+
221
+ async def revoke_session(self, session_key: str) -> bool:
222
+ """
223
+ Revoke a WebSocket session.
224
+
225
+ Args:
226
+ session_key: Session key to revoke
227
+
228
+ Returns:
229
+ True if session was revoked, False if not found
230
+ """
231
+ try:
232
+ result = await self._sessions_collection.delete_one({"_id": session_key})
233
+ if result.deleted_count > 0:
234
+ logger.info(f"Revoked WebSocket session: {session_key[:16]}...")
235
+ return True
236
+ return False
237
+ except (OperationFailure, PyMongoError):
238
+ logger.exception("Failed to revoke WebSocket session")
239
+ return False
240
+
241
+ async def revoke_user_sessions(self, user_id: str, app_slug: str | None = None) -> int:
242
+ """
243
+ Revoke all sessions for a user.
244
+
245
+ Args:
246
+ user_id: User ID
247
+ app_slug: Optional app slug filter
248
+
249
+ Returns:
250
+ Number of sessions revoked
251
+ """
252
+ try:
253
+ query = {"user_id": user_id}
254
+ if app_slug:
255
+ query["app_slug"] = app_slug
256
+
257
+ result = await self._sessions_collection.delete_many(query)
258
+ logger.info(
259
+ f"Revoked {result.deleted_count} WebSocket sessions "
260
+ f"for user '{user_id}' (app: {app_slug})"
261
+ )
262
+ return result.deleted_count
263
+ except (OperationFailure, PyMongoError):
264
+ logger.exception("Failed to revoke user WebSocket sessions")
265
+ return 0
266
+
267
+ async def cleanup_expired_sessions(self) -> int:
268
+ """
269
+ Clean up expired WebSocket sessions.
270
+
271
+ Returns:
272
+ Number of sessions cleaned up
273
+ """
274
+ try:
275
+ result = await self._sessions_collection.delete_many(
276
+ {"expires_at": {"$lt": datetime.utcnow()}}
277
+ )
278
+ if result.deleted_count > 0:
279
+ logger.info(f"Cleaned up {result.deleted_count} expired WebSocket sessions")
280
+ return result.deleted_count
281
+ except (OperationFailure, PyMongoError):
282
+ logger.exception("Failed to cleanup expired WebSocket sessions")
283
+ return 0
284
+
285
+
286
+ def create_websocket_session_endpoint(
287
+ session_manager: WebSocketSessionManager,
288
+ ) -> Callable:
289
+ """
290
+ Create a FastAPI endpoint for generating WebSocket session keys.
291
+
292
+ This endpoint requires authentication and generates a new WebSocket session key
293
+ for the authenticated user. The session key is encrypted and stored in the
294
+ private collection.
295
+
296
+ Args:
297
+ session_manager: WebSocketSessionManager instance
298
+
299
+ Returns:
300
+ FastAPI route handler function
301
+
302
+ Example:
303
+ ```python
304
+ from mdb_engine.auth.websocket_sessions import (
305
+ WebSocketSessionManager,
306
+ create_websocket_session_endpoint,
307
+ )
308
+ from mdb_engine.core.encryption import EnvelopeEncryptionService
309
+
310
+ # Initialize session manager
311
+ encryption_service = EnvelopeEncryptionService()
312
+ session_manager = WebSocketSessionManager(
313
+ mongo_db=db,
314
+ encryption_service=encryption_service,
315
+ )
316
+
317
+ # Create endpoint
318
+ endpoint = create_websocket_session_endpoint(session_manager)
319
+ app.get("/auth/websocket-session")(endpoint)
320
+ ```
321
+
322
+ The endpoint:
323
+ - Requires authentication (user must be logged in)
324
+ - Returns JSON: `{"session_key": "...", "expires_at": "..."}`
325
+ - Uses user info from `request.state.user` (set by SharedAuthMiddleware)
326
+ """
327
+ from fastapi import Request, status
328
+ from fastapi.responses import JSONResponse
329
+
330
+ async def websocket_session_endpoint(request: Request) -> JSONResponse:
331
+ """
332
+ Generate a WebSocket session key for the authenticated user.
333
+
334
+ Requires:
335
+ - User to be authenticated (via request.state.user or auth cookie)
336
+ - WebSocket session manager to be available
337
+
338
+ Returns:
339
+ - JSONResponse with session_key and expires_at
340
+ """
341
+ # Check if user is authenticated (set by middleware)
342
+ user = getattr(request.state, "user", None)
343
+
344
+ # If not set by middleware, try to authenticate using cookie
345
+ # This handles the case where endpoint is on parent app without auth middleware
346
+ if not user:
347
+ from .shared_middleware import AUTH_COOKIE_NAME
348
+
349
+ # Get user pool from app state
350
+ user_pool = None
351
+ try:
352
+ if hasattr(request, "app") and hasattr(request.app, "state"):
353
+ user_pool = getattr(request.app.state, "user_pool", None)
354
+ except (AttributeError, TypeError):
355
+ pass
356
+
357
+ # Only try to authenticate if we have a real user pool (not None)
358
+ if user_pool is not None:
359
+ # Extract token from cookie
360
+ token = None
361
+ try:
362
+ if hasattr(request, "cookies"):
363
+ token = request.cookies.get(AUTH_COOKIE_NAME)
364
+ except (AttributeError, TypeError):
365
+ pass
366
+
367
+ if token:
368
+ try:
369
+ # Validate token and get user
370
+ user = await user_pool.validate_token(token)
371
+ except (TypeError, AttributeError):
372
+ # If user_pool is a mock that can't be awaited, ignore
373
+ pass
374
+
375
+ if not user:
376
+ return JSONResponse(
377
+ status_code=status.HTTP_401_UNAUTHORIZED,
378
+ content={"detail": "Authentication required"},
379
+ )
380
+
381
+ # Extract user info
382
+ # Prefer user_id, sub (JWT standard), or _id (MongoDB document ID)
383
+ user_id = user.get("user_id") or user.get("sub") or user.get("_id")
384
+ if not user_id:
385
+ # Email is not a valid user_id - it's just metadata
386
+ logger.error("Cannot generate WebSocket session: user_id not found in user data")
387
+ return JSONResponse(
388
+ status_code=status.HTTP_400_BAD_REQUEST,
389
+ content={"detail": "Invalid user data"},
390
+ )
391
+ user_email = user.get("email")
392
+ app_slug = getattr(request.state, "app_slug", None)
393
+
394
+ try:
395
+ # Generate session key
396
+ session_key = await session_manager.create_session(
397
+ user_id=str(user_id),
398
+ user_email=user_email,
399
+ app_slug=app_slug,
400
+ )
401
+
402
+ # Get expiration time (24 hours from now)
403
+ from datetime import datetime, timedelta
404
+
405
+ expires_at = datetime.utcnow() + timedelta(hours=SESSION_TTL_HOURS)
406
+
407
+ logger.info(
408
+ f"Generated WebSocket session key for user '{user_id}' " f"(app: {app_slug})"
409
+ )
410
+
411
+ return JSONResponse(
412
+ {
413
+ "session_key": session_key,
414
+ "expires_at": expires_at.isoformat(),
415
+ "ttl_hours": SESSION_TTL_HOURS,
416
+ }
417
+ )
418
+
419
+ except (
420
+ ValueError,
421
+ TypeError,
422
+ AttributeError,
423
+ RuntimeError,
424
+ OperationFailure,
425
+ PyMongoError,
426
+ ):
427
+ logger.exception("Failed to generate WebSocket session key")
428
+ return JSONResponse(
429
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
430
+ content={"detail": "Failed to generate WebSocket session key"},
431
+ )
432
+
433
+ return websocket_session_endpoint
mdb_engine/core/engine.py CHANGED
@@ -148,6 +148,7 @@ class MongoDBEngine:
148
148
  self._service_initializer: ServiceInitializer | None = None
149
149
  self._encryption_service: EnvelopeEncryptionService | None = None
150
150
  self._app_secrets_manager: AppSecretsManager | None = None
151
+ self._websocket_session_manager: Any | None = None # WebSocketSessionManager
151
152
 
152
153
  # Store app read_scopes mapping for validation
153
154
  self._app_read_scopes: dict[str, list[str]] = {}
@@ -201,6 +202,13 @@ class MongoDBEngine:
201
202
  mongo_db=self._connection_manager.mongo_db,
202
203
  encryption_service=self._encryption_service,
203
204
  )
205
+ # Initialize WebSocket session manager for secure-by-default WebSocket auth
206
+ from ..auth.websocket_sessions import WebSocketSessionManager
207
+
208
+ self._websocket_session_manager = WebSocketSessionManager(
209
+ mongo_db=self._connection_manager.mongo_db,
210
+ encryption_service=self._encryption_service,
211
+ )
204
212
 
205
213
  # Set up component managers
206
214
  self._app_registration_manager = AppRegistrationManager(
@@ -2283,6 +2291,11 @@ class MongoDBEngine:
2283
2291
  logger.debug(f"No WebSocket configuration found for app '{slug}'")
2284
2292
  return
2285
2293
 
2294
+ # Store WebSocket config in parent app state for CSRF middleware to access
2295
+ if not hasattr(parent_app.state, "websocket_configs"):
2296
+ parent_app.state.websocket_configs = {}
2297
+ parent_app.state.websocket_configs[slug] = websockets_config
2298
+
2286
2299
  try:
2287
2300
  from fastapi import APIRouter
2288
2301
 
@@ -2489,6 +2502,13 @@ class MongoDBEngine:
2489
2502
  child_app.state.audit_log = app.state.audit_log
2490
2503
  logger.debug(f"Shared user_pool with child app '{slug}'")
2491
2504
 
2505
+ # Share WebSocket session manager with child app
2506
+ if hasattr(app.state, "websocket_session_manager"):
2507
+ child_app.state.websocket_session_manager = (
2508
+ app.state.websocket_session_manager
2509
+ )
2510
+ logger.debug(f"Shared WebSocket session manager with child app '{slug}'")
2511
+
2492
2512
  # Add middleware for app context helpers
2493
2513
  from starlette.middleware.base import BaseHTTPMiddleware
2494
2514
  from starlette.requests import Request
@@ -2836,12 +2856,31 @@ class MongoDBEngine:
2836
2856
  # Create CSRF middleware with default config (will use parent app's CORS config)
2837
2857
  # Exempt routes that don't need CSRF (health checks, public routes from child apps)
2838
2858
  # all_public_routes includes base routes + child app public routes with path prefixes
2859
+ # Add WebSocket session endpoint to public routes (it handles its own auth)
2860
+ public_routes_with_session_endpoint = list(all_public_routes) + [
2861
+ "/auth/websocket-session"
2862
+ ]
2839
2863
  parent_csrf_config = {
2840
2864
  "csrf_protection": True,
2841
- "public_routes": all_public_routes,
2865
+ "public_routes": public_routes_with_session_endpoint,
2842
2866
  }
2843
2867
  csrf_middleware = create_csrf_middleware(parent_csrf_config)
2844
2868
  parent_app.add_middleware(csrf_middleware)
2869
+
2870
+ # Store WebSocket session manager in app state for CSRF middleware and endpoints
2871
+ if self._websocket_session_manager:
2872
+ parent_app.state.websocket_session_manager = self._websocket_session_manager
2873
+ logger.info("WebSocket session manager stored in parent app state")
2874
+
2875
+ # Register WebSocket session endpoint on parent app
2876
+ from ..auth.websocket_sessions import create_websocket_session_endpoint
2877
+
2878
+ session_endpoint = create_websocket_session_endpoint(
2879
+ self._websocket_session_manager
2880
+ )
2881
+ parent_app.get("/auth/websocket-session")(session_endpoint)
2882
+ logger.info("WebSocket session endpoint registered at /auth/websocket-session")
2883
+
2845
2884
  logger.info("CSRFMiddleware added to parent app for WebSocket origin validation")
2846
2885
 
2847
2886
  # Add shared CORS middleware if configured
@@ -3320,6 +3359,7 @@ class MongoDBEngine:
3320
3359
  self._shared_user_pool = SharedUserPool(
3321
3360
  self._connection_manager.mongo_db,
3322
3361
  allow_insecure_dev=is_dev,
3362
+ websocket_session_manager=self._websocket_session_manager,
3323
3363
  )
3324
3364
  await self._shared_user_pool.ensure_indexes()
3325
3365
  logger.info("SharedUserPool initialized")
@@ -1338,6 +1338,18 @@ MANIFEST_SCHEMA_V2 = {
1338
1338
  "auth is required (default: false)"
1339
1339
  ),
1340
1340
  },
1341
+ "csrf_required": {
1342
+ "type": "boolean",
1343
+ "default": True,
1344
+ "description": (
1345
+ "Require CSRF validation for WebSocket connections "
1346
+ "(default: true - security by default). "
1347
+ "When true, uses encrypted session keys stored in "
1348
+ "private collection for CSRF protection. "
1349
+ "Set to false to use Origin validation + "
1350
+ "SameSite cookies only."
1351
+ ),
1352
+ },
1341
1353
  },
1342
1354
  "additionalProperties": False,
1343
1355
  "description": (
mdb_engine/core/types.py CHANGED
@@ -243,6 +243,7 @@ class WebSocketAuthDict(TypedDict, total=False):
243
243
 
244
244
  required: bool
245
245
  allow_anonymous: bool
246
+ csrf_required: bool # Whether CSRF cookie is required (default: False)
246
247
 
247
248
 
248
249
  class WebSocketEndpointDict(TypedDict, total=False):
@@ -365,12 +365,11 @@ async def authenticate_websocket(
365
365
  require_auth: bool = True,
366
366
  ) -> tuple[str | None, str | None]:
367
367
  """
368
- Authenticate a WebSocket connection via httpOnly cookies.
368
+ Authenticate a WebSocket connection via session key or httpOnly cookies.
369
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
370
+ Authentication methods (in order of preference):
371
+ 1. Session key (query param or header) - secure-by-default, uses envelope encryption
372
+ 2. Cookie-based authentication - backward compatibility fallback
374
373
 
375
374
  Args:
376
375
  websocket: FastAPI WebSocket instance (can access headers before accept)
@@ -394,22 +393,71 @@ async def authenticate_websocket(
394
393
  return None, None
395
394
 
396
395
  try:
397
- # Extract token from httpOnly cookie
396
+ # Try to get WebSocket session manager from app
397
+ websocket_session_manager = None
398
+ try:
399
+ app = getattr(websocket, "app", None)
400
+ if app:
401
+ websocket_session_manager = getattr(app.state, "websocket_session_manager", None)
402
+ except (AttributeError, TypeError):
403
+ pass
404
+
405
+ # Method 1: Try session key authentication (secure-by-default)
406
+ session_key = None
407
+ try:
408
+ # Check query params first
409
+ if hasattr(websocket, "query_params"):
410
+ session_key = websocket.query_params.get("session_key")
411
+
412
+ # Check headers if not in query params
413
+ if not session_key and hasattr(websocket, "headers"):
414
+ session_key = websocket.headers.get("X-WebSocket-Session-Key")
415
+ except (AttributeError, TypeError, KeyError):
416
+ pass
417
+
418
+ if session_key and websocket_session_manager:
419
+ try:
420
+ # Validate session key
421
+ session_data = await websocket_session_manager.validate_session(session_key)
422
+ if session_data:
423
+ user_id = session_data.get("user_id")
424
+ user_email = session_data.get("user_email")
425
+
426
+ logger.info(
427
+ f"WebSocket authenticated successfully for app '{app_slug}': {user_email} "
428
+ f"(method: session_key)"
429
+ )
430
+ return user_id, user_email
431
+ else:
432
+ logger.warning(
433
+ f"WebSocket session key validation failed for app '{app_slug}'. "
434
+ f"Session key: {session_key[:16]}..."
435
+ )
436
+ except (ValueError, TypeError, AttributeError, KeyError, RuntimeError) as e:
437
+ logger.warning(f"WebSocket session key validation error for app '{app_slug}': {e}")
438
+ # Fall through to cookie-based auth
439
+
440
+ # Method 2: Fall back to cookie-based authentication (backward compatibility)
441
+ from ..auth.shared_middleware import AUTH_COOKIE_NAME
442
+
398
443
  cookies = _get_cookies_from_websocket(websocket)
399
- token = cookies.get("token") # Standard auth token cookie name
444
+ token = cookies.get(AUTH_COOKIE_NAME) # Use mdb_auth_token (same as shared middleware)
400
445
 
401
446
  if not token:
402
- logger.warning(
403
- f"No token cookie found for WebSocket connection to app '{app_slug}' "
447
+ logger.error(
448
+ f"No authentication found for WebSocket connection to app '{app_slug}' "
404
449
  f"(require_auth={require_auth}). "
405
- f"Ensure httpOnly cookie is set during authentication."
450
+ f"Session key: {bool(session_key)}, Cookie: {bool(token)}, "
451
+ f"Available cookies: {list(cookies.keys()) if cookies else 'none'}. "
452
+ f"Ensure session key or httpOnly cookie is set during authentication."
406
453
  )
407
454
  if require_auth:
408
455
  return None, None # Signal auth failure
409
456
  return None, None
410
457
 
411
458
  logger.info(
412
- f"WebSocket token found in cookie for app '{app_slug}' " "(cookie-based authentication)"
459
+ f"WebSocket token found in cookie for app '{app_slug}' "
460
+ "(cookie-based authentication, fallback)"
413
461
  )
414
462
 
415
463
  # Decode and validate token
@@ -428,8 +476,11 @@ async def authenticate_websocket(
428
476
  f"(method: cookie)"
429
477
  )
430
478
  return user_id, user_email
431
- except (jwt.ExpiredSignatureError, jwt.InvalidTokenError) as decode_error:
432
- logger.error(f"JWT decode error for app '{app_slug}': {decode_error}", exc_info=True)
479
+ except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
480
+ logger.exception(
481
+ f"❌ JWT decode error for app '{app_slug}'. "
482
+ f"Token present: {bool(token)}, Token length: {len(token) if token else 0}"
483
+ )
433
484
  raise
434
485
 
435
486
  except WebSocketDisconnect:
@@ -689,12 +740,26 @@ def create_websocket_endpoint(
689
740
  # CRITICAL: Authenticate BEFORE accepting connection
690
741
  # This prevents CSRF middleware from rejecting established connections
691
742
  # We can access headers/query_params before accept() is called
743
+
744
+ # Debug: Log cookies before authentication
745
+ try:
746
+ cookies = _get_cookies_from_websocket(websocket)
747
+ cookie_names = list(cookies.keys()) if cookies else []
748
+ logger.info(
749
+ f"🔍 WebSocket cookies for app '{app_slug}': {cookie_names} "
750
+ f"(require_auth={require_auth})"
751
+ )
752
+ except (AttributeError, TypeError, KeyError, RuntimeError) as cookie_error:
753
+ logger.warning(f"Could not extract cookies for debugging: {cookie_error}")
754
+
692
755
  user_id, user_email = await authenticate_websocket(websocket, app_slug, require_auth)
693
756
 
694
757
  # Handle authentication failure
695
758
  if require_auth and not user_id:
696
- logger.warning(
697
- f"WebSocket authentication failed for app '{app_slug}' - rejecting connection"
759
+ logger.error(
760
+ f"WebSocket authentication FAILED for app '{app_slug}' - "
761
+ f"rejecting connection. require_auth={require_auth}, "
762
+ f"user_id={user_id}, user_email={user_email}"
698
763
  )
699
764
  # Reject without accepting - FastAPI will send 403 if accept() not called
700
765
  # We can't call websocket.close() before accept(), so we just return