mdb-engine 0.2.1__py3-none-any.whl → 0.2.4__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 (70) hide show
  1. mdb_engine/__init__.py +7 -1
  2. mdb_engine/auth/README.md +6 -0
  3. mdb_engine/auth/audit.py +40 -40
  4. mdb_engine/auth/base.py +3 -3
  5. mdb_engine/auth/casbin_factory.py +6 -6
  6. mdb_engine/auth/config_defaults.py +5 -5
  7. mdb_engine/auth/config_helpers.py +12 -12
  8. mdb_engine/auth/cookie_utils.py +9 -9
  9. mdb_engine/auth/csrf.py +9 -8
  10. mdb_engine/auth/decorators.py +7 -6
  11. mdb_engine/auth/dependencies.py +22 -21
  12. mdb_engine/auth/integration.py +9 -9
  13. mdb_engine/auth/jwt.py +9 -9
  14. mdb_engine/auth/middleware.py +4 -3
  15. mdb_engine/auth/oso_factory.py +6 -6
  16. mdb_engine/auth/provider.py +4 -4
  17. mdb_engine/auth/rate_limiter.py +12 -11
  18. mdb_engine/auth/restrictions.py +16 -15
  19. mdb_engine/auth/session_manager.py +11 -13
  20. mdb_engine/auth/shared_middleware.py +344 -132
  21. mdb_engine/auth/shared_users.py +20 -20
  22. mdb_engine/auth/token_lifecycle.py +10 -12
  23. mdb_engine/auth/token_store.py +4 -5
  24. mdb_engine/auth/users.py +51 -52
  25. mdb_engine/auth/utils.py +29 -33
  26. mdb_engine/cli/commands/generate.py +6 -6
  27. mdb_engine/cli/utils.py +4 -4
  28. mdb_engine/config.py +6 -7
  29. mdb_engine/core/app_registration.py +12 -12
  30. mdb_engine/core/app_secrets.py +1 -2
  31. mdb_engine/core/connection.py +3 -4
  32. mdb_engine/core/encryption.py +1 -2
  33. mdb_engine/core/engine.py +43 -44
  34. mdb_engine/core/manifest.py +80 -58
  35. mdb_engine/core/ray_integration.py +10 -9
  36. mdb_engine/core/seeding.py +3 -3
  37. mdb_engine/core/service_initialization.py +10 -9
  38. mdb_engine/core/types.py +40 -40
  39. mdb_engine/database/abstraction.py +15 -16
  40. mdb_engine/database/connection.py +40 -12
  41. mdb_engine/database/query_validator.py +8 -8
  42. mdb_engine/database/resource_limiter.py +7 -7
  43. mdb_engine/database/scoped_wrapper.py +51 -58
  44. mdb_engine/dependencies.py +14 -13
  45. mdb_engine/di/container.py +12 -13
  46. mdb_engine/di/providers.py +14 -13
  47. mdb_engine/di/scopes.py +5 -5
  48. mdb_engine/embeddings/dependencies.py +2 -2
  49. mdb_engine/embeddings/service.py +67 -50
  50. mdb_engine/exceptions.py +20 -20
  51. mdb_engine/indexes/helpers.py +11 -11
  52. mdb_engine/indexes/manager.py +9 -9
  53. mdb_engine/memory/README.md +93 -2
  54. mdb_engine/memory/service.py +361 -1109
  55. mdb_engine/observability/health.py +10 -9
  56. mdb_engine/observability/logging.py +10 -10
  57. mdb_engine/observability/metrics.py +8 -7
  58. mdb_engine/repositories/base.py +25 -25
  59. mdb_engine/repositories/mongo.py +17 -17
  60. mdb_engine/repositories/unit_of_work.py +6 -6
  61. mdb_engine/routing/websockets.py +19 -18
  62. mdb_engine/utils/__init__.py +3 -1
  63. mdb_engine/utils/mongo.py +117 -0
  64. {mdb_engine-0.2.1.dist-info → mdb_engine-0.2.4.dist-info}/METADATA +88 -13
  65. mdb_engine-0.2.4.dist-info/RECORD +97 -0
  66. {mdb_engine-0.2.1.dist-info → mdb_engine-0.2.4.dist-info}/WHEEL +1 -1
  67. mdb_engine-0.2.1.dist-info/RECORD +0 -96
  68. {mdb_engine-0.2.1.dist-info → mdb_engine-0.2.4.dist-info}/entry_points.txt +0 -0
  69. {mdb_engine-0.2.1.dist-info → mdb_engine-0.2.4.dist-info}/licenses/LICENSE +0 -0
  70. {mdb_engine-0.2.1.dist-info → mdb_engine-0.2.4.dist-info}/top_level.txt +0 -0
