mdb-engine 0.1.6__py3-none-any.whl → 0.1.7__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 (75) hide show
  1. mdb_engine/__init__.py +38 -6
  2. mdb_engine/auth/README.md +534 -11
  3. mdb_engine/auth/__init__.py +129 -28
  4. mdb_engine/auth/audit.py +592 -0
  5. mdb_engine/auth/casbin_factory.py +10 -14
  6. mdb_engine/auth/config_helpers.py +7 -6
  7. mdb_engine/auth/cookie_utils.py +3 -7
  8. mdb_engine/auth/csrf.py +373 -0
  9. mdb_engine/auth/decorators.py +3 -10
  10. mdb_engine/auth/dependencies.py +37 -45
  11. mdb_engine/auth/helpers.py +3 -3
  12. mdb_engine/auth/integration.py +30 -73
  13. mdb_engine/auth/jwt.py +2 -6
  14. mdb_engine/auth/middleware.py +77 -34
  15. mdb_engine/auth/oso_factory.py +16 -36
  16. mdb_engine/auth/provider.py +17 -38
  17. mdb_engine/auth/rate_limiter.py +504 -0
  18. mdb_engine/auth/restrictions.py +8 -24
  19. mdb_engine/auth/session_manager.py +14 -29
  20. mdb_engine/auth/shared_middleware.py +600 -0
  21. mdb_engine/auth/shared_users.py +759 -0
  22. mdb_engine/auth/token_store.py +14 -28
  23. mdb_engine/auth/users.py +54 -113
  24. mdb_engine/auth/utils.py +213 -15
  25. mdb_engine/cli/commands/generate.py +545 -9
  26. mdb_engine/cli/commands/validate.py +3 -7
  27. mdb_engine/cli/utils.py +3 -3
  28. mdb_engine/config.py +7 -21
  29. mdb_engine/constants.py +65 -0
  30. mdb_engine/core/README.md +117 -6
  31. mdb_engine/core/__init__.py +39 -7
  32. mdb_engine/core/app_registration.py +22 -41
  33. mdb_engine/core/app_secrets.py +290 -0
  34. mdb_engine/core/connection.py +18 -9
  35. mdb_engine/core/encryption.py +223 -0
  36. mdb_engine/core/engine.py +758 -95
  37. mdb_engine/core/index_management.py +12 -16
  38. mdb_engine/core/manifest.py +424 -135
  39. mdb_engine/core/ray_integration.py +435 -0
  40. mdb_engine/core/seeding.py +10 -18
  41. mdb_engine/core/service_initialization.py +12 -23
  42. mdb_engine/core/types.py +2 -5
  43. mdb_engine/database/README.md +112 -16
  44. mdb_engine/database/__init__.py +17 -6
  45. mdb_engine/database/abstraction.py +25 -37
  46. mdb_engine/database/connection.py +11 -18
  47. mdb_engine/database/query_validator.py +367 -0
  48. mdb_engine/database/resource_limiter.py +204 -0
  49. mdb_engine/database/scoped_wrapper.py +713 -196
  50. mdb_engine/embeddings/__init__.py +17 -9
  51. mdb_engine/embeddings/dependencies.py +1 -3
  52. mdb_engine/embeddings/service.py +11 -25
  53. mdb_engine/exceptions.py +92 -0
  54. mdb_engine/indexes/README.md +30 -13
  55. mdb_engine/indexes/__init__.py +1 -0
  56. mdb_engine/indexes/helpers.py +1 -1
  57. mdb_engine/indexes/manager.py +50 -114
  58. mdb_engine/memory/README.md +2 -2
  59. mdb_engine/memory/__init__.py +1 -2
  60. mdb_engine/memory/service.py +30 -87
  61. mdb_engine/observability/README.md +4 -2
  62. mdb_engine/observability/__init__.py +26 -9
  63. mdb_engine/observability/health.py +8 -9
  64. mdb_engine/observability/metrics.py +32 -12
  65. mdb_engine/routing/README.md +1 -1
  66. mdb_engine/routing/__init__.py +1 -3
  67. mdb_engine/routing/websockets.py +25 -60
  68. mdb_engine-0.1.7.dist-info/METADATA +285 -0
  69. mdb_engine-0.1.7.dist-info/RECORD +85 -0
  70. mdb_engine-0.1.6.dist-info/METADATA +0 -213
  71. mdb_engine-0.1.6.dist-info/RECORD +0 -75
  72. {mdb_engine-0.1.6.dist-info → mdb_engine-0.1.7.dist-info}/WHEEL +0 -0
  73. {mdb_engine-0.1.6.dist-info → mdb_engine-0.1.7.dist-info}/entry_points.txt +0 -0
  74. {mdb_engine-0.1.6.dist-info → mdb_engine-0.1.7.dist-info}/licenses/LICENSE +0 -0
  75. {mdb_engine-0.1.6.dist-info → mdb_engine-0.1.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,600 @@
1
+ """
2
+ Shared Auth Middleware for Multi-App SSO
3
+
4
+ ASGI middleware that handles authentication for apps using "shared" auth mode.
5
+ Automatically validates JWT tokens and populates request.state with user info.
6
+
7
+ This module is part of MDB_ENGINE - MongoDB Engine.
8
+
9
+ Usage (auto-configured by engine.create_app() when auth.mode="shared"):
10
+ # Middleware is automatically added when manifest has auth.mode="shared"
11
+
12
+ # Access user in route handlers:
13
+ @app.get("/protected")
14
+ async def protected(request: Request):
15
+ user = request.state.user # None if not authenticated
16
+ if not user:
17
+ raise HTTPException(status_code=401)
18
+ return {"email": user["email"]}
19
+
20
+ Manual usage:
21
+ from mdb_engine.auth import SharedAuthMiddleware, SharedUserPool
22
+
23
+ pool = SharedUserPool(db)
24
+ app.add_middleware(
25
+ SharedAuthMiddleware,
26
+ user_pool=pool,
27
+ require_role="viewer",
28
+ public_routes=["/health", "/api/public/*"],
29
+ )
30
+ """
31
+
32
+ import fnmatch
33
+ import hashlib
34
+ import logging
35
+ from typing import Any, Callable, Dict, List, Optional
36
+
37
+ import jwt
38
+ from starlette.middleware.base import BaseHTTPMiddleware
39
+ from starlette.requests import Request
40
+ from starlette.responses import JSONResponse, Response
41
+
42
+ from .shared_users import SharedUserPool
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # Cookie and header names for JWT token
47
+ AUTH_COOKIE_NAME = "mdb_auth_token"
48
+ AUTH_HEADER_NAME = "Authorization"
49
+ AUTH_HEADER_PREFIX = "Bearer "
50
+
51
+
52
+ def _get_client_ip(request: Request) -> Optional[str]:
53
+ """Extract client IP address from request, handling proxies."""
54
+ # Check X-Forwarded-For header (behind load balancer/proxy)
55
+ forwarded_for = request.headers.get("x-forwarded-for")
56
+ if forwarded_for:
57
+ # Take the first IP (original client)
58
+ return forwarded_for.split(",")[0].strip()
59
+
60
+ # Check X-Real-IP header
61
+ real_ip = request.headers.get("x-real-ip")
62
+ if real_ip:
63
+ return real_ip
64
+
65
+ # Fall back to direct client
66
+ if request.client:
67
+ return request.client.host
68
+
69
+ return None
70
+
71
+
72
+ def _compute_fingerprint(request: Request) -> str:
73
+ """Compute a device fingerprint from request characteristics."""
74
+ components = [
75
+ request.headers.get("user-agent", ""),
76
+ request.headers.get("accept-language", ""),
77
+ request.headers.get("accept-encoding", ""),
78
+ ]
79
+ fingerprint_string = "|".join(components)
80
+ return hashlib.sha256(fingerprint_string.encode()).hexdigest()
81
+
82
+
83
+ class SharedAuthMiddleware(BaseHTTPMiddleware):
84
+ """
85
+ Middleware for shared authentication across multi-app deployments.
86
+
87
+ Features:
88
+ - Reads JWT from cookie or Authorization header
89
+ - Validates token and populates request.state.user
90
+ - Checks role requirements if configured
91
+ - Skips authentication for public routes
92
+ - Returns 401/403 JSON responses for auth failures
93
+
94
+ The middleware sets:
95
+ - request.state.user: Dict with user info (or None if not authenticated)
96
+ - request.state.user_roles: List of user's roles for current app
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ app: Callable,
102
+ user_pool: Optional[SharedUserPool],
103
+ 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,
108
+ cookie_name: str = AUTH_COOKIE_NAME,
109
+ header_name: str = AUTH_HEADER_NAME,
110
+ header_prefix: str = AUTH_HEADER_PREFIX,
111
+ ):
112
+ """
113
+ Initialize shared auth middleware.
114
+
115
+ Args:
116
+ app: ASGI application
117
+ user_pool: SharedUserPool instance (optional for lazy loading)
118
+ app_slug: Current app's slug (for role checking)
119
+ require_role: Role required to access this app (None = no role check)
120
+ public_routes: List of route patterns that don't require auth.
121
+ Supports wildcards, e.g., ["/health", "/api/public/*"]
122
+ role_hierarchy: Optional role hierarchy for inheritance
123
+ session_binding: Session binding configuration:
124
+ - bind_ip: Strict - reject if IP changes
125
+ - bind_fingerprint: Soft - log warning if fingerprint changes
126
+ - allow_ip_change_with_reauth: Allow IP change on re-authentication
127
+ cookie_name: Name of auth cookie (default: mdb_auth_token)
128
+ header_name: Name of auth header (default: Authorization)
129
+ header_prefix: Prefix for header value (default: "Bearer ")
130
+ """
131
+ super().__init__(app)
132
+ self._user_pool = user_pool
133
+ self._app_slug = app_slug
134
+ self._require_role = require_role
135
+ self._public_routes = public_routes or []
136
+ self._role_hierarchy = role_hierarchy
137
+ self._session_binding = session_binding or {}
138
+ self._cookie_name = cookie_name
139
+ self._header_name = header_name
140
+ self._header_prefix = header_prefix
141
+
142
+ logger.info(
143
+ f"SharedAuthMiddleware initialized for '{app_slug}' "
144
+ f"(require_role={require_role}, public_routes={len(self._public_routes)}, "
145
+ f"session_binding={bool(self._session_binding)})"
146
+ )
147
+
148
+ def get_user_pool(self, request: Request) -> Optional[SharedUserPool]:
149
+ """Get the user pool instance. Override in subclasses for lazy loading."""
150
+ return self._user_pool
151
+
152
+ async def dispatch(
153
+ self,
154
+ request: Request,
155
+ call_next: Callable[[Request], Response],
156
+ ) -> Response:
157
+ """Process request through auth middleware."""
158
+ # Initialize request state
159
+ request.state.user = None
160
+ request.state.user_roles = []
161
+
162
+ # Get user pool
163
+ user_pool = self.get_user_pool(request)
164
+ if not user_pool:
165
+ # User pool not available (e.g., lazy loading failed), skip auth if not strict
166
+ # But here we default to skipping for robustness if pool is missing
167
+ # However, for Lazy middleware, we want to skip if not initialized yet
168
+ return await call_next(request)
169
+
170
+ is_public = self._is_public_route(request.url.path)
171
+
172
+ # Extract token from cookie or header
173
+ token = self._extract_token(request)
174
+
175
+ if not token:
176
+ # No token provided
177
+ if not is_public and self._require_role:
178
+ return self._unauthorized_response("Authentication required")
179
+ # No role required or public route, continue without user
180
+ return await call_next(request)
181
+
182
+ # Validate token and get user
183
+ user = await user_pool.validate_token(token)
184
+
185
+ if not user:
186
+ # Invalid token - for public routes, continue without user
187
+ if is_public:
188
+ return await call_next(request)
189
+ return self._unauthorized_response("Invalid or expired token")
190
+
191
+ # Validate session binding if configured
192
+ binding_error = await self._validate_session_binding(request, token)
193
+ if binding_error:
194
+ if is_public:
195
+ # For public routes, log but continue
196
+ logger.warning(f"Session binding mismatch on public route: {binding_error}")
197
+ else:
198
+ return self._forbidden_response(binding_error)
199
+
200
+ # Set user on request state
201
+ request.state.user = user
202
+ request.state.user_roles = SharedUserPool.get_user_roles_for_app(user, self._app_slug)
203
+
204
+ # Check role requirement (only for non-public routes)
205
+ if not is_public and self._require_role:
206
+ if not SharedUserPool.user_has_role(
207
+ user,
208
+ self._app_slug,
209
+ self._require_role,
210
+ self._role_hierarchy,
211
+ ):
212
+ return self._forbidden_response(
213
+ f"Role '{self._require_role}' required for this app"
214
+ )
215
+
216
+ return await call_next(request)
217
+
218
+ async def _validate_session_binding(
219
+ self,
220
+ request: Request,
221
+ token: str,
222
+ ) -> Optional[str]:
223
+ """
224
+ Validate session binding claims in token.
225
+
226
+ Returns error message if validation fails, None if OK.
227
+ """
228
+ if not self._session_binding:
229
+ return None
230
+
231
+ try:
232
+ # Decode token without verification to get claims
233
+ # (verification already done in validate_token)
234
+ payload = jwt.decode(token, options={"verify_signature": False})
235
+
236
+ # Check IP binding
237
+ if self._session_binding.get("bind_ip", False):
238
+ token_ip = payload.get("ip")
239
+ if token_ip:
240
+ client_ip = _get_client_ip(request)
241
+ if client_ip and client_ip != token_ip:
242
+ logger.warning(f"Session IP mismatch: token={token_ip}, client={client_ip}")
243
+ return "Session bound to different IP address"
244
+
245
+ # Check fingerprint binding (soft check - just warn)
246
+ if self._session_binding.get("bind_fingerprint", True):
247
+ token_fp = payload.get("fp")
248
+ if token_fp:
249
+ client_fp = _compute_fingerprint(request)
250
+ if client_fp != token_fp:
251
+ logger.warning(
252
+ f"Session fingerprint mismatch for user {payload.get('email')}"
253
+ )
254
+ # Soft check - don't reject, just log
255
+ # Could be legitimate (browser update, different device)
256
+
257
+ return None
258
+
259
+ except jwt.InvalidTokenError as e:
260
+ logger.warning(f"Error validating session binding: {e}")
261
+ return None # Don't reject for binding check errors
262
+
263
+ def _extract_token(self, request: Request) -> Optional[str]:
264
+ """Extract JWT token from cookie or header."""
265
+ # Try cookie first
266
+ token = request.cookies.get(self._cookie_name)
267
+ if token:
268
+ return token
269
+
270
+ # Try Authorization header
271
+ auth_header = request.headers.get(self._header_name)
272
+ if auth_header and auth_header.startswith(self._header_prefix):
273
+ return auth_header[len(self._header_prefix) :]
274
+
275
+ return None
276
+
277
+ def _is_public_route(self, path: str) -> bool:
278
+ """Check if path matches any public route pattern."""
279
+ for pattern in self._public_routes:
280
+ # Normalize pattern for fnmatch
281
+ if not pattern.startswith("/"):
282
+ pattern = "/" + pattern
283
+
284
+ # Check exact match
285
+ if path == pattern:
286
+ return True
287
+
288
+ # Check wildcard match
289
+ if fnmatch.fnmatch(path, pattern):
290
+ return True
291
+
292
+ # Check prefix match for patterns ending with /*
293
+ if pattern.endswith("/*"):
294
+ prefix = pattern[:-2]
295
+ if path.startswith(prefix):
296
+ return True
297
+
298
+ return False
299
+
300
+ @staticmethod
301
+ def _unauthorized_response(detail: str) -> JSONResponse:
302
+ """Return 401 Unauthorized response."""
303
+ return JSONResponse(
304
+ status_code=401,
305
+ content={"detail": detail, "error": "unauthorized"},
306
+ )
307
+
308
+ @staticmethod
309
+ def _forbidden_response(detail: str) -> JSONResponse:
310
+ """Return 403 Forbidden response."""
311
+ return JSONResponse(
312
+ status_code=403,
313
+ content={"detail": detail, "error": "forbidden"},
314
+ )
315
+
316
+
317
+ def create_shared_auth_middleware(
318
+ user_pool: SharedUserPool,
319
+ app_slug: str,
320
+ manifest_auth: Dict[str, Any],
321
+ ) -> type:
322
+ """
323
+ Factory function to create SharedAuthMiddleware configured from manifest.
324
+
325
+ Args:
326
+ user_pool: SharedUserPool instance
327
+ app_slug: Current app's slug
328
+ manifest_auth: Auth section from manifest
329
+
330
+ Returns:
331
+ Configured middleware class ready to add to FastAPI app
332
+
333
+ Usage:
334
+ middleware_class = create_shared_auth_middleware(pool, "my_app", manifest["auth"])
335
+ app.add_middleware(middleware_class)
336
+ """
337
+ require_role = manifest_auth.get("require_role")
338
+ public_routes = manifest_auth.get("public_routes", [])
339
+
340
+ # Build role hierarchy from manifest if available
341
+ role_hierarchy = None
342
+ roles = manifest_auth.get("roles", [])
343
+ if roles and len(roles) > 1:
344
+ # Auto-generate hierarchy: each role inherits from roles below it
345
+ # e.g., roles=["viewer", "editor", "admin"] -> admin > editor > viewer
346
+ role_hierarchy = {}
347
+ for i, role in enumerate(roles):
348
+ if i > 0:
349
+ role_hierarchy[role] = roles[:i]
350
+
351
+ # Create a wrapper class with the configuration baked in
352
+ class ConfiguredSharedAuthMiddleware(SharedAuthMiddleware):
353
+ def __init__(self, app: Callable):
354
+ super().__init__(
355
+ app=app,
356
+ user_pool=user_pool,
357
+ app_slug=app_slug,
358
+ require_role=require_role,
359
+ public_routes=public_routes,
360
+ role_hierarchy=role_hierarchy,
361
+ )
362
+
363
+ return ConfiguredSharedAuthMiddleware
364
+
365
+
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.
372
+
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.
376
+
377
+ Args:
378
+ app_slug: Current app's slug
379
+ manifest_auth: Auth section from manifest
380
+
381
+ Returns:
382
+ Configured middleware class ready to add to FastAPI app
383
+
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)
388
+
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", [])
394
+
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]
404
+
405
+ # Session binding configuration
406
+ session_binding = manifest_auth.get("session_binding", {})
407
+
408
+ class LazySharedAuthMiddleware(BaseHTTPMiddleware):
409
+ """
410
+ Lazy version of SharedAuthMiddleware that gets user_pool from app.state.
411
+
412
+ This enables adding middleware at app creation time while deferring
413
+ the actual user pool initialization to the lifespan startup.
414
+ """
415
+
416
+ def __init__(self, app: Callable):
417
+ super().__init__(app)
418
+ self._app_slug = app_slug
419
+ self._require_role = require_role
420
+ self._public_routes = public_routes
421
+ self._role_hierarchy = role_hierarchy
422
+ self._session_binding = session_binding
423
+ self._cookie_name = AUTH_COOKIE_NAME
424
+ self._header_name = AUTH_HEADER_NAME
425
+ self._header_prefix = AUTH_HEADER_PREFIX
426
+
427
+ logger.info(
428
+ f"LazySharedAuthMiddleware initialized for '{app_slug}' "
429
+ f"(require_role={require_role}, public_routes={len(self._public_routes)}, "
430
+ f"session_binding={bool(self._session_binding)})"
431
+ )
432
+
433
+ async def dispatch(
434
+ self,
435
+ request: Request,
436
+ call_next: Callable[[Request], Response],
437
+ ) -> Response:
438
+ """Process request through auth middleware."""
439
+ # Initialize request state
440
+ request.state.user = None
441
+ request.state.user_roles = []
442
+
443
+ # Get user_pool from app.state (set during lifespan)
444
+ user_pool: Optional[SharedUserPool] = getattr(request.app.state, "user_pool", None)
445
+
446
+ if user_pool is None:
447
+ # User pool not initialized yet, skip auth
448
+ logger.warning(
449
+ f"LazySharedAuthMiddleware: user_pool not found on app.state for '{app_slug}'"
450
+ )
451
+ return await call_next(request)
452
+
453
+ is_public = self._is_public_route(request.url.path)
454
+
455
+ # Extract token from cookie or header
456
+ token = self._extract_token(request)
457
+
458
+ 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)
464
+
465
+ # Validate token and get user
466
+ user = await user_pool.validate_token(token)
467
+
468
+ 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")
473
+
474
+ # Validate session binding if configured
475
+ binding_error = await self._validate_session_binding(request, token)
476
+ 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)
482
+
483
+ # Set user on request state
484
+ request.state.user = user
485
+ request.state.user_roles = SharedUserPool.get_user_roles_for_app(user, self._app_slug)
486
+
487
+ # Check role requirement (only for non-public routes)
488
+ 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) :]
512
+
513
+ return None
514
+
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
529
+
530
+ # Check prefix match for patterns ending with /*
531
+ if pattern.endswith("/*"):
532
+ prefix = pattern[:-2]
533
+ if path.startswith(prefix):
534
+ return True
535
+
536
+ return False
537
+
538
+ @staticmethod
539
+ def _unauthorized_response(detail: str) -> JSONResponse:
540
+ """Return 401 Unauthorized response."""
541
+ return JSONResponse(
542
+ status_code=401,
543
+ content={"detail": detail, "error": "unauthorized"},
544
+ )
545
+
546
+ @staticmethod
547
+ def _forbidden_response(detail: str) -> JSONResponse:
548
+ """Return 403 Forbidden response."""
549
+ return JSONResponse(
550
+ status_code=403,
551
+ content={"detail": detail, "error": "forbidden"},
552
+ )
553
+
554
+ async def _validate_session_binding(
555
+ self,
556
+ request: Request,
557
+ token: str,
558
+ ) -> Optional[str]:
559
+ """
560
+ Validate session binding claims in token.
561
+
562
+ Returns error message if validation fails, None if OK.
563
+ """
564
+ if not self._session_binding:
565
+ return None
566
+
567
+ try:
568
+ # Decode token without verification to get claims
569
+ # (verification already done in validate_token)
570
+ payload = jwt.decode(token, options={"verify_signature": False})
571
+
572
+ # 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"
582
+
583
+ # 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
593
+
594
+ return None
595
+
596
+ except jwt.InvalidTokenError as e:
597
+ logger.warning(f"Error validating session binding: {e}")
598
+ return None # Don't reject for binding check errors
599
+
600
+ return LazySharedAuthMiddleware