regent-httpsig 0.6.0__py3-none-any.whl → 0.7.0__py3-none-any.whl

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.
@@ -18,9 +18,19 @@ from regent_httpsig.netguard import NotPublicURL, assert_public_url
18
18
  from regent_httpsig.sfv import (
19
19
  build_aauth_budget_header,
20
20
  build_aauth_requirement,
21
+ build_person_token_requirement,
21
22
  parse_signature_agent,
23
+ parse_signature_input,
24
+ parse_signature_key,
25
+ )
26
+ from regent_httpsig.sign import (
27
+ DIRECTORY_MEDIA_TYPE,
28
+ EgressSigner,
29
+ ServerSigner,
30
+ content_digest,
31
+ generate_seed,
32
+ sign_request,
22
33
  )
23
- from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
24
34
  from regent_httpsig.usage import (
25
35
  ResponseSigner,
26
36
  UsageQueryError,
@@ -30,9 +40,14 @@ from regent_httpsig.usage import (
30
40
  parse_usage_request,
31
41
  validate_budget_grant,
32
42
  )
33
- from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
43
+ from regent_httpsig.verify import (
44
+ WBA_TAG,
45
+ HttpsigVerifier,
46
+ VerificationError,
47
+ VerifiedSignature,
48
+ )
34
49
 
35
- __version__ = "0.6.0"
50
+ __version__ = "0.7.0"
36
51
 
37
52
  __all__ = [
38
53
  "DIRECTORY_MEDIA_TYPE",
@@ -46,7 +61,14 @@ __all__ = [
46
61
  "InvalidBudgetClaim",
47
62
  "NotPublicURL",
48
63
  "UnitMismatch",
64
+ "VerificationError",
49
65
  "VerifiedSignature",
66
+ "ServerSigner",
67
+ "content_digest",
68
+ "sign_request",
69
+ "build_person_token_requirement",
70
+ "parse_signature_input",
71
+ "parse_signature_key",
50
72
  "WBA_TAG",
51
73
  "__version__",
52
74
  "assert_public_url",
regent_httpsig/budget.py CHANGED
@@ -192,6 +192,10 @@ class InMemoryMeter:
192
192
  usage_key_retention: float = 86400.0) -> None:
193
193
  self._pools: dict[MeterKey, _Pool] = {}
194
194
  self._jti_index: dict[tuple[str, str], MeterKey] = {} # (iss, jti) -> ledger key
195
+ # -11 §11.12.1: revocations keyed by (iss, jti), kept until the token's
196
+ # own exp plus clock skew — whether or not the token was ever seen here.
197
+ self._revocations: dict[tuple[str, str], float] = {} # (iss, jti) -> exp (wall)
198
+ self._revocation_skew = 60.0
195
199
  self._lock = asyncio.Lock()
196
200
  self._rids = itertools.count(1)
197
201
  self._reservation_ttl = reservation_ttl
@@ -326,12 +330,51 @@ class InMemoryMeter:
326
330
  pool = self._purge(key, time.monotonic())
327
331
  return 0 if pool is None else self._remaining(pool, jti)
328
332
 
333
+ def _prune_revocations(self, wall: float) -> None:
334
+ for pair, exp in list(self._revocations.items()):
335
+ if wall > exp + self._revocation_skew:
336
+ del self._revocations[pair]
337
+
338
+ def _withdraw(self, iss: str, jti: str) -> bool:
339
+ """Withdraw a known token's grant (call under lock). ``False`` when the
340
+ pair was never granted here — the revocation is still on record."""
341
+ key = self._jti_index.get((iss, jti))
342
+ if key is None:
343
+ return False
344
+ pool = self._purge(key, time.monotonic())
345
+ if pool is None:
346
+ return False
347
+ pool.revoked.setdefault(jti, time.time())
348
+ pool.grants.pop(jti, None)
349
+ pool.last_activity = time.monotonic()
350
+ return True
351
+
352
+ async def record_revocation(self, iss: str, jti: str, exp: float) -> bool:
353
+ """Record that ``iss`` revoked ``jti`` (-11 §11.12.1): remembered until
354
+ ``exp`` plus clock skew whether or not the token was ever presented
355
+ here, so a later presentation is answered ``revoked_jwt``. If the token
356
+ holds a grant, it is withdrawn — no new request can reserve against it;
357
+ requests already in flight complete and are committed as usual.
358
+ Returns whether the token was known here. Idempotent."""
359
+ async with self._lock:
360
+ wall = time.time()
361
+ self._prune_revocations(wall)
362
+ if wall <= float(exp) + self._revocation_skew: # §11.12.1: past exp + skew is droppable
363
+ self._revocations[(iss, jti)] = float(exp)
364
+ return self._withdraw(iss, jti)
365
+
366
+ async def is_revoked(self, iss: str, jti: str) -> bool:
367
+ """Whether ``(iss, jti)`` is on record as revoked — the hook for
368
+ :class:`HttpsigVerifier` (``is_revoked=meter.is_revoked``)."""
369
+ async with self._lock:
370
+ self._prune_revocations(time.time())
371
+ return (iss, jti) in self._revocations
372
+
329
373
  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."""
374
+ """Revoke a token this meter holds a grant for, by ``(iss, jti)``. Like
375
+ :meth:`record_revocation` with the grant's own ``exp``; ``False`` when
376
+ the pair is unknown here (nothing is recorded then — use
377
+ :meth:`record_revocation` when the issuer told you the exp)."""
335
378
  async with self._lock:
336
379
  key = self._jti_index.get((iss, jti))
337
380
  if key is None:
@@ -339,10 +382,11 @@ class InMemoryMeter:
339
382
  pool = self._purge(key, time.monotonic())
340
383
  if pool is None:
341
384
  return False
342
- pool.revoked.setdefault(jti, time.time())
343
- pool.grants.pop(jti, None)
344
- pool.last_activity = time.monotonic()
345
- return True
385
+ grant = pool.grants.get(jti)
386
+ wall = time.time()
387
+ exp = wall + max(0.0, grant[1] - time.monotonic()) if grant else wall
388
+ self._revocations[(iss, jti)] = max(self._revocations.get((iss, jti), 0.0), exp)
389
+ return self._withdraw(iss, jti)
346
390
 
347
391
  async def revocation_state(self, key: MeterKey, jti: str) -> TokenRevoked | None:
348
392
  """``TokenRevoked`` (with its drain state) if the token was revoked
regent_httpsig/config.py CHANGED
@@ -5,22 +5,35 @@ from __future__ import annotations
5
5
  from collections.abc import Mapping
6
6
  from dataclasses import dataclass, field
7
7
 
8
- __all__ = ["HttpsigConfig"]
8
+ __all__ = ["AAUTH_REQUIRED_COMPONENTS", "HttpsigConfig"]
9
+
10
+ # AAuth -11 §11.3.3.1: what every agent signature MUST cover. A request with a
11
+ # body to a PS/AS/revocation endpoint additionally covers content-digest and
12
+ # content-type; the verifier adds those when it is handed the body.
13
+ AAUTH_REQUIRED_COMPONENTS: tuple[str, ...] = ("@method", "@authority", "@path", "signature-key")
9
14
 
10
15
 
11
16
  @dataclass(frozen=True)
12
17
  class HttpsigConfig:
13
18
  """Configuration for :class:`regent_httpsig.HttpsigVerifier`.
14
19
 
15
- Defaults are production-safe: https-only directory fetching over public IPs,
16
- bounded cache, 25-hour max signature age (Web Bot Auth signatures carry
17
- ``created``/``expires``; 25h tolerates a day of clock drift)."""
20
+ Defaults are production-safe and, on the AAuth path, follow -11 as
21
+ published: a 60-second ``created`` window, fully-specified algorithms only,
22
+ no clock-skew tolerance on ``exp``. Web Bot Auth keeps its own, looser age
23
+ rule (``max_age_hours``) because that profile defines no window."""
18
24
 
19
25
  # Origins/issuers you additionally mark as trusted (``VerifiedSignature.trusted``).
20
26
  # Verification itself never depends on this — it only annotates the result.
21
27
  trusted_agents: frozenset[str] = field(default_factory=frozenset)
22
- # Reject signatures created earlier than this many hours ago.
28
+ # Web Bot Auth only: reject signatures created earlier than this many hours ago.
23
29
  max_age_hours: int = 25
30
+ # AAuth -11 §11.3.4 step 3: the signature validity window for `created`, in
31
+ # seconds. Older than the window → invalid_signature; further ahead of our
32
+ # clock → clock_skew. The same window bounds a token's `iat` (§11.5.2).
33
+ # Advertise a different value in resource metadata as `signature_window`.
34
+ signature_window_seconds: int = 60
35
+ # AAuth -11 §11.3.3.1: covered components every agent signature must include.
36
+ required_components: tuple[str, ...] = AAUTH_REQUIRED_COMPONENTS
24
37
  # Key-directory cache TTL (seconds); failures are cached for negative_cache_ttl.
25
38
  cache_ttl: float = 600.0
26
39
  negative_cache_ttl: float = 120.0
@@ -31,17 +44,19 @@ class HttpsigConfig:
31
44
  # Hosts exempt from the https-only + public-IP SSRF guard (local dev only —
32
45
  # e.g. frozenset({"localhost"})). Leave empty in production.
33
46
  insecure_hosts: frozenset[str] = field(default_factory=frozenset)
34
- # AAuth -11 (editor's copy): JOSE algs must be fully-specified per RFC 9864 —
35
- # implementations MUST NOT accept the polymorphic "EdDSA". True enforces that;
36
- # the False default keeps accepting "EdDSA" while the -10 ecosystem migrates.
37
- require_fully_specified_algs: bool = False
47
+ # AAuth -11 §11.3.1: JOSE algs must be fully-specified (RFC 9864); the
48
+ # polymorphic "EdDSA" and keys without `alg` are refused. False is the
49
+ # pre-cut-over behaviour kept only for private test rigs; -11 forbids it.
50
+ require_fully_specified_algs: bool = True
38
51
  # This service's public URL (e.g. "https://api.example"). Required to accept
39
- # AAuth person tokens — their `aud` must name this resource. None disables
40
- # the person-token path entirely.
52
+ # AAuth person tokens and auth tokens — their `aud` must name this resource.
53
+ # None disables both paths.
41
54
  resource_url: str | None = None
42
- # AAuth auth tokens (typ "aa-auth+jwt" — the carrier of budget envelopes):
43
- # issuer → JWKS URL for each Person Server this resource accepts auth tokens
44
- # from. A resource has an established relationship with its PS, so the key
45
- # location is pinned by configuration rather than discovered open-world.
46
- # Empty (the default) disables the auth-token path entirely.
55
+ # AAuth auth tokens (typ "aa-auth+jwt" — the carrier of budget envelopes) and
56
+ # signed server requests (the jwks_uri scheme, §11.3.2): issuer → JWKS URL
57
+ # for each Person Server / Access Server this resource accepts. A resource has
58
+ # an established relationship with its PS, so trust is pinned by
59
+ # configuration. The value may be "" to discover the JWKS through the
60
+ # issuer's metadata document ({id}/.well-known/{dwk}) instead of a fixed URL.
61
+ # Empty (the default) disables the auth-token and server paths entirely.
47
62
  trusted_ps: Mapping[str, str] = field(default_factory=dict)
regent_httpsig/fastapi.py CHANGED
@@ -13,9 +13,12 @@ Usage::
13
13
  if sig:
14
14
  ... # sig.agent == "https://chatgpt.com", sig.keyid, sig.trusted
15
15
 
16
- ``SignatureDep`` is enrichment: ``None`` when absent/invalid, never raises.
17
- ``RequiredSignatureDep`` is authentication: a coded 401 tells the agent exactly
18
- how to sign.
16
+ ``SignatureDep`` is enrichment: ``None`` when absent/invalid, never raises
17
+ (``SignatureErrorDep`` tells you why). ``RequiredSignatureDep`` is
18
+ authentication: a signed request that fails is answered ``401`` with the
19
+ ``Signature-Error`` header of draft-hardt-httpbis-signature-key (the code
20
+ AAuth -11 §11.3.4 assigns), an unsigned one with ``AAuth-Requirement:
21
+ requirement=agent-token`` (-11 §6.1).
19
22
 
20
23
  Proxy note: the signer signed the PUBLIC url. Behind a reverse proxy this
21
24
  dependency rebuilds it from ``X-Forwarded-Proto`` + ``Host`` — make sure your
@@ -44,15 +47,31 @@ from regent_httpsig.budget import (
44
47
  TokenRevoked,
45
48
  UnitMismatch,
46
49
  )
47
- from regent_httpsig.sfv import build_aauth_budget_header, build_aauth_requirement
48
- from regent_httpsig.verify import HttpsigVerifier, VerifiedSignature
50
+ from regent_httpsig.sfv import (
51
+ build_aauth_budget_header,
52
+ build_aauth_requirement,
53
+ build_person_token_requirement,
54
+ )
55
+ from regent_httpsig.verify import (
56
+ AAUTH_AUTH_TYP,
57
+ AAUTH_PERSON_TYP,
58
+ HttpsigVerifier,
59
+ VerificationError,
60
+ VerifiedSignature,
61
+ )
49
62
 
50
63
  __all__ = [
51
64
  "BudgetMiddleware",
52
65
  "RequiredSignatureDep",
53
66
  "SignatureDep",
67
+ "SignatureErrorDep",
68
+ "VerificationError",
54
69
  "VerifiedSignature",
55
70
  "attach",
71
+ "get_signature",
72
+ "get_signature_error",
73
+ "require_server_signature",
74
+ "signature_error_response",
56
75
  ]
57
76
 
58
77
  _STATE_ATTR = "regent_httpsig_verifier"
@@ -74,49 +93,128 @@ def _public_url(request: Request) -> str:
74
93
  return f"{scheme}://{host}{path}{query}"
75
94
 
76
95
 
77
- async def get_signature(request: Request) -> VerifiedSignature | None:
78
- """Optional verification: zero-cost without a Signature header, never raises."""
96
+ def _verifier_of(request: Request) -> HttpsigVerifier:
79
97
  verifier: HttpsigVerifier | None = getattr(request.app.state, _STATE_ATTR, None)
80
98
  if verifier is None:
81
99
  raise RuntimeError(
82
100
  "regent-httpsig verifier not attached — call "
83
101
  "regent_httpsig.fastapi.attach(app, HttpsigVerifier()) at startup"
84
102
  )
85
- if "signature" not in request.headers:
86
- return None
87
- cached = getattr(request.state, "regent_httpsig_result", "unset")
103
+ return verifier
104
+
105
+
106
+ async def _run_verifier(
107
+ verifier: HttpsigVerifier, request: Request,
108
+ ) -> VerifiedSignature | VerificationError | None:
109
+ """Verify once per request; both outcomes are cached on ``request.state``."""
110
+ cached: Any = getattr(request.state, "regent_httpsig_outcome", "unset")
88
111
  if cached != "unset":
89
- return cached # type: ignore[return-value]
90
- result = await verifier.verify(
91
- request.method, _public_url(request), dict(request.headers)
92
- )
93
- request.state.regent_httpsig_result = result
94
- return result
112
+ return cached if isinstance(cached, VerifiedSignature | VerificationError) else None
113
+ outcome: VerifiedSignature | VerificationError | None = None
114
+ if "signature" in request.headers:
115
+ # The -11 body rule (content-digest covered and matching) applies to the
116
+ # Signature-Key path; Web Bot Auth has no such rule and needs no body.
117
+ body = await request.body() if "signature-key" in request.headers else None
118
+ outcome = await verifier.verify_detailed(
119
+ request.method, _public_url(request), dict(request.headers), body,
120
+ )
121
+ request.state.regent_httpsig_outcome = outcome
122
+ request.state.regent_httpsig_result = (
123
+ outcome if isinstance(outcome, VerifiedSignature) else None)
124
+ request.state.regent_httpsig_error = (
125
+ outcome if isinstance(outcome, VerificationError) else None)
126
+ return outcome
127
+
128
+
129
+ def signature_error_response(
130
+ error: VerificationError, *, code: str | None = None, requirement: str | None = None,
131
+ ) -> JSONResponse:
132
+ """``401`` + ``Signature-Error`` (+ ``Accept-Signature-*``) with the
133
+ problem+json body of draft-hardt-httpbis-signature-key §5. ``requirement``
134
+ adds an ``AAuth-Requirement`` header; ``code`` adds a legacy ``code`` member."""
135
+ headers = error.headers()
136
+ if requirement:
137
+ headers["AAuth-Requirement"] = requirement
138
+ body = error.problem(401)
139
+ if code:
140
+ body["code"] = code
141
+ return JSONResponse(status_code=401, content=body, headers=headers,
142
+ media_type="application/problem+json")
143
+
144
+
145
+ def _repair_requirement(error: VerificationError) -> str | None:
146
+ """-11 §11.12.5 / budgets "auth token expired": a revoked or expired person
147
+ or auth token is repaired by a fresh person token; nothing repairs an agent
148
+ token (the agent obtains a new one from its provider)."""
149
+ if error.code in ("revoked_jwt", "expired_jwt") and error.token_typ in (
150
+ AAUTH_PERSON_TYP, AAUTH_AUTH_TYP,
151
+ ):
152
+ return build_person_token_requirement()
153
+ return None
154
+
155
+
156
+ async def get_signature(request: Request) -> VerifiedSignature | None:
157
+ """Optional verification: zero-cost without a Signature header, never raises."""
158
+ outcome = await _run_verifier(_verifier_of(request), request)
159
+ return outcome if isinstance(outcome, VerifiedSignature) else None
160
+
161
+
162
+ async def get_signature_error(request: Request) -> VerificationError | None:
163
+ """Why a signed request did not verify (``None`` when unsigned or verified)."""
164
+ outcome = await _run_verifier(_verifier_of(request), request)
165
+ return outcome if isinstance(outcome, VerificationError) else None
95
166
 
96
167
 
97
168
  async def require_signature(request: Request) -> VerifiedSignature:
98
169
  """Hard requirement: a fully verified signature, or a 401 that tells the
99
- agent exactly how to sign."""
100
- sig = await get_signature(request)
101
- if sig is None:
102
- raise HTTPException(
103
- status_code=401,
104
- detail={
105
- "code": "SIGNATURE_REQUIRED",
106
- "message": (
107
- "Sign this request with RFC 9421 HTTP Message Signatures: either "
108
- 'Web Bot Auth (tag="web-bot-auth", Ed25519 key published at '
109
- "{your-origin}/.well-known/http-message-signatures-directory, "
110
- "Signature-Agent header naming that origin) or AAuth (agent_token "
111
- "in the Signature-Key header, signature bound to its cnf.jwk)."
112
- ),
113
- },
114
- )
115
- return sig
170
+ agent exactly what went wrong (``Signature-Error``) or what to present
171
+ (``AAuth-Requirement: requirement=agent-token``)."""
172
+ outcome = await _run_verifier(_verifier_of(request), request)
173
+ if isinstance(outcome, VerifiedSignature):
174
+ return outcome
175
+ if isinstance(outcome, VerificationError):
176
+ headers = outcome.headers()
177
+ requirement = _repair_requirement(outcome)
178
+ if requirement:
179
+ headers["AAuth-Requirement"] = requirement
180
+ raise HTTPException(status_code=401, headers=headers,
181
+ detail={**outcome.problem(401), "code": "SIGNATURE_INVALID"})
182
+ raise HTTPException(
183
+ status_code=401,
184
+ headers={"AAuth-Requirement": "requirement=agent-token"},
185
+ detail={
186
+ "code": "SIGNATURE_REQUIRED",
187
+ "message": (
188
+ "Sign this request with RFC 9421 HTTP Message Signatures: either "
189
+ "AAuth (your agent token in the Signature-Key header under the jwt "
190
+ "scheme, signature over @method @authority @path signature-key bound "
191
+ 'to its cnf.jwk) or Web Bot Auth (tag="web-bot-auth", Ed25519 key '
192
+ "published at {your-origin}/.well-known/http-message-signatures-directory, "
193
+ "Signature-Agent header naming that origin)."
194
+ ),
195
+ },
196
+ )
197
+
198
+
199
+ async def require_server_signature(request: Request) -> VerifiedSignature:
200
+ """For endpoints servers call (usage, revocation): the caller must sign
201
+ under the ``jwks_uri`` scheme (-11 §11.3.2); the body is covered. A
202
+ failure is ``401`` + ``Signature-Error``."""
203
+ verifier = _verifier_of(request)
204
+ body = await request.body()
205
+ outcome = await verifier.verify_server(
206
+ request.method, _public_url(request), dict(request.headers), body,
207
+ )
208
+ if isinstance(outcome, VerifiedSignature):
209
+ return outcome
210
+ raise HTTPException(status_code=401, headers=outcome.headers(),
211
+ detail={**outcome.problem(401), "code": "SIGNATURE_INVALID"})
116
212
 
117
213
 
118
214
  SignatureDep = Depends(get_signature)
215
+ SignatureErrorDep = Depends(get_signature_error)
119
216
  RequiredSignatureDep = Depends(require_signature)
217
+ RequiredServerSignatureDep = Depends(require_server_signature)
120
218
 
121
219
 
122
220
  # ── AAuth Budgets enforcement (draft-hardt-aauth-budgets) ────────────────────
@@ -133,20 +231,17 @@ async def _maybe_await(value: Any) -> Any:
133
231
 
134
232
  class BudgetMiddleware(BaseHTTPMiddleware):
135
233
  """Meter budget-carrying requests: reserve the maximum cost atomically,
136
- serve, commit the actual, release the difference, and answer with an
137
- ``AAuth-Budget`` header. The full checklist a resource owes the draft —
138
- pricing excepted, which is the one thing only the resource can know.
234
+ serve, commit the actual cost, answer with ``AAuth-Budget``.
139
235
 
140
- Usage::
236
+ ::
141
237
 
142
- meter = InMemoryMeter()
143
238
  app.add_middleware(
144
239
  BudgetMiddleware,
145
240
  verifier=HttpsigVerifier(HttpsigConfig(
146
241
  resource_url="https://api.example",
147
242
  trusted_ps={"https://ps.example": "https://ps.example/jwks.json"},
148
243
  )),
149
- meter=meter,
244
+ meter=InMemoryMeter(),
150
245
  price_fn=lambda request: PRICES.get(request.url.path),
151
246
  )
152
247
 
@@ -157,6 +252,12 @@ class BudgetMiddleware(BaseHTTPMiddleware):
157
252
  - ``require=False`` (default) lets requests without a budget envelope pass
158
253
  through untouched — run per-decision authorization for them instead.
159
254
  ``require=True`` refuses them with 401 + ``AAuth-Requirement``.
255
+ - A *signed* request that does not verify is refused with ``401`` +
256
+ ``Signature-Error`` (-11 §11.3.4) whatever ``require`` says. A revoked or
257
+ expired auth token is ``revoked_jwt`` / ``expired_jwt`` plus
258
+ ``AAuth-Requirement: requirement=person-token`` (-11 §11.12.5) — no
259
+ resource token, no ``AAuth-Budget``; its final consumption record reaches
260
+ the issuer through the usage endpoint.
160
261
  - Error responses (4xx/5xx) release the reservation — nothing was served,
161
262
  the envelope is not charged.
162
263
  - ``resource_token_provider(key, record)`` (optional) mints the resource
@@ -165,6 +266,10 @@ class BudgetMiddleware(BaseHTTPMiddleware):
165
266
  the PRESENTED token's ``{"jti", "consumed"}`` (draft §The Consumption
166
267
  Record — one record, two members) or ``None`` when nothing was metered
167
268
  against it yet.
269
+ - Wire the verifier to the meter's revocation record
270
+ (``HttpsigVerifier(..., is_revoked=meter.is_revoked)``) so a revoked
271
+ token is named as revoked before it is metered; the middleware checks the
272
+ meter too, for verifiers wired otherwise.
168
273
  """
169
274
 
170
275
  def __init__(
@@ -191,14 +296,10 @@ class BudgetMiddleware(BaseHTTPMiddleware):
191
296
  if max_cost is None:
192
297
  return await call_next(request)
193
298
 
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)
299
+ outcome = await _run_verifier(self._verifier, request)
300
+ if isinstance(outcome, VerificationError):
301
+ return self._signature_error(outcome)
302
+ sig = outcome
202
303
  envelope: BudgetClaim | None = None
203
304
  if sig is not None:
204
305
  try:
@@ -217,6 +318,9 @@ class BudgetMiddleware(BaseHTTPMiddleware):
217
318
  str(sig.claims.get("sub", "")),
218
319
  str(sig.claims.get("aud", "")),
219
320
  )
321
+ is_revoked = getattr(self._meter, "is_revoked", None)
322
+ if is_revoked is not None and await is_revoked(key[0], jti):
323
+ return self._revoked()
220
324
  try:
221
325
  # sig.keyid is the RFC 7638 thumbprint of the token's cnf key (jkt) —
222
326
  # recorded so refusal-time consumption records are scoped to the
@@ -229,23 +333,23 @@ class BudgetMiddleware(BaseHTTPMiddleware):
229
333
  return self._refusal(reason="insufficient-budget", envelope=envelope,
230
334
  remaining=0, key=key)
231
335
 
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)
238
- if isinstance(outcome, InsufficientBudget):
239
- reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
336
+ outcome_r = await self._meter.reserve(key, jti, int(max_cost))
337
+ if isinstance(outcome_r, TokenRevoked):
338
+ # §Token Scope / -11 §11.12.5: say it was revoked, ask for a person
339
+ # token, carry no resource token. The final record (once nothing is
340
+ # in flight, AAuth #151) is the usage endpoint's to report.
341
+ return self._revoked()
342
+ if isinstance(outcome_r, InsufficientBudget):
343
+ reason = "budget-exhausted" if outcome_r.exhausted else "insufficient-budget"
240
344
  return await self._refusal_with_token(
241
- reason=reason, envelope=envelope, remaining=outcome.remaining,
345
+ reason=reason, envelope=envelope, remaining=outcome_r.remaining,
242
346
  key=key, jti=jti,
243
347
  # `required` rides only on insufficient-budget: what THIS
244
348
  # request needed, so the agent can lower its bound and retry.
245
349
  required=int(max_cost) if reason == "insufficient-budget" else None,
246
350
  )
