regent-httpsig 0.5.0__tar.gz → 0.6.0__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.
Files changed (30) hide show
  1. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/CHANGELOG.md +47 -0
  2. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/PKG-INFO +9 -1
  3. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/README.md +8 -0
  4. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/pyproject.toml +1 -1
  5. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/__init__.py +11 -7
  6. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/budget.py +60 -4
  7. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/fastapi.py +67 -2
  8. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/sfv.py +10 -5
  9. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/usage.py +48 -0
  10. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/verify.py +35 -4
  11. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_budget.py +171 -6
  12. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_usage.py +0 -2
  13. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/.github/workflows/ci.yml +0 -0
  14. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/.github/workflows/release.yml +0 -0
  15. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/.gitignore +0 -0
  16. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/LICENSE +0 -0
  17. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/SECURITY.md +0 -0
  18. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/examples/fastapi_verify.py +0 -0
  19. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/examples/httpx_signer.py +0 -0
  20. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/cli.py +0 -0
  21. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/config.py +0 -0
  22. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/jwk.py +0 -0
  23. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/netguard.py +0 -0
  24. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/sign.py +0 -0
  25. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_netguard.py +0 -0
  26. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_signer.py +0 -0
  27. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_vectors.py +0 -0
  28. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_verifier.py +0 -0
  29. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/vectors/aauth-budgets-vectors.json +0 -0
  30. {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/vectors/generate.py +0 -0
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
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
+
27
+ ## 0.5.1
28
+
29
+ **Budgets — the final-record challenge for expired auth tokens** (draft
30
+ §Budget Exhaustion "auth token expired" + §Settlement). The final/snapshot
31
+ settlement rule needs a moment at which the resource *states* a token's final
32
+ figure — and that moment is the challenge to an expired token. A verifier that
33
+ refuses to look at an expired token can never build it, so the issuer never
34
+ sees a final record and accounts every allocation as fully consumed forever.
35
+
36
+ - `BudgetMiddleware` answers a genuine-but-expired `aa-auth+jwt` (issuer
37
+ signature and proof of possession verified, request signature fresh) with
38
+ `401` + plain `AAuth-Requirement: requirement=auth-token;resource-token=…`
39
+ (no `reason` — the budget didn't run out, the token did) and `code:
40
+ AUTH_TOKEN_EXPIRED`. The resource token carries the presented token's
41
+ `{jti, consumed}`; no `AAuth-Budget` header. Nothing is served or metered.
42
+ - `HttpsigVerifier.verify(..., allow_expired_auth_token=True)` — **opt-in,
43
+ default False**: returns the token with `VerifiedSignature.expired=True`
44
+ for up to `EXPIRED_AUTH_TOKEN_GRACE` (2h, the meter's retention) after
45
+ `exp`. Access decisions through `verify()`/`require_signature` are
46
+ unchanged: an expired token is still `None` there, and the middleware never
47
+ caches an expired result for downstream dependencies.
48
+ - `build_aauth_requirement(reason=None, …)` emits the reason-less challenge.
49
+
3
50
  ## 0.5.0
4
51
 
5
52
  **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.0
3
+ Version: 0.6.0
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,14 @@ 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
+ - **Revocation**: `make_revocation_endpoint(meter, authenticate_ps=…)` is the
178
+ base protocol's endpoint; a revoked token stops spending at once, in-flight
179
+ requests complete, and its final record rides on the next challenge.
180
+ - An **expired** auth token gets the base protocol's plain challenge with a
181
+ resource token carrying that token's **final** consumption record — the
182
+ figure its issuer needs to settle the allocation (a record stated at or
183
+ after `exp` is final; one on a live token is a snapshot and releases
184
+ nothing).
177
185
  - **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
178
186
  the header, commit when the stream ends (set `request.state.budget_cost`
179
187
  mid-stream if you learn the actual), and the agent recovers the exact cost
@@ -144,6 +144,14 @@ 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
+ - **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.
150
+ - An **expired** auth token gets the base protocol's plain challenge with a
151
+ resource token carrying that token's **final** consumption record — the
152
+ figure its issuer needs to settle the allocation (a record stated at or
153
+ after `exp` is final; one on a live token is a snapshot and releases
154
+ nothing).
147
155
  - **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
148
156
  the header, commit when the stream ends (set `request.state.budget_cost`
149
157
  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.0"
7
+ version = "0.6.0"
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"
@@ -9,28 +9,30 @@ from regent_httpsig.budget import (
9
9
  InMemoryMeter,
10
10
  InsufficientBudget,
11
11
  InvalidBudgetClaim,
12
+ TokenRevoked,
12
13
  UnitMismatch,
13
14
  )
14
15
  from regent_httpsig.config import HttpsigConfig
15
16
  from regent_httpsig.jwk import b64url, jwk_thumbprint, load_ed25519_jwk
16
17
  from regent_httpsig.netguard import NotPublicURL, assert_public_url
18
+ from regent_httpsig.sfv import (
19
+ build_aauth_budget_header,
20
+ build_aauth_requirement,
21
+ parse_signature_agent,
22
+ )
23
+ from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
17
24
  from regent_httpsig.usage import (
18
25
  ResponseSigner,
19
26
  UsageQueryError,
20
27
  build_usage_response,
28
+ make_revocation_endpoint,
21
29
  make_usage_endpoint,
22
30
  parse_usage_request,
23
31
  validate_budget_grant,
24
32
  )
25
- from regent_httpsig.sfv import (
26
- build_aauth_budget_header,
27
- build_aauth_requirement,
28
- parse_signature_agent,
29
- )
30
- from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
31
33
  from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
32
34
 
33
- __version__ = "0.3.0"
35
+ __version__ = "0.6.0"
34
36
 
35
37
  __all__ = [
36
38
  "DIRECTORY_MEDIA_TYPE",
@@ -40,6 +42,7 @@ __all__ = [
40
42
  "HttpsigVerifier",
41
43
  "InMemoryMeter",
42
44
  "InsufficientBudget",
45
+ "TokenRevoked",
43
46
  "InvalidBudgetClaim",
44
47
  "NotPublicURL",
45
48
  "UnitMismatch",
@@ -51,6 +54,7 @@ __all__ = [
51
54
  "ResponseSigner",
52
55
  "UsageQueryError",
53
56
  "build_usage_response",
57
+ "make_revocation_endpoint",
54
58
  "make_usage_endpoint",
55
59
  "parse_usage_request",
56
60
  "validate_budget_grant",
@@ -33,8 +33,8 @@ import asyncio
33
33
  import itertools
34
34
  import time
35
35
  from collections.abc import Mapping
36
- from datetime import UTC, datetime, timedelta
37
36
  from dataclasses import dataclass, field
37
+ from datetime import UTC, datetime, timedelta
38
38
  from typing import Any
39
39
 
40
40
  __all__ = [
@@ -43,6 +43,7 @@ __all__ = [
43
43
  "InsufficientBudget",
44
44
  "InvalidBudgetClaim",
45
45
  "Reservation",
46
+ "TokenRevoked",
46
47
  "UnitMismatch",
47
48
  ]
48
49
 
@@ -112,6 +113,17 @@ class InsufficientBudget:
112
113
  exhausted: bool
113
114
 
114
115
 
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
+
115
127
  @dataclass
116
128
  class _Pool:
117
129
  unit: str
@@ -120,6 +132,7 @@ class _Pool:
120
132
  consumed: dict[str, int] = field(default_factory=dict) # jti -> total committed
121
133
  jkt_of: dict[str, str] = field(default_factory=dict) # jti -> presenting key thumbprint
122
134
  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
123
136
  last_activity: float = 0.0
124
137
 
125
138
 
@@ -178,6 +191,7 @@ class InMemoryMeter:
178
191
  retention_seconds: float = 7200.0,
179
192
  usage_key_retention: float = 86400.0) -> None:
180
193
  self._pools: dict[MeterKey, _Pool] = {}
194
+ self._jti_index: dict[tuple[str, str], MeterKey] = {} # (iss, jti) -> ledger key
181
195
  self._lock = asyncio.Lock()
182
196
  self._rids = itertools.count(1)
183
197
  self._reservation_ttl = reservation_ttl
@@ -190,7 +204,8 @@ class InMemoryMeter:
190
204
  # request" — so a key in continuous use is never pruned.
191
205
  self._usage_key_retention = usage_key_retention
192
206
  self._scope_usage: dict[tuple[str, str], _ScopeCounters] = {} # (iss, sub)
193
- self._key_usage: dict[tuple[str, str], tuple[int, float]] = {} # (iss, jkt) -> (total, last_wall)
207
+ # (iss, jkt) -> (total, last_wall)
208
+ self._key_usage: dict[tuple[str, str], tuple[int, float]] = {}
194
209
  self._metering_unit: tuple[str, int] | None = None
195
210
 
196
211
  # ── internals (call under lock) ──────────────────────────────────────────
@@ -213,9 +228,16 @@ class InMemoryMeter:
213
228
  if (not pool.grants and not pool.reservations
214
229
  and now - pool.last_activity > self._retention):
215
230
  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]
216
233
  return None
217
234
  return pool
218
235
 
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
+
219
241
  @staticmethod
220
242
  def _remaining(pool: _Pool, jti: str) -> int:
221
243
  """The presented token's balance: its grant minus what was committed
@@ -251,14 +273,17 @@ class InMemoryMeter:
251
273
  pool.last_activity = now
252
274
  if jkt:
253
275
  pool.jkt_of.setdefault(jti, jkt)
254
- 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:
255
278
  pool.grants[jti] = (claim.amount, now + wall_delta)
256
279
 
257
280
  async def reserve(self, key: MeterKey, jti: str,
258
- max_cost: int) -> Reservation | InsufficientBudget:
281
+ max_cost: int) -> Reservation | InsufficientBudget | TokenRevoked:
259
282
  async with self._lock:
260
283
  now = time.monotonic()
261
284
  pool = self._purge(key, now)
285
+ if pool is not None and jti in pool.revoked:
286
+ return TokenRevoked(drained=self._drained(pool, jti))
262
287
  if pool is None or jti not in pool.grants:
263
288
  return InsufficientBudget(remaining=0, exhausted=True)
264
289
  remaining = self._remaining(pool, jti)
@@ -301,6 +326,33 @@ class InMemoryMeter:
301
326
  pool = self._purge(key, time.monotonic())
302
327
  return 0 if pool is None else self._remaining(pool, jti)
303
328
 
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
+
304
356
  def _record_usage(self, key: MeterKey, pool: _Pool, jti: str,
305
357
  amount: int) -> None:
306
358
  """Post a committed cost to the usage counters (call under lock).
@@ -351,6 +403,10 @@ class InMemoryMeter:
351
403
  pool = self._purge(key, time.monotonic())
352
404
  if pool is None:
353
405
  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
354
410
  total = pool.consumed.get(jti, 0)
355
411
  return {"jti": jti, "consumed": total} if total > 0 else None
356
412
 
@@ -41,6 +41,7 @@ from regent_httpsig.budget import (
41
41
  InvalidBudgetClaim,
42
42
  MeterKey,
43
43
  Reservation,
44
+ TokenRevoked,
44
45
  UnitMismatch,
45
46
  )
46
47
  from regent_httpsig.sfv import build_aauth_budget_header, build_aauth_requirement
@@ -191,6 +192,13 @@ class BudgetMiddleware(BaseHTTPMiddleware):
191
192
  return await call_next(request)
192
193
 
193
194
  sig = await self._verified(request)
195
+ if sig is not None and sig.expired:
196
+ # §Budget Exhaustion, "auth token expired": the base protocol's plain
197
+ # 401 — and the resource token we attach carries the presented
198
+ # token's FINAL consumption record (stated at/after its exp), which
199
+ # is what lets its issuer settle the allocation exactly. Without
200
+ # this challenge an issuer never sees a final figure at all.
201
+ return await self._expired_challenge(sig)
194
202
  envelope: BudgetClaim | None = None
195
203
  if sig is not None:
196
204
  try:
@@ -222,6 +230,11 @@ class BudgetMiddleware(BaseHTTPMiddleware):
222
230
  remaining=0, key=key)
223
231
 
224
232
  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)
225
238
  if isinstance(outcome, InsufficientBudget):
226
239
  reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
227
240
  return await self._refusal_with_token(
@@ -307,11 +320,63 @@ class BudgetMiddleware(BaseHTTPMiddleware):
307
320
  result = None
308
321
  if "signature" in request.headers:
309
322
  result = await self._verifier.verify(
310
- request.method, _public_url(request), dict(request.headers)
323
+ request.method, _public_url(request), dict(request.headers),
324
+ allow_expired_auth_token=True,
311
325
  )
312
- request.state.regent_httpsig_result = result
326
+ # An expired token is never handed to the app: the middleware answers
327
+ # it with the final-record challenge and nothing downstream runs. The
328
+ # per-request cache still must not carry it — a later dependency
329
+ # reading the cache would otherwise see a token that is not valid.
330
+ request.state.regent_httpsig_result = (
331
+ None if result is not None and result.expired else result)
313
332
  return result
314
333
 
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
+
355
+ async def _expired_challenge(self, sig: VerifiedSignature) -> Response:
356
+ jti = str(sig.claims.get("jti") or "")
357
+ key: MeterKey = (
358
+ str(sig.claims.get("iss", "")),
359
+ str(sig.claims.get("sub", "")),
360
+ str(sig.claims.get("aud", "")),
361
+ )
362
+ token: str | None = None
363
+ if self._resource_token is not None and jti:
364
+ try:
365
+ record = await self._meter.consumed_record(key, jti)
366
+ token = await _maybe_await(self._resource_token(key, record))
367
+ except Exception: # noqa: BLE001 — the challenge must not fail on the extras
368
+ logger.warning("resource_token_provider failed", exc_info=True)
369
+ return JSONResponse(
370
+ status_code=401,
371
+ content={
372
+ "code": "AUTH_TOKEN_EXPIRED",
373
+ "message": "The auth token has expired. Take the resource token in "
374
+ "AAuth-Requirement to your PS for a fresh one.",
375
+ },
376
+ headers={"AAuth-Requirement": build_aauth_requirement(
377
+ reason=None, resource_token=token)},
378
+ )
379
+
315
380
  async def _refusal_with_token(
316
381
  self, *, reason: str, envelope: BudgetClaim,
317
382
  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, 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:
181
182
  ``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."""
184
188
  out = "requirement=auth-token"
185
189
  if resource_token:
186
190
  out += f";resource-token={_sf_string(resource_token)}"
187
- out += f";reason={reason}"
191
+ if reason:
192
+ out += f";reason={reason}"
188
193
  return out
@@ -221,3 +221,51 @@ def validate_budget_grant(unit: str, decimals: int,
221
221
  f"(declared in budget_units), got {decimals}")
222
222
  return
223
223
  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
@@ -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(method, url, hdrs)
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
  )
@@ -28,7 +28,7 @@ from regent_httpsig import (
28
28
  build_aauth_requirement,
29
29
  generate_seed,
30
30
  )
31
- from regent_httpsig.budget import Reservation
31
+ from regent_httpsig.budget import Reservation, TokenRevoked
32
32
  from regent_httpsig.fastapi import BudgetMiddleware
33
33
  from regent_httpsig.sfv import SFDictionary
34
34
 
@@ -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,
@@ -487,7 +489,8 @@ async def test_streaming_cost_omitted_reserved_math(
487
489
 
488
490
  r1 = await _post(app, "/v1/stream", _signed_headers(agent, token, "/v1/stream"))
489
491
  assert r1.status_code == 200
490
- d1 = SFDictionary(); d1.parse(r1.headers["AAuth-Budget"].encode())
492
+ d1 = SFDictionary()
493
+ d1.parse(r1.headers["AAuth-Budget"].encode())
491
494
  assert "cost" not in d1 # omitted — no trailer runtime
492
495
  assert int(str(d1["reserved"].value)) == 300 # REQUIRED when cost omitted
493
496
  assert int(str(d1["remaining"].value)) == 700 # net of the hold
@@ -495,7 +498,169 @@ async def test_streaming_cost_omitted_reserved_math(
495
498
 
496
499
  # Stream ended → the actual 120 was committed, 180 returned to the grant.
497
500
  r2 = await _post(app, "/v1/search", _signed_headers(agent, token, "/v1/search"))
498
- d2 = SFDictionary(); d2.parse(r2.headers["AAuth-Budget"].encode())
501
+ d2 = SFDictionary()
502
+ d2.parse(r2.headers["AAuth-Budget"].encode())
499
503
  next_remaining = int(str(d2["remaining"].value)) + 300 # add back r2's own cost
500
- recovered = 1000 + 300 - 700 - (1000 - next_remaining) # draft's subtraction…
501
504
  assert 700 + 300 - next_remaining == 120 # …prev + reserved − next = cost
505
+
506
+
507
+ async def test_expired_token_gets_final_record_challenge(
508
+ monkeypatch: pytest.MonkeyPatch,
509
+ ) -> None:
510
+ """§Budget Exhaustion, "auth token expired": plain requirement=auth-token
511
+ (no reason) + a resource token carrying the token's FINAL record — the
512
+ figure the issuer needs to settle the allocation (§Settlement: a record
513
+ stated at/after exp is final)."""
514
+ ps_priv, ps_jwk = _ps_pair()
515
+ agent = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
516
+ live = _auth_token(ps_priv, agent, amount=1000, jti="at-9")
517
+ provider_calls: list[Any] = []
518
+
519
+ def provider(key: Any, record: Any) -> str:
520
+ provider_calls.append((key, record))
521
+ return "resource.token.final"
522
+
523
+ meter = InMemoryMeter()
524
+ app = _app(_verifier(ps_jwk, monkeypatch), meter=meter,
525
+ resource_token_provider=provider)
526
+ # Spend 300 on the live token, so there is a figure to carry.
527
+ r = await _post(app, "/v1/search", _signed_headers(agent, live, "/v1/search"))
528
+ assert r.status_code == 200
529
+
530
+ # The same jti, now past its exp (same issuer, same key, same claims).
531
+ stale = _auth_token(ps_priv, agent, amount=1000, jti="at-9", age=700)
532
+ r = await _post(app, "/v1/search", _signed_headers(agent, stale, "/v1/search"))
533
+ assert r.status_code == 401
534
+ assert r.json()["code"] == "AUTH_TOKEN_EXPIRED"
535
+ assert (r.headers["AAuth-Requirement"]
536
+ == 'requirement=auth-token;resource-token="resource.token.final"')
537
+ assert "AAuth-Budget" not in r.headers # the budget didn't run out; the token did
538
+ assert provider_calls[-1] == ((PS_ISS, "owner-1", RESOURCE),
539
+ {"jti": "at-9", "consumed": 300})
540
+ # Nothing was served and nothing more was metered.
541
+ assert await meter.consumed_record(
542
+ (PS_ISS, "owner-1", RESOURCE), "at-9") == {"jti": "at-9", "consumed": 300}
543
+
544
+
545
+ async def test_expired_token_past_grace_is_no_envelope(
546
+ monkeypatch: pytest.MonkeyPatch,
547
+ ) -> None:
548
+ """Beyond the grace window there is no figure left to carry: the token is
549
+ just an unknown one and the request falls through as unsigned."""
550
+ ps_priv, ps_jwk = _ps_pair()
551
+ agent = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
552
+ ancient = _auth_token(ps_priv, agent, amount=1000, jti="at-old", age=3 * 3600)
553
+ app = _app(_verifier(ps_jwk, monkeypatch), require=True)
554
+ r = await _post(app, "/v1/search", _signed_headers(agent, ancient, "/v1/search"))
555
+ assert r.status_code == 401 and r.json()["code"] == "AUTH_TOKEN_REQUIRED"
556
+
557
+
558
+ async def test_plain_verify_still_rejects_expired_tokens(
559
+ monkeypatch: pytest.MonkeyPatch,
560
+ ) -> None:
561
+ """The tolerance is opt-in for the budgets middleware only: an access
562
+ decision through ``verify()`` never sees an expired token as verified."""
563
+ ps_priv, ps_jwk = _ps_pair()
564
+ agent = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
565
+ stale = _auth_token(ps_priv, agent, amount=1000, jti="at-9", age=700)
566
+ verifier = _verifier(ps_jwk, monkeypatch)
567
+ headers = _signed_headers(agent, stale, "/v1/search")
568
+ assert await verifier.verify("POST", f"{RESOURCE}/v1/search", headers) is None
569
+ sig = await verifier.verify("POST", f"{RESOURCE}/v1/search", headers,
570
+ allow_expired_auth_token=True)
571
+ assert sig is not None and sig.expired
572
+
573
+
574
+ # ── revocation (base protocol §Token Revocation · budgets §Token Scope · AAuth #151) ──
575
+
576
+ async def test_meter_revoke_drains_before_final_record() -> None:
577
+ """Revocation stops new spend at once; a request already in flight completes;
578
+ the token's record is withheld until nothing is in flight, then it is final."""
579
+ meter = InMemoryMeter()
580
+ await meter.observe_grant(KEY, "jti-r", _claim(1000), time.time() + 600)
581
+ inflight = await meter.reserve(KEY, "jti-r", 300)
582
+ assert isinstance(inflight, Reservation)
583
+
584
+ assert await meter.revoke(KEY[0], "jti-r") is True
585
+ assert await meter.revoke(KEY[0], "jti-r") is True # idempotent
586
+ assert await meter.revoke(KEY[0], "never-seen") is False
587
+
588
+ # No new spend, and the record is withheld while the in-flight request runs.
589
+ outcome = await meter.reserve(KEY, "jti-r", 1)
590
+ assert outcome == TokenRevoked(drained=False)
591
+ assert await meter.consumed_record(KEY, "jti-r") is None
592
+ # The in-flight request completes and is committed as usual.
593
+ await meter.commit(inflight, 250)
594
+ assert await meter.reserve(KEY, "jti-r", 1) == TokenRevoked(drained=True)
595
+ assert await meter.consumed_record(KEY, "jti-r") == {"jti": "jti-r", "consumed": 250}
596
+ assert await meter.revocation_state(KEY, "jti-r") == TokenRevoked(drained=True)
597
+ assert await meter.revocation_state(KEY, "other") is None
598
+ # A later observe_grant of the same jti does not resurrect the grant.
599
+ await meter.observe_grant(KEY, "jti-r", _claim(1000), time.time() + 600)
600
+ assert await meter.reserve(KEY, "jti-r", 1) == TokenRevoked(drained=True)
601
+
602
+
603
+ def _revocation_app(meter: InMemoryMeter, caller: str | None) -> FastAPI:
604
+ from regent_httpsig import make_revocation_endpoint
605
+
606
+ async def auth(request: Any) -> str | None:
607
+ return caller
608
+
609
+ handler = make_revocation_endpoint(meter, authenticate_ps=auth)
610
+ app = FastAPI()
611
+
612
+ @app.post("/revoke")
613
+ async def revoke(request: Request): # type: ignore[no-untyped-def]
614
+ return await handler(request)
615
+
616
+ return app
617
+
618
+
619
+ async def test_revocation_endpoint_contract() -> None:
620
+ from starlette.testclient import TestClient
621
+
622
+ meter = InMemoryMeter()
623
+ await meter.observe_grant(KEY, "jti-e", _claim(500), time.time() + 600)
624
+ # Unauthenticated → 401, nothing revoked.
625
+ r = TestClient(_revocation_app(meter, None)).post(
626
+ "/revoke", json={"iss": PS_ISS, "jti": "jti-e"})
627
+ assert r.status_code == 401
628
+ assert isinstance(await meter.reserve(KEY, "jti-e", 1), Reservation)
629
+ client = TestClient(_revocation_app(meter, PS_ISS))
630
+ assert client.post("/revoke", json={"iss": PS_ISS}).status_code == 400 # jti REQUIRED
631
+ other = {"iss": "https://other.example", "jti": "jti-e"}
632
+ assert client.post("/revoke", json=other).status_code == 403
633
+ assert client.post("/revoke", json={"iss": PS_ISS, "jti": "ghost"}).status_code == 404
634
+ r = client.post("/revoke", json={"iss": PS_ISS, "jti": "jti-e"})
635
+ assert r.status_code == 200 and r.content == b"" # empty on success
636
+ again = client.post("/revoke", json={"iss": PS_ISS, "jti": "jti-e"})
637
+ assert again.status_code == 200 # already invalid → still 200
638
+ assert isinstance(await meter.reserve(KEY, "jti-e", 1), TokenRevoked)
639
+
640
+
641
+ async def test_middleware_revoked_token_gets_challenge_then_final_record(
642
+ monkeypatch: pytest.MonkeyPatch,
643
+ ) -> None:
644
+ ps_priv, ps_jwk = _ps_pair()
645
+ agent = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
646
+ token = _auth_token(ps_priv, agent, amount=1000, jti="at-rv")
647
+ calls: list[Any] = []
648
+
649
+ def provider(key: Any, record: Any) -> str:
650
+ calls.append(record)
651
+ return "resource.token.rv"
652
+
653
+ meter = InMemoryMeter()
654
+ app = _app(_verifier(ps_jwk, monkeypatch), meter=meter, resource_token_provider=provider)
655
+ first = await _post(app, "/v1/search", _signed_headers(agent, token, "/v1/search"))
656
+ assert first.status_code == 200
657
+
658
+ # The issuer revokes; the next request is refused with the plain challenge
659
+ # and, nothing being in flight, the FINAL record rides on it.
660
+ assert await meter.revoke(PS_ISS, "at-rv")
661
+ r = await _post(app, "/v1/search", _signed_headers(agent, token, "/v1/search"))
662
+ assert r.status_code == 401 and r.json()["code"] == "AUTH_TOKEN_REVOKED"
663
+ assert (r.headers["AAuth-Requirement"]
664
+ == 'requirement=auth-token;resource-token="resource.token.rv"')
665
+ assert "AAuth-Budget" not in r.headers
666
+ assert calls[-1] == {"jti": "at-rv", "consumed": 300}
@@ -8,9 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import asyncio
10
10
  import base64
11
- import json
12
11
  import time
13
- from typing import Any
14
12
 
15
13
  import pytest
16
14
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
File without changes