Skip to content

Commit 36c3cfc

Browse files
committed
budgets 0.6.0: revocation reaches the meter (revoke, drain, final record, endpoint)
Base protocol §Token Revocation + budgets §Token Scope, drain rule per AAuth issue #151 (raised from production, not yet in the editor's copy): - InMemoryMeter.revoke(iss, jti): no new reservations, in-flight complete; TokenRevoked(drained) from reserve(); consumed_record withheld until drained so the post-revocation record is final. - make_revocation_endpoint: signed POST {iss, jti}; 200 empty / 404 / 403. - BudgetMiddleware: AUTH_TOKEN_REVOKED challenge with the final record. 86 tests.
1 parent 235501e commit 36c3cfc

8 files changed

Lines changed: 261 additions & 5 deletions

File tree

CHANGELOG.md

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

3+
## 0.6.0
4+
5+
**Budgets — revocation reaches the meter** (base protocol §Token Revocation,
6+
budgets §Token Scope; drain rule per AAuth issue #151, raised from our
7+
production and not yet in the editor's copy):
8+
9+
- **`InMemoryMeter.revoke(iss, jti)`**: withdraws the grant so no new request
10+
can reserve against the token; requests already in flight complete and are
11+
committed as usual. Idempotent; `False` for an unknown `(iss, jti)`.
12+
- **`TokenRevoked(drained)`**: what `reserve()` returns for a revoked token.
13+
`drained` is true once nothing is in flight on it.
14+
- **Final record only after the drain**: `consumed_record()` withholds a
15+
revoked token's record while requests are in flight, so a record the
16+
resource issues after the revocation is the token's final figure and the
17+
issuer can settle on it (the ordering is checkable by `iat`).
18+
- **`make_revocation_endpoint(meter, authenticate_ps=…)`**: the base
19+
protocol's endpoint — signed `POST {"iss","jti"}`, `200` empty on success or
20+
already-invalid, `404` unknown, `403` when the caller is not the issuer.
21+
- **`BudgetMiddleware`** answers a revoked token with the plain
22+
`requirement=auth-token` challenge (`code: AUTH_TOKEN_REVOKED`, no
23+
`AAuth-Budget`), the resource token carrying the final record once drained.
24+
- `revocation_state(key, jti)` for resources that learn of revocation out of
25+
band (e.g. from their own registry) and want the same challenge.
26+
327
## 0.5.1
428

529
**Budgets — the final-record challenge for expired auth tokens** (draft

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ 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+
- **Revocation**: `make_revocation_endpoint(meter, authenticate_ps=…)` is the
148+
base protocol's endpoint; a revoked token stops spending at once, in-flight
149+
requests complete, and its final record rides on the next challenge.
147150
- An **expired** auth token gets the base protocol's plain challenge with a
148151
resource token carrying that token's **final** consumption record — the
149152
figure its issuer needs to settle the allocation (a record stated at or

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.1"
7+
version = "0.6.0"
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/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
InMemoryMeter,
1010
InsufficientBudget,
1111
InvalidBudgetClaim,
12+
TokenRevoked,
1213
UnitMismatch,
1314
)
1415
from regent_httpsig.config import HttpsigConfig
@@ -24,13 +25,14 @@
2425
ResponseSigner,
2526
UsageQueryError,
2627
build_usage_response,
28+
make_revocation_endpoint,
2729
make_usage_endpoint,
2830
parse_usage_request,
2931
validate_budget_grant,
3032
)
3133
from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
3234

33-
__version__ = "0.5.1"
35+
__version__ = "0.6.0"
3436

3537
__all__ = [
3638
"DIRECTORY_MEDIA_TYPE",
@@ -40,6 +42,7 @@
4042
"HttpsigVerifier",
4143
"InMemoryMeter",
4244
"InsufficientBudget",
45+
"TokenRevoked",
4346
"InvalidBudgetClaim",
4447
"NotPublicURL",
4548
"UnitMismatch",
@@ -51,6 +54,7 @@
5154
"ResponseSigner",
5255
"UsageQueryError",
5356
"build_usage_response",
57+
"make_revocation_endpoint",
5458
"make_usage_endpoint",
5559
"parse_usage_request",
5660
"validate_budget_grant",

src/regent_httpsig/budget.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"InsufficientBudget",
4444
"InvalidBudgetClaim",
4545
"Reservation",
46+
"TokenRevoked",
4647
"UnitMismatch",
4748
]
4849

@@ -112,6 +113,17 @@ class InsufficientBudget:
112113
exhausted: bool
113114

114115

116+
@dataclass(frozen=True)
117+
class TokenRevoked:
118+
"""Refusal: the presented token was revoked (base protocol §Token
119+
Revocation, budgets §Token Scope). The budget ended with the token — no
120+
new spend — while requests already in flight complete. ``drained`` says
121+
whether those have all settled: only then may the resource state the
122+
token's consumption record, which is then FINAL (AAuth issue #151)."""
123+
124+
drained: bool
125+
126+
115127
@dataclass
116128
class _Pool:
117129
unit: str
@@ -120,6 +132,7 @@ class _Pool:
120132
consumed: dict[str, int] = field(default_factory=dict) # jti -> total committed
121133
jkt_of: dict[str, str] = field(default_factory=dict) # jti -> presenting key thumbprint
122134
reservations: dict[int, tuple[str, int, float]] = field(default_factory=dict)
135+
revoked: dict[str, float] = field(default_factory=dict) # jti -> wall time of revocation
123136
last_activity: float = 0.0
124137

125138

@@ -178,6 +191,7 @@ def __init__(self, *, reservation_ttl: float = 120.0,
178191
retention_seconds: float = 7200.0,
179192
usage_key_retention: float = 86400.0) -> None:
180193
self._pools: dict[MeterKey, _Pool] = {}
194+
self._jti_index: dict[tuple[str, str], MeterKey] = {} # (iss, jti) -> ledger key
181195
self._lock = asyncio.Lock()
182196
self._rids = itertools.count(1)
183197
self._reservation_ttl = reservation_ttl
@@ -214,9 +228,16 @@ def _purge(self, key: MeterKey, now: float) -> _Pool | None:
214228
if (not pool.grants and not pool.reservations
215229
and now - pool.last_activity > self._retention):
216230
del self._pools[key]
231+
for pair in [p for p, k in self._jti_index.items() if k == key]:
232+
del self._jti_index[pair]
217233
return None
218234
return pool
219235

236+
@staticmethod
237+
def _drained(pool: _Pool, jti: str) -> bool:
238+
"""No request is in flight on this token any more."""
239+
return not any(j == jti for j, _, _ in pool.reservations.values())
240+
220241
@staticmethod
221242
def _remaining(pool: _Pool, jti: str) -> int:
222243
"""The presented token's balance: its grant minus what was committed
@@ -252,14 +273,17 @@ async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
252273
pool.last_activity = now
253274
if jkt:
254275
pool.jkt_of.setdefault(jti, jkt)
255-
if jti not in pool.grants and wall_delta > 0:
276+
self._jti_index.setdefault((key[0], jti), key)
277+
if jti not in pool.grants and wall_delta > 0 and jti not in pool.revoked:
256278
pool.grants[jti] = (claim.amount, now + wall_delta)
257279

258280
async def reserve(self, key: MeterKey, jti: str,
259-
max_cost: int) -> Reservation | InsufficientBudget:
281+
max_cost: int) -> Reservation | InsufficientBudget | TokenRevoked:
260282
async with self._lock:
261283
now = time.monotonic()
262284
pool = self._purge(key, now)
285+
if pool is not None and jti in pool.revoked:
286+
return TokenRevoked(drained=self._drained(pool, jti))
263287
if pool is None or jti not in pool.grants:
264288
return InsufficientBudget(remaining=0, exhausted=True)
265289
remaining = self._remaining(pool, jti)
@@ -302,6 +326,33 @@ async def remaining(self, key: MeterKey, jti: str) -> int:
302326
pool = self._purge(key, time.monotonic())
303327
return 0 if pool is None else self._remaining(pool, jti)
304328

329+
async def revoke(self, iss: str, jti: str) -> bool:
330+
"""Revoke an auth token by ``(iss, jti)`` — the base protocol's
331+
revocation identifier. The grant is withdrawn so no new request can
332+
reserve against it; requests already in flight complete and are
333+
committed as usual. ``False`` when the pair is unknown here (the
334+
endpoint answers 404). Idempotent."""
335+
async with self._lock:
336+
key = self._jti_index.get((iss, jti))
337+
if key is None:
338+
return False
339+
pool = self._purge(key, time.monotonic())
340+
if pool is None:
341+
return False
342+
pool.revoked.setdefault(jti, time.time())
343+
pool.grants.pop(jti, None)
344+
pool.last_activity = time.monotonic()
345+
return True
346+
347+
async def revocation_state(self, key: MeterKey, jti: str) -> TokenRevoked | None:
348+
"""``TokenRevoked`` (with its drain state) if the token was revoked
349+
here, else ``None``."""
350+
async with self._lock:
351+
pool = self._purge(key, time.monotonic())
352+
if pool is None or jti not in pool.revoked:
353+
return None
354+
return TokenRevoked(drained=self._drained(pool, jti))
355+
305356
def _record_usage(self, key: MeterKey, pool: _Pool, jti: str,
306357
amount: int) -> None:
307358
"""Post a committed cost to the usage counters (call under lock).
@@ -352,6 +403,10 @@ async def consumed_record(self, key: MeterKey, jti: str) -> dict[str, Any] | Non
352403
pool = self._purge(key, time.monotonic())
353404
if pool is None:
354405
return None
406+
if jti in pool.revoked and not self._drained(pool, jti):
407+
# §Token Scope + AAuth #151: a revoked token's record is FINAL,
408+
# so it is stated only once nothing is in flight on it.
409+
return None
355410
total = pool.consumed.get(jti, 0)
356411
return {"jti": jti, "consumed": total} if total > 0 else None
357412

src/regent_httpsig/fastapi.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ async def create_order(sig: VerifiedSignature | None = SignatureDep):
4141
InvalidBudgetClaim,
4242
MeterKey,
4343
Reservation,
44+
TokenRevoked,
4445
UnitMismatch,
4546
)
4647
from regent_httpsig.sfv import build_aauth_budget_header, build_aauth_requirement
@@ -229,6 +230,11 @@ async def dispatch(
229230
remaining=0, key=key)
230231

