regent-httpsig 0.5.1__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.
regent_httpsig/sign.py CHANGED
@@ -1,39 +1,63 @@
1
- """Outbound RFC 9421 signing (Web Bot Auth) — give your agent a verifiable identity.
1
+ """Outbound RFC 9421 signing — give your agent (or your server) a verifiable identity.
2
2
 
3
- Sign every request your agent makes with an Ed25519 key whose public half you
4
- publish at ``{signature_agent}/.well-known/http-message-signatures-directory``.
5
- Verifiers that speak Web Bot Auth (Cloudflare, AWS WAF, Vercel, this library…)
6
- then see a *signed agent* instead of an anonymous bot.
3
+ Three signers, one key format (a 32-byte Ed25519 seed, base64url):
7
4
 
8
- The ``Signature-Agent`` header is emitted in the LEGACY sf-string form
9
- (``"https://…"``) — the form OpenAI ships in production, accepted by every
10
- deployed verifier today; the draft -05 sf-dictionary form still has patchy
11
- support.
5
+ - :class:`EgressSigner` signs as a **Web Bot Auth** agent: ``tag="web-bot-auth"``,
6
+ key published at ``{signature_agent}/.well-known/http-message-signatures-directory``.
7
+ Verifiers that speak Web Bot Auth (Cloudflare, AWS WAF, Vercel, this library…)
8
+ then see a *signed agent* instead of an anonymous bot. The ``Signature-Agent``
9
+ header is emitted in the LEGACY sf-string form (``"https://…"``) — the form
10
+ OpenAI ships in production, accepted by every deployed verifier today.
11
+ - :meth:`EgressSigner.sign_aauth` signs as an **AAuth** agent (-11 §11.3.3): the
12
+ token rides in ``Signature-Key`` under the ``jwt`` scheme, the signature covers
13
+ ``@method @authority @path signature-key`` (plus ``content-digest`` and
14
+ ``content-type`` when there is a body), ``created`` only — no ``keyid``, no
15
+ ``tag``, no ``alg`` parameter (the key comes from the token's ``cnf.jwk``).
16
+ - :class:`ServerSigner` signs as an AAuth **server** (-11 §11.3.2): a PS or AS
17
+ calling a resource's usage or revocation endpoint, under the ``jwks_uri``
18
+ scheme with ``id``, ``dwk`` and ``kid``.
12
19
 
13
20
  Unlike a service-level integration, this library FAILS LOUD: a bad seed raises
14
- at construction and a signing error raises from :meth:`EgressSigner.sign`.
15
- Wrap in try/except yourself if your egress path must never break on signing.
21
+ at construction and a signing error raises from the ``sign`` methods. Wrap in
22
+ try/except yourself if your egress path must never break on signing.
16
23
  """
17
24
 
18
25
  from __future__ import annotations
19
26
 
27
+ import base64
28
+ import hashlib
20
29
  import secrets
30
+ import time
31
+ from collections.abc import Mapping
21
32
  from datetime import datetime, timedelta
22
33
  from typing import Any
34
+ from urllib.parse import urlsplit
23
35
 
36
+ from cryptography.hazmat.primitives import hashes
37
+ from cryptography.hazmat.primitives.asymmetric import ec
24
38
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
39
+ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
25
40
  from http_message_signatures import ( # type: ignore[attr-defined]
26
41
  HTTPMessageSigner,
27
42
  HTTPSignatureKeyResolver,
28
43
  )
29
44
 
45
+ from regent_httpsig.config import AAUTH_REQUIRED_COMPONENTS
30
46
  from regent_httpsig.jwk import b64url, b64url_decode, jwk_thumbprint
31
47
  from regent_httpsig.sfv import ED25519, Message
32
48
 
33
- __all__ = ["DIRECTORY_MEDIA_TYPE", "EgressSigner", "generate_seed"]
49
+ __all__ = [
50
+ "DIRECTORY_MEDIA_TYPE",
51
+ "EgressSigner",
52
+ "ServerSigner",
53
+ "content_digest",
54
+ "generate_seed",
55
+ "sign_request",
56
+ ]
34
57
 
35
58
  DIRECTORY_MEDIA_TYPE = "application/http-message-signatures-directory+json"
36
59
  _DEFAULT_COVERED = ("@method", "@authority", "@path", "signature-agent")
60
+ _BODY_COMPONENTS = ("content-digest", "content-type")
37
61
 
38
62
 
39
63
  def generate_seed() -> str:
@@ -41,6 +65,119 @@ def generate_seed() -> str:
41
65
  return b64url(secrets.token_bytes(32))
42
66
 
43
67
 
68
+ def content_digest(body: bytes) -> str:
69
+ """RFC 9530 ``Content-Digest`` value for ``body`` (sha-256, sf-byte-sequence)."""
70
+ return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
71
+
72
+
73
+ def _load_seed(seed: str) -> Ed25519PrivateKey:
74
+ raw = b64url_decode(seed)
75
+ if len(raw) != 32:
76
+ raise ValueError("seed must be 32 bytes (base64url-encoded)")
77
+ return Ed25519PrivateKey.from_private_bytes(raw)
78
+
79
+
80
+ def _ed25519_jwk(key: Ed25519PrivateKey) -> dict[str, Any]:
81
+ return {"kty": "OKP", "crv": "Ed25519", "x": b64url(key.public_key().public_bytes_raw())}
82
+
83
+
84
+ # ── RFC 9421 signature base, request side ─────────────────────────────────────
85
+
86
+
87
+ def _field(headers: Mapping[str, str], name: str) -> str:
88
+ """RFC 9421 §2.1: the field value, whitespace trimmed, instances joined by ', '."""
89
+ values = [v.strip() for k, v in headers.items() if k.lower() == name]
90
+ if not values:
91
+ raise ValueError(f'covered header "{name}" is not in the message')
92
+ return ", ".join(values)
93
+
94
+
95
+ def _derived(name: str, method: str, url: str) -> str:
96
+ parts = urlsplit(url)
97
+ if name == "@method":
98
+ return method.upper()
99
+ if name == "@authority":
100
+ host = (parts.hostname or "").lower()
101
+ port = parts.port
102
+ default = {"https": 443, "http": 80}.get(parts.scheme)
103
+ return host if port is None or port == default else f"{host}:{port}"
104
+ if name == "@path":
105
+ return parts.path or "/"
106
+ if name == "@query":
107
+ return "?" + parts.query
108
+ if name == "@target-uri":
109
+ return url
110
+ if name == "@scheme":
111
+ return parts.scheme
112
+ raise ValueError(f"unsupported derived component {name!r}")
113
+
114
+
115
+ def _signature_base(method: str, url: str, headers: Mapping[str, str],
116
+ covered: tuple[str, ...], params: str) -> str:
117
+ lines = []
118
+ for component in covered:
119
+ value = (_derived(component, method, url) if component.startswith("@")
120
+ else _field(headers, component))
121
+ lines.append(f'"{component}": {value}')
122
+ lines.append(f'"@signature-params": {params}')
123
+ return "\n".join(lines)
124
+
125
+
126
+ def _raw_sign(key: Any, data: bytes) -> bytes:
127
+ """RFC 9421 §3.3 signature bytes: Ed25519 raw, ECDSA as raw ``r || s``."""
128
+ if isinstance(key, Ed25519PrivateKey):
129
+ return key.sign(data)
130
+ if isinstance(key, ec.EllipticCurvePrivateKey):
131
+ r, s = decode_dss_signature(key.sign(data, ec.ECDSA(hashes.SHA256())))
132
+ size = (key.curve.key_size + 7) // 8
133
+ return r.to_bytes(size, "big") + s.to_bytes(size, "big")
134
+ raise TypeError("key must be an Ed25519 or EC private key")
135
+
136
+
137
+ def sign_request(
138
+ key: Any,
139
+ method: str,
140
+ url: str,
141
+ headers: Mapping[str, str],
142
+ *,
143
+ covered: tuple[str, ...],
144
+ label: str = "sig",
145
+ created: int | None = None,
146
+ expires: int | None = None,
147
+ ) -> dict[str, str]:
148
+ """Sign ``(method, url, headers)`` the AAuth way: the covered components as
149
+ given, ``created`` (and ``expires`` if asked), no ``keyid``/``alg``/``tag``.
150
+ Returns ``headers`` plus ``Signature-Input`` and ``Signature``. Every
151
+ covered header must already be present (``Signature-Key``, ``Content-Digest``,
152
+ ``Content-Type``…) — the caller adds them first."""
153
+ created = int(created if created is not None else time.time())
154
+ inner = "(" + " ".join(f'"{c}"' for c in covered) + f");created={created}"
155
+ if expires is not None:
156
+ inner += f";expires={int(expires)}"
157
+ base = _signature_base(method, url, headers, covered, inner)
158
+ signature = base64.b64encode(_raw_sign(key, base.encode())).decode()
159
+ out = dict(headers)
160
+ out["Signature-Input"] = f"{label}={inner}"
161
+ out["Signature"] = f"{label}=:{signature}:"
162
+ return out
163
+
164
+
165
+ def _covered_for(body: bytes | None, headers: Mapping[str, str]) -> tuple[str, ...]:
166
+ has_body = body is not None and len(body) > 0
167
+ return AAUTH_REQUIRED_COMPONENTS + (_BODY_COMPONENTS if has_body else ())
168
+
169
+
170
+ def _with_body(headers: dict[str, str], body: bytes | None) -> dict[str, str]:
171
+ if body:
172
+ headers["Content-Digest"] = content_digest(body)
173
+ if not any(k.lower() == "content-type" for k in headers):
174
+ headers["Content-Type"] = "application/json"
175
+ return headers
176
+
177
+
178
+ # ── Web Bot Auth / AAuth agent ────────────────────────────────────────────────
179
+
180
+
44
181
  class _Resolver(HTTPSignatureKeyResolver):
45
182
  def __init__(self, key: Ed25519PrivateKey):
46
183
  self._key = key
@@ -53,7 +190,8 @@ class _Resolver(HTTPSignatureKeyResolver):
53
190
 
54
191
 
55
192
  class EgressSigner:
56
- """Sign outbound requests as a Web Bot Auth agent.
193
+ """Sign outbound requests as an agent — Web Bot Auth (:meth:`sign`) or
194
+ AAuth (:meth:`sign_aauth`) with the same Ed25519 key.
57
195
 
58
196
  Usage::
59
197
 
@@ -65,25 +203,22 @@ class EgressSigner:
65
203
  Publish ``signer.directory()`` as JSON at
66
204
  ``https://myagent.example/.well-known/http-message-signatures-directory``
67
205
  (or run ``regent-httpsig keygen`` to generate both the seed and the files).
206
+ For AAuth, register ``signer.public_jwk`` as the ``cnf.jwk`` of your agent
207
+ token and call :meth:`sign_aauth` with the token.
68
208
  """
69
209
 
70
210
  def __init__(self, *, seed: str, signature_agent: str, ttl_minutes: int = 5):
71
- raw = b64url_decode(seed)
72
- if len(raw) != 32:
73
- raise ValueError("seed must be 32 bytes (base64url-encoded)")
74
- self._key = Ed25519PrivateKey.from_private_bytes(raw)
211
+ self._key = _load_seed(seed)
75
212
  self.signature_agent = signature_agent
76
213
  self._ttl = ttl_minutes
77
- self._jwk = {
78
- "kty": "OKP",
79
- "crv": "Ed25519",
80
- "x": b64url(self._key.public_key().public_bytes_raw()),
81
- }
214
+ self._jwk = _ed25519_jwk(self._key)
82
215
  self.keyid = jwk_thumbprint(self._jwk)
83
216
 
84
217
  @property
85
218
  def public_jwk(self) -> dict[str, Any]:
86
- return dict(self._jwk)
219
+ """The public key as a JWK with a fully-specified ``alg`` (RFC 9864) —
220
+ what an AAuth token's ``cnf.jwk`` must carry under -11."""
221
+ return {**self._jwk, "alg": "Ed25519"}
87
222
 
88
223
  def directory(self) -> dict[str, Any]:
89
224
  """The JWKS document to serve at
@@ -101,7 +236,7 @@ class EgressSigner:
101
236
  covered: tuple[str, ...] = _DEFAULT_COVERED,
102
237
  label: str = "sig1",
103
238
  ) -> dict[str, str]:
104
- """Return ``headers`` + ``Signature-Agent``/``Signature-Input``/``Signature``."""
239
+ """Web Bot Auth: ``headers`` + ``Signature-Agent``/``Signature-Input``/``Signature``."""
105
240
  out = dict(headers or {})
106
241
  out["Signature-Agent"] = f'"{self.signature_agent}"' # legacy sf-string form
107
242
  message = Message(method.upper(), url, out)
@@ -119,3 +254,76 @@ class EgressSigner:
119
254
  covered_component_ids=covered,
120
255
  )
121
256
  return dict(message.headers)
257
+
258
+ def sign_aauth(
259
+ self,
260
+ method: str,
261
+ url: str,
262
+ headers: dict[str, str] | None = None,
263
+ *,
264
+ token: str,
265
+ body: bytes | None = None,
266
+ label: str = "sig",
267
+ created: int | None = None,
268
+ ) -> dict[str, str]:
269
+ """AAuth -11 §11.3.3: carry ``token`` (an agent, person or auth token
270
+ whose ``cnf.jwk`` is this signer's key) in ``Signature-Key`` under the
271
+ ``jwt`` scheme and sign ``@method @authority @path signature-key`` —
272
+ plus ``content-digest content-type`` when ``body`` is given, with
273
+ ``Content-Digest`` computed here. Send ``body`` exactly as passed."""
274
+ out = _with_body(dict(headers or {}), body)
275
+ out["Signature-Key"] = f'{label}=jwt;jwt="{token}"'
276
+ return sign_request(self._key, method, url, out,
277
+ covered=_covered_for(body, out), label=label, created=created)
278
+
279
+
280
+ # ── AAuth server (PS / AS / resource calling another server) ──────────────────
281
+
282
+
283
+ class ServerSigner:
284
+ """Sign requests as an AAuth server under the ``jwks_uri`` scheme (-11
285
+ §11.3.2): ``Signature-Key: sig=jwks_uri;id="<server_id>";dwk="<dwk>";kid="…"``.
286
+ The recipient fetches ``{server_id}/.well-known/{dwk}``, follows its
287
+ ``jwks_uri`` and finds ``kid`` — serve :meth:`jwks` there.
288
+
289
+ ``dwk`` names the metadata document you publish: ``aauth-person.json`` for
290
+ a PS, ``aauth-access.json`` for an AS, ``aauth-resource.json`` for a
291
+ resource, ``aauth-agent.json`` for an agent provider.
292
+ """
293
+
294
+ def __init__(self, *, seed: str, server_id: str, dwk: str,
295
+ kid: str | None = None, label: str = "sig") -> None:
296
+ self._key = _load_seed(seed)
297
+ self.server_id = server_id.rstrip("/")
298
+ self.dwk = dwk
299
+ self.label = label
300
+ self._jwk = _ed25519_jwk(self._key)
301
+ self.keyid = kid or jwk_thumbprint(self._jwk)
302
+
303
+ @property
304
+ def public_jwk(self) -> dict[str, Any]:
305
+ return {**self._jwk, "kid": self.keyid, "alg": "Ed25519", "use": "sig"}
306
+
307
+ def jwks(self) -> dict[str, Any]:
308
+ """The JWKS to serve at your metadata document's ``jwks_uri``."""
309
+ return {"keys": [self.public_jwk]}
310
+
311
+ def signature_key_header(self) -> str:
312
+ return (f'{self.label}=jwks_uri;id="{self.server_id}";'
313
+ f'dwk="{self.dwk}";kid="{self.keyid}"')
314
+
315
+ def sign(
316
+ self,
317
+ method: str,
318
+ url: str,
319
+ headers: dict[str, str] | None = None,
320
+ *,
321
+ body: bytes | None = None,
322
+ created: int | None = None,
323
+ ) -> dict[str, str]:
324
+ """Return ``headers`` + ``Signature-Key``/``Signature-Input``/``Signature``
325
+ (+ ``Content-Digest`` when ``body`` is given)."""
326
+ out = _with_body(dict(headers or {}), body)
327
+ out["Signature-Key"] = self.signature_key_header()
328
+ return sign_request(self._key, method, url, out,
329
+ covered=_covered_for(body, out), label=self.label, created=created)
regent_httpsig/usage.py CHANGED
@@ -36,7 +36,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
36
36
  from regent_httpsig.jwk import b64url_decode, jwk_thumbprint
37
37
 
38
38
  __all__ = ["UsageQueryError", "ResponseSigner", "build_usage_response",
39
- "parse_usage_request", "make_usage_endpoint",
39
+ "parse_usage_request", "make_usage_endpoint", "make_revocation_endpoint",
40
40
  "validate_budget_grant"]
41
41
 
42
42
  _SCOPE_KEYS = ("sub", "tenant", "mission_s256")
@@ -109,19 +109,25 @@ async def build_usage_response(
109
109
  class ResponseSigner:
110
110
  """Signs a usage response per §The Signed Response: an Ed25519 HTTP Sig
111
111
  covering ``@status``, ``content-type``, ``content-digest``, bound to the
112
- request via ``@authority``/``@path`` with the ``req`` parameter.
112
+ request via ``@authority``/``@path`` with the ``req`` parameter. The key is
113
+ identified the -11 way (§11.3.2): ``Signature-Key: sig=jwks_uri;
114
+ id="<server_id>";dwk="aauth-resource.json";kid="…"`` — the PS fetches
115
+ ``{server_id}/.well-known/aauth-resource.json``, follows ``jwks_uri`` and
116
+ finds ``kid`` there (serve :meth:`jwks`).
113
117
 
114
118
  Hand-built base string: response signing with request-bound components is
115
119
  beyond the request-oriented helper libraries, the component set is fixed by
116
120
  the draft, and the golden tests freeze every byte of it.
117
121
  """
118
122
 
119
- def __init__(self, *, seed: str, jwks_url: str, label: str = "sig") -> None:
123
+ def __init__(self, *, seed: str, server_id: str, dwk: str = "aauth-resource.json",
124
+ kid: str | None = None, label: str = "sig") -> None:
120
125
  raw = b64url_decode(seed)
121
126
  if len(raw) != 32:
122
127
  raise ValueError("seed must be 32 bytes (base64url-encoded)")
123
128
  self._key = Ed25519PrivateKey.from_private_bytes(raw)
124
- self._jwks_url = jwks_url
129
+ self.server_id = server_id.rstrip("/")
130
+ self.dwk = dwk
125
131
  self._label = label
126
132
  self.public_jwk = {
127
133
  "kty": "OKP", "crv": "Ed25519",
@@ -129,7 +135,15 @@ class ResponseSigner:
129
135
  self._key.public_key().public_bytes_raw()
130
136
  ).rstrip(b"=").decode(),
131
137
  }
132
- self.keyid = jwk_thumbprint(self.public_jwk)
138
+ self.keyid = kid or jwk_thumbprint(self.public_jwk)
139
+
140
+ def jwks(self) -> dict[str, Any]:
141
+ """The JWKS to serve at the ``jwks_uri`` your metadata document names."""
142
+ return {"keys": [{**self.public_jwk, "kid": self.keyid, "alg": "Ed25519", "use": "sig"}]}
143
+
144
+ def signature_key_header(self) -> str:
145
+ return (f'{self._label}=jwks_uri;id="{self.server_id}";'
146
+ f'dwk="{self.dwk}";kid="{self.keyid}"')
133
147
 
134
148
  def sign(self, *, status: int, content_type: str, body: bytes,
135
149
  authority: str, path: str,
@@ -157,10 +171,41 @@ class ResponseSigner:
157
171
  "Content-Digest": digest,
158
172
  "Signature-Input": f"{self._label}={inner}",
159
173
  "Signature": f"{self._label}=:{sig}:",
160
- "Signature-Key": f'{self._label}=jwks_uri; jwks_uri="{self._jwks_url}"',
174
+ "Signature-Key": self.signature_key_header(),
161
175
  }
162
176
 
163
177
 
178
+ def _problem(status: int, error: str, detail: str,
179
+ headers: dict[str, str] | None = None) -> Any:
180
+ """-11 §11.9.2: ``application/problem+json`` with the REQUIRED ``error`` member.
181
+ Typed ``Any`` because starlette is an optional extra; it is a ``JSONResponse``."""
182
+ from starlette.responses import JSONResponse
183
+
184
+ return JSONResponse(
185
+ status_code=status, media_type="application/problem+json",
186
+ content={"error": error, "status": status, "title": error.replace("_", " "),
187
+ "detail": detail},
188
+ headers=headers or {},
189
+ )
190
+
191
+
192
+ async def _caller(authenticate: Callable[[Any], Awaitable[Any]],
193
+ request: Any) -> tuple[str | None, Any]:
194
+ """Normalize what ``authenticate_ps`` returns: an issuer string, a
195
+ :class:`VerifiedSignature` (its ``agent``), a :class:`VerificationError`
196
+ (→ 401 + Signature-Error) or ``None`` (→ 401 invalid_signature)."""
197
+ from regent_httpsig.verify import VerificationError, VerifiedSignature
198
+
199
+ result = await authenticate(request)
200
+ if isinstance(result, VerifiedSignature):
201
+ return result.agent, None
202
+ if isinstance(result, str) and result:
203
+ return result, None
204
+ error = result if isinstance(result, VerificationError) else VerificationError(
205
+ "invalid_signature", "sign the request as a server (jwks_uri scheme)")
206
+ return None, _problem(401, error.code, error.detail, error.headers())
207
+
208
+
164
209
  def make_usage_endpoint(
165
210
  meter: Any,
166
211
  *,
@@ -171,19 +216,19 @@ def make_usage_endpoint(
171
216
  ) -> Callable[[Any], Awaitable[Any]]:
172
217
  """Build an ASGI-framework-agnostic handler: ``handler(request)`` returns a
173
218
  Starlette/FastAPI ``Response``. ``authenticate_ps(request)`` verifies the
174
- calling person server's signature (jwks_uri scheme, per the AS token
175
- endpoint rules) and returns its issuer identifier, or ``None`` to refuse —
176
- the resource MUST only answer for values seen in tokens from that PS,
219
+ calling person server's signature (-11 §11.3.2, jwks_uri scheme) and
220
+ returns its identity — a ``VerifiedSignature`` from
221
+ :meth:`HttpsigVerifier.verify_server`, or the issuer string — or a
222
+ ``VerificationError``/``None`` to refuse with 401 + ``Signature-Error``.
223
+ The resource MUST only answer for values seen in tokens from that PS,
177
224
  which the ``iss``-keyed counters enforce structurally."""
178
225
  from starlette.responses import JSONResponse, Response
179
226
 
180
227
  async def handler(request: Any) -> Response:
181
- iss = await authenticate_ps(request)
228
+ iss, refused = await _caller(authenticate_ps, request)
182
229
  if iss is None:
183
- return JSONResponse(status_code=401, content={
184
- "code": "PS_AUTH_REQUIRED",
185
- "message": "Sign the query as a person server (jwks_uri scheme).",
186
- })
230
+ refused_response: Response = refused
231
+ return refused_response
187
232
  try:
188
233
  payload = json.loads(await request.body() or b"{}")
189
234
  scope_key, scope_value, jkts = parse_usage_request(payload)
@@ -221,3 +266,63 @@ def validate_budget_grant(unit: str, decimals: int,
221
266
  f"(declared in budget_units), got {decimals}")
222
267
  return
223
268
  raise ValueError(f"unit {unit!r} is not declared in budget_units")
269
+
270
+
271
+ def make_revocation_endpoint(
272
+ meter: Any,
273
+ *,
274
+ authenticate_ps: Callable[[Any], Awaitable[Any]],
275
+ accepted_issuers: Callable[[str], bool] | None = None,
276
+ max_token_lifetime: float = 86400.0,
277
+ clock_skew: float = 60.0,
278
+ ) -> Callable[[Any], Awaitable[Any]]:
279
+ """The base protocol's revocation endpoint (-11 §11.12) for the budgets
280
+ meter: a signed ``POST {"jti", "exp"}``. The issuer is NOT a request
281
+ parameter — it is the identity verified on the caller's server signature
282
+ (§11.3.2), so a caller revokes only its own tokens. The pair ``(iss, jti)``
283
+ is recorded whether or not the token was ever seen here (a resource that
284
+ verifies statelessly still answers 200), and the answer is always an empty
285
+ ``200``: there is no "not found" (Appendix C.2.7).
286
+
287
+ Errors (§11.12.3, problem+json): ``invalid_request`` 400 for malformed
288
+ JSON or a missing/malformed ``jti``/``exp`` — including an ``exp`` further
289
+ ahead than ``max_token_lifetime`` + ``clock_skew``, the longest lifetime
290
+ this resource accepts; ``unsupported_iss`` 403 when ``accepted_issuers``
291
+ says no (default: any verified caller); a signature that does not verify
292
+ is 401 + ``Signature-Error``. ``rate_limited`` and ``server_error`` are
293
+ the application's to raise.
294
+
295
+ What revocation does to the meter is :meth:`InMemoryMeter.record_revocation`:
296
+ no new spend, in-flight requests complete, and the token's consumption
297
+ record is withheld until they have (AAuth #151); a later presentation of
298
+ the token is refused with ``revoked_jwt`` (§11.12.5)."""
299
+ from starlette.responses import Response
300
+
301
+ async def handler(request: Any) -> Response:
302
+ caller, refused = await _caller(authenticate_ps, request)
303
+ if caller is None:
304
+ refused_response: Response = refused
305
+ return refused_response
306
+ if accepted_issuers is not None and not accepted_issuers(caller):
307
+ unsupported: Response = _problem(
308
+ 403, "unsupported_iss",
309
+ "this resource does not accept revocations from that issuer")
310
+ return unsupported
311
+ try:
312
+ payload = json.loads(await request.body() or b"")
313
+ if not isinstance(payload, dict):
314
+ raise ValueError("body must be a JSON object")
315
+ jti, exp = payload.get("jti"), payload.get("exp")
316
+ if not isinstance(jti, str) or not jti or len(jti) > 512:
317
+ raise ValueError("jti is REQUIRED (string)")
318
+ if isinstance(exp, bool) or not isinstance(exp, int | float):
319
+ raise ValueError("exp is REQUIRED (integer seconds)")
320
+ if exp > time.time() + max_token_lifetime + clock_skew:
321
+ raise ValueError("exp is further ahead than any token this resource accepts")
322
+ except (ValueError, json.JSONDecodeError) as exc:
323
+ invalid: Response = _problem(400, "invalid_request", str(exc)[:200])
324
+ return invalid
325
+ await meter.record_revocation(caller, jti, float(exp))
326
+ return Response(status_code=200)
327
+
328
+ return handler