Skip to content

Commit 040f4db

Browse files
committed
budgets 0.5.1: final-record challenge for expired auth tokens
§Budget Exhaustion 'auth token expired' + §Settlement: a genuine-but- expired aa-auth+jwt (issuer signature + PoP verified, request fresh) now gets 401 + plain requirement=auth-token (no reason) with a resource token carrying the presented token's final {jti, consumed}. Without this challenge the issuer never sees a final figure and accounts every allocation as fully consumed forever. verify(..., allow_expired_auth_token=True) is opt-in (default False) and bounded by EXPIRED_AUTH_TOKEN_GRACE (2h); access decisions are unchanged and the middleware never caches an expired result. 83 tests.
1 parent 1c4d2a0 commit 040f4db

7 files changed

Lines changed: 185 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
# Changelog
22

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+
326
## 0.5.0
427

528
**AAuth Budgets — per-token cap, one consumption record** (draft-hardt-aauth-budgets

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,11 @@ production on [get4agent.com](https://get4agent.com).
144144

145145
- `insufficient-budget` refusals carry **`required`** — the refused request's
146146
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).
147152
- **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
148153
the header, commit when the stream ends (set `request.state.budget_cost`
149154
mid-stream if you learn the actual), and the agent recovers the exact cost

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "regent-httpsig"
7-
version = "0.5.0"
7+
version = "0.5.1"
88
description = "Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth."
99
readme = "README.md"
1010
license = "Apache-2.0"

src/regent_httpsig/fastapi.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,13 @@ async def dispatch(
191191
return await call_next(request)
192192

193193
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)
194201
envelope: BudgetClaim | None = None
195202
if sig is not None:
196203
try:
@@ -307,11 +314,42 @@ async def _verified(self, request: Request) -> VerifiedSignature | None:
307314
result = None
308315
if "signature" in request.headers:
309316
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,
311319
)
312-
request.state.regent_httpsig_result = result
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)
313326
return result
314327

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+
315353
async def _refusal_with_token(
316354
self, *, reason: str, envelope: BudgetClaim,
317355
remaining: int, key: MeterKey, jti: str,

src/regent_httpsig/sfv.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,18 @@ def build_aauth_budget_header(
176176
return ", ".join(members)
177177

178178

179-
def build_aauth_requirement(*, reason: str, resource_token: str | None = None) -> str:
180-
"""Serialize ``AAuth-Requirement`` for a budget refusal:
179+
def build_aauth_requirement(*, reason: str | None,
180+
resource_token: str | None = None) -> str:
181+
"""Serialize ``AAuth-Requirement`` for a budget challenge:
181182
``requirement=auth-token;resource-token="eyJ…";reason=insufficient-budget``.
182-
``reason`` is an sf-token (``insufficient-budget`` | ``budget-exhausted``);
183-
the resource token (when the resource issues one) rides as an sf-string."""
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."""
184188
out = "requirement=auth-token"
185189
if resource_token:
186190
out += f";resource-token={_sf_string(resource_token)}"
187-
out += f";reason={reason}"
191+
if reason:
192+
out += f";reason={reason}"
188193
return out

src/regent_httpsig/verify.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@
6161
AAUTH_AUTH_TYP = "aa-auth+jwt" # PS-issued auth tokens — the budget carrier
6262
# -11: person and auth tokens live at most one hour — enforced with tolerance.
6363
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
6468

6569

6670
def _register_fully_specified_algs() -> None:
@@ -86,6 +90,11 @@ class VerifiedSignature:
8690
sub: str | None = None # AAuth agent id (token `sub`)
8791
label: str = ""
8892
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
8998

9099
def context(self) -> dict[str, Any]:
91100
"""A flat dict suitable for logging / policy engines / audit trails."""
@@ -164,18 +173,28 @@ def __init__(
164173
self._cache: dict[str, tuple[float, dict[str, Any] | None]] = {}
165174

166175
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,
168178
) -> VerifiedSignature | None:
169179
"""Verify the request's agent signature. Returns ``None`` when there is
170180
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."""
172190
hdrs = {str(k): str(v) for k, v in headers.items()}
173191
if not any(k.lower() == "signature" for k in hdrs):
174192
return None
175193
result: VerifiedSignature | None = None
176194
try:
177195
if any(k.lower() == "signature-key" for k in hdrs):
178-
result = await self._verify_aauth(method, url, hdrs)
196+
result = await self._verify_aauth(
197+
method, url, hdrs, allow_expired_auth_token=allow_expired_auth_token)
179198
if result is None:
180199
result = await self._verify_web_bot_auth(method, url, hdrs)
181200
except Exception as exc: # noqa: BLE001 — belt and braces
@@ -288,7 +307,8 @@ async def _verify_web_bot_auth(
288307
# ── AAuth (identity-based mode) ──────────────────────────────────────────
289308

290309
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,
292312
) -> VerifiedSignature | None:
293313
try:
294314
import jwt as pyjwt # the [aauth] extra
@@ -387,6 +407,7 @@ async def _verify_aauth(
387407
continue
388408
if issuer_key is None:
389409
return None
410+
tolerate_exp = allow_expired_auth_token and typ == AAUTH_AUTH_TYP
390411
try:
391412
claims = pyjwt.decode(
392413
token,
@@ -396,11 +417,20 @@ async def _verify_aauth(
396417
options={
397418
"require": ["iss", "sub", "exp", "iat"],
398419
"verify_aud": audience is not None,
420+
"verify_exp": not tolerate_exp,
399421
},
400422
)
401423
except Exception as exc: # noqa: BLE001
402424
logger.info("aauth token invalid iss=%s: %s", iss, str(exc)[:200])
403425
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
404434

405435
# -11: person and auth tokens live at most one hour.
406436
if typ in (AAUTH_PERSON_TYP, AAUTH_AUTH_TYP):
@@ -456,4 +486,5 @@ async def _verify_aauth(
456486
"budget") # budgets: the envelope rides in the token
457487
if k in claims
458488
},
489+
expired=expired,
459490
)

tests/test_budget.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,11 +266,13 @@ def _ps_pair() -> tuple[Ed25519PrivateKey, dict[str, Any]]:
266266

267267

268268
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."""
270272
from regent_httpsig.verify import _register_fully_specified_algs
271273

272274
_register_fully_specified_algs() # PyJWT knows "Ed25519" only after this
273-
now = int(time.time())
275+
now = int(time.time()) - age
274276
return pyjwt.encode(
275277
{
276278
"iss": PS_ISS, "sub": "owner-1", "aud": RESOURCE, "jti": jti,
@@ -499,3 +501,70 @@ async def test_streaming_cost_omitted_reserved_math(
499501
next_remaining = int(str(d2["remaining"].value)) + 300 # add back r2's own cost
500502
recovered = 1000 + 300 - 700 - (1000 - next_remaining) # draft's subtraction…
501503
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

0 commit comments

Comments
 (0)