4343 "InsufficientBudget" ,
4444 "InvalidBudgetClaim" ,
4545 "Reservation" ,
46+ "TokenRevoked" ,
4647 "UnitMismatch" ,
4748]
4849
@@ -112,6 +113,17 @@ class InsufficientBudget:
112113 exhausted : bool
113114
114115
116+ @dataclass (frozen = True )
117+ class TokenRevoked :
118+ """Refusal: the presented token was revoked (base protocol §Token
119+ Revocation, budgets §Token Scope). The budget ended with the token — no
120+ new spend — while requests already in flight complete. ``drained`` says
121+ whether those have all settled: only then may the resource state the
122+ token's consumption record, which is then FINAL (AAuth issue #151)."""
123+
124+ drained : bool
125+
126+
115127@dataclass
116128class _Pool :
117129 unit : str
@@ -120,6 +132,7 @@ class _Pool:
120132 consumed : dict [str , int ] = field (default_factory = dict ) # jti -> total committed
121133 jkt_of : dict [str , str ] = field (default_factory = dict ) # jti -> presenting key thumbprint
122134 reservations : dict [int , tuple [str , int , float ]] = field (default_factory = dict )
135+ revoked : dict [str , float ] = field (default_factory = dict ) # jti -> wall time of revocation
123136 last_activity : float = 0.0
124137
125138
@@ -178,6 +191,7 @@ def __init__(self, *, reservation_ttl: float = 120.0,
178191 retention_seconds : float = 7200.0 ,
179192 usage_key_retention : float = 86400.0 ) -> None :
180193 self ._pools : dict [MeterKey , _Pool ] = {}
194+ self ._jti_index : dict [tuple [str , str ], MeterKey ] = {} # (iss, jti) -> ledger key
181195 self ._lock = asyncio .Lock ()
182196 self ._rids = itertools .count (1 )
183197 self ._reservation_ttl = reservation_ttl
@@ -214,9 +228,16 @@ def _purge(self, key: MeterKey, now: float) -> _Pool | None:
214228 if (not pool .grants and not pool .reservations
215229 and now - pool .last_activity > self ._retention ):
216230 del self ._pools [key ]
231+ for pair in [p for p , k in self ._jti_index .items () if k == key ]:
232+ del self ._jti_index [pair ]
217233 return None
218234 return pool
219235
236+ @staticmethod
237+ def _drained (pool : _Pool , jti : str ) -> bool :
238+ """No request is in flight on this token any more."""
239+ return not any (j == jti for j , _ , _ in pool .reservations .values ())
240+
220241 @staticmethod
221242 def _remaining (pool : _Pool , jti : str ) -> int :
222243 """The presented token's balance: its grant minus what was committed
@@ -252,14 +273,17 @@ async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
252273 pool .last_activity = now
253274 if jkt :
254275 pool .jkt_of .setdefault (jti , jkt )
255- if jti not in pool .grants and wall_delta > 0 :
276+ self ._jti_index .setdefault ((key [0 ], jti ), key )
277+ if jti not in pool .grants and wall_delta > 0 and jti not in pool .revoked :
256278 pool .grants [jti ] = (claim .amount , now + wall_delta )
257279
258280 async def reserve (self , key : MeterKey , jti : str ,
259- max_cost : int ) -> Reservation | InsufficientBudget :
281+ max_cost : int ) -> Reservation | InsufficientBudget | TokenRevoked :
260282 async with self ._lock :
261283 now = time .monotonic ()
262284 pool = self ._purge (key , now )
285+ if pool is not None and jti in pool .revoked :
286+ return TokenRevoked (drained = self ._drained (pool , jti ))
263287 if pool is None or jti not in pool .grants :
264288 return InsufficientBudget (remaining = 0 , exhausted = True )
265289 remaining = self ._remaining (pool , jti )
@@ -302,6 +326,33 @@ async def remaining(self, key: MeterKey, jti: str) -> int:
302326 pool = self ._purge (key , time .monotonic ())
303327 return 0 if pool is None else self ._remaining (pool , jti )
304328
329+ async def revoke (self , iss : str , jti : str ) -> bool :
330+ """Revoke an auth token by ``(iss, jti)`` — the base protocol's
331+ revocation identifier. The grant is withdrawn so no new request can
332+ reserve against it; requests already in flight complete and are
333+ committed as usual. ``False`` when the pair is unknown here (the
334+ endpoint answers 404). Idempotent."""
335+ async with self ._lock :
336+ key = self ._jti_index .get ((iss , jti ))
337+ if key is None :
338+ return False
339+ pool = self ._purge (key , time .monotonic ())
340+ if pool is None :
341+ return False
342+ pool .revoked .setdefault (jti , time .time ())
343+ pool .grants .pop (jti , None )
344+ pool .last_activity = time .monotonic ()
345+ return True
346+
347+ async def revocation_state (self , key : MeterKey , jti : str ) -> TokenRevoked | None :
348+ """``TokenRevoked`` (with its drain state) if the token was revoked
349+ here, else ``None``."""
350+ async with self ._lock :
351+ pool = self ._purge (key , time .monotonic ())
352+ if pool is None or jti not in pool .revoked :
353+ return None
354+ return TokenRevoked (drained = self ._drained (pool , jti ))
355+
305356 def _record_usage (self , key : MeterKey , pool : _Pool , jti : str ,
306357 amount : int ) -> None :
307358 """Post a committed cost to the usage counters (call under lock).
@@ -352,6 +403,10 @@ async def consumed_record(self, key: MeterKey, jti: str) -> dict[str, Any] | Non
352403 pool = self ._purge (key , time .monotonic ())
353404 if pool is None :
354405 return None
406+ if jti in pool .revoked and not self ._drained (pool , jti ):
407+ # §Token Scope + AAuth #151: a revoked token's record is FINAL,
408+ # so it is stated only once nothing is in flight on it.
409+ return None
355410 total = pool .consumed .get (jti , 0 )
356411 return {"jti" : jti , "consumed" : total } if total > 0 else None
357412
0 commit comments