chitmark 0.6.2__tar.gz → 0.6.3__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.
- {chitmark-0.6.2 → chitmark-0.6.3}/PKG-INFO +23 -10
- {chitmark-0.6.2 → chitmark-0.6.3}/README.md +22 -9
- {chitmark-0.6.2 → chitmark-0.6.3}/pyproject.toml +1 -1
- {chitmark-0.6.2 → chitmark-0.6.3}/src/chitmark/__init__.py +4 -1
- {chitmark-0.6.2 → chitmark-0.6.3}/src/chitmark/client.py +58 -14
- {chitmark-0.6.2 → chitmark-0.6.3}/src/chitmark/types.py +91 -10
- {chitmark-0.6.2 → chitmark-0.6.3}/src/chitmark/verify_token.py +45 -16
- {chitmark-0.6.2 → chitmark-0.6.3}/tests/test_client.py +26 -1
- {chitmark-0.6.2 → chitmark-0.6.3}/tests/test_client_extra.py +2 -0
- {chitmark-0.6.2 → chitmark-0.6.3}/tests/test_verify_token.py +23 -21
- {chitmark-0.6.2 → chitmark-0.6.3}/tests/test_verify_token_extra.py +15 -19
- {chitmark-0.6.2 → chitmark-0.6.3}/.gitignore +0 -0
- {chitmark-0.6.2 → chitmark-0.6.3}/LICENSE +0 -0
- {chitmark-0.6.2 → chitmark-0.6.3}/src/chitmark/errors.py +0 -0
- {chitmark-0.6.2 → chitmark-0.6.3}/src/chitmark/pii.py +0 -0
- {chitmark-0.6.2 → chitmark-0.6.3}/src/chitmark/py.typed +0 -0
- {chitmark-0.6.2 → chitmark-0.6.3}/tests/test_errors.py +0 -0
- {chitmark-0.6.2 → chitmark-0.6.3}/tests/test_pii.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: chitmark
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: Official Python SDK for Chitmark by Open Agent Ledger: trust decisions on agent-mediated actions (verify, feedback, challenge)
|
|
5
5
|
Project-URL: Homepage, https://chitmark.com
|
|
6
6
|
Project-URL: Documentation, https://chitmark.com/docs
|
|
@@ -80,7 +80,14 @@ with Chitmark(api_key="ck_live_...") as client:
|
|
|
80
80
|
| `client.feedback(...)` | `POST /v1/feedback` | Learn |
|
|
81
81
|
| `client.challenge(...)` | `POST /v1/challenge` | Escalate |
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
Store-outage degradation on verify is HTTP 200 with a Verdict
|
|
84
|
+
(``degraded: True``); the client deserializes it like any other Verdict.
|
|
85
|
+
Verify never returns HTTP 503. Timeouts and transport errors synthesize a
|
|
86
|
+
local degraded challenge Verdict and never raise on ``verify``. HTTP 4xx /
|
|
87
|
+
429 Error responses raise ``ChitmarkApiError``. Set ``on_degraded`` to
|
|
88
|
+
``challenge``, never allow on degraded.
|
|
89
|
+
|
|
90
|
+
Use `is_eligible_allow(verdict)` before any protected action: only an explicit non-degraded `allow` is eligible. Unknown or malformed decisions fail closed.
|
|
84
91
|
|
|
85
92
|
### Report outcomes
|
|
86
93
|
|
|
@@ -95,12 +102,12 @@ db.accounts.update(user_id, chitmark_event_id=verdict["eventId"])
|
|
|
95
102
|
client.feedback(
|
|
96
103
|
event_id=account.chitmark_event_id, # the stored join key
|
|
97
104
|
outcome="credit_burn",
|
|
98
|
-
value=87.4, #
|
|
105
|
+
value=87.4, # measured magnitude: unit rides along (default "usd"; also "credits" | "count")
|
|
99
106
|
observed_at="2026-08-06T04:00:00Z",
|
|
100
107
|
)
|
|
101
108
|
```
|
|
102
109
|
|
|
103
|
-
Exact duplicate feedback bodies derive the same warehouse id, so retrying a connector never double-counts a burned value. `unit` ships only alongside `value`.
|
|
110
|
+
Exact duplicate feedback bodies derive the same warehouse id (`eventId` + `outcome` + `value` + `unit` + `observedAt`), so retrying a connector with a stable business timestamp never double-counts a burned value. Prefer an idempotency key; omitting `observedAt` uses server time. `unit` ships only alongside `value`.
|
|
104
111
|
|
|
105
112
|
### Handle a challenge
|
|
106
113
|
|
|
@@ -129,12 +136,12 @@ if instructions["type"] == "pow":
|
|
|
129
136
|
session="sess_9f3a",
|
|
130
137
|
proof={"type": "proof_of_work", "nonce": str(nonce)},
|
|
131
138
|
)
|
|
132
|
-
# Re-verify with context={"challengeId": ...}
|
|
139
|
+
# Re-verify with context={"challengeId": ...}; completion alone does not authorize.
|
|
133
140
|
```
|
|
134
141
|
|
|
135
142
|
### Verify the receipt
|
|
136
143
|
|
|
137
|
-
Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify
|
|
144
|
+
Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify authenticity, then authorize on the decision (install the `verdict` extra for the crypto dependency):
|
|
138
145
|
|
|
139
146
|
```python
|
|
140
147
|
pip install "chitmark[verdict]"
|
|
@@ -145,13 +152,19 @@ from chitmark.verify_token import verify_verdict_token
|
|
|
145
152
|
|
|
146
153
|
claims = verify_verdict_token(
|
|
147
154
|
verdict["verdictToken"],
|
|
148
|
-
session="sess_9f3a",
|
|
149
|
-
|
|
155
|
+
session="sess_9f3a",
|
|
156
|
+
tenant_id="org_acme",
|
|
150
157
|
)
|
|
151
|
-
|
|
158
|
+
|
|
159
|
+
if claims["degraded"] or claims["decision"] != "allow":
|
|
160
|
+
raise ChallengeRequiredError() # HTTP 428 / challenge response
|
|
161
|
+
|
|
162
|
+
# Only now trust the allow.
|
|
152
163
|
```
|
|
153
164
|
|
|
154
|
-
Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes.
|
|
165
|
+
Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes. A verified token that is not a non-degraded `allow` must still fail closed.
|
|
166
|
+
|
|
167
|
+
Cache JWKS using HTTP headers: respect `Cache-Control` (`max-age=60`), and when `ETag` or `Last-Modified` are present use conditional requests (`If-None-Match` / `If-Modified-Since`) instead of a hard-coded refresh interval. Refresh immediately on unknown `kid`. Do not fetch on every verification. Every signed JWT requires `kid`. Keys are EC P-256 for ES256: require JWT `alg === "ES256"` before verifying (`wrong_algorithm` otherwise); never trust the token-requested alg. Rotation overlaps old and new keys; the old key stays until tokens under it expire (keep ≥ 5 minutes + 60 seconds). An empty JWKS (`keys: []`) means verification is impossible: fail closed; never treat an empty set as proof a token is valid (local/CI may also emit `unsigned.<eventId>`). Pass a cached `jwks=` mapping into `verify_verdict_token` to skip the network call.
|
|
155
168
|
|
|
156
169
|
### PII modes
|
|
157
170
|
|
|
@@ -46,7 +46,14 @@ with Chitmark(api_key="ck_live_...") as client:
|
|
|
46
46
|
| `client.feedback(...)` | `POST /v1/feedback` | Learn |
|
|
47
47
|
| `client.challenge(...)` | `POST /v1/challenge` | Escalate |
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
Store-outage degradation on verify is HTTP 200 with a Verdict
|
|
50
|
+
(``degraded: True``); the client deserializes it like any other Verdict.
|
|
51
|
+
Verify never returns HTTP 503. Timeouts and transport errors synthesize a
|
|
52
|
+
local degraded challenge Verdict and never raise on ``verify``. HTTP 4xx /
|
|
53
|
+
429 Error responses raise ``ChitmarkApiError``. Set ``on_degraded`` to
|
|
54
|
+
``challenge``, never allow on degraded.
|
|
55
|
+
|
|
56
|
+
Use `is_eligible_allow(verdict)` before any protected action: only an explicit non-degraded `allow` is eligible. Unknown or malformed decisions fail closed.
|
|
50
57
|
|
|
51
58
|
### Report outcomes
|
|
52
59
|
|
|
@@ -61,12 +68,12 @@ db.accounts.update(user_id, chitmark_event_id=verdict["eventId"])
|
|
|
61
68
|
client.feedback(
|
|
62
69
|
event_id=account.chitmark_event_id, # the stored join key
|
|
63
70
|
outcome="credit_burn",
|
|
64
|
-
value=87.4, #
|
|
71
|
+
value=87.4, # measured magnitude: unit rides along (default "usd"; also "credits" | "count")
|
|
65
72
|
observed_at="2026-08-06T04:00:00Z",
|
|
66
73
|
)
|
|
67
74
|
```
|
|
68
75
|
|
|
69
|
-
Exact duplicate feedback bodies derive the same warehouse id, so retrying a connector never double-counts a burned value. `unit` ships only alongside `value`.
|
|
76
|
+
Exact duplicate feedback bodies derive the same warehouse id (`eventId` + `outcome` + `value` + `unit` + `observedAt`), so retrying a connector with a stable business timestamp never double-counts a burned value. Prefer an idempotency key; omitting `observedAt` uses server time. `unit` ships only alongside `value`.
|
|
70
77
|
|
|
71
78
|
### Handle a challenge
|
|
72
79
|
|
|
@@ -95,12 +102,12 @@ if instructions["type"] == "pow":
|
|
|
95
102
|
session="sess_9f3a",
|
|
96
103
|
proof={"type": "proof_of_work", "nonce": str(nonce)},
|
|
97
104
|
)
|
|
98
|
-
# Re-verify with context={"challengeId": ...}
|
|
105
|
+
# Re-verify with context={"challengeId": ...}; completion alone does not authorize.
|
|
99
106
|
```
|
|
100
107
|
|
|
101
108
|
### Verify the receipt
|
|
102
109
|
|
|
103
|
-
Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify
|
|
110
|
+
Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify authenticity, then authorize on the decision (install the `verdict` extra for the crypto dependency):
|
|
104
111
|
|
|
105
112
|
```python
|
|
106
113
|
pip install "chitmark[verdict]"
|
|
@@ -111,13 +118,19 @@ from chitmark.verify_token import verify_verdict_token
|
|
|
111
118
|
|
|
112
119
|
claims = verify_verdict_token(
|
|
113
120
|
verdict["verdictToken"],
|
|
114
|
-
session="sess_9f3a",
|
|
115
|
-
|
|
121
|
+
session="sess_9f3a",
|
|
122
|
+
tenant_id="org_acme",
|
|
116
123
|
)
|
|
117
|
-
|
|
124
|
+
|
|
125
|
+
if claims["degraded"] or claims["decision"] != "allow":
|
|
126
|
+
raise ChallengeRequiredError() # HTTP 428 / challenge response
|
|
127
|
+
|
|
128
|
+
# Only now trust the allow.
|
|
118
129
|
```
|
|
119
130
|
|
|
120
|
-
Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes.
|
|
131
|
+
Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes. A verified token that is not a non-degraded `allow` must still fail closed.
|
|
132
|
+
|
|
133
|
+
Cache JWKS using HTTP headers: respect `Cache-Control` (`max-age=60`), and when `ETag` or `Last-Modified` are present use conditional requests (`If-None-Match` / `If-Modified-Since`) instead of a hard-coded refresh interval. Refresh immediately on unknown `kid`. Do not fetch on every verification. Every signed JWT requires `kid`. Keys are EC P-256 for ES256: require JWT `alg === "ES256"` before verifying (`wrong_algorithm` otherwise); never trust the token-requested alg. Rotation overlaps old and new keys; the old key stays until tokens under it expire (keep ≥ 5 minutes + 60 seconds). An empty JWKS (`keys: []`) means verification is impossible: fail closed; never treat an empty set as proof a token is valid (local/CI may also emit `unsigned.<eventId>`). Pass a cached `jwks=` mapping into `verify_verdict_token` to skip the network call.
|
|
121
134
|
|
|
122
135
|
### PII modes
|
|
123
136
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "chitmark"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.3"
|
|
8
8
|
description = "Official Python SDK for Chitmark by Open Agent Ledger: trust decisions on agent-mediated actions (verify, feedback, challenge)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "Proprietary: no open-source grant. Copyright (c) 2026 Open Agent Ledger. All rights reserved." }
|
|
@@ -4,7 +4,7 @@ Trust decisions on agent-mediated actions (verify, feedback, challenge),
|
|
|
4
4
|
tuned by business outcomes.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from chitmark.client import SDK_VERSION, Chitmark
|
|
7
|
+
from chitmark.client import SDK_VERSION, Chitmark, is_eligible_allow
|
|
8
8
|
from chitmark.errors import ChitmarkApiError, ChitmarkError
|
|
9
9
|
from chitmark.pii import hash_email_sha256, truncate_ip
|
|
10
10
|
from chitmark.types import (
|
|
@@ -14,6 +14,7 @@ from chitmark.types import (
|
|
|
14
14
|
Decision,
|
|
15
15
|
DecisionCosts,
|
|
16
16
|
FeedbackResponse,
|
|
17
|
+
FeedbackSource,
|
|
17
18
|
Outcome,
|
|
18
19
|
Principal,
|
|
19
20
|
Subject,
|
|
@@ -35,6 +36,7 @@ __all__ = [
|
|
|
35
36
|
"Decision",
|
|
36
37
|
"DecisionCosts",
|
|
37
38
|
"FeedbackResponse",
|
|
39
|
+
"FeedbackSource",
|
|
38
40
|
"Outcome",
|
|
39
41
|
"Principal",
|
|
40
42
|
"Subject",
|
|
@@ -43,6 +45,7 @@ __all__ = [
|
|
|
43
45
|
"VerdictTokenError",
|
|
44
46
|
"VerifyContext",
|
|
45
47
|
"hash_email_sha256",
|
|
48
|
+
"is_eligible_allow",
|
|
46
49
|
"truncate_ip",
|
|
47
50
|
"verify_verdict_token",
|
|
48
51
|
]
|
|
@@ -22,6 +22,8 @@ from chitmark.types import (
|
|
|
22
22
|
ChitmarkEnv,
|
|
23
23
|
Decision,
|
|
24
24
|
FeedbackResponse,
|
|
25
|
+
FeedbackSource,
|
|
26
|
+
FeedbackUnit,
|
|
25
27
|
OnDegraded,
|
|
26
28
|
Outcome,
|
|
27
29
|
PiiMode,
|
|
@@ -30,7 +32,7 @@ from chitmark.types import (
|
|
|
30
32
|
VerifyContext,
|
|
31
33
|
)
|
|
32
34
|
|
|
33
|
-
__all__ = ["Chitmark", "DEFAULT_BASE_URLS", "SDK_VERSION"]
|
|
35
|
+
__all__ = ["Chitmark", "DEFAULT_BASE_URLS", "SDK_VERSION", "is_eligible_allow"]
|
|
34
36
|
|
|
35
37
|
SDK_VERSION = "0.6.0"
|
|
36
38
|
|
|
@@ -66,6 +68,15 @@ def _enforce_degraded_never_allow(verdict: Verdict) -> Verdict:
|
|
|
66
68
|
return verdict
|
|
67
69
|
|
|
68
70
|
|
|
71
|
+
def is_eligible_allow(verdict: Mapping[str, Any] | Verdict) -> bool:
|
|
72
|
+
"""True only for an explicit non-degraded allow.
|
|
73
|
+
|
|
74
|
+
Use this before any protected action so unknown or malformed decisions
|
|
75
|
+
cannot fall through. Fail closed: anything else is ineligible.
|
|
76
|
+
"""
|
|
77
|
+
return verdict.get("decision") == "allow" and not verdict.get("degraded")
|
|
78
|
+
|
|
79
|
+
|
|
69
80
|
def _is_well_formed_verdict(value: Any) -> bool:
|
|
70
81
|
"""Minimal shape guard for a 200 verify response.
|
|
71
82
|
|
|
@@ -204,13 +215,26 @@ class Chitmark:
|
|
|
204
215
|
context: VerifyContext | Mapping[str, Any] | None = None,
|
|
205
216
|
idempotency_key: str | None = None,
|
|
206
217
|
) -> Verdict:
|
|
207
|
-
"""Score + route an inbound action.
|
|
218
|
+
"""Score + route an inbound action.
|
|
219
|
+
|
|
220
|
+
Store-outage degradation arrives as HTTP 200 with a Verdict body
|
|
221
|
+
(``degraded: True``, ``decision: "challenge"``). The client
|
|
222
|
+
deserializes that like any other Verdict. Verify never returns
|
|
223
|
+
HTTP 503.
|
|
224
|
+
|
|
225
|
+
HTTP 4xx / 429 Error responses raise ``ChitmarkApiError``. Transport
|
|
226
|
+
timeout / network failure synthesizes a local degraded challenge
|
|
227
|
+
Verdict (never allow).
|
|
228
|
+
|
|
229
|
+
``session`` is a binding id you supply (prefer high-entropy,
|
|
230
|
+
server-generated); it binds challenge and ``verdictToken``.
|
|
208
231
|
|
|
209
232
|
``context`` carries per-request policy flags: ``challengeId``
|
|
210
233
|
(honor a cleared challenge once), ``challengePolicy``
|
|
211
234
|
("default" | "confirmed_tail_only"), and ``decisionCosts``
|
|
212
|
-
(falseAllow/falseChallenge/
|
|
213
|
-
allow
|
|
235
|
+
(relative, dimensionless weights for falseAllow / falseChallenge /
|
|
236
|
+
falseDeny: abusive allow, over-friction challenge on legitimate
|
|
237
|
+
traffic, legitimate deny; defaults 1:1:3).
|
|
214
238
|
"""
|
|
215
239
|
body: dict[str, Any] = {
|
|
216
240
|
"action": action,
|
|
@@ -249,15 +273,30 @@ class Chitmark:
|
|
|
249
273
|
event_id: str,
|
|
250
274
|
outcome: Outcome,
|
|
251
275
|
value: float | None = None,
|
|
252
|
-
unit:
|
|
276
|
+
unit: FeedbackUnit = "usd",
|
|
277
|
+
source: FeedbackSource | None = None,
|
|
253
278
|
observed_at: str | None = None,
|
|
254
279
|
idempotency_key: str | None = None,
|
|
255
280
|
) -> FeedbackResponse:
|
|
256
281
|
"""Report a downstream outcome joined on eventId.
|
|
257
282
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
283
|
+
Security invariant: feedback cannot create attribution. It only
|
|
284
|
+
attaches an outcome to an existing verify event for this tenant.
|
|
285
|
+
Unknown or cross-tenant ``event_id`` values return 404. Never invent
|
|
286
|
+
a join from email/IP.
|
|
287
|
+
|
|
288
|
+
Append-only (not latest-wins): multiple outcomes may attach to one
|
|
289
|
+
eventId. Exact duplicates share one feedbackId. Compatible labels
|
|
290
|
+
coexist; conflicting pairs quarantine the new observation without
|
|
291
|
+
deleting prior evidence. HTTP ``{ok: true}`` still acknowledges
|
|
292
|
+
quarantined ingest.
|
|
293
|
+
|
|
294
|
+
``value`` is the measured economic magnitude for this outcome,
|
|
295
|
+
expressed in ``unit`` (``usd`` | ``credits`` | ``count``). ``unit``
|
|
296
|
+
rides along only when ``value`` is present. Different units are not
|
|
297
|
+
converted or comparable. ``source`` is optional provenance (billing,
|
|
298
|
+
usage_metering, trust_queue, manual_review, customer_system,
|
|
299
|
+
automated_detector) and is included in warehouse identity when set.
|
|
261
300
|
``idempotency_key`` is sent as both the ``Idempotency-Key`` header
|
|
262
301
|
and body ``idempotencyKey`` so exact retries replay the original
|
|
263
302
|
acknowledgement; a reused key with a different payload conflicts.
|
|
@@ -266,6 +305,8 @@ class Chitmark:
|
|
|
266
305
|
if value is not None:
|
|
267
306
|
body["value"] = value
|
|
268
307
|
body["unit"] = unit
|
|
308
|
+
if source is not None:
|
|
309
|
+
body["source"] = source
|
|
269
310
|
if observed_at is not None:
|
|
270
311
|
body["observedAt"] = observed_at
|
|
271
312
|
if idempotency_key is not None:
|
|
@@ -290,9 +331,11 @@ class Chitmark:
|
|
|
290
331
|
) -> ChallengeResponse:
|
|
291
332
|
"""Issue an asymmetric-cost challenge.
|
|
292
333
|
|
|
293
|
-
``
|
|
294
|
-
|
|
295
|
-
|
|
334
|
+
``session`` is a binding id you supply (prefer high-entropy,
|
|
335
|
+
server-generated). ``risk_tier`` scales issued PoW difficulty
|
|
336
|
+
monotonically; difficulty is sealed in ``instructions`` and
|
|
337
|
+
completion validates that value only. ``prefer`` is ordered hints:
|
|
338
|
+
first issuable method wins.
|
|
296
339
|
"""
|
|
297
340
|
body: dict[str, Any] = {"eventId": event_id, "session": session}
|
|
298
341
|
if prefer is not None:
|
|
@@ -314,9 +357,10 @@ class Chitmark:
|
|
|
314
357
|
) -> ChallengeCompletionResponse:
|
|
315
358
|
"""Complete a challenge by submitting its solved proof.
|
|
316
359
|
|
|
317
|
-
Clears only the bound session, once
|
|
318
|
-
``context={"challengeId": challenge_id}``
|
|
319
|
-
|
|
360
|
+
Clears only the bound session, once. Does not authorize the action:
|
|
361
|
+
re-verify with ``context={"challengeId": challenge_id}`` and proceed
|
|
362
|
+
only on non-degraded ``allow`` (honored clear sets
|
|
363
|
+
``challengeCleared: True``).
|
|
320
364
|
"""
|
|
321
365
|
body: dict[str, Any] = {
|
|
322
366
|
"eventId": event_id,
|
|
@@ -19,6 +19,13 @@ Action = (
|
|
|
19
19
|
]
|
|
20
20
|
| str
|
|
21
21
|
)
|
|
22
|
+
# Outcomes reported via feedback. Prefer fast labels first
|
|
23
|
+
# (credit_burn, multi_account_cluster, abuse_confirmed).
|
|
24
|
+
# false_positive: a previously attributed abuse signal on this eventId was
|
|
25
|
+
# reviewed and determined not to represent abuse (T&S clear / overturn).
|
|
26
|
+
# Conflicts with abuse-positive labels. Not "Chitmark challenged/denied a
|
|
27
|
+
# legitimate user": that is context.decisionCosts.falseChallenge / falseDeny
|
|
28
|
+
# on verify.
|
|
22
29
|
Outcome = Literal[
|
|
23
30
|
"credit_burn",
|
|
24
31
|
"multi_account_cluster",
|
|
@@ -28,6 +35,18 @@ Outcome = Literal[
|
|
|
28
35
|
"churned",
|
|
29
36
|
"false_positive",
|
|
30
37
|
]
|
|
38
|
+
# Scale for feedback value. Allowlist only (not free-form). Values in
|
|
39
|
+
# different units are not converted or comparable.
|
|
40
|
+
FeedbackUnit = Literal["usd", "credits", "count"]
|
|
41
|
+
# Provenance of a feedback label: why you trusted this outcome.
|
|
42
|
+
FeedbackSource = Literal[
|
|
43
|
+
"billing",
|
|
44
|
+
"usage_metering",
|
|
45
|
+
"trust_queue",
|
|
46
|
+
"manual_review",
|
|
47
|
+
"customer_system",
|
|
48
|
+
"automated_detector",
|
|
49
|
+
]
|
|
31
50
|
ChallengeMethod = Literal[
|
|
32
51
|
"payment_preauth",
|
|
33
52
|
"proof_of_work",
|
|
@@ -42,19 +61,49 @@ OnDegraded = Literal["challenge"]
|
|
|
42
61
|
|
|
43
62
|
class PrincipalRequired(TypedDict):
|
|
44
63
|
"""Required principal fields from verify."""
|
|
64
|
+
|
|
45
65
|
resolved: bool
|
|
46
66
|
identityBasis: list[str]
|
|
47
67
|
|
|
48
68
|
|
|
49
69
|
class Principal(PrincipalRequired, total=False):
|
|
50
|
-
"""Principal
|
|
70
|
+
"""Principal attribution: who is acting and on whose behalf.
|
|
71
|
+
|
|
72
|
+
Not an authorization credential. ``resolved is True`` is not "this is a
|
|
73
|
+
human", not an IdP login, and not permission to unlock an action. Gate
|
|
74
|
+
on ``decision`` (plus ``degraded`` / ``enforcementMode``).
|
|
75
|
+
|
|
76
|
+
- ``resolved``: usable principal signal (today: verified Web Bot Auth
|
|
77
|
+
operator signature and/or enrichment known-good).
|
|
78
|
+
- ``operator``: bot maker from Web Bot Auth Signature-Agent when the
|
|
79
|
+
operator channel verified; does not by itself prove ``onBehalfOf``.
|
|
80
|
+
- ``onBehalfOf``: principal the agent is acting for when known
|
|
81
|
+
(organization **or** end-user identifier). Often absent at the wedge.
|
|
82
|
+
- ``identityBasis``: contributing signal sources (mixed cryptographic
|
|
83
|
+
and inferred strength).
|
|
84
|
+
"""
|
|
51
85
|
|
|
52
86
|
operator: str
|
|
53
87
|
onBehalfOf: str
|
|
54
88
|
|
|
55
|
-
|
|
56
89
|
class Verdict(TypedDict):
|
|
57
|
-
"""Verdict object: single source of truth for a verify call.
|
|
90
|
+
"""Verdict object: single source of truth for a verify call.
|
|
91
|
+
|
|
92
|
+
``confidence`` is a model confidence measure for the returned decision
|
|
93
|
+
(0..1). It is not a calibrated probability of abuse, and not the
|
|
94
|
+
threshold ``decisionCosts`` moves. Gate on ``decision`` (plus
|
|
95
|
+
``degraded`` / ``enforcementMode``); use confidence for ops triage and
|
|
96
|
+
dashboards, not as a substitute allow/deny cutoff.
|
|
97
|
+
|
|
98
|
+
``reasons`` is ``list[str]`` display copy for ops today. Do not scrape
|
|
99
|
+
reason text as a stable machine API. A future additive revision may
|
|
100
|
+
expose structured ``{code, message, category}`` entries while keeping a
|
|
101
|
+
human-readable message.
|
|
102
|
+
|
|
103
|
+
Under ``enforcementMode == "log_only"``, ``verdictToken`` is a signed
|
|
104
|
+
observation receipt and does not authorize the action. Mode is on this
|
|
105
|
+
body (not a JWT claim today): gate only when mode is ``enforce``.
|
|
106
|
+
"""
|
|
58
107
|
|
|
59
108
|
decision: Decision
|
|
60
109
|
actorType: ActorType
|
|
@@ -70,6 +119,7 @@ class Verdict(TypedDict):
|
|
|
70
119
|
class VerdictTokenClaims(TypedDict, total=False):
|
|
71
120
|
"""Decoded verdictToken JWT payload (see SDK.md §6)."""
|
|
72
121
|
|
|
122
|
+
iss: str
|
|
73
123
|
eventId: str
|
|
74
124
|
decision: Decision
|
|
75
125
|
actorType: ActorType
|
|
@@ -84,7 +134,13 @@ class VerdictTokenClaims(TypedDict, total=False):
|
|
|
84
134
|
|
|
85
135
|
|
|
86
136
|
class VerdictWithChallenge(Verdict, total=False):
|
|
87
|
-
"""Optional verdict additions for cleared challenges.
|
|
137
|
+
"""Optional verdict additions for cleared challenges.
|
|
138
|
+
|
|
139
|
+
``challengeCleared: true`` means this verify honored
|
|
140
|
+
``context.challengeId`` for the session. Guaranteed only with
|
|
141
|
+
``decision: allow`` and ``degraded: false`` (clear short-circuits
|
|
142
|
+
scoring). Under ``log_only``, still observation-only.
|
|
143
|
+
"""
|
|
88
144
|
|
|
89
145
|
challengeCleared: bool
|
|
90
146
|
|
|
@@ -103,13 +159,22 @@ class Subject(TypedDict, total=False):
|
|
|
103
159
|
|
|
104
160
|
|
|
105
161
|
class DecisionCosts(TypedDict, total=False):
|
|
106
|
-
"""
|
|
162
|
+
"""Relative, dimensionless weights for three error classes.
|
|
163
|
+
|
|
164
|
+
Not dollars and not score thresholds; only ratios matter. Repositions
|
|
165
|
+
the allow/challenge/deny boundaries for a single verify.
|
|
166
|
+
|
|
167
|
+
- ``falseAllow``: cost of allowing abusive traffic
|
|
168
|
+
- ``falseChallenge``: cost of challenging legitimate traffic when allow
|
|
169
|
+
would have been fine (a challenge can still be appropriate for
|
|
170
|
+
legitimate users; this names the over-friction case)
|
|
171
|
+
- ``falseDeny``: cost of denying legitimate traffic
|
|
107
172
|
|
|
108
173
|
Higher ``falseAllow`` challenges earlier (abuse-averse); higher
|
|
109
174
|
``falseChallenge`` lets more of the gray zone through; higher
|
|
110
175
|
``falseDeny`` reserves deny for confirmed abuse. Defaults 1:1:3 keep
|
|
111
|
-
|
|
112
|
-
|
|
176
|
+
fail-to-challenge semantics; missing/non-positive fields fall back to
|
|
177
|
+
their default.
|
|
113
178
|
"""
|
|
114
179
|
|
|
115
180
|
falseAllow: float
|
|
@@ -122,15 +187,26 @@ class VerifyContext(TypedDict, total=False):
|
|
|
122
187
|
|
|
123
188
|
Unknown keys are carried (not scored); ``decisionCosts`` repositions
|
|
124
189
|
the allow/challenge/deny boundaries for this request.
|
|
190
|
+
|
|
191
|
+
``logOnly`` is an integration-side control: set it in server code (or
|
|
192
|
+
rely on env/key log-only policy). Never derive it from client-provided
|
|
193
|
+
request data. The verdict reports the effective mode as
|
|
194
|
+
``enforcementMode``.
|
|
125
195
|
"""
|
|
126
196
|
|
|
127
197
|
challengeId: str
|
|
128
198
|
challengePolicy: Literal["default", "confirmed_tail_only"]
|
|
199
|
+
logOnly: bool
|
|
129
200
|
decisionCosts: DecisionCosts
|
|
130
201
|
|
|
131
202
|
|
|
132
203
|
class VerifyParams(TypedDict, total=False):
|
|
133
|
-
"""Parameters for Client.verify.
|
|
204
|
+
"""Parameters for Client.verify.
|
|
205
|
+
|
|
206
|
+
``session`` is supplied by you: prefer a high-entropy, server-generated
|
|
207
|
+
id (binds challenge and verdictToken). Client-generated values are
|
|
208
|
+
attacker-influenced.
|
|
209
|
+
"""
|
|
134
210
|
|
|
135
211
|
idempotency_key: str
|
|
136
212
|
action: Action
|
|
@@ -146,13 +222,18 @@ class FeedbackParams(TypedDict, total=False):
|
|
|
146
222
|
event_id: str
|
|
147
223
|
outcome: Outcome
|
|
148
224
|
value: float
|
|
149
|
-
unit:
|
|
225
|
+
unit: FeedbackUnit
|
|
226
|
+
source: FeedbackSource
|
|
150
227
|
observed_at: str
|
|
151
228
|
idempotency_key: str
|
|
152
229
|
|
|
153
230
|
|
|
154
231
|
class ChallengeParams(TypedDict, total=False):
|
|
155
|
-
"""Parameters for Client.challenge (issue mode).
|
|
232
|
+
"""Parameters for Client.challenge (issue mode).
|
|
233
|
+
|
|
234
|
+
``session`` is a binding id you supply (prefer high-entropy,
|
|
235
|
+
server-generated). Same value across issue, complete, and re-verify.
|
|
236
|
+
"""
|
|
156
237
|
|
|
157
238
|
event_id: str
|
|
158
239
|
session: str
|
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
"""Client-side verdictToken verification (MVP-016).
|
|
2
2
|
|
|
3
3
|
A verdictToken is an ES256 JWT decision receipt minted by the edge and bound
|
|
4
|
-
to session (``sub``), origin hint (``aud``), issuing tenant (``tid``), and
|
|
5
|
-
``jti`` with a short ``exp
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
``
|
|
9
|
-
``
|
|
10
|
-
|
|
4
|
+
to session (``sub``), origin hint (``aud``), issuing tenant (``tid``), and a
|
|
5
|
+
unique ``jti`` (correlation id) with a short ``exp`` (5 minutes). Every signed
|
|
6
|
+
token requires a ``kid`` in the JWT header. Signature verification proves
|
|
7
|
+
authenticity and integrity; it does not make the JWT single-use. This module
|
|
8
|
+
always enforces ``session`` and ``tenant_id`` (security boundary), validates
|
|
9
|
+
``iss`` against the Chitmark issuer, and optionally ``aud``. ``aud`` reflects
|
|
10
|
+
request headers and is attacker influenced: never enforce ``aud`` alone.
|
|
11
|
+
Rejection reasons are surfaced as ``VerdictTokenError.code``.
|
|
12
|
+
|
|
13
|
+
JWKS: respect ``Cache-Control`` and, when present, ``ETag`` /
|
|
14
|
+
``Last-Modified`` via conditional requests (instead of a hard-coded
|
|
15
|
+
refresh interval). Refresh immediately on unknown ``kid``. Do not fetch on
|
|
16
|
+
every verification; never permanently pin a single signing key. Each JWKS
|
|
17
|
+
key is EC P-256 for ES256: require JWT ``alg == "ES256"`` before verifying
|
|
18
|
+
(``wrong_algorithm`` otherwise); never trust the token-requested algorithm.
|
|
19
|
+
Rotation overlaps old and new keys; the old key stays published until tokens under
|
|
20
|
+
it expire (keep ≥ 5 minutes + 60 seconds). Pass a cached ``jwks`` mapping
|
|
21
|
+
to skip the network call. An empty JWKS (``keys: []``) means no token can
|
|
22
|
+
be cryptographically verified: fail closed; never treat an empty set as
|
|
23
|
+
evidence that a token is valid (local/CI may emit ``unsigned.<eventId>``).
|
|
11
24
|
|
|
12
25
|
Requires the optional extras ``PyJWT`` and ``cryptography``
|
|
13
26
|
(``uv sync --extra verdict``).
|
|
@@ -42,14 +55,16 @@ class VerdictTokenError(ChitmarkError):
|
|
|
42
55
|
|
|
43
56
|
DEFAULT_JWKS_URL = "https://api.chitmark.com/.well-known/jwks.json"
|
|
44
57
|
DEFAULT_LEEWAY_SECONDS = 30
|
|
58
|
+
VERDICT_TOKEN_ISSUER = "https://api.chitmark.com"
|
|
45
59
|
|
|
46
60
|
|
|
47
61
|
def verify_verdict_token(
|
|
48
62
|
token: str,
|
|
49
63
|
*,
|
|
50
|
-
session: str
|
|
51
|
-
tenant_id: str
|
|
64
|
+
session: str,
|
|
65
|
+
tenant_id: str,
|
|
52
66
|
aud: str | None = None,
|
|
67
|
+
issuer: str = VERDICT_TOKEN_ISSUER,
|
|
53
68
|
jwks: Mapping[str, Any] | None = None,
|
|
54
69
|
jwks_url: str = DEFAULT_JWKS_URL,
|
|
55
70
|
http_client: httpx.Client | None = None,
|
|
@@ -59,10 +74,19 @@ def verify_verdict_token(
|
|
|
59
74
|
|
|
60
75
|
Args:
|
|
61
76
|
token: The ES256 JWT from a verify verdict.
|
|
62
|
-
session: Expected session; token ``sub`` must equal this.
|
|
63
|
-
tenant_id: Expected issuing tenant; token ``tid`` must equal this
|
|
77
|
+
session: Expected session; token ``sub`` must equal this (required).
|
|
78
|
+
tenant_id: Expected issuing tenant; token ``tid`` must equal this
|
|
79
|
+
(required).
|
|
64
80
|
aud: Expected origin hint; token ``aud`` must equal this when provided.
|
|
65
|
-
|
|
81
|
+
issuer: Expected ``iss``; defaults to ``https://api.chitmark.com``.
|
|
82
|
+
jwks: Pre-fetched/cached JWKS mapping; skips the network fetch.
|
|
83
|
+
Prefer caching via HTTP headers (``Cache-Control``, ``ETag``,
|
|
84
|
+
and ``Last-Modified`` when present) with conditional refresh
|
|
85
|
+
and immediate refresh on unknown ``kid``, rather than fetching
|
|
86
|
+
on every verification. Never permanently pin a single signing
|
|
87
|
+
key. Rotation overlaps old+new keys; keep the retiring key
|
|
88
|
+
until tokens under it expire (≥ 5 min TTL + 60s JWKS cache
|
|
89
|
+
skew).
|
|
66
90
|
jwks_url: JWKS endpoint; defaults to the production API.
|
|
67
91
|
http_client: Reusable ``httpx.Client`` for the JWKS fetch.
|
|
68
92
|
leeway_seconds: Clock-skew tolerance.
|
|
@@ -71,8 +95,8 @@ def verify_verdict_token(
|
|
|
71
95
|
VerdictTokenError: with a machine-readable ``code``:
|
|
72
96
|
``malformed_token``, ``wrong_algorithm``, ``wrong_type``,
|
|
73
97
|
``jwks_unavailable``, ``unknown_kid``, ``invalid_signature``,
|
|
74
|
-
``expired``, ``not_yet_valid``, ``
|
|
75
|
-
``wrong_tenant``, or ``wrong_aud``.
|
|
98
|
+
``expired``, ``not_yet_valid``, ``wrong_issuer``,
|
|
99
|
+
``wrong_session``, ``wrong_tenant``, or ``wrong_aud``.
|
|
76
100
|
"""
|
|
77
101
|
if pyjwt is None: # pragma: no cover
|
|
78
102
|
raise VerdictTokenError(
|
|
@@ -140,12 +164,17 @@ def verify_verdict_token(
|
|
|
140
164
|
code="malformed_token",
|
|
141
165
|
) from None
|
|
142
166
|
|
|
143
|
-
if
|
|
167
|
+
if claims.get("iss") != issuer:
|
|
168
|
+
raise VerdictTokenError(
|
|
169
|
+
"verdictToken was not issued by Chitmark",
|
|
170
|
+
code="wrong_issuer",
|
|
171
|
+
)
|
|
172
|
+
if claims.get("sub") != session:
|
|
144
173
|
raise VerdictTokenError(
|
|
145
174
|
"verdictToken was minted for a different session",
|
|
146
175
|
code="wrong_session",
|
|
147
176
|
)
|
|
148
|
-
if
|
|
177
|
+
if claims.get("tid") != tenant_id:
|
|
149
178
|
raise VerdictTokenError(
|
|
150
179
|
"verdictToken was minted for a different tenant",
|
|
151
180
|
code="wrong_tenant",
|
|
@@ -9,7 +9,11 @@ import pytest
|
|
|
9
9
|
import respx
|
|
10
10
|
|
|
11
11
|
from chitmark import Chitmark, ChitmarkError
|
|
12
|
-
from chitmark.client import
|
|
12
|
+
from chitmark.client import (
|
|
13
|
+
_degraded_challenge_verdict,
|
|
14
|
+
_enforce_degraded_never_allow,
|
|
15
|
+
is_eligible_allow,
|
|
16
|
+
)
|
|
13
17
|
from chitmark.pii import hash_email_sha256, truncate_ip
|
|
14
18
|
from chitmark.types import Verdict
|
|
15
19
|
|
|
@@ -45,6 +49,27 @@ def test_degraded_never_allow() -> None:
|
|
|
45
49
|
assert _enforce_degraded_never_allow(bad)["decision"] == "challenge"
|
|
46
50
|
|
|
47
51
|
|
|
52
|
+
def test_is_eligible_allow() -> None:
|
|
53
|
+
allow: Verdict = {
|
|
54
|
+
"decision": "allow",
|
|
55
|
+
"actorType": "human",
|
|
56
|
+
"confidence": 0.9,
|
|
57
|
+
"reasons": ["ok"],
|
|
58
|
+
"principal": {"resolved": False, "identityBasis": []},
|
|
59
|
+
"verdictToken": "tok",
|
|
60
|
+
"eventId": "evt_ok",
|
|
61
|
+
"degraded": False,
|
|
62
|
+
"enforcementMode": "enforce",
|
|
63
|
+
}
|
|
64
|
+
assert is_eligible_allow(allow) is True
|
|
65
|
+
|
|
66
|
+
degraded_allow = {**allow, "degraded": True}
|
|
67
|
+
assert is_eligible_allow(degraded_allow) is False
|
|
68
|
+
assert is_eligible_allow({**allow, "decision": "challenge"}) is False
|
|
69
|
+
assert is_eligible_allow({**allow, "decision": "deny"}) is False
|
|
70
|
+
assert is_eligible_allow({**allow, "decision": "weird"}) is False # type: ignore[typeddict-item]
|
|
71
|
+
|
|
72
|
+
|
|
48
73
|
def test_requires_api_key() -> None:
|
|
49
74
|
with pytest.raises(ChitmarkError, match="api_key"):
|
|
50
75
|
Chitmark(api_key="")
|
|
@@ -169,11 +169,13 @@ def test_feedback_sends_value_unit_and_observed_at() -> None:
|
|
|
169
169
|
event_id="evt_x",
|
|
170
170
|
outcome="credit_burn",
|
|
171
171
|
value=4.2,
|
|
172
|
+
source="usage_metering",
|
|
172
173
|
observed_at="2026-08-01T00:00:00Z",
|
|
173
174
|
)
|
|
174
175
|
sent = respx.calls[-1].request.content
|
|
175
176
|
assert b'"value":4.2' in sent
|
|
176
177
|
assert b'"unit":"usd"' in sent
|
|
178
|
+
assert b'"source":"usage_metering"' in sent
|
|
177
179
|
assert b'"observedAt":"2026-08-01T00:00:00Z"' in sent
|
|
178
180
|
|
|
179
181
|
|
|
@@ -92,6 +92,7 @@ def _mint(keys: dict, **overrides: object) -> str:
|
|
|
92
92
|
"decision": "allow",
|
|
93
93
|
"actorType": "human",
|
|
94
94
|
"confidence": 0.56,
|
|
95
|
+
"iss": "https://api.chitmark.com",
|
|
95
96
|
"aud": "https://app.acme.com",
|
|
96
97
|
"sub": "sess_token_a",
|
|
97
98
|
"tid": "org_unit",
|
|
@@ -114,12 +115,7 @@ def _jwks(keys: dict) -> dict:
|
|
|
114
115
|
def test_accepts_valid_signed_token(keys: dict) -> None:
|
|
115
116
|
token = _mint(keys, sub="sess_token_a", aud="https://app.acme.com")
|
|
116
117
|
|
|
117
|
-
claims = verify_verdict_token(
|
|
118
|
-
token,
|
|
119
|
-
session="sess_token_a",
|
|
120
|
-
aud="https://app.acme.com",
|
|
121
|
-
jwks=_jwks(keys),
|
|
122
|
-
)
|
|
118
|
+
claims = verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", aud="https://app.acme.com", jwks=_jwks(keys))
|
|
123
119
|
|
|
124
120
|
assert claims["eventId"] == "evt_token_test"
|
|
125
121
|
assert claims["decision"] == "allow"
|
|
@@ -133,7 +129,7 @@ def test_rejects_wrong_session(keys: dict) -> None:
|
|
|
133
129
|
token = _mint(keys, sub="sess_other")
|
|
134
130
|
|
|
135
131
|
with pytest.raises(VerdictTokenError) as exc:
|
|
136
|
-
verify_verdict_token(token, session="sess_token_a", jwks=_jwks(keys))
|
|
132
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
137
133
|
assert exc.value.code == "wrong_session"
|
|
138
134
|
|
|
139
135
|
|
|
@@ -141,7 +137,7 @@ def test_rejects_wrong_audience(keys: dict) -> None:
|
|
|
141
137
|
token = _mint(keys, aud="https://other.app")
|
|
142
138
|
|
|
143
139
|
with pytest.raises(VerdictTokenError) as exc:
|
|
144
|
-
verify_verdict_token(token, aud="https://app.acme.com", jwks=_jwks(keys))
|
|
140
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", aud="https://app.acme.com", jwks=_jwks(keys))
|
|
145
141
|
assert exc.value.code == "wrong_aud"
|
|
146
142
|
|
|
147
143
|
|
|
@@ -149,16 +145,22 @@ def test_rejects_wrong_tenant(keys: dict) -> None:
|
|
|
149
145
|
token = _mint(keys, tid="org_other")
|
|
150
146
|
|
|
151
147
|
with pytest.raises(VerdictTokenError) as exc:
|
|
152
|
-
verify_verdict_token(token, tenant_id="org_unit", jwks=_jwks(keys))
|
|
148
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
153
149
|
assert exc.value.code == "wrong_tenant"
|
|
154
150
|
|
|
155
151
|
|
|
152
|
+
def test_rejects_wrong_issuer(keys: dict) -> None:
|
|
153
|
+
token = _mint(keys, iss="https://evil.example")
|
|
154
|
+
|
|
155
|
+
with pytest.raises(VerdictTokenError) as exc:
|
|
156
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
157
|
+
assert exc.value.code == "wrong_issuer"
|
|
158
|
+
|
|
159
|
+
|
|
156
160
|
def test_accepts_expected_tenant(keys: dict) -> None:
|
|
157
161
|
token = _mint(keys, tid="org_unit", sub="sess_token_a")
|
|
158
162
|
|
|
159
|
-
claims = verify_verdict_token(
|
|
160
|
-
token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
161
|
-
)
|
|
163
|
+
claims = verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
162
164
|
assert claims["tid"] == "org_unit"
|
|
163
165
|
|
|
164
166
|
|
|
@@ -166,7 +168,7 @@ def test_rejects_expired_token(keys: dict) -> None:
|
|
|
166
168
|
token = _mint(keys, exp=int(time.time()) - 120)
|
|
167
169
|
|
|
168
170
|
with pytest.raises(VerdictTokenError) as exc:
|
|
169
|
-
verify_verdict_token(token, jwks=_jwks(keys))
|
|
171
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
170
172
|
assert exc.value.code == "expired"
|
|
171
173
|
|
|
172
174
|
|
|
@@ -174,7 +176,7 @@ def test_rejects_unknown_kid(keys: dict) -> None:
|
|
|
174
176
|
token = _mint(keys)
|
|
175
177
|
|
|
176
178
|
with pytest.raises(VerdictTokenError) as exc:
|
|
177
|
-
verify_verdict_token(token, jwks={"keys": []})
|
|
179
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": []})
|
|
178
180
|
assert exc.value.code == "unknown_kid"
|
|
179
181
|
|
|
180
182
|
|
|
@@ -187,7 +189,7 @@ def test_rejects_tampered_signature(keys: dict) -> None:
|
|
|
187
189
|
tampered = f"{header}.{payload}.{tampered_sig}"
|
|
188
190
|
|
|
189
191
|
with pytest.raises(VerdictTokenError) as exc:
|
|
190
|
-
verify_verdict_token(tampered, jwks=_jwks(keys))
|
|
192
|
+
verify_verdict_token(tampered, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
191
193
|
assert exc.value.code == "invalid_signature"
|
|
192
194
|
|
|
193
195
|
|
|
@@ -198,7 +200,7 @@ def test_rejects_wrong_algorithm(keys: dict) -> None:
|
|
|
198
200
|
wrong = f"{wrong_header}.{payload}.{signature}"
|
|
199
201
|
|
|
200
202
|
with pytest.raises(VerdictTokenError) as exc:
|
|
201
|
-
verify_verdict_token(wrong, jwks=_jwks(keys))
|
|
203
|
+
verify_verdict_token(wrong, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
202
204
|
assert exc.value.code == "wrong_algorithm"
|
|
203
205
|
|
|
204
206
|
|
|
@@ -209,13 +211,13 @@ def test_rejects_missing_jwt_typ(keys: dict) -> None:
|
|
|
209
211
|
wrong = f"{wrong_header}.{payload}.{signature}"
|
|
210
212
|
|
|
211
213
|
with pytest.raises(VerdictTokenError) as exc:
|
|
212
|
-
verify_verdict_token(wrong, jwks=_jwks(keys))
|
|
214
|
+
verify_verdict_token(wrong, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
213
215
|
assert exc.value.code == "wrong_type"
|
|
214
216
|
|
|
215
217
|
|
|
216
218
|
def test_rejects_malformed_token() -> None:
|
|
217
219
|
with pytest.raises(VerdictTokenError) as exc:
|
|
218
|
-
verify_verdict_token("not-a-jwt", jwks={"keys": []})
|
|
220
|
+
verify_verdict_token("not-a-jwt", session="sess_token_a", tenant_id="org_unit", jwks={"keys": []})
|
|
219
221
|
assert exc.value.code == "malformed_token"
|
|
220
222
|
|
|
221
223
|
|
|
@@ -226,7 +228,7 @@ def test_fetches_jwks_from_url(keys: dict) -> None:
|
|
|
226
228
|
return_value=httpx.Response(200, json=_jwks(keys)),
|
|
227
229
|
)
|
|
228
230
|
|
|
229
|
-
claims = verify_verdict_token(token, session="sess_fetch")
|
|
231
|
+
claims = verify_verdict_token(token, session="sess_fetch", tenant_id="org_unit")
|
|
230
232
|
|
|
231
233
|
assert route.called
|
|
232
234
|
assert claims["sub"] == "sess_fetch"
|
|
@@ -240,14 +242,14 @@ def test_jwks_fetch_failure_is_surfaced(keys: dict) -> None:
|
|
|
240
242
|
)
|
|
241
243
|
|
|
242
244
|
with pytest.raises(VerdictTokenError) as exc:
|
|
243
|
-
verify_verdict_token(token)
|
|
245
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
|
|
244
246
|
assert exc.value.code == "jwks_unavailable"
|
|
245
247
|
|
|
246
248
|
|
|
247
249
|
def test_accepts_degraded_receipt(keys: dict) -> None:
|
|
248
250
|
token = _mint(keys, decision="challenge", degraded=True)
|
|
249
251
|
|
|
250
|
-
claims = verify_verdict_token(token, jwks=_jwks(keys))
|
|
252
|
+
claims = verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
251
253
|
|
|
252
254
|
assert claims["decision"] == "challenge"
|
|
253
255
|
assert claims["degraded"] is True
|
|
@@ -89,6 +89,7 @@ def _mint(keys: dict, **overrides: object) -> str:
|
|
|
89
89
|
claims = {
|
|
90
90
|
"iss": "https://api.chitmark.com",
|
|
91
91
|
"sub": "sess_token_a",
|
|
92
|
+
"tid": "org_unit",
|
|
92
93
|
"aud": "https://api.chitmark.com",
|
|
93
94
|
"iat": now,
|
|
94
95
|
"exp": now + 300,
|
|
@@ -125,38 +126,38 @@ def _jwks(keys: dict) -> dict:
|
|
|
125
126
|
def test_rejects_missing_or_empty_kid(keys: dict) -> None:
|
|
126
127
|
token = _mint(keys, _header_remove=["kid"])
|
|
127
128
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
128
|
-
verify_verdict_token(token, jwks=_jwks(keys))
|
|
129
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
129
130
|
assert excinfo.value.code == "unknown_kid"
|
|
130
131
|
|
|
131
132
|
token2 = _mint(keys, _header={"kid": ""})
|
|
132
133
|
with pytest.raises(VerdictTokenError) as excinfo2:
|
|
133
|
-
verify_verdict_token(token2, jwks=_jwks(keys))
|
|
134
|
+
verify_verdict_token(token2, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
134
135
|
assert excinfo2.value.code == "unknown_kid"
|
|
135
136
|
|
|
136
137
|
token3 = _mint(keys, _header={"kid": 123})
|
|
137
138
|
with pytest.raises(VerdictTokenError) as excinfo3:
|
|
138
|
-
verify_verdict_token(token3, jwks=_jwks(keys))
|
|
139
|
+
verify_verdict_token(token3, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
139
140
|
assert excinfo3.value.code == "malformed_token"
|
|
140
141
|
|
|
141
142
|
|
|
142
143
|
def test_rejects_future_iat(keys: dict) -> None:
|
|
143
144
|
token = _mint(keys, iat=int(time.time()) + 3600)
|
|
144
145
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
145
|
-
verify_verdict_token(token, jwks=_jwks(keys))
|
|
146
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
146
147
|
assert excinfo.value.code == "not_yet_valid"
|
|
147
148
|
|
|
148
149
|
|
|
149
150
|
def test_rejects_undecodable_payload(keys: dict) -> None:
|
|
150
151
|
token = _mint(keys, _raw_payload=b"hello")
|
|
151
152
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
152
|
-
verify_verdict_token(token, jwks=_jwks(keys))
|
|
153
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
|
|
153
154
|
assert excinfo.value.code == "malformed_token"
|
|
154
155
|
|
|
155
156
|
|
|
156
157
|
def test_rejects_malformed_header() -> None:
|
|
157
158
|
token = "a.b.c"
|
|
158
159
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
159
|
-
verify_verdict_token(token, jwks={"keys": []})
|
|
160
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": []})
|
|
160
161
|
assert excinfo.value.code == "malformed_token"
|
|
161
162
|
|
|
162
163
|
|
|
@@ -170,7 +171,7 @@ def test_rejects_jwk_with_bad_curve_data(keys: dict) -> None:
|
|
|
170
171
|
}
|
|
171
172
|
token = _mint(keys)
|
|
172
173
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
173
|
-
verify_verdict_token(token, jwks={"keys": [bad]})
|
|
174
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": [bad]})
|
|
174
175
|
assert excinfo.value.code == "invalid_signature"
|
|
175
176
|
|
|
176
177
|
|
|
@@ -181,7 +182,7 @@ def test_jwks_fetch_network_error(keys: dict) -> None:
|
|
|
181
182
|
)
|
|
182
183
|
token = _mint(keys)
|
|
183
184
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
184
|
-
verify_verdict_token(token)
|
|
185
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
|
|
185
186
|
assert excinfo.value.code == "jwks_unavailable"
|
|
186
187
|
|
|
187
188
|
|
|
@@ -192,7 +193,7 @@ def test_jwks_fetch_non_200(keys: dict) -> None:
|
|
|
192
193
|
)
|
|
193
194
|
token = _mint(keys)
|
|
194
195
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
195
|
-
verify_verdict_token(token)
|
|
196
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
|
|
196
197
|
assert excinfo.value.code == "jwks_unavailable"
|
|
197
198
|
|
|
198
199
|
|
|
@@ -203,7 +204,7 @@ def test_jwks_fetch_invalid_json(keys: dict) -> None:
|
|
|
203
204
|
)
|
|
204
205
|
token = _mint(keys)
|
|
205
206
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
206
|
-
verify_verdict_token(token)
|
|
207
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
|
|
207
208
|
assert excinfo.value.code == "jwks_unavailable"
|
|
208
209
|
|
|
209
210
|
|
|
@@ -214,7 +215,7 @@ def test_jwks_fetch_with_reusable_client(keys: dict) -> None:
|
|
|
214
215
|
)
|
|
215
216
|
client = httpx.Client()
|
|
216
217
|
try:
|
|
217
|
-
claims = verify_verdict_token(_mint(keys), http_client=client)
|
|
218
|
+
claims = verify_verdict_token(_mint(keys), session="sess_token_a", tenant_id="org_unit", http_client=client)
|
|
218
219
|
assert claims["jti"] == "jwt_1"
|
|
219
220
|
assert client.is_closed is False
|
|
220
221
|
finally:
|
|
@@ -224,19 +225,14 @@ def test_jwks_fetch_with_reusable_client(keys: dict) -> None:
|
|
|
224
225
|
def test_jwks_mapping_and_keys_list_shapes(keys: dict) -> None:
|
|
225
226
|
token = _mint(keys)
|
|
226
227
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
227
|
-
verify_verdict_token(token, jwks=["not", "a", "mapping"])
|
|
228
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=["not", "a", "mapping"])
|
|
228
229
|
assert excinfo.value.code == "jwks_unavailable"
|
|
229
230
|
|
|
230
231
|
with pytest.raises(VerdictTokenError) as excinfo2:
|
|
231
|
-
verify_verdict_token(token, jwks={"nokeys": True})
|
|
232
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks={"nokeys": True})
|
|
232
233
|
assert excinfo2.value.code == "jwks_unavailable"
|
|
233
234
|
|
|
234
235
|
|
|
235
236
|
def test_accepts_valid_token_with_explicit_audience(keys: dict) -> None:
|
|
236
|
-
claims = verify_verdict_token(
|
|
237
|
-
_mint(keys),
|
|
238
|
-
session="sess_token_a",
|
|
239
|
-
aud="https://api.chitmark.com",
|
|
240
|
-
jwks=_jwks(keys),
|
|
241
|
-
)
|
|
237
|
+
claims = verify_verdict_token(_mint(keys), session="sess_token_a", tenant_id="org_unit", aud="https://api.chitmark.com", jwks=_jwks(keys))
|
|
242
238
|
assert claims["v"] == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|