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