yanez-agent-authorization 0.1.0a1__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.
- yanez_agent_authorization-0.1.0a1/.gitignore +10 -0
- yanez_agent_authorization-0.1.0a1/PKG-INFO +45 -0
- yanez_agent_authorization-0.1.0a1/README.md +26 -0
- yanez_agent_authorization-0.1.0a1/pyproject.toml +32 -0
- yanez_agent_authorization-0.1.0a1/src/yanez_authz/__init__.py +41 -0
- yanez_agent_authorization-0.1.0a1/src/yanez_authz/async_client.py +182 -0
- yanez_agent_authorization-0.1.0a1/src/yanez_authz/errors.py +79 -0
- yanez_agent_authorization-0.1.0a1/src/yanez_authz/models.py +55 -0
- yanez_agent_authorization-0.1.0a1/src/yanez_authz/verifier.py +237 -0
- yanez_agent_authorization-0.1.0a1/tests/conftest.py +23 -0
- yanez_agent_authorization-0.1.0a1/tests/test_client.py +200 -0
- yanez_agent_authorization-0.1.0a1/tests/test_verifier.py +230 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: yanez-agent-authorization
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Yanez agent authorization: request human approval, verify signed receipts.
|
|
5
|
+
Project-URL: Homepage, https://github.com/yanez-compliance/yanez-agent-authorization
|
|
6
|
+
Project-URL: Repository, https://github.com/yanez-compliance/yanez-agent-authorization
|
|
7
|
+
Project-URL: Issues, https://github.com/yanez-compliance/yanez-agent-authorization/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Security
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: cryptography>=42
|
|
14
|
+
Requires-Dist: httpx<1,>=0.27
|
|
15
|
+
Requires-Dist: pyjwt<3,>=2.8
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# yanez-agent-authorization (Python)
|
|
21
|
+
|
|
22
|
+
Async client for requesting verifiable human approval through Yanez, and a verifier
|
|
23
|
+
for the signed receipts. Import package: `yanez_authz`.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install yanez-agent-authorization # pre-release: not on PyPI yet; pip install -e packages/python
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from yanez_authz import AuthorizationClient, ReceiptVerifier
|
|
31
|
+
|
|
32
|
+
async with AuthorizationClient(base_url, agent_api_key) as client:
|
|
33
|
+
pending = await client.request_authorization(terms={...})
|
|
34
|
+
result = await client.wait_for_authorization(pending.request_id, 900)
|
|
35
|
+
|
|
36
|
+
receipt = ReceiptVerifier(base_url, expected_issuer).authorize_action(
|
|
37
|
+
result.artifact, expected_terms, max_age_seconds=900, consume=True,
|
|
38
|
+
expected_sub=account_yid) # the YID your records tie to the account
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`wait_for_authorization` raises the builtin `TimeoutError` when the local deadline
|
|
42
|
+
passes; rejection and expiry are returned as values. `expected_issuer` is the issuer
|
|
43
|
+
string your Yanez operator publishes for the deployment.
|
|
44
|
+
|
|
45
|
+
Docs and the full integration model: https://github.com/yanez-compliance/yanez-agent-authorization
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# yanez-agent-authorization (Python)
|
|
2
|
+
|
|
3
|
+
Async client for requesting verifiable human approval through Yanez, and a verifier
|
|
4
|
+
for the signed receipts. Import package: `yanez_authz`.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install yanez-agent-authorization # pre-release: not on PyPI yet; pip install -e packages/python
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from yanez_authz import AuthorizationClient, ReceiptVerifier
|
|
12
|
+
|
|
13
|
+
async with AuthorizationClient(base_url, agent_api_key) as client:
|
|
14
|
+
pending = await client.request_authorization(terms={...})
|
|
15
|
+
result = await client.wait_for_authorization(pending.request_id, 900)
|
|
16
|
+
|
|
17
|
+
receipt = ReceiptVerifier(base_url, expected_issuer).authorize_action(
|
|
18
|
+
result.artifact, expected_terms, max_age_seconds=900, consume=True,
|
|
19
|
+
expected_sub=account_yid) # the YID your records tie to the account
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`wait_for_authorization` raises the builtin `TimeoutError` when the local deadline
|
|
23
|
+
passes; rejection and expiry are returned as values. `expected_issuer` is the issuer
|
|
24
|
+
string your Yanez operator publishes for the deployment.
|
|
25
|
+
|
|
26
|
+
Docs and the full integration model: https://github.com/yanez-compliance/yanez-agent-authorization
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "yanez-agent-authorization"
|
|
7
|
+
version = "0.1.0a1"
|
|
8
|
+
description = "Yanez agent authorization: request human approval, verify signed receipts."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
classifiers = [
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Topic :: Security",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"httpx>=0.27,<1",
|
|
19
|
+
"PyJWT>=2.8,<3",
|
|
20
|
+
"cryptography>=42",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/yanez-compliance/yanez-agent-authorization"
|
|
25
|
+
Repository = "https://github.com/yanez-compliance/yanez-agent-authorization"
|
|
26
|
+
Issues = "https://github.com/yanez-compliance/yanez-agent-authorization/issues"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
test = ["pytest>=8"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/yanez_authz"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Yanez agent authorization SDK.
|
|
2
|
+
|
|
3
|
+
Two halves, deliberately separate:
|
|
4
|
+
|
|
5
|
+
- `AuthorizationClient` — the agent side: ask the key's owner to approve terms, poll
|
|
6
|
+
for the decision. Needs the yak_ agent key.
|
|
7
|
+
- `ReceiptVerifier` — the relying-party side: verify and (for single-use actions)
|
|
8
|
+
consume a signed receipt. Needs no credentials at all.
|
|
9
|
+
"""
|
|
10
|
+
from yanez_authz.async_client import AuthorizationClient
|
|
11
|
+
from yanez_authz.errors import (
|
|
12
|
+
AlreadyConsumedError,
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
ConflictError,
|
|
15
|
+
ConsentPolicyError,
|
|
16
|
+
FeatureUnavailableError,
|
|
17
|
+
InvalidRequestError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
ReceiptVerificationError,
|
|
21
|
+
TermsTooLargeError,
|
|
22
|
+
TransportError,
|
|
23
|
+
YanezAuthzError,
|
|
24
|
+
)
|
|
25
|
+
from yanez_authz.models import (
|
|
26
|
+
AuthorizationResult,
|
|
27
|
+
IntrospectionResult,
|
|
28
|
+
PendingAuthorization,
|
|
29
|
+
VerifiedReceipt,
|
|
30
|
+
)
|
|
31
|
+
from yanez_authz.verifier import ReceiptVerifier
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AuthorizationClient", "ReceiptVerifier",
|
|
37
|
+
"PendingAuthorization", "AuthorizationResult", "IntrospectionResult", "VerifiedReceipt",
|
|
38
|
+
"YanezAuthzError", "AuthenticationError", "InvalidRequestError", "TermsTooLargeError",
|
|
39
|
+
"RateLimitError", "ConflictError", "NotFoundError", "FeatureUnavailableError",
|
|
40
|
+
"TransportError", "ReceiptVerificationError", "ConsentPolicyError", "AlreadyConsumedError",
|
|
41
|
+
]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from urllib.parse import quote, urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from yanez_authz.errors import InvalidRequestError, TransportError, error_for_status
|
|
13
|
+
from yanez_authz.models import TERMINAL, AuthorizationResult, PendingAuthorization
|
|
14
|
+
|
|
15
|
+
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
|
|
16
|
+
# Server ids look like azr_<hex>; anything outside this set could only be an attempt
|
|
17
|
+
# to steer the keyed request at another path.
|
|
18
|
+
_REQUEST_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def require_trusted_origin(base_url: str) -> str:
|
|
22
|
+
"""HTTPS everywhere except loopback development hosts. The base URL is process
|
|
23
|
+
configuration, never a per-call (model-supplied) value."""
|
|
24
|
+
parts = urlsplit(base_url)
|
|
25
|
+
if parts.scheme == "https":
|
|
26
|
+
return base_url.rstrip("/")
|
|
27
|
+
if parts.scheme == "http" and parts.hostname in _LOOPBACK:
|
|
28
|
+
return base_url.rstrip("/")
|
|
29
|
+
raise ValueError("base_url must be https:// (plain http is allowed only for loopback)")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _raise_for(response: httpx.Response, *, create: bool = False) -> None:
|
|
33
|
+
if response.is_redirect:
|
|
34
|
+
# Never carry the Authorization header across a redirect; a credentialed
|
|
35
|
+
# request that gets redirected is treated as a transport failure.
|
|
36
|
+
raise TransportError("unexpected redirect")
|
|
37
|
+
if response.is_success:
|
|
38
|
+
return
|
|
39
|
+
try:
|
|
40
|
+
detail = response.json().get("detail")
|
|
41
|
+
except Exception: # noqa: BLE001 — a non-JSON error body has no detail to extract
|
|
42
|
+
detail = None
|
|
43
|
+
raise error_for_status(response.status_code, detail, create=create)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthorizationClient:
|
|
47
|
+
"""Agent-side client: create an authorization request and poll for its decision.
|
|
48
|
+
|
|
49
|
+
Async and context-managed:
|
|
50
|
+
|
|
51
|
+
async with AuthorizationClient(base_url, agent_api_key) as client:
|
|
52
|
+
pending = await client.request_authorization(terms={...})
|
|
53
|
+
result = await client.wait_for_authorization(pending.request_id, 900)
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, base_url: str, agent_api_key: str, *,
|
|
57
|
+
timeout_seconds: float = 10.0,
|
|
58
|
+
transport: Optional[httpx.AsyncBaseTransport] = None) -> None:
|
|
59
|
+
self._base_url = require_trusted_origin(base_url)
|
|
60
|
+
self._timeout = timeout_seconds
|
|
61
|
+
self._client = httpx.AsyncClient(
|
|
62
|
+
base_url=self._base_url,
|
|
63
|
+
headers={"Authorization": f"Bearer {agent_api_key}"},
|
|
64
|
+
follow_redirects=False,
|
|
65
|
+
timeout=timeout_seconds,
|
|
66
|
+
transport=transport,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def __aenter__(self) -> "AuthorizationClient":
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
async def __aexit__(self, *exc) -> None:
|
|
73
|
+
await self.aclose()
|
|
74
|
+
|
|
75
|
+
async def aclose(self) -> None:
|
|
76
|
+
await self._client.aclose()
|
|
77
|
+
|
|
78
|
+
async def request_authorization(
|
|
79
|
+
self,
|
|
80
|
+
terms: dict[str, Any],
|
|
81
|
+
*,
|
|
82
|
+
decision_window_seconds: int = 900,
|
|
83
|
+
intent_expires_at: Optional[str] = None,
|
|
84
|
+
idempotency_key: Optional[str] = None,
|
|
85
|
+
) -> PendingAuthorization:
|
|
86
|
+
"""Create a request. One idempotency key is generated per call and reused for
|
|
87
|
+
the internal retry, so an ambiguous network failure can never prompt the user
|
|
88
|
+
twice — the retry replays instead.
|
|
89
|
+
|
|
90
|
+
Supplying `idempotency_key` yourself is for resuming one specific earlier
|
|
91
|
+
create. Derive it from randomness, never from the terms: a content-derived key
|
|
92
|
+
makes a second genuine purchase of the same item replay the first one instead
|
|
93
|
+
of asking the user, and the reservation is permanent.
|
|
94
|
+
"""
|
|
95
|
+
if idempotency_key is None:
|
|
96
|
+
idempotency_key = secrets.token_urlsafe(16)
|
|
97
|
+
body: dict[str, Any] = {"terms": terms,
|
|
98
|
+
"decision_window_seconds": decision_window_seconds}
|
|
99
|
+
if intent_expires_at is not None:
|
|
100
|
+
body["intent_expires_at"] = intent_expires_at
|
|
101
|
+
|
|
102
|
+
response = await self._post_with_one_retry(
|
|
103
|
+
"/api/agent/authorizations", body, {"Idempotency-Key": idempotency_key})
|
|
104
|
+
_raise_for(response, create=True)
|
|
105
|
+
data = response.json()
|
|
106
|
+
return PendingAuthorization(
|
|
107
|
+
request_id=data["request_id"],
|
|
108
|
+
status=data["status"],
|
|
109
|
+
decide_by=data["decide_by"],
|
|
110
|
+
idempotency_key=idempotency_key,
|
|
111
|
+
replayed=response.headers.get("Idempotency-Replayed") == "true",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def _post_with_one_retry(self, path: str, body: dict,
|
|
115
|
+
headers: dict[str, str]) -> httpx.Response:
|
|
116
|
+
# Safe to retry ONLY because the idempotency key rides along unchanged.
|
|
117
|
+
try:
|
|
118
|
+
return await self._client.post(path, json=body, headers=headers)
|
|
119
|
+
except httpx.TransportError:
|
|
120
|
+
try:
|
|
121
|
+
return await self._client.post(path, json=body, headers=headers)
|
|
122
|
+
except httpx.TransportError as e:
|
|
123
|
+
raise TransportError(type(e).__name__) from None
|
|
124
|
+
|
|
125
|
+
async def get_authorization(self, request_id: str,
|
|
126
|
+
wait_seconds: int = 0) -> AuthorizationResult:
|
|
127
|
+
"""One poll, long-polling server-side for up to `wait_seconds` (0-25)."""
|
|
128
|
+
if not _REQUEST_ID.fullmatch(request_id):
|
|
129
|
+
# The id may come from a model (MCP tool argument): validate before it
|
|
130
|
+
# touches the URL, and encode so it can never rewrite the path.
|
|
131
|
+
raise InvalidRequestError("malformed request id")
|
|
132
|
+
params = {"wait": wait_seconds} if wait_seconds else None
|
|
133
|
+
try:
|
|
134
|
+
response = await self._client.get(
|
|
135
|
+
f"/api/agent/authorizations/{quote(request_id, safe='')}", params=params,
|
|
136
|
+
# The server holds the connection for the whole long-poll.
|
|
137
|
+
timeout=self._timeout + wait_seconds,
|
|
138
|
+
)
|
|
139
|
+
except httpx.TransportError as e:
|
|
140
|
+
raise TransportError(type(e).__name__) from None
|
|
141
|
+
_raise_for(response)
|
|
142
|
+
data = response.json()
|
|
143
|
+
return AuthorizationResult(
|
|
144
|
+
request_id=data["request_id"], status=data["status"],
|
|
145
|
+
artifact=data.get("artifact"),
|
|
146
|
+
consent_not_after=data.get("consent_not_after"),
|
|
147
|
+
decided_at=data.get("decided_at"),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def wait_for_authorization(
|
|
151
|
+
self,
|
|
152
|
+
request_id: str,
|
|
153
|
+
overall_timeout_seconds: float,
|
|
154
|
+
*,
|
|
155
|
+
long_poll_seconds: int = 25,
|
|
156
|
+
) -> AuthorizationResult:
|
|
157
|
+
"""Repeat bounded long-polls until a terminal state.
|
|
158
|
+
|
|
159
|
+
Rejection and expiry come back as values — they are answers, not failures.
|
|
160
|
+
Running out of local time raises TimeoutError, and cancellation propagates;
|
|
161
|
+
neither touches the server-side request, which the user can still decide.
|
|
162
|
+
"""
|
|
163
|
+
deadline = time.monotonic() + overall_timeout_seconds
|
|
164
|
+
while True:
|
|
165
|
+
remaining = deadline - time.monotonic()
|
|
166
|
+
if remaining <= 0:
|
|
167
|
+
raise TimeoutError(f"no decision within {overall_timeout_seconds}s")
|
|
168
|
+
wait = max(0, min(long_poll_seconds, int(remaining)))
|
|
169
|
+
started = time.monotonic()
|
|
170
|
+
try:
|
|
171
|
+
result = await self.get_authorization(request_id, wait_seconds=wait)
|
|
172
|
+
except TransportError:
|
|
173
|
+
# A dropped long-poll is routine; back off briefly and ask again.
|
|
174
|
+
await asyncio.sleep(min(1.0, max(0.0, deadline - time.monotonic())))
|
|
175
|
+
continue
|
|
176
|
+
if result.status in TERMINAL:
|
|
177
|
+
return result
|
|
178
|
+
# A non-terminal reply that came back in under a second (wait 0 in the
|
|
179
|
+
# final second, or a proxy answering early) must not become a tight loop.
|
|
180
|
+
elapsed = time.monotonic() - started
|
|
181
|
+
if elapsed < 1.0:
|
|
182
|
+
await asyncio.sleep(min(1.0 - elapsed, max(0.0, deadline - time.monotonic())))
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Typed errors, one per failure class of the agent-authorization contract.
|
|
2
|
+
|
|
3
|
+
Messages never contain the agent key, the Authorization header, or a raw artifact —
|
|
4
|
+
only HTTP status and the server's sanitized detail string.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class YanezAuthzError(Exception):
|
|
12
|
+
"""Base for every error this SDK raises deliberately."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthenticationError(YanezAuthzError):
|
|
16
|
+
"""Missing, malformed, or revoked agent key. Do not retry; reissue the key."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvalidRequestError(YanezAuthzError):
|
|
20
|
+
"""Invalid terms, time, or schema. Fix the request."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TermsTooLargeError(YanezAuthzError):
|
|
24
|
+
"""Terms exceed the server's 4 KB cap. Reduce without losing material facts."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RateLimitError(YanezAuthzError):
|
|
28
|
+
"""Request or pending limit reached. Wait; never create a replacement loop."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConflictError(YanezAuthzError):
|
|
32
|
+
"""Idempotency mismatch or terminal transition. Inspect the original request."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NotFoundError(YanezAuthzError):
|
|
36
|
+
"""Unknown or cross-key request id. The server discloses nothing more."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FeatureUnavailableError(YanezAuthzError):
|
|
40
|
+
"""Authorization routes are disabled or not deployed. Operator action needed."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TransportError(YanezAuthzError):
|
|
44
|
+
"""Timeout, TLS, DNS, connection failure — or an unexpected redirect, which is
|
|
45
|
+
never followed with credentials."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ReceiptVerificationError(YanezAuthzError):
|
|
49
|
+
"""Bad signature, issuer, claims, or exact terms. Never execute the action."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ConsentPolicyError(YanezAuthzError):
|
|
53
|
+
"""The receipt is genuine but stale or past the user's bound. Request new approval."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AlreadyConsumedError(YanezAuthzError):
|
|
57
|
+
"""A genuine single-use receipt was previously spent. Never execute again."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def error_for_status(status: int, detail: Optional[str], *, create: bool = False) -> YanezAuthzError:
|
|
61
|
+
"""Map a non-2xx agent-API response to a typed error.
|
|
62
|
+
|
|
63
|
+
A 404 means two different things by route: on create, the whole router is absent
|
|
64
|
+
(feature disabled); on get, the request id is unknown or belongs to another key.
|
|
65
|
+
"""
|
|
66
|
+
message = detail or f"HTTP {status}"
|
|
67
|
+
if status == 401:
|
|
68
|
+
return AuthenticationError(message)
|
|
69
|
+
if status == 404:
|
|
70
|
+
return FeatureUnavailableError(message) if create else NotFoundError(message)
|
|
71
|
+
if status == 409:
|
|
72
|
+
return ConflictError(message)
|
|
73
|
+
if status == 413:
|
|
74
|
+
return TermsTooLargeError(message)
|
|
75
|
+
if status == 429:
|
|
76
|
+
return RateLimitError(message)
|
|
77
|
+
if status in (400, 422):
|
|
78
|
+
return InvalidRequestError(message)
|
|
79
|
+
return TransportError(f"unexpected HTTP {status}")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
# Wire statuses, verbatim. Exactly one per response; only `approved` carries an artifact.
|
|
7
|
+
PENDING = "pending"
|
|
8
|
+
APPROVED = "approved"
|
|
9
|
+
REJECTED = "rejected"
|
|
10
|
+
EXPIRED = "expired"
|
|
11
|
+
TERMINAL = frozenset({APPROVED, REJECTED, EXPIRED})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PendingAuthorization:
|
|
16
|
+
request_id: str
|
|
17
|
+
status: str
|
|
18
|
+
decide_by: str
|
|
19
|
+
idempotency_key: str
|
|
20
|
+
replayed: bool # True when the server answered from an earlier create
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AuthorizationResult:
|
|
25
|
+
request_id: str
|
|
26
|
+
status: str
|
|
27
|
+
artifact: Optional[str] = None
|
|
28
|
+
# ISO timestamps as the server sent them; parse only if you need arithmetic.
|
|
29
|
+
consent_not_after: Optional[str] = None
|
|
30
|
+
decided_at: Optional[str] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class IntrospectionResult:
|
|
35
|
+
valid: bool
|
|
36
|
+
reason: Optional[str] = None
|
|
37
|
+
consumed_now: Optional[bool] = None
|
|
38
|
+
sub: Optional[str] = None
|
|
39
|
+
jti: Optional[str] = None
|
|
40
|
+
decided_at: Optional[int] = None
|
|
41
|
+
consent_not_after: Optional[int] = None
|
|
42
|
+
terms: Optional[dict[str, Any]] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class VerifiedReceipt:
|
|
47
|
+
"""A receipt that passed signature, issuer, claim-profile, exact-terms, freshness,
|
|
48
|
+
and consent checks. Holding one means "permission to act now", not just validity."""
|
|
49
|
+
sub: str
|
|
50
|
+
jti: str
|
|
51
|
+
agent_key_id: str
|
|
52
|
+
decided_at: int
|
|
53
|
+
match_overlap: int
|
|
54
|
+
terms: dict[str, Any]
|
|
55
|
+
consent_not_after: Optional[int] = None
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import math
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import jwt
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
11
|
+
|
|
12
|
+
from yanez_authz.async_client import _raise_for, require_trusted_origin
|
|
13
|
+
from yanez_authz.errors import (
|
|
14
|
+
AlreadyConsumedError,
|
|
15
|
+
ConsentPolicyError,
|
|
16
|
+
ReceiptVerificationError,
|
|
17
|
+
TransportError,
|
|
18
|
+
)
|
|
19
|
+
from yanez_authz.models import IntrospectionResult, VerifiedReceipt
|
|
20
|
+
|
|
21
|
+
_KEY_CACHE_TTL_SECONDS = 600
|
|
22
|
+
# An unknown kid may force one early refresh (key rotation), but a stream of garbage
|
|
23
|
+
# kids must not become a stream of key-set fetches.
|
|
24
|
+
_KEY_REFRESH_COOLDOWN_SECONDS = 30
|
|
25
|
+
_CLOCK_SKEW_SECONDS = 60
|
|
26
|
+
|
|
27
|
+
_REQUIRED_CLAIMS = ("sub", "jti", "iat", "yanez_agent_key_id", "yanez_decision",
|
|
28
|
+
"yanez_decided_at", "yanez_match_overlap", "yanez_terms")
|
|
29
|
+
_STRING_CLAIMS = ("sub", "jti", "yanez_agent_key_id")
|
|
30
|
+
# NumericDate claims the SDK does arithmetic on; a signed string here must be a typed
|
|
31
|
+
# rejection, never a TypeError or a comparison that silently passes.
|
|
32
|
+
_INTEGER_CLAIMS = ("yanez_decided_at", "yanez_consent_not_after")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _b64url_decode(s: str) -> bytes:
|
|
36
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _terms_equal(a: Any, b: Any) -> bool:
|
|
40
|
+
"""Deep JSON equality with bool distinct from int, the verdict the TypeScript SDK's
|
|
41
|
+
isDeepStrictEqual reaches; plain `==` would let {"n": true} match {"n": 1}."""
|
|
42
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
43
|
+
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
44
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
45
|
+
return a.keys() == b.keys() and all(_terms_equal(a[k], b[k]) for k in a)
|
|
46
|
+
if isinstance(a, list) and isinstance(b, list):
|
|
47
|
+
return len(a) == len(b) and all(map(_terms_equal, a, b))
|
|
48
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
49
|
+
return a == b # JSON has one number type
|
|
50
|
+
return type(a) is type(b) and a == b
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ReceiptVerifier:
|
|
54
|
+
"""Relying-party verification. Needs no agent key — a receipt is portable proof.
|
|
55
|
+
|
|
56
|
+
`expected_issuer` is mandatory: the unverified `iss` claim is never trusted to
|
|
57
|
+
name its own authority.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, base_url: str, expected_issuer: str, *,
|
|
61
|
+
timeout_seconds: float = 10.0,
|
|
62
|
+
now: Callable[[], float] = time.time,
|
|
63
|
+
transport: Optional[httpx.BaseTransport] = None) -> None:
|
|
64
|
+
if not expected_issuer:
|
|
65
|
+
raise ValueError("expected_issuer is mandatory")
|
|
66
|
+
self._base_url = require_trusted_origin(base_url)
|
|
67
|
+
self._issuer = expected_issuer
|
|
68
|
+
self._now = now
|
|
69
|
+
self._http = httpx.Client(base_url=self._base_url, timeout=timeout_seconds,
|
|
70
|
+
follow_redirects=False, transport=transport)
|
|
71
|
+
self._keys: dict[str, Ed25519PublicKey] = {}
|
|
72
|
+
self._keys_fetched_at: Optional[float] = None
|
|
73
|
+
|
|
74
|
+
# --- key set ---
|
|
75
|
+
|
|
76
|
+
def _fetch_keys(self) -> None:
|
|
77
|
+
try:
|
|
78
|
+
response = self._http.get("/api/authz/public-keys")
|
|
79
|
+
except httpx.HTTPError as e:
|
|
80
|
+
raise TransportError(type(e).__name__) from None
|
|
81
|
+
_raise_for(response, create=True) # a 404 here means the feature is absent
|
|
82
|
+
try:
|
|
83
|
+
entries = response.json()["keys"]
|
|
84
|
+
if not isinstance(entries, list):
|
|
85
|
+
raise TypeError
|
|
86
|
+
except (ValueError, TypeError, KeyError):
|
|
87
|
+
raise TransportError("malformed key set") from None
|
|
88
|
+
keys = {}
|
|
89
|
+
for jwk in entries:
|
|
90
|
+
if not isinstance(jwk, dict) or jwk.get("kty") != "OKP" \
|
|
91
|
+
or jwk.get("crv") != "Ed25519" or not jwk.get("kid"):
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
keys[jwk["kid"]] = Ed25519PublicKey.from_public_bytes(
|
|
95
|
+
_b64url_decode(jwk["x"]))
|
|
96
|
+
except (KeyError, TypeError, ValueError):
|
|
97
|
+
continue # one bad entry must not take the whole key set down
|
|
98
|
+
self._keys = keys
|
|
99
|
+
self._keys_fetched_at = time.monotonic()
|
|
100
|
+
|
|
101
|
+
def _key_for(self, kid: str) -> Ed25519PublicKey:
|
|
102
|
+
"""Cached for ten minutes; an unknown kid forces one early refresh (at most one
|
|
103
|
+
per cooldown) so a freshly rotated key verifies without a restart. Never
|
|
104
|
+
selected by algorithm."""
|
|
105
|
+
age = (math.inf if self._keys_fetched_at is None
|
|
106
|
+
else time.monotonic() - self._keys_fetched_at)
|
|
107
|
+
if age > _KEY_CACHE_TTL_SECONDS:
|
|
108
|
+
self._fetch_keys()
|
|
109
|
+
elif kid not in self._keys and age > _KEY_REFRESH_COOLDOWN_SECONDS:
|
|
110
|
+
self._fetch_keys()
|
|
111
|
+
key = self._keys.get(kid)
|
|
112
|
+
if key is None:
|
|
113
|
+
raise ReceiptVerificationError(f"unknown signing key {kid!r}")
|
|
114
|
+
return key
|
|
115
|
+
|
|
116
|
+
# --- verification ---
|
|
117
|
+
|
|
118
|
+
def verify(self, artifact: str, expected_terms: dict[str, Any],
|
|
119
|
+
max_age_seconds: int, *, now: Optional[float] = None,
|
|
120
|
+
expected_sub: Optional[str] = None,
|
|
121
|
+
expected_agent_key_id: Optional[str] = None) -> VerifiedReceipt:
|
|
122
|
+
"""Signature + profile + exact terms + freshness + consent bound.
|
|
123
|
+
|
|
124
|
+
Freshness (`max_age_seconds`, against `yanez_decided_at`) and the user's
|
|
125
|
+
`yanez_consent_not_after` are THIS relying party's gate on acting; neither
|
|
126
|
+
affects whether the receipt is genuine. There is deliberately no `exp`
|
|
127
|
+
requirement — a receipt still verifies years later, when the dispute happens.
|
|
128
|
+
|
|
129
|
+
A genuine receipt says that *some* YID approved these terms. When the terms do
|
|
130
|
+
not name the account, pass `expected_sub` (and/or `expected_agent_key_id`) so
|
|
131
|
+
an approval by one user can never authorize an action for another.
|
|
132
|
+
"""
|
|
133
|
+
current = self._now() if now is None else now
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
header = jwt.get_unverified_header(artifact)
|
|
137
|
+
except jwt.PyJWTError as e:
|
|
138
|
+
raise ReceiptVerificationError(str(e)) from None
|
|
139
|
+
# Pinned algorithm; the token's own header is never an allow-list.
|
|
140
|
+
if header.get("alg") != "EdDSA" or not header.get("kid"):
|
|
141
|
+
raise ReceiptVerificationError("receipt must be EdDSA with a kid")
|
|
142
|
+
|
|
143
|
+
key = self._key_for(header["kid"])
|
|
144
|
+
try:
|
|
145
|
+
claims = jwt.decode(
|
|
146
|
+
artifact, key, algorithms=["EdDSA"], issuer=self._issuer,
|
|
147
|
+
leeway=_CLOCK_SKEW_SECONDS,
|
|
148
|
+
)
|
|
149
|
+
except jwt.PyJWTError as e:
|
|
150
|
+
raise ReceiptVerificationError(str(e)) from None
|
|
151
|
+
|
|
152
|
+
for name in _REQUIRED_CLAIMS:
|
|
153
|
+
if claims.get(name) is None:
|
|
154
|
+
raise ReceiptVerificationError(f"missing claim {name}")
|
|
155
|
+
for name in _STRING_CLAIMS:
|
|
156
|
+
if not isinstance(claims[name], str):
|
|
157
|
+
raise ReceiptVerificationError(f"claim {name} must be a string")
|
|
158
|
+
for name in _INTEGER_CLAIMS:
|
|
159
|
+
value = claims.get(name)
|
|
160
|
+
if value is not None and (isinstance(value, bool) or not isinstance(value, int)):
|
|
161
|
+
raise ReceiptVerificationError(f"claim {name} must be an integer NumericDate")
|
|
162
|
+
if claims["yanez_decision"] != "approved":
|
|
163
|
+
raise ReceiptVerificationError("receipt is not an approval")
|
|
164
|
+
if expected_sub is not None and claims["sub"] != expected_sub:
|
|
165
|
+
raise ReceiptVerificationError("sub does not match expected_sub")
|
|
166
|
+
if expected_agent_key_id is not None and claims["yanez_agent_key_id"] != expected_agent_key_id:
|
|
167
|
+
raise ReceiptVerificationError(
|
|
168
|
+
"yanez_agent_key_id does not match expected_agent_key_id")
|
|
169
|
+
if claims["iat"] != claims["yanez_decided_at"]:
|
|
170
|
+
raise ReceiptVerificationError("iat and yanez_decided_at disagree")
|
|
171
|
+
overlap = claims["yanez_match_overlap"]
|
|
172
|
+
# Any non-negative integer: the issuer's block count and threshold are its
|
|
173
|
+
# policy at signing time, not part of this public contract.
|
|
174
|
+
if isinstance(overlap, bool) or not isinstance(overlap, int) or overlap < 0:
|
|
175
|
+
raise ReceiptVerificationError("yanez_match_overlap must be a non-negative integer")
|
|
176
|
+
if not _terms_equal(claims["yanez_terms"], expected_terms):
|
|
177
|
+
# Deep equality, no ignored or wildcard fields: changed terms mean a new
|
|
178
|
+
# authorization, never a reused receipt.
|
|
179
|
+
raise ReceiptVerificationError("terms do not match the approved terms")
|
|
180
|
+
|
|
181
|
+
decided_at = claims["yanez_decided_at"]
|
|
182
|
+
not_after = claims.get("yanez_consent_not_after")
|
|
183
|
+
if decided_at > current + _CLOCK_SKEW_SECONDS:
|
|
184
|
+
raise ReceiptVerificationError("yanez_decided_at is in the future")
|
|
185
|
+
if current - decided_at > max_age_seconds:
|
|
186
|
+
raise ConsentPolicyError(
|
|
187
|
+
f"approval is {int(current - decided_at)}s old, policy allows {max_age_seconds}s")
|
|
188
|
+
if not_after is not None and current > not_after:
|
|
189
|
+
raise ConsentPolicyError("past the user's consent bound")
|
|
190
|
+
|
|
191
|
+
return VerifiedReceipt(
|
|
192
|
+
sub=claims["sub"], jti=claims["jti"],
|
|
193
|
+
agent_key_id=claims["yanez_agent_key_id"], decided_at=decided_at,
|
|
194
|
+
match_overlap=overlap, terms=claims["yanez_terms"],
|
|
195
|
+
consent_not_after=not_after,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def introspect(self, artifact: str, *, consume: bool = False) -> IntrospectionResult:
|
|
199
|
+
"""Online check; `consume=True` permanently spends the receipt's jti."""
|
|
200
|
+
try:
|
|
201
|
+
response = self._http.post("/api/authz/introspect",
|
|
202
|
+
json={"artifact": artifact, "consume": consume})
|
|
203
|
+
except httpx.HTTPError as e:
|
|
204
|
+
raise TransportError(type(e).__name__) from None
|
|
205
|
+
_raise_for(response, create=True) # a 404 here means the feature is absent
|
|
206
|
+
data = response.json()
|
|
207
|
+
return IntrospectionResult(**{k: data.get(k) for k in (
|
|
208
|
+
"valid", "reason", "consumed_now", "sub", "jti",
|
|
209
|
+
"decided_at", "consent_not_after", "terms")})
|
|
210
|
+
|
|
211
|
+
def authorize_action(self, artifact: str, expected_terms: dict[str, Any],
|
|
212
|
+
max_age_seconds: int, *, consume: bool,
|
|
213
|
+
expected_sub: Optional[str] = None,
|
|
214
|
+
expected_agent_key_id: Optional[str] = None) -> VerifiedReceipt:
|
|
215
|
+
"""Everything the action boundary needs, in order — but never the action itself.
|
|
216
|
+
|
|
217
|
+
For a single-use action pass consume=True and call this immediately before
|
|
218
|
+
executing. If the action then fails, the receipt stays spent: retry means a
|
|
219
|
+
new authorization, because consumption and a third-party side effect cannot
|
|
220
|
+
be one atomic transaction.
|
|
221
|
+
"""
|
|
222
|
+
receipt = self.verify(artifact, expected_terms, max_age_seconds,
|
|
223
|
+
expected_sub=expected_sub,
|
|
224
|
+
expected_agent_key_id=expected_agent_key_id)
|
|
225
|
+
if consume:
|
|
226
|
+
result = self.introspect(artifact, consume=True)
|
|
227
|
+
if result.valid and result.reason == "already_consumed":
|
|
228
|
+
raise AlreadyConsumedError("receipt was already spent")
|
|
229
|
+
if result.valid and result.reason == "consent_expired":
|
|
230
|
+
raise ConsentPolicyError("past the user's consent bound")
|
|
231
|
+
if not result.valid:
|
|
232
|
+
raise ReceiptVerificationError(result.reason or "invalid receipt")
|
|
233
|
+
if result.consumed_now is not True:
|
|
234
|
+
# Only a consumption the server confirmed authorizes a single-use action.
|
|
235
|
+
raise ReceiptVerificationError(
|
|
236
|
+
"receipt was not consumed" + (f": {result.reason}" if result.reason else ""))
|
|
237
|
+
return receipt
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
FIXTURES = Path(__file__).resolve().parents[3] / "conformance" / "fixtures"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture(scope="session")
|
|
12
|
+
def jwks() -> dict:
|
|
13
|
+
return json.loads((FIXTURES / "jwks.json").read_text())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(scope="session")
|
|
17
|
+
def receipts() -> dict:
|
|
18
|
+
return json.loads((FIXTURES / "receipts.json").read_text())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(scope="session")
|
|
22
|
+
def http_fixtures() -> dict:
|
|
23
|
+
return json.loads((FIXTURES / "http.json").read_text())
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Agent-client behavior: auth header, idempotency, error typing, redirect refusal,
|
|
2
|
+
and terminal-state polling. Async client driven through asyncio.run so the suite needs
|
|
3
|
+
no async pytest plugin."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from yanez_authz import (
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
AuthorizationClient,
|
|
14
|
+
ConflictError,
|
|
15
|
+
FeatureUnavailableError,
|
|
16
|
+
InvalidRequestError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
TermsTooLargeError,
|
|
20
|
+
TransportError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
BASE = "https://yanez.test"
|
|
24
|
+
KEY = "yak_abc123abc123_s3cr3t-value"
|
|
25
|
+
TERMS = {"action": "purchase", "summary": "Buy running shoes for $180 at Example Store"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _client(handler, **kw) -> AuthorizationClient:
|
|
29
|
+
return AuthorizationClient(BASE, KEY, transport=httpx.MockTransport(handler), **kw)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_create_sends_bearer_and_generated_idempotency_key(http_fixtures):
|
|
33
|
+
seen = {}
|
|
34
|
+
|
|
35
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
36
|
+
seen["auth"] = request.headers.get("Authorization")
|
|
37
|
+
seen["idem"] = request.headers.get("Idempotency-Key")
|
|
38
|
+
return httpx.Response(201, json=http_fixtures["create_response"])
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
async with _client(handler) as client:
|
|
42
|
+
return await client.request_authorization(TERMS)
|
|
43
|
+
|
|
44
|
+
pending = asyncio.run(main())
|
|
45
|
+
assert seen["auth"] == f"Bearer {KEY}"
|
|
46
|
+
assert seen["idem"] and pending.idempotency_key == seen["idem"]
|
|
47
|
+
assert pending.status == "pending" and not pending.replayed
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_transport_retry_reuses_the_same_idempotency_key(http_fixtures):
|
|
51
|
+
attempts = []
|
|
52
|
+
|
|
53
|
+
class FlakyTransport(httpx.AsyncBaseTransport):
|
|
54
|
+
async def handle_async_request(self, request):
|
|
55
|
+
attempts.append(request.headers.get("Idempotency-Key"))
|
|
56
|
+
if len(attempts) == 1:
|
|
57
|
+
raise httpx.ConnectError("boom", request=request)
|
|
58
|
+
return httpx.Response(201, json=http_fixtures["create_response"],
|
|
59
|
+
headers={"Idempotency-Replayed": "true"})
|
|
60
|
+
|
|
61
|
+
async def main():
|
|
62
|
+
async with AuthorizationClient(BASE, KEY, transport=FlakyTransport()) as client:
|
|
63
|
+
return await client.request_authorization(TERMS, idempotency_key="retry-1")
|
|
64
|
+
|
|
65
|
+
pending = asyncio.run(main())
|
|
66
|
+
assert attempts == ["retry-1", "retry-1"]
|
|
67
|
+
assert pending.replayed is True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.parametrize("status,exc,create", [
|
|
71
|
+
(401, AuthenticationError, True),
|
|
72
|
+
(404, FeatureUnavailableError, True), # the whole router is absent on create
|
|
73
|
+
(404, NotFoundError, False), # unknown or cross-key id on get
|
|
74
|
+
(400, InvalidRequestError, True),
|
|
75
|
+
(409, ConflictError, True),
|
|
76
|
+
(413, TermsTooLargeError, True),
|
|
77
|
+
(422, InvalidRequestError, True),
|
|
78
|
+
(429, RateLimitError, True),
|
|
79
|
+
])
|
|
80
|
+
def test_http_errors_are_typed(status, exc, create):
|
|
81
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
82
|
+
return httpx.Response(status, json={"detail": "nope"})
|
|
83
|
+
|
|
84
|
+
async def main():
|
|
85
|
+
async with _client(handler) as client:
|
|
86
|
+
if create:
|
|
87
|
+
await client.request_authorization(TERMS)
|
|
88
|
+
else:
|
|
89
|
+
await client.get_authorization("azr_x")
|
|
90
|
+
|
|
91
|
+
with pytest.raises(exc):
|
|
92
|
+
asyncio.run(main())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_errors_never_carry_the_agent_key():
|
|
96
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
97
|
+
return httpx.Response(401, json={"detail": "Unauthorized"})
|
|
98
|
+
|
|
99
|
+
async def main():
|
|
100
|
+
async with _client(handler) as client:
|
|
101
|
+
await client.request_authorization(TERMS)
|
|
102
|
+
|
|
103
|
+
with pytest.raises(AuthenticationError) as e:
|
|
104
|
+
asyncio.run(main())
|
|
105
|
+
assert KEY not in str(e.value) and KEY not in repr(e.value)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_redirects_are_transport_errors_not_followed():
|
|
109
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
110
|
+
return httpx.Response(307, headers={"Location": "https://evil.example/"})
|
|
111
|
+
|
|
112
|
+
async def main():
|
|
113
|
+
async with _client(handler) as client:
|
|
114
|
+
await client.get_authorization("azr_x")
|
|
115
|
+
|
|
116
|
+
with pytest.raises(TransportError):
|
|
117
|
+
asyncio.run(main())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_https_is_required_off_loopback():
|
|
121
|
+
with pytest.raises(ValueError):
|
|
122
|
+
AuthorizationClient("http://yanez.example", KEY)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_wait_returns_every_terminal_state_as_a_value(http_fixtures):
|
|
126
|
+
for terminal in ("poll_approved", "poll_rejected", "poll_expired"):
|
|
127
|
+
responses = iter([http_fixtures["poll_pending"], http_fixtures[terminal]])
|
|
128
|
+
|
|
129
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
130
|
+
return httpx.Response(200, json=next(responses))
|
|
131
|
+
|
|
132
|
+
async def main():
|
|
133
|
+
async with _client(handler) as client:
|
|
134
|
+
return await client.wait_for_authorization("azr_x", 30,
|
|
135
|
+
long_poll_seconds=0)
|
|
136
|
+
|
|
137
|
+
result = asyncio.run(main())
|
|
138
|
+
assert result.status == http_fixtures[terminal]["status"]
|
|
139
|
+
assert (result.artifact is not None) == (result.status == "approved")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_wait_times_out_locally_without_touching_the_request(http_fixtures):
|
|
143
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
144
|
+
return httpx.Response(200, json=http_fixtures["poll_pending"])
|
|
145
|
+
|
|
146
|
+
async def main():
|
|
147
|
+
async with _client(handler) as client:
|
|
148
|
+
await client.wait_for_authorization("azr_x", 0.2, long_poll_seconds=0)
|
|
149
|
+
|
|
150
|
+
with pytest.raises(TimeoutError):
|
|
151
|
+
asyncio.run(main())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_malformed_request_id_is_rejected_before_any_request():
|
|
155
|
+
"""A model-supplied id must never steer the keyed request to another path."""
|
|
156
|
+
calls = []
|
|
157
|
+
|
|
158
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
159
|
+
calls.append(str(request.url))
|
|
160
|
+
return httpx.Response(200, json={})
|
|
161
|
+
|
|
162
|
+
async def main():
|
|
163
|
+
async with _client(handler) as client:
|
|
164
|
+
await client.get_authorization("../../admin/keys?x=1")
|
|
165
|
+
|
|
166
|
+
with pytest.raises(InvalidRequestError):
|
|
167
|
+
asyncio.run(main())
|
|
168
|
+
assert calls == []
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_long_poll_sends_the_wait_query(http_fixtures):
|
|
172
|
+
seen = {}
|
|
173
|
+
|
|
174
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
175
|
+
seen["url"] = str(request.url)
|
|
176
|
+
return httpx.Response(200, json=http_fixtures["poll_approved"])
|
|
177
|
+
|
|
178
|
+
async def main():
|
|
179
|
+
async with _client(handler) as client:
|
|
180
|
+
return await client.get_authorization("azr_x", wait_seconds=25)
|
|
181
|
+
|
|
182
|
+
asyncio.run(main())
|
|
183
|
+
assert seen["url"] == f"{BASE}/api/agent/authorizations/azr_x?wait=25"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_wait_paces_polls_when_the_server_answers_early(http_fixtures):
|
|
187
|
+
"""Wait 0 in the final second, or a proxy answering early, must not spin."""
|
|
188
|
+
calls = []
|
|
189
|
+
|
|
190
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
191
|
+
calls.append(1)
|
|
192
|
+
return httpx.Response(200, json=http_fixtures["poll_pending"])
|
|
193
|
+
|
|
194
|
+
async def main():
|
|
195
|
+
async with _client(handler) as client:
|
|
196
|
+
await client.wait_for_authorization("azr_x", 0.3, long_poll_seconds=0)
|
|
197
|
+
|
|
198
|
+
with pytest.raises(TimeoutError):
|
|
199
|
+
asyncio.run(main())
|
|
200
|
+
assert len(calls) <= 3
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Verifier behavior against the shared conformance fixtures — the same cases the
|
|
2
|
+
TypeScript SDK runs, so both languages reach the same verdict on every receipt."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import jwt
|
|
7
|
+
import pytest
|
|
8
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
9
|
+
|
|
10
|
+
from yanez_authz import (
|
|
11
|
+
AlreadyConsumedError,
|
|
12
|
+
ConsentPolicyError,
|
|
13
|
+
InvalidRequestError,
|
|
14
|
+
ReceiptVerificationError,
|
|
15
|
+
ReceiptVerifier,
|
|
16
|
+
TransportError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
BASE = "https://yanez.test"
|
|
20
|
+
ISSUER = "https://yanez.test"
|
|
21
|
+
|
|
22
|
+
_ERRORS = {"verification": ReceiptVerificationError, "consent_policy": ConsentPolicyError}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _signed(receipts, **overrides) -> str:
|
|
26
|
+
"""The conformance receipt re-signed under the fixture key with claim overrides."""
|
|
27
|
+
key = Ed25519PrivateKey.from_private_bytes(bytes(range(32))) # generate_fixtures seed
|
|
28
|
+
claims = jwt.decode(receipts["cases"]["valid"]["artifact"],
|
|
29
|
+
options={"verify_signature": False})
|
|
30
|
+
return jwt.encode({**claims, **overrides}, key, algorithm="EdDSA",
|
|
31
|
+
headers={"kid": "authz_test_1"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _verifier(jwks, extra_handler=None, **kw) -> tuple[ReceiptVerifier, list[str]]:
|
|
35
|
+
calls: list[str] = []
|
|
36
|
+
|
|
37
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
38
|
+
calls.append(request.url.path)
|
|
39
|
+
if request.url.path == "/api/authz/public-keys":
|
|
40
|
+
return httpx.Response(200, json=jwks)
|
|
41
|
+
if extra_handler is not None:
|
|
42
|
+
return extra_handler(request)
|
|
43
|
+
return httpx.Response(404, json={"detail": "unknown"})
|
|
44
|
+
|
|
45
|
+
v = ReceiptVerifier(BASE, ISSUER, transport=httpx.MockTransport(handler), **kw)
|
|
46
|
+
return v, calls
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_conformance_cases(jwks, receipts):
|
|
50
|
+
verifier, _ = _verifier(jwks)
|
|
51
|
+
for name, case in receipts["cases"].items():
|
|
52
|
+
now = case.get("now", receipts["now_fresh"])
|
|
53
|
+
expected_terms = case.get("expected_terms", receipts["expected_terms"])
|
|
54
|
+
if case["ok"]:
|
|
55
|
+
receipt = verifier.verify(case["artifact"], expected_terms, 900, now=now)
|
|
56
|
+
assert receipt.jti and receipt.sub, name
|
|
57
|
+
assert receipt.terms == receipts["expected_terms"], name
|
|
58
|
+
assert receipt.match_overlap >= 0, name
|
|
59
|
+
else:
|
|
60
|
+
with pytest.raises(_ERRORS[case["error"]]):
|
|
61
|
+
verifier.verify(case["artifact"], expected_terms, 900, now=now)
|
|
62
|
+
# pytest.raises message loses `name`; re-raise manually if this ever fails.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_valid_receipt_exposes_the_full_decoded_profile(jwks, receipts):
|
|
66
|
+
verifier, _ = _verifier(jwks)
|
|
67
|
+
case = receipts["cases"]["valid"]
|
|
68
|
+
receipt = verifier.verify(case["artifact"], receipts["expected_terms"], 900,
|
|
69
|
+
now=receipts["now_fresh"])
|
|
70
|
+
assert receipt.decided_at == receipts["decided_at"]
|
|
71
|
+
assert receipt.agent_key_id.startswith("yak_")
|
|
72
|
+
assert receipt.consent_not_after is None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_unknown_kids_do_not_refetch_within_the_cooldown(jwks, receipts):
|
|
76
|
+
"""Anyone can submit garbage kids; that must not turn into a fetch per receipt."""
|
|
77
|
+
verifier, calls = _verifier(jwks)
|
|
78
|
+
case = receipts["cases"]["unknown_kid"]
|
|
79
|
+
for _ in range(5):
|
|
80
|
+
with pytest.raises(ReceiptVerificationError):
|
|
81
|
+
verifier.verify(case["artifact"], receipts["expected_terms"], 900,
|
|
82
|
+
now=receipts["now_fresh"])
|
|
83
|
+
assert calls.count("/api/authz/public-keys") == 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_rotated_kid_verifies_after_the_cooldown_refresh(jwks, receipts):
|
|
87
|
+
"""The rotation path the refresh exists for: after the cooldown, an unknown kid
|
|
88
|
+
fetches again and the second fetch carries the new kid."""
|
|
89
|
+
rotated = {"keys": [{**jwks["keys"][0], "kid": "authz_retired"}]}
|
|
90
|
+
fetches = []
|
|
91
|
+
|
|
92
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
93
|
+
fetches.append(1)
|
|
94
|
+
return httpx.Response(200, json=jwks if len(fetches) == 1 else rotated)
|
|
95
|
+
|
|
96
|
+
verifier = ReceiptVerifier(BASE, ISSUER, transport=httpx.MockTransport(handler))
|
|
97
|
+
verifier.verify(receipts["cases"]["valid"]["artifact"], receipts["expected_terms"],
|
|
98
|
+
900, now=receipts["now_fresh"])
|
|
99
|
+
verifier._keys_fetched_at -= 31 # the cooldown has elapsed
|
|
100
|
+
case = receipts["cases"]["unknown_kid"] # signed with kid authz_retired
|
|
101
|
+
receipt = verifier.verify(case["artifact"], receipts["expected_terms"], 900,
|
|
102
|
+
now=receipts["now_fresh"])
|
|
103
|
+
assert receipt.jti and len(fetches) == 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_key_set_is_cached_between_verifies(jwks, receipts):
|
|
107
|
+
verifier, calls = _verifier(jwks)
|
|
108
|
+
case = receipts["cases"]["valid"]
|
|
109
|
+
for _ in range(3):
|
|
110
|
+
verifier.verify(case["artifact"], receipts["expected_terms"], 900,
|
|
111
|
+
now=receipts["now_fresh"])
|
|
112
|
+
assert calls.count("/api/authz/public-keys") == 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_introspect_and_authorize_action_map_consumption(jwks, receipts, http_fixtures):
|
|
116
|
+
responses = iter([http_fixtures["introspect_first_consume"],
|
|
117
|
+
http_fixtures["introspect_repeat_consume"]])
|
|
118
|
+
|
|
119
|
+
def introspect(request: httpx.Request) -> httpx.Response:
|
|
120
|
+
return httpx.Response(200, json=next(responses))
|
|
121
|
+
|
|
122
|
+
verifier, _ = _verifier(jwks, extra_handler=introspect,
|
|
123
|
+
now=lambda: receipts["now_fresh"])
|
|
124
|
+
artifact = receipts["cases"]["valid"]["artifact"]
|
|
125
|
+
|
|
126
|
+
receipt = verifier.authorize_action(artifact, receipts["expected_terms"], 900,
|
|
127
|
+
consume=True)
|
|
128
|
+
assert receipt.jti == http_fixtures["introspect_first_consume"]["jti"]
|
|
129
|
+
|
|
130
|
+
# The repeat is a genuine receipt that must never authorize the action again.
|
|
131
|
+
with pytest.raises(AlreadyConsumedError):
|
|
132
|
+
verifier.authorize_action(artifact, receipts["expected_terms"], 900, consume=True)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_expected_issuer_is_mandatory_and_https_is_enforced():
|
|
136
|
+
with pytest.raises(ValueError):
|
|
137
|
+
ReceiptVerifier(BASE, "")
|
|
138
|
+
with pytest.raises(ValueError):
|
|
139
|
+
ReceiptVerifier("http://yanez.example", ISSUER)
|
|
140
|
+
ReceiptVerifier("http://127.0.0.1:8001", ISSUER) # loopback development is fine
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_expected_sub_and_agent_key_bind_the_receipt(jwks, receipts):
|
|
144
|
+
verifier, _ = _verifier(jwks)
|
|
145
|
+
artifact = receipts["cases"]["valid"]["artifact"]
|
|
146
|
+
terms, now = receipts["expected_terms"], receipts["now_fresh"]
|
|
147
|
+
genuine = verifier.verify(artifact, terms, 900, now=now)
|
|
148
|
+
bound = verifier.verify(artifact, terms, 900, now=now, expected_sub=genuine.sub,
|
|
149
|
+
expected_agent_key_id=genuine.agent_key_id)
|
|
150
|
+
assert bound.jti == genuine.jti
|
|
151
|
+
with pytest.raises(ReceiptVerificationError):
|
|
152
|
+
verifier.verify(artifact, terms, 900, now=now, expected_sub="b" * 32)
|
|
153
|
+
with pytest.raises(ReceiptVerificationError):
|
|
154
|
+
verifier.authorize_action(artifact, terms, 900, consume=False,
|
|
155
|
+
expected_agent_key_id="yak_someone_else")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_decided_at_in_the_future_is_rejected_beyond_clock_skew(jwks, receipts):
|
|
159
|
+
verifier, _ = _verifier(jwks)
|
|
160
|
+
terms, now = receipts["expected_terms"], receipts["now_fresh"]
|
|
161
|
+
with pytest.raises(ReceiptVerificationError):
|
|
162
|
+
verifier.verify(_signed(receipts, iat=now + 3600, yanez_decided_at=now + 3600),
|
|
163
|
+
terms, 900, now=now)
|
|
164
|
+
assert verifier.verify(_signed(receipts, iat=now + 30, yanez_decided_at=now + 30),
|
|
165
|
+
terms, 900, now=now).jti
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_null_or_mistyped_required_claims_are_rejected(jwks, receipts):
|
|
169
|
+
verifier, _ = _verifier(jwks)
|
|
170
|
+
for bad in (_signed(receipts, yanez_agent_key_id=None), _signed(receipts, sub=12345)):
|
|
171
|
+
with pytest.raises(ReceiptVerificationError):
|
|
172
|
+
verifier.verify(bad, receipts["expected_terms"], 900, now=receipts["now_fresh"])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_terms_comparison_keeps_bool_and_int_distinct(jwks, receipts):
|
|
176
|
+
"""Python's True == 1 must not reach a verdict the TypeScript SDK would not."""
|
|
177
|
+
verifier, _ = _verifier(jwks)
|
|
178
|
+
terms, now = receipts["expected_terms"], receipts["now_fresh"]
|
|
179
|
+
artifact = _signed(receipts, yanez_terms={**terms, "gift": True})
|
|
180
|
+
with pytest.raises(ReceiptVerificationError):
|
|
181
|
+
verifier.verify(artifact, {**terms, "gift": 1}, 900, now=now)
|
|
182
|
+
assert verifier.verify(artifact, {**terms, "gift": True}, 900, now=now).jti
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_malformed_key_set_is_tolerated_per_entry_and_refused_per_body(jwks, receipts):
|
|
186
|
+
good = jwks["keys"][0]
|
|
187
|
+
artifact, terms, now = (receipts["cases"]["valid"]["artifact"],
|
|
188
|
+
receipts["expected_terms"], receipts["now_fresh"])
|
|
189
|
+
verifier, _ = _verifier({"keys": [{k: v for k, v in good.items() if k != "x"}, good]})
|
|
190
|
+
assert verifier.verify(artifact, terms, 900, now=now).jti # bad entry skipped
|
|
191
|
+
verifier, _ = _verifier("not a key set")
|
|
192
|
+
with pytest.raises(TransportError):
|
|
193
|
+
verifier.verify(artifact, terms, 900, now=now)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_introspection_outcomes_are_typed(jwks, receipts, http_fixtures):
|
|
197
|
+
artifact, terms = receipts["cases"]["valid"]["artifact"], receipts["expected_terms"]
|
|
198
|
+
outcomes = [
|
|
199
|
+
(httpx.Response(200, json=http_fixtures["introspect_bad_signature"]),
|
|
200
|
+
ReceiptVerificationError),
|
|
201
|
+
(httpx.Response(200, json={"valid": True, "reason": "consent_expired",
|
|
202
|
+
"consumed_now": False}), ConsentPolicyError),
|
|
203
|
+
(httpx.Response(422, json={"detail": "artifact too large"}), InvalidRequestError),
|
|
204
|
+
]
|
|
205
|
+
for response, exc in outcomes:
|
|
206
|
+
verifier, _ = _verifier(jwks, extra_handler=lambda r, response=response: response,
|
|
207
|
+
now=lambda: receipts["now_fresh"])
|
|
208
|
+
with pytest.raises(exc):
|
|
209
|
+
verifier.authorize_action(artifact, terms, 900, consume=True)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_consume_requires_confirmed_consumption(jwks, receipts):
|
|
213
|
+
artifact, terms = receipts["cases"]["valid"]["artifact"], receipts["expected_terms"]
|
|
214
|
+
for body in ({"valid": True, "consumed_now": False}, {"valid": True}):
|
|
215
|
+
verifier, _ = _verifier(jwks, extra_handler=lambda r, body=body: httpx.Response(200, json=body),
|
|
216
|
+
now=lambda: receipts["now_fresh"])
|
|
217
|
+
with pytest.raises(ReceiptVerificationError):
|
|
218
|
+
verifier.authorize_action(artifact, terms, 900, consume=True)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_numeric_date_claims_must_be_integers(jwks, receipts):
|
|
222
|
+
verifier, _ = _verifier(jwks)
|
|
223
|
+
terms, now = receipts["expected_terms"], receipts["now_fresh"]
|
|
224
|
+
for bad in (_signed(receipts, yanez_consent_not_after="not-a-date"),
|
|
225
|
+
_signed(receipts, yanez_decided_at="x")):
|
|
226
|
+
with pytest.raises(ReceiptVerificationError):
|
|
227
|
+
verifier.verify(bad, terms, 900, now=now)
|
|
228
|
+
# A JSON null bound is "no bound", the same verdict the TypeScript SDK reaches.
|
|
229
|
+
receipt = verifier.verify(_signed(receipts, yanez_consent_not_after=None), terms, 900, now=now)
|
|
230
|
+
assert receipt.consent_not_after is None
|