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.
mdb_engine/__init__.py CHANGED
@@ -82,15 +82,19 @@ 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.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
85
+ "0.6.0" # Secure-by-default WebSocket authentication with encrypted session keys
86
+ # - NEW: WebSocket session key generation and management
87
+ # - NEW: Envelope encryption for WebSocket session keys
88
+ # - NEW: Secure-by-default CSRF protection (csrf_required: true)
89
+ # - NEW: WebSocketSessionManager with private collection storage
90
+ # - NEW: Session key endpoint (/auth/websocket-session)
91
+ # - NEW: Session key integration in login flow
92
+ # - ENHANCED: WebSocket authentication with session key support
93
+ # - ENHANCED: CSRF middleware session key validation
94
+ # - ENHANCED: Multi-app WebSocket routing with session keys
95
+ # - BACKWARD COMPATIBLE: Cookie-based authentication fallback
96
+ # - UPDATED: All documentation for secure-by-default approach
97
+ # - COMPREHENSIVE: Unit and integration tests for session keys
94
98
  )
95
99
 
96
100
  __all__ = [
@@ -125,6 +125,12 @@ from .utils import (
125
125
  validate_password_strength_async,
126
126
  )
127
127
 
128
+ # WebSocket sessions
129
+ from .websocket_sessions import (
130
+ WebSocketSessionManager,
131
+ create_websocket_session_endpoint,
132
+ )
133
+
128
134
  __all__ = [
129
135
  # Base classes
130
136
  "BaseAuthorizationProvider",
@@ -232,4 +238,7 @@ __all__ = [
232
238
  "generate_csrf_token",
233
239
  "validate_csrf_token",
234
240
  "get_csrf_token",
241
+ # WebSocket sessions
242
+ "WebSocketSessionManager",
243
+ "create_websocket_session_endpoint",
235
244
  ]
mdb_engine/auth/csrf.py CHANGED
@@ -102,14 +102,16 @@ def validate_csrf_token(
102
102
  try:
103
103
  parts = token.split(":")
104
104
  if len(parts) != 3:
105
+ logger.debug("CSRF token has wrong format (expected 3 parts)")
105
106
  return False
106
107
 
107
108
  raw_token, timestamp_str, signature = parts
108
109
  timestamp = int(timestamp_str)
109
110
 
110
111
  # Check age
111
- if time.time() - timestamp > max_age:
112
- logger.debug("CSRF token expired")
112
+ age = time.time() - timestamp
113
+ if age > max_age:
114
+ logger.debug(f"CSRF token expired (age: {age:.0f}s, max: {max_age}s)")
113
115
  return False
114
116
 
115
117
  # Verify signature
@@ -119,7 +121,10 @@ def validate_csrf_token(
119
121
  ]
120
122
 
121
123
  if not hmac.compare_digest(signature, expected_sig):
122
- logger.warning("CSRF token signature mismatch")
124
+ logger.warning(
125
+ f"CSRF token signature mismatch. "
126
+ f"Token format: signed, Has secret: {bool(secret)}"
127
+ )
123
128
  return False
124
129
 
125
130
  return True
@@ -128,7 +133,10 @@ def validate_csrf_token(
128
133
  return False
129
134
 
130
135
  # Simple token validation (just check it exists and has reasonable length)
131
- return len(token) >= CSRF_TOKEN_LENGTH
136
+ is_valid = len(token) >= CSRF_TOKEN_LENGTH
137
+ if not is_valid:
138
+ logger.debug(f"CSRF token too short (length: {len(token)}, required: {CSRF_TOKEN_LENGTH})")
139
+ return is_valid
132
140
 
133
141
 
134
142
  class CSRFMiddleware(BaseHTTPMiddleware):
@@ -197,10 +205,116 @@ class CSRFMiddleware(BaseHTTPMiddleware):
197
205
  return True
198
206
  return False
199
207
 
208
+ def _websocket_requires_csrf(self, request: Request, path: str) -> bool:
209
+ """
210
+ Check if WebSocket endpoint requires CSRF validation.
211
+
212
+ Defaults to True (security by default). Can be disabled per-endpoint via manifest.json:
213
+ websockets.{endpoint}.auth.csrf_required = false
214
+
215
+ Args:
216
+ request: FastAPI request
217
+ path: WebSocket path (e.g., "/app-3/ws")
218
+
219
+ Returns:
220
+ True if CSRF validation is required, False otherwise
221
+ """
222
+ # Check parent app state for WebSocket configs
223
+ websocket_configs = getattr(request.app.state, "websocket_configs", None)
224
+ if not websocket_configs:
225
+ # No WebSocket configs found - use default (CSRF required for security by default)
226
+ return True
227
+
228
+ # Normalize path for matching
229
+ normalized_path = path.rstrip("/")
230
+
231
+ # Try to find matching app config
232
+ # WebSocket paths are registered as /app-slug/endpoint-path
233
+ # e.g., /app-3/ws where app_slug="app-3" and endpoint_path="/ws"
234
+ for app_slug, config in websocket_configs.items():
235
+ # Check each endpoint in this app's config
236
+ for endpoint_name, endpoint_config in config.items():
237
+ endpoint_path = endpoint_config.get("path", "")
238
+ # Normalize endpoint path
239
+ normalized_endpoint = endpoint_path.rstrip("/")
240
+
241
+ # Match patterns:
242
+ # 1. Full path match: /app-slug/endpoint-path
243
+ # 2. Endpoint-only match: /endpoint-path (if path starts with endpoint)
244
+ expected_full_path = f"/{app_slug}{normalized_endpoint}"
245
+ if (
246
+ normalized_path == expected_full_path
247
+ or normalized_path.endswith(normalized_endpoint)
248
+ or normalized_path == normalized_endpoint
249
+ ):
250
+ auth_config = endpoint_config.get("auth", {})
251
+ if isinstance(auth_config, dict):
252
+ # Return csrf_required setting (defaults to True - security by default)
253
+ csrf_required = auth_config.get("csrf_required", True)
254
+ logger.debug(
255
+ f"WebSocket {path} csrf_required={csrf_required} "
256
+ f"(from app={app_slug}, endpoint={endpoint_name})"
257
+ )
258
+ return csrf_required
259
+
260
+ # No matching config found - use default (CSRF required for security by default)
261
+ logger.debug(f"No WebSocket config match for {path}, using default csrf_required=true")
262
+ return True
263
+
200
264
  def _is_websocket_upgrade(self, request: Request) -> bool:
201
265
  """Check if request is a WebSocket upgrade request."""
202
266
  upgrade_header = request.headers.get("upgrade", "").lower()
203
- return upgrade_header == "websocket"
267
+ connection_header = request.headers.get("connection", "").lower()
268
+
269
+ # Primary check: WebSocket upgrade requires both Upgrade: websocket
270
+ # and Connection: Upgrade headers
271
+ has_upgrade_header = upgrade_header == "websocket"
272
+ has_connection_upgrade = "upgrade" in connection_header or "websocket" in connection_header
273
+
274
+ # Secondary check: If upgrade header is present but connection is
275
+ # overridden (e.g., by TestClient), check if path matches a known
276
+ # WebSocket route pattern
277
+ path_matches_websocket_route = False
278
+ if has_upgrade_header and not has_connection_upgrade:
279
+ # Check if path matches any configured WebSocket route
280
+ websocket_configs = getattr(request.app.state, "websocket_configs", None)
281
+ if websocket_configs:
282
+ path = request.url.path.rstrip("/") or "/"
283
+ for app_slug, config in websocket_configs.items():
284
+ for _endpoint_name, endpoint_config in config.items():
285
+ endpoint_path = endpoint_config.get("path", "").rstrip("/") or "/"
286
+ # Try various path matching patterns
287
+ expected_full_path = (
288
+ f"/{app_slug}{endpoint_path}"
289
+ if endpoint_path != "/"
290
+ else f"/{app_slug}"
291
+ )
292
+ # Match patterns:
293
+ # 1. Exact match with app prefix: /app-slug/endpoint-path
294
+ # 2. Endpoint-only match: /endpoint-path (if path ends with endpoint)
295
+ # 3. Root match: / matches / or /app-slug
296
+ if (
297
+ path == expected_full_path
298
+ or path.endswith(endpoint_path)
299
+ or path == endpoint_path
300
+ or (path == "/" and endpoint_path == "/")
301
+ or (path == f"/{app_slug}" and endpoint_path == "/")
302
+ ):
303
+ path_matches_websocket_route = True
304
+ break
305
+ if path_matches_websocket_route:
306
+ break
307
+
308
+ is_websocket = has_upgrade_header and (
309
+ has_connection_upgrade or path_matches_websocket_route
310
+ )
311
+ if is_websocket:
312
+ logger.debug(
313
+ f"WebSocket upgrade detected: path={request.url.path}, "
314
+ f"upgrade={upgrade_header}, connection={connection_header}, "
315
+ f"path_match={path_matches_websocket_route}"
316
+ )
317
+ return is_websocket
204
318
 
205
319
  def _get_allowed_origins(self, request: Request) -> list[str]:
206
320
  """
@@ -298,9 +412,22 @@ class CSRFMiddleware(BaseHTTPMiddleware):
298
412
  path = request.url.path
299
413
  method = request.method
300
414
 
415
+ # Debug: Log all requests to see what's happening
416
+ upgrade_header = request.headers.get("upgrade", "").lower()
417
+ connection_header = request.headers.get("connection", "").lower()
418
+ if upgrade_header or "websocket" in path.lower():
419
+ logger.info(
420
+ f"🔍 CSRF middleware: {method} {path}, "
421
+ f"upgrade={upgrade_header}, connection={connection_header}"
422
+ )
423
+
301
424
  # CRITICAL: Handle WebSocket upgrade requests BEFORE other CSRF checks
302
425
  # WebSocket upgrades use cookie-based authentication and require CSRF validation
303
426
  if self._is_websocket_upgrade(request):
427
+ logger.info(
428
+ f"🔌 CSRF middleware processing WebSocket upgrade: {path}, "
429
+ f"origin: {request.headers.get('origin')}"
430
+ )
304
431
  # Always validate origin for WebSocket connections (CSWSH protection)
305
432
  if not self._validate_websocket_origin(request):
306
433
  logger.warning(
@@ -315,51 +442,160 @@ class CSRFMiddleware(BaseHTTPMiddleware):
315
442
 
316
443
  # Cookie-based authentication requires CSRF protection
317
444
  # Check if authentication token cookie is present
318
- auth_token_cookie = request.cookies.get("token")
445
+ # Use same cookie name as SharedAuthMiddleware for consistency
446
+ from .shared_middleware import AUTH_COOKIE_NAME
447
+
448
+ auth_token_cookie = request.cookies.get(AUTH_COOKIE_NAME)
319
449
  if auth_token_cookie:
320
- # For WebSocket upgrades, CSRF protection relies on:
450
+ # SECURITY BY DEFAULT: WebSocket CSRF protection uses encrypted session keys
451
+ # stored in private collection via envelope encryption.
452
+ #
453
+ # Security Model:
321
454
  # 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
455
+ # 2. Encrypted session key validation - CSRF protection via database
456
+ # 3. SameSite cookies - prevents cross-site cookie sending
324
457
  #
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"},
458
+ # Session keys are:
459
+ # - Generated on authentication
460
+ # - Encrypted using envelope encryption (same as app secrets)
461
+ # - Stored in _mdb_engine_websocket_sessions private collection
462
+ # - Validated during WebSocket upgrade
463
+
464
+ # Check if this WebSocket endpoint requires CSRF validation
465
+ csrf_required = self._websocket_requires_csrf(request, path)
466
+
467
+ if csrf_required:
468
+ # Try to get WebSocket session manager from app state
469
+ websocket_session_manager = getattr(
470
+ request.app.state, "websocket_session_manager", None
334
471
  )
335
472
 
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
- },
473
+ if websocket_session_manager:
474
+ # Use encrypted session key validation (secure-by-default)
475
+ session_key = request.query_params.get(
476
+ "session_key"
477
+ ) or request.headers.get("X-WebSocket-Session-Key")
478
+
479
+ if not session_key:
480
+ logger.error(
481
+ f" WebSocket upgrade missing session key for {path}. "
482
+ f"Auth cookie present: {bool(auth_token_cookie)}. "
483
+ f"Tip: Generate session key via /auth/websocket-session endpoint."
484
+ )
485
+ return JSONResponse(
486
+ status_code=status.HTTP_403_FORBIDDEN,
487
+ content={
488
+ "detail": (
489
+ "WebSocket session key missing. "
490
+ "Generate session key via /auth/websocket-session endpoint."
491
+ )
492
+ },
493
+ )
494
+
495
+ # Validate session key against encrypted storage
496
+ try:
497
+ session_data = await websocket_session_manager.validate_session(
498
+ session_key
499
+ )
500
+ if not session_data:
501
+ logger.error(
502
+ f"❌ WebSocket session key validation failed for {path}. "
503
+ f"Session key: {session_key[:16]}..."
504
+ )
505
+ return JSONResponse(
506
+ status_code=status.HTTP_403_FORBIDDEN,
507
+ content={
508
+ "detail": (
509
+ "WebSocket session key expired or invalid. "
510
+ "Generate a new session key."
511
+ )
512
+ },
513
+ )
514
+
515
+ # Store session data in request state for WebSocket handler
516
+ request.state.websocket_session = session_data
517
+ logger.debug(
518
+ f"✅ WebSocket session key validated for {path} "
519
+ f"(user: {session_data.get('user_id')})"
520
+ )
521
+ except (
522
+ ValueError,
523
+ TypeError,
524
+ AttributeError,
525
+ RuntimeError,
526
+ ):
527
+ logger.exception("Error validating WebSocket session key")
528
+ return JSONResponse(
529
+ status_code=status.HTTP_403_FORBIDDEN,
530
+ content={"detail": "WebSocket session validation error"},
531
+ )
532
+ else:
533
+ # Fallback to cookie-based CSRF (backward compatibility)
534
+ csrf_cookie_token = request.cookies.get(self.cookie_name)
535
+ if not csrf_cookie_token:
536
+ logger.error(
537
+ f"❌ WebSocket upgrade missing CSRF cookie for {path}. "
538
+ f"Auth cookie present: {bool(auth_token_cookie)}, "
539
+ f"CSRF cookie name: {self.cookie_name}, "
540
+ f"Available cookies: {list(request.cookies.keys())}. "
541
+ f"Tip: Make a GET request first to receive CSRF cookie."
542
+ )
543
+ return JSONResponse(
544
+ status_code=status.HTTP_403_FORBIDDEN,
545
+ content={
546
+ "detail": (
547
+ "CSRF token missing for WebSocket authentication. "
548
+ "Make a GET request first to receive the CSRF cookie."
549
+ )
550
+ },
551
+ )
552
+
553
+ # Validate CSRF token signature if secret is used
554
+ if self.secret and not validate_csrf_token(
555
+ csrf_cookie_token, self.secret, self.token_ttl
556
+ ):
557
+ logger.error(f"❌ WebSocket CSRF token validation failed for {path}.")
558
+ return JSONResponse(
559
+ status_code=status.HTTP_403_FORBIDDEN,
560
+ content={
561
+ "detail": (
562
+ "CSRF token expired or invalid for WebSocket connection"
563
+ )
564
+ },
565
+ )
566
+
567
+ # If CSRF header is provided, validate it matches the cookie
568
+ # (Header is optional for WebSocket, but if present, must match cookie)
569
+ csrf_header_token = request.headers.get(self.header_name)
570
+ if csrf_header_token:
571
+ if not hmac.compare_digest(csrf_cookie_token, csrf_header_token):
572
+ logger.error(
573
+ f"❌ WebSocket CSRF header mismatch for {path}. "
574
+ f"Cookie token and header token do not match."
575
+ )
576
+ return JSONResponse(
577
+ status_code=status.HTTP_403_FORBIDDEN,
578
+ content={
579
+ "detail": (
580
+ "CSRF token mismatch: header token does not "
581
+ "match cookie token"
582
+ )
583
+ },
584
+ )
585
+ logger.debug(
586
+ f"✅ CSRF header validated and matches cookie for WebSocket {path}"
587
+ )
588
+
589
+ logger.debug(f"✅ CSRF cookie validation passed for WebSocket {path}")
590
+ else:
591
+ logger.debug(
592
+ f"✅ WebSocket CSRF validation skipped for {path} "
593
+ f"(csrf_required=false, Origin validation sufficient)"
346
594
  )
347
595
 
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
596
  logger.debug(
361
597
  f"WebSocket upgrade CSRF validation passed for {path} "
362
- f"(Origin validated, CSRF cookie present)"
598
+ f"(Origin validated, CSRF validated)"
363
599
  )
364
600
 
365
601
  # Origin validated (and CSRF validated if authenticated)
@@ -120,6 +120,7 @@ class SharedUserPool:
120
120
  token_expiry_hours: int = DEFAULT_TOKEN_EXPIRY_HOURS,
121
121
  allow_insecure_dev: bool = False,
122
122
  blacklist_fail_closed: bool = True,
123
+ websocket_session_manager: Any | None = None,
123
124
  ):
124
125
  """
125
126
  Initialize the shared user pool.
@@ -174,6 +175,7 @@ class SharedUserPool:
174
175
 
175
176
  self._token_expiry_hours = token_expiry_hours
176
177
  self._blacklist_indexes_created = False
178
+ self._websocket_session_manager = websocket_session_manager
177
179
 
178
180
  logger.info(f"SharedUserPool initialized (algorithm={jwt_algorithm})")
179
181
 
@@ -340,7 +342,9 @@ class SharedUserPool:
340
342
  ip_address: str | None = None,
341
343
  fingerprint: str | None = None,
342
344
  session_binding: dict[str, Any] | None = None,
343
- ) -> str | None:
345
+ generate_websocket_session: bool = True,
346
+ app_slug: str | None = None,
347
+ ) -> str | tuple[str, str] | None:
344
348
  """
345
349
  Authenticate user and return JWT token.
346
350
 
@@ -352,9 +356,14 @@ class SharedUserPool:
352
356
  session_binding: Session binding config from manifest:
353
357
  - bind_ip: Include IP in token claims
354
358
  - bind_fingerprint: Include fingerprint in token claims
359
+ generate_websocket_session: If True and WebSocket session manager available,
360
+ also generate WebSocket session key (default: True)
361
+ app_slug: Optional app slug for WebSocket session scoping
355
362
 
356
363
  Returns:
357
- JWT token if authentication succeeds, None otherwise
364
+ JWT token if authentication succeeds, None otherwise.
365
+ If generate_websocket_session=True and session manager available,
366
+ returns tuple (jwt_token, websocket_session_key), otherwise just jwt_token.
358
367
  """
359
368
  user = await self._collection.find_one(
360
369
  {
@@ -392,7 +401,28 @@ class SharedUserPool:
392
401
  # Generate JWT token with session binding claims
393
402
  token = self._generate_token(user, extra_claims=extra_claims or None)
394
403
 
404
+ # Generate WebSocket session key if requested and manager available
405
+ websocket_session_key = None
406
+ if generate_websocket_session and self._websocket_session_manager:
407
+ try:
408
+ user_id = str(user["_id"])
409
+ websocket_session_key = await self._websocket_session_manager.create_session(
410
+ user_id=user_id,
411
+ user_email=email,
412
+ app_slug=app_slug,
413
+ )
414
+ logger.debug(
415
+ f"Generated WebSocket session key for user '{email}' " f"(app: {app_slug})"
416
+ )
417
+ except (ValueError, TypeError, AttributeError, RuntimeError) as e:
418
+ # Log but don't fail authentication if WebSocket session generation fails
419
+ logger.warning(f"Failed to generate WebSocket session key: {e}")
420
+
395
421
  logger.info(f"User '{email}' authenticated successfully")
422
+
423
+ # Return tuple if WebSocket session key was generated, otherwise just token
424
+ if websocket_session_key:
425
+ return (token, websocket_session_key)
396
426
  return token
397
427
 
398
428
  async def validate_token(self, token: str) -> dict[str, Any] | None:
mdb_engine/auth/utils.py CHANGED
@@ -514,16 +514,41 @@ async def login_user(
514
514
  ip_address=device_info.get("ip_address"),
515
515
  )
516
516
 
517
+ # Generate WebSocket session key if WebSocket session manager available
518
+ websocket_session_key = None
519
+ try:
520
+ # Try to get WebSocket session manager from app state
521
+ app = getattr(request, "app", None)
522
+ if app:
523
+ websocket_session_manager = getattr(app.state, "websocket_session_manager", None)
524
+ if websocket_session_manager:
525
+ # Get app slug from request state if available
526
+ app_slug = getattr(request.state, "app_slug", None)
527
+ websocket_session_key = await websocket_session_manager.create_session(
528
+ user_id=str(user["_id"]),
529
+ user_email=user["email"],
530
+ app_slug=app_slug,
531
+ )
532
+ logger.debug(
533
+ f"Generated WebSocket session key for user '{user['email']}' "
534
+ f"(app: {app_slug})"
535
+ )
536
+ except (ValueError, TypeError, AttributeError, RuntimeError) as e:
537
+ # Log but don't fail login if WebSocket session generation fails
538
+ logger.warning(f"Failed to generate WebSocket session key during login: {e}")
539
+
517
540
  # Create response
541
+ response_data = {
542
+ "success": True,
543
+ "user": {"email": user["email"], "user_id": str(user["_id"])},
544
+ }
545
+ if websocket_session_key:
546
+ response_data["websocket_session_key"] = websocket_session_key
547
+
518
548
  if redirect_url:
519
549
  response = RedirectResponse(url=redirect_url, status_code=302)
520
550
  else:
521
- response = JSONResponse(
522
- {
523
- "success": True,
524
- "user": {"email": user["email"], "user_id": str(user["_id"])},
525
- }
526
- )
551
+ response = JSONResponse(response_data)
527
552
 
528
553
  # Set cookies
529
554
  set_auth_cookies(