regent-httpsig 0.5.0__tar.gz → 0.5.1__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.
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/CHANGELOG.md +23 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/PKG-INFO +6 -1
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/README.md +5 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/pyproject.toml +1 -1
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/fastapi.py +40 -2
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/sfv.py +10 -5
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/verify.py +35 -4
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_budget.py +71 -2
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/.github/workflows/ci.yml +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/.github/workflows/release.yml +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/.gitignore +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/LICENSE +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/SECURITY.md +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/examples/fastapi_verify.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/examples/httpx_signer.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/__init__.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/budget.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/cli.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/config.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/jwk.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/netguard.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/sign.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/usage.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_netguard.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_signer.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_usage.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_vectors.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_verifier.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/vectors/aauth-budgets-vectors.json +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/vectors/generate.py +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
**Budgets — the final-record challenge for expired auth tokens** (draft
|
|
6
|
+
§Budget Exhaustion "auth token expired" + §Settlement). The final/snapshot
|
|
7
|
+
settlement rule needs a moment at which the resource *states* a token's final
|
|
8
|
+
figure — and that moment is the challenge to an expired token. A verifier that
|
|
9
|
+
refuses to look at an expired token can never build it, so the issuer never
|
|
10
|
+
sees a final record and accounts every allocation as fully consumed forever.
|
|
11
|
+
|
|
12
|
+
- `BudgetMiddleware` answers a genuine-but-expired `aa-auth+jwt` (issuer
|
|
13
|
+
signature and proof of possession verified, request signature fresh) with
|
|
14
|
+
`401` + plain `AAuth-Requirement: requirement=auth-token;resource-token=…`
|
|
15
|
+
(no `reason` — the budget didn't run out, the token did) and `code:
|
|
16
|
+
AUTH_TOKEN_EXPIRED`. The resource token carries the presented token's
|
|
17
|
+
`{jti, consumed}`; no `AAuth-Budget` header. Nothing is served or metered.
|
|
18
|
+
- `HttpsigVerifier.verify(..., allow_expired_auth_token=True)` — **opt-in,
|
|
19
|
+
default False**: returns the token with `VerifiedSignature.expired=True`
|
|
20
|
+
for up to `EXPIRED_AUTH_TOKEN_GRACE` (2h, the meter's retention) after
|
|
21
|
+
`exp`. Access decisions through `verify()`/`require_signature` are
|
|
22
|
+
unchanged: an expired token is still `None` there, and the middleware never
|
|
23
|
+
caches an expired result for downstream dependencies.
|
|
24
|
+
- `build_aauth_requirement(reason=None, …)` emits the reason-less challenge.
|
|
25
|
+
|
|
3
26
|
## 0.5.0
|
|
4
27
|
|
|
5
28
|
**AAuth Budgets — per-token cap, one consumption record** (draft-hardt-aauth-budgets
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: regent-httpsig
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth.
|
|
5
5
|
Project-URL: Homepage, https://github.com/regent-protocol/regent-httpsig
|
|
6
6
|
Project-URL: Repository, https://github.com/regent-protocol/regent-httpsig
|
|
@@ -174,6 +174,11 @@ production on [get4agent.com](https://get4agent.com).
|
|
|
174
174
|
|
|
175
175
|
- `insufficient-budget` refusals carry **`required`** — the refused request's
|
|
176
176
|
maximum cost — so the agent lowers its bound and retries instead of guessing.
|
|
177
|
+
- An **expired** auth token gets the base protocol's plain challenge with a
|
|
178
|
+
resource token carrying that token's **final** consumption record — the
|
|
179
|
+
figure its issuer needs to settle the allocation (a record stated at or
|
|
180
|
+
after `exp` is final; one on a live token is a snapshot and releases
|
|
181
|
+
nothing).
|
|
177
182
|
- **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
|
|
178
183
|
the header, commit when the stream ends (set `request.state.budget_cost`
|
|
179
184
|
mid-stream if you learn the actual), and the agent recovers the exact cost
|
|
@@ -144,6 +144,11 @@ production on [get4agent.com](https://get4agent.com).
|
|
|
144
144
|
|
|
145
145
|
- `insufficient-budget` refusals carry **`required`** — the refused request's
|
|
146
146
|
maximum cost — so the agent lowers its bound and retries instead of guessing.
|
|
147
|
+
- An **expired** auth token gets the base protocol's plain challenge with a
|
|
148
|
+
resource token carrying that token's **final** consumption record — the
|
|
149
|
+
figure its issuer needs to settle the allocation (a record stated at or
|
|
150
|
+
after `exp` is final; one on a live token is a snapshot and releases
|
|
151
|
+
nothing).
|
|
147
152
|
- **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
|
|
148
153
|
the header, commit when the stream ends (set `request.state.budget_cost`
|
|
149
154
|
mid-stream if you learn the actual), and the agent recovers the exact cost
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "regent-httpsig"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.1"
|
|
8
8
|
description = "Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -191,6 +191,13 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
191
191
|
return await call_next(request)
|
|
192
192
|
|
|
193
193
|
sig = await self._verified(request)
|
|
194
|
+
if sig is not None and sig.expired:
|
|
195
|
+
# §Budget Exhaustion, "auth token expired": the base protocol's plain
|
|
196
|
+
# 401 — and the resource token we attach carries the presented
|
|
197
|
+
# token's FINAL consumption record (stated at/after its exp), which
|
|
198
|
+
# is what lets its issuer settle the allocation exactly. Without
|
|
199
|
+
# this challenge an issuer never sees a final figure at all.
|
|
200
|
+
return await self._expired_challenge(sig)
|
|
194
201
|
envelope: BudgetClaim | None = None
|
|
195
202
|
if sig is not None:
|
|
196
203
|
try:
|
|
@@ -307,11 +314,42 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
307
314
|
result = None
|
|
308
315
|
if "signature" in request.headers:
|
|
309
316
|
result = await self._verifier.verify(
|
|
310
|
-
request.method, _public_url(request), dict(request.headers)
|
|
317
|
+
request.method, _public_url(request), dict(request.headers),
|
|
318
|
+
allow_expired_auth_token=True,
|
|
311
319
|
)
|
|
312
|
-
|
|
320
|
+
# An expired token is never handed to the app: the middleware answers
|
|
321
|
+
# it with the final-record challenge and nothing downstream runs. The
|
|
322
|
+
# per-request cache still must not carry it — a later dependency
|
|
323
|
+
# reading the cache would otherwise see a token that is not valid.
|
|
324
|
+
request.state.regent_httpsig_result = (
|
|
325
|
+
None if result is not None and result.expired else result)
|
|
313
326
|
return result
|
|
314
327
|
|
|
328
|
+
async def _expired_challenge(self, sig: VerifiedSignature) -> Response:
|
|
329
|
+
jti = str(sig.claims.get("jti") or "")
|
|
330
|
+
key: MeterKey = (
|
|
331
|
+
str(sig.claims.get("iss", "")),
|
|
332
|
+
str(sig.claims.get("sub", "")),
|
|
333
|
+
str(sig.claims.get("aud", "")),
|
|
334
|
+
)
|
|
335
|
+
token: str | None = None
|
|
336
|
+
if self._resource_token is not None and jti:
|
|
337
|
+
try:
|
|
338
|
+
record = await self._meter.consumed_record(key, jti)
|
|
339
|
+
token = await _maybe_await(self._resource_token(key, record))
|
|
340
|
+
except Exception: # noqa: BLE001 — the challenge must not fail on the extras
|
|
341
|
+
logger.warning("resource_token_provider failed", exc_info=True)
|
|
342
|
+
return JSONResponse(
|
|
343
|
+
status_code=401,
|
|
344
|
+
content={
|
|
345
|
+
"code": "AUTH_TOKEN_EXPIRED",
|
|
346
|
+
"message": "The auth token has expired. Take the resource token in "
|
|
347
|
+
"AAuth-Requirement to your PS for a fresh one.",
|
|
348
|
+
},
|
|
349
|
+
headers={"AAuth-Requirement": build_aauth_requirement(
|
|
350
|
+
reason=None, resource_token=token)},
|
|
351
|
+
)
|
|
352
|
+
|
|
315
353
|
async def _refusal_with_token(
|
|
316
354
|
self, *, reason: str, envelope: BudgetClaim,
|
|
317
355
|
remaining: int, key: MeterKey, jti: str,
|
|
@@ -176,13 +176,18 @@ def build_aauth_budget_header(
|
|
|
176
176
|
return ", ".join(members)
|
|
177
177
|
|
|
178
178
|
|
|
179
|
-
def build_aauth_requirement(*, reason: str
|
|
180
|
-
|
|
179
|
+
def build_aauth_requirement(*, reason: str | None,
|
|
180
|
+
resource_token: str | None = None) -> str:
|
|
181
|
+
"""Serialize ``AAuth-Requirement`` for a budget challenge:
|
|
181
182
|
``requirement=auth-token;resource-token="eyJ…";reason=insufficient-budget``.
|
|
182
|
-
``reason`` is an sf-token (``insufficient-budget`` | ``budget-exhausted``)
|
|
183
|
-
|
|
183
|
+
``reason`` is an sf-token (``insufficient-budget`` | ``budget-exhausted``)
|
|
184
|
+
for an exhaustion refusal, or ``None`` for the base protocol's plain
|
|
185
|
+
challenge (an EXPIRED auth token — the budget did not run out, the token
|
|
186
|
+
did); the resource token (when the resource issues one) rides as an
|
|
187
|
+
sf-string."""
|
|
184
188
|
out = "requirement=auth-token"
|
|
185
189
|
if resource_token:
|
|
186
190
|
out += f";resource-token={_sf_string(resource_token)}"
|
|
187
|
-
|
|
191
|
+
if reason:
|
|
192
|
+
out += f";reason={reason}"
|
|
188
193
|
return out
|
|
@@ -61,6 +61,10 @@ AAUTH_PERSON_TYP = "aa-person+jwt"
|
|
|
61
61
|
AAUTH_AUTH_TYP = "aa-auth+jwt" # PS-issued auth tokens — the budget carrier
|
|
62
62
|
# -11: person and auth tokens live at most one hour — enforced with tolerance.
|
|
63
63
|
PERSON_TOKEN_MAX_LIFETIME = 3600 + 90
|
|
64
|
+
# How long after its exp an auth token still earns the budgets "final record"
|
|
65
|
+
# challenge (opt-in, BudgetMiddleware only): the meter retains consumption for
|
|
66
|
+
# about this long after last activity; beyond it there is no figure to carry.
|
|
67
|
+
EXPIRED_AUTH_TOKEN_GRACE = 7200
|
|
64
68
|
|
|
65
69
|
|
|
66
70
|
def _register_fully_specified_algs() -> None:
|
|
@@ -86,6 +90,11 @@ class VerifiedSignature:
|
|
|
86
90
|
sub: str | None = None # AAuth agent id (token `sub`)
|
|
87
91
|
label: str = ""
|
|
88
92
|
claims: dict[str, Any] = field(default_factory=dict) # AAuth token claims (redacted)
|
|
93
|
+
# Set only when ``verify(..., allow_expired_auth_token=True)`` accepted an
|
|
94
|
+
# auth token PAST its exp: genuine (issuer signature + proof of possession
|
|
95
|
+
# verified) but NOT valid for access. BudgetMiddleware uses it to answer
|
|
96
|
+
# with the final-record challenge; nothing else should ever see it True.
|
|
97
|
+
expired: bool = False
|
|
89
98
|
|
|
90
99
|
def context(self) -> dict[str, Any]:
|
|
91
100
|
"""A flat dict suitable for logging / policy engines / audit trails."""
|
|
@@ -164,18 +173,28 @@ class HttpsigVerifier:
|
|
|
164
173
|
self._cache: dict[str, tuple[float, dict[str, Any] | None]] = {}
|
|
165
174
|
|
|
166
175
|
async def verify(
|
|
167
|
-
self, method: str, url: str, headers: Mapping[str, str]
|
|
176
|
+
self, method: str, url: str, headers: Mapping[str, str],
|
|
177
|
+
*, allow_expired_auth_token: bool = False,
|
|
168
178
|
) -> VerifiedSignature | None:
|
|
169
179
|
"""Verify the request's agent signature. Returns ``None`` when there is
|
|
170
180
|
no ``Signature`` header, the signature is invalid, or the signer's keys
|
|
171
|
-
cannot be (safely) fetched — never raises on untrusted input.
|
|
181
|
+
cannot be (safely) fetched — never raises on untrusted input.
|
|
182
|
+
|
|
183
|
+
``allow_expired_auth_token`` (default False — leave it so for access
|
|
184
|
+
decisions) lets a genuine ``aa-auth+jwt`` past its ``exp`` come back
|
|
185
|
+
with ``expired=True`` instead of ``None``, for up to
|
|
186
|
+
:data:`EXPIRED_AUTH_TOKEN_GRACE` seconds. Budgets need this: the
|
|
187
|
+
resource owes the holder of an expired token a challenge carrying that
|
|
188
|
+
token's final consumption record (draft §Budget Exhaustion), and it
|
|
189
|
+
cannot build one for a token it refused to look at."""
|
|
172
190
|
hdrs = {str(k): str(v) for k, v in headers.items()}
|
|
173
191
|
if not any(k.lower() == "signature" for k in hdrs):
|
|
174
192
|
return None
|
|
175
193
|
result: VerifiedSignature | None = None
|
|
176
194
|
try:
|
|
177
195
|
if any(k.lower() == "signature-key" for k in hdrs):
|
|
178
|
-
result = await self._verify_aauth(
|
|
196
|
+
result = await self._verify_aauth(
|
|
197
|
+
method, url, hdrs, allow_expired_auth_token=allow_expired_auth_token)
|
|
179
198
|
if result is None:
|
|
180
199
|
result = await self._verify_web_bot_auth(method, url, hdrs)
|
|
181
200
|
except Exception as exc: # noqa: BLE001 — belt and braces
|
|
@@ -288,7 +307,8 @@ class HttpsigVerifier:
|
|
|
288
307
|
# ── AAuth (identity-based mode) ──────────────────────────────────────────
|
|
289
308
|
|
|
290
309
|
async def _verify_aauth(
|
|
291
|
-
self, method: str, url: str, headers: dict[str, str]
|
|
310
|
+
self, method: str, url: str, headers: dict[str, str],
|
|
311
|
+
*, allow_expired_auth_token: bool = False,
|
|
292
312
|
) -> VerifiedSignature | None:
|
|
293
313
|
try:
|
|
294
314
|
import jwt as pyjwt # the [aauth] extra
|
|
@@ -387,6 +407,7 @@ class HttpsigVerifier:
|
|
|
387
407
|
continue
|
|
388
408
|
if issuer_key is None:
|
|
389
409
|
return None
|
|
410
|
+
tolerate_exp = allow_expired_auth_token and typ == AAUTH_AUTH_TYP
|
|
390
411
|
try:
|
|
391
412
|
claims = pyjwt.decode(
|
|
392
413
|
token,
|
|
@@ -396,11 +417,20 @@ class HttpsigVerifier:
|
|
|
396
417
|
options={
|
|
397
418
|
"require": ["iss", "sub", "exp", "iat"],
|
|
398
419
|
"verify_aud": audience is not None,
|
|
420
|
+
"verify_exp": not tolerate_exp,
|
|
399
421
|
},
|
|
400
422
|
)
|
|
401
423
|
except Exception as exc: # noqa: BLE001
|
|
402
424
|
logger.info("aauth token invalid iss=%s: %s", iss, str(exc)[:200])
|
|
403
425
|
return None
|
|
426
|
+
expired = False
|
|
427
|
+
if tolerate_exp:
|
|
428
|
+
overdue = int(time.time()) - int(claims.get("exp", 0))
|
|
429
|
+
expired = overdue >= 0
|
|
430
|
+
if overdue > EXPIRED_AUTH_TOKEN_GRACE:
|
|
431
|
+
logger.info("aauth auth token expired %ss ago — past grace iss=%s",
|
|
432
|
+
overdue, iss)
|
|
433
|
+
return None
|
|
404
434
|
|
|
405
435
|
# -11: person and auth tokens live at most one hour.
|
|
406
436
|
if typ in (AAUTH_PERSON_TYP, AAUTH_AUTH_TYP):
|
|
@@ -456,4 +486,5 @@ class HttpsigVerifier:
|
|
|
456
486
|
"budget") # budgets: the envelope rides in the token
|
|
457
487
|
if k in claims
|
|
458
488
|
},
|
|
489
|
+
expired=expired,
|
|
459
490
|
)
|
|
@@ -266,11 +266,13 @@ def _ps_pair() -> tuple[Ed25519PrivateKey, dict[str, Any]]:
|
|
|
266
266
|
|
|
267
267
|
|
|
268
268
|
def _auth_token(ps_priv: Ed25519PrivateKey, agent: EgressSigner, *,
|
|
269
|
-
amount: int, jti: str = "at-1") -> str:
|
|
269
|
+
amount: int, jti: str = "at-1", age: int = 0) -> str:
|
|
270
|
+
"""``age`` > 600 mints a token that has ALREADY expired (iat/exp shifted
|
|
271
|
+
into the past) — genuine, possession-bound, not valid for access."""
|
|
270
272
|
from regent_httpsig.verify import _register_fully_specified_algs
|
|
271
273
|
|
|
272
274
|
_register_fully_specified_algs() # PyJWT knows "Ed25519" only after this
|
|
273
|
-
now = int(time.time())
|
|
275
|
+
now = int(time.time()) - age
|
|
274
276
|
return pyjwt.encode(
|
|
275
277
|
{
|
|
276
278
|
"iss": PS_ISS, "sub": "owner-1", "aud": RESOURCE, "jti": jti,
|
|
@@ -499,3 +501,70 @@ async def test_streaming_cost_omitted_reserved_math(
|
|
|
499
501
|
next_remaining = int(str(d2["remaining"].value)) + 300 # add back r2's own cost
|
|
500
502
|
recovered = 1000 + 300 - 700 - (1000 - next_remaining) # draft's subtraction…
|
|
501
503
|
assert 700 + 300 - next_remaining == 120 # …prev + reserved − next = cost
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
async def test_expired_token_gets_final_record_challenge(
|
|
507
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
508
|
+
) -> None:
|
|
509
|
+
"""§Budget Exhaustion, "auth token expired": plain requirement=auth-token
|
|
510
|
+
(no reason) + a resource token carrying the token's FINAL record — the
|
|
511
|
+
figure the issuer needs to settle the allocation (§Settlement: a record
|
|
512
|
+
stated at/after exp is final)."""
|
|
513
|
+
ps_priv, ps_jwk = _ps_pair()
|
|
514
|
+
agent = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
|
|
515
|
+
live = _auth_token(ps_priv, agent, amount=1000, jti="at-9")
|
|
516
|
+
provider_calls: list[Any] = []
|
|
517
|
+
|
|
518
|
+
def provider(key: Any, record: Any) -> str:
|
|
519
|
+
provider_calls.append((key, record))
|
|
520
|
+
return "resource.token.final"
|
|
521
|
+
|
|
522
|
+
meter = InMemoryMeter()
|
|
523
|
+
app = _app(_verifier(ps_jwk, monkeypatch), meter=meter,
|
|
524
|
+
resource_token_provider=provider)
|
|
525
|
+
# Spend 300 on the live token, so there is a figure to carry.
|
|
526
|
+
r = await _post(app, "/v1/search", _signed_headers(agent, live, "/v1/search"))
|
|
527
|
+
assert r.status_code == 200
|
|
528
|
+
|
|
529
|
+
# The same jti, now past its exp (same issuer, same key, same claims).
|
|
530
|
+
stale = _auth_token(ps_priv, agent, amount=1000, jti="at-9", age=700)
|
|
531
|
+
r = await _post(app, "/v1/search", _signed_headers(agent, stale, "/v1/search"))
|
|
532
|
+
assert r.status_code == 401
|
|
533
|
+
assert r.json()["code"] == "AUTH_TOKEN_EXPIRED"
|
|
534
|
+
assert (r.headers["AAuth-Requirement"]
|
|
535
|
+
== 'requirement=auth-token;resource-token="resource.token.final"')
|
|
536
|
+
assert "AAuth-Budget" not in r.headers # the budget didn't run out; the token did
|
|
537
|
+
assert provider_calls[-1] == ((PS_ISS, "owner-1", RESOURCE),
|
|
538
|
+
{"jti": "at-9", "consumed": 300})
|
|
539
|
+
# Nothing was served and nothing more was metered.
|
|
540
|
+
assert await meter.consumed_record(
|
|
541
|
+
(PS_ISS, "owner-1", RESOURCE), "at-9") == {"jti": "at-9", "consumed": 300}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
async def test_expired_token_past_grace_is_no_envelope(
|
|
545
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
546
|
+
) -> None:
|
|
547
|
+
"""Beyond the grace window there is no figure left to carry: the token is
|
|
548
|
+
just an unknown one and the request falls through as unsigned."""
|
|
549
|
+
ps_priv, ps_jwk = _ps_pair()
|
|
550
|
+
agent = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
|
|
551
|
+
ancient = _auth_token(ps_priv, agent, amount=1000, jti="at-old", age=3 * 3600)
|
|
552
|
+
app = _app(_verifier(ps_jwk, monkeypatch), require=True)
|
|
553
|
+
r = await _post(app, "/v1/search", _signed_headers(agent, ancient, "/v1/search"))
|
|
554
|
+
assert r.status_code == 401 and r.json()["code"] == "AUTH_TOKEN_REQUIRED"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
async def test_plain_verify_still_rejects_expired_tokens(
|
|
558
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
559
|
+
) -> None:
|
|
560
|
+
"""The tolerance is opt-in for the budgets middleware only: an access
|
|
561
|
+
decision through ``verify()`` never sees an expired token as verified."""
|
|
562
|
+
ps_priv, ps_jwk = _ps_pair()
|
|
563
|
+
agent = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
|
|
564
|
+
stale = _auth_token(ps_priv, agent, amount=1000, jti="at-9", age=700)
|
|
565
|
+
verifier = _verifier(ps_jwk, monkeypatch)
|
|
566
|
+
headers = _signed_headers(agent, stale, "/v1/search")
|
|
567
|
+
assert await verifier.verify("POST", f"{RESOURCE}/v1/search", headers) is None
|
|
568
|
+
sig = await verifier.verify("POST", f"{RESOURCE}/v1/search", headers,
|
|
569
|
+
allow_expired_auth_token=True)
|
|
570
|
+
assert sig is not None and sig.expired
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|