@@ -32,9 +32,11 @@ Manual usage:
32
32
  import fnmatch
33
33
  import hashlib
34
34
  import logging
35
- from typing import Any, Callable, Dict, List, Optional
35
+ from collections.abc import Callable
36
+ from typing import Any
36
37
 
37
38
  import jwt
39
+ from pymongo.errors import PyMongoError
38
40
  from starlette.middleware.base import BaseHTTPMiddleware
39
41
  from starlette.requests import Request
40
42
  from starlette.responses import JSONResponse, Response
@@ -49,7 +51,7 @@ AUTH_HEADER_NAME = "Authorization"
49
51
  AUTH_HEADER_PREFIX = "Bearer "
50
52
 
51
53
 
52
- def _get_client_ip(request: Request) -> Optional[str]:
54
+ def _get_client_ip(request: Request) -> str | None:
53
55
  """Extract client IP address from request, handling proxies."""
54
56
  # Check X-Forwarded-For header (behind load balancer/proxy)
55
57
  forwarded_for = request.headers.get("x-forwarded-for")
@@ -99,12 +101,12 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
99
101
  def __init__(
100
102
  self,
101
103
  app: Callable,
102
- user_pool: Optional[SharedUserPool],
104
+ user_pool: SharedUserPool | None,
103
105
  app_slug: str,
104
- require_role: Optional[str] = None,
105
- public_routes: Optional[List[str]] = None,
106
- role_hierarchy: Optional[Dict[str, List[str]]] = None,
107
- session_binding: Optional[Dict[str, Any]] = None,
106
+ require_role: str | None = None,
107
+ public_routes: list[str] | None = None,
108
+ role_hierarchy: dict[str, list[str]] | None = None,
109
+ session_binding: dict[str, Any] | None = None,
108
110
  cookie_name: str = AUTH_COOKIE_NAME,
109
111
  header_name: str = AUTH_HEADER_NAME,
110
112
  header_prefix: str = AUTH_HEADER_PREFIX,
@@ -145,7 +147,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
145
147
  f"session_binding={bool(self._session_binding)})"
146
148
  )
147
149
 
148
- def get_user_pool(self, request: Request) -> Optional[SharedUserPool]:
150
+ def get_user_pool(self, request: Request) -> SharedUserPool | None:
149
151
  """Get the user pool instance. Override in subclasses for lazy loading."""
150
152
  return self._user_pool
151
153
 
@@ -203,15 +205,63 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
203
205
 
204
206
  # Check role requirement (only for non-public routes)
205
207
  if not is_public and self._require_role:
