mdb-engine 0.1.6__py3-none-any.whl → 0.4.12__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.
Files changed (92) hide show
  1. mdb_engine/__init__.py +116 -11
  2. mdb_engine/auth/ARCHITECTURE.md +112 -0
  3. mdb_engine/auth/README.md +654 -11
  4. mdb_engine/auth/__init__.py +136 -29
  5. mdb_engine/auth/audit.py +592 -0
  6. mdb_engine/auth/base.py +252 -0
  7. mdb_engine/auth/casbin_factory.py +265 -70
  8. mdb_engine/auth/config_defaults.py +5 -5
  9. mdb_engine/auth/config_helpers.py +19 -18
  10. mdb_engine/auth/cookie_utils.py +12 -16
  11. mdb_engine/auth/csrf.py +483 -0
  12. mdb_engine/auth/decorators.py +10 -16
  13. mdb_engine/auth/dependencies.py +69 -71
  14. mdb_engine/auth/helpers.py +3 -3
  15. mdb_engine/auth/integration.py +61 -88
  16. mdb_engine/auth/jwt.py +11 -15
  17. mdb_engine/auth/middleware.py +79 -35
  18. mdb_engine/auth/oso_factory.py +21 -41
  19. mdb_engine/auth/provider.py +270 -171
  20. mdb_engine/auth/rate_limiter.py +505 -0
  21. mdb_engine/auth/restrictions.py +21 -36
  22. mdb_engine/auth/session_manager.py +24 -41
  23. mdb_engine/auth/shared_middleware.py +977 -0
  24. mdb_engine/auth/shared_users.py +775 -0
  25. mdb_engine/auth/token_lifecycle.py +10 -12
  26. mdb_engine/auth/token_store.py +17 -32
  27. mdb_engine/auth/users.py +99 -159
  28. mdb_engine/auth/utils.py +236 -42
  29. mdb_engine/cli/commands/generate.py +546 -10
  30. mdb_engine/cli/commands/validate.py +3 -7
  31. mdb_engine/cli/utils.py +7 -7
  32. mdb_engine/config.py +13 -28
  33. mdb_engine/constants.py +65 -0
  34. mdb_engine/core/README.md +117 -6
  35. mdb_engine/core/__init__.py +39 -7
  36. mdb_engine/core/app_registration.py +31 -50
  37. mdb_engine/core/app_secrets.py +289 -0
  38. mdb_engine/core/connection.py +20 -12
  39. mdb_engine/core/encryption.py +222 -0
  40. mdb_engine/core/engine.py +2862 -115
  41. mdb_engine/core/index_management.py +12 -16
  42. mdb_engine/core/manifest.py +628 -204
  43. mdb_engine/core/ray_integration.py +436 -0
  44. mdb_engine/core/seeding.py +13 -21
  45. mdb_engine/core/service_initialization.py +20 -30
  46. mdb_engine/core/types.py +40 -43
  47. mdb_engine/database/README.md +140 -17
  48. mdb_engine/database/__init__.py +17 -6
  49. mdb_engine/database/abstraction.py +37 -50
  50. mdb_engine/database/connection.py +51 -30
  51. mdb_engine/database/query_validator.py +367 -0
  52. mdb_engine/database/resource_limiter.py +204 -0
  53. mdb_engine/database/scoped_wrapper.py +747 -237
  54. mdb_engine/dependencies.py +427 -0
  55. mdb_engine/di/__init__.py +34 -0
  56. mdb_engine/di/container.py +247 -0
  57. mdb_engine/di/providers.py +206 -0
  58. mdb_engine/di/scopes.py +139 -0
  59. mdb_engine/embeddings/README.md +54 -24
  60. mdb_engine/embeddings/__init__.py +31 -24
  61. mdb_engine/embeddings/dependencies.py +38 -155
  62. mdb_engine/embeddings/service.py +78 -75
  63. mdb_engine/exceptions.py +104 -12
  64. mdb_engine/indexes/README.md +30 -13
  65. mdb_engine/indexes/__init__.py +1 -0
  66. mdb_engine/indexes/helpers.py +11 -11
  67. mdb_engine/indexes/manager.py +59 -123
  68. mdb_engine/memory/README.md +95 -4
  69. mdb_engine/memory/__init__.py +1 -2
  70. mdb_engine/memory/service.py +363 -1168
  71. mdb_engine/observability/README.md +4 -2
  72. mdb_engine/observability/__init__.py +26 -9
  73. mdb_engine/observability/health.py +17 -17
  74. mdb_engine/observability/logging.py +10 -10
  75. mdb_engine/observability/metrics.py +40 -19
  76. mdb_engine/repositories/__init__.py +34 -0
  77. mdb_engine/repositories/base.py +325 -0
  78. mdb_engine/repositories/mongo.py +233 -0
  79. mdb_engine/repositories/unit_of_work.py +166 -0
  80. mdb_engine/routing/README.md +1 -1
  81. mdb_engine/routing/__init__.py +1 -3
  82. mdb_engine/routing/websockets.py +41 -75
  83. mdb_engine/utils/__init__.py +3 -1
  84. mdb_engine/utils/mongo.py +117 -0
  85. mdb_engine-0.4.12.dist-info/METADATA +492 -0
  86. mdb_engine-0.4.12.dist-info/RECORD +97 -0
  87. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/WHEEL +1 -1
  88. mdb_engine-0.1.6.dist-info/METADATA +0 -213
  89. mdb_engine-0.1.6.dist-info/RECORD +0 -75
  90. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/entry_points.txt +0 -0
  91. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/licenses/LICENSE +0 -0
  92. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,775 @@
