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/__init__.py +33 -7
- regent_httpsig/budget.py +104 -4
- regent_httpsig/config.py +31 -16
- regent_httpsig/fastapi.py +189 -106
- regent_httpsig/sfv.py +58 -6
- regent_httpsig/sign.py +232 -24
- regent_httpsig/usage.py +119 -14
- regent_httpsig/verify.py +456 -202
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/METADATA +67 -24
- regent_httpsig-0.7.0.dist-info/RECORD +16 -0
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/WHEEL +1 -1
- regent_httpsig-0.5.1.dist-info/RECORD +0 -16
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/entry_points.txt +0 -0
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/licenses/LICENSE +0 -0
regent_httpsig/fastapi.py
CHANGED
|
@@ -13,9 +13,12 @@ Usage::
|
|
|
13
13
|
if sig:
|
|
14
14
|
... # sig.agent == "https://chatgpt.com", sig.keyid, sig.trusted
|
|
15
15
|
|
|
16
|
-
``SignatureDep`` is enrichment: ``None`` when absent/invalid, never raises
|
|
17
|
-
``
|
|
18
|
-
|
|
16
|
+
``SignatureDep`` is enrichment: ``None`` when absent/invalid, never raises
|
|
17
|
+
(``SignatureErrorDep`` tells you why). ``RequiredSignatureDep`` is
|
|
18
|
+
authentication: a signed request that fails is answered ``401`` with the
|
|
19
|
+
``Signature-Error`` header of draft-hardt-httpbis-signature-key (the code
|
|
20
|
+
AAuth -11 §11.3.4 assigns), an unsigned one with ``AAuth-Requirement:
|
|
21
|
+
requirement=agent-token`` (-11 §6.1).
|
|
19
22
|
|
|
20
23
|
Proxy note: the signer signed the PUBLIC url. Behind a reverse proxy this
|
|
21
24
|
dependency rebuilds it from ``X-Forwarded-Proto`` + ``Host`` — make sure your
|
|
@@ -41,17 +44,34 @@ from regent_httpsig.budget import (
|
|
|
41
44
|
InvalidBudgetClaim,
|
|
42
45
|
MeterKey,
|
|
43
46
|
Reservation,
|
|
47
|
+
TokenRevoked,
|
|
44
48
|
UnitMismatch,
|
|
45
49
|
)
|
|
46
|
-
from regent_httpsig.sfv import
|
|
47
|
-
|
|
50
|
+
from regent_httpsig.sfv import (
|
|
51
|
+
build_aauth_budget_header,
|
|
52
|
+
build_aauth_requirement,
|
|
53
|
+
build_person_token_requirement,
|
|
54
|
+
)
|
|
55
|
+
from regent_httpsig.verify import (
|
|
56
|
+
AAUTH_AUTH_TYP,
|
|
57
|
+
AAUTH_PERSON_TYP,
|
|
58
|
+
HttpsigVerifier,
|
|
59
|
+
VerificationError,
|
|
60
|
+
VerifiedSignature,
|
|
61
|
+
)
|
|
48
62
|
|
|
49
63
|
__all__ = [
|
|
50
64
|
"BudgetMiddleware",
|
|
51
65
|
"RequiredSignatureDep",
|
|
52
66
|
"SignatureDep",
|
|
67
|
+
"SignatureErrorDep",
|
|
68
|
+
"VerificationError",
|
|
53
69
|
"VerifiedSignature",
|
|
54
70
|
"attach",
|
|
71
|
+
"get_signature",
|
|
72
|
+
"get_signature_error",
|
|
73
|
+
"require_server_signature",
|
|
74
|
+
"signature_error_response",
|
|
55
75
|
]
|
|
56
76
|
|
|
57
77
|
_STATE_ATTR = "regent_httpsig_verifier"
|
|
@@ -73,49 +93,128 @@ def _public_url(request: Request) -> str:
|
|
|
73
93
|
return f"{scheme}://{host}{path}{query}"
|
|
74
94
|
|
|
75
95
|
|
|
76
|
-
|
|
77
|
-
"""Optional verification: zero-cost without a Signature header, never raises."""
|
|
96
|
+
def _verifier_of(request: Request) -> HttpsigVerifier:
|
|
78
97
|
verifier: HttpsigVerifier | None = getattr(request.app.state, _STATE_ATTR, None)
|
|
79
98
|
if verifier is None:
|
|
80
99
|
raise RuntimeError(
|
|
81
100
|
"regent-httpsig verifier not attached — call "
|
|
82
101
|
"regent_httpsig.fastapi.attach(app, HttpsigVerifier()) at startup"
|
|
83
102
|
)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
return verifier
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _run_verifier(
|
|
107
|
+
verifier: HttpsigVerifier, request: Request,
|
|
108
|
+
) -> VerifiedSignature | VerificationError | None:
|
|
109
|
+
"""Verify once per request; both outcomes are cached on ``request.state``."""
|
|
110
|
+
cached: Any = getattr(request.state, "regent_httpsig_outcome", "unset")
|
|
87
111
|
if cached != "unset":
|
|
88
|
-
return cached
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
112
|
+
return cached if isinstance(cached, VerifiedSignature | VerificationError) else None
|
|
113
|
+
outcome: VerifiedSignature | VerificationError | None = None
|
|
114
|
+
if "signature" in request.headers:
|
|
115
|
+
# The -11 body rule (content-digest covered and matching) applies to the
|
|
116
|
+
# Signature-Key path; Web Bot Auth has no such rule and needs no body.
|
|
117
|
+
body = await request.body() if "signature-key" in request.headers else None
|
|
118
|
+
outcome = await verifier.verify_detailed(
|
|
119
|
+
request.method, _public_url(request), dict(request.headers), body,
|
|
120
|
+
)
|
|
121
|
+
request.state.regent_httpsig_outcome = outcome
|
|
122
|
+
request.state.regent_httpsig_result = (
|
|
123
|
+
outcome if isinstance(outcome, VerifiedSignature) else None)
|
|
124
|
+
request.state.regent_httpsig_error = (
|
|
125
|
+
outcome if isinstance(outcome, VerificationError) else None)
|
|
126
|
+
return outcome
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def signature_error_response(
|
|
130
|
+
error: VerificationError, *, code: str | None = None, requirement: str | None = None,
|
|
131
|
+
) -> JSONResponse:
|
|
132
|
+
"""``401`` + ``Signature-Error`` (+ ``Accept-Signature-*``) with the
|
|
133
|
+
problem+json body of draft-hardt-httpbis-signature-key §5. ``requirement``
|
|
134
|
+
adds an ``AAuth-Requirement`` header; ``code`` adds a legacy ``code`` member."""
|
|
135
|
+
headers = error.headers()
|
|
136
|
+
if requirement:
|
|
137
|
+
headers["AAuth-Requirement"] = requirement
|
|
138
|
+
body = error.problem(401)
|
|
139
|
+
if code:
|
|
140
|
+
body["code"] = code
|
|
141
|
+
return JSONResponse(status_code=401, content=body, headers=headers,
|
|
142
|
+
media_type="application/problem+json")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _repair_requirement(error: VerificationError) -> str | None:
|
|
146
|
+
"""-11 §11.12.5 / budgets "auth token expired": a revoked or expired person
|
|
147
|
+
or auth token is repaired by a fresh person token; nothing repairs an agent
|
|
148
|
+
token (the agent obtains a new one from its provider)."""
|
|
149
|
+
if error.code in ("revoked_jwt", "expired_jwt") and error.token_typ in (
|
|
150
|
+
AAUTH_PERSON_TYP, AAUTH_AUTH_TYP,
|
|
151
|
+
):
|
|
152
|
+
return build_person_token_requirement()
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def get_signature(request: Request) -> VerifiedSignature | None:
|
|
157
|
+
"""Optional verification: zero-cost without a Signature header, never raises."""
|
|
158
|
+
outcome = await _run_verifier(_verifier_of(request), request)
|
|
159
|
+
return outcome if isinstance(outcome, VerifiedSignature) else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def get_signature_error(request: Request) -> VerificationError | None:
|
|
163
|
+
"""Why a signed request did not verify (``None`` when unsigned or verified)."""
|
|
164
|
+
outcome = await _run_verifier(_verifier_of(request), request)
|
|
165
|
+
return outcome if isinstance(outcome, VerificationError) else None
|
|
94
166
|
|
|
95
167
|
|
|
96
168
|
async def require_signature(request: Request) -> VerifiedSignature:
|
|
97
169
|
"""Hard requirement: a fully verified signature, or a 401 that tells the
|
|
98
|
-
agent exactly
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
170
|
+
agent exactly what went wrong (``Signature-Error``) or what to present
|
|
171
|
+
(``AAuth-Requirement: requirement=agent-token``)."""
|
|
172
|
+
outcome = await _run_verifier(_verifier_of(request), request)
|
|
173
|
+
if isinstance(outcome, VerifiedSignature):
|
|
174
|
+
return outcome
|
|
175
|
+
if isinstance(outcome, VerificationError):
|
|
176
|
+
headers = outcome.headers()
|
|
177
|
+
requirement = _repair_requirement(outcome)
|
|
178
|
+
if requirement:
|
|
179
|
+
headers["AAuth-Requirement"] = requirement
|
|
180
|
+
raise HTTPException(status_code=401, headers=headers,
|
|
181
|
+
detail={**outcome.problem(401), "code": "SIGNATURE_INVALID"})
|
|
182
|
+
raise HTTPException(
|
|
183
|
+
status_code=401,
|
|
184
|
+
headers={"AAuth-Requirement": "requirement=agent-token"},
|
|
185
|
+
detail={
|
|
186
|
+
"code": "SIGNATURE_REQUIRED",
|
|
187
|
+
"message": (
|
|
188
|
+
"Sign this request with RFC 9421 HTTP Message Signatures: either "
|
|
189
|
+
"AAuth (your agent token in the Signature-Key header under the jwt "
|
|
190
|
+
"scheme, signature over @method @authority @path signature-key bound "
|
|
191
|
+
'to its cnf.jwk) or Web Bot Auth (tag="web-bot-auth", Ed25519 key '
|
|
192
|
+
"published at {your-origin}/.well-known/http-message-signatures-directory, "
|
|
193
|
+
"Signature-Agent header naming that origin)."
|
|
194
|
+
),
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def require_server_signature(request: Request) -> VerifiedSignature:
|
|
200
|
+
"""For endpoints servers call (usage, revocation): the caller must sign
|
|
201
|
+
under the ``jwks_uri`` scheme (-11 §11.3.2); the body is covered. A
|
|
202
|
+
failure is ``401`` + ``Signature-Error``."""
|
|
203
|
+
verifier = _verifier_of(request)
|
|
204
|
+
body = await request.body()
|
|
205
|
+
outcome = await verifier.verify_server(
|
|
206
|
+
request.method, _public_url(request), dict(request.headers), body,
|
|
207
|
+
)
|
|
208
|
+
if isinstance(outcome, VerifiedSignature):
|
|
209
|
+
return outcome
|
|
210
|
+
raise HTTPException(status_code=401, headers=outcome.headers(),
|
|
211
|
+
detail={**outcome.problem(401), "code": "SIGNATURE_INVALID"})
|
|
115
212
|
|
|
116
213
|
|
|
117
214
|
SignatureDep = Depends(get_signature)
|
|
215
|
+
SignatureErrorDep = Depends(get_signature_error)
|
|
118
216
|
RequiredSignatureDep = Depends(require_signature)
|
|
217
|
+
RequiredServerSignatureDep = Depends(require_server_signature)
|
|
119
218
|
|
|
120
219
|
|
|
121
220
|
# ── AAuth Budgets enforcement (draft-hardt-aauth-budgets) ────────────────────
|
|
@@ -132,20 +231,17 @@ async def _maybe_await(value: Any) -> Any:
|
|
|
132
231
|
|
|
133
232
|
class BudgetMiddleware(BaseHTTPMiddleware):
|
|
134
233
|
"""Meter budget-carrying requests: reserve the maximum cost atomically,
|
|
135
|
-
serve, commit the actual
|
|
136
|
-
``AAuth-Budget`` header. The full checklist a resource owes the draft —
|
|
137
|
-
pricing excepted, which is the one thing only the resource can know.
|
|
234
|
+
serve, commit the actual cost, answer with ``AAuth-Budget``.
|
|
138
235
|
|
|
139
|
-
|
|
236
|
+
::
|
|
140
237
|
|
|
141
|
-
meter = InMemoryMeter()
|
|
142
238
|
app.add_middleware(
|
|
143
239
|
BudgetMiddleware,
|
|
144
240
|
verifier=HttpsigVerifier(HttpsigConfig(
|
|
145
241
|
resource_url="https://api.example",
|
|
146
242
|
trusted_ps={"https://ps.example": "https://ps.example/jwks.json"},
|
|
147
243
|
)),
|
|
148
|
-
meter=
|
|
244
|
+
meter=InMemoryMeter(),
|
|
149
245
|
price_fn=lambda request: PRICES.get(request.url.path),
|
|
150
246
|
)
|
|
151
247
|
|
|
@@ -156,6 +252,12 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
156
252
|
- ``require=False`` (default) lets requests without a budget envelope pass
|
|
157
253
|
through untouched — run per-decision authorization for them instead.
|
|
158
254
|
``require=True`` refuses them with 401 + ``AAuth-Requirement``.
|
|
255
|
+
- A *signed* request that does not verify is refused with ``401`` +
|
|
256
|
+
``Signature-Error`` (-11 §11.3.4) whatever ``require`` says. A revoked or
|
|
257
|
+
expired auth token is ``revoked_jwt`` / ``expired_jwt`` plus
|
|
258
|
+
``AAuth-Requirement: requirement=person-token`` (-11 §11.12.5) — no
|
|
259
|
+
resource token, no ``AAuth-Budget``; its final consumption record reaches
|
|
260
|
+
the issuer through the usage endpoint.
|
|
159
261
|
- Error responses (4xx/5xx) release the reservation — nothing was served,
|
|
160
262
|
the envelope is not charged.
|
|
161
263
|
- ``resource_token_provider(key, record)`` (optional) mints the resource
|
|
@@ -164,6 +266,10 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
164
266
|
the PRESENTED token's ``{"jti", "consumed"}`` (draft §The Consumption
|
|
165
267
|
Record — one record, two members) or ``None`` when nothing was metered
|
|
166
268
|
against it yet.
|
|
269
|
+
- Wire the verifier to the meter's revocation record
|
|
270
|
+
(``HttpsigVerifier(..., is_revoked=meter.is_revoked)``) so a revoked
|
|
271
|
+
token is named as revoked before it is metered; the middleware checks the
|
|
272
|
+
meter too, for verifiers wired otherwise.
|
|
167
273
|
"""
|
|
168
274
|
|
|
169
275
|
def __init__(
|
|
@@ -190,14 +296,10 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
190
296
|
if max_cost is None:
|
|
191
297
|
return await call_next(request)
|
|
192
298
|
|
|
193
|
-
|
|
194
|
-
if
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
# token's FINAL consumption record (stated at/after its exp), which
|
|
198
|
-
# is what lets its issuer settle the allocation exactly. Without
|
|
199
|
-
# this challenge an issuer never sees a final figure at all.
|
|
200
|
-
return await self._expired_challenge(sig)
|
|
299
|
+
outcome = await _run_verifier(self._verifier, request)
|
|
300
|
+
if isinstance(outcome, VerificationError):
|
|
301
|
+
return self._signature_error(outcome)
|
|
302
|
+
sig = outcome
|
|
201
303
|
envelope: BudgetClaim | None = None
|
|
202
304
|
if sig is not None:
|
|
203
305
|
try:
|
|
@@ -216,6 +318,9 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
216
318
|
str(sig.claims.get("sub", "")),
|
|
217
319
|
str(sig.claims.get("aud", "")),
|
|
218
320
|
)
|
|
321
|
+
is_revoked = getattr(self._meter, "is_revoked", None)
|
|
322
|
+
if is_revoked is not None and await is_revoked(key[0], jti):
|
|
323
|
+
return self._revoked()
|
|
219
324
|
try:
|
|
220
325
|
# sig.keyid is the RFC 7638 thumbprint of the token's cnf key (jkt) —
|
|
221
326
|
# recorded so refusal-time consumption records are scoped to the
|
|
@@ -228,18 +333,23 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
228
333
|
return self._refusal(reason="insufficient-budget", envelope=envelope,
|
|
229
334
|
remaining=0, key=key)
|
|
230
335
|
|
|
231
|
-
|
|
232
|
-
if isinstance(
|
|
233
|
-
|
|
336
|
+
outcome_r = await self._meter.reserve(key, jti, int(max_cost))
|
|
337
|
+
if isinstance(outcome_r, TokenRevoked):
|
|
338
|
+
# §Token Scope / -11 §11.12.5: say it was revoked, ask for a person
|
|
339
|
+
# token, carry no resource token. The final record (once nothing is
|
|
340
|
+
# in flight, AAuth #151) is the usage endpoint's to report.
|
|
341
|
+
return self._revoked()
|
|
342
|
+
if isinstance(outcome_r, InsufficientBudget):
|
|
343
|
+
reason = "budget-exhausted" if outcome_r.exhausted else "insufficient-budget"
|
|
234
344
|
return await self._refusal_with_token(
|
|
235
|
-
reason=reason, envelope=envelope, remaining=
|
|
345
|
+
reason=reason, envelope=envelope, remaining=outcome_r.remaining,
|
|
236
346
|
key=key, jti=jti,
|
|
237
347
|
# `required` rides only on insufficient-budget: what THIS
|
|
238
348
|
# request needed, so the agent can lower its bound and retry.
|
|
239
349
|
required=int(max_cost) if reason == "insufficient-budget" else None,
|
|
240
350
|
)
|
|
241
351
|
|
|
242
|
-
reservation: Reservation =
|
|
352
|
+
reservation: Reservation = outcome_r
|
|
243
353
|
try:
|
|
244
354
|
response = await call_next(request)
|
|
245
355
|
except Exception:
|
|
@@ -286,69 +396,42 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
286
396
|
|
|
287
397
|
def _commit_after_stream(self, request: Request, response: Response,
|
|
288
398
|
reservation: Reservation, max_cost: int) -> None:
|
|
289
|
-
"""Wrap the body iterator
|
|
290
|
-
set
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
399
|
+
"""Wrap the body iterator so the reservation is committed (at the
|
|
400
|
+
handler's actual cost if it set one mid-stream, else the full hold)
|
|
401
|
+
when the stream ends, and released if it breaks before a byte is sent."""
|
|
402
|
+
meter = self._meter
|
|
403
|
+
original = response.body_iterator # type: ignore[attr-defined]
|
|
294
404
|
|
|
295
405
|
async def metered() -> Any:
|
|
296
|
-
|
|
406
|
+
sent = False
|
|
297
407
|
try:
|
|
298
|
-
async for chunk in
|
|
408
|
+
async for chunk in original:
|
|
409
|
+
sent = True
|
|
299
410
|
yield chunk
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
411
|
+
except BaseException:
|
|
412
|
+
await (meter.commit(reservation, max_cost) if sent
|
|
413
|
+
else meter.release(reservation))
|
|
414
|
+
raise
|
|
415
|
+
actual = getattr(request.state, "budget_cost", None)
|
|
416
|
+
await meter.commit(reservation,
|
|
417
|
+
int(actual) if actual is not None else max_cost)
|
|
305
418
|
|
|
306
419
|
response.body_iterator = metered() # type: ignore[attr-defined]
|
|
307
420
|
|
|
308
421
|
# ── helpers ──────────────────────────────────────────────────────────────
|
|
309
422
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
# per-request cache still must not carry it — a later dependency
|
|
323
|
-
# reading the cache would otherwise see a token that is not valid.
|
|
324
|
-
request.state.regent_httpsig_result = (
|
|
325
|
-
None if result is not None and result.expired else result)
|
|
326
|
-
return result
|
|
327
|
-
|
|
328
|
-
async def _expired_challenge(self, sig: VerifiedSignature) -> Response:
|
|
329
|
-
jti = str(sig.claims.get("jti") or "")
|
|
330
|
-
key: MeterKey = (
|
|
331
|
-
str(sig.claims.get("iss", "")),
|
|
332
|
-
str(sig.claims.get("sub", "")),
|
|
333
|
-
str(sig.claims.get("aud", "")),
|
|
334
|
-
)
|
|
335
|
-
token: str | None = None
|
|
336
|
-
if self._resource_token is not None and jti:
|
|
337
|
-
try:
|
|
338
|
-
record = await self._meter.consumed_record(key, jti)
|
|
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_EXPIRED",
|
|
346
|
-
"message": "The auth token has expired. Take the resource token in "
|
|
347
|
-
"AAuth-Requirement to your PS for a fresh one.",
|
|
348
|
-
},
|
|
349
|
-
headers={"AAuth-Requirement": build_aauth_requirement(
|
|
350
|
-
reason=None, resource_token=token)},
|
|
351
|
-
)
|
|
423
|
+
def _signature_error(self, error: VerificationError) -> Response:
|
|
424
|
+
code = {"revoked_jwt": "AUTH_TOKEN_REVOKED", "expired_jwt": "AUTH_TOKEN_EXPIRED"}.get(
|
|
425
|
+
error.code, "SIGNATURE_INVALID")
|
|
426
|
+
return signature_error_response(error, code=code,
|
|
427
|
+
requirement=_repair_requirement(error))
|
|
428
|
+
|
|
429
|
+
def _revoked(self) -> Response:
|
|
430
|
+
"""The meter knows the token is revoked (the verifier was not wired to
|
|
431
|
+
it, or learned out of band): the same -11 §11.12.5 answer."""
|
|
432
|
+
error = VerificationError("revoked_jwt", "the issuer has withdrawn this token",
|
|
433
|
+
token_typ=AAUTH_AUTH_TYP)
|
|
434
|
+
return self._signature_error(error)
|
|
352
435
|
|
|
353
436
|
async def _refusal_with_token(
|
|
354
437
|
self, *, reason: str, envelope: BudgetClaim,
|
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
|
|
124
|
-
"""
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|