206
- if not SharedUserPool.user_has_role(
208
+ user_roles = request.state.user_roles
209
+ has_required_role = SharedUserPool.user_has_role(
207
210
  user,
208
211
  self._app_slug,
209
212
  self._require_role,
210
213
  self._role_hierarchy,
211
- ):
212
- return self._forbidden_response(
213
- f"Role '{self._require_role}' required for this app"
214
- )
214
+ )
215
+
216
+ if not has_required_role:
217
+ # Auto-assign required role if user has no roles for this app
218
+ # This is a fallback for SSO scenarios where users might be authenticated
219
+ # but not yet assigned roles. Only do this if they have NO roles (not if
220
+ # they have other roles but not the required one - prevents privilege escalation).
221
+ if not user_roles:
222
+ user_email = user.get("email")
223
+ if user_email:
224
+ try:
225
+ # Auto-assign the required role
226
+ success = await user_pool.update_user_roles(
227
+ user_email, self._app_slug, [self._require_role]
228
+ )
229
+ if success:
230
+ # Refresh user data to include new role
231
+ user = await user_pool.get_user_by_email(user_email)
232
+ if user:
233
+ request.state.user = user
234
+ request.state.user_roles = [self._require_role]
235
+ logger.info(
236
+ f"Auto-assigned role '{self._require_role}' to user "
237
+ f"{user_email} for app '{self._app_slug}'"
238
+ )
239
+ else:
240
+ logger.warning(
241
+ f"Failed to refresh user after auto-assigning role: "
242
+ f"{user_email}"
243
+ )
244
+ else:
245
+ logger.warning(
246
+ f"Failed to auto-assign role '{self._require_role}' to "
247
+ f"user {user_email} for app '{self._app_slug}'"
248
+ )
249
+ except (PyMongoError, ValueError, AttributeError) as e:
250
+ logger.error(
251
+ f"Error auto-assigning role to user {user_email}: {e}",
252
+ exc_info=True,
253
+ )
254
+
255
+ # Check again after potential auto-assignment
256
+ if not SharedUserPool.user_has_role(
257
+ user,
258
+ self._app_slug,
259
+ self._require_role,
260
+ self._role_hierarchy,
261
+ ):
262
+ return self._forbidden_response(
263
+ f"Role '{self._require_role}' required for this app"
264
+ )
215
265
 
216
266
  return await call_next(request)
217
267
 
@@ -219,7 +269,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
219
269
  self,
220
270
  request: Request,
221
271
  token: str,
222
- ) -> Optional[str]:
272
+ ) -> str | None:
223
273
  """
224
274
  Validate session binding claims in token.
225
275
 
@@ -260,7 +310,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
260
310
  logger.warning(f"Error validating session binding: {e}")
261
311
  return None # Don't reject for binding check errors
262
312
 
263
- def _extract_token(self, request: Request) -> Optional[str]:
313
+ def _extract_token(self, request: Request) -> str | None:
264
314
  """Extract JWT token from cookie or header."""
265
315
  # Try cookie first
266
316
  token = request.cookies.get(self._cookie_name)
@@ -317,7 +367,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
317
367
  def create_shared_auth_middleware(
318
368
  user_pool: SharedUserPool,
319
369
  app_slug: str,
320
- manifest_auth: Dict[str, Any],
370
+ manifest_auth: dict[str, Any],
321
371
  ) -> type:
322
372
  """
323
373
  Factory function to create SharedAuthMiddleware configured from manifest.