247
351
 
248
- reservation: Reservation = outcome
352
+ reservation: Reservation = outcome_r
249
353
  try:
250
354
  response = await call_next(request)
251
355
  except Exception:
@@ -292,90 +396,42 @@ class BudgetMiddleware(BaseHTTPMiddleware):
292
396
 
293
397
  def _commit_after_stream(self, request: Request, response: Response,
294
398
  reservation: Reservation, max_cost: int) -> None:
295
- """Wrap the body iterator: commit when the stream ends (the handler may
296
- set ``request.state.budget_cost`` while streaming), release the unspent
297
- remainder; a broken stream commits the full hold — conservative, per
298
- the reservation-timeout rule."""
299
- inner = response.body_iterator # type: ignore[attr-defined]
399
+ """Wrap the body iterator so the reservation is committed (at the
400
+ handler's actual cost if it set one mid-stream, else the full hold)
401
+ when the stream ends, and released if it breaks before a byte is sent."""
402
+ meter = self._meter
403
+ original = response.body_iterator # type: ignore[attr-defined]
300
404
 
301
405
  async def metered() -> Any:
302
- ok = False
406
+ sent = False
303
407
  try:
304
- async for chunk in inner:
408
+ async for chunk in original:
409
+ sent = True
305
410
  yield chunk
