mcp-hangar 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 (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,360 @@
1
+ """JWT/OIDC authentication implementation.
2
+
3
+ Provides authenticator and token validator for JWT-based authentication
4
+ with OIDC support (JWKS validation, standard claims).
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import structlog
11
+
12
+ from ...domain.contracts.authentication import AuthRequest, IAuthenticator, ITokenValidator
13
+ from ...domain.exceptions import ExpiredCredentialsError, InvalidCredentialsError
14
+ from ...domain.value_objects import Principal, PrincipalId, PrincipalType
15
+
16
+ logger = structlog.get_logger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class OIDCConfig:
21
+ """OIDC provider configuration.
22
+
23
+ Attributes:
24
+ issuer: OIDC issuer URL (e.g., https://auth.company.com).
25
+ audience: Expected audience claim value.
26
+ jwks_uri: JWKS endpoint URL (auto-discovered if None).
27
+ client_id: Optional client ID for additional validation.
28
+ subject_claim: JWT claim for subject (default: sub).
29
+ groups_claim: JWT claim for groups (default: groups).
30
+ tenant_claim: JWT claim for tenant ID (default: tenant_id).
31
+ email_claim: JWT claim for email (default: email).
32
+ """
33
+
34
+ issuer: str
35
+ audience: str
36
+ jwks_uri: str | None = None
37
+ client_id: str | None = None
38
+
39
+ # Claim mappings
40
+ subject_claim: str = "sub"
41
+ groups_claim: str = "groups"
42
+ tenant_claim: str = "tenant_id"
43
+ email_claim: str = "email"
44
+
45
+
46
+ class JWTAuthenticator(IAuthenticator):
47
+ """Authenticates requests using JWT tokens (Bearer auth).
48
+
49
+ Expects JWT in the Authorization header with 'Bearer' scheme.
50
+ Validates signature, expiration, issuer, and audience.
51
+ """
52
+
53
+ def __init__(self, config: OIDCConfig, token_validator: ITokenValidator):
54
+ """Initialize the JWT authenticator.
55
+
56
+ Args:
57
+ config: OIDC configuration with issuer, audience, and claim mappings.
58
+ token_validator: Validator for JWT signature and structure.
59
+ """
60
+ self._config = config
61
+ self._validator = token_validator
62
+
63
+ def supports(self, request: AuthRequest) -> bool:
64
+ """Check if request has Bearer token."""
65
+ auth_header = request.headers.get("Authorization", "")
66
+ return auth_header.startswith("Bearer ")
67
+
68
+ def authenticate(self, request: AuthRequest) -> Principal:
69
+ """Authenticate using JWT token.
70
+
71
+ Args:
72
+ request: The authentication request with headers.
73
+
74
+ Returns:
75
+ Authenticated Principal extracted from JWT claims.
76
+
77
+ Raises:
78
+ InvalidCredentialsError: If token is invalid or malformed.
79
+ ExpiredCredentialsError: If token has expired.
80
+ """
81
+ auth_header = request.headers.get("Authorization", "")
82
+
83
+ if not auth_header.startswith("Bearer "):
84
+ raise InvalidCredentialsError(
85
+ message="Missing Bearer token",
86
+ auth_method="jwt",
87
+ )
88
+
89
+ token = auth_header[7:] # Remove "Bearer " prefix
90
+
91
+ if not token:
92
+ raise InvalidCredentialsError(
93
+ message="Empty Bearer token",
94
+ auth_method="jwt",
95
+ )
96
+
97
+ claims = self._validator.validate(token)
98
+
99
+ principal = self._claims_to_principal(claims)
100
+
101
+ logger.info(
102
+ "jwt_authenticated",
103
+ principal_id=principal.id.value,
104
+ principal_type=principal.type.value,
105
+ issuer=claims.get("iss"),
106
+ source_ip=request.source_ip,
107
+ )
108
+
109
+ return principal
110
+
111
+ def _claims_to_principal(self, claims: dict[str, Any]) -> Principal:
112
+ """Convert JWT claims to Principal.
113
+
114
+ Args:
115
+ claims: Validated JWT claims.
116
+
117
+ Returns:
118
+ Principal constructed from claims.
119
+
120
+ Raises:
121
+ InvalidCredentialsError: If required claims are missing.
122
+ """
123
+ subject = claims.get(self._config.subject_claim)
124
+ if not subject:
125
+ raise InvalidCredentialsError(
126
+ message=f"Missing {self._config.subject_claim} claim in JWT",
127
+ auth_method="jwt",
128
+ )
129
+
130
+ groups = claims.get(self._config.groups_claim, [])
131
+ if isinstance(groups, str):
132
+ groups = [groups]
133
+
134
+ tenant_id = claims.get(self._config.tenant_claim)
135
+ email = claims.get(self._config.email_claim)
136
+
137
+ return Principal(
138
+ id=PrincipalId(subject),
139
+ type=PrincipalType.USER,
140
+ tenant_id=tenant_id,
141
+ groups=frozenset(groups) if groups else frozenset(),
142
+ metadata={
143
+ "email": email,
144
+ "issuer": claims.get("iss"),
145
+ "issued_at": claims.get("iat"),
146
+ "expires_at": claims.get("exp"),
147
+ },
148
+ )
149
+
150
+
151
+ class JWKSTokenValidator(ITokenValidator):
152
+ """Validates JWT tokens using JWKS (JSON Web Key Set).
153
+
154
+ Lazily initializes the JWKS client on first validation.
155
+ Supports RS256 and ES256 algorithms.
156
+
157
+ Note: Requires PyJWT library to be installed.
158
+ """
159
+
160
+ def __init__(self, config: OIDCConfig):
161
+ """Initialize the JWKS validator.
162
+
163
+ Args:
164
+ config: OIDC configuration with issuer and optional JWKS URI.
165
+ """
166
+ self._config = config
167
+ self._jwks_client = None
168
+ self._jwks_uri: str | None = None
169
+
170
+ def validate(self, token: str) -> dict:
171
+ """Validate JWT and return claims.
172
+
173
+ Args:
174
+ token: The JWT string to validate.
175
+
176
+ Returns:
177
+ Dictionary of validated claims.
178
+
179
+ Raises:
180
+ InvalidCredentialsError: If token is invalid or malformed.
181
+ ExpiredCredentialsError: If token has expired.
182
+ """
183
+ try:
184
+ import jwt
185
+ except ImportError:
186
+ raise InvalidCredentialsError(
187
+ message="JWT validation requires PyJWT library. Install with: pip install pyjwt[crypto]",
188
+ auth_method="jwt",
189
+ )
190
+
191
+ try:
192
+ # Lazy init JWKS client
193
+ if self._jwks_client is None:
194
+ self._init_jwks_client()
195
+
196
+ signing_key = self._jwks_client.get_signing_key_from_jwt(token)
197
+
198
+ claims = jwt.decode(
199
+ token,
200
+ signing_key.key,
201
+ algorithms=["RS256", "ES256"],
202
+ audience=self._config.audience,
203
+ issuer=self._config.issuer,
204
+ options={
205
+ "verify_exp": True,
206
+ "verify_iat": True,
207
+ "verify_aud": True,
208
+ "verify_iss": True,
209
+ "verify_nbf": True, # Verify 'not before' claim
210
+ },
211
+ )
212
+
213
+ return claims
214
+
215
+ except jwt.ExpiredSignatureError:
216
+ raise ExpiredCredentialsError(
217
+ message="JWT token has expired",
218
+ auth_method="jwt",
219
+ )
220
+ except jwt.InvalidAudienceError:
221
+ raise InvalidCredentialsError(
222
+ message="Invalid JWT audience",
223
+ auth_method="jwt",
224
+ )
225
+ except jwt.InvalidIssuerError:
226
+ raise InvalidCredentialsError(
227
+ message="Invalid JWT issuer",
228
+ auth_method="jwt",
229
+ )
230
+ except jwt.InvalidTokenError as e:
231
+ raise InvalidCredentialsError(
232
+ message=f"Invalid JWT token: {e}",
233
+ auth_method="jwt",
234
+ )
235
+
236
+ def _init_jwks_client(self) -> None:
237
+ """Initialize JWKS client, discovering URI if needed."""
238
+ try:
239
+ import httpx
240
+ import jwt
241
+ except ImportError as e:
242
+ raise InvalidCredentialsError(
243
+ message=f"JWT validation requires additional libraries: {e}",
244
+ auth_method="jwt",
245
+ )
246
+
247
+ # Security check: OIDC issuer should use HTTPS in production
248
+ if not self._config.issuer.startswith("https://"):
249
+ logger.warning(
250
+ "oidc_issuer_not_https",
251
+ issuer=self._config.issuer,
252
+ warning="OIDC issuer should use HTTPS to prevent MITM attacks",
253
+ )
254
+
255
+ jwks_uri = self._config.jwks_uri
256
+
257
+ if not jwks_uri:
258
+ # Discover from OIDC well-known endpoint
259
+ discovery_url = f"{self._config.issuer.rstrip('/')}/.well-known/openid-configuration"
260
+ try:
261
+ response = httpx.get(discovery_url, timeout=10)
262
+ response.raise_for_status()
263
+ oidc_config = response.json()
264
+ jwks_uri = oidc_config.get("jwks_uri")
265
+
266
+ if not jwks_uri:
267
+ raise InvalidCredentialsError(
268
+ message="OIDC discovery did not return jwks_uri",
269
+ auth_method="jwt",
270
+ )
271
+
272
+ # Security check: JWKS URI should also use HTTPS
273
+ if not jwks_uri.startswith("https://"):
274
+ logger.warning(
275
+ "jwks_uri_not_https",
276
+ jwks_uri=jwks_uri,
277
+ warning="JWKS URI should use HTTPS to prevent key tampering",
278
+ )
279
+
280
+ logger.info(
281
+ "oidc_discovery_complete",
282
+ issuer=self._config.issuer,
283
+ jwks_uri=jwks_uri,
284
+ )
285
+ except httpx.HTTPError as e:
286
+ raise InvalidCredentialsError(
287
+ message=f"Failed to discover OIDC configuration: {e}",
288
+ auth_method="jwt",
289
+ )
290
+
291
+ self._jwks_uri = jwks_uri
292
+ self._jwks_client = jwt.PyJWKClient(jwks_uri)
293
+
294
+
295
+ class StaticSecretTokenValidator(ITokenValidator):
296
+ """Simple JWT validator using a static secret (HS256).
297
+
298
+ WARNING: Only for development/testing. Use JWKS in production.
299
+ """
300
+
301
+ def __init__(self, secret: str, issuer: str | None = None, audience: str | None = None):
302
+ """Initialize with a static secret.
303
+
304
+ Args:
305
+ secret: The HMAC secret for HS256 validation.
306
+ issuer: Optional expected issuer.
307
+ audience: Optional expected audience.
308
+ """
309
+ self._secret = secret
310
+ self._issuer = issuer
311
+ self._audience = audience
312
+
313
+ def validate(self, token: str) -> dict:
314
+ """Validate JWT using static secret.
315
+
316
+ Args:
317
+ token: The JWT string to validate.
318
+
319
+ Returns:
320
+ Dictionary of validated claims.
321
+
322
+ Raises:
323
+ InvalidCredentialsError: If token is invalid.
324
+ ExpiredCredentialsError: If token has expired.
325
+ """
326
+ try:
327
+ import jwt
328
+ except ImportError:
329
+ raise InvalidCredentialsError(
330
+ message="JWT validation requires PyJWT library",
331
+ auth_method="jwt",
332
+ )
333
+
334
+ options = {
335
+ "verify_exp": True,
336
+ "verify_iat": True,
337
+ "verify_aud": self._audience is not None,
338
+ "verify_iss": self._issuer is not None,
339
+ }
340
+
341
+ try:
342
+ claims = jwt.decode(
343
+ token,
344
+ self._secret,
345
+ algorithms=["HS256"],
346
+ audience=self._audience,
347
+ issuer=self._issuer,
348
+ options=options,
349
+ )
350
+ return claims
351
+ except jwt.ExpiredSignatureError:
352
+ raise ExpiredCredentialsError(
353
+ message="JWT token has expired",
354
+ auth_method="jwt",
355
+ )
356
+ except jwt.InvalidTokenError as e:
357
+ raise InvalidCredentialsError(
358
+ message=f"Invalid JWT token: {e}",
359
+ auth_method="jwt",
360
+ )
@@ -0,0 +1,340 @@
1
+ """Authentication and Authorization middleware.
2
+
3
+ Provides middleware components that can be integrated with HTTP frameworks
4
+ (Starlette, FastAPI) or used directly in application code.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Callable
9
+
10
+ import structlog
11
+
12
+ from ...domain.contracts.authentication import AuthRequest, IAuthenticator
13
+ from ...domain.contracts.authorization import AuthorizationRequest, IAuthorizer
14
+ from ...domain.events import AuthenticationFailed, AuthenticationSucceeded, AuthorizationDenied, AuthorizationGranted
15
+ from ...domain.exceptions import AccessDeniedError, AuthenticationError, MissingCredentialsError, RateLimitExceededError
16
+ from ...domain.value_objects import Principal
17
+ from .rate_limiter import AuthRateLimiter
18
+
19
+ logger = structlog.get_logger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class AuthContext:
24
+ """Authentication context attached to requests.
25
+
26
+ Contains the authenticated principal and metadata about how
27
+ authentication was performed.
28
+
29
+ Attributes:
30
+ principal: The authenticated principal.
31
+ auth_method: Name of the authenticator that handled the request.
32
+ """
33
+
34
+ principal: Principal
35
+ auth_method: str
36
+
37
+ def is_authenticated(self) -> bool:
38
+ """Check if request is authenticated (not anonymous)."""
39
+ return not self.principal.is_anonymous()
40
+
41
+
42
+ class AuthenticationMiddleware:
43
+ """Middleware that authenticates incoming requests.
44
+
45
+ Tries each registered authenticator in order until one succeeds.
46
+ If no authenticator handles the request, returns anonymous principal
47
+ (if allowed) or raises MissingCredentialsError.
48
+
49
+ Usage:
50
+ authenticators = [
51
+ ApiKeyAuthenticator(key_store),
52
+ JWTAuthenticator(oidc_config, token_validator),
53
+ ]
54
+ auth_middleware = AuthenticationMiddleware(authenticators)
55
+
56
+ # In request handler:
57
+ auth_context = auth_middleware.authenticate(request)
58
+ principal = auth_context.principal
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ authenticators: list[IAuthenticator],
64
+ allow_anonymous: bool = False,
65
+ event_publisher: Callable | None = None,
66
+ rate_limiter: AuthRateLimiter | None = None,
67
+ ):
68
+ """Initialize the authentication middleware.
69
+
70
+ Args:
71
+ authenticators: List of authenticators to try in order.
72
+ allow_anonymous: If True, return anonymous principal when no auth provided.
73
+ event_publisher: Optional function to publish domain events.
74
+ rate_limiter: Optional rate limiter for brute-force protection.
75
+ """
76
+ self._authenticators = authenticators
77
+ self._allow_anonymous = allow_anonymous
78
+ self._event_publisher = event_publisher
79
+ self._rate_limiter = rate_limiter
80
+
81
+ def authenticate(self, request: AuthRequest) -> AuthContext:
82
+ """Authenticate request and return context.
83
+
84
+ Args:
85
+ request: The normalized authentication request.
86
+
87
+ Returns:
88
+ AuthContext with authenticated principal.
89
+
90
+ Raises:
91
+ AuthenticationError: If authentication fails.
92
+ MissingCredentialsError: If no credentials and anonymous not allowed.
93
+ RateLimitExceededError: If rate limit exceeded for this IP.
94
+ """
95
+ # Check rate limit before attempting authentication
96
+ if self._rate_limiter:
97
+ rate_result = self._rate_limiter.check_rate_limit(request.source_ip)
98
+ if not rate_result.allowed:
99
+ logger.warning(
100
+ "auth_rate_limit_blocked",
101
+ source_ip=request.source_ip,
102
+ reason=rate_result.reason,
103
+ retry_after=rate_result.retry_after,
104
+ )
105
+ raise RateLimitExceededError(
106
+ message=f"Too many authentication attempts. Try again in {int(rate_result.retry_after or 0)} seconds.",
107
+ retry_after=rate_result.retry_after,
108
+ )
109
+
110
+ # Try each authenticator in order
111
+ for authenticator in self._authenticators:
112
+ if authenticator.supports(request):
113
+ try:
114
+ principal = authenticator.authenticate(request)
115
+ auth_method = authenticator.__class__.__name__
116
+
117
+ self._publish_event(
118
+ AuthenticationSucceeded(
119
+ principal_id=principal.id.value,
120
+ principal_type=principal.type.value,
121
+ auth_method=auth_method,
122
+ source_ip=request.source_ip,
123
+ tenant_id=principal.tenant_id,
124
+ )
125
+ )
126
+
127
+ logger.info(
128
+ "authentication_succeeded",
129
+ principal_id=principal.id.value,
130
+ auth_method=auth_method,
131
+ source_ip=request.source_ip,
132
+ )
133
+
134
+ # Clear rate limit on success
135
+ if self._rate_limiter:
136
+ self._rate_limiter.record_success(request.source_ip)
137
+
138
+ return AuthContext(principal=principal, auth_method=auth_method)
139
+
140
+ except AuthenticationError as e:
141
+ # Record failure for rate limiting
142
+ if self._rate_limiter:
143
+ self._rate_limiter.record_failure(request.source_ip)
144
+
145
+ self._publish_event(
146
+ AuthenticationFailed(
147
+ auth_method=authenticator.__class__.__name__,
148
+ source_ip=request.source_ip,
149
+ reason=e.message,
150
+ )
151
+ )
152
+ raise
153
+
154
+ # No authenticator matched
155
+ if self._allow_anonymous:
156
+ logger.debug(
157
+ "anonymous_access",
158
+ source_ip=request.source_ip,
159
+ path=request.path,
160
+ )
161
+ return AuthContext(principal=Principal.anonymous(), auth_method="anonymous")
162
+
163
+ # Authentication required but no credentials provided
164
+ expected_methods = [a.__class__.__name__ for a in self._authenticators]
165
+ raise MissingCredentialsError(
166
+ message="No valid credentials provided",
167
+ expected_methods=expected_methods,
168
+ )
169
+
170
+ def _publish_event(self, event) -> None:
171
+ """Publish domain event if publisher is configured."""
172
+ if self._event_publisher:
173
+ try:
174
+ self._event_publisher(event)
175
+ except Exception as e:
176
+ logger.warning("event_publish_failed", event_type=type(event).__name__, error=str(e))
177
+
178
+
179
+ class AuthorizationMiddleware:
180
+ """Middleware that checks authorization for requests.
181
+
182
+ Integrates with an authorizer to check permissions and emits
183
+ domain events for audit trail.
184
+
185
+ Usage:
186
+ auth_middleware = AuthorizationMiddleware(authorizer)
187
+
188
+ # In request handler (after authentication):
189
+ auth_middleware.authorize(
190
+ principal=auth_context.principal,
191
+ action="invoke",
192
+ resource_type="tool",
193
+ resource_id="math:add",
194
+ )
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ authorizer: IAuthorizer,
200
+ event_publisher: Callable | None = None,
201
+ ):
202
+ """Initialize the authorization middleware.
203
+
204
+ Args:
205
+ authorizer: The authorizer to use for access decisions.
206
+ event_publisher: Optional function to publish domain events.
207
+ """
208
+ self._authorizer = authorizer
209
+ self._event_publisher = event_publisher
210
+
211
+ def authorize(
212
+ self,
213
+ principal: Principal,
214
+ action: str,
215
+ resource_type: str,
216
+ resource_id: str,
217
+ context: dict | None = None,
218
+ ) -> None:
219
+ """Check authorization, raise AccessDeniedError if denied.
220
+
221
+ Args:
222
+ principal: The authenticated principal.
223
+ action: The action being performed.
224
+ resource_type: Type of resource being accessed.
225
+ resource_id: Specific resource identifier.
226
+ context: Optional additional context for policy evaluation.
227
+
228
+ Raises:
229
+ AccessDeniedError: If the principal is not authorized.
230
+ """
231
+ request = AuthorizationRequest(
232
+ principal=principal,
233
+ action=action,
234
+ resource_type=resource_type,
235
+ resource_id=resource_id,
236
+ context=context or {},
237
+ )
238
+
239
+ result = self._authorizer.authorize(request)
240
+
241
+ if result.allowed:
242
+ self._publish_event(
243
+ AuthorizationGranted(
244
+ principal_id=principal.id.value,
245
+ action=action,
246
+ resource_type=resource_type,
247
+ resource_id=resource_id,
248
+ granted_by_role=result.matched_role or "unknown",
249
+ )
250
+ )
251
+ return
252
+
253
+ # Access denied
254
+ self._publish_event(
255
+ AuthorizationDenied(
256
+ principal_id=principal.id.value,
257
+ action=action,
258
+ resource_type=resource_type,
259
+ resource_id=resource_id,
260
+ reason=result.reason,
261
+ )
262
+ )
263
+
264
+ raise AccessDeniedError(
265
+ principal_id=principal.id.value,
266
+ action=action,
267
+ resource=f"{resource_type}:{resource_id}",
268
+ reason=result.reason,
269
+ )
270
+
271
+ def check(
272
+ self,
273
+ principal: Principal,
274
+ action: str,
275
+ resource_type: str,
276
+ resource_id: str,
277
+ context: dict | None = None,
278
+ ) -> bool:
279
+ """Check authorization without raising exception.
280
+
281
+ Args:
282
+ principal: The authenticated principal.
283
+ action: The action being performed.
284
+ resource_type: Type of resource being accessed.
285
+ resource_id: Specific resource identifier.
286
+ context: Optional additional context.
287
+
288
+ Returns:
289
+ True if authorized, False otherwise.
290
+ """
291
+ request = AuthorizationRequest(
292
+ principal=principal,
293
+ action=action,
294
+ resource_type=resource_type,
295
+ resource_id=resource_id,
296
+ context=context or {},
297
+ )
298
+
299
+ result = self._authorizer.authorize(request)
300
+ return result.allowed
301
+
302
+ def _publish_event(self, event) -> None:
303
+ """Publish domain event if publisher is configured."""
304
+ if self._event_publisher:
305
+ try:
306
+ self._event_publisher(event)
307
+ except Exception as e:
308
+ logger.warning("event_publish_failed", event_type=type(event).__name__, error=str(e))
309
+
310
+
311
+ def create_auth_request_from_headers(
312
+ headers: dict[str, str],
313
+ source_ip: str = "unknown",
314
+ method: str = "",
315
+ path: str = "",
316
+ ) -> AuthRequest:
317
+ """Create AuthRequest from HTTP headers.
318
+
319
+ Convenience function for creating AuthRequest from HTTP request data.
320
+
321
+ Args:
322
+ headers: HTTP headers (case-insensitive dict preferred).
323
+ source_ip: Client IP address.
324
+ method: HTTP method.
325
+ path: Request path.
326
+
327
+ Returns:
328
+ AuthRequest ready for authentication.
329
+ """
330
+ # Normalize headers to lowercase for consistent lookup
331
+ normalized_headers = {k.lower(): v for k, v in headers.items()}
332
+ # Also keep original case for backwards compatibility
333
+ normalized_headers.update(headers)
334
+
335
+ return AuthRequest(
336
+ headers=normalized_headers,
337
+ source_ip=source_ip,
338
+ method=method,
339
+ path=path,
340
+ )