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.
- mdb_engine/__init__.py +104 -11
- mdb_engine/auth/ARCHITECTURE.md +112 -0
- mdb_engine/auth/README.md +648 -11
- mdb_engine/auth/__init__.py +136 -29
- mdb_engine/auth/audit.py +592 -0
- mdb_engine/auth/base.py +252 -0
- mdb_engine/auth/casbin_factory.py +264 -69
- mdb_engine/auth/config_helpers.py +7 -6
- mdb_engine/auth/cookie_utils.py +3 -7
- mdb_engine/auth/csrf.py +373 -0
- mdb_engine/auth/decorators.py +3 -10
- mdb_engine/auth/dependencies.py +47 -50
- mdb_engine/auth/helpers.py +3 -3
- mdb_engine/auth/integration.py +53 -80
- mdb_engine/auth/jwt.py +2 -6
- mdb_engine/auth/middleware.py +77 -34
- mdb_engine/auth/oso_factory.py +18 -38
- mdb_engine/auth/provider.py +270 -171
- mdb_engine/auth/rate_limiter.py +504 -0
- mdb_engine/auth/restrictions.py +8 -24
- mdb_engine/auth/session_manager.py +14 -29
- mdb_engine/auth/shared_middleware.py +600 -0
- mdb_engine/auth/shared_users.py +759 -0
- mdb_engine/auth/token_store.py +14 -28
- mdb_engine/auth/users.py +54 -113
- mdb_engine/auth/utils.py +213 -15
- mdb_engine/cli/commands/generate.py +545 -9
- mdb_engine/cli/commands/validate.py +3 -7
- mdb_engine/cli/utils.py +3 -3
- mdb_engine/config.py +7 -21
- mdb_engine/constants.py +65 -0
- mdb_engine/core/README.md +117 -6
- mdb_engine/core/__init__.py +39 -7
- mdb_engine/core/app_registration.py +22 -41
- mdb_engine/core/app_secrets.py +290 -0
- mdb_engine/core/connection.py +18 -9
- mdb_engine/core/encryption.py +223 -0
- mdb_engine/core/engine.py +1057 -93
- mdb_engine/core/index_management.py +12 -16
- mdb_engine/core/manifest.py +459 -150
- mdb_engine/core/ray_integration.py +435 -0
- mdb_engine/core/seeding.py +10 -18
- mdb_engine/core/service_initialization.py +12 -23
- mdb_engine/core/types.py +2 -5
- mdb_engine/database/README.md +140 -17
- mdb_engine/database/__init__.py +17 -6
- mdb_engine/database/abstraction.py +25 -37
- mdb_engine/database/connection.py +11 -18
- mdb_engine/database/query_validator.py +367 -0
- mdb_engine/database/resource_limiter.py +204 -0
- mdb_engine/database/scoped_wrapper.py +713 -196
- mdb_engine/dependencies.py +426 -0
- mdb_engine/di/__init__.py +34 -0
- mdb_engine/di/container.py +248 -0
- mdb_engine/di/providers.py +205 -0
- mdb_engine/di/scopes.py +139 -0
- mdb_engine/embeddings/README.md +54 -24
- mdb_engine/embeddings/__init__.py +31 -24
- mdb_engine/embeddings/dependencies.py +37 -154
- mdb_engine/embeddings/service.py +11 -25
- mdb_engine/exceptions.py +92 -0
- mdb_engine/indexes/README.md +30 -13
- mdb_engine/indexes/__init__.py +1 -0
- mdb_engine/indexes/helpers.py +1 -1
- mdb_engine/indexes/manager.py +50 -114
- mdb_engine/memory/README.md +2 -2
- mdb_engine/memory/__init__.py +1 -2
- mdb_engine/memory/service.py +30 -87
- mdb_engine/observability/README.md +4 -2
- mdb_engine/observability/__init__.py +26 -9
- mdb_engine/observability/health.py +8 -9
- mdb_engine/observability/metrics.py +32 -12
- mdb_engine/repositories/__init__.py +34 -0
- mdb_engine/repositories/base.py +325 -0
- mdb_engine/repositories/mongo.py +233 -0
- mdb_engine/repositories/unit_of_work.py +166 -0
- mdb_engine/routing/README.md +1 -1
- mdb_engine/routing/__init__.py +1 -3
- mdb_engine/routing/websockets.py +25 -60
- mdb_engine-0.2.0.dist-info/METADATA +313 -0
- mdb_engine-0.2.0.dist-info/RECORD +96 -0
- mdb_engine-0.1.6.dist-info/METADATA +0 -213
- mdb_engine-0.1.6.dist-info/RECORD +0 -75
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/WHEEL +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.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
|