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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|