authweave-core 7.0.0__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.
- authweave_core-7.0.0/.gitignore +186 -0
- authweave_core-7.0.0/LICENSE +21 -0
- authweave_core-7.0.0/PKG-INFO +39 -0
- authweave_core-7.0.0/README.md +14 -0
- authweave_core-7.0.0/authweave_core/__init__.py +45 -0
- authweave_core-7.0.0/authweave_core/coordinator.py +148 -0
- authweave_core-7.0.0/authweave_core/models.py +357 -0
- authweave_core-7.0.0/authweave_core/py.typed +0 -0
- authweave_core-7.0.0/pyproject.toml +54 -0
- authweave_core-7.0.0/tests/__init__.py +1 -0
- authweave_core-7.0.0/tests/test_coordinator.py +203 -0
- authweave_core-7.0.0/tests/test_imports.py +31 -0
- authweave_core-7.0.0/tests/test_models.py +261 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
.gitignore### Python ###
|
|
2
|
+
# Byte-compiled / optimized / DLL files
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
*$py.class
|
|
6
|
+
|
|
7
|
+
# C extensions
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# Distribution / packaging
|
|
11
|
+
.Python
|
|
12
|
+
build/
|
|
13
|
+
develop-eggs/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
*.db
|
|
65
|
+
|
|
66
|
+
# Flask stuff:
|
|
67
|
+
instance/
|
|
68
|
+
.webassets-cache
|
|
69
|
+
|
|
70
|
+
# Scrapy stuff:
|
|
71
|
+
.scrapy
|
|
72
|
+
|
|
73
|
+
# Sphinx documentation
|
|
74
|
+
docs/_build/
|
|
75
|
+
|
|
76
|
+
# PyBuilder
|
|
77
|
+
.pybuilder/
|
|
78
|
+
target/
|
|
79
|
+
|
|
80
|
+
# Jupyter Notebook
|
|
81
|
+
.ipynb_checkpoints
|
|
82
|
+
|
|
83
|
+
# IPython
|
|
84
|
+
profile_default/
|
|
85
|
+
ipython_config.py
|
|
86
|
+
|
|
87
|
+
# pyenv
|
|
88
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
89
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
90
|
+
# .python-version
|
|
91
|
+
|
|
92
|
+
# pipenv
|
|
93
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
94
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
95
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
96
|
+
# install all needed dependencies.
|
|
97
|
+
#Pipfile.lock
|
|
98
|
+
|
|
99
|
+
# poetry
|
|
100
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
101
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
102
|
+
# commonly ignored for libraries.
|
|
103
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
104
|
+
#poetry.lock
|
|
105
|
+
|
|
106
|
+
# pdm
|
|
107
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
108
|
+
#pdm.lock
|
|
109
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
110
|
+
# in version control.
|
|
111
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
112
|
+
.pdm.toml
|
|
113
|
+
|
|
114
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
115
|
+
__pypackages__/
|
|
116
|
+
|
|
117
|
+
# Celery stuff
|
|
118
|
+
celerybeat-schedule
|
|
119
|
+
celerybeat.pid
|
|
120
|
+
|
|
121
|
+
# SageMath parsed files
|
|
122
|
+
*.sage.py
|
|
123
|
+
|
|
124
|
+
# Environments
|
|
125
|
+
.venv
|
|
126
|
+
env/
|
|
127
|
+
venv/
|
|
128
|
+
ENV/
|
|
129
|
+
env.bak/
|
|
130
|
+
venv.bak/
|
|
131
|
+
|
|
132
|
+
# Spyder project settings
|
|
133
|
+
.spyderproject
|
|
134
|
+
.spyproject
|
|
135
|
+
|
|
136
|
+
# Rope project settings
|
|
137
|
+
.ropeproject
|
|
138
|
+
|
|
139
|
+
# zensical documentation
|
|
140
|
+
/site
|
|
141
|
+
docs/_include/
|
|
142
|
+
|
|
143
|
+
# mypy
|
|
144
|
+
.mypy_cache/
|
|
145
|
+
.dmypy.json
|
|
146
|
+
dmypy.json
|
|
147
|
+
|
|
148
|
+
# Pyre type checker
|
|
149
|
+
.pyre/
|
|
150
|
+
|
|
151
|
+
# pytype static type analyzer
|
|
152
|
+
.pytype/
|
|
153
|
+
|
|
154
|
+
# Cython debug symbols
|
|
155
|
+
cython_debug/
|
|
156
|
+
|
|
157
|
+
# PyCharm
|
|
158
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
159
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
160
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
161
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
162
|
+
#.idea/
|
|
163
|
+
|
|
164
|
+
### Python Patch ###
|
|
165
|
+
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
|
166
|
+
poetry.toml
|
|
167
|
+
|
|
168
|
+
# ruff
|
|
169
|
+
.ruff_cache/
|
|
170
|
+
|
|
171
|
+
# LSP config files
|
|
172
|
+
pyrightconfig.json
|
|
173
|
+
|
|
174
|
+
.env
|
|
175
|
+
.uv-cache
|
|
176
|
+
|
|
177
|
+
# AI agents
|
|
178
|
+
PRD.md
|
|
179
|
+
AGENTS.md
|
|
180
|
+
CLAUDE.md
|
|
181
|
+
TECHSTACK.md
|
|
182
|
+
.agents/
|
|
183
|
+
.claude/
|
|
184
|
+
.cursor/
|
|
185
|
+
.codacy/
|
|
186
|
+
.codegraph/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vladislav Shepilov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: authweave-core
|
|
3
|
+
Version: 7.0.0
|
|
4
|
+
Summary: Framework-neutral principal authentication contracts and coordinator
|
|
5
|
+
Project-URL: homepage, https://github.com/ZYLVEXT/litestar-auth
|
|
6
|
+
Project-URL: documentation, https://zylvext.github.io/litestar-auth/
|
|
7
|
+
Project-URL: source, https://github.com/ZYLVEXT/litestar-auth
|
|
8
|
+
Project-URL: tracker, https://github.com/ZYLVEXT/litestar-auth/issues
|
|
9
|
+
Author-email: Vladislav Shepilov <shepilov.v@protonmail.com>
|
|
10
|
+
Maintainer-email: Vladislav Shepilov <shepilov.v@protonmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: authentication,framework-agnostic,principal,security
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: <3.15.0,>=3.12.0
|
|
23
|
+
Requires-Dist: anyio<5.0,>=4.14.2
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# authweave-core
|
|
27
|
+
|
|
28
|
+
Framework-neutral contracts and fail-closed coordination for principal authentication.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv add authweave-core
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The package models verified principals, authentication evidence, immutable request projections,
|
|
35
|
+
typed authentication decisions, provider ownership, route policies, and deadline-aware
|
|
36
|
+
coordination. It does not depend on a web framework, ORM, cache, or cryptography implementation.
|
|
37
|
+
|
|
38
|
+
`authweave-core` performs authentication orchestration only. Applications remain responsible for
|
|
39
|
+
resource authorization.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# authweave-core
|
|
2
|
+
|
|
3
|
+
Framework-neutral contracts and fail-closed coordination for principal authentication.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv add authweave-core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The package models verified principals, authentication evidence, immutable request projections,
|
|
10
|
+
typed authentication decisions, provider ownership, route policies, and deadline-aware
|
|
11
|
+
coordination. It does not depend on a web framework, ORM, cache, or cryptography implementation.
|
|
12
|
+
|
|
13
|
+
`authweave-core` performs authentication orchestration only. Applications remain responsible for
|
|
14
|
+
resource authorization.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Framework-neutral principal authentication contracts."""
|
|
2
|
+
|
|
3
|
+
from authweave_core.coordinator import AuthenticationCoordinator, RequestAuthenticationProvider
|
|
4
|
+
from authweave_core.models import (
|
|
5
|
+
Authenticated,
|
|
6
|
+
AuthenticationContext,
|
|
7
|
+
AuthenticationDecision,
|
|
8
|
+
AuthenticationEvidence,
|
|
9
|
+
AuthenticationRuntime,
|
|
10
|
+
CredentialMatch,
|
|
11
|
+
EvidenceValue,
|
|
12
|
+
FailureCode,
|
|
13
|
+
Invalid,
|
|
14
|
+
InvariantFailure,
|
|
15
|
+
NotApplicable,
|
|
16
|
+
PrincipalRef,
|
|
17
|
+
RequestView,
|
|
18
|
+
RouteProviderPolicy,
|
|
19
|
+
TlsPeerEvidence,
|
|
20
|
+
Unavailable,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__version__ = "7.0.0"
|
|
24
|
+
|
|
25
|
+
__all__ = (
|
|
26
|
+
"Authenticated",
|
|
27
|
+
"AuthenticationContext",
|
|
28
|
+
"AuthenticationCoordinator",
|
|
29
|
+
"AuthenticationDecision",
|
|
30
|
+
"AuthenticationEvidence",
|
|
31
|
+
"AuthenticationRuntime",
|
|
32
|
+
"CredentialMatch",
|
|
33
|
+
"EvidenceValue",
|
|
34
|
+
"FailureCode",
|
|
35
|
+
"Invalid",
|
|
36
|
+
"InvariantFailure",
|
|
37
|
+
"NotApplicable",
|
|
38
|
+
"PrincipalRef",
|
|
39
|
+
"RequestAuthenticationProvider",
|
|
40
|
+
"RequestView",
|
|
41
|
+
"RouteProviderPolicy",
|
|
42
|
+
"TlsPeerEvidence",
|
|
43
|
+
"Unavailable",
|
|
44
|
+
"__version__",
|
|
45
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Fail-closed authentication provider coordination."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
|
|
9
|
+
from authweave_core.models import (
|
|
10
|
+
Authenticated,
|
|
11
|
+
AuthenticationDecision,
|
|
12
|
+
AuthenticationRuntime,
|
|
13
|
+
CredentialMatch,
|
|
14
|
+
FailureCode,
|
|
15
|
+
Invalid,
|
|
16
|
+
InvariantFailure,
|
|
17
|
+
NotApplicable,
|
|
18
|
+
RequestView,
|
|
19
|
+
RouteProviderPolicy,
|
|
20
|
+
Unavailable,
|
|
21
|
+
_validate_label,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Iterable
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class RequestAuthenticationProvider(Protocol):
|
|
30
|
+
"""Framework-neutral request authentication provider."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
profile: str
|
|
34
|
+
|
|
35
|
+
def match(self, request: RequestView) -> CredentialMatch:
|
|
36
|
+
"""Classify credential ownership without performing expensive verification."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
async def authenticate(
|
|
40
|
+
self,
|
|
41
|
+
request: RequestView,
|
|
42
|
+
runtime: AuthenticationRuntime,
|
|
43
|
+
) -> AuthenticationDecision:
|
|
44
|
+
"""Verify an owned credential presentation."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AuthenticationCoordinator:
|
|
49
|
+
"""Route providers deterministically and stop on every owned failure."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, providers: Iterable[RequestAuthenticationProvider]) -> None:
|
|
52
|
+
"""Freeze and validate the provider inventory.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If provider names or profiles are invalid or duplicated.
|
|
56
|
+
"""
|
|
57
|
+
inventory = tuple(providers)
|
|
58
|
+
names: set[str] = set()
|
|
59
|
+
profiles: set[str] = set()
|
|
60
|
+
for provider in inventory:
|
|
61
|
+
_validate_label(provider.name, name="provider name")
|
|
62
|
+
_validate_label(provider.profile, name="provider profile")
|
|
63
|
+
if provider.name in names:
|
|
64
|
+
msg = f"duplicate provider name: {provider.name}"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
if provider.profile in profiles:
|
|
67
|
+
msg = f"duplicate provider profile: {provider.profile}"
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
names.add(provider.name)
|
|
70
|
+
profiles.add(provider.profile)
|
|
71
|
+
self._providers = {provider.name: provider for provider in inventory}
|
|
72
|
+
|
|
73
|
+
async def authenticate(
|
|
74
|
+
self,
|
|
75
|
+
request: RequestView,
|
|
76
|
+
runtime: AuthenticationRuntime,
|
|
77
|
+
policy: RouteProviderPolicy,
|
|
78
|
+
) -> AuthenticationDecision:
|
|
79
|
+
"""Authenticate through exactly one credential owner.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
A terminal typed authentication decision.
|
|
83
|
+
"""
|
|
84
|
+
selected = self._select_provider(request, policy)
|
|
85
|
+
if not isinstance(selected, RequestAuthenticationProvider):
|
|
86
|
+
return selected
|
|
87
|
+
|
|
88
|
+
decision = await self._authenticate_with_deadline(selected, request, runtime)
|
|
89
|
+
return self._validate_decision(selected, decision)
|
|
90
|
+
|
|
91
|
+
def _select_provider(
|
|
92
|
+
self,
|
|
93
|
+
request: RequestView,
|
|
94
|
+
policy: RouteProviderPolicy,
|
|
95
|
+
) -> RequestAuthenticationProvider | AuthenticationDecision:
|
|
96
|
+
allowed: list[RequestAuthenticationProvider] = []
|
|
97
|
+
for name in policy.providers:
|
|
98
|
+
provider = self._providers.get(name)
|
|
99
|
+
if provider is None:
|
|
100
|
+
return InvariantFailure()
|
|
101
|
+
match provider.match(request):
|
|
102
|
+
case CredentialMatch.NOT_APPLICABLE:
|
|
103
|
+
continue
|
|
104
|
+
case CredentialMatch.OWNED:
|
|
105
|
+
allowed.append(provider)
|
|
106
|
+
case CredentialMatch.AMBIGUOUS:
|
|
107
|
+
return Invalid(FailureCode.AMBIGUOUS_CREDENTIALS)
|
|
108
|
+
case _:
|
|
109
|
+
return InvariantFailure()
|
|
110
|
+
if not allowed:
|
|
111
|
+
return NotApplicable()
|
|
112
|
+
return allowed[0]
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _validate_decision(
|
|
116
|
+
provider: RequestAuthenticationProvider,
|
|
117
|
+
decision: object,
|
|
118
|
+
) -> AuthenticationDecision:
|
|
119
|
+
if isinstance(decision, NotApplicable):
|
|
120
|
+
return InvariantFailure()
|
|
121
|
+
if isinstance(decision, Authenticated):
|
|
122
|
+
evidence = decision.context.evidence
|
|
123
|
+
if evidence.provider != provider.name or evidence.profile != provider.profile:
|
|
124
|
+
return InvariantFailure()
|
|
125
|
+
if not isinstance(decision, (Authenticated, Invalid, Unavailable, InvariantFailure)):
|
|
126
|
+
return InvariantFailure()
|
|
127
|
+
return decision
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
async def _authenticate_with_deadline(
|
|
131
|
+
provider: RequestAuthenticationProvider,
|
|
132
|
+
request: RequestView,
|
|
133
|
+
runtime: AuthenticationRuntime,
|
|
134
|
+
) -> AuthenticationDecision:
|
|
135
|
+
"""Apply the request deadline without swallowing external cancellation.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Provider decision or unavailable when the deadline expires.
|
|
139
|
+
"""
|
|
140
|
+
if runtime.deadline is None:
|
|
141
|
+
return await provider.authenticate(request, runtime)
|
|
142
|
+
|
|
143
|
+
remaining = runtime.deadline - anyio.current_time()
|
|
144
|
+
if remaining <= 0:
|
|
145
|
+
return Unavailable()
|
|
146
|
+
with anyio.move_on_after(remaining) as timeout_scope:
|
|
147
|
+
decision = await provider.authenticate(request, runtime)
|
|
148
|
+
return Unavailable() if timeout_scope.cancelled_caught else decision
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Immutable contracts shared by authentication providers and adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
|
|
15
|
+
_LABEL_PATTERN = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
|
|
16
|
+
_EXTENSION_KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_.-]{0,62}:[a-z][a-z0-9_.-]{0,62}$")
|
|
17
|
+
_HTTP_TOKEN_PATTERN = re.compile(rb"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
|
18
|
+
_THUMBPRINT_PATTERN = re.compile(r"^[A-Za-z0-9_-]{43}$")
|
|
19
|
+
_MAX_ISSUER_LENGTH = 2048
|
|
20
|
+
_MAX_SUBJECT_LENGTH = 512
|
|
21
|
+
_MAX_VALUE_LENGTH = 512
|
|
22
|
+
_MAX_EXTENSIONS = 16
|
|
23
|
+
_MAX_EVIDENCE_VALUES = 64
|
|
24
|
+
_MAX_HEADERS = 128
|
|
25
|
+
_MAX_HEADER_BYTES = 65_536
|
|
26
|
+
|
|
27
|
+
type EvidenceValue = str | int | bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _validate_text(value: str, *, name: str, max_length: int = _MAX_VALUE_LENGTH) -> None:
|
|
31
|
+
if not value or value != value.strip() or len(value) > max_length:
|
|
32
|
+
msg = f"{name} must be non-empty, trimmed, and at most {max_length} characters"
|
|
33
|
+
raise ValueError(msg)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validate_label(value: str, *, name: str) -> None:
|
|
37
|
+
if _LABEL_PATTERN.fullmatch(value) is None:
|
|
38
|
+
msg = f"{name} must match {_LABEL_PATTERN.pattern!r}"
|
|
39
|
+
raise ValueError(msg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_aware(value: datetime | None, *, name: str) -> None:
|
|
43
|
+
if value is not None and value.utcoffset() is None:
|
|
44
|
+
msg = f"{name} must be timezone-aware"
|
|
45
|
+
raise ValueError(msg)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
49
|
+
class PrincipalRef:
|
|
50
|
+
"""Stable principal identity with a verified classification."""
|
|
51
|
+
|
|
52
|
+
issuer: str
|
|
53
|
+
subject: str
|
|
54
|
+
kind: str
|
|
55
|
+
|
|
56
|
+
def __post_init__(self) -> None:
|
|
57
|
+
"""Validate bounded identity components."""
|
|
58
|
+
_validate_text(self.issuer, name="issuer", max_length=_MAX_ISSUER_LENGTH)
|
|
59
|
+
_validate_text(self.subject, name="subject", max_length=_MAX_SUBJECT_LENGTH)
|
|
60
|
+
_validate_label(self.kind, name="kind")
|
|
61
|
+
|
|
62
|
+
def __eq__(self, other: object) -> bool:
|
|
63
|
+
"""Compare stable identity independently from classification.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Whether issuer and subject match.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(other, PrincipalRef):
|
|
69
|
+
return NotImplemented
|
|
70
|
+
return (self.issuer, self.subject) == (other.issuer, other.subject)
|
|
71
|
+
|
|
72
|
+
def __hash__(self) -> int:
|
|
73
|
+
"""Hash the stable identity independently from classification.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Hash of issuer and subject.
|
|
77
|
+
"""
|
|
78
|
+
return hash((self.issuer, self.subject))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class TlsPeerEvidence:
|
|
83
|
+
"""Verified TLS peer facts produced by a trusted termination boundary."""
|
|
84
|
+
|
|
85
|
+
tls_version: str
|
|
86
|
+
certificate_thumbprint: str
|
|
87
|
+
certificate_not_before: datetime
|
|
88
|
+
certificate_not_after: datetime
|
|
89
|
+
revocation_checked_at: datetime
|
|
90
|
+
trust_anchor: str
|
|
91
|
+
termination_boundary: str
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
"""Reject malformed or internally inconsistent TLS evidence.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ValueError: If a field is malformed or the validity interval is empty.
|
|
98
|
+
"""
|
|
99
|
+
_validate_text(self.tls_version, name="tls_version", max_length=32)
|
|
100
|
+
if _THUMBPRINT_PATTERN.fullmatch(self.certificate_thumbprint) is None:
|
|
101
|
+
msg = "certificate_thumbprint must be an unpadded base64url SHA-256 digest"
|
|
102
|
+
raise ValueError(msg)
|
|
103
|
+
_validate_aware(self.certificate_not_before, name="certificate_not_before")
|
|
104
|
+
_validate_aware(self.certificate_not_after, name="certificate_not_after")
|
|
105
|
+
_validate_aware(self.revocation_checked_at, name="revocation_checked_at")
|
|
106
|
+
if self.certificate_not_before >= self.certificate_not_after:
|
|
107
|
+
msg = "certificate_not_before must be earlier than certificate_not_after"
|
|
108
|
+
raise ValueError(msg)
|
|
109
|
+
_validate_text(self.trust_anchor, name="trust_anchor")
|
|
110
|
+
_validate_text(self.termination_boundary, name="termination_boundary")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class RequestView:
|
|
115
|
+
"""Immutable, framework-neutral projection of authentication inputs."""
|
|
116
|
+
|
|
117
|
+
method: str
|
|
118
|
+
headers: tuple[tuple[bytes, bytes], ...] = field(default=(), repr=False)
|
|
119
|
+
scheme: str | None = None
|
|
120
|
+
authority: str | None = None
|
|
121
|
+
tls_peer: TlsPeerEvidence | None = None
|
|
122
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
123
|
+
correlation_id: str | None = None
|
|
124
|
+
|
|
125
|
+
def __post_init__(self) -> None:
|
|
126
|
+
"""Validate request metadata without collapsing duplicate headers.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ValueError: If request metadata is malformed.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
method = self.method.encode("ascii")
|
|
133
|
+
except UnicodeEncodeError as exc:
|
|
134
|
+
msg = "method must be an ASCII HTTP token"
|
|
135
|
+
raise ValueError(msg) from exc
|
|
136
|
+
if _HTTP_TOKEN_PATTERN.fullmatch(method) is None:
|
|
137
|
+
msg = "method must be an ASCII HTTP token"
|
|
138
|
+
raise ValueError(msg)
|
|
139
|
+
object.__setattr__(self, "headers", tuple(self.headers))
|
|
140
|
+
if (
|
|
141
|
+
len(self.headers) > _MAX_HEADERS
|
|
142
|
+
or sum(len(name) + len(value) for name, value in self.headers) > _MAX_HEADER_BYTES
|
|
143
|
+
):
|
|
144
|
+
msg = "headers exceed the authentication projection limits"
|
|
145
|
+
raise ValueError(msg)
|
|
146
|
+
for name, value in self.headers:
|
|
147
|
+
if _HTTP_TOKEN_PATTERN.fullmatch(name) is None:
|
|
148
|
+
msg = "header names must be non-empty ASCII HTTP tokens"
|
|
149
|
+
raise ValueError(msg)
|
|
150
|
+
if b"\x00" in value or b"\r" in value or b"\n" in value:
|
|
151
|
+
msg = "header values must not contain NUL, CR, or LF"
|
|
152
|
+
raise ValueError(msg)
|
|
153
|
+
if self.scheme is not None:
|
|
154
|
+
_validate_text(self.scheme, name="scheme", max_length=32)
|
|
155
|
+
if self.authority is not None:
|
|
156
|
+
_validate_text(self.authority, name="authority", max_length=512)
|
|
157
|
+
_validate_aware(self.timestamp, name="timestamp")
|
|
158
|
+
if self.correlation_id is not None:
|
|
159
|
+
_validate_text(self.correlation_id, name="correlation_id")
|
|
160
|
+
|
|
161
|
+
def header_values(self, name: bytes) -> tuple[bytes, ...]:
|
|
162
|
+
"""Return every value for a case-insensitive header name."""
|
|
163
|
+
normalized = name.lower()
|
|
164
|
+
return tuple(value for header_name, value in self.headers if header_name.lower() == normalized)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True)
|
|
168
|
+
class AuthenticationEvidence:
|
|
169
|
+
"""Verified, secret-free facts produced by an authentication provider."""
|
|
170
|
+
|
|
171
|
+
provider: str
|
|
172
|
+
profile: str
|
|
173
|
+
method: str
|
|
174
|
+
issuer: str
|
|
175
|
+
audiences: tuple[str, ...] = ()
|
|
176
|
+
scopes: tuple[str, ...] = ()
|
|
177
|
+
issued_at: datetime | None = None
|
|
178
|
+
not_before: datetime | None = None
|
|
179
|
+
expires_at: datetime | None = None
|
|
180
|
+
credential_id: str | None = None
|
|
181
|
+
token_id: str | None = None
|
|
182
|
+
confirmation_thumbprint: str | None = None
|
|
183
|
+
environment: str | None = None
|
|
184
|
+
extensions: Mapping[str, EvidenceValue] = field(default_factory=dict)
|
|
185
|
+
|
|
186
|
+
def __post_init__(self) -> None:
|
|
187
|
+
"""Validate bounded evidence and freeze extension data."""
|
|
188
|
+
_validate_label(self.provider, name="provider")
|
|
189
|
+
_validate_label(self.profile, name="profile")
|
|
190
|
+
_validate_label(self.method, name="method")
|
|
191
|
+
_validate_text(self.issuer, name="issuer", max_length=_MAX_ISSUER_LENGTH)
|
|
192
|
+
object.__setattr__(self, "audiences", _freeze_evidence_values(self.audiences, name="audiences"))
|
|
193
|
+
object.__setattr__(self, "scopes", _freeze_evidence_values(self.scopes, name="scopes"))
|
|
194
|
+
_validate_evidence_times(self)
|
|
195
|
+
_validate_evidence_identifiers(self)
|
|
196
|
+
object.__setattr__(self, "extensions", _freeze_extensions(self.extensions))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True, slots=True)
|
|
200
|
+
class AuthenticationContext:
|
|
201
|
+
"""Authenticated subject, actor, delegation chain, and verified evidence."""
|
|
202
|
+
|
|
203
|
+
subject: PrincipalRef
|
|
204
|
+
actor: PrincipalRef
|
|
205
|
+
evidence: AuthenticationEvidence
|
|
206
|
+
delegation_chain: tuple[PrincipalRef, ...] = ()
|
|
207
|
+
|
|
208
|
+
def __post_init__(self) -> None:
|
|
209
|
+
"""Freeze the delegation chain."""
|
|
210
|
+
object.__setattr__(self, "delegation_chain", tuple(self.delegation_chain))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class FailureCode(StrEnum):
|
|
214
|
+
"""Stable neutral authentication failure codes."""
|
|
215
|
+
|
|
216
|
+
MISSING = "missing"
|
|
217
|
+
MALFORMED = "malformed"
|
|
218
|
+
INVALID = "invalid"
|
|
219
|
+
EXPIRED = "expired"
|
|
220
|
+
NOT_YET_VALID = "not_yet_valid"
|
|
221
|
+
REVOKED = "revoked"
|
|
222
|
+
PRINCIPAL_DISABLED = "principal_disabled"
|
|
223
|
+
ISSUER_MISMATCH = "issuer_mismatch"
|
|
224
|
+
AUDIENCE_MISMATCH = "audience_mismatch"
|
|
225
|
+
TOKEN_TYPE_MISMATCH = "token_type_mismatch"
|
|
226
|
+
ALGORITHM_MISMATCH = "algorithm_mismatch"
|
|
227
|
+
SENDER_CONSTRAINT_MISMATCH = "sender_constraint_mismatch"
|
|
228
|
+
AMBIGUOUS_CREDENTIALS = "ambiguous_credentials"
|
|
229
|
+
PROVIDER_UNAVAILABLE = "provider_unavailable"
|
|
230
|
+
INTERNAL_INVARIANT = "internal_invariant"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class CredentialMatch(StrEnum):
|
|
234
|
+
"""Credential ownership result returned by a provider matcher."""
|
|
235
|
+
|
|
236
|
+
NOT_APPLICABLE = "not_applicable"
|
|
237
|
+
OWNED = "owned"
|
|
238
|
+
AMBIGUOUS = "ambiguous"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(frozen=True, slots=True)
|
|
242
|
+
class NotApplicable:
|
|
243
|
+
"""No allowed provider owns a credential presentation."""
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True, slots=True)
|
|
247
|
+
class Authenticated:
|
|
248
|
+
"""Credential was fully verified."""
|
|
249
|
+
|
|
250
|
+
context: AuthenticationContext
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass(frozen=True, slots=True)
|
|
254
|
+
class Invalid:
|
|
255
|
+
"""Credential was owned but failed verification."""
|
|
256
|
+
|
|
257
|
+
code: FailureCode
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass(frozen=True, slots=True)
|
|
261
|
+
class Unavailable:
|
|
262
|
+
"""Credential verification could not complete safely."""
|
|
263
|
+
|
|
264
|
+
code: FailureCode = FailureCode.PROVIDER_UNAVAILABLE
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True, slots=True)
|
|
268
|
+
class InvariantFailure:
|
|
269
|
+
"""Provider or coordinator contract was violated."""
|
|
270
|
+
|
|
271
|
+
code: FailureCode = field(default=FailureCode.INTERNAL_INVARIANT, init=False)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
type AuthenticationDecision = NotApplicable | Authenticated | Invalid | Unavailable | InvariantFailure
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True, slots=True)
|
|
278
|
+
class AuthenticationRuntime:
|
|
279
|
+
"""Request-scoped execution limits for providers."""
|
|
280
|
+
|
|
281
|
+
deadline: float | None = None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass(frozen=True, slots=True)
|
|
285
|
+
class RouteProviderPolicy:
|
|
286
|
+
"""Ordered-free set of provider names permitted for a route."""
|
|
287
|
+
|
|
288
|
+
providers: tuple[str, ...] = ()
|
|
289
|
+
|
|
290
|
+
def __post_init__(self) -> None:
|
|
291
|
+
"""Validate and deduplicate provider names.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
ValueError: If a provider name is invalid or duplicated.
|
|
295
|
+
"""
|
|
296
|
+
providers = tuple(self.providers)
|
|
297
|
+
for provider in providers:
|
|
298
|
+
_validate_label(provider, name="provider")
|
|
299
|
+
if len(providers) > 1:
|
|
300
|
+
msg = "route provider policy permits at most one authentication profile"
|
|
301
|
+
raise ValueError(msg)
|
|
302
|
+
object.__setattr__(self, "providers", providers)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _freeze_evidence_values(values: tuple[str, ...], *, name: str) -> tuple[str, ...]:
|
|
306
|
+
frozen = tuple(values)
|
|
307
|
+
if len(frozen) > _MAX_EVIDENCE_VALUES:
|
|
308
|
+
msg = f"{name} must contain at most {_MAX_EVIDENCE_VALUES} values"
|
|
309
|
+
raise ValueError(msg)
|
|
310
|
+
for value in frozen:
|
|
311
|
+
_validate_text(value, name=name)
|
|
312
|
+
return frozen
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _validate_evidence_times(evidence: AuthenticationEvidence) -> None:
|
|
316
|
+
for name in ("issued_at", "not_before", "expires_at"):
|
|
317
|
+
_validate_aware(getattr(evidence, name), name=name)
|
|
318
|
+
if (
|
|
319
|
+
evidence.not_before is not None
|
|
320
|
+
and evidence.expires_at is not None
|
|
321
|
+
and evidence.not_before >= evidence.expires_at
|
|
322
|
+
):
|
|
323
|
+
msg = "not_before must be earlier than expires_at"
|
|
324
|
+
raise ValueError(msg)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _validate_evidence_identifiers(evidence: AuthenticationEvidence) -> None:
|
|
328
|
+
for name in ("credential_id", "token_id", "environment"):
|
|
329
|
+
value = getattr(evidence, name)
|
|
330
|
+
if value is not None:
|
|
331
|
+
_validate_text(value, name=name)
|
|
332
|
+
if (
|
|
333
|
+
evidence.confirmation_thumbprint is not None
|
|
334
|
+
and _THUMBPRINT_PATTERN.fullmatch(
|
|
335
|
+
evidence.confirmation_thumbprint,
|
|
336
|
+
)
|
|
337
|
+
is None
|
|
338
|
+
):
|
|
339
|
+
msg = "confirmation_thumbprint must be an unpadded base64url SHA-256 digest"
|
|
340
|
+
raise ValueError(msg)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _freeze_extensions(extensions: Mapping[str, EvidenceValue]) -> Mapping[str, EvidenceValue]:
|
|
344
|
+
frozen = dict(extensions)
|
|
345
|
+
if len(frozen) > _MAX_EXTENSIONS:
|
|
346
|
+
msg = f"extensions must contain at most {_MAX_EXTENSIONS} entries"
|
|
347
|
+
raise ValueError(msg)
|
|
348
|
+
for key, value in frozen.items():
|
|
349
|
+
if _EXTENSION_KEY_PATTERN.fullmatch(key) is None:
|
|
350
|
+
msg = f"extension key {key!r} must be namespaced"
|
|
351
|
+
raise ValueError(msg)
|
|
352
|
+
if isinstance(value, str):
|
|
353
|
+
_validate_text(value, name=f"extension {key}", max_length=256)
|
|
354
|
+
elif not isinstance(value, (bool, int)):
|
|
355
|
+
msg = f"extension {key!r} must contain a string, integer, or boolean"
|
|
356
|
+
raise TypeError(msg)
|
|
357
|
+
return MappingProxyType(frozen)
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "authweave-core"
|
|
3
|
+
version = "7.0.0"
|
|
4
|
+
description = "Framework-neutral principal authentication contracts and coordinator"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Vladislav Shepilov", email = "shepilov.v@protonmail.com"},
|
|
10
|
+
]
|
|
11
|
+
maintainers = [
|
|
12
|
+
{name = "Vladislav Shepilov", email = "shepilov.v@protonmail.com"},
|
|
13
|
+
]
|
|
14
|
+
keywords = ["authentication", "principal", "security", "framework-agnostic"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Topic :: Security",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
]
|
|
25
|
+
requires-python = "<3.15.0,>=3.12.0"
|
|
26
|
+
dependencies = ["anyio>=4.14.2,<5.0"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
homepage = "https://github.com/ZYLVEXT/litestar-auth"
|
|
30
|
+
documentation = "https://zylvext.github.io/litestar-auth/"
|
|
31
|
+
source = "https://github.com/ZYLVEXT/litestar-auth"
|
|
32
|
+
tracker = "https://github.com/ZYLVEXT/litestar-auth/issues"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["authweave_core"]
|
|
40
|
+
artifacts = ["authweave_core/py.typed"]
|
|
41
|
+
|
|
42
|
+
[tool.coverage.run]
|
|
43
|
+
source = ["authweave_core"]
|
|
44
|
+
branch = true
|
|
45
|
+
|
|
46
|
+
[tool.coverage.report]
|
|
47
|
+
fail_under = 100
|
|
48
|
+
show_missing = true
|
|
49
|
+
|
|
50
|
+
[tool.ty.environment]
|
|
51
|
+
python-version = "3.12"
|
|
52
|
+
|
|
53
|
+
[tool.ty.src]
|
|
54
|
+
include = ["authweave_core", "tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""authweave-core tests."""
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Behavior tests for deterministic provider coordination."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
import pytest
|
|
10
|
+
from authweave_core import (
|
|
11
|
+
Authenticated,
|
|
12
|
+
AuthenticationContext,
|
|
13
|
+
AuthenticationCoordinator,
|
|
14
|
+
AuthenticationDecision,
|
|
15
|
+
AuthenticationEvidence,
|
|
16
|
+
AuthenticationRuntime,
|
|
17
|
+
CredentialMatch,
|
|
18
|
+
FailureCode,
|
|
19
|
+
Invalid,
|
|
20
|
+
InvariantFailure,
|
|
21
|
+
NotApplicable,
|
|
22
|
+
PrincipalRef,
|
|
23
|
+
RequestView,
|
|
24
|
+
RouteProviderPolicy,
|
|
25
|
+
Unavailable,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
pytestmark = pytest.mark.unit
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _authenticated(*, provider: str = "provider", profile: str = "profile") -> Authenticated:
|
|
32
|
+
principal = PrincipalRef("issuer", "subject", "service")
|
|
33
|
+
evidence = AuthenticationEvidence(provider, profile, "mtls", "issuer")
|
|
34
|
+
return Authenticated(AuthenticationContext(principal, principal, evidence))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class _Provider:
|
|
39
|
+
name: str = "provider"
|
|
40
|
+
profile: str = "profile"
|
|
41
|
+
match_result: CredentialMatch | object = CredentialMatch.OWNED
|
|
42
|
+
decision: AuthenticationDecision | object = field(default_factory=_authenticated)
|
|
43
|
+
delay: float = 0
|
|
44
|
+
calls: int = 0
|
|
45
|
+
|
|
46
|
+
def match(self, request: RequestView) -> CredentialMatch:
|
|
47
|
+
return cast("CredentialMatch", self.match_result)
|
|
48
|
+
|
|
49
|
+
async def authenticate(
|
|
50
|
+
self,
|
|
51
|
+
request: RequestView,
|
|
52
|
+
runtime: AuthenticationRuntime,
|
|
53
|
+
) -> AuthenticationDecision:
|
|
54
|
+
self.calls += 1
|
|
55
|
+
if self.delay:
|
|
56
|
+
await anyio.sleep(self.delay)
|
|
57
|
+
return cast("AuthenticationDecision", self.decision)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_coordinator_rejects_duplicate_inventory() -> None:
|
|
61
|
+
with pytest.raises(ValueError, match="name"):
|
|
62
|
+
AuthenticationCoordinator((_Provider(), _Provider(profile="other")))
|
|
63
|
+
with pytest.raises(ValueError, match="profile"):
|
|
64
|
+
AuthenticationCoordinator((_Provider(), _Provider(name="other")))
|
|
65
|
+
with pytest.raises(ValueError, match="must match"):
|
|
66
|
+
AuthenticationCoordinator((_Provider(name="Not Valid"),))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def test_coordinator_returns_not_applicable_without_owned_credentials() -> None:
|
|
70
|
+
provider = _Provider(match_result=CredentialMatch.NOT_APPLICABLE)
|
|
71
|
+
coordinator = AuthenticationCoordinator((provider,))
|
|
72
|
+
|
|
73
|
+
result = await coordinator.authenticate(
|
|
74
|
+
RequestView("GET"),
|
|
75
|
+
AuthenticationRuntime(),
|
|
76
|
+
RouteProviderPolicy(("provider",)),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
assert isinstance(result, NotApplicable)
|
|
80
|
+
assert provider.calls == 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_route_policy_rejects_overlapping_provider_profiles() -> None:
|
|
84
|
+
"""A route cannot activate two credential owners."""
|
|
85
|
+
with pytest.raises(ValueError, match="at most one"):
|
|
86
|
+
RouteProviderPolicy(("first", "second"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.parametrize(
|
|
90
|
+
"match_result",
|
|
91
|
+
[CredentialMatch.AMBIGUOUS, object()],
|
|
92
|
+
)
|
|
93
|
+
async def test_coordinator_fails_closed_for_invalid_match_result(match_result: CredentialMatch | object) -> None:
|
|
94
|
+
provider = _Provider(match_result=match_result)
|
|
95
|
+
coordinator = AuthenticationCoordinator((provider,))
|
|
96
|
+
|
|
97
|
+
result = await coordinator.authenticate(
|
|
98
|
+
RequestView("GET"),
|
|
99
|
+
AuthenticationRuntime(),
|
|
100
|
+
RouteProviderPolicy(("provider",)),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert result in {Invalid(FailureCode.AMBIGUOUS_CREDENTIALS), InvariantFailure()}
|
|
104
|
+
assert provider.calls == 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def test_coordinator_returns_config_invariant_for_unknown_provider() -> None:
|
|
108
|
+
result = await AuthenticationCoordinator(()).authenticate(
|
|
109
|
+
RequestView("GET"),
|
|
110
|
+
AuthenticationRuntime(),
|
|
111
|
+
RouteProviderPolicy(("missing",)),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
assert isinstance(result, InvariantFailure)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.parametrize(
|
|
118
|
+
"decision",
|
|
119
|
+
[
|
|
120
|
+
Invalid(FailureCode.INVALID),
|
|
121
|
+
Unavailable(),
|
|
122
|
+
InvariantFailure(),
|
|
123
|
+
],
|
|
124
|
+
)
|
|
125
|
+
async def test_coordinator_returns_terminal_provider_decision(decision: AuthenticationDecision) -> None:
|
|
126
|
+
provider = _Provider(decision=decision)
|
|
127
|
+
coordinator = AuthenticationCoordinator((provider,))
|
|
128
|
+
|
|
129
|
+
result = await coordinator.authenticate(
|
|
130
|
+
RequestView("GET"),
|
|
131
|
+
AuthenticationRuntime(),
|
|
132
|
+
RouteProviderPolicy(("provider",)),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
assert result == decision
|
|
136
|
+
assert provider.calls == 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize(
|
|
140
|
+
"decision",
|
|
141
|
+
[
|
|
142
|
+
NotApplicable(),
|
|
143
|
+
_authenticated(provider="other"),
|
|
144
|
+
_authenticated(profile="other"),
|
|
145
|
+
object(),
|
|
146
|
+
],
|
|
147
|
+
)
|
|
148
|
+
async def test_coordinator_rejects_provider_contract_violations(decision: AuthenticationDecision | object) -> None:
|
|
149
|
+
provider = _Provider(decision=decision)
|
|
150
|
+
coordinator = AuthenticationCoordinator((provider,))
|
|
151
|
+
|
|
152
|
+
result = await coordinator.authenticate(
|
|
153
|
+
RequestView("GET"),
|
|
154
|
+
AuthenticationRuntime(),
|
|
155
|
+
RouteProviderPolicy(("provider",)),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
assert isinstance(result, InvariantFailure)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def test_coordinator_returns_authenticated_context() -> None:
|
|
162
|
+
expected = _authenticated()
|
|
163
|
+
provider = _Provider(decision=expected)
|
|
164
|
+
coordinator = AuthenticationCoordinator((provider,))
|
|
165
|
+
|
|
166
|
+
result = await coordinator.authenticate(
|
|
167
|
+
RequestView("GET"),
|
|
168
|
+
AuthenticationRuntime(),
|
|
169
|
+
RouteProviderPolicy(("provider",)),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
assert result == expected
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def test_coordinator_enforces_expired_and_active_deadlines() -> None:
|
|
176
|
+
provider = _Provider(delay=1)
|
|
177
|
+
coordinator = AuthenticationCoordinator((provider,))
|
|
178
|
+
request = RequestView("GET")
|
|
179
|
+
policy = RouteProviderPolicy(("provider",))
|
|
180
|
+
|
|
181
|
+
expired = await coordinator.authenticate(request, AuthenticationRuntime(deadline=anyio.current_time()), policy)
|
|
182
|
+
timed_out = await coordinator.authenticate(
|
|
183
|
+
request,
|
|
184
|
+
AuthenticationRuntime(deadline=anyio.current_time() + 0.01),
|
|
185
|
+
policy,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
assert isinstance(expired, Unavailable)
|
|
189
|
+
assert isinstance(timed_out, Unavailable)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def test_coordinator_does_not_swallow_external_cancellation() -> None:
|
|
193
|
+
provider = _Provider(delay=1)
|
|
194
|
+
coordinator = AuthenticationCoordinator((provider,))
|
|
195
|
+
|
|
196
|
+
with anyio.move_on_after(0.01) as scope:
|
|
197
|
+
await coordinator.authenticate(
|
|
198
|
+
RequestView("GET"),
|
|
199
|
+
AuthenticationRuntime(),
|
|
200
|
+
RouteProviderPolicy(("provider",)),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
assert scope.cancel_called
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Import-isolation tests for authweave-core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
pytestmark = pytest.mark.imports
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_base_import_does_not_load_framework_storage_or_crypto_packages() -> None:
|
|
15
|
+
script = """
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import authweave_core
|
|
19
|
+
|
|
20
|
+
blocked = ("litestar", "sqlalchemy", "redis", "jwt", "cryptography")
|
|
21
|
+
print(json.dumps(sorted(name for name in sys.modules if name.split(".", 1)[0] in blocked)))
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
completed = subprocess.run(
|
|
25
|
+
[sys.executable, "-c", script],
|
|
26
|
+
check=True,
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
assert json.loads(completed.stdout) == []
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Behavior tests for public authweave-core contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from authweave_core import (
|
|
10
|
+
AuthenticationContext,
|
|
11
|
+
AuthenticationEvidence,
|
|
12
|
+
FailureCode,
|
|
13
|
+
InvariantFailure,
|
|
14
|
+
PrincipalRef,
|
|
15
|
+
RequestView,
|
|
16
|
+
RouteProviderPolicy,
|
|
17
|
+
TlsPeerEvidence,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
pytestmark = pytest.mark.unit
|
|
21
|
+
|
|
22
|
+
_NOW = datetime(2026, 7, 30, tzinfo=UTC)
|
|
23
|
+
_THUMBPRINT = "A" * 43
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_principal_identity_is_issuer_and_subject() -> None:
|
|
27
|
+
human = PrincipalRef("https://issuer.example", "subject-1", "human")
|
|
28
|
+
service = PrincipalRef("https://issuer.example", "subject-1", "service")
|
|
29
|
+
|
|
30
|
+
assert human == service
|
|
31
|
+
assert len({human, service}) == 1
|
|
32
|
+
assert human != object()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.parametrize(
|
|
36
|
+
("field", "value"),
|
|
37
|
+
[
|
|
38
|
+
("issuer", ""),
|
|
39
|
+
("subject", " subject"),
|
|
40
|
+
("kind", "Not Valid"),
|
|
41
|
+
],
|
|
42
|
+
)
|
|
43
|
+
def test_principal_rejects_invalid_identity_components(field: str, value: str) -> None:
|
|
44
|
+
values = {"issuer": "issuer", "subject": "subject", "kind": "service", field: value}
|
|
45
|
+
|
|
46
|
+
with pytest.raises(ValueError, match="must"):
|
|
47
|
+
PrincipalRef(**cast("dict[str, Any]", values))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_request_preserves_duplicate_headers_case_insensitively() -> None:
|
|
51
|
+
request = RequestView(
|
|
52
|
+
method="GET",
|
|
53
|
+
headers=((b"Authorization", b"one"), (b"authorization", b"two")),
|
|
54
|
+
timestamp=_NOW,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
assert request.header_values(b"AUTHORIZATION") == (b"one", b"two")
|
|
58
|
+
assert request.header_values(b"missing") == ()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_request_hides_credentials_and_bounds_headers() -> None:
|
|
62
|
+
token = b"secret-session-token"
|
|
63
|
+
request = RequestView(method="GET", headers=((b"cookie", token),), timestamp=_NOW)
|
|
64
|
+
|
|
65
|
+
assert token.decode() not in repr(request)
|
|
66
|
+
with pytest.raises(ValueError, match="projection limits"):
|
|
67
|
+
RequestView(method="GET", headers=tuple((b"x", b"1") for _ in range(129)), timestamp=_NOW)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.parametrize(
|
|
71
|
+
("method", "headers"),
|
|
72
|
+
[
|
|
73
|
+
("G ET", ()),
|
|
74
|
+
("GÉT", ()),
|
|
75
|
+
("GET", ((b"", b"value"),)),
|
|
76
|
+
("GET", ((b"x-test", b"bad\rvalue"),)),
|
|
77
|
+
("GET", ((b"x-test", b"bad\nvalue"),)),
|
|
78
|
+
("GET", ((b"x-test", b"bad\x00value"),)),
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
def test_request_rejects_malformed_http_metadata(
|
|
82
|
+
method: str,
|
|
83
|
+
headers: tuple[tuple[bytes, bytes], ...],
|
|
84
|
+
) -> None:
|
|
85
|
+
with pytest.raises(ValueError, match="must"):
|
|
86
|
+
RequestView(method=method, headers=headers, timestamp=_NOW)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.parametrize(
|
|
90
|
+
("field", "value"),
|
|
91
|
+
[
|
|
92
|
+
("scheme", ""),
|
|
93
|
+
("authority", " host"),
|
|
94
|
+
("correlation_id", ""),
|
|
95
|
+
],
|
|
96
|
+
)
|
|
97
|
+
def test_request_rejects_invalid_optional_text(field: str, value: str) -> None:
|
|
98
|
+
values = {"method": "GET", "timestamp": _NOW, field: value}
|
|
99
|
+
|
|
100
|
+
with pytest.raises(ValueError, match="must"):
|
|
101
|
+
RequestView(**cast("dict[str, Any]", values))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_request_requires_aware_timestamp() -> None:
|
|
105
|
+
with pytest.raises(ValueError, match="timezone-aware"):
|
|
106
|
+
RequestView(method="GET", timestamp=datetime(2026, 7, 30))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_tls_peer_evidence_validates_thumbprint_and_times() -> None:
|
|
110
|
+
evidence = TlsPeerEvidence(
|
|
111
|
+
tls_version="TLSv1.3",
|
|
112
|
+
certificate_thumbprint=_THUMBPRINT,
|
|
113
|
+
certificate_not_before=_NOW,
|
|
114
|
+
certificate_not_after=_NOW + timedelta(hours=1),
|
|
115
|
+
revocation_checked_at=_NOW,
|
|
116
|
+
trust_anchor="ca:payments",
|
|
117
|
+
termination_boundary="envoy:ingress",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
assert evidence.certificate_thumbprint == _THUMBPRINT
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.parametrize(
|
|
124
|
+
("field", "value"),
|
|
125
|
+
[
|
|
126
|
+
("tls_version", ""),
|
|
127
|
+
("certificate_thumbprint", "not-a-thumbprint"),
|
|
128
|
+
("trust_anchor", ""),
|
|
129
|
+
("termination_boundary", ""),
|
|
130
|
+
("certificate_not_before", datetime(2026, 7, 30)),
|
|
131
|
+
("certificate_not_after", datetime(2026, 7, 30)),
|
|
132
|
+
("revocation_checked_at", datetime(2026, 7, 30)),
|
|
133
|
+
],
|
|
134
|
+
)
|
|
135
|
+
def test_tls_peer_evidence_rejects_invalid_fields(field: str, value: object) -> None:
|
|
136
|
+
values = {
|
|
137
|
+
"tls_version": "TLSv1.3",
|
|
138
|
+
"certificate_thumbprint": _THUMBPRINT,
|
|
139
|
+
"certificate_not_before": _NOW,
|
|
140
|
+
"certificate_not_after": _NOW + timedelta(hours=1),
|
|
141
|
+
"revocation_checked_at": _NOW,
|
|
142
|
+
"trust_anchor": "ca:payments",
|
|
143
|
+
"termination_boundary": "envoy:ingress",
|
|
144
|
+
field: value,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
with pytest.raises(ValueError, match="must"):
|
|
148
|
+
TlsPeerEvidence(**values)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_tls_peer_evidence_rejects_reversed_validity() -> None:
|
|
152
|
+
with pytest.raises(ValueError, match="earlier"):
|
|
153
|
+
TlsPeerEvidence(
|
|
154
|
+
tls_version="TLSv1.3",
|
|
155
|
+
certificate_thumbprint=_THUMBPRINT,
|
|
156
|
+
certificate_not_before=_NOW,
|
|
157
|
+
certificate_not_after=_NOW,
|
|
158
|
+
revocation_checked_at=_NOW,
|
|
159
|
+
trust_anchor="ca:payments",
|
|
160
|
+
termination_boundary="envoy:ingress",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_authentication_evidence_is_bounded_and_immutable() -> None:
|
|
165
|
+
extensions: dict[str, str | int | bool] = {
|
|
166
|
+
"example:risk": "low",
|
|
167
|
+
"example:score": 7,
|
|
168
|
+
"example:reviewed": True,
|
|
169
|
+
}
|
|
170
|
+
evidence = AuthenticationEvidence(
|
|
171
|
+
provider="service_mtls",
|
|
172
|
+
profile="direct_mtls",
|
|
173
|
+
method="mtls",
|
|
174
|
+
issuer="urn:example:payments",
|
|
175
|
+
audiences=("payments",),
|
|
176
|
+
scopes=("payments.read",),
|
|
177
|
+
issued_at=_NOW,
|
|
178
|
+
not_before=_NOW,
|
|
179
|
+
expires_at=_NOW + timedelta(hours=1),
|
|
180
|
+
credential_id="credential-1",
|
|
181
|
+
token_id="token-1",
|
|
182
|
+
confirmation_thumbprint=_THUMBPRINT,
|
|
183
|
+
environment="sandbox",
|
|
184
|
+
extensions=extensions,
|
|
185
|
+
)
|
|
186
|
+
extensions["example:risk"] = "high"
|
|
187
|
+
|
|
188
|
+
assert evidence.extensions == {
|
|
189
|
+
"example:risk": "low",
|
|
190
|
+
"example:score": 7,
|
|
191
|
+
"example:reviewed": True,
|
|
192
|
+
}
|
|
193
|
+
with pytest.raises(TypeError):
|
|
194
|
+
cast("dict[str, str | int | bool]", evidence.extensions)["example:risk"] = "high"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.parametrize(
|
|
198
|
+
("field", "value", "exception"),
|
|
199
|
+
[
|
|
200
|
+
("provider", "Not Valid", ValueError),
|
|
201
|
+
("audiences", ("",), ValueError),
|
|
202
|
+
("scopes", tuple(str(index) for index in range(65)), ValueError),
|
|
203
|
+
("issued_at", datetime(2026, 7, 30), ValueError),
|
|
204
|
+
("confirmation_thumbprint", "bad", ValueError),
|
|
205
|
+
("extensions", {"not-namespaced": "value"}, ValueError),
|
|
206
|
+
("extensions", {"example:value": object()}, TypeError),
|
|
207
|
+
("extensions", {f"example:{index}": index for index in range(17)}, ValueError),
|
|
208
|
+
],
|
|
209
|
+
)
|
|
210
|
+
def test_authentication_evidence_rejects_invalid_data(
|
|
211
|
+
field: str,
|
|
212
|
+
value: object,
|
|
213
|
+
exception: type[Exception],
|
|
214
|
+
) -> None:
|
|
215
|
+
values = {
|
|
216
|
+
"provider": "service_mtls",
|
|
217
|
+
"profile": "direct_mtls",
|
|
218
|
+
"method": "mtls",
|
|
219
|
+
"issuer": "urn:example:payments",
|
|
220
|
+
field: value,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
with pytest.raises(exception):
|
|
224
|
+
AuthenticationEvidence(**cast("dict[str, Any]", values))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_authentication_evidence_rejects_reversed_time_window() -> None:
|
|
228
|
+
with pytest.raises(ValueError, match="earlier"):
|
|
229
|
+
AuthenticationEvidence(
|
|
230
|
+
provider="service_mtls",
|
|
231
|
+
profile="direct_mtls",
|
|
232
|
+
method="mtls",
|
|
233
|
+
issuer="urn:example:payments",
|
|
234
|
+
not_before=_NOW,
|
|
235
|
+
expires_at=_NOW,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_context_and_route_policy_freeze_inputs() -> None:
|
|
240
|
+
principal = PrincipalRef("issuer", "subject", "service")
|
|
241
|
+
evidence = AuthenticationEvidence("provider", "profile", "mtls", "issuer")
|
|
242
|
+
chain = [PrincipalRef("issuer", "actor", "agent")]
|
|
243
|
+
context = AuthenticationContext(principal, chain[0], evidence, cast("tuple[PrincipalRef, ...]", chain))
|
|
244
|
+
providers = ["provider"]
|
|
245
|
+
policy = RouteProviderPolicy(cast("tuple[str, ...]", providers))
|
|
246
|
+
chain.clear()
|
|
247
|
+
providers.clear()
|
|
248
|
+
|
|
249
|
+
assert len(context.delegation_chain) == 1
|
|
250
|
+
assert policy.providers == ("provider",)
|
|
251
|
+
assert InvariantFailure().code is FailureCode.INTERNAL_INVARIANT
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_route_policy_rejects_invalid_or_duplicate_names() -> None:
|
|
255
|
+
with pytest.raises(ValueError, match="must match"):
|
|
256
|
+
RouteProviderPolicy(("Not Valid",))
|
|
257
|
+
with pytest.raises(ValueError, match="at most one"):
|
|
258
|
+
RouteProviderPolicy(("provider", "provider"))
|
|
259
|
+
|
|
260
|
+
with pytest.raises(ValueError, match="at most one"):
|
|
261
|
+
RouteProviderPolicy(("provider", "other"))
|