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 +13 -9
- mdb_engine/auth/__init__.py +9 -0
- mdb_engine/auth/csrf.py +277 -41
- mdb_engine/auth/shared_users.py +32 -2
- mdb_engine/auth/utils.py +31 -6
- mdb_engine/auth/websocket_sessions.py +433 -0
- mdb_engine/core/engine.py +41 -1
- mdb_engine/core/manifest.py +12 -0
- mdb_engine/core/types.py +1 -0
- mdb_engine/routing/websockets.py +80 -15
- {mdb_engine-0.5.0.dist-info → mdb_engine-0.6.0.dist-info}/METADATA +1 -1
- {mdb_engine-0.5.0.dist-info → mdb_engine-0.6.0.dist-info}/RECORD +16 -15
- {mdb_engine-0.5.0.dist-info → mdb_engine-0.6.0.dist-info}/WHEEL +0 -0
- {mdb_engine-0.5.0.dist-info → mdb_engine-0.6.0.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.5.0.dist-info → mdb_engine-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.5.0.dist-info → mdb_engine-0.6.0.dist-info}/top_level.txt +0 -0
|
@@ -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":
|
|
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")
|
mdb_engine/core/manifest.py
CHANGED
|
@@ -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
mdb_engine/routing/websockets.py
CHANGED
|
@@ -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
|
-
|
|
371
|
-
|
|
372
|
-
-
|
|
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
|
-
#
|
|
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(
|
|
444
|
+
token = cookies.get(AUTH_COOKIE_NAME) # Use mdb_auth_token (same as shared middleware)
|
|
400
445
|
|
|
401
446
|
if not token:
|
|
402
|
-
logger.
|
|
403
|
-
f"No
|
|
447
|
+
logger.error(
|
|
448
|
+
f"❌ No authentication found for WebSocket connection to app '{app_slug}' "
|
|
404
449
|
f"(require_auth={require_auth}). "
|
|
405
|
-
f"
|
|
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}' "
|
|
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)
|
|
432
|
-
logger.
|
|
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.
|
|
697
|
-
f"WebSocket authentication
|
|
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
|