agentsts-core 0.0.2__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.
- agentsts/core/__init__.py +11 -0
- agentsts/core/_actor_service.py +51 -0
- agentsts/core/_base.py +99 -0
- agentsts/core/client/__init__.py +23 -0
- agentsts/core/client/_client.py +217 -0
- agentsts/core/client/_config.py +9 -0
- agentsts/core/client/_exceptions.py +35 -0
- agentsts/core/client/_models.py +91 -0
- agentsts/core/client/_utils.py +62 -0
- agentsts_core-0.0.2.dist-info/METADATA +14 -0
- agentsts_core-0.0.2.dist-info/RECORD +12 -0
- agentsts_core-0.0.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Base actor token service for STS integration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
SERVICE_ACCOUNT_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ActorTokenService:
|
|
12
|
+
"""Service that loads actor tokens for STS delegation.
|
|
13
|
+
|
|
14
|
+
This service provides a simple, synchronous approach for loading actor tokens
|
|
15
|
+
(like Kubernetes service account tokens) used in STS token exchange.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, token_path: Optional[str] = None):
|
|
19
|
+
"""Initialize the actor token service.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
token_path: Path to the token file. Defaults to Kubernetes service account token path.
|
|
23
|
+
"""
|
|
24
|
+
self.token_path = token_path or SERVICE_ACCOUNT_TOKEN_PATH
|
|
25
|
+
|
|
26
|
+
def get_actor_token(self) -> Optional[str]:
|
|
27
|
+
"""Get the actor token for STS delegation.
|
|
28
|
+
|
|
29
|
+
This method reads the token from the file each time it's called.
|
|
30
|
+
If loading fails, it returns None.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Actor token string if available, None otherwise
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
logger.debug(f"Loading actor token from {self.token_path}")
|
|
37
|
+
|
|
38
|
+
with open(self.token_path, "r", encoding="utf-8") as f:
|
|
39
|
+
token = f.read().strip()
|
|
40
|
+
|
|
41
|
+
if token:
|
|
42
|
+
logger.info("Successfully loaded actor token'")
|
|
43
|
+
return token
|
|
44
|
+
else:
|
|
45
|
+
logger.warning(f"No actor token found at {self.token_path}")
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
except Exception as e:
|
|
49
|
+
logger.error(f"Failed to load actor token': {e}")
|
|
50
|
+
logger.error(f"Token path: {self.token_path}")
|
|
51
|
+
return None
|
agentsts/core/_base.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Base classes for framework-specific STS integration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Any, Dict, Optional, Union
|
|
6
|
+
|
|
7
|
+
from ._actor_service import ActorTokenService
|
|
8
|
+
from .client import STSClient, STSConfig, TokenType
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class STSIntegrationBase(ABC):
|
|
14
|
+
"""Base class for framework-specific STS integrations."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
well_known_uri: str,
|
|
19
|
+
service_account_token_path: Optional[str] = None,
|
|
20
|
+
timeout: int = 30,
|
|
21
|
+
verify_ssl: bool = True,
|
|
22
|
+
additional_config: Optional[Dict[str, Any]] = None,
|
|
23
|
+
):
|
|
24
|
+
"""Initialize the STS integration.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
well_known_uri: Well-known configuration URI for the STS server
|
|
28
|
+
timeout: Request timeout in seconds
|
|
29
|
+
verify_ssl: Whether to verify SSL certificates
|
|
30
|
+
additional_config: Additional configuration for the specific framework
|
|
31
|
+
"""
|
|
32
|
+
self.well_known_uri = well_known_uri
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
self.verify_ssl = verify_ssl
|
|
35
|
+
self.additional_config = additional_config or {}
|
|
36
|
+
|
|
37
|
+
# Initialize STS client
|
|
38
|
+
config = STSConfig(
|
|
39
|
+
well_known_uri=well_known_uri,
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
verify_ssl=verify_ssl,
|
|
42
|
+
)
|
|
43
|
+
self.sts_client = STSClient(config)
|
|
44
|
+
self.access_token = None # cached access token
|
|
45
|
+
self._actor_token = ActorTokenService(service_account_token_path).get_actor_token()
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def create_auth_credential(self, access_token: str) -> Any:
|
|
49
|
+
"""create a framework specific auth credential object from an access token."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
async def exchange_token(
|
|
53
|
+
self,
|
|
54
|
+
subject_token: str,
|
|
55
|
+
subject_token_type: TokenType = TokenType.JWT,
|
|
56
|
+
actor_token: Optional[str] = None,
|
|
57
|
+
actor_token_type: Optional[TokenType] = None,
|
|
58
|
+
resource: Optional[Union[str, list]] = None,
|
|
59
|
+
audience: Optional[Union[str, list]] = None,
|
|
60
|
+
scope: Optional[str] = None,
|
|
61
|
+
requested_token_type: Optional[TokenType] = None,
|
|
62
|
+
additional_parameters: Optional[Dict[str, Any]] = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Exchange token using STS.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
subject_token: The security token representing the identity
|
|
68
|
+
subject_token_type: Type of the subject token
|
|
69
|
+
actor_token: The security token representing the identity of the acting party
|
|
70
|
+
actor_token_type: Type of the actor token
|
|
71
|
+
resource: The logical name of the target service or resource
|
|
72
|
+
audience: The logical name of the target service or resource
|
|
73
|
+
scope: The scope of the requested token
|
|
74
|
+
requested_token_type: The type of the requested token
|
|
75
|
+
additional_parameters: Additional parameters for the request
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Access token
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
TokenExchangeError: If token exchange fails
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
response = await self.sts_client.exchange_token(
|
|
85
|
+
subject_token=subject_token,
|
|
86
|
+
subject_token_type=subject_token_type,
|
|
87
|
+
actor_token=actor_token,
|
|
88
|
+
actor_token_type=actor_token_type,
|
|
89
|
+
resource=resource,
|
|
90
|
+
audience=audience,
|
|
91
|
+
scope=scope,
|
|
92
|
+
requested_token_type=requested_token_type,
|
|
93
|
+
additional_parameters=additional_parameters,
|
|
94
|
+
)
|
|
95
|
+
logger.debug(f"Successfully obtained access token for ADK with length: {len(response.access_token)}")
|
|
96
|
+
return response.access_token
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.error(f"Token exchange failed: {e}")
|
|
99
|
+
raise
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from ._client import STSClient
|
|
2
|
+
from ._config import STSConfig
|
|
3
|
+
from ._exceptions import AuthenticationError, ConfigurationError, NetworkError, STSError, TokenExchangeError
|
|
4
|
+
from ._models import GrantType, TokenExchangeRequest, TokenExchangeResponse, TokenType, WellKnownConfiguration
|
|
5
|
+
from ._models import TokenExchangeError as TokenExchangeErrorModel
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"STSClient",
|
|
11
|
+
"STSConfig",
|
|
12
|
+
"STSError",
|
|
13
|
+
"TokenExchangeError",
|
|
14
|
+
"ConfigurationError",
|
|
15
|
+
"AuthenticationError",
|
|
16
|
+
"NetworkError",
|
|
17
|
+
"TokenExchangeRequest",
|
|
18
|
+
"TokenExchangeResponse",
|
|
19
|
+
"TokenExchangeErrorModel",
|
|
20
|
+
"TokenType",
|
|
21
|
+
"GrantType",
|
|
22
|
+
"WellKnownConfiguration",
|
|
23
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Dict, Optional, Union
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from ._config import STSConfig
|
|
7
|
+
from ._exceptions import AuthenticationError, NetworkError, TokenExchangeError
|
|
8
|
+
from ._models import TokenExchangeRequest, TokenExchangeResponse, TokenType, WellKnownConfiguration
|
|
9
|
+
from ._utils import fetch_well_known_configuration, parse_token_exchange_error
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class STSClient:
|
|
15
|
+
"""Security Token Service client implementing RFC 8693 OAuth 2.0 Token Exchange."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
config: STSConfig,
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Initialize STS client.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
config: STS configuration
|
|
26
|
+
"""
|
|
27
|
+
self.config = config
|
|
28
|
+
self._well_known_config: Optional[WellKnownConfiguration] = None
|
|
29
|
+
self._http_client: Optional[httpx.AsyncClient] = None
|
|
30
|
+
|
|
31
|
+
async def __aenter__(self):
|
|
32
|
+
"""Async context manager entry."""
|
|
33
|
+
await self._initialize()
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
37
|
+
"""Async context manager exit."""
|
|
38
|
+
await self.close()
|
|
39
|
+
|
|
40
|
+
async def _initialize(self):
|
|
41
|
+
"""Initialize the client by fetching well-known configuration."""
|
|
42
|
+
if not self._well_known_config:
|
|
43
|
+
self._well_known_config = await fetch_well_known_configuration(
|
|
44
|
+
self.config.well_known_uri, self.config.timeout, self.config.verify_ssl
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if not self._http_client:
|
|
48
|
+
self._http_client = httpx.AsyncClient(timeout=self.config.timeout, verify=self.config.verify_ssl)
|
|
49
|
+
|
|
50
|
+
async def close(self):
|
|
51
|
+
"""Close the HTTP client."""
|
|
52
|
+
if self._http_client:
|
|
53
|
+
await self._http_client.aclose()
|
|
54
|
+
self._http_client = None
|
|
55
|
+
|
|
56
|
+
def _build_request_data(self, request: TokenExchangeRequest) -> Dict[str, Any]:
|
|
57
|
+
"""Build form data for the token exchange request."""
|
|
58
|
+
data = {
|
|
59
|
+
"grant_type": request.grant_type.value,
|
|
60
|
+
"subject_token": request.subject_token,
|
|
61
|
+
"subject_token_type": request.subject_token_type.value,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Add actor token for delegation requests
|
|
65
|
+
if request.actor_token:
|
|
66
|
+
data["actor_token"] = request.actor_token
|
|
67
|
+
data["actor_token_type"] = request.actor_token_type.value
|
|
68
|
+
|
|
69
|
+
# Add optional parameters
|
|
70
|
+
if request.resource:
|
|
71
|
+
data["resource"] = request.resource
|
|
72
|
+
if request.audience:
|
|
73
|
+
data["audience"] = request.audience
|
|
74
|
+
if request.scope:
|
|
75
|
+
data["scope"] = request.scope
|
|
76
|
+
if request.requested_token_type:
|
|
77
|
+
data["requested_token_type"] = request.requested_token_type.value
|
|
78
|
+
|
|
79
|
+
# Add additional parameters
|
|
80
|
+
if request.additional_parameters:
|
|
81
|
+
data.update(request.additional_parameters)
|
|
82
|
+
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
async def exchange_token(
|
|
86
|
+
self,
|
|
87
|
+
subject_token: str,
|
|
88
|
+
subject_token_type: TokenType = TokenType.JWT,
|
|
89
|
+
actor_token: Optional[str] = None,
|
|
90
|
+
actor_token_type: Optional[TokenType] = None,
|
|
91
|
+
resource: Optional[Union[str, list]] = None,
|
|
92
|
+
audience: Optional[Union[str, list]] = None,
|
|
93
|
+
scope: Optional[str] = None,
|
|
94
|
+
requested_token_type: Optional[TokenType] = None,
|
|
95
|
+
additional_parameters: Optional[Dict[str, Any]] = None,
|
|
96
|
+
) -> TokenExchangeResponse:
|
|
97
|
+
"""
|
|
98
|
+
Exchange a token using RFC 8693 OAuth 2.0 Token Exchange.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
subject_token: The security token representing the identity
|
|
102
|
+
subject_token_type: Type of the subject token
|
|
103
|
+
actor_token: The security token representing the identity of the acting party
|
|
104
|
+
actor_token_type: Type of the actor token
|
|
105
|
+
resource: The logical name of the target service or resource
|
|
106
|
+
audience: The logical name of the target service or resource
|
|
107
|
+
scope: The scope of the requested token
|
|
108
|
+
requested_token_type: The type of the requested token
|
|
109
|
+
additional_parameters: Additional parameters for the request
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
TokenExchangeResponse containing the issued token
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
TokenExchangeError: If token exchange fails
|
|
116
|
+
NetworkError: If network operation fails
|
|
117
|
+
"""
|
|
118
|
+
await self._initialize()
|
|
119
|
+
|
|
120
|
+
# Build the request
|
|
121
|
+
request = TokenExchangeRequest(
|
|
122
|
+
subject_token=subject_token,
|
|
123
|
+
subject_token_type=subject_token_type,
|
|
124
|
+
actor_token=actor_token,
|
|
125
|
+
actor_token_type=actor_token_type,
|
|
126
|
+
resource=resource,
|
|
127
|
+
audience=audience,
|
|
128
|
+
scope=scope,
|
|
129
|
+
requested_token_type=requested_token_type,
|
|
130
|
+
additional_parameters=additional_parameters,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Prepare the request
|
|
134
|
+
data = self._build_request_data(request)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
response = await self._http_client.post(self._well_known_config.token_endpoint, data=data)
|
|
138
|
+
|
|
139
|
+
if response.status_code == 200:
|
|
140
|
+
response_data = response.json()
|
|
141
|
+
result = TokenExchangeResponse.model_validate(response_data)
|
|
142
|
+
return result
|
|
143
|
+
else:
|
|
144
|
+
# Parse error response
|
|
145
|
+
try:
|
|
146
|
+
response_data = response.json()
|
|
147
|
+
error = parse_token_exchange_error(response_data)
|
|
148
|
+
raise TokenExchangeError(
|
|
149
|
+
error=error.error, error_description=error.error_description, status_code=response.status_code
|
|
150
|
+
)
|
|
151
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
152
|
+
response_text = response.text
|
|
153
|
+
raise TokenExchangeError(
|
|
154
|
+
error="invalid_response",
|
|
155
|
+
error_description=f"Invalid error response: {response_text}",
|
|
156
|
+
status_code=response.status_code,
|
|
157
|
+
) from e
|
|
158
|
+
|
|
159
|
+
except httpx.RequestError as e:
|
|
160
|
+
raise NetworkError(f"Network error during token exchange: {e}") from e
|
|
161
|
+
|
|
162
|
+
async def impersonate(
|
|
163
|
+
self, subject_token: str, subject_token_type: TokenType = TokenType.JWT, **kwargs
|
|
164
|
+
) -> TokenExchangeResponse:
|
|
165
|
+
"""
|
|
166
|
+
Perform impersonation token exchange (no actor token).
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
subject_token: The security token representing the identity to impersonate
|
|
170
|
+
subject_token_type: Type of the subject token
|
|
171
|
+
**kwargs: Additional parameters for the token exchange
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
TokenExchangeResponse containing the issued token
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
result = await self.exchange_token(
|
|
178
|
+
subject_token=subject_token, subject_token_type=subject_token_type, **kwargs
|
|
179
|
+
)
|
|
180
|
+
return result
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.error(f"Exception in impersonate method: {type(e)} - {e}")
|
|
183
|
+
logger.error(f"Exception args: {e.args}")
|
|
184
|
+
raise
|
|
185
|
+
|
|
186
|
+
async def delegate(
|
|
187
|
+
self, subject_token: str, subject_token_type: TokenType, actor_token: str, actor_token_type: TokenType, **kwargs
|
|
188
|
+
) -> TokenExchangeResponse:
|
|
189
|
+
"""
|
|
190
|
+
Perform delegation token exchange (with actor token).
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
subject_token: The security token representing the identity to delegate
|
|
194
|
+
subject_token_type: Type of the subject token
|
|
195
|
+
actor_token: The security token representing the identity of the acting party
|
|
196
|
+
actor_token_type: Type of the actor token
|
|
197
|
+
**kwargs: Additional parameters for the token exchange
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
TokenExchangeResponse containing the issued token
|
|
201
|
+
"""
|
|
202
|
+
if not subject_token:
|
|
203
|
+
raise AuthenticationError("Subject token required for delegation")
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
result = await self.exchange_token(
|
|
207
|
+
subject_token=subject_token,
|
|
208
|
+
subject_token_type=subject_token_type,
|
|
209
|
+
actor_token=actor_token,
|
|
210
|
+
actor_token_type=actor_token_type,
|
|
211
|
+
**kwargs,
|
|
212
|
+
)
|
|
213
|
+
return result
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logger.error(f"Exception in delegate method: {type(e)} - {e}")
|
|
216
|
+
logger.error(f"Exception args: {e.args}")
|
|
217
|
+
raise
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class STSConfig(BaseModel):
|
|
5
|
+
"""Configuration for STS client."""
|
|
6
|
+
|
|
7
|
+
well_known_uri: str = Field(..., description="The well-known configuration URI")
|
|
8
|
+
timeout: int = Field(default=5, description="Request timeout in seconds")
|
|
9
|
+
verify_ssl: bool = Field(default=True, description="Whether to verify SSL certificates")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class STSError(Exception):
|
|
5
|
+
"""Base exception for STS client errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TokenExchangeError(STSError):
|
|
11
|
+
"""Exception raised when token exchange fails."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, error: str, error_description: Optional[str] = None, status_code: Optional[int] = None):
|
|
14
|
+
self.error = error
|
|
15
|
+
self.error_description = error_description
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
super().__init__(f"Token exchange failed: {error} - {error_description}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigurationError(STSError):
|
|
21
|
+
"""Exception raised when STS configuration is invalid."""
|
|
22
|
+
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthenticationError(STSError):
|
|
27
|
+
"""Exception raised when authentication fails."""
|
|
28
|
+
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NetworkError(STSError):
|
|
33
|
+
"""Exception raised when network operations fail."""
|
|
34
|
+
|
|
35
|
+
pass
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Dict, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TokenType(str, Enum):
|
|
10
|
+
"""RFC 8693 defined token types."""
|
|
11
|
+
|
|
12
|
+
JWT = "urn:ietf:params:oauth:token-type:jwt"
|
|
13
|
+
SAML2 = "urn:ietf:params:oauth:token-type:saml2"
|
|
14
|
+
SAML1 = "urn:ietf:params:oauth:token-type:saml1"
|
|
15
|
+
ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token"
|
|
16
|
+
ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GrantType(str, Enum):
|
|
20
|
+
"""OAuth 2.0 grant types."""
|
|
21
|
+
|
|
22
|
+
TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TokenExchangeRequest(BaseModel):
|
|
26
|
+
"""RFC 8693 Token Exchange Request model."""
|
|
27
|
+
|
|
28
|
+
grant_type: GrantType = GrantType.TOKEN_EXCHANGE
|
|
29
|
+
subject_token: str = Field(
|
|
30
|
+
...,
|
|
31
|
+
description="The security token representing the identity of the party on behalf of whom the new token is being requested",
|
|
32
|
+
)
|
|
33
|
+
subject_token_type: TokenType = Field(..., description="The type of the subject_token")
|
|
34
|
+
actor_token: Optional[str] = Field(
|
|
35
|
+
None, description="The security token representing the identity of the acting party"
|
|
36
|
+
)
|
|
37
|
+
actor_token_type: Optional[TokenType] = Field(None, description="The type of the actor_token")
|
|
38
|
+
resource: Optional[Union[str, List[str]]] = Field(
|
|
39
|
+
None, description="The logical name of the target service or resource"
|
|
40
|
+
)
|
|
41
|
+
audience: Optional[Union[str, List[str]]] = Field(
|
|
42
|
+
None, description="The logical name of the target service or resource"
|
|
43
|
+
)
|
|
44
|
+
scope: Optional[str] = Field(None, description="The scope of the requested token")
|
|
45
|
+
requested_token_type: Optional[TokenType] = Field(None, description="The type of the requested token")
|
|
46
|
+
additional_parameters: Optional[Dict[str, Any]] = Field(None, description="Additional parameters for the request")
|
|
47
|
+
|
|
48
|
+
@model_validator(mode="after")
|
|
49
|
+
def actor_token_type_required_with_actor_token(self):
|
|
50
|
+
if self.actor_token and not self.actor_token_type:
|
|
51
|
+
raise ValueError("actor_token_type is required when actor_token is provided")
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def is_delegation_request(self) -> bool:
|
|
55
|
+
"""Check if this is a delegation request (has actor_token)."""
|
|
56
|
+
return self.actor_token is not None
|
|
57
|
+
|
|
58
|
+
def is_impersonation_request(self) -> bool:
|
|
59
|
+
"""Check if this is an impersonation request (no actor_token)."""
|
|
60
|
+
return self.actor_token is None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TokenExchangeResponse(BaseModel):
|
|
64
|
+
"""RFC 8693 Token Exchange Response model."""
|
|
65
|
+
|
|
66
|
+
access_token: str = Field(..., description="The issued security token")
|
|
67
|
+
issued_token_type: TokenType = Field(..., description="The type of the issued token")
|
|
68
|
+
token_type: str = Field(default="Bearer", description="The type of the access token")
|
|
69
|
+
expires_in: Optional[int] = Field(None, description="The lifetime in seconds of the access token")
|
|
70
|
+
scope: Optional[str] = Field(None, description="The scope of the access token")
|
|
71
|
+
refresh_token: Optional[str] = Field(None, description="Refresh token if applicable")
|
|
72
|
+
additional_parameters: Optional[Dict[str, Any]] = Field(None, description="Additional response parameters")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TokenExchangeError(BaseModel):
|
|
76
|
+
"""RFC 8693 Token Exchange Error model."""
|
|
77
|
+
|
|
78
|
+
error: str = Field(..., description="Error code")
|
|
79
|
+
error_description: Optional[str] = Field(None, description="Human-readable error description")
|
|
80
|
+
error_uri: Optional[str] = Field(None, description="URI identifying the error")
|
|
81
|
+
additional_parameters: Optional[Dict[str, Any]] = Field(None, description="Additional error parameters")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class WellKnownConfiguration(BaseModel):
|
|
85
|
+
"""OAuth 2.0 Authorization Server Metadata model."""
|
|
86
|
+
|
|
87
|
+
issuer: str = Field(..., description="The authorization server's issuer identifier")
|
|
88
|
+
token_endpoint: str = Field(..., description="The token endpoint URL")
|
|
89
|
+
token_endpoint_auth_methods_supported: List[str] = Field(default_factory=list)
|
|
90
|
+
token_endpoint_auth_signing_alg_values_supported: List[str] = Field(default_factory=list)
|
|
91
|
+
additional_parameters: Optional[Dict[str, Any]] = Field(None, description="Additional configuration parameters")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Dict
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
|
|
7
|
+
from ._exceptions import ConfigurationError, NetworkError
|
|
8
|
+
from ._exceptions import TokenExchangeError as TokenExchangeException
|
|
9
|
+
from ._models import WellKnownConfiguration
|
|
10
|
+
|
|
11
|
+
# Protocol constants
|
|
12
|
+
HTTP_PROTOCOL = "http://"
|
|
13
|
+
HTTPS_PROTOCOL = "https://"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def fetch_well_known_configuration(
|
|
17
|
+
well_known_uri: str, timeout: int = 5, verify_ssl: bool = True
|
|
18
|
+
) -> WellKnownConfiguration:
|
|
19
|
+
try:
|
|
20
|
+
async with httpx.AsyncClient(timeout=timeout, verify=verify_ssl) as client:
|
|
21
|
+
response = await client.get(well_known_uri)
|
|
22
|
+
response.raise_for_status()
|
|
23
|
+
|
|
24
|
+
data = response.json()
|
|
25
|
+
|
|
26
|
+
# add protocol to token_endpoint if it's missing
|
|
27
|
+
if "token_endpoint" in data and not data["token_endpoint"].startswith((HTTP_PROTOCOL, HTTPS_PROTOCOL)):
|
|
28
|
+
# use the protocol from the well_known_uri
|
|
29
|
+
if well_known_uri.startswith(HTTPS_PROTOCOL):
|
|
30
|
+
protocol = HTTPS_PROTOCOL
|
|
31
|
+
else:
|
|
32
|
+
protocol = HTTP_PROTOCOL
|
|
33
|
+
data["token_endpoint"] = protocol + data["token_endpoint"]
|
|
34
|
+
|
|
35
|
+
config = WellKnownConfiguration.model_validate(data)
|
|
36
|
+
return config
|
|
37
|
+
|
|
38
|
+
except httpx.HTTPStatusError as e:
|
|
39
|
+
raise NetworkError(f"Failed to fetch well-known configuration: HTTP {e.response.status_code}") from e
|
|
40
|
+
except httpx.RequestError as e:
|
|
41
|
+
raise NetworkError(f"Network error fetching well-known configuration: {e}") from e
|
|
42
|
+
except (json.JSONDecodeError, ValidationError) as e:
|
|
43
|
+
raise ConfigurationError(f"Invalid well-known configuration response: {e}") from e
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_token_exchange_error(response_data: Dict[str, Any]) -> TokenExchangeException:
|
|
47
|
+
"""Parse token exchange error response."""
|
|
48
|
+
return TokenExchangeException(
|
|
49
|
+
error=response_data.get("error", "unknown_error"),
|
|
50
|
+
error_description=response_data.get("error_description"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def extract_jwt_claims(token: str) -> Dict[str, Any]:
|
|
55
|
+
"""Extract claims from a JWT token without verification."""
|
|
56
|
+
try:
|
|
57
|
+
import jwt
|
|
58
|
+
|
|
59
|
+
# Decode without verification to extract claims
|
|
60
|
+
return jwt.decode(token, options={"verify_signature": False})
|
|
61
|
+
except Exception as e:
|
|
62
|
+
raise ValueError(f"Failed to extract JWT claims: {e}") from e
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentsts-core
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Security Token Service client implementing RFC 8693 OAuth 2.0 Token Exchange
|
|
5
|
+
Requires-Python: >=3.11.0
|
|
6
|
+
Requires-Dist: cryptography>=41.0.0
|
|
7
|
+
Requires-Dist: httpx>=0.25.0
|
|
8
|
+
Requires-Dist: pydantic>=2.5.0
|
|
9
|
+
Requires-Dist: pyjwt>=2.8.0
|
|
10
|
+
Requires-Dist: typing-extensions>=4.8.0
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'test'
|
|
13
|
+
Requires-Dist: pytest-mock>=3.0.0; extra == 'test'
|
|
14
|
+
Requires-Dist: pytest>=7.0.0; extra == 'test'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
agentsts/core/__init__.py,sha256=Jcy4HnEVedco0WfFdzc6-1DSkMCIKlf3RWtUDeT7Q_Q,253
|
|
2
|
+
agentsts/core/_actor_service.py,sha256=SAgk-E0KwNs0_MxH37d_fWJ99PmYeIcXSTtssKe3lmU,1685
|
|
3
|
+
agentsts/core/_base.py,sha256=qKzKEWoD4TVN8wv4HcO_dRJ2dAho0ndOZ_MsOpSChVA,3751
|
|
4
|
+
agentsts/core/client/__init__.py,sha256=IgYBFJp8aJk-JbYuFgkdIJUsxSX1YLKt3jfhxs_HUwY,688
|
|
5
|
+
agentsts/core/client/_client.py,sha256=Ryko-Y_Rourlrhgc3ygt56gLfc4-sfqN0hP_8g7LZ0I,8297
|
|
6
|
+
agentsts/core/client/_config.py,sha256=x3NrAozxKy2Jel93vNcFDHzyaRFMCyOsHoL4XPwxKE8,365
|
|
7
|
+
agentsts/core/client/_exceptions.py,sha256=giYHNE3m_TI_Am7qY0l9iwIRq7X5MF0x2ZfV0tB4rzM,831
|
|
8
|
+
agentsts/core/client/_models.py,sha256=n_cFmEay-3HTaWbV7xj3JsgRszEEmvbsZwFOtO2ec7A,4237
|
|
9
|
+
agentsts/core/client/_utils.py,sha256=X5b5hQ1PVNCE7sh-bH_7ykX1WkeF_R3HixwXsv5yD94,2386
|
|
10
|
+
agentsts_core-0.0.2.dist-info/METADATA,sha256=j7QAvqExLcJiZENhHqTKq8PGS3V_V1tiFTLm-FdkYeI,506
|
|
11
|
+
agentsts_core-0.0.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
12
|
+
agentsts_core-0.0.2.dist-info/RECORD,,
|