agentsts-core 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.
@@ -0,0 +1,207 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
@@ -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,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agentsts-core"
7
+ version = "0.0.2"
8
+ description = "Security Token Service client implementing RFC 8693 OAuth 2.0 Token Exchange"
9
+ requires-python = ">=3.11.0"
10
+ dependencies = [
11
+ "httpx>=0.25.0",
12
+ "pydantic>=2.5.0",
13
+ "typing-extensions>=4.8.0",
14
+ "cryptography>=41.0.0", # For JWT handling
15
+ "PyJWT>=2.8.0", # For JWT token parsing
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ test = [
20
+ "pytest>=7.0.0",
21
+ "pytest-asyncio>=0.21.0",
22
+ "pytest-mock>=3.0.0",
23
+ ]
24
+
25
+ [tool.ruff]
26
+ extend = "../../pyproject.toml"
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ asyncio_default_fixture_loop_scope = "function"
31
+ asyncio_mode = "auto"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/agentsts"]
@@ -0,0 +1,11 @@
1
+ from ._actor_service import ActorTokenService
2
+ from ._base import STSIntegrationBase
3
+ from .client import STSClient, STSConfig, TokenType
4
+
5
+ __all__ = [
6
+ "STSIntegrationBase",
7
+ "ActorTokenService",
8
+ "STSClient",
9
+ "STSConfig",
10
+ "TokenType",
11
+ ]
@@ -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
@@ -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
File without changes
@@ -0,0 +1,85 @@
1
+ """Tests for STSIntegrationBase."""
2
+
3
+ from typing import Any
4
+ from unittest.mock import AsyncMock, Mock, patch
5
+
6
+ import pytest
7
+
8
+ from agentsts.core import STSIntegrationBase
9
+ from agentsts.core.client import TokenType
10
+
11
+
12
+ class MockSTSIntegration(STSIntegrationBase):
13
+ """Concrete implementation for testing."""
14
+
15
+ def create_auth_credential(self, access_token: str) -> Any:
16
+ """Create a mock auth credential."""
17
+ return {"access_token": access_token, "type": "mock"}
18
+
19
+
20
+ class TestSTSIntegrationBase:
21
+ """Test cases for STSIntegrationBase."""
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_exchange_token_success(self):
25
+ """Test successful token exchange."""
26
+ well_known_uri = "https://sts.example.com/.well-known/openid_configuration"
27
+ subject_token = "subject-token-123"
28
+ expected_access_token = "access-token-456"
29
+
30
+ mock_response = Mock()
31
+ mock_response.access_token = expected_access_token
32
+
33
+ with patch("agentsts.core._base.STSClient") as mock_sts_client_class:
34
+ mock_sts_client = Mock()
35
+ mock_sts_client.exchange_token = AsyncMock(return_value=mock_response)
36
+ mock_sts_client_class.return_value = mock_sts_client
37
+
38
+ with patch("agentsts.core._base.ActorTokenService"):
39
+ integration = MockSTSIntegration(well_known_uri)
40
+
41
+ result = await integration.exchange_token(subject_token)
42
+
43
+ assert result == expected_access_token
44
+ mock_sts_client.exchange_token.assert_called_once_with(
45
+ subject_token=subject_token,
46
+ subject_token_type=TokenType.JWT,
47
+ actor_token=None,
48
+ actor_token_type=None,
49
+ resource=None,
50
+ audience=None,
51
+ scope=None,
52
+ requested_token_type=None,
53
+ additional_parameters=None,
54
+ )
55
+
56
+ @pytest.mark.asyncio
57
+ async def test_exchange_token_failure(self):
58
+ """Test token exchange failure."""
59
+ well_known_uri = "https://sts.example.com/.well-known/openid_configuration"
60
+ subject_token = "invalid-token"
61
+
62
+ with patch("agentsts.core._base.STSClient") as mock_sts_client_class:
63
+ mock_sts_client = Mock()
64
+ mock_sts_client.exchange_token = AsyncMock(side_effect=Exception("Token exchange failed"))
65
+ mock_sts_client_class.return_value = mock_sts_client
66
+
67
+ with patch("agentsts.core._base.ActorTokenService"):
68
+ integration = MockSTSIntegration(well_known_uri)
69
+
70
+ with pytest.raises(Exception, match="Token exchange failed"):
71
+ await integration.exchange_token(subject_token)
72
+
73
+ def test_concrete_implementation(self):
74
+ """Test that concrete implementation works correctly."""
75
+ well_known_uri = "https://sts.example.com/.well-known/openid_configuration"
76
+
77
+ with patch("agentsts.core._base.STSClient"):
78
+ with patch("agentsts.core._base.ActorTokenService"):
79
+ integration = MockSTSIntegration(well_known_uri)
80
+
81
+ # Test that create_auth_credential works
82
+ access_token = "test-access-token"
83
+ credential = integration.create_auth_credential(access_token)
84
+
85
+ assert credential == {"access_token": access_token, "type": "mock"}
@@ -0,0 +1,237 @@
1
+ from unittest.mock import AsyncMock, MagicMock, patch
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from agentsts.core.client import (
7
+ AuthenticationError,
8
+ NetworkError,
9
+ STSClient,
10
+ STSConfig,
11
+ TokenExchangeError,
12
+ TokenType,
13
+ )
14
+
15
+
16
+ class MockWellKnownConfig:
17
+ """Mock well-known configuration for testing."""
18
+
19
+ def __init__(self):
20
+ self.issuer = "https://auth.example.com"
21
+ self.token_endpoint = "https://auth.example.com/oauth/token"
22
+ self.token_endpoint_auth_methods_supported = ["client_secret_basic"]
23
+ self.token_endpoint_auth_signing_alg_values_supported = ["RS256"]
24
+
25
+
26
+ @pytest.fixture
27
+ def config():
28
+ """Create test configuration."""
29
+ return STSConfig(
30
+ well_known_uri="https://auth.example.com/.well-known/oauth-authorization-server",
31
+ )
32
+
33
+
34
+ @pytest.fixture
35
+ def mock_well_known_config():
36
+ """Create mock well-known configuration."""
37
+ return MockWellKnownConfig()
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_impersonation_token_exchange(config, mock_well_known_config):
42
+ """Test impersonation token exchange."""
43
+ with patch("agentsts.core.client._client.fetch_well_known_configuration") as mock_fetch:
44
+ mock_fetch.return_value = mock_well_known_config
45
+
46
+ async with STSClient(config) as client:
47
+ # Mock successful response
48
+ with patch.object(client._http_client, "post", new_callable=AsyncMock) as mock_post:
49
+ mock_response = MagicMock()
50
+ mock_response.status_code = 200
51
+ mock_response.json.return_value = {
52
+ "access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjcyIn0.eyJhdWQiOiJ1cm46ZXhhbXBsZTpjb29wZXJhdGlvbi1jb250ZXh0IiwiaXNzIjoiaHR0cHM6Ly9hcy5leGFtcGxlLmNvbSIsImV4cCI6MTQ0MTkxMzYxMCwic2NvcGUiOiJzdGF0dXMgZmVlZCIsInN1YiI6InVzZXJAZXhhbXBsZS5uZXQifQ.3paKl9UySKYB5ng6_cUtQ2qlO8Rc_y7Mea7IwEXTcYbNdwG9-G1EKCFe5fW3H0hwX-MSZ49Wpcb1SiAZaOQBtw",
53
+ "issued_token_type": "urn:ietf:params:oauth:token-type:jwt",
54
+ "token_type": "Bearer",
55
+ "expires_in": 3600,
56
+ }
57
+ mock_post.return_value = mock_response
58
+
59
+ response = await client.impersonate("subject_token")
60
+
61
+ assert (
62
+ response.access_token
63
+ == "eyJhbGciOiJFUzI1NiIsImtpZCI6IjcyIn0.eyJhdWQiOiJ1cm46ZXhhbXBsZTpjb29wZXJhdGlvbi1jb250ZXh0IiwiaXNzIjoiaHR0cHM6Ly9hcy5leGFtcGxlLmNvbSIsImV4cCI6MTQ0MTkxMzYxMCwic2NvcGUiOiJzdGF0dXMgZmVlZCIsInN1YiI6InVzZXJAZXhhbXBsZS5uZXQifQ.3paKl9UySKYB5ng6_cUtQ2qlO8Rc_y7Mea7IwEXTcYbNdwG9-G1EKCFe5fW3H0hwX-MSZ49Wpcb1SiAZaOQBtw"
64
+ )
65
+ assert response.issued_token_type == TokenType.JWT
66
+ assert response.token_type == "Bearer"
67
+ assert response.expires_in == 3600
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_delegation_token_exchange(config, mock_well_known_config):
72
+ """Test delegation token exchange."""
73
+ with patch("agentsts.core.client._client.fetch_well_known_configuration") as mock_fetch:
74
+ mock_fetch.return_value = mock_well_known_config
75
+
76
+ async with STSClient(config) as client:
77
+ # Mock successful response
78
+ with patch.object(client._http_client, "post", new_callable=AsyncMock) as mock_post:
79
+ mock_response = MagicMock()
80
+ mock_response.status_code = 200
81
+ mock_response.json.return_value = {
82
+ "access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjcyIn0.eyJhdWQiOiJ1cm46ZXhhbXBsZTpjb29wZXJhdGlvbi1jb250ZXh0IiwiaXNzIjoiaHR0cHM6Ly9hcy5leGFtcGxlLmNvbSIsImV4cCI6MTQ0MTkxMzYxMCwic2NvcGUiOiJzdGF0dXMgZmVlZCIsInN1YiI6InVzZXJAZXhhbXBsZS5uZXQiLCJhY3QiOnsic3ViIjoiYWRtaW5AZXhhbXBsZS5uZXQifX0.3paKl9UySKYB5ng6_cUtQ2qlO8Rc_y7Mea7IwEXTcYbNdwG9-G1EKCFe5fW3H0hwX-MSZ49Wpcb1SiAZaOQBtw",
83
+ "issued_token_type": "urn:ietf:params:oauth:token-type:jwt",
84
+ "token_type": "N_A",
85
+ "expires_in": 3600,
86
+ }
87
+ mock_post.return_value = mock_response
88
+
89
+ response = await client.delegate(
90
+ subject_token="subject_token",
91
+ subject_token_type=TokenType.JWT,
92
+ actor_token="actor_token",
93
+ actor_token_type=TokenType.JWT,
94
+ )
95
+
96
+ assert (
97
+ response.access_token
98
+ == "eyJhbGciOiJFUzI1NiIsImtpZCI6IjcyIn0.eyJhdWQiOiJ1cm46ZXhhbXBsZTpjb29wZXJhdGlvbi1jb250ZXh0IiwiaXNzIjoiaHR0cHM6Ly9hcy5leGFtcGxlLmNvbSIsImV4cCI6MTQ0MTkxMzYxMCwic2NvcGUiOiJzdGF0dXMgZmVlZCIsInN1YiI6InVzZXJAZXhhbXBsZS5uZXQiLCJhY3QiOnsic3ViIjoiYWRtaW5AZXhhbXBsZS5uZXQifX0.3paKl9UySKYB5ng6_cUtQ2qlO8Rc_y7Mea7IwEXTcYbNdwG9-G1EKCFe5fW3H0hwX-MSZ49Wpcb1SiAZaOQBtw"
99
+ )
100
+ assert response.issued_token_type == TokenType.JWT
101
+ assert response.token_type == "N_A"
102
+ assert response.expires_in == 3600
103
+
104
+
105
+ @pytest.mark.asyncio
106
+ async def test_delegation_without_subject_token(config, mock_well_known_config):
107
+ """Test delegation without identity token raises error."""
108
+ with patch("agentsts.core.client._client.fetch_well_known_configuration") as mock_fetch:
109
+ mock_fetch.return_value = mock_well_known_config
110
+
111
+ async with STSClient(config) as client: # No identity token
112
+ with pytest.raises(AuthenticationError) as exc_info:
113
+ await client.delegate(
114
+ subject_token=None,
115
+ subject_token_type=TokenType.JWT,
116
+ actor_token="actor_token",
117
+ actor_token_type=TokenType.JWT,
118
+ )
119
+
120
+ assert "Subject token required for delegation" in str(exc_info.value)
121
+
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_token_exchange_error_response(config, mock_well_known_config):
125
+ """Test token exchange error response handling."""
126
+ with patch("agentsts.core.client._client.fetch_well_known_configuration") as mock_fetch:
127
+ mock_fetch.return_value = mock_well_known_config
128
+
129
+ async with STSClient(config) as client:
130
+ with patch.object(client._http_client, "post", new_callable=AsyncMock) as mock_post:
131
+ mock_response = MagicMock()
132
+ mock_response.status_code = 400
133
+ mock_response.json.return_value = {
134
+ "error": "invalid_request",
135
+ "error_description": "The request is missing a required parameter",
136
+ }
137
+ mock_response.text = "Invalid error response"
138
+ mock_post.return_value = mock_response
139
+
140
+ with pytest.raises(TokenExchangeError) as exc_info:
141
+ await client.impersonate("subject_token")
142
+
143
+ assert exc_info.value.error == "invalid_request"
144
+ assert exc_info.value.error_description == "The request is missing a required parameter"
145
+ assert exc_info.value.status_code == 400
146
+
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_network_error(config, mock_well_known_config):
150
+ """Test network error handling."""
151
+ with patch("agentsts.core.client._client.fetch_well_known_configuration") as mock_fetch:
152
+ mock_fetch.return_value = mock_well_known_config
153
+
154
+ async with STSClient(config) as client:
155
+ with patch.object(client._http_client, "post", new_callable=AsyncMock) as mock_post:
156
+ mock_post.side_effect = httpx.RequestError("Network error")
157
+
158
+ with pytest.raises(NetworkError) as exc_info:
159
+ await client.impersonate("subject_token")
160
+
161
+ assert "Network error during token exchange" in str(exc_info.value)
162
+
163
+
164
+ @pytest.mark.asyncio
165
+ async def test_request_data_building(config, mock_well_known_config):
166
+ """Test that request data is built correctly."""
167
+ with patch("agentsts.core.client._client.fetch_well_known_configuration") as mock_fetch:
168
+ mock_fetch.return_value = mock_well_known_config
169
+
170
+ async with STSClient(config) as client:
171
+ with patch.object(client._http_client, "post", new_callable=AsyncMock) as mock_post:
172
+ mock_response = MagicMock()
173
+ mock_response.status_code = 200
174
+ mock_response.json.return_value = {
175
+ "access_token": "new_token",
176
+ "issued_token_type": "urn:ietf:params:oauth:token-type:jwt",
177
+ "token_type": "Bearer",
178
+ }
179
+ mock_post.return_value = mock_response
180
+
181
+ await client.delegate(
182
+ subject_token="subject_token",
183
+ subject_token_type=TokenType.JWT,
184
+ actor_token="actor_token",
185
+ actor_token_type=TokenType.JWT,
186
+ audience="https://api.example.com",
187
+ scope="read write",
188
+ )
189
+
190
+ # Verify the request was made with correct data
191
+ mock_post.assert_called_once()
192
+ call_args = mock_post.call_args
193
+
194
+ # Check URL
195
+ assert call_args[0][0] == "https://auth.example.com/oauth/token"
196
+
197
+ # Check data
198
+ data = call_args[1]["data"]
199
+ assert data["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange"
200
+ assert data["subject_token"] == "subject_token"
201
+ assert data["subject_token_type"] == "urn:ietf:params:oauth:token-type:jwt"
202
+ assert data["actor_token"] == "actor_token"
203
+ assert data["actor_token_type"] == "urn:ietf:params:oauth:token-type:jwt"
204
+ assert data["audience"] == "https://api.example.com"
205
+ assert data["scope"] == "read write"
206
+
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_context_manager(config, mock_well_known_config):
210
+ """Test async context manager functionality."""
211
+ with patch("agentsts.core.client._client.fetch_well_known_configuration") as mock_fetch:
212
+ mock_fetch.return_value = mock_well_known_config
213
+
214
+ async with STSClient(config) as client:
215
+ assert client._well_known_config is not None
216
+ assert client._http_client is not None
217
+
218
+ # After context exit, client should be closed
219
+ assert client._http_client is None
220
+
221
+
222
+ @pytest.mark.asyncio
223
+ async def test_manual_initialization_and_close(config, mock_well_known_config):
224
+ """Test manual initialization and close."""
225
+ with patch("agentsts.core.client._client.fetch_well_known_configuration") as mock_fetch:
226
+ mock_fetch.return_value = mock_well_known_config
227
+
228
+ client = STSClient(config)
229
+ assert client._well_known_config is None
230
+ assert client._http_client is None
231
+
232
+ await client._initialize()
233
+ assert client._well_known_config is not None
234
+ assert client._http_client is not None
235
+
236
+ await client.close()
237
+ assert client._http_client is None
@@ -0,0 +1,155 @@
1
+ """Tests for kagent-sts models."""
2
+
3
+ import pytest
4
+ from pydantic import ValidationError
5
+
6
+ from agentsts.core.client import (
7
+ GrantType,
8
+ TokenExchangeError,
9
+ TokenExchangeRequest,
10
+ TokenExchangeResponse,
11
+ TokenType,
12
+ WellKnownConfiguration,
13
+ )
14
+
15
+
16
+ class TestTokenExchangeRequest:
17
+ """Test TokenExchangeRequest model."""
18
+
19
+ def test_impersonation_request(self):
20
+ """Test impersonation request creation."""
21
+ request = TokenExchangeRequest(subject_token="test_token", subject_token_type=TokenType.JWT)
22
+
23
+ assert request.grant_type == GrantType.TOKEN_EXCHANGE
24
+ assert request.subject_token == "test_token"
25
+ assert request.subject_token_type == TokenType.JWT
26
+ assert request.actor_token is None
27
+ assert request.actor_token_type is None
28
+ assert request.is_impersonation_request() is True
29
+ assert request.is_delegation_request() is False
30
+
31
+ def test_delegation_request(self):
32
+ """Test delegation request creation."""
33
+ request = TokenExchangeRequest(
34
+ subject_token="test_token",
35
+ subject_token_type=TokenType.JWT,
36
+ actor_token="actor_token",
37
+ actor_token_type=TokenType.JWT,
38
+ )
39
+
40
+ assert request.actor_token == "actor_token"
41
+ assert request.actor_token_type == TokenType.JWT
42
+ assert request.is_delegation_request() is True
43
+ assert request.is_impersonation_request() is False
44
+
45
+ def test_actor_token_type_required_with_actor_token(self):
46
+ """Test that actor_token_type is required when actor_token is provided."""
47
+ with pytest.raises(ValidationError) as exc_info:
48
+ TokenExchangeRequest(
49
+ subject_token="test_token",
50
+ subject_token_type=TokenType.JWT,
51
+ actor_token="actor_token",
52
+ # Missing actor_token_type
53
+ )
54
+
55
+ # Check that the error message is in the validation error
56
+ error_messages = []
57
+ for error in exc_info.value.errors():
58
+ error_messages.append(error["msg"])
59
+
60
+ assert any("actor_token_type is required when actor_token is provided" in msg for msg in error_messages)
61
+
62
+ def test_optional_parameters(self):
63
+ """Test optional parameters."""
64
+ request = TokenExchangeRequest(
65
+ subject_token="test_token",
66
+ subject_token_type=TokenType.JWT,
67
+ resource="https://api.example.com",
68
+ audience="https://api.example.com",
69
+ scope="read write",
70
+ requested_token_type=TokenType.JWT,
71
+ )
72
+
73
+ assert request.resource == "https://api.example.com"
74
+ assert request.audience == "https://api.example.com"
75
+ assert request.scope == "read write"
76
+ assert request.requested_token_type == TokenType.JWT
77
+
78
+
79
+ class TestTokenExchangeResponse:
80
+ """Test TokenExchangeResponse model."""
81
+
82
+ def test_successful_response(self):
83
+ """Test successful response creation."""
84
+ response = TokenExchangeResponse(
85
+ access_token="new_token",
86
+ issued_token_type=TokenType.JWT,
87
+ token_type="Bearer",
88
+ expires_in=3600,
89
+ scope="read write",
90
+ )
91
+
92
+ assert response.access_token == "new_token"
93
+ assert response.issued_token_type == TokenType.JWT
94
+ assert response.token_type == "Bearer"
95
+ assert response.expires_in == 3600
96
+ assert response.scope == "read write"
97
+
98
+ def test_minimal_response(self):
99
+ """Test minimal response creation."""
100
+ response = TokenExchangeResponse(access_token="new_token", issued_token_type=TokenType.JWT)
101
+
102
+ assert response.access_token == "new_token"
103
+ assert response.issued_token_type == TokenType.JWT
104
+ assert response.token_type == "Bearer" # Default value
105
+ assert response.expires_in is None
106
+ assert response.scope is None
107
+
108
+
109
+ class TestTokenExchangeError:
110
+ """Test TokenExchangeError model."""
111
+
112
+ def test_error_creation(self):
113
+ """Test error creation."""
114
+ error = TokenExchangeError(
115
+ error="invalid_request", error_description="The request is missing a required parameter"
116
+ )
117
+
118
+ assert error.error == "invalid_request"
119
+ assert error.error_description == "The request is missing a required parameter"
120
+
121
+ def test_minimal_error(self):
122
+ """Test minimal error creation."""
123
+ error = TokenExchangeError(error="server_error")
124
+
125
+ assert error.error == "server_error"
126
+ assert error.error_description is None
127
+
128
+
129
+ class TestWellKnownConfiguration:
130
+ """Test WellKnownConfiguration model."""
131
+
132
+ def test_configuration_creation(self):
133
+ """Test configuration creation."""
134
+ config = WellKnownConfiguration(
135
+ issuer="https://auth.example.com",
136
+ token_endpoint="https://auth.example.com/oauth/token",
137
+ token_endpoint_auth_methods_supported=["client_secret_basic", "client_secret_post"],
138
+ token_endpoint_auth_signing_alg_values_supported=["RS256", "HS256"],
139
+ )
140
+
141
+ assert config.issuer == "https://auth.example.com"
142
+ assert config.token_endpoint == "https://auth.example.com/oauth/token"
143
+ assert "client_secret_basic" in config.token_endpoint_auth_methods_supported
144
+ assert "RS256" in config.token_endpoint_auth_signing_alg_values_supported
145
+
146
+ def test_minimal_configuration(self):
147
+ """Test minimal configuration creation."""
148
+ config = WellKnownConfiguration(
149
+ issuer="https://auth.example.com", token_endpoint="https://auth.example.com/oauth/token"
150
+ )
151
+
152
+ assert config.issuer == "https://auth.example.com"
153
+ assert config.token_endpoint == "https://auth.example.com/oauth/token"
154
+ assert config.token_endpoint_auth_methods_supported == []
155
+ assert config.token_endpoint_auth_signing_alg_values_supported == []