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.
- agentsts_core-0.0.2/.gitignore +207 -0
- agentsts_core-0.0.2/PKG-INFO +14 -0
- agentsts_core-0.0.2/pyproject.toml +34 -0
- agentsts_core-0.0.2/src/agentsts/core/__init__.py +11 -0
- agentsts_core-0.0.2/src/agentsts/core/_actor_service.py +51 -0
- agentsts_core-0.0.2/src/agentsts/core/_base.py +99 -0
- agentsts_core-0.0.2/src/agentsts/core/client/__init__.py +23 -0
- agentsts_core-0.0.2/src/agentsts/core/client/_client.py +217 -0
- agentsts_core-0.0.2/src/agentsts/core/client/_config.py +9 -0
- agentsts_core-0.0.2/src/agentsts/core/client/_exceptions.py +35 -0
- agentsts_core-0.0.2/src/agentsts/core/client/_models.py +91 -0
- agentsts_core-0.0.2/src/agentsts/core/client/_utils.py +62 -0
- agentsts_core-0.0.2/tests/__init__.py +0 -0
- agentsts_core-0.0.2/tests/test_base.py +85 -0
- agentsts_core-0.0.2/tests/test_client.py +237 -0
- agentsts_core-0.0.2/tests/test_models.py +155 -0
|
@@ -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,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 == []
|