@@ -363,47 +413,69 @@ def create_shared_auth_middleware(
363
413
  return ConfiguredSharedAuthMiddleware
364
414
 
365
415
 
366
- def create_shared_auth_middleware_lazy(
367
- app_slug: str,
368
- manifest_auth: Dict[str, Any],
369
- ) -> type:
370
- """
371
- Factory function to create a lazy SharedAuthMiddleware that reads user_pool from app.state.
416
+ def _build_role_hierarchy(manifest_auth: dict[str, Any]) -> dict[str, list[str]] | None:
417
+ """Build role hierarchy from manifest roles."""
418
+ roles = manifest_auth.get("roles", [])
419
+ if not roles or len(roles) <= 1:
420
+ return None
372
421
 
373
- This allows middleware to be added at app creation time (before startup),
374
- while the actual SharedUserPool is initialized during the lifespan.
375
- The middleware accesses `request.app.state.user_pool` at request time.
422
+ # Auto-generate hierarchy: each role inherits from roles below it
423
+ role_hierarchy = {}
424
+ for i, role in enumerate(roles):
425
+ if i > 0:
426
+ role_hierarchy[role] = roles[:i]
427
+ return role_hierarchy
428
+
429
+
430
+ def _is_public_route_helper(path: str, public_routes: list[str]) -> bool:
431
+ """Check if path matches any public route pattern."""
432
+ for pattern in public_routes:
433
+ # Normalize pattern for fnmatch
434
+ if not pattern.startswith("/"):
435
+ pattern = "/" + pattern
436
+
437
+ # Check exact match
438
+ if path == pattern:
439
+ return True
440
+
441
+ # Check wildcard match
442
+ if fnmatch.fnmatch(path, pattern):
443
+ return True
444
+
445
+ # Check prefix match for patterns ending with /*
446
+ if pattern.endswith("/*"):
447
+ prefix = pattern[:-2]
448
+ if path.startswith(prefix):
449
+ return True
376
450
 
377
- Args:
378
- app_slug: Current app's slug
379
- manifest_auth: Auth section from manifest
451
+ return False
380
452
 
381
- Returns:
382
- Configured middleware class ready to add to FastAPI app
383
453
 
384
- Usage:
385
- # At app creation time:
386
- middleware_class = create_shared_auth_middleware_lazy("my_app", manifest["auth"])
387
- app.add_middleware(middleware_class)
454
+ def _extract_token_helper(
455
+ request: Request, cookie_name: str, header_name: str, header_prefix: str
456
+ ) -> str | None:
457
+ """Extract JWT token from cookie or header."""
458
+ # Try cookie first
459
+ token = request.cookies.get(cookie_name)
460
+ if token:
461
+ return token
388
462
 
389
- # During lifespan startup:
390
- app.state.user_pool = SharedUserPool(db)
391
- """
392
- require_role = manifest_auth.get("require_role")
393
- public_routes = manifest_auth.get("public_routes", [])
463
+ # Try Authorization header
464
+ auth_header = request.headers.get(header_name)
465
+ if auth_header and auth_header.startswith(header_prefix):
466
+ return auth_header[len(header_prefix) :]
394
467
 
395
- # Build role hierarchy from manifest if available
396
- role_hierarchy = None
397
- roles = manifest_auth.get("roles", [])
398
- if roles and len(roles) > 1:
399
- # Auto-generate hierarchy: each role inherits from roles below it
400
- role_hierarchy = {}
401
- for i, role in enumerate(roles):
402
- if i > 0:
403
- role_hierarchy[role] = roles[:i]
468
+ return None
404
469
 
405
- # Session binding configuration
406
- session_binding = manifest_auth.get("session_binding", {})
470
+
471
+ def _create_lazy_middleware_class(
472
+ app_slug: str,
473
+ require_role: str | None,
474
+ public_routes: list[str],
475
+ role_hierarchy: dict[str, list[str]] | None,
476
+ session_binding: dict[str, Any],
477
+ ) -> type:
478
+ """Create the LazySharedAuthMiddleware class with configuration."""
407
479
 
408
480
  class LazySharedAuthMiddleware(BaseHTTPMiddleware):
409
481
  """
@@ -441,7 +513,7 @@ def create_shared_auth_middleware_lazy(
441
513
  request.state.user_roles = []
442
514
 
443
515
  # Get user_pool from app.state (set during lifespan)
444
- user_pool: Optional[SharedUserPool] = getattr(request.app.state, "user_pool", None)
516
+ user_pool: SharedUserPool | None = getattr(request.app.state, "user_pool", None)
445
517
 
446
518
  if user_pool is None:
447
519
  # User pool not initialized yet, skip auth
@@ -450,35 +522,44 @@ def create_shared_auth_middleware_lazy(
450
522
  )
451
523
  return await call_next(request)
452
524
 
453
- is_public = self._is_public_route(request.url.path)
454
-
455
- # Extract token from cookie or header
456
- token = self._extract_token(request)
525
+ is_public = _is_public_route_helper(request.url.path, self._public_routes)
526
+ token = _extract_token_helper(
527
+ request, self._cookie_name, self._header_name, self._header_prefix
528
+ )
457
529
 
530
+ # Handle unauthenticated requests
458
531
  if not token:
459
- # No token provided
460
- if not is_public and self._require_role:
461
- return self._unauthorized_response("Authentication required")
462
- # No role required or public route, continue without user
463
- return await call_next(request)
532
+ return await self._handle_no_token(is_public, request, call_next)
464
533
 
534
+ # Authenticate and authorize user
535
+ auth_result = await self._authenticate_and_authorize(
536
+ request, user_pool, token, is_public, call_next
537
+ )
538
+ if auth_result is not None:
539
+ return auth_result
540
+
541
+ return await call_next(request)
542
+
543
+ async def _authenticate_and_authorize(
544
+ self,
545
+ request: Request,
546
+ user_pool: SharedUserPool,
547
+ token: str,
548
+ is_public: bool,
549
+ call_next: Callable[[Request], Response],
550
+ ) -> Response | None:
551
+ """Authenticate user and check authorization."""
465
552
  # Validate token and get user
466
553
  user = await user_pool.validate_token(token)
467
-
468
554
  if not user:
469
- # Invalid token - for public routes, continue without user
470
- if is_public:
471
- return await call_next(request)
472
- return self._unauthorized_response("Invalid or expired token")
555
+ return await self._handle_invalid_token(is_public, request, call_next)
473
556
 
474
557
  # Validate session binding if configured
475
558
  binding_error = await self._validate_session_binding(request, token)
476
559
  if binding_error:
477
- if is_public:
478
- # For public routes, log but continue
479
- logger.warning(f"Session binding mismatch on public route: {binding_error}")
480
- else:
481
- return self._forbidden_response(binding_error)
560
+ return await self._handle_binding_error(
561
+ binding_error, is_public, request, call_next
562
+ )
482
563
 
483
564
  # Set user on request state
484
565
  request.state.user = user
@@ -486,54 +567,46 @@ def create_shared_auth_middleware_lazy(
486
567
 
487
568
  # Check role requirement (only for non-public routes)
488
569
  if not is_public and self._require_role:
489
- if not SharedUserPool.user_has_role(
490
- user,
491
- self._app_slug,
492
- self._require_role,
493
- self._role_hierarchy,
494
- ):
495
- return self._forbidden_response(
496
- f"Role '{self._require_role}' required for this app"
497
- )
498
-
499
- return await call_next(request)
500
-
501
- def _extract_token(self, request: Request) -> Optional[str]:
502
- """Extract JWT token from cookie or header."""
503
- # Try cookie first
504
- token = request.cookies.get(self._cookie_name)
505
- if token:
506
- return token
507
-
508
- # Try Authorization header
509
- auth_header = request.headers.get(self._header_name)
510
- if auth_header and auth_header.startswith(self._header_prefix):
511
- return auth_header[len(self._header_prefix) :]
570
+ role_check_result = await self._check_and_assign_role(user, user_pool, request)
571
+ if role_check_result is not None:
572
+ return role_check_result
512
573
 
513
574
  return None
514
575
 
515
- def _is_public_route(self, path: str) -> bool:
516
- """Check if path matches any public route pattern."""
517
- for pattern in self._public_routes:
518
- # Normalize pattern for fnmatch
519
- if not pattern.startswith("/"):
520
- pattern = "/" + pattern
521
-
522
- # Check exact match
523
- if path == pattern:
524
- return True
525
-
526
- # Check wildcard match
527
- if fnmatch.fnmatch(path, pattern):
528
- return True
576
+ async def _handle_no_token(
577
+ self,
578
+ is_public: bool,
579
+ request: Request,
580
+ call_next: Callable[[Request], Response],
581
+ ) -> Response:
582
+ """Handle request with no token."""
583
+ if not is_public and self._require_role:
584
+ return self._unauthorized_response("Authentication required")
585
+ return await call_next(request)
529
586
 
530
- # Check prefix match for patterns ending with /*
531
- if pattern.endswith("/*"):
532
- prefix = pattern[:-2]
533
- if path.startswith(prefix):
534
- return True
587
+ async def _handle_invalid_token(
588
+ self,
589
+ is_public: bool,
590
+ request: Request,
591
+ call_next: Callable[[Request], Response],
592
+ ) -> Response:
593
+ """Handle request with invalid token."""
594
+ if is_public:
595
+ return await call_next(request)
596
+ return self._unauthorized_response("Invalid or expired token")
535
597
 
536
- return False
598
+ async def _handle_binding_error(
599
+ self,
600
+ binding_error: str,
601
+ is_public: bool,
602
+ request: Request,
603
+ call_next: Callable[[Request], Response],
604
+ ) -> Response:
605
+ """Handle session binding validation error."""
606
+ if is_public:
607
+ logger.warning(f"Session binding mismatch on public route: {binding_error}")
608
+ return await call_next(request)
609
+ return self._forbidden_response(binding_error)
537
610
 
538
611
  @staticmethod
539
612
  def _unauthorized_response(detail: str) -> JSONResponse:
@@ -551,11 +624,98 @@ def create_shared_auth_middleware_lazy(
551
624
  content={"detail": detail, "error": "forbidden"},
552
625
  )
553
626
 
627
+ async def _check_and_assign_role(
628
+ self,
629
+ user: dict[str, Any],
630
+ user_pool: SharedUserPool,
631
+ request: Request,
632
+ ) -> Response | None:
633
+ """
634
+ Check if user has required role and auto-assign if needed.
635
+
636
+ Returns Response if access should be denied, None if OK.
637
+ """
638
+ user_roles = request.state.user_roles
639
+ has_required_role = SharedUserPool.user_has_role(
640
+ user,
641
+ self._app_slug,
642
+ self._require_role,
643
+ self._role_hierarchy,
644
+ )
645
+
646
+ if has_required_role:
647
+ return None
648
+
649
+ # Auto-assign required role if user has no roles for this app
650
+ if not user_roles:
651
+ await self._try_auto_assign_role(user, user_pool, request)
652
+
653
+ # Check again after potential auto-assignment
654
+ if not SharedUserPool.user_has_role(
655
+ user,
656
+ self._app_slug,
657
+ self._require_role,
658
+ self._role_hierarchy,
659
+ ):
660
+ return self._forbidden_response(
661
+ f"Role '{self._require_role}' required for this app"
662
+ )
663
+
664
+ return None
665
+
666
+ async def _try_auto_assign_role(
667
+ self,
668
+ user: dict[str, Any],
669
+ user_pool: SharedUserPool,
670
+ request: Request,
671
+ ) -> None:
672
+ """
673
+ Attempt to auto-assign required role to user.
674
+
675
+ This is a fallback for SSO scenarios where users might be authenticated
676
+ but not yet assigned roles. Only do this if they have NO roles (not if
677
+ they have other roles but not the required one - prevents privilege
678
+ escalation).
679
+ """
680
+ user_email = user.get("email")
681
+ if not user_email:
682
+ return
683
+
684
+ try:
685
+ # Auto-assign the required role
686
+ success = await user_pool.update_user_roles(
687
+ user_email, self._app_slug, [self._require_role]
688
+ )
689
+ if success:
690
+ # Refresh user data to include new role
691
+ updated_user = await user_pool.get_user_by_email(user_email)
692
+ if updated_user:
693
+ request.state.user = updated_user
694
+ request.state.user_roles = [self._require_role]
695
+ logger.info(
696
+ f"Auto-assigned role '{self._require_role}' to user "
697
+ f"{user_email} for app '{self._app_slug}'"
698
+ )
699
+ else:
700
+ logger.warning(
701
+ f"Failed to refresh user after auto-assigning role: " f"{user_email}"
702
+ )
703
+ else:
704
+ logger.warning(
705
+ f"Failed to auto-assign role '{self._require_role}' to "
706
+ f"user {user_email} for app '{self._app_slug}'"
707
+ )
708
+ except (PyMongoError, ValueError, AttributeError) as e:
709
+ logger.error(
710
+ f"Error auto-assigning role to user {user_email}: {e}",
711
+ exc_info=True,
712
+ )
713
+
554
714
  async def _validate_session_binding(
555
715
  self,
556
716
  request: Request,
557
717
  token: str,
558
- ) -> Optional[str]:
718
+ ) -> str | None:
559
719
  """
560
720
  Validate session binding claims in token.
561
721
 
@@ -570,26 +730,12 @@ def create_shared_auth_middleware_lazy(
570
730
  payload = jwt.decode(token, options={"verify_signature": False})
571
731
 
572
732
  # Check IP binding
573
- if self._session_binding.get("bind_ip", False):
574
- token_ip = payload.get("ip")
575
- if token_ip:
576
- client_ip = _get_client_ip(request)
577
- if client_ip and client_ip != token_ip:
578
- logger.warning(
579
- f"Session IP mismatch: token={token_ip}, client={client_ip}"
580
- )
581
- return "Session bound to different IP address"
733
+ ip_error = self._check_ip_binding(request, payload)
734
+ if ip_error:
735
+ return ip_error
582
736
 
583
737
  # Check fingerprint binding (soft check - just warn)
584
- if self._session_binding.get("bind_fingerprint", True):
585
- token_fp = payload.get("fp")
586
- if token_fp:
587
- client_fp = _compute_fingerprint(request)
588
- if client_fp != token_fp:
589
- logger.warning(
590
- f"Session fingerprint mismatch for user {payload.get('email')}"
591
- )
592
- # Soft check - don't reject, just log
738
+ self._check_fingerprint_binding(request, payload)
593
739
 
594
740
  return None
595
741
 
@@ -597,4 +743,70 @@ def create_shared_auth_middleware_lazy(
597
743
  logger.warning(f"Error validating session binding: {e}")
598
744
  return None # Don't reject for binding check errors
599
745
 
746
+ def _check_ip_binding(self, request: Request, payload: dict) -> str | None:
747
+ """Check IP binding from token payload."""
748
+ if not self._session_binding.get("bind_ip", False):
749
+ return None
750
+
751
+ token_ip = payload.get("ip")
752
+ if not token_ip:
753
+ return None
754
+
755
+ client_ip = _get_client_ip(request)
756
+ if client_ip and client_ip != token_ip:
757
+ logger.warning(f"Session IP mismatch: token={token_ip}, client={client_ip}")
758
+ return "Session bound to different IP address"
759
+
760
+ return None
761
+
762
+ def _check_fingerprint_binding(self, request: Request, payload: dict) -> None:
763
+ """Check fingerprint binding from token payload (soft check - just warn)."""
764
+ if not self._session_binding.get("bind_fingerprint", True):
765
+ return
766
+
767
+ token_fp = payload.get("fp")
768
+ if not token_fp:
769
+ return
770
+
771
+ client_fp = _compute_fingerprint(request)
772
+ if client_fp != token_fp:
773
+ logger.warning(f"Session fingerprint mismatch for user {payload.get('email')}")
774
+ # Soft check - don't reject, just log
775
+
600
776
  return LazySharedAuthMiddleware
777
+
778
+
779
+ def create_shared_auth_middleware_lazy(
780
+ app_slug: str,
781
+ manifest_auth: dict[str, Any],
782
+ ) -> type:
783
+ """
784
+ Factory function to create a lazy SharedAuthMiddleware that reads user_pool from app.state.
785
+
786
+ This allows middleware to be added at app creation time (before startup),
787
+ while the actual SharedUserPool is initialized during the lifespan.
788
+ The middleware accesses `request.app.state.user_pool` at request time.
789
+
790
+ Args:
791
+ app_slug: Current app's slug
792
+ manifest_auth: Auth section from manifest
793
+
794
+ Returns:
795
+ Configured middleware class ready to add to FastAPI app
796
+
797
+ Usage:
798
+ # At app creation time:
799
+ middleware_class = create_shared_auth_middleware_lazy("my_app", manifest["auth"])
800
+ app.add_middleware(middleware_class)
801
+
802
+ # During lifespan startup:
803
+ app.state.user_pool = SharedUserPool(db)
804
+ """
805
+ require_role = manifest_auth.get("require_role")
806
+ public_routes = manifest_auth.get("public_routes", [])
807
+ role_hierarchy = _build_role_hierarchy(manifest_auth)
808
+ session_binding = manifest_auth.get("session_binding", {})
809
+
810
+ return _create_lazy_middleware_class(
811
+ app_slug, require_role, public_routes, role_hierarchy, session_binding
812
+ )