ygo74-agent-runtime 0.0.2__tar.gz

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 (53) hide show
  1. ygo74_agent_runtime-0.0.2/PKG-INFO +8 -0
  2. ygo74_agent_runtime-0.0.2/pyproject.toml +14 -0
  3. ygo74_agent_runtime-0.0.2/setup.cfg +4 -0
  4. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/__init__.py +105 -0
  5. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/apikey_authenticator.py +111 -0
  6. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/auth_context.py +108 -0
  7. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/auth_errors.py +40 -0
  8. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/authenticator.py +70 -0
  9. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/claims_projection.py +107 -0
  10. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/auth/jwt_authenticator.py +248 -0
  11. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/configuration/models.py +54 -0
  12. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/configuration/validator.py +12 -0
  13. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/contracts/error_envelope.py +12 -0
  14. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/contracts/exchange_models.py +22 -0
  15. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/contracts/stream_events.py +12 -0
  16. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/__init__.py +7 -0
  17. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/agent_access_policy.py +59 -0
  18. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/agent_descriptor.py +316 -0
  19. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/anthropic_model_projection.py +53 -0
  20. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/capability_extensions.py +47 -0
  21. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/capability_validator.py +53 -0
  22. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/descriptor_binding.py +32 -0
  23. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/descriptor_defaults.py +76 -0
  24. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/descriptor_registry.py +97 -0
  25. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/dialect_selector.py +72 -0
  26. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/discovery_configuration.py +247 -0
  27. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/discovery_errors.py +142 -0
  28. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/model_route_resolver.py +28 -0
  29. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/openai_model_projection.py +37 -0
  30. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/discovery/pagination.py +100 -0
  31. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/endpoints/adapters.py +22 -0
  32. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/endpoints/fastapi_endpoints.py +716 -0
  33. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/handlers/handler_protocol.py +8 -0
  34. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/handlers/response_validator.py +10 -0
  35. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/mapping/request_mapper.py +7 -0
  36. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/mapping/response_mapper.py +17 -0
  37. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/streaming/openai_stream_mapper.py +10 -0
  38. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/domains/streaming/stream_termination.py +17 -0
  39. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/middleware/interfaces.py +14 -0
  40. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/middleware/middleware_errors.py +6 -0
  41. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/middleware/pipeline.py +12 -0
  42. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/middleware/registry.py +20 -0
  43. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/observability/logging_config.py +8 -0
  44. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/observability/otel.py +3 -0
  45. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/routing/dispatcher.py +14 -0
  46. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/routing/dispatcher_impl.py +9 -0
  47. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/routing/route_registry.py +18 -0
  48. ygo74_agent_runtime-0.0.2/ygo74/agent_runtime/routing/routing_errors.py +6 -0
  49. ygo74_agent_runtime-0.0.2/ygo74_agent_runtime.egg-info/PKG-INFO +8 -0
  50. ygo74_agent_runtime-0.0.2/ygo74_agent_runtime.egg-info/SOURCES.txt +51 -0
  51. ygo74_agent_runtime-0.0.2/ygo74_agent_runtime.egg-info/dependency_links.txt +1 -0
  52. ygo74_agent_runtime-0.0.2/ygo74_agent_runtime.egg-info/requires.txt +3 -0
  53. ygo74_agent_runtime-0.0.2/ygo74_agent_runtime.egg-info/top_level.txt +1 -0
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: ygo74-agent-runtime
3
+ Version: 0.0.2
4
+ Summary: Cross-language AI enterprise agent runtime
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: pydantic>=2.7
7
+ Requires-Dist: typing-extensions>=4.12
8
+ Requires-Dist: PyJWT>=2.8
@@ -0,0 +1,14 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ygo74-agent-runtime"
7
+ version = "0.0.2"
8
+ description = "Cross-language AI enterprise agent runtime"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "pydantic>=2.7",
12
+ "typing-extensions>=4.12",
13
+ "PyJWT>=2.8"
14
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,105 @@
1
+ from ygo74.agent_runtime.domains.contracts.exchange_models import StandardExchangeRequest, StandardExchangeResponse
2
+ from ygo74.agent_runtime.domains.contracts.error_envelope import ErrorEnvelope
3
+ from ygo74.agent_runtime.domains.auth.auth_context import AuthenticatedUserContext, ResolvedUser, UserIdentity
4
+ from ygo74.agent_runtime.domains.auth.auth_errors import AuthenticationError, AuthorizationError
5
+ from ygo74.agent_runtime.domains.auth.authenticator import Authenticator, RequestAuthenticator
6
+ from ygo74.agent_runtime.domains.auth.apikey_authenticator import (
7
+ ApiKeyAuthenticator,
8
+ ApiKeyUserResolver,
9
+ StaticApiKeyUserResolver,
10
+ )
11
+ from ygo74.agent_runtime.domains.auth.jwt_authenticator import JwtAuthenticator, JwtValidationConfig
12
+ from ygo74.agent_runtime.domains.discovery.agent_descriptor import (
13
+ AgentCapabilitySet,
14
+ AgentDescriptor,
15
+ AgentSkill,
16
+ CapabilitySizeUnit,
17
+ DiscoveryVisibility,
18
+ Modality,
19
+ )
20
+ from ygo74.agent_runtime.domains.discovery.agent_access_policy import AgentAccessPolicy, RoleRequiredAccessPolicy
21
+ from ygo74.agent_runtime.domains.discovery.anthropic_model_projection import AnthropicModelProjection
22
+ from ygo74.agent_runtime.domains.discovery.capability_extensions import CapabilityExtensions
23
+ from ygo74.agent_runtime.domains.discovery.capability_validator import CapabilityValidator
24
+ from ygo74.agent_runtime.domains.discovery.descriptor_binding import DescriptorBinding
25
+ from ygo74.agent_runtime.domains.discovery.descriptor_defaults import DescriptorDefaults
26
+ from ygo74.agent_runtime.domains.discovery.descriptor_registry import DescriptorOrdering, DescriptorRegistry
27
+ from ygo74.agent_runtime.domains.discovery.dialect_selector import (
28
+ DialectSelection,
29
+ DialectSelector,
30
+ ProviderDialect,
31
+ )
32
+ from ygo74.agent_runtime.domains.discovery.discovery_configuration import (
33
+ DiscoveryConfiguration,
34
+ DiscoveryService,
35
+ DiscoverySurface,
36
+ )
37
+ from ygo74.agent_runtime.domains.discovery.discovery_errors import (
38
+ DiscoveryError,
39
+ DiscoveryErrorCategory,
40
+ DiscoveryErrorCode,
41
+ DiscoveryErrors,
42
+ )
43
+ from ygo74.agent_runtime.domains.discovery.model_route_resolver import ModelRouteResolver
44
+ from ygo74.agent_runtime.domains.discovery.openai_model_projection import OpenAiModelProjection
45
+ from ygo74.agent_runtime.domains.discovery.pagination import (
46
+ DiscoveryPagination,
47
+ PaginationRequest,
48
+ PaginationResult,
49
+ )
50
+ from ygo74.agent_runtime.domains.endpoints.fastapi_endpoints import (
51
+ add_ai_endpoint,
52
+ add_ai_endpoints,
53
+ add_discovery_endpoints,
54
+ )
55
+
56
+ __all__ = [
57
+ "StandardExchangeRequest",
58
+ "StandardExchangeResponse",
59
+ "ErrorEnvelope",
60
+ "AuthenticatedUserContext",
61
+ "UserIdentity",
62
+ "ResolvedUser",
63
+ "AuthenticationError",
64
+ "AuthorizationError",
65
+ "Authenticator",
66
+ "RequestAuthenticator",
67
+ "JwtAuthenticator",
68
+ "JwtValidationConfig",
69
+ "ApiKeyAuthenticator",
70
+ "ApiKeyUserResolver",
71
+ "StaticApiKeyUserResolver",
72
+ "AgentDescriptor",
73
+ "AgentCapabilitySet",
74
+ "AgentSkill",
75
+ "CapabilitySizeUnit",
76
+ "DiscoveryVisibility",
77
+ "Modality",
78
+ "AgentAccessPolicy",
79
+ "RoleRequiredAccessPolicy",
80
+ "DescriptorRegistry",
81
+ "DescriptorOrdering",
82
+ "DescriptorDefaults",
83
+ "DescriptorBinding",
84
+ "CapabilityValidator",
85
+ "CapabilityExtensions",
86
+ "DiscoveryConfiguration",
87
+ "DiscoveryService",
88
+ "DiscoverySurface",
89
+ "DiscoveryError",
90
+ "DiscoveryErrorCategory",
91
+ "DiscoveryErrorCode",
92
+ "DiscoveryErrors",
93
+ "DialectSelection",
94
+ "DialectSelector",
95
+ "ProviderDialect",
96
+ "DiscoveryPagination",
97
+ "ModelRouteResolver",
98
+ "PaginationRequest",
99
+ "PaginationResult",
100
+ "OpenAiModelProjection",
101
+ "AnthropicModelProjection",
102
+ "add_ai_endpoint",
103
+ "add_ai_endpoints",
104
+ "add_discovery_endpoints",
105
+ ]
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Mapping, Protocol
5
+
6
+ from ygo74.agent_runtime.domains.auth.auth_context import AuthenticatedUserContext, ResolvedUser
7
+ from ygo74.agent_runtime.domains.auth.auth_errors import AuthenticationError
8
+
9
+
10
+ class ApiKeyUserResolver(Protocol):
11
+ """Contract a developer implements to map an API key to a user.
12
+
13
+ Returning ``None`` means the key is unknown and the request is rejected with
14
+ ``api_key_invalid``. The returned :class:`ResolvedUser` defines exactly which
15
+ user information is loaded into the handler's ``auth_context``.
16
+ """
17
+
18
+ def resolve_user(self, api_key: str) -> ResolvedUser | None:
19
+ ...
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class StaticApiKeyUserResolver:
24
+ """In-memory resolver, mostly useful for local development and tests."""
25
+
26
+ users_by_key: dict[str, ResolvedUser]
27
+
28
+ def resolve_user(self, api_key: str) -> ResolvedUser | None:
29
+ return self.users_by_key.get(api_key)
30
+
31
+
32
+ class ApiKeyAuthenticator:
33
+ """Authenticates callers presenting an API key header.
34
+
35
+ The raw key is never propagated into the resulting context: only the user
36
+ information returned by the resolver is exposed to the handler.
37
+ """
38
+
39
+ DEFAULT_HEADER_NAME = "x-api-key"
40
+
41
+ def __init__(self, resolver: ApiKeyUserResolver, *, header_name: str = DEFAULT_HEADER_NAME) -> None:
42
+ self._resolver = resolver
43
+ self._header_name = header_name.lower()
44
+
45
+ @property
46
+ def auth_type(self) -> str:
47
+ return "api_key"
48
+
49
+ @property
50
+ def header_name(self) -> str:
51
+ return self._header_name
52
+
53
+ def can_authenticate(self, headers: Mapping[str, Any]) -> bool:
54
+ return bool(headers.get(self._header_name))
55
+
56
+ def missing_credential_error(self) -> AuthenticationError:
57
+ return AuthenticationError(
58
+ code="api_key_header_missing",
59
+ message=f"Missing {self._header_name} header",
60
+ )
61
+
62
+ def authenticate(self, headers: Mapping[str, Any]) -> AuthenticatedUserContext:
63
+ api_key = headers.get(self._header_name)
64
+ if not isinstance(api_key, str) or not api_key.strip():
65
+ raise self.missing_credential_error()
66
+
67
+ return self.authenticate_key(api_key)
68
+
69
+ def authenticate_key(self, api_key: str) -> AuthenticatedUserContext:
70
+ if not api_key:
71
+ raise self.missing_credential_error()
72
+
73
+ user = self._resolve(api_key)
74
+
75
+ return AuthenticatedUserContext(
76
+ auth_type=self.auth_type,
77
+ identity=user.to_identity(),
78
+ roles=list(user.roles),
79
+ groups=list(user.groups),
80
+ scopes=list(user.scopes),
81
+ claims=dict(user.claims),
82
+ tenant_id=user.tenant_id,
83
+ )
84
+
85
+ def _resolve(self, api_key: str) -> ResolvedUser:
86
+ try:
87
+ resolved: object = self._resolver.resolve_user(api_key)
88
+ except AuthenticationError:
89
+ raise
90
+ except Exception as ex:
91
+ raise AuthenticationError(
92
+ code="user_resolution_failed",
93
+ message="API key user-resolution hook raised an error",
94
+ ) from ex
95
+
96
+ if resolved is None:
97
+ raise AuthenticationError(code="api_key_invalid", message="API key is not recognized")
98
+
99
+ if not isinstance(resolved, ResolvedUser):
100
+ raise AuthenticationError(
101
+ code="user_context_malformed",
102
+ message="API key user-resolution hook must return a ResolvedUser",
103
+ )
104
+
105
+ if not resolved.user_id or not resolved.user_id.strip():
106
+ raise AuthenticationError(
107
+ code="user_context_malformed",
108
+ message="API key user-resolution hook must provide a user_id",
109
+ )
110
+
111
+ return resolved
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class UserIdentity:
9
+ """Normalized identity of the authenticated caller."""
10
+
11
+ user_id: str
12
+ subject: str | None = None
13
+ username: str | None = None
14
+ name: str | None = None
15
+ given_name: str | None = None
16
+ family_name: str | None = None
17
+ email: str | None = None
18
+ email_verified: bool | None = None
19
+
20
+ def to_dict(self) -> dict[str, Any]:
21
+ return {
22
+ "userId": self.user_id,
23
+ "subject": self.subject or self.user_id,
24
+ "username": self.username,
25
+ "name": self.name,
26
+ "givenName": self.given_name,
27
+ "familyName": self.family_name,
28
+ "email": self.email,
29
+ "emailVerified": self.email_verified,
30
+ }
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class ResolvedUser:
35
+ """Return schema of an API key user-resolution hook.
36
+
37
+ This is the contract a developer must satisfy when mapping an API key to a
38
+ user: whatever is populated here is what the handler will find in its
39
+ ``auth_context``.
40
+ """
41
+
42
+ user_id: str
43
+ username: str | None = None
44
+ name: str | None = None
45
+ given_name: str | None = None
46
+ family_name: str | None = None
47
+ email: str | None = None
48
+ email_verified: bool | None = None
49
+ roles: list[str] = field(default_factory=list)
50
+ groups: list[str] = field(default_factory=list)
51
+ scopes: list[str] = field(default_factory=list)
52
+ tenant_id: str | None = None
53
+ claims: dict[str, Any] = field(default_factory=dict)
54
+
55
+ def to_identity(self) -> UserIdentity:
56
+ return UserIdentity(
57
+ user_id=self.user_id,
58
+ subject=self.user_id,
59
+ username=self.username,
60
+ name=self.name,
61
+ given_name=self.given_name,
62
+ family_name=self.family_name,
63
+ email=self.email,
64
+ email_verified=self.email_verified,
65
+ )
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class AuthenticatedUserContext:
70
+ """Normalized authentication context handed to the handler.
71
+
72
+ ``to_dict`` produces the wire shape required by the Standard Exchange
73
+ contract (``userId`` and ``authType`` at the top level).
74
+ """
75
+
76
+ auth_type: str
77
+ identity: UserIdentity
78
+ roles: list[str] = field(default_factory=list)
79
+ groups: list[str] = field(default_factory=list)
80
+ scopes: list[str] = field(default_factory=list)
81
+ claims: dict[str, Any] = field(default_factory=dict)
82
+ tenant_id: str | None = None
83
+
84
+ @property
85
+ def user_id(self) -> str:
86
+ return self.identity.user_id
87
+
88
+ def has_role(self, role: str) -> bool:
89
+ return role in self.roles
90
+
91
+ def has_scope(self, scope: str) -> bool:
92
+ return scope in self.scopes
93
+
94
+ def to_dict(self) -> dict[str, Any]:
95
+ context: dict[str, Any] = {
96
+ "authType": self.auth_type,
97
+ "userId": self.identity.user_id,
98
+ "identity": self.identity.to_dict(),
99
+ "roles": list(self.roles),
100
+ "groups": list(self.groups),
101
+ "scopes": list(self.scopes),
102
+ "claims": dict(self.claims),
103
+ }
104
+
105
+ if self.tenant_id is not None:
106
+ context["tenantId"] = self.tenant_id
107
+
108
+ return context
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ def auth_error(code: str, message: str, category: str, details: Any | None = None) -> dict[str, Any]:
8
+ err: dict[str, Any] = {"code": code, "category": category, "message": message}
9
+ if details is not None:
10
+ err["details"] = details
11
+ return err
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class AuthenticationError(Exception):
16
+ code: str
17
+ message: str
18
+ category: str = "authentication"
19
+ details: Any | None = None
20
+
21
+ def to_dict(self) -> dict[str, Any]:
22
+ return auth_error(self.code, self.message, self.category, self.details)
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class AuthorizationError(Exception):
27
+ """Raised by developer-owned authorization logic to deny an authenticated request.
28
+
29
+ The runtime never raises this itself: authorization decisions belong to the
30
+ handler. When raised, the runtime short-circuits handler execution and maps
31
+ it to an HTTP 403 with a structured error envelope.
32
+ """
33
+
34
+ code: str = "forbidden"
35
+ message: str = "Access denied"
36
+ category: str = "authorization"
37
+ details: Any | None = None
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ return auth_error(self.code, self.message, self.category, self.details)
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Mapping, Protocol, runtime_checkable
5
+
6
+ from ygo74.agent_runtime.domains.auth.auth_context import AuthenticatedUserContext
7
+ from ygo74.agent_runtime.domains.auth.auth_errors import AuthenticationError
8
+
9
+
10
+ @runtime_checkable
11
+ class Authenticator(Protocol):
12
+ """Contract implemented by every authentication scheme.
13
+
14
+ ``RequestAuthenticator`` inspects the incoming headers and delegates to the
15
+ first authenticator that claims them, so adding a scheme means adding a
16
+ class implementing this protocol.
17
+ """
18
+
19
+ @property
20
+ def auth_type(self) -> str:
21
+ """Stable identifier projected as ``authContext.authType``."""
22
+ ...
23
+
24
+ def can_authenticate(self, headers: Mapping[str, Any]) -> bool:
25
+ """Return True when the request carries a credential for this scheme."""
26
+ ...
27
+
28
+ def authenticate(self, headers: Mapping[str, Any]) -> AuthenticatedUserContext:
29
+ """Validate the credential and project a normalized user context."""
30
+ ...
31
+
32
+ def missing_credential_error(self) -> AuthenticationError:
33
+ """Error raised when authentication is required but no credential was sent."""
34
+ ...
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class RequestAuthenticator:
39
+ """Selects and runs the authenticator matching the incoming request headers.
40
+
41
+ Authenticators are evaluated in order and the first one claiming the request
42
+ wins, so ordering expresses precedence (JWT before API key by convention).
43
+ """
44
+
45
+ authenticators: list[Authenticator] = field(default_factory=list)
46
+ require_authentication: bool = False
47
+
48
+ def authenticate(self, headers: Mapping[str, Any] | None) -> AuthenticatedUserContext | None:
49
+ if headers is None:
50
+ if self.require_authentication:
51
+ raise self._missing_credential_error()
52
+ return None
53
+
54
+ for authenticator in self.authenticators:
55
+ if authenticator.can_authenticate(headers):
56
+ return authenticator.authenticate(headers)
57
+
58
+ if self.require_authentication:
59
+ raise self._missing_credential_error()
60
+
61
+ return None
62
+
63
+ def _missing_credential_error(self) -> AuthenticationError:
64
+ if self.authenticators:
65
+ return self.authenticators[0].missing_credential_error()
66
+
67
+ return AuthenticationError(
68
+ code="authentication_not_configured",
69
+ message="Authentication is required but no authenticator is configured",
70
+ )
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Mapping, cast
5
+
6
+ from ygo74.agent_runtime.domains.auth.auth_context import UserIdentity
7
+
8
+ _PROJECTED_CLAIM_KEYS = (
9
+ "iss",
10
+ "aud",
11
+ "exp",
12
+ "nbf",
13
+ "iat",
14
+ "jti",
15
+ "scope",
16
+ "roles",
17
+ "name",
18
+ "given_name",
19
+ "family_name",
20
+ "preferred_username",
21
+ "email",
22
+ "email_verified",
23
+ "groups",
24
+ "realm_access",
25
+ "resource_access",
26
+ )
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class ClaimsProjector:
31
+ """Projects raw OIDC claims into the normalized authentication context.
32
+
33
+ ``roles_claim_path`` and ``groups_claim_path`` are dot-separated paths into
34
+ the decoded claims (for example ``realm_access.roles`` or
35
+ ``resource_access.<client>.roles``), mirroring the
36
+ ``OPENID_REQUIRED_ROLE_PARAMETER_PATH`` style of configuration used by OIDC
37
+ providers such as Keycloak.
38
+ """
39
+
40
+ roles_claim_path: str | None = None
41
+ groups_claim_path: str | None = None
42
+
43
+ def identity(self, claims: Mapping[str, Any], subject: str) -> UserIdentity:
44
+ email_verified = claims.get("email_verified")
45
+
46
+ return UserIdentity(
47
+ user_id=subject,
48
+ subject=subject,
49
+ username=self._optional_str(claims.get("preferred_username")),
50
+ name=self._optional_str(claims.get("name")),
51
+ given_name=self._optional_str(claims.get("given_name")),
52
+ family_name=self._optional_str(claims.get("family_name")),
53
+ email=self._optional_str(claims.get("email")),
54
+ email_verified=email_verified if isinstance(email_verified, bool) else None,
55
+ )
56
+
57
+ def roles(self, claims: Mapping[str, Any]) -> list[str]:
58
+ return self.values_at(claims, self.roles_claim_path)
59
+
60
+ def groups(self, claims: Mapping[str, Any]) -> list[str]:
61
+ return self.values_at(claims, self.groups_claim_path)
62
+
63
+ def scopes(self, claims: Mapping[str, Any]) -> list[str]:
64
+ scope = claims.get("scope")
65
+ if isinstance(scope, str):
66
+ return scope.split()
67
+
68
+ return self._as_string_list(scope)
69
+
70
+ def context_claims(self, claims: Mapping[str, Any]) -> dict[str, Any]:
71
+ return {key: claims[key] for key in _PROJECTED_CLAIM_KEYS if key in claims}
72
+
73
+ def values_at(self, claims: Mapping[str, Any], path: str | None) -> list[str]:
74
+ if not path:
75
+ return []
76
+
77
+ return self._as_string_list(self.resolve_path(claims, path))
78
+
79
+ @staticmethod
80
+ def resolve_path(claims: Mapping[str, Any], path: str) -> Any:
81
+ value: Any = claims
82
+ for part in path.split("."):
83
+ if not isinstance(value, Mapping):
84
+ return None
85
+
86
+ current = cast("Mapping[str, Any]", value)
87
+ if part not in current:
88
+ return None
89
+
90
+ value = current[part]
91
+
92
+ return value
93
+
94
+ @staticmethod
95
+ def _as_string_list(value: Any) -> list[str]:
96
+ if isinstance(value, str):
97
+ return [value]
98
+
99
+ if isinstance(value, (list, tuple)):
100
+ items = cast("list[Any] | tuple[Any, ...]", value)
101
+ return [str(item) for item in items]
102
+
103
+ return []
104
+
105
+ @staticmethod
106
+ def _optional_str(value: Any) -> str | None:
107
+ return value if isinstance(value, str) else None