x403 0.1.0__tar.gz
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.
- x403-0.1.0/.gitignore +4 -0
- x403-0.1.0/PKG-INFO +122 -0
- x403-0.1.0/README.md +89 -0
- x403-0.1.0/pyproject.toml +45 -0
- x403-0.1.0/src/x403/__init__.py +46 -0
- x403-0.1.0/src/x403/client.py +166 -0
- x403-0.1.0/src/x403/headers.py +70 -0
- x403-0.1.0/src/x403/jcs.py +103 -0
- x403-0.1.0/src/x403/jws.py +211 -0
- x403-0.1.0/src/x403/middleware/__init__.py +0 -0
- x403-0.1.0/src/x403/middleware/fastapi.py +189 -0
- x403-0.1.0/src/x403/reason-codes.json +136 -0
- x403-0.1.0/src/x403/registry.py +70 -0
- x403-0.1.0/src/x403/types.py +306 -0
- x403-0.1.0/tests/conftest.py +46 -0
- x403-0.1.0/tests/test_client.py +75 -0
- x403-0.1.0/tests/test_fastapi.py +77 -0
- x403-0.1.0/tests/test_headers.py +22 -0
- x403-0.1.0/tests/test_jcs.py +65 -0
- x403-0.1.0/tests/test_jws.py +68 -0
- x403-0.1.0/tests/test_registry.py +79 -0
x403-0.1.0/.gitignore
ADDED
x403-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: x403
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: x403: a signed risk decision before money moves. Objects, canonical JSON, ES256 signing and verifying, the HTTP binding, a client and FastAPI middleware.
|
|
5
|
+
Project-URL: Homepage, https://kredit.sh/x403
|
|
6
|
+
Project-URL: Repository, https://github.com/kreditsh/x403
|
|
7
|
+
Project-URL: Spec, https://github.com/kreditsh/x403/blob/main/spec/x403.md
|
|
8
|
+
Author-email: kredit <hello@kredit.sh>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: agentic payments,ap2,kredit,mpp,risk,x401,x402,x403
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security :: Cryptography
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: cryptography>=42
|
|
22
|
+
Provides-Extra: client
|
|
23
|
+
Requires-Dist: httpx>=0.27; extra == 'client'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
29
|
+
Provides-Extra: fastapi
|
|
30
|
+
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
|
|
31
|
+
Requires-Dist: httpx>=0.27; extra == 'fastapi'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# x403
|
|
35
|
+
|
|
36
|
+
The x403 protocol for Python: the objects, RFC 8785 canonical JSON, ES256 signing and verifying, the HTTP binding, a sync and an async client for any authority, and a FastAPI gate. One dependency, `cryptography`. Python 3.10+.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
pip install x403 # objects, canonical JSON, signing, verifying
|
|
40
|
+
pip install "x403[client]" # plus the HTTP client (httpx)
|
|
41
|
+
pip install "x403[fastapi]" # plus the FastAPI gate
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Ask before you execute
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from x403 import X403
|
|
48
|
+
|
|
49
|
+
x403 = X403(authority="https://api.kredit.sh", api_key=os.environ["KREDIT_API_KEY"])
|
|
50
|
+
|
|
51
|
+
decision = x403.decide(
|
|
52
|
+
direction="inbound",
|
|
53
|
+
policy_id="shop-checkout-v1",
|
|
54
|
+
payment={"type": "payment", "amount": "189.00", "currency": "USD", "rail": "card", "executor": {"id": "shop.example"}},
|
|
55
|
+
actor={"agent": {"status": "asserted", "id": "did:key:z6Mk..."}},
|
|
56
|
+
evidence=[{"type": "kredit.passport", "ref": passport_hash, "inline": passport}],
|
|
57
|
+
budget_ms=150,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if decision["decision"] == "allow":
|
|
61
|
+
... # within decision["constraints"]
|
|
62
|
+
else:
|
|
63
|
+
... # decision["http_status"], decision["reasons"], decision.get("escalation")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Verify offline
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
result = x403.verify(decision) # keys fetched once from authority.json
|
|
70
|
+
assert result, result.reason
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or without a client, against keys you already hold:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from x403 import verify_decision
|
|
77
|
+
ok = verify_decision(decision, jwks)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## FastAPI
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from fastapi import Depends, FastAPI
|
|
84
|
+
from x403 import AsyncX403
|
|
85
|
+
from x403.middleware.fastapi import X403Gate, install, post_receipt
|
|
86
|
+
|
|
87
|
+
app = FastAPI()
|
|
88
|
+
install(app)
|
|
89
|
+
authority = AsyncX403("https://api.kredit.sh", api_key=os.environ["KREDIT_API_KEY"])
|
|
90
|
+
|
|
91
|
+
gate = X403Gate(
|
|
92
|
+
authority,
|
|
93
|
+
payment=lambda request: {"type": "payment", "amount": "189.00", "currency": "USD", "rail": "card", "executor": {"id": "shop.example"}},
|
|
94
|
+
policy_id="shop-checkout-v1",
|
|
95
|
+
budget_ms=150,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@app.post("/checkout")
|
|
99
|
+
async def checkout(decision: dict = Depends(gate)):
|
|
100
|
+
auth = await charge(decision["constraints"])
|
|
101
|
+
await post_receipt(authority, decision, executed=True, execution={"rail": "card", "ref": auth.id},
|
|
102
|
+
executor={"id": "did:web:shop.example", "kid": kid}, private_jwk=private_jwk)
|
|
103
|
+
return {"ok": True}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
On allow the route receives the decision. On anything else the gate answers with the decision's `http_status`, the decision as the body and `X403-Decision` on the response. If the authority cannot be reached it fails closed with an executor-signed `authority_timeout` decision.
|
|
107
|
+
|
|
108
|
+
## Sign your side
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from x403 import generate_key, sign_receipt, decision_hash
|
|
112
|
+
|
|
113
|
+
pair = generate_key() # publish pair.public_jwk at /.well-known/x403/jwks.json
|
|
114
|
+
receipt = sign_receipt({"decision_ref": d["id"], "decision_hash": decision_hash(d), "executed": True,
|
|
115
|
+
"executor": {"id": "did:web:shop.example", "kid": pair.kid}}, pair.private_jwk)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Status codes
|
|
119
|
+
|
|
120
|
+
`status_for(decision)` implements section 6.3 of the spec. The decision already carries `http_status`; use the function to pin the table in your own tests.
|
|
121
|
+
|
|
122
|
+
Apache-2.0. Spec and registry: https://github.com/kreditsh/x403
|
x403-0.1.0/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# x403
|
|
2
|
+
|
|
3
|
+
The x403 protocol for Python: the objects, RFC 8785 canonical JSON, ES256 signing and verifying, the HTTP binding, a sync and an async client for any authority, and a FastAPI gate. One dependency, `cryptography`. Python 3.10+.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install x403 # objects, canonical JSON, signing, verifying
|
|
7
|
+
pip install "x403[client]" # plus the HTTP client (httpx)
|
|
8
|
+
pip install "x403[fastapi]" # plus the FastAPI gate
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Ask before you execute
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from x403 import X403
|
|
15
|
+
|
|
16
|
+
x403 = X403(authority="https://api.kredit.sh", api_key=os.environ["KREDIT_API_KEY"])
|
|
17
|
+
|
|
18
|
+
decision = x403.decide(
|
|
19
|
+
direction="inbound",
|
|
20
|
+
policy_id="shop-checkout-v1",
|
|
21
|
+
payment={"type": "payment", "amount": "189.00", "currency": "USD", "rail": "card", "executor": {"id": "shop.example"}},
|
|
22
|
+
actor={"agent": {"status": "asserted", "id": "did:key:z6Mk..."}},
|
|
23
|
+
evidence=[{"type": "kredit.passport", "ref": passport_hash, "inline": passport}],
|
|
24
|
+
budget_ms=150,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if decision["decision"] == "allow":
|
|
28
|
+
... # within decision["constraints"]
|
|
29
|
+
else:
|
|
30
|
+
... # decision["http_status"], decision["reasons"], decision.get("escalation")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Verify offline
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
result = x403.verify(decision) # keys fetched once from authority.json
|
|
37
|
+
assert result, result.reason
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or without a client, against keys you already hold:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from x403 import verify_decision
|
|
44
|
+
ok = verify_decision(decision, jwks)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## FastAPI
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from fastapi import Depends, FastAPI
|
|
51
|
+
from x403 import AsyncX403
|
|
52
|
+
from x403.middleware.fastapi import X403Gate, install, post_receipt
|
|
53
|
+
|
|
54
|
+
app = FastAPI()
|
|
55
|
+
install(app)
|
|
56
|
+
authority = AsyncX403("https://api.kredit.sh", api_key=os.environ["KREDIT_API_KEY"])
|
|
57
|
+
|
|
58
|
+
gate = X403Gate(
|
|
59
|
+
authority,
|
|
60
|
+
payment=lambda request: {"type": "payment", "amount": "189.00", "currency": "USD", "rail": "card", "executor": {"id": "shop.example"}},
|
|
61
|
+
policy_id="shop-checkout-v1",
|
|
62
|
+
budget_ms=150,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@app.post("/checkout")
|
|
66
|
+
async def checkout(decision: dict = Depends(gate)):
|
|
67
|
+
auth = await charge(decision["constraints"])
|
|
68
|
+
await post_receipt(authority, decision, executed=True, execution={"rail": "card", "ref": auth.id},
|
|
69
|
+
executor={"id": "did:web:shop.example", "kid": kid}, private_jwk=private_jwk)
|
|
70
|
+
return {"ok": True}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
On allow the route receives the decision. On anything else the gate answers with the decision's `http_status`, the decision as the body and `X403-Decision` on the response. If the authority cannot be reached it fails closed with an executor-signed `authority_timeout` decision.
|
|
74
|
+
|
|
75
|
+
## Sign your side
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from x403 import generate_key, sign_receipt, decision_hash
|
|
79
|
+
|
|
80
|
+
pair = generate_key() # publish pair.public_jwk at /.well-known/x403/jwks.json
|
|
81
|
+
receipt = sign_receipt({"decision_ref": d["id"], "decision_hash": decision_hash(d), "executed": True,
|
|
82
|
+
"executor": {"id": "did:web:shop.example", "kid": pair.kid}}, pair.private_jwk)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Status codes
|
|
86
|
+
|
|
87
|
+
`status_for(decision)` implements section 6.3 of the spec. The decision already carries `http_status`; use the function to pin the table in your own tests.
|
|
88
|
+
|
|
89
|
+
Apache-2.0. Spec and registry: https://github.com/kreditsh/x403
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "x403"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "x403: a signed risk decision before money moves. Objects, canonical JSON, ES256 signing and verifying, the HTTP binding, a client and FastAPI middleware."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "kredit", email = "hello@kredit.sh" }]
|
|
13
|
+
keywords = ["x403", "x402", "x401", "agentic payments", "risk", "ap2", "mpp", "kredit"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Security :: Cryptography",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["cryptography>=42"]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
client = ["httpx>=0.27"]
|
|
29
|
+
fastapi = ["fastapi>=0.110", "httpx>=0.27"]
|
|
30
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "build>=1.2", "twine>=5"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://kredit.sh/x403"
|
|
34
|
+
Repository = "https://github.com/kreditsh/x403"
|
|
35
|
+
Spec = "https://github.com/kreditsh/x403/blob/main/spec/x403.md"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/x403"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
include = ["src/x403", "README.md", "tests"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""x403: a signed risk decision before money moves."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncX403, X403, X403Error, build_query
|
|
4
|
+
from .headers import (
|
|
5
|
+
AUTH_SCHEME,
|
|
6
|
+
HEADER_DECISION,
|
|
7
|
+
HEADER_PRESENTATION,
|
|
8
|
+
HEADER_REQUIRED,
|
|
9
|
+
HEADER_RISK_DECISION,
|
|
10
|
+
authorization_risk,
|
|
11
|
+
decode,
|
|
12
|
+
encode,
|
|
13
|
+
parse_authorization_risk,
|
|
14
|
+
parse_www_authenticate,
|
|
15
|
+
www_authenticate,
|
|
16
|
+
)
|
|
17
|
+
from .jcs import canonicalize, hash_object, hash_string
|
|
18
|
+
from .jws import (
|
|
19
|
+
KeyPair,
|
|
20
|
+
VerifyResult,
|
|
21
|
+
decision_hash,
|
|
22
|
+
generate_key,
|
|
23
|
+
sign,
|
|
24
|
+
sign_outcome,
|
|
25
|
+
sign_query,
|
|
26
|
+
sign_receipt,
|
|
27
|
+
thumbprint,
|
|
28
|
+
verify,
|
|
29
|
+
verify_decision,
|
|
30
|
+
verify_receipt,
|
|
31
|
+
)
|
|
32
|
+
from .registry import CODES, GROUPS, NORMATIVE_STATUSES, REGISTRY, check_of, effect_of, entry_of, escalation_of, group_of, status_for
|
|
33
|
+
from .types import VERSION
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"AsyncX403", "X403", "X403Error", "build_query",
|
|
39
|
+
"AUTH_SCHEME", "HEADER_DECISION", "HEADER_PRESENTATION", "HEADER_REQUIRED", "HEADER_RISK_DECISION",
|
|
40
|
+
"authorization_risk", "decode", "encode", "parse_authorization_risk", "parse_www_authenticate", "www_authenticate",
|
|
41
|
+
"canonicalize", "hash_object", "hash_string",
|
|
42
|
+
"KeyPair", "VerifyResult", "decision_hash", "generate_key", "sign", "sign_outcome", "sign_query", "sign_receipt",
|
|
43
|
+
"thumbprint", "verify", "verify_decision", "verify_receipt",
|
|
44
|
+
"CODES", "GROUPS", "NORMATIVE_STATUSES", "REGISTRY", "check_of", "effect_of", "entry_of", "escalation_of", "group_of", "status_for",
|
|
45
|
+
"VERSION", "__version__",
|
|
46
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The authority API client: the six calls of spec section 7, sync and async, and
|
|
2
|
+
offline verification against the authority's published keys."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .headers import decode, encode
|
|
9
|
+
from .jws import VerifyResult, verify_decision
|
|
10
|
+
from .types import VERSION, Authority, Decision, JWKS, Outcome, Receipt, RiskQuery
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import httpx
|
|
14
|
+
except ImportError: # pragma: no cover - the client extra is optional
|
|
15
|
+
httpx = None # type: ignore[assignment]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class X403Error(Exception):
|
|
19
|
+
def __init__(self, status: int, detail: Any):
|
|
20
|
+
self.status = status
|
|
21
|
+
self.detail = detail
|
|
22
|
+
super().__init__(f"{status}: {detail if isinstance(detail, str) else detail!r}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_query(**fields: Any) -> RiskQuery:
|
|
26
|
+
"""Fill scheme and version so a caller passes only what varies."""
|
|
27
|
+
query: dict[str, Any] = {"scheme": "x403", "version": VERSION}
|
|
28
|
+
query.update({k: v for k, v in fields.items() if v is not None})
|
|
29
|
+
return query # type: ignore[return-value]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _need_httpx() -> None:
|
|
33
|
+
if httpx is None:
|
|
34
|
+
raise RuntimeError("install x403[client] for the HTTP client")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _unpack(res: Any) -> Any:
|
|
38
|
+
try:
|
|
39
|
+
payload = res.json() if res.content else {}
|
|
40
|
+
except ValueError:
|
|
41
|
+
payload = res.text
|
|
42
|
+
if res.status_code >= 400:
|
|
43
|
+
detail = payload.get("detail") if isinstance(payload, dict) and "detail" in payload else payload
|
|
44
|
+
raise X403Error(res.status_code, detail)
|
|
45
|
+
return payload
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Base:
|
|
49
|
+
encode = staticmethod(encode)
|
|
50
|
+
decode = staticmethod(decode)
|
|
51
|
+
|
|
52
|
+
def __init__(self, authority: str, api_key: str | None = None, headers: dict[str, str] | None = None, timeout: float = 10.0):
|
|
53
|
+
self.authority = authority.rstrip("/")
|
|
54
|
+
self.api_key = api_key
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
self.extra = dict(headers or {})
|
|
57
|
+
self._jwks: JWKS | None = None
|
|
58
|
+
self._authority: Authority | None = None
|
|
59
|
+
|
|
60
|
+
def _headers(self, body: bool) -> dict[str, str]:
|
|
61
|
+
out = {"accept": "application/json", **self.extra}
|
|
62
|
+
if body:
|
|
63
|
+
out["content-type"] = "application/json"
|
|
64
|
+
if self.api_key:
|
|
65
|
+
out["authorization"] = f"Bearer {self.api_key}"
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
def _jwks_path(self) -> str:
|
|
69
|
+
url = (self._authority or {}).get("jwks_url") or f"{self.authority}/.well-known/x403/jwks.json"
|
|
70
|
+
return url[len(self.authority) :] if url.startswith(self.authority) else url
|
|
71
|
+
|
|
72
|
+
def reset(self) -> None:
|
|
73
|
+
"""Forget cached keys, e.g. after a verify fails on an unknown kid."""
|
|
74
|
+
self._jwks = None
|
|
75
|
+
self._authority = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class X403(_Base):
|
|
79
|
+
def __init__(self, authority: str, api_key: str | None = None, headers: dict[str, str] | None = None, timeout: float = 10.0, client: Any = None):
|
|
80
|
+
super().__init__(authority, api_key, headers, timeout)
|
|
81
|
+
_need_httpx()
|
|
82
|
+
self._client = client or httpx.Client(timeout=timeout)
|
|
83
|
+
|
|
84
|
+
def _call(self, method: str, path: str, body: Any = None) -> Any:
|
|
85
|
+
url = path if path.startswith("http") else f"{self.authority}{path}"
|
|
86
|
+
res = self._client.request(method, url, json=body, headers=self._headers(body is not None))
|
|
87
|
+
return _unpack(res)
|
|
88
|
+
|
|
89
|
+
def decide(self, **fields: Any) -> Decision:
|
|
90
|
+
return self._call("POST", "/x403/decide", build_query(**fields))
|
|
91
|
+
|
|
92
|
+
def preflight(self, **fields: Any) -> Decision:
|
|
93
|
+
"""The same question with mode preflight: decide now, pay later citing the id."""
|
|
94
|
+
return self._call("POST", "/x403/decide", build_query(**{**fields, "mode": "preflight"}))
|
|
95
|
+
|
|
96
|
+
def receipt(self, receipt: Receipt) -> Receipt:
|
|
97
|
+
return self._call("POST", "/x403/receipts", receipt)
|
|
98
|
+
|
|
99
|
+
def outcome(self, outcome: Outcome) -> Outcome:
|
|
100
|
+
return self._call("POST", "/x403/outcomes", outcome)
|
|
101
|
+
|
|
102
|
+
def get(self, decision_id: str) -> Decision:
|
|
103
|
+
return self._call("GET", f"/x403/decisions/{decision_id}")
|
|
104
|
+
|
|
105
|
+
def authority_document(self) -> Authority:
|
|
106
|
+
if self._authority is None:
|
|
107
|
+
self._authority = self._call("GET", "/.well-known/x403/authority.json")
|
|
108
|
+
return self._authority # type: ignore[return-value]
|
|
109
|
+
|
|
110
|
+
def jwks(self) -> JWKS:
|
|
111
|
+
"""The authority's keys, fetched once from the address its document names."""
|
|
112
|
+
if self._jwks is None:
|
|
113
|
+
try:
|
|
114
|
+
self.authority_document()
|
|
115
|
+
except X403Error:
|
|
116
|
+
pass # without a document the conventional path still answers
|
|
117
|
+
self._jwks = self._call("GET", self._jwks_path())
|
|
118
|
+
return self._jwks # type: ignore[return-value]
|
|
119
|
+
|
|
120
|
+
def verify(self, decision: Decision) -> VerifyResult:
|
|
121
|
+
"""Offline verification of a decision against the cached keys."""
|
|
122
|
+
return verify_decision(decision, self.jwks())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AsyncX403(_Base):
|
|
126
|
+
def __init__(self, authority: str, api_key: str | None = None, headers: dict[str, str] | None = None, timeout: float = 10.0, client: Any = None):
|
|
127
|
+
super().__init__(authority, api_key, headers, timeout)
|
|
128
|
+
_need_httpx()
|
|
129
|
+
self._client = client or httpx.AsyncClient(timeout=timeout)
|
|
130
|
+
|
|
131
|
+
async def _call(self, method: str, path: str, body: Any = None) -> Any:
|
|
132
|
+
url = path if path.startswith("http") else f"{self.authority}{path}"
|
|
133
|
+
res = await self._client.request(method, url, json=body, headers=self._headers(body is not None))
|
|
134
|
+
return _unpack(res)
|
|
135
|
+
|
|
136
|
+
async def decide(self, **fields: Any) -> Decision:
|
|
137
|
+
return await self._call("POST", "/x403/decide", build_query(**fields))
|
|
138
|
+
|
|
139
|
+
async def preflight(self, **fields: Any) -> Decision:
|
|
140
|
+
return await self._call("POST", "/x403/decide", build_query(**{**fields, "mode": "preflight"}))
|
|
141
|
+
|
|
142
|
+
async def receipt(self, receipt: Receipt) -> Receipt:
|
|
143
|
+
return await self._call("POST", "/x403/receipts", receipt)
|
|
144
|
+
|
|
145
|
+
async def outcome(self, outcome: Outcome) -> Outcome:
|
|
146
|
+
return await self._call("POST", "/x403/outcomes", outcome)
|
|
147
|
+
|
|
148
|
+
async def get(self, decision_id: str) -> Decision:
|
|
149
|
+
return await self._call("GET", f"/x403/decisions/{decision_id}")
|
|
150
|
+
|
|
151
|
+
async def authority_document(self) -> Authority:
|
|
152
|
+
if self._authority is None:
|
|
153
|
+
self._authority = await self._call("GET", "/.well-known/x403/authority.json")
|
|
154
|
+
return self._authority # type: ignore[return-value]
|
|
155
|
+
|
|
156
|
+
async def jwks(self) -> JWKS:
|
|
157
|
+
if self._jwks is None:
|
|
158
|
+
try:
|
|
159
|
+
await self.authority_document()
|
|
160
|
+
except X403Error:
|
|
161
|
+
pass
|
|
162
|
+
self._jwks = await self._call("GET", self._jwks_path())
|
|
163
|
+
return self._jwks # type: ignore[return-value]
|
|
164
|
+
|
|
165
|
+
async def verify(self, decision: Decision) -> VerifyResult:
|
|
166
|
+
return verify_decision(decision, await self.jwks())
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The HTTP binding: X403-* headers carry base64url JSON without padding, and the
|
|
2
|
+
auth-scheme binding carries the same objects under WWW-Authenticate: Risk."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .jws import base64url, from_base64url
|
|
11
|
+
|
|
12
|
+
HEADER_REQUIRED = "X403-Required"
|
|
13
|
+
HEADER_PRESENTATION = "X403-Presentation"
|
|
14
|
+
HEADER_DECISION = "X403-Decision"
|
|
15
|
+
HEADER_RISK_DECISION = "Risk-Decision"
|
|
16
|
+
AUTH_SCHEME = "Risk"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def encode(value: Any) -> str:
|
|
20
|
+
return base64url(json.dumps(value, separators=(",", ":"), ensure_ascii=False))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def decode(text: str) -> Any:
|
|
24
|
+
return json.loads(from_base64url(text).decode("utf-8"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _quote(value: str) -> str:
|
|
28
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def www_authenticate(policy: str, realm: str | None = None, request: str | None = None, expires: str | None = None) -> str:
|
|
32
|
+
"""`WWW-Authenticate: Risk policy="...", realm="...", request="...", expires="..."`"""
|
|
33
|
+
parts = [f"policy={_quote(policy)}"]
|
|
34
|
+
if realm:
|
|
35
|
+
parts.append(f"realm={_quote(realm)}")
|
|
36
|
+
if request:
|
|
37
|
+
parts.append(f"request={_quote(request)}")
|
|
38
|
+
if expires:
|
|
39
|
+
parts.append(f"expires={_quote(expires)}")
|
|
40
|
+
return f"{AUTH_SCHEME} " + ", ".join(parts)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_PARAM = re.compile(r'(\w+)=(?:"((?:[^"\\]|\\.)*)"|([^,\s]+))')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_www_authenticate(header: str) -> dict[str, str] | None:
|
|
47
|
+
m = re.match(r"^\s*Risk\s+(.*)$", header, re.S)
|
|
48
|
+
if not m:
|
|
49
|
+
return None
|
|
50
|
+
out: dict[str, str] = {}
|
|
51
|
+
for name, quoted, bare in _PARAM.findall(m.group(1)):
|
|
52
|
+
out[name] = re.sub(r"\\(.)", r"\1", quoted if quoted else bare)
|
|
53
|
+
return out if "policy" in out else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def authorization_risk(presentation: dict[str, Any]) -> str:
|
|
57
|
+
"""`Authorization: Risk <base64url presentation>`"""
|
|
58
|
+
return f"{AUTH_SCHEME} {encode(presentation)}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_authorization_risk(header: str | None) -> dict[str, Any] | None:
|
|
62
|
+
if not header:
|
|
63
|
+
return None
|
|
64
|
+
m = re.match(r"^\s*Risk\s+([A-Za-z0-9_-]+)\s*$", header)
|
|
65
|
+
if not m:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
return decode(m.group(1))
|
|
69
|
+
except (ValueError, UnicodeDecodeError):
|
|
70
|
+
return None
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""RFC 8785 (JSON Canonicalization Scheme). Every hash and every signature in x403 is
|
|
2
|
+
over this form, so two implementations that agree here agree everywhere. Numbers print
|
|
3
|
+
the way ES6 Number.prototype.toString prints them, which is the form the RFC fixes."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _es6_number(x: float) -> str:
|
|
14
|
+
if math.isnan(x) or math.isinf(x):
|
|
15
|
+
raise ValueError("JCS: non-finite number")
|
|
16
|
+
if x == 0:
|
|
17
|
+
return "0"
|
|
18
|
+
sign = "-" if x < 0 else ""
|
|
19
|
+
x = abs(x)
|
|
20
|
+
# repr gives the shortest digits that round-trip, which is the digit string ES6 uses;
|
|
21
|
+
# only the placement of the point and the exponent form differ, handled below.
|
|
22
|
+
text = repr(x)
|
|
23
|
+
if "e" in text:
|
|
24
|
+
mantissa, exponent = text.split("e")
|
|
25
|
+
e = int(exponent)
|
|
26
|
+
else:
|
|
27
|
+
mantissa, e = text, 0
|
|
28
|
+
if "." in mantissa:
|
|
29
|
+
whole, frac = mantissa.split(".")
|
|
30
|
+
else:
|
|
31
|
+
whole, frac = mantissa, ""
|
|
32
|
+
digits = whole + frac
|
|
33
|
+
point = e - len(frac) # value = int(digits) * 10 ** point
|
|
34
|
+
digits = digits.lstrip("0")
|
|
35
|
+
while digits.endswith("0"):
|
|
36
|
+
digits = digits[:-1]
|
|
37
|
+
point += 1
|
|
38
|
+
k = len(digits)
|
|
39
|
+
n = point + k # value = 0.digits * 10 ** n
|
|
40
|
+
if k <= n <= 21:
|
|
41
|
+
return sign + digits + "0" * (n - k)
|
|
42
|
+
if 0 < n <= 21:
|
|
43
|
+
return sign + digits[:n] + "." + digits[n:]
|
|
44
|
+
if -6 < n <= 0:
|
|
45
|
+
return sign + "0." + "0" * (-n) + digits
|
|
46
|
+
exp = n - 1
|
|
47
|
+
exp_text = ("+" if exp >= 0 else "-") + str(abs(exp))
|
|
48
|
+
if k == 1:
|
|
49
|
+
return sign + digits + "e" + exp_text
|
|
50
|
+
return sign + digits[0] + "." + digits[1:] + "e" + exp_text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _key(name: str) -> bytes:
|
|
54
|
+
# The RFC sorts by UTF-16 code units; comparing UTF-16BE bytes is the same order.
|
|
55
|
+
return name.encode("utf-16-be")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _serialize(value: Any) -> str:
|
|
59
|
+
if value is None:
|
|
60
|
+
return "null"
|
|
61
|
+
if value is True:
|
|
62
|
+
return "true"
|
|
63
|
+
if value is False:
|
|
64
|
+
return "false"
|
|
65
|
+
if isinstance(value, int):
|
|
66
|
+
if abs(value) < 2**53:
|
|
67
|
+
return str(value)
|
|
68
|
+
return _es6_number(float(value))
|
|
69
|
+
if isinstance(value, float):
|
|
70
|
+
return _es6_number(value)
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
# json.dumps escapes only what the RFC escapes, with lowercase hex.
|
|
73
|
+
return json.dumps(value, ensure_ascii=False)
|
|
74
|
+
if isinstance(value, (list, tuple)):
|
|
75
|
+
return "[" + ",".join(_serialize(v) for v in value) + "]"
|
|
76
|
+
if isinstance(value, dict):
|
|
77
|
+
items = sorted(((str(k), v) for k, v in value.items()), key=lambda kv: _key(kv[0]))
|
|
78
|
+
return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + _serialize(v) for k, v in items) + "}"
|
|
79
|
+
raise TypeError(f"JCS: cannot serialize {type(value).__name__}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def canonicalize(value: Any) -> str:
|
|
83
|
+
"""The canonical JSON text of a value."""
|
|
84
|
+
return _serialize(value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def canonical_bytes(value: Any) -> bytes:
|
|
88
|
+
return canonicalize(value).encode("utf-8")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def sha256_hex(data: bytes | str) -> str:
|
|
92
|
+
raw = data.encode("utf-8") if isinstance(data, str) else data
|
|
93
|
+
return hashlib.sha256(raw).hexdigest()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def hash_object(value: Any) -> str:
|
|
97
|
+
"""`sha256:<hex>` of the canonical form of an object."""
|
|
98
|
+
return "sha256:" + sha256_hex(canonical_bytes(value))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def hash_string(text: str) -> str:
|
|
102
|
+
"""`sha256:<hex>` of a string as given: a JWT, an SD-JWT, any opaque artifact."""
|
|
103
|
+
return "sha256:" + sha256_hex(text)
|