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.
regent_httpsig/sfv.py CHANGED
@@ -34,7 +34,10 @@ __all__ = [
34
34
  "StaticKeyResolver",
35
35
  "build_aauth_budget_header",
36
36
  "build_aauth_requirement",
37
+ "build_person_token_requirement",
37
38
  "parse_signature_agent",
39
+ "parse_signature_input",
40
+ "parse_signature_key",
38
41
  "parse_signature_key_header",
39
42
  ]
40
43
 
@@ -120,20 +123,69 @@ def parse_signature_agent(value: str) -> str | None:
120
123
  return None
121
124
 
122
125
 
123
- def parse_signature_key_header(value: str) -> tuple[str, str] | None:
124
- """``Signature-Key: sig=jwt;jwt="eyJ…"`` → (label, jwt) — the AAuth carrier."""
126
+ def _plain(value: Any) -> Any:
127
+ """An sf-value as a plain Python value (Token/String → str, Integer → int)."""
128
+ if isinstance(value, (int, str, bool)):
129
+ return value
130
+ try:
131
+ inner = getattr(value, "value", value)
132
+ return inner if isinstance(inner, (int, str, bool)) else str(inner)
133
+ except Exception: # noqa: BLE001
134
+ return str(value)
135
+
136
+
137
+ def parse_signature_key(value: str) -> list[tuple[str, str, dict[str, Any]]]:
138
+ """``Signature-Key`` (draft-hardt-httpbis-signature-key) → one
139
+ ``(label, scheme, params)`` per member, in header order. The member's bare
140
+ item is the scheme token (``jwt``, ``jwks_uri``, ``hwk``, …); its parameters
141
+ are returned as plain values. Malformed header → ``[]``."""
142
+ out: list[tuple[str, str, dict[str, Any]]] = []
125
143
  try:
126
144
  node = SFDictionary()
127
145
  node.parse(value.encode())
128
146
  for label, member in node.items():
129
- jwt_param = member.params.get("jwt")
130
- if jwt_param:
131
- return str(label), str(jwt_param)
147
+ scheme = _plain(getattr(member, "value", member))
148
+ params = {str(k): _plain(v) for k, v in member.params.items()}
149
+ out.append((str(label), str(scheme), params))
132
150
  except Exception: # noqa: BLE001
133
- return None
151
+ return []
152
+ return out
153
+
154
+
155
+ def parse_signature_key_header(value: str) -> tuple[str, str] | None:
156
+ """``Signature-Key: sig=jwt;jwt="eyJ…"`` → (label, jwt) — the AAuth carrier.
157
+ Kept for callers of 0.6; :func:`parse_signature_key` sees every scheme."""
158
+ for label, scheme, params in parse_signature_key(value):
159
+ if scheme == "jwt" and params.get("jwt"):
160
+ return label, str(params["jwt"])
134
161
  return None
135
162
 
136
163
 
164
+ def parse_signature_input(value: str) -> dict[str, tuple[list[str], dict[str, Any]]]:
165
+ """``Signature-Input`` (RFC 9421 §4.1) → ``{label: (covered components, params)}``.
166
+ Components come back as their identifiers (``@method``, ``content-digest``,
167
+ ``"signature-agent";key="agent2"`` → ``signature-agent``); params as plain values."""
168
+ out: dict[str, tuple[list[str], dict[str, Any]]] = {}
169
+ try:
170
+ node = SFDictionary()
171
+ node.parse(value.encode())
172
+ for label, member in node.items():
173
+ # An InnerList is a UserList of Items; a bare Item is not a valid member here.
174
+ items = list(member) if hasattr(member, "data") else []
175
+ covered = [str(_plain(getattr(item, "value", item))) for item in items]
176
+ params = {str(k): _plain(v) for k, v in member.params.items()}
177
+ out[str(label)] = (covered, params)
178
+ except Exception: # noqa: BLE001
179
+ return {}
180
+ return out
181
+
182
+
183
+ def build_person_token_requirement() -> str:
184
+ """``AAuth-Requirement: requirement=person-token`` (-11 §6.4): what a resource
185
+ answers a revoked or expired auth token with. The header takes no parameters."""
186
+ return "requirement=person-token"
187
+
188
+
137
189
  # ── AAuth Budgets response headers (draft-hardt-aauth-budgets) ───────────────
138
190
  # Hand-serialized: the value space is tiny (non-negative sf-integers, one
139
191
  # sf-string, sf-tokens) and the golden tests round-trip the output through the
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)
@@ -226,46 +271,58 @@ def validate_budget_grant(unit: str, decimals: int,
226
271
  def make_revocation_endpoint(
227
272
  meter: Any,
228
273
  *,
229
- authenticate_ps: Callable[[Any], Awaitable[str | None]],
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,
230
278
  ) -> 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
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
246
300
 
247
301
  async def handler(request: Any) -> Response:
248
- caller = await authenticate_ps(request)
302
+ caller, refused = await _caller(authenticate_ps, request)
249
303
  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
- })
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
254
311
  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")
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")
259
322
  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."})
323
+ invalid: Response = _problem(400, "invalid_request", str(exc)[:200])
324
+ return invalid
325
+ await meter.record_revocation(caller, jti, float(exp))
269
326
  return Response(status_code=200)
270
327
 
271
328
  return handler