authweave-core 7.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- authweave_core/__init__.py +45 -0
- authweave_core/coordinator.py +148 -0
- authweave_core/models.py +357 -0
- authweave_core/py.typed +0 -0
- authweave_core-7.0.0.dist-info/METADATA +39 -0
- authweave_core-7.0.0.dist-info/RECORD +8 -0
- authweave_core-7.0.0.dist-info/WHEEL +4 -0
- authweave_core-7.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
authweave_core/models.py
ADDED
|
@@ -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)
|
authweave_core/py.typed
ADDED
|
File without changes
|
|
@@ -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,8 @@
|
|
|
1
|
+
authweave_core/__init__.py,sha256=CGSV9DuCiN2G1gyLxZuiCDwrXSsg1muHGqL8zAFs2Fo,1030
|
|
2
|
+
authweave_core/coordinator.py,sha256=4FW1R0WzRV4q6ITI9iauin1NR-u7hnYBBmZCNQ4SJLw,5106
|
|
3
|
+
authweave_core/models.py,sha256=B_FUO_2kLHO_JbWVsL66A4_gx1Le0Nt3g71QY2jQ9jw,12895
|
|
4
|
+
authweave_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
authweave_core-7.0.0.dist-info/METADATA,sha256=QU9ZzkstJQJeFYh8c8v6C9g5dDLlmnWfkwbaxwiTcng,1682
|
|
6
|
+
authweave_core-7.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
authweave_core-7.0.0.dist-info/licenses/LICENSE,sha256=vICktpxlg9VJdRVRknH1W42Zl_8WEEfYWUQ75ql3eyI,1075
|
|
8
|
+
authweave_core-7.0.0.dist-info/RECORD,,
|
|
@@ -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.
|