306
- ok = True
307
- finally:
308
- actual = getattr(request.state, "budget_cost", None)
309
- cost = int(actual) if (ok and actual is not None) else max_cost
310
- await self._meter.commit(reservation, cost)
411
+ except BaseException:
412
+ await (meter.commit(reservation, max_cost) if sent
413
+ else meter.release(reservation))
414
+ raise
415
+ actual = getattr(request.state, "budget_cost", None)
416
+ await meter.commit(reservation,
417
+ int(actual) if actual is not None else max_cost)
311
418
 
312
419
  response.body_iterator = metered() # type: ignore[attr-defined]
313
420
 
314
421
  # ── helpers ──────────────────────────────────────────────────────────────
315
422
 
316
- async def _verified(self, request: Request) -> VerifiedSignature | None:
317
- cached = getattr(request.state, "regent_httpsig_result", "unset")
318
- if cached != "unset":
319
- return cached # type: ignore[return-value]
320
- result = None
321
- if "signature" in request.headers:
322
- result = await self._verifier.verify(
323
- request.method, _public_url(request), dict(request.headers),
324
- allow_expired_auth_token=True,
325
- )
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)
332
- return result
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
- )
423
+ def _signature_error(self, error: VerificationError) -> Response:
424
+ code = {"revoked_jwt": "AUTH_TOKEN_REVOKED", "expired_jwt": "AUTH_TOKEN_EXPIRED"}.get(
425
+ error.code, "SIGNATURE_INVALID")
426
+ return signature_error_response(error, code=code,
427
+ requirement=_repair_requirement(error))
428
+
429
+ def _revoked(self) -> Response:
430
+ """The meter knows the token is revoked (the verifier was not wired to
431
+ it, or learned out of band): the same -11 §11.12.5 answer."""
432
+ error = VerificationError("revoked_jwt", "the issuer has withdrawn this token",
433
+ token_typ=AAUTH_AUTH_TYP)
434
+ return self._signature_error(error)
379
435
 
380
436
  async def _refusal_with_token(
381
437
  self, *, reason: str, envelope: BudgetClaim,