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.
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ node_modules/
5
+ dist/
6
+ .DS_Store
7
+ .idea/
8
+ *.swp
9
+ .pytest_cache/
10
+ .serena/
@@ -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