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.
@@ -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"))