mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-05 22:14:11 +00:00
74 lines
2.5 KiB
Python
74 lines
2.5 KiB
Python
"""OAuth2/OIDC access token validation (EP-01-T03, README.md §5, §19, §20).
|
|
|
|
Validates a bearer token against the Identity Provider's JWKS (Keycloak,
|
|
EP-01-T01, in production; any standards-compliant OIDC IdP works, since this
|
|
speaks the standard, not a Keycloak-specific protocol) -- signature,
|
|
expiry, issuer and audience. The verified `azp`/`client_id` claim is the
|
|
*only* source of client identity this module trusts; nothing about
|
|
`clientInfo` or any other agent-declared field is ever consulted here
|
|
(README.md §5, "Observação crítica sobre clientInfo", Axiom 2).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import jwt
|
|
from jwt import PyJWKClient
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class AuthenticationError(Exception):
|
|
"""Token missing/malformed/expired/wrong audience-issuer/bad signature.
|
|
|
|
Callers (the Gateway, EP-05-T02/T04) must treat this identically to
|
|
DENY -- fail closed (README.md §38), never fall back to an unauthenticated
|
|
or agent-declared identity.
|
|
"""
|
|
|
|
|
|
class AuthenticatedClient(BaseModel):
|
|
client_id: str
|
|
"""From the token's `azp` (OIDC 'authorized party') or `client_id` claim --
|
|
never from clientInfo or any other unauthenticated, agent-controlled field."""
|
|
|
|
issuer: str
|
|
expires_at: int
|
|
|
|
|
|
class TokenValidator:
|
|
def __init__(
|
|
self,
|
|
jwks_url: str,
|
|
issuer: str,
|
|
audience: str,
|
|
leeway_seconds: int = 5,
|
|
) -> None:
|
|
self._jwks_client = PyJWKClient(jwks_url)
|
|
self._issuer = issuer
|
|
self._audience = audience
|
|
self._leeway_seconds = leeway_seconds
|
|
|
|
def validate(self, token: str) -> AuthenticatedClient:
|
|
try:
|
|
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
|
claims = jwt.decode(
|
|
token,
|
|
signing_key.key,
|
|
algorithms=["RS256"],
|
|
audience=self._audience,
|
|
issuer=self._issuer,
|
|
leeway=self._leeway_seconds,
|
|
options={"require": ["exp", "iss", "aud"]},
|
|
)
|
|
except jwt.PyJWTError as exc:
|
|
raise AuthenticationError(str(exc)) from exc
|
|
|
|
client_id = claims.get("azp") or claims.get("client_id")
|
|
if not isinstance(client_id, str) or not client_id:
|
|
raise AuthenticationError("Token has neither a usable 'azp' nor 'client_id' claim")
|
|
|
|
return AuthenticatedClient(
|
|
client_id=client_id,
|
|
issuer=str(claims["iss"]),
|
|
expires_at=int(claims["exp"]),
|
|
)
|