231232
outcome = await self._meter.reserve(key, jti, int(max_cost))
233+
if isinstance(outcome, TokenRevoked):
234+
# §Token Scope: the budget ended with the token. The challenge is
235+
# the base protocol's plain one; the resource token on it carries
236+
# the token's FINAL record once nothing is in flight (AAuth #151).
237+
return await self._revoked_challenge(key, jti, outcome.drained)
232238
if isinstance(outcome, InsufficientBudget):
233239
reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
234240
return await self._refusal_with_token(
@@ -325,6 +331,27 @@ async def _verified(self, request: Request) -> VerifiedSignature | None:
325331
None if result is not None and result.expired else result)
326332
return result
327333

334+
async def _revoked_challenge(self, key: MeterKey, jti: str, drained: bool) -> Response:
335+
token: str | None = None
336+
if self._resource_token is not None:
337+
try:
338+
record = await self._meter.consumed_record(key, jti) if drained else None
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_REVOKED",
346+
"message": "This auth token was revoked by its issuer. Take the resource "
347+
"token in AAuth-Requirement to your PS for a fresh one."
348+
+ ("" if drained else " Its final consumption record follows once "
349+
"in-flight requests settle."),
350+
},
351+
headers={"AAuth-Requirement": build_aauth_requirement(
352+
reason=None, resource_token=token)},
353+
)
354+
328355
async def _expired_challenge(self, sig: VerifiedSignature) -> Response:
329356
jti = str(sig.claims.get("jti") or "")
330357
key: MeterKey = (

src/regent_httpsig/usage.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,51 @@ def validate_budget_grant(unit: str, decimals: int,
221221
f"(declared in budget_units), got {decimals}")
222222
return
223223
raise ValueError(f"unit {unit!r} is not declared in budget_units")
224+
225+
226+
def make_revocation_endpoint(
227+
meter: Any,
228+
*,
229+
authenticate_ps: Callable[[Any], Awaitable[str | None]],
230+
) -> Callable[[Any], Awaitable[Any]]:
231+
"""The base protocol's revocation endpoint (§Token Revocation) for the
232+
budgets meter: a signed ``POST`` identifying an auth token by
233+
``{"iss", "jti"}`` — both REQUIRED, keyed together because a jti is unique
234+
only within its issuer. ``200`` when the token was revoked or is already
235+
invalid here, ``404`` when the pair is not recognized. The body is
236+
deliberately empty on success: the response must not vary with what the
237+
recipient holds.
238+
239+
``authenticate_ps`` is the same pinned-PS check the usage endpoint uses.
240+
The issuer named in the body must be the caller: a PS revokes tokens it
241+
issued, never another issuer's. What revocation does to the meter is in
242+
:meth:`InMemoryMeter.revoke` — no new spend, in-flight requests complete,
243+
and the token's consumption record is withheld until they have (AAuth
244+
issue #151: a record issued after the revocation is then final)."""
245+
from starlette.responses import JSONResponse, Response
246+
247+
async def handler(request: Any) -> Response:
248+
caller = await authenticate_ps(request)
249+
if caller is None:
250+
return JSONResponse(status_code=401, content={
251+
"code": "PS_AUTH_REQUIRED",
252+
"message": "Sign the revocation as the person server that issued the token.",
253+
})
254+
try:
255+
payload = json.loads(await request.body() or b"{}")
256+
iss, jti = payload.get("iss"), payload.get("jti")
257+
if not isinstance(iss, str) or not isinstance(jti, str) or not iss or not jti:
258+
raise ValueError("iss and jti are REQUIRED strings")
259+
except (ValueError, json.JSONDecodeError) as exc:
260+
return JSONResponse(status_code=400, content={
261+
"code": "INVALID_REVOCATION", "message": str(exc)})
262+
if iss.rstrip("/") != caller.rstrip("/"):
263+
return JSONResponse(status_code=403, content={
264+
"code": "NOT_YOUR_TOKEN",
265+
"message": "A person server may revoke only tokens it issued."})
266+
if not await meter.revoke(iss, jti):
267+
return JSONResponse(status_code=404, content={
268+
"code": "TOKEN_UNKNOWN", "message": "No such (iss, jti) here."})
269+
return Response(status_code=200)
270+
271+
return handler

0 commit comments

Comments
 (0)