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.
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/CHANGELOG.md +47 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/PKG-INFO +9 -1
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/README.md +8 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/pyproject.toml +1 -1
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/__init__.py +11 -7
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/budget.py +60 -4
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/fastapi.py +67 -2
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/sfv.py +10 -5
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/usage.py +48 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/verify.py +35 -4
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_budget.py +171 -6
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_usage.py +0 -2
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/.github/workflows/ci.yml +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/.github/workflows/release.yml +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/.gitignore +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/LICENSE +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/SECURITY.md +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/examples/fastapi_verify.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/examples/httpx_signer.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/cli.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/config.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/jwk.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/netguard.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/src/regent_httpsig/sign.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_netguard.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_signer.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_vectors.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/tests/test_verifier.py +0 -0
- {regent_httpsig-0.5.0 → regent_httpsig-0.6.0}/vectors/aauth-budgets-vectors.json +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
180
|
-
|
|
179
|
+
def build_aauth_requirement(*, reason: str | None,
|
|
180
|
+
resource_token: str | None = None) -> str:
|
|
181
|
+
"""Serialize ``AAuth-Requirement`` for a budget challenge:
|
|
181
182
|
``requirement=auth-token;resource-token="eyJ…";reason=insufficient-budget``.
|
|
182
|
-
``reason`` is an sf-token (``insufficient-budget`` | ``budget-exhausted``)
|
|
183
|
-
|
|
183
|
+
``reason`` is an sf-token (``insufficient-budget`` | ``budget-exhausted``)
|
|
184
|
+
for an exhaustion refusal, or ``None`` for the base protocol's plain
|
|
185
|
+
challenge (an EXPIRED auth token — the budget did not run out, the token
|
|
186
|
+
did); the resource token (when the resource issues one) rides as an
|
|
187
|
+
sf-string."""
|
|
184
188
|
out = "requirement=auth-token"
|
|
185
189
|
if resource_token:
|
|
186
190
|
out += f";resource-token={_sf_string(resource_token)}"
|
|
187
|
-
|
|
191
|
+
if reason:
|
|
192
|
+
out += f";reason={reason}"
|
|
188
193
|
return out
|
|
@@ -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(
|
|
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()
|
|
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()
|
|
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}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|