1
+ """
2
+ Shared User Pool for Multi-App SSO
3
+
4
+ Provides a centralized user pool that enables Single Sign-On (SSO) across
5
+ all apps using "shared" auth mode. Users authenticate once and can access
6
+ any app that uses shared auth mode (subject to role requirements).
7
+
8
+ This module is part of MDB_ENGINE - MongoDB Engine.
9
+
10
+ Security Features:
11
+ - JWT secret required (fail-fast validation)
12
+ - JTI (JWT ID) for token revocation support
13
+ - Token blacklist integration
14
+ - Secure cookie configuration helpers
15
+
16
+ Usage:
17
+ # Initialize shared user pool (JWT secret required!)
18
+ pool = SharedUserPool(mongo_db, jwt_secret="your-secret")
19
+ # Or use environment variable: MDB_ENGINE_JWT_SECRET
20
+
21
+ # For local development only:
22
+ pool = SharedUserPool(mongo_db, allow_insecure_dev=True)
23
+
24
+ # Register a new user
25
+ user = await pool.create_user(
26
+ email="user@example.com",
27
+ password="secure_password",
28
+ app_roles={"my_app": ["viewer"]}
29
+ )
30
+
31
+ # Authenticate and get JWT token
32
+ token = await pool.authenticate("user@example.com", "secure_password")
33
+
34
+ # Validate token and get user
35
+ user = await pool.validate_token(token)
36
+
37
+ # Revoke a token (e.g., on logout)
38
+ await pool.revoke_token(token)
39
+
40
+ # Check if user has required role for an app
41
+ has_access = pool.user_has_role(user, "my_app", "viewer")
42
+ """
43
+
44
+ import logging
45
+ import os
46
+ import secrets
47
+ from datetime import datetime, timedelta
48
+ from typing import TYPE_CHECKING, Any
49
+
50
+ import bcrypt
51
+ import jwt
52
+ from motor.motor_asyncio import AsyncIOMotorDatabase
53
+ from pymongo.errors import OperationFailure, PyMongoError
54
+
55
+ if TYPE_CHECKING:
56
+ from fastapi import Request
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ # Collection names
61
+ SHARED_USERS_COLLECTION = "_mdb_engine_shared_users"
62
+ TOKEN_BLACKLIST_COLLECTION = "_mdb_engine_token_blacklist"
63
+
64
+ # Default JWT settings
65
+ DEFAULT_JWT_ALGORITHM = "HS256"
66
+ DEFAULT_TOKEN_EXPIRY_HOURS = 24
67
+
68
+ # Supported JWT algorithms
69
+ SYMMETRIC_ALGORITHMS = {"HS256", "HS384", "HS512"}
70
+ ASYMMETRIC_ALGORITHMS = {"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}
71
+ SUPPORTED_ALGORITHMS = SYMMETRIC_ALGORITHMS | ASYMMETRIC_ALGORITHMS
72
+
73
+
74
+ class JWTSecretError(ValueError):
75
+ """Raised when JWT secret is missing or invalid."""
76
+
77
+ pass
78
+
79
+
80
+ class JWTKeyError(ValueError):
81
+ """Raised when JWT key configuration is invalid."""
82
+
83
+ pass
84
+
85
+
86
+ class SharedUserPool:
87
+ """
88
+ Manages a shared user pool for SSO across multi-app deployments.
89
+
90
+ Users are stored in a central collection with per-app role assignments.
91
+ JWT tokens are used for stateless session management.
92
+
93
+ Supports both symmetric (HS256) and asymmetric (RS256, ES256) algorithms:
94
+ - HS256: Uses shared secret for both signing and verification
95
+ - RS256: Uses RSA private key for signing, public key for verification
96
+ - ES256: Uses ECDSA private key for signing, public key for verification
97
+
98
+ Schema for user documents:
99
+ {
100
+ "_id": ObjectId,
101
+ "email": "user@example.com",
102
+ "password_hash": "bcrypt_hash",
103
+ "app_roles": {
104
+ "app_slug_1": ["role1", "role2"],
105
+ "app_slug_2": ["role3"]
106
+ },
107
+ "created_at": datetime,
108
+ "updated_at": datetime,
109
+ "last_login": datetime,
110
+ "is_active": bool
111
+ }
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ mongo_db: AsyncIOMotorDatabase,
117
+ jwt_secret: str | None = None,
118
+ jwt_public_key: str | None = None,
119
+ jwt_algorithm: str = DEFAULT_JWT_ALGORITHM,
120
+ token_expiry_hours: int = DEFAULT_TOKEN_EXPIRY_HOURS,
121
+ allow_insecure_dev: bool = False,
122
+ blacklist_fail_closed: bool = True,
123
+ ):
124
+ """
125
+ Initialize the shared user pool.
126
+
127
+ Args:
128
+ mongo_db: MongoDB database instance (raw, not scoped)
129
+ jwt_secret: Secret or private key for JWT signing. For HS256, this is
130
+ the shared secret. For RS256/ES256, this is the private key.
131
+ If not provided, reads from MDB_ENGINE_JWT_SECRET env var.
132
+ jwt_public_key: Public key for RS256/ES256 verification. If not provided,
133
+ reads from MDB_ENGINE_JWT_PUBLIC_KEY env var.
134
+ Not needed for HS256 (symmetric).
135
+ jwt_algorithm: JWT algorithm (default: HS256). Supported:
136
+ - HS256, HS384, HS512 (symmetric/HMAC)
137
+ - RS256, RS384, RS512 (RSA asymmetric)
138
+ - ES256, ES384, ES512 (ECDSA asymmetric)
139
+ token_expiry_hours: Token expiry in hours (default: 24)
140
+ allow_insecure_dev: Allow insecure auto-generated secret for local
141
+ development only. NEVER use in production!
142
+ blacklist_fail_closed: If True (default), reject tokens when blacklist check
143
+ fails (secure). If False, allow tokens when check fails
144
+ (availability). SECURITY: Default is True for security.
145
+
146
+ Raises:
147
+ JWTSecretError: If no JWT secret is provided and allow_insecure_dev=False
148
+ JWTKeyError: If asymmetric algorithm is used without proper keys
149
+ """
150
+ self._db = mongo_db
151
+ self._collection = mongo_db[SHARED_USERS_COLLECTION]
152
+ self._blacklist_collection = mongo_db[TOKEN_BLACKLIST_COLLECTION]
153
+ self._blacklist_fail_closed = blacklist_fail_closed
154
+
155
+ # Validate algorithm
156
+ if jwt_algorithm not in SUPPORTED_ALGORITHMS:
157
+ raise JWTKeyError(
158
+ f"Unsupported JWT algorithm: {jwt_algorithm}. "
159
+ f"Supported: {', '.join(sorted(SUPPORTED_ALGORITHMS))}"
160
+ )
161
+
162
+ self._jwt_algorithm = jwt_algorithm
163
+ self._is_asymmetric = jwt_algorithm in ASYMMETRIC_ALGORITHMS
164
+
165
+ # Load keys from params or environment
166
+ self._jwt_secret = jwt_secret or os.getenv("MDB_ENGINE_JWT_SECRET")
167
+ self._jwt_public_key = jwt_public_key or os.getenv("MDB_ENGINE_JWT_PUBLIC_KEY")
168
+
169
+ # Validate key configuration
170
+ if self._is_asymmetric:
171
+ self._setup_asymmetric_keys(allow_insecure_dev)
172
+ else:
173
+ self._setup_symmetric_key(allow_insecure_dev)
174
+
175
+ self._token_expiry_hours = token_expiry_hours
176
+ self._blacklist_indexes_created = False
177
+
178
+ logger.info(f"SharedUserPool initialized (algorithm={jwt_algorithm})")
179
+
180
+ def _setup_symmetric_key(self, allow_insecure_dev: bool) -> None:
181
+ """Set up symmetric key for HMAC algorithms (HS256, etc.)."""
182
+ if not self._jwt_secret:
183
+ if allow_insecure_dev:
184
+ # Generate ephemeral secret for development
185
+ self._jwt_secret = secrets.token_urlsafe(32)
186
+ logger.warning(
187
+ "⚠️ INSECURE: Using auto-generated JWT secret. "
188
+ "Tokens will be invalid after restart. "
189
+ "Set MDB_ENGINE_JWT_SECRET for production!"
190
+ )
191
+ else:
192
+ raise JWTSecretError(
193
+ "JWT secret required for SharedUserPool. "
194
+ "Set MDB_ENGINE_JWT_SECRET environment variable or pass jwt_secret parameter. "
195
+ "Generate a secure secret with: "
196
+ 'python -c "import secrets; print(secrets.token_urlsafe(32))" '
197
+ "For local development only, pass allow_insecure_dev=True."
198
+ )
199
+
200
+ # For symmetric, signing key = verification key
201
+ self._signing_key = self._jwt_secret
202
+ self._verification_key = self._jwt_secret
203
+
204
+ def _setup_asymmetric_keys(self, allow_insecure_dev: bool) -> None:
205
+ """Set up asymmetric keys for RSA/ECDSA algorithms (RS256, ES256, etc.)."""
206
+ if not self._jwt_secret:
207
+ if allow_insecure_dev:
208
+ logger.warning(
209
+ f"⚠️ INSECURE: {self._jwt_algorithm} requires a private key. "
210
+ f"Set MDB_ENGINE_JWT_SECRET with your private key in production!"
211
+ )
212
+ # We can't auto-generate RSA/ECDSA keys easily, so error out
213
+ raise JWTKeyError(
214
+ f"Private key required for {self._jwt_algorithm} algorithm. "
215
+ f"Set MDB_ENGINE_JWT_SECRET environment variable with your "
216
+ f"PEM-encoded private key. "
217
+ f"Asymmetric algorithms cannot auto-generate keys even in dev mode."
218
+ )
219
+ else:
220
+ raise JWTKeyError(
221
+ f"Private key required for {self._jwt_algorithm} algorithm. "
222
+ f"Set MDB_ENGINE_JWT_SECRET environment variable with your "
223
+ f"PEM-encoded private key."
224
+ )
225
+
226
+ if not self._jwt_public_key:
227
+ # For verification, we can derive public key from private in some cases,
228
+ # or require it explicitly for better security
229
+ logger.warning(
230
+ f"⚠️ No public key provided for {self._jwt_algorithm}. "
231
+ f"Token verification will use the private key (less secure). "
232
+ f"Set MDB_ENGINE_JWT_PUBLIC_KEY for better key separation."
233
+ )
234
+ # Use private key for both (PyJWT can handle this for RSA)
235
+ self._verification_key = self._jwt_secret
236
+ else:
237
+ self._verification_key = self._jwt_public_key
238
+
239
+ self._signing_key = self._jwt_secret
240
+
241
+ logger.info(
242
+ f"Asymmetric JWT configured: algorithm={self._jwt_algorithm}, "
243
+ f"public_key={'provided' if self._jwt_public_key else 'derived'}"
244
+ )
245
+
246
+ @property
247
+ def jwt_algorithm(self) -> str:
248
+ """Get the configured JWT algorithm."""
249
+ return self._jwt_algorithm
250
+
251
+ @property
252
+ def is_asymmetric(self) -> bool:
253
+ """Check if using asymmetric (public/private key) algorithm."""
254
+ return self._is_asymmetric
255
+
256
+ async def ensure_indexes(self) -> None:
257
+ """Create necessary indexes for the shared users and blacklist collections."""
258
+ try:
259
+ # Unique index on email
260
+ await self._collection.create_index("email", unique=True, name="email_unique_idx")
261
+ # Index for active users
262
+ await self._collection.create_index(
263
+ [("is_active", 1), ("email", 1)], name="active_email_idx"
264
+ )
265
+ logger.info("SharedUserPool user indexes ensured")
266
+ except OperationFailure as e:
267
+ logger.warning(f"Failed to create user indexes: {e}")
268
+
269
+ # Ensure blacklist indexes
270
+ await self._ensure_blacklist_indexes()
271
+
272
+ async def _ensure_blacklist_indexes(self) -> None:
273
+ """Create indexes for token blacklist collection."""
274
+ if self._blacklist_indexes_created:
275
+ return
276
+
277
+ try:
278
+ # Unique index on JTI for fast lookups
279
+ await self._blacklist_collection.create_index("jti", unique=True, name="jti_unique_idx")
280
+ # TTL index for automatic cleanup of expired entries
281
+ await self._blacklist_collection.create_index(
282
+ "expires_at", expireAfterSeconds=0, name="expires_at_ttl_idx"
283
+ )
284
+ self._blacklist_indexes_created = True
285
+ logger.info("SharedUserPool blacklist indexes ensured")
286
+ except OperationFailure as e:
287
+ logger.warning(f"Failed to create blacklist indexes: {e}")
288
+
289
+ async def create_user(
290
+ self,
291
+ email: str,
292
+ password: str,
293
+ app_roles: dict[str, list[str]] | None = None,
294
+ is_active: bool = True,
295
+ ) -> dict[str, Any]:
296
+ """
297
+ Create a new shared user.
298
+
299
+ Args:
300
+ email: User email (must be unique)
301
+ password: Plain text password (will be hashed)
302
+ app_roles: Dict of app_slug -> list of roles
303
+ is_active: Whether user is active (default: True)
304
+
305
+ Returns:
306
+ Created user document (without password_hash)
307
+
308
+ Raises:
309
+ ValueError: If email already exists
310
+ """
311
+ # Check if email already exists
312
+ existing = await self._collection.find_one({"email": email})
313
+ if existing:
314
+ raise ValueError(f"User with email '{email}' already exists")
315
+
316
+ # Hash password
317
+ password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
318
+
319
+ now = datetime.utcnow()
320
+ user_doc = {
321
+ "email": email,
322
+ "password_hash": password_hash,
323
+ "app_roles": app_roles or {},
324
+ "is_active": is_active,
325
+ "created_at": now,
326
+ "updated_at": now,
327
+ "last_login": None,
328
+ }
329
+
330
+ result = await self._collection.insert_one(user_doc)
331
+ user_doc["_id"] = result.inserted_id
332
+
333
+ # Return without password hash
334
+ return self._sanitize_user(user_doc)
335
+
336
+ async def authenticate(
337
+ self,
338
+ email: str,
339
+ password: str,
340
+ ip_address: str | None = None,
341
+ fingerprint: str | None = None,
342
+ session_binding: dict[str, Any] | None = None,
343
+ ) -> str | None:
344
+ """
345
+ Authenticate user and return JWT token.
346
+
347
+ Args:
348
+ email: User email
349
+ password: Plain text password
350
+ ip_address: Client IP address for session binding
351
+ fingerprint: Device fingerprint for session binding
352
+ session_binding: Session binding config from manifest:
353
+ - bind_ip: Include IP in token claims
354
+ - bind_fingerprint: Include fingerprint in token claims
355
+
356
+ Returns:
357
+ JWT token if authentication succeeds, None otherwise
358
+ """
359
+ user = await self._collection.find_one(
360
+ {
361
+ "email": email,
362
+ "is_active": True,
363
+ }
364
+ )
365
+
366
+ if not user:
367
+ logger.warning(f"Authentication failed: user '{email}' not found or inactive")
368
+ return None
369
+
370
+ # Verify password
371
+ if not bcrypt.checkpw(password.encode("utf-8"), user["password_hash"].encode("utf-8")):
372
+ logger.warning(f"Authentication failed: invalid password for '{email}'")
373
+ return None
374
+
375
+ # Update last login
376
+ await self._collection.update_one(
377
+ {"_id": user["_id"]}, {"$set": {"last_login": datetime.utcnow()}}
378
+ )
379
+
380
+ # Prepare extra claims for session binding
381
+ extra_claims = {}
382
+ session_binding = session_binding or {}
383
+
384
+ if session_binding.get("bind_ip", False) and ip_address:
385
+ extra_claims["ip"] = ip_address
386
+ logger.debug(f"Session bound to IP: {ip_address}")
387
+
388
+ if session_binding.get("bind_fingerprint", True) and fingerprint:
389
+ extra_claims["fp"] = fingerprint
390
+ logger.debug(f"Session bound to fingerprint: {fingerprint[:16]}...")
391
+
392
+ # Generate JWT token with session binding claims
393
+ token = self._generate_token(user, extra_claims=extra_claims or None)
394
+
395
+ logger.info(f"User '{email}' authenticated successfully")
396
+ return token
397
+
398
+ async def validate_token(self, token: str) -> dict[str, Any] | None:
399
+ """
400
+ Validate JWT token and return user data.
401
+
402
+ Validation steps:
403
+ 1. Decode and verify JWT signature (uses public key for asymmetric)
404
+ 2. Check if token is blacklisted (revoked)
405
+ 3. Fetch current user data (roles may have changed)
406
+
407
+ Args:
408
+ token: JWT token string
409
+
410
+ Returns:
411
+ User dict if token is valid, None otherwise
412
+ """
413
+ try:
414
+ payload = jwt.decode(token, self._verification_key, algorithms=[self._jwt_algorithm])
415
+
416
+ user_id = payload.get("sub")
417
+ if not user_id:
418
+ return None
419
+
420
+ # Check if token is revoked (blacklisted)
421
+ jti = payload.get("jti")
422
+ if jti and await self._is_token_revoked(jti):
423
+ logger.debug(f"Token validation failed: token revoked (jti={jti})")
424
+ return None
425
+
426
+ # Fetch current user data (roles may have changed)
427
+ from bson import ObjectId
428
+
429
+ user = await self._collection.find_one(
430
+ {
431
+ "_id": ObjectId(user_id),
432
+ "is_active": True,
433
+ }
434
+ )
435
+
436
+ if not user:
437
+ return None
438
+
439
+ return self._sanitize_user(user)
440
+
441
+ except jwt.ExpiredSignatureError:
442
+ logger.debug("Token validation failed: token expired")
443
+ return None
444
+ except jwt.InvalidTokenError as e:
445
+ logger.debug(f"Token validation failed: {e}")
446
+ return None
447
+
448
+ async def _is_token_revoked(self, jti: str) -> bool:
449
+ """Check if a token JTI is in the blacklist."""
450
+ try:
451
+ entry = await self._blacklist_collection.find_one({"jti": jti})
452
+ if entry:
453
+ # Double-check expiration (TTL index should handle this)
454
+ expires_at = entry.get("expires_at")
455
+ if expires_at and isinstance(expires_at, datetime):
456
+ return datetime.utcnow() < expires_at
457
+ return True # No expiration = permanently blacklisted
458
+ return False
459
+ except PyMongoError as e:
460
+ logger.exception(f"Error checking token blacklist: {e}")
461
+ # Fail closed for security by default (reject token if we can't verify)
462
+ if self._blacklist_fail_closed:
463
+ logger.warning(
464
+ "Token blacklist check failed - rejecting token for security "
465
+ "(blacklist_fail_closed=True)"
466
+ )
467
+ return True # Token IS revoked (reject access)
468
+ # Fail open only if explicitly configured (availability over security)
469
+ logger.warning(
470
+ "Token blacklist check failed - allowing token for availability "
471
+ "(blacklist_fail_closed=False). SECURITY RISK: Revoked tokens may be accepted."
472
+ )
473
+ return False # Token NOT revoked (allow access)
474
+
475
+ async def revoke_token(
476
+ self,
477
+ token: str,
478
+ reason: str = "logout",
479
+ ) -> bool:
480
+ """
481
+ Revoke a token by adding its JTI to the blacklist.
482
+
483
+ Args:
484
+ token: JWT token to revoke
485
+ reason: Reason for revocation (default: "logout")
486
+
487
+ Returns:
488
+ True if token was successfully revoked, False otherwise
489
+ """
490
+ try:
491
+ # Decode token to get JTI and expiration
492
+ payload = jwt.decode(
493
+ token,
494
+ self._verification_key,
495
+ algorithms=[self._jwt_algorithm],
496
+ options={"verify_exp": False}, # Allow revoking expired tokens
497
+ )
498
+
499
+ jti = payload.get("jti")
500
+ if not jti:
501
+ logger.warning("Cannot revoke token: no JTI claim")
502
+ return False
503
+
504
+ # Get expiration from token, or use default
505
+ exp_timestamp = payload.get("exp")
506
+ if exp_timestamp:
507
+ expires_at = datetime.utcfromtimestamp(exp_timestamp)
508
+ else:
509
+ expires_at = datetime.utcnow() + timedelta(days=7)
510
+
511
+ # Ensure blacklist indexes exist
512
+ await self._ensure_blacklist_indexes()
513
+
514
+ # Add to blacklist with upsert
515
+ await self._blacklist_collection.update_one(
516
+ {"jti": jti},
517
+ {
518
+ "$set": {
519
+ "jti": jti,
520
+ "user_id": payload.get("sub"),
521
+ "email": payload.get("email"),
522
+ "revoked_at": datetime.utcnow(),
523
+ "expires_at": expires_at,
524
+ "reason": reason,
525
+ }
526
+ },
527
+ upsert=True,
528
+ )
529
+
530
+ logger.info(f"Token revoked: jti={jti}, reason={reason}")
531
+ return True
532
+
533
+ except jwt.InvalidTokenError as e:
534
+ logger.warning(f"Cannot revoke invalid token: {e}")
535
+ return False
536
+ except PyMongoError as e:
537
+ logger.exception(f"Error revoking token: {e}")
538
+ return False
539
+
540
+ async def revoke_all_user_tokens(
541
+ self,
542
+ user_id: str,
543
+ reason: str = "logout_all",
544
+ ) -> None:
545
+ """
546
+ Revoke all tokens for a user by storing a revocation marker.
547
+
548
+ Note: This requires checking user's token_revoked_at during validation.
549
+ For immediate effect, consider using short token expiry + refresh tokens.
550
+
551
+ Args:
552
+ user_id: User ID to revoke tokens for
553
+ reason: Reason for revocation
554
+ """
555
+ await self._collection.update_one(
556
+ {"_id": user_id},
557
+ {
558
+ "$set": {
559
+ "tokens_revoked_at": datetime.utcnow(),
560
+ "tokens_revoked_reason": reason,
561
+ }
562
+ },
563
+ )
564
+ logger.info(f"All tokens revoked for user {user_id}: {reason}")
565
+
566
+ async def get_user_by_email(self, email: str) -> dict[str, Any] | None:
567
+ """Get user by email."""
568
+ user = await self._collection.find_one({"email": email})
569
+ if user:
570
+ return self._sanitize_user(user)
571
+ return None
572
+
573
+ async def update_user_roles(
574
+ self,
575
+ email: str,
576
+ app_slug: str,
577
+ roles: list[str],
578
+ ) -> bool:
579
+ """
580
+ Update a user's roles for a specific app.
581
+
582
+ Args:
583
+ email: User email
584
+ app_slug: App slug to update roles for
585
+ roles: New list of roles for this app
586
+
587
+ Returns:
588
+ True if updated, False if user not found
589
+ """
590
+ result = await self._collection.update_one(
591
+ {"email": email},
592
+ {
593
+ "$set": {
594
+ f"app_roles.{app_slug}": roles,
595
+ "updated_at": datetime.utcnow(),
596
+ }
597
+ },
598
+ )
599
+
600
+ if result.modified_count > 0:
601
+ logger.info(f"Updated roles for '{email}' in app '{app_slug}': {roles}")
602
+ return True
603
+ return False
604
+
605
+ async def remove_user_from_app(self, email: str, app_slug: str) -> bool:
606
+ """
607
+ Remove a user's access to a specific app.
608
+
609
+ Args:
610
+ email: User email
611
+ app_slug: App slug to remove access from
612
+
613
+ Returns:
614
+ True if updated, False if user not found
615
+ """
616
+ result = await self._collection.update_one(
617
+ {"email": email},
618
+ {
619
+ "$unset": {f"app_roles.{app_slug}": ""},
620
+ "$set": {"updated_at": datetime.utcnow()},
621
+ },
622
+ )
623
+
624
+ if result.modified_count > 0:
625
+ logger.info(f"Removed '{email}' from app '{app_slug}'")
626
+ return True
627
+ return False
628
+
629
+ async def deactivate_user(self, email: str) -> bool:
630
+ """Deactivate a user account."""
631
+ result = await self._collection.update_one(
632
+ {"email": email},
633
+ {
634
+ "$set": {
635
+ "is_active": False,
636
+ "updated_at": datetime.utcnow(),
637
+ }
638
+ },
639
+ )
640
+ return result.modified_count > 0
641
+
642
+ async def activate_user(self, email: str) -> bool:
643
+ """Activate a user account."""
644
+ result = await self._collection.update_one(
645
+ {"email": email},
646
+ {
647
+ "$set": {
648
+ "is_active": True,
649
+ "updated_at": datetime.utcnow(),
650
+ }
651
+ },
652
+ )
653
+ return result.modified_count > 0
654
+
655
+ @staticmethod
656
+ def user_has_role(
657
+ user: dict[str, Any],
658
+ app_slug: str,
659
+ required_role: str,
660
+ role_hierarchy: dict[str, list[str]] | None = None,
661
+ ) -> bool:
662
+ """
663
+ Check if user has a required role for an app.
664
+
665
+ Args:
666
+ user: User dict (from validate_token or get_user_by_email)
667
+ app_slug: App slug to check
668
+ required_role: Role to check for
669
+ role_hierarchy: Optional dict mapping roles to their inherited roles
670
+ e.g., {"admin": ["editor", "viewer"], "editor": ["viewer"]}
671
+
672
+ Returns:
673
+ True if user has the required role (directly or via hierarchy)
674
+ """
675
+ app_roles = user.get("app_roles", {}).get(app_slug, [])
676
+
677
+ # Direct role check
678
+ if required_role in app_roles:
679
+ return True
680
+
681
+ # Check via hierarchy
682
+ if role_hierarchy:
683
+ for user_role in app_roles:
684
+ inherited_roles = role_hierarchy.get(user_role, [])
685
+ if required_role in inherited_roles:
686
+ return True
687
+
688
+ return False
689
+
690
+ @staticmethod
691
+ def get_user_roles_for_app(
692
+ user: dict[str, Any],
693
+ app_slug: str,
694
+ ) -> list[str]:
695
+ """Get user's roles for a specific app."""
696
+ return user.get("app_roles", {}).get(app_slug, [])
697
+
698
+ def _generate_token(
699
+ self,
700
+ user: dict[str, Any],
701
+ extra_claims: dict[str, Any] | None = None,
702
+ ) -> str:
703
+ """
704
+ Generate JWT token for user with unique JTI for revocation support.
705
+
706
+ Token payload includes:
707
+ - sub: User ID
708
+ - email: User email
709
+ - jti: Unique token identifier (for revocation)
710
+ - iat: Issued at timestamp
711
+ - exp: Expiration timestamp
712
+ - Additional claims for session binding (ip, fingerprint) if provided
713
+
714
+ Args:
715
+ user: User document
716
+ extra_claims: Optional extra claims to include (e.g., ip, fingerprint)
717
+
718
+ Returns:
719
+ Signed JWT token string
720
+ """
721
+ now = datetime.utcnow()
722
+ payload = {
723
+ "sub": str(user["_id"]),
724
+ "email": user["email"],
725
+ "jti": secrets.token_urlsafe(16), # Unique token ID for revocation
726
+ "iat": now,
727
+ "exp": now + timedelta(hours=self._token_expiry_hours),
728
+ }
729
+
730
+ # Add extra claims (session binding info, etc.)
731
+ if extra_claims:
732
+ payload.update(extra_claims)
733
+
734
+ return jwt.encode(payload, self._signing_key, algorithm=self._jwt_algorithm)
735
+
736
+ @staticmethod
737
+ def _sanitize_user(user: dict[str, Any]) -> dict[str, Any]:
738
+ """Remove sensitive fields from user document."""
739
+ sanitized = dict(user)
740
+ sanitized.pop("password_hash", None)
741
+ # Convert ObjectId to string for JSON serialization
742
+ if "_id" in sanitized:
743
+ sanitized["_id"] = str(sanitized["_id"])
744
+ return sanitized
745
+
746
+ def get_secure_cookie_config(self, request: "Request") -> dict[str, Any]:
747
+ """
748
+ Get secure cookie settings for auth tokens.
749
+
750
+ Integrates with existing cookie_utils for environment-aware security settings.
751
+ Automatically enables Secure flag in production/HTTPS environments.
752
+
753
+ Args:
754
+ request: FastAPI Request object for environment detection
755
+
756
+ Returns:
757
+ Dict of cookie settings ready for response.set_cookie()
758
+
759
+ Usage:
760
+ cookie_config = user_pool.get_secure_cookie_config(request)
761
+ response.set_cookie(value=token, **cookie_config)
762
+ """
763
+ from .cookie_utils import get_secure_cookie_settings
764
+
765
+ base_settings = get_secure_cookie_settings(request)
766
+ return {
767
+ **base_settings,
768
+ "key": "mdb_auth_token",
769
+ "max_age": self._token_expiry_hours * 3600,
770
+ }
771
+
772
+ @property
773
+ def token_expiry_hours(self) -> int:
774
+ """Get token expiry duration in hours."""
775
+ return self._token_expiry_hours