@@ -266,11 +266,13 @@ def _ps_pair() -> tuple[Ed25519PrivateKey, dict[str, Any]]:
266266
267267
268268def _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."""
270272 from regent_httpsig .verify import _register_fully_specified_algs
271273
272274 _register_fully_specified_algs () # PyJWT knows "Ed25519" only after this
273- now = int (time .time ())
275+ now = int (time .time ()) - age
274276 return pyjwt .encode (
275277 {
276278 "iss" : PS_ISS , "sub" : "owner-1" , "aud" : RESOURCE , "jti" : jti ,
@@ -499,3 +501,70 @@ async def test_streaming_cost_omitted_reserved_math(
499501 next_remaining = int (str (d2 ["remaining" ].value )) + 300 # add back r2's own cost
500502 recovered = 1000 + 300 - 700 - (1000 - next_remaining ) # draft's subtraction…
501503 assert 700 + 300 - next_remaining == 120 # …prev + reserved − next = cost
504+
505+
506+ async def test_expired_token_gets_final_record_challenge (
507+ monkeypatch : pytest .MonkeyPatch ,
508+ ) -> None :
509+ """§Budget Exhaustion, "auth token expired": plain requirement=auth-token
510+ (no reason) + a resource token carrying the token's FINAL record — the
511+ figure the issuer needs to settle the allocation (§Settlement: a record
512+ stated at/after exp is final)."""
513+ ps_priv , ps_jwk = _ps_pair ()
514+ agent = EgressSigner (seed = generate_seed (), signature_agent = PS_ISS )
515+ live = _auth_token (ps_priv , agent , amount = 1000 , jti = "at-9" )
516+ provider_calls : list [Any ] = []
517+
518+ def provider (key : Any , record : Any ) -> str :
519+ provider_calls .append ((key , record ))
520+ return "resource.token.final"
521+
522+ meter = InMemoryMeter ()
523+ app = _app (_verifier (ps_jwk , monkeypatch ), meter = meter ,
524+ resource_token_provider = provider )
525+ # Spend 300 on the live token, so there is a figure to carry.
526+ r = await _post (app , "/v1/search" , _signed_headers (agent , live , "/v1/search" ))
527+ assert r .status_code == 200
528+
529+ # The same jti, now past its exp (same issuer, same key, same claims).
530+ stale = _auth_token (ps_priv , agent , amount = 1000 , jti = "at-9" , age = 700 )
531+ r = await _post (app , "/v1/search" , _signed_headers (agent , stale , "/v1/search" ))
532+ assert r .status_code == 401
533+ assert r .json ()["code" ] == "AUTH_TOKEN_EXPIRED"
534+ assert (r .headers ["AAuth-Requirement" ]
535+ == 'requirement=auth-token;resource-token="resource.token.final"' )
536+ assert "AAuth-Budget" not in r .headers # the budget didn't run out; the token did
537+ assert provider_calls [- 1 ] == ((PS_ISS , "owner-1" , RESOURCE ),
538+ {"jti" : "at-9" , "consumed" : 300 })
539+ # Nothing was served and nothing more was metered.
540+ assert await meter .consumed_record (
541+ (PS_ISS , "owner-1" , RESOURCE ), "at-9" ) == {"jti" : "at-9" , "consumed" : 300 }
542+
543+
544+ async def test_expired_token_past_grace_is_no_envelope (
545+ monkeypatch : pytest .MonkeyPatch ,
546+ ) -> None :
547+ """Beyond the grace window there is no figure left to carry: the token is
548+ just an unknown one and the request falls through as unsigned."""
549+ ps_priv , ps_jwk = _ps_pair ()
550+ agent = EgressSigner (seed = generate_seed (), signature_agent = PS_ISS )
551+ ancient = _auth_token (ps_priv , agent , amount = 1000 , jti = "at-old" , age = 3 * 3600 )
552+ app = _app (_verifier (ps_jwk , monkeypatch ), require = True )
553+ r = await _post (app , "/v1/search" , _signed_headers (agent , ancient , "/v1/search" ))
554+ assert r .status_code == 401 and r .json ()["code" ] == "AUTH_TOKEN_REQUIRED"
555+
556+
557+ async def test_plain_verify_still_rejects_expired_tokens (
558+ monkeypatch : pytest .MonkeyPatch ,
559+ ) -> None :
560+ """The tolerance is opt-in for the budgets middleware only: an access
561+ decision through ``verify()`` never sees an expired token as verified."""
562+ ps_priv , ps_jwk = _ps_pair ()
563+ agent = EgressSigner (seed = generate_seed (), signature_agent = PS_ISS )
564+ stale = _auth_token (ps_priv , agent , amount = 1000 , jti = "at-9" , age = 700 )
565+ verifier = _verifier (ps_jwk , monkeypatch )
566+ headers = _signed_headers (agent , stale , "/v1/search" )
567+ assert await verifier .verify ("POST" , f"{ RESOURCE } /v1/search" , headers ) is None
568+ sig = await verifier .verify ("POST" , f"{ RESOURCE } /v1/search" , headers ,
569+ allow_expired_auth_token = True )
570+ assert sig is not None and sig .expired
0 commit comments