access402-fastapi 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.
- access402_fastapi-0.1.0/.gitignore +33 -0
- access402_fastapi-0.1.0/PKG-INFO +99 -0
- access402_fastapi-0.1.0/README.md +70 -0
- access402_fastapi-0.1.0/pyproject.toml +43 -0
- access402_fastapi-0.1.0/src/access402_fastapi/__init__.py +3 -0
- access402_fastapi-0.1.0/src/access402_fastapi/adapter.py +317 -0
- access402_fastapi-0.1.0/src/access402_fastapi/client.py +70 -0
- access402_fastapi-0.1.0/src/access402_fastapi/models.py +57 -0
- access402_fastapi-0.1.0/src/access402_fastapi/py.typed +1 -0
- access402_fastapi-0.1.0/src/access402_fastapi/security.py +78 -0
- access402_fastapi-0.1.0/tests/test_adapter.py +142 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Logs
|
|
2
|
+
logs
|
|
3
|
+
*.log
|
|
4
|
+
npm-debug.log*
|
|
5
|
+
yarn-debug.log*
|
|
6
|
+
yarn-error.log*
|
|
7
|
+
pnpm-debug.log*
|
|
8
|
+
lerna-debug.log*
|
|
9
|
+
|
|
10
|
+
node_modules
|
|
11
|
+
dist
|
|
12
|
+
dist-ssr
|
|
13
|
+
*.local
|
|
14
|
+
supabase/.temp
|
|
15
|
+
|
|
16
|
+
# Editor directories and files
|
|
17
|
+
.vscode/*
|
|
18
|
+
!.vscode/extensions.json
|
|
19
|
+
.idea
|
|
20
|
+
.DS_Store
|
|
21
|
+
*.suo
|
|
22
|
+
*.ntvs*
|
|
23
|
+
*.njsproj
|
|
24
|
+
*.sln
|
|
25
|
+
*.sw?
|
|
26
|
+
|
|
27
|
+
# Python adapter build and test output
|
|
28
|
+
__pycache__/
|
|
29
|
+
*.py[cod]
|
|
30
|
+
.pytest_cache/
|
|
31
|
+
*.egg-info/
|
|
32
|
+
.venv/
|
|
33
|
+
adapters/fastapi/dist/
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: access402-fastapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Managed Access402 x402 v2 protection for FastAPI
|
|
5
|
+
Project-URL: Homepage, https://access402.com
|
|
6
|
+
Project-URL: Documentation, https://access402.com/how-it-works
|
|
7
|
+
Project-URL: Repository, https://github.com/JonathanRoyere/Access402Dash
|
|
8
|
+
Project-URL: Issues, https://github.com/JonathanRoyere/Access402Dash/issues
|
|
9
|
+
Author: Jonathan Royere
|
|
10
|
+
Keywords: access402,api-monetization,fastapi,payments,x402
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
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: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: fastapi>=0.115
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: pydantic>=2.8
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest>=8.3; extra == 'test'
|
|
27
|
+
Requires-Dist: uvicorn>=0.30; extra == 'test'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Access402 FastAPI adapter
|
|
31
|
+
|
|
32
|
+
This package protects FastAPI routes with Access402's managed x402 v2 payment service. Route shapes and OpenAPI schemas come from the running application; pricing, enablement, sandbox/live mode, reusable access, and discovery remain dashboard settings.
|
|
33
|
+
|
|
34
|
+
## Install locally
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e "./adapters/fastapi[test]"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Connect an application
|
|
41
|
+
|
|
42
|
+
Create a **FastAPI** installation in the Access402 dashboard, then configure server-only environment variables:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
ACCESS402_INSTALLATION_ID=your-installation-uuid
|
|
46
|
+
ACCESS402_API_KEY=your-installation-key
|
|
47
|
+
ACCESS402_PUBLIC_BASE_URL=https://api.example.com
|
|
48
|
+
ACCESS402_ALLOW_LIVE=false
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The API key belongs only in the FastAPI server environment. Never expose it in browser code, logs, OpenAPI documents, or a committed `.env` file. `ACCESS402_API_BASE_URL` is intentionally optional and should only be overridden for local Access402 backend development.
|
|
52
|
+
|
|
53
|
+
Define routes first, then install Access402:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from fastapi import FastAPI
|
|
57
|
+
from access402_fastapi import Access402
|
|
58
|
+
|
|
59
|
+
app = FastAPI()
|
|
60
|
+
|
|
61
|
+
@app.get("/reports/{report_id}")
|
|
62
|
+
async def report(report_id: str):
|
|
63
|
+
return {"id": report_id, "result": "..."}
|
|
64
|
+
|
|
65
|
+
access402 = Access402.from_env()
|
|
66
|
+
access402.install(app)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
On its first request, the adapter authenticates the installation, uploads a compact route catalog, and downloads an HMAC-authenticated configuration. The configuration is cached in memory and refreshed periodically; payment requests go directly to the Access402 settlement function. No CDP credential is installed in this package.
|
|
70
|
+
|
|
71
|
+
After the first catalog sync, open the installation's **Manage API routes** screen in the dashboard to choose routes, prices, access policies, environment, and Bazaar publication.
|
|
72
|
+
|
|
73
|
+
Dynamic path routes such as `/reports/{report_id}` can be protected. Discovery publication for those routes stays disabled until Access402 supports a dashboard-provided concrete path-parameter example; publishing a literal template URL would create a broken Bazaar entry.
|
|
74
|
+
|
|
75
|
+
Live mode requires both the dashboard toggle and `ACCESS402_ALLOW_LIVE=true`. This second switch is a deployment safety ceiling, not another credential.
|
|
76
|
+
|
|
77
|
+
The adapter answers its own 402 responses with `Access-Control-Allow-Origin: *` by default so agent and browser clients can read the payment challenge. Set `ACCESS402_CORS_ALLOW_ORIGIN` to the API's exact browser origin when credentials are involved. CORS preflight (`OPTIONS`) is never paywalled.
|
|
78
|
+
|
|
79
|
+
## Bypasses
|
|
80
|
+
|
|
81
|
+
There is no header-based administrator bypass. If an application needs trusted internal access, pass a callback that validates the application's real authentication state:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
async def trusted_internal_request(scope):
|
|
85
|
+
user = scope.get("state", {}).get("user")
|
|
86
|
+
return bool(user and user.is_admin)
|
|
87
|
+
|
|
88
|
+
access402 = Access402.from_env(bypass=trusted_internal_request)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Put authentication middleware outside Access402 if the callback depends on middleware-populated state. A spoofable header must never be used as the bypass decision.
|
|
92
|
+
|
|
93
|
+
## Tests
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
cd adapters/fastapi
|
|
97
|
+
python -m pip install -e ".[test]"
|
|
98
|
+
pytest
|
|
99
|
+
```
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Access402 FastAPI adapter
|
|
2
|
+
|
|
3
|
+
This package protects FastAPI routes with Access402's managed x402 v2 payment service. Route shapes and OpenAPI schemas come from the running application; pricing, enablement, sandbox/live mode, reusable access, and discovery remain dashboard settings.
|
|
4
|
+
|
|
5
|
+
## Install locally
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e "./adapters/fastapi[test]"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Connect an application
|
|
12
|
+
|
|
13
|
+
Create a **FastAPI** installation in the Access402 dashboard, then configure server-only environment variables:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
ACCESS402_INSTALLATION_ID=your-installation-uuid
|
|
17
|
+
ACCESS402_API_KEY=your-installation-key
|
|
18
|
+
ACCESS402_PUBLIC_BASE_URL=https://api.example.com
|
|
19
|
+
ACCESS402_ALLOW_LIVE=false
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The API key belongs only in the FastAPI server environment. Never expose it in browser code, logs, OpenAPI documents, or a committed `.env` file. `ACCESS402_API_BASE_URL` is intentionally optional and should only be overridden for local Access402 backend development.
|
|
23
|
+
|
|
24
|
+
Define routes first, then install Access402:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from fastapi import FastAPI
|
|
28
|
+
from access402_fastapi import Access402
|
|
29
|
+
|
|
30
|
+
app = FastAPI()
|
|
31
|
+
|
|
32
|
+
@app.get("/reports/{report_id}")
|
|
33
|
+
async def report(report_id: str):
|
|
34
|
+
return {"id": report_id, "result": "..."}
|
|
35
|
+
|
|
36
|
+
access402 = Access402.from_env()
|
|
37
|
+
access402.install(app)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
On its first request, the adapter authenticates the installation, uploads a compact route catalog, and downloads an HMAC-authenticated configuration. The configuration is cached in memory and refreshed periodically; payment requests go directly to the Access402 settlement function. No CDP credential is installed in this package.
|
|
41
|
+
|
|
42
|
+
After the first catalog sync, open the installation's **Manage API routes** screen in the dashboard to choose routes, prices, access policies, environment, and Bazaar publication.
|
|
43
|
+
|
|
44
|
+
Dynamic path routes such as `/reports/{report_id}` can be protected. Discovery publication for those routes stays disabled until Access402 supports a dashboard-provided concrete path-parameter example; publishing a literal template URL would create a broken Bazaar entry.
|
|
45
|
+
|
|
46
|
+
Live mode requires both the dashboard toggle and `ACCESS402_ALLOW_LIVE=true`. This second switch is a deployment safety ceiling, not another credential.
|
|
47
|
+
|
|
48
|
+
The adapter answers its own 402 responses with `Access-Control-Allow-Origin: *` by default so agent and browser clients can read the payment challenge. Set `ACCESS402_CORS_ALLOW_ORIGIN` to the API's exact browser origin when credentials are involved. CORS preflight (`OPTIONS`) is never paywalled.
|
|
49
|
+
|
|
50
|
+
## Bypasses
|
|
51
|
+
|
|
52
|
+
There is no header-based administrator bypass. If an application needs trusted internal access, pass a callback that validates the application's real authentication state:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
async def trusted_internal_request(scope):
|
|
56
|
+
user = scope.get("state", {}).get("user")
|
|
57
|
+
return bool(user and user.is_admin)
|
|
58
|
+
|
|
59
|
+
access402 = Access402.from_env(bypass=trusted_internal_request)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Put authentication middleware outside Access402 if the callback depends on middleware-populated state. A spoofable header must never be used as the bypass decision.
|
|
63
|
+
|
|
64
|
+
## Tests
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cd adapters/fastapi
|
|
68
|
+
python -m pip install -e ".[test]"
|
|
69
|
+
pytest
|
|
70
|
+
```
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "access402-fastapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Managed Access402 x402 v2 protection for FastAPI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Jonathan Royere" }]
|
|
12
|
+
keywords = ["access402", "x402", "fastapi", "payments", "api-monetization"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Framework :: FastAPI",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["fastapi>=0.115", "httpx>=0.27", "pydantic>=2.8"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://access402.com"
|
|
28
|
+
Documentation = "https://access402.com/how-it-works"
|
|
29
|
+
Repository = "https://github.com/JonathanRoyere/Access402Dash"
|
|
30
|
+
Issues = "https://github.com/JonathanRoyere/Access402Dash/issues"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
test = ["pytest>=8.3", "pytest-asyncio>=0.24", "uvicorn>=0.30"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
asyncio_mode = "auto"
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/access402_fastapi"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.sdist]
|
|
43
|
+
include = ["src/access402_fastapi", "tests", "README.md", "pyproject.toml"]
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from typing import Any, Awaitable, Callable
|
|
14
|
+
from urllib.parse import quote, urlsplit
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
from fastapi.routing import APIRoute
|
|
19
|
+
|
|
20
|
+
from .client import Access402Client, stable_hash
|
|
21
|
+
from .models import Configuration, ResourcePolicy
|
|
22
|
+
from .security import canonical_request_path, sign_grant, template_matches, valid_grant
|
|
23
|
+
|
|
24
|
+
BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
25
|
+
BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
|
|
26
|
+
Bypass = Callable[[dict[str, Any]], bool | Awaitable[bool]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Access402Settings:
|
|
31
|
+
installation_id: str
|
|
32
|
+
api_key: str
|
|
33
|
+
public_base_url: str
|
|
34
|
+
api_base_url: str = "https://sfowjygkbsubaeggmckp.supabase.co/functions/v1"
|
|
35
|
+
refresh_seconds: int = 60
|
|
36
|
+
catalog_refresh_seconds: int = 300
|
|
37
|
+
stale_after_seconds: int = 900
|
|
38
|
+
allow_live: bool = False
|
|
39
|
+
fail_closed: bool = True
|
|
40
|
+
cors_allow_origin: str = "*"
|
|
41
|
+
excluded_paths: tuple[str, ...] = ("/health", "/docs", "/redoc", "/openapi.json")
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_env(cls) -> "Access402Settings":
|
|
45
|
+
required = {
|
|
46
|
+
name: os.environ.get(name, "").strip()
|
|
47
|
+
for name in ("ACCESS402_INSTALLATION_ID", "ACCESS402_API_KEY", "ACCESS402_PUBLIC_BASE_URL")
|
|
48
|
+
}
|
|
49
|
+
missing = [name for name, value in required.items() if not value]
|
|
50
|
+
if missing:
|
|
51
|
+
raise RuntimeError(f"Missing Access402 environment variables: {', '.join(missing)}")
|
|
52
|
+
return cls(
|
|
53
|
+
installation_id=required["ACCESS402_INSTALLATION_ID"],
|
|
54
|
+
api_key=required["ACCESS402_API_KEY"],
|
|
55
|
+
public_base_url=required["ACCESS402_PUBLIC_BASE_URL"].rstrip("/"),
|
|
56
|
+
api_base_url=os.environ.get("ACCESS402_API_BASE_URL", cls.api_base_url).rstrip("/"),
|
|
57
|
+
refresh_seconds=int(os.environ.get("ACCESS402_REFRESH_SECONDS", "60")),
|
|
58
|
+
catalog_refresh_seconds=int(os.environ.get("ACCESS402_CATALOG_REFRESH_SECONDS", "300")),
|
|
59
|
+
stale_after_seconds=int(os.environ.get("ACCESS402_STALE_AFTER_SECONDS", "900")),
|
|
60
|
+
allow_live=os.environ.get("ACCESS402_ALLOW_LIVE", "false").lower() == "true",
|
|
61
|
+
cors_allow_origin=os.environ.get("ACCESS402_CORS_ALLOW_ORIGIN", "*"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Access402:
|
|
66
|
+
def __init__(self, settings: Access402Settings, *, bypass: Bypass | None = None, transport: httpx.AsyncBaseTransport | None = None):
|
|
67
|
+
self.settings = settings
|
|
68
|
+
self.bypass = bypass
|
|
69
|
+
self.client = Access402Client(settings.api_base_url, settings.api_key, settings.installation_id, transport=transport)
|
|
70
|
+
self.app: FastAPI | None = None
|
|
71
|
+
self.configuration: Configuration | None = None
|
|
72
|
+
self._loaded_at = 0.0
|
|
73
|
+
self._catalog_at = 0.0
|
|
74
|
+
self._lock = asyncio.Lock()
|
|
75
|
+
self._refresh_task: asyncio.Task[Configuration] | None = None
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_env(cls, **kwargs: Any) -> "Access402":
|
|
79
|
+
return cls(Access402Settings.from_env(), **kwargs)
|
|
80
|
+
|
|
81
|
+
def install(self, app: FastAPI) -> None:
|
|
82
|
+
self.app = app
|
|
83
|
+
app.add_middleware(Access402Middleware, manager=self)
|
|
84
|
+
|
|
85
|
+
async def close(self) -> None:
|
|
86
|
+
await self.client.close()
|
|
87
|
+
|
|
88
|
+
def route_catalog(self) -> list[dict[str, Any]]:
|
|
89
|
+
if not self.app:
|
|
90
|
+
raise RuntimeError("Access402 is not installed on a FastAPI app")
|
|
91
|
+
schema = self.app.openapi()
|
|
92
|
+
base = self.settings.public_base_url
|
|
93
|
+
routes: list[dict[str, Any]] = []
|
|
94
|
+
for route in self.app.routes:
|
|
95
|
+
if not isinstance(route, APIRoute) or route.path in self.settings.excluded_paths:
|
|
96
|
+
continue
|
|
97
|
+
path_item = schema.get("paths", {}).get(route.path, {})
|
|
98
|
+
for method in sorted(route.methods or []):
|
|
99
|
+
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"}:
|
|
100
|
+
continue
|
|
101
|
+
operation = path_item.get(method.lower(), {})
|
|
102
|
+
content = operation.get("requestBody", {}).get("content", {})
|
|
103
|
+
input_schema = next((entry.get("schema", {}) for entry in content.values() if isinstance(entry, dict)), {})
|
|
104
|
+
responses = operation.get("responses", {})
|
|
105
|
+
success = next((value for key, value in responses.items() if str(key).startswith("2")), {})
|
|
106
|
+
output_content = success.get("content", {}) if isinstance(success, dict) else {}
|
|
107
|
+
output_schema = next((entry.get("schema", {}) for entry in output_content.values() if isinstance(entry, dict)), {})
|
|
108
|
+
mime_type = next(iter(output_content), "application/json")
|
|
109
|
+
identity = f"{method} {route.path}"
|
|
110
|
+
route_key = stable_hash(identity)
|
|
111
|
+
item = {
|
|
112
|
+
"route_key": route_key, "method": method, "path_template": route.path,
|
|
113
|
+
"resource_url": f"{base}{route.path}", "operation_id": operation.get("operationId") or route.name,
|
|
114
|
+
"display_name": operation.get("summary") or route.name or identity,
|
|
115
|
+
"description": operation.get("description") or route.description or None,
|
|
116
|
+
"mime_type": mime_type, "input_schema": input_schema if isinstance(input_schema, dict) else {},
|
|
117
|
+
"output_schema": output_schema if isinstance(output_schema, dict) else {},
|
|
118
|
+
}
|
|
119
|
+
item["catalog_hash"] = stable_hash(item)
|
|
120
|
+
routes.append(item)
|
|
121
|
+
return routes
|
|
122
|
+
|
|
123
|
+
async def refresh(self, *, force: bool = False) -> Configuration:
|
|
124
|
+
now = time.monotonic()
|
|
125
|
+
if self.configuration and not force and now - self._loaded_at < self.settings.refresh_seconds:
|
|
126
|
+
return self.configuration
|
|
127
|
+
async with self._lock:
|
|
128
|
+
now = time.monotonic()
|
|
129
|
+
if self.configuration and not force and now - self._loaded_at < self.settings.refresh_seconds:
|
|
130
|
+
return self.configuration
|
|
131
|
+
if not self._catalog_at or now - self._catalog_at >= self.settings.catalog_refresh_seconds:
|
|
132
|
+
await self.client.sync_catalog(self.route_catalog())
|
|
133
|
+
self._catalog_at = now
|
|
134
|
+
configuration = await self.client.configuration()
|
|
135
|
+
if configuration.environment == "live" and not self.settings.allow_live:
|
|
136
|
+
raise RuntimeError("Live settlement is disabled by ACCESS402_ALLOW_LIVE")
|
|
137
|
+
self.configuration = configuration
|
|
138
|
+
self._loaded_at = now
|
|
139
|
+
return configuration
|
|
140
|
+
|
|
141
|
+
async def current_configuration(self) -> Configuration:
|
|
142
|
+
now = time.monotonic()
|
|
143
|
+
if self.configuration and now - self._loaded_at < self.settings.stale_after_seconds:
|
|
144
|
+
if now - self._loaded_at >= self.settings.refresh_seconds and (
|
|
145
|
+
self._refresh_task is None or self._refresh_task.done()
|
|
146
|
+
):
|
|
147
|
+
self._refresh_task = asyncio.create_task(self.refresh(force=True))
|
|
148
|
+
self._refresh_task.add_done_callback(lambda task: task.exception() if not task.cancelled() else None)
|
|
149
|
+
return self.configuration
|
|
150
|
+
return await self.refresh(force=True)
|
|
151
|
+
|
|
152
|
+
def policy_for(self, method: str, path: str) -> ResourcePolicy | None:
|
|
153
|
+
if not self.configuration:
|
|
154
|
+
return None
|
|
155
|
+
return next((policy for policy in self.configuration.resources
|
|
156
|
+
if policy.enabled and policy.route_method == method and template_matches(policy.route_template, path)), None)
|
|
157
|
+
|
|
158
|
+
def stale(self) -> bool:
|
|
159
|
+
return not self.configuration or time.monotonic() - self._loaded_at > self.settings.stale_after_seconds
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Access402Middleware:
|
|
163
|
+
def __init__(self, app: Any, manager: Access402):
|
|
164
|
+
self.app = app
|
|
165
|
+
self.manager = manager
|
|
166
|
+
|
|
167
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
168
|
+
if scope["type"] == "lifespan":
|
|
169
|
+
try:
|
|
170
|
+
await self.app(scope, receive, send)
|
|
171
|
+
finally:
|
|
172
|
+
await self.manager.close()
|
|
173
|
+
return
|
|
174
|
+
if scope["type"] != "http":
|
|
175
|
+
await self.app(scope, receive, send)
|
|
176
|
+
return
|
|
177
|
+
try:
|
|
178
|
+
path = canonical_request_path(scope.get("raw_path") or scope.get("path", "/").encode())
|
|
179
|
+
except ValueError:
|
|
180
|
+
await self._json(send, 400, {"error": "ambiguous_request_path"})
|
|
181
|
+
return
|
|
182
|
+
if path in self.manager.settings.excluded_paths:
|
|
183
|
+
await self.app(scope, receive, send)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
if str(scope.get("method", "GET")).upper() == "OPTIONS":
|
|
187
|
+
await self.app(scope, receive, send)
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
configuration = await self.manager.current_configuration()
|
|
192
|
+
except Exception:
|
|
193
|
+
if self.manager.settings.fail_closed and self.manager.stale():
|
|
194
|
+
await self._json(send, 503, {"error": "access402_configuration_unavailable"}, [(b"cache-control", b"no-store")])
|
|
195
|
+
return
|
|
196
|
+
configuration = self.manager.configuration
|
|
197
|
+
if not configuration:
|
|
198
|
+
await self.app(scope, receive, send)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
method = str(scope.get("method", "GET")).upper()
|
|
202
|
+
policy = self.manager.policy_for(method, path)
|
|
203
|
+
if not policy:
|
|
204
|
+
await self.app(scope, receive, send)
|
|
205
|
+
return
|
|
206
|
+
if self.manager.bypass:
|
|
207
|
+
result = self.manager.bypass(scope)
|
|
208
|
+
if await result if inspect.isawaitable(result) else result:
|
|
209
|
+
await self.app(scope, receive, send)
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
headers = {key.decode("latin1").lower(): value.decode("latin1") for key, value in scope.get("headers", [])}
|
|
213
|
+
grant = headers.get("x-access402-grant")
|
|
214
|
+
if policy.access_type != "per_request" and valid_grant(self.manager.settings.api_key, grant, policy.local_rule_id):
|
|
215
|
+
await self._downstream(scope, receive, send, [(b"x-access402-grant", grant.encode())])
|
|
216
|
+
self._event(configuration, policy, "delivered", {"access_grant": True})
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
challenge, requirements = self._challenge(configuration, policy, path)
|
|
220
|
+
encoded_challenge = base64.b64encode(json.dumps(challenge, separators=(",", ":")).encode()).decode()
|
|
221
|
+
payment = headers.get("payment-signature") or headers.get("x-payment")
|
|
222
|
+
if not payment:
|
|
223
|
+
self._event(configuration, policy, "payment_required")
|
|
224
|
+
await self._json(send, 402, challenge, self._challenge_headers(encoded_challenge, "payment_required"))
|
|
225
|
+
return
|
|
226
|
+
try:
|
|
227
|
+
settled = await self.manager.client.settle(
|
|
228
|
+
policy, payment, requirements, "live" if configuration.environment == "live" else "sandbox"
|
|
229
|
+
)
|
|
230
|
+
except httpx.HTTPStatusError as error:
|
|
231
|
+
code = "settlement_failed" if error.response.status_code < 500 else "settlement_service_unavailable"
|
|
232
|
+
status = 402 if error.response.status_code < 500 else 503
|
|
233
|
+
self._event(configuration, policy, code)
|
|
234
|
+
await self._json(send, status, {**challenge, "error": code}, self._challenge_headers(encoded_challenge, code))
|
|
235
|
+
return
|
|
236
|
+
except Exception:
|
|
237
|
+
self._event(configuration, policy, "settlement_service_unavailable")
|
|
238
|
+
await self._json(send, 503, {**challenge, "error": "settlement_service_unavailable"}, self._challenge_headers(encoded_challenge, "settlement_service_unavailable"))
|
|
239
|
+
return
|
|
240
|
+
if settled.get("settled") is not True:
|
|
241
|
+
self._event(configuration, policy, "settlement_failed")
|
|
242
|
+
await self._json(send, 402, {**challenge, "error": "settlement_failed"}, self._challenge_headers(encoded_challenge, "settlement_failed"))
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
response_headers: list[tuple[bytes, bytes]] = []
|
|
246
|
+
if isinstance(settled.get("payment_response"), str):
|
|
247
|
+
response_headers.append((b"payment-response", settled["payment_response"].encode()))
|
|
248
|
+
if policy.access_type != "per_request":
|
|
249
|
+
expires = None if policy.access_type == "wallet_once" else int(time.time()) + int(policy.access_duration_seconds or 0)
|
|
250
|
+
token = sign_grant(self.manager.settings.api_key, policy.local_rule_id, str(settled.get("payer", "")), expires)
|
|
251
|
+
response_headers.append((b"x-access402-grant", token.encode()))
|
|
252
|
+
response_headers.append((b"access-control-expose-headers", b"PAYMENT-RESPONSE, X-Access402-Grant"))
|
|
253
|
+
await self._downstream(scope, receive, send, response_headers)
|
|
254
|
+
self._event(configuration, policy, "payment_settled", {"access_type": policy.access_type}, settled.get("payment_event_id"))
|
|
255
|
+
|
|
256
|
+
def _challenge(self, configuration: Configuration, policy: ResourcePolicy, path: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
257
|
+
amount = str(int(Decimal(policy.price) * Decimal(1_000_000)))
|
|
258
|
+
asset = BASE_USDC if configuration.network == "eip155:8453" else BASE_SEPOLIA_USDC
|
|
259
|
+
requirements = {
|
|
260
|
+
"scheme": "exact", "network": configuration.network, "amount": amount, "asset": asset,
|
|
261
|
+
"payTo": configuration.pay_to, "maxTimeoutSeconds": 60,
|
|
262
|
+
"extra": {"assetTransferMethod": "eip3009", "name": "USD Coin" if configuration.network == "eip155:8453" else "USDC", "version": "2"},
|
|
263
|
+
}
|
|
264
|
+
extension: dict[str, Any] = {}
|
|
265
|
+
if policy.discovery_enabled:
|
|
266
|
+
metadata = policy.discovery_metadata
|
|
267
|
+
method = policy.discovery_method
|
|
268
|
+
input_value: dict[str, Any] = {"type": "http", "method": method}
|
|
269
|
+
if method in ("POST", "PUT", "PATCH"):
|
|
270
|
+
input_value.update({"bodyType": "json", "body": metadata.get("input_example", {})})
|
|
271
|
+
else:
|
|
272
|
+
input_value["queryParams"] = metadata.get("input_example", {})
|
|
273
|
+
extension = {"bazaar": {"info": {"input": input_value}, "schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object"}}}
|
|
274
|
+
resource_url = f"{self.manager.settings.public_base_url}{quote(path, safe='/:@-._~!$&\'()*+,;=')}"
|
|
275
|
+
challenge = {
|
|
276
|
+
"x402Version": 2, "error": "PAYMENT-SIGNATURE header is required",
|
|
277
|
+
"resource": {"url": resource_url, "description": policy.description or policy.display_name,
|
|
278
|
+
"mimeType": policy.mime_type or "application/json"},
|
|
279
|
+
"accepts": [requirements], "extensions": extension,
|
|
280
|
+
}
|
|
281
|
+
return challenge, requirements
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _challenge_headers(encoded: str, error: str) -> list[tuple[bytes, bytes]]:
|
|
285
|
+
return [(b"cache-control", b"no-store"), (b"payment-required", encoded.encode()),
|
|
286
|
+
(b"x-access402-error", error.encode()),
|
|
287
|
+
(b"access-control-expose-headers", b"PAYMENT-REQUIRED, X-Access402-Error")]
|
|
288
|
+
|
|
289
|
+
async def _downstream(self, scope: dict[str, Any], receive: Any, send: Any, extra: list[tuple[bytes, bytes]]) -> None:
|
|
290
|
+
async def wrapped(message: dict[str, Any]) -> None:
|
|
291
|
+
if message["type"] == "http.response.start":
|
|
292
|
+
message["headers"] = list(message.get("headers", [])) + extra
|
|
293
|
+
await send(message)
|
|
294
|
+
await self.app(scope, receive, wrapped)
|
|
295
|
+
|
|
296
|
+
async def _json(self, send: Any, status: int, body: dict[str, Any], headers: list[tuple[bytes, bytes]] | None = None) -> None:
|
|
297
|
+
payload = json.dumps(body, separators=(",", ":")).encode()
|
|
298
|
+
response_headers = list(headers or [])
|
|
299
|
+
if not any(name.lower() == b"access-control-allow-origin" for name, _ in response_headers):
|
|
300
|
+
response_headers.append((b"access-control-allow-origin", self.manager.settings.cors_allow_origin.encode()))
|
|
301
|
+
await send({"type": "http.response.start", "status": status,
|
|
302
|
+
"headers": [(b"content-type", b"application/json"), (b"content-length", str(len(payload)).encode()), *response_headers]})
|
|
303
|
+
await send({"type": "http.response.body", "body": payload})
|
|
304
|
+
|
|
305
|
+
def _event(self, configuration: Configuration, policy: ResourcePolicy, event_type: str,
|
|
306
|
+
details: dict[str, Any] | None = None, payment_event_id: Any = None) -> None:
|
|
307
|
+
event = {"event_id": str(uuid.uuid4()), "event_type": event_type,
|
|
308
|
+
"occurred_at": datetime.now(timezone.utc).isoformat(), "local_rule_id": policy.local_rule_id,
|
|
309
|
+
"resource_key": policy.resource_key,
|
|
310
|
+
"payment_event_id": payment_event_id if event_type == "payment_settled" else None,
|
|
311
|
+
"details": details or {}}
|
|
312
|
+
try:
|
|
313
|
+
asyncio.get_running_loop().create_task(self.manager.client.publish_event(
|
|
314
|
+
"live" if configuration.environment == "live" else "sandbox", event
|
|
315
|
+
))
|
|
316
|
+
except RuntimeError:
|
|
317
|
+
pass
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .models import Configuration
|
|
10
|
+
from .security import verify_configuration_signature
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Access402Client:
|
|
14
|
+
def __init__(self, base_url: str, api_key: str, installation_id: str, timeout: float = 8.0, transport: httpx.AsyncBaseTransport | None = None):
|
|
15
|
+
self.base_url = base_url.rstrip("/")
|
|
16
|
+
self.api_key = api_key
|
|
17
|
+
self.installation_id = installation_id
|
|
18
|
+
self.http = httpx.AsyncClient(timeout=timeout, follow_redirects=False, transport=transport)
|
|
19
|
+
|
|
20
|
+
def headers(self, mode: str = "sandbox") -> dict[str, str]:
|
|
21
|
+
return {
|
|
22
|
+
"Authorization": f"Bearer {self.api_key}", "Accept": "application/json",
|
|
23
|
+
"X-Access402-Adapter-Version": "1", "X-Access402-Mode": mode,
|
|
24
|
+
"User-Agent": "Access402-FastAPI/0.1.0",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async def close(self) -> None:
|
|
28
|
+
await self.http.aclose()
|
|
29
|
+
|
|
30
|
+
async def sync_catalog(self, routes: list[dict[str, Any]]) -> str:
|
|
31
|
+
response = await self.http.put(f"{self.base_url}/adapter/catalog", headers=self.headers(), json={"routes": routes})
|
|
32
|
+
response.raise_for_status()
|
|
33
|
+
value = response.json()
|
|
34
|
+
return str(value["configuration_version"])
|
|
35
|
+
|
|
36
|
+
async def configuration(self) -> Configuration:
|
|
37
|
+
response = await self.http.get(f"{self.base_url}/adapter/configuration", headers=self.headers())
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
verify_configuration_signature(self.api_key, response.content, response.headers.get("X-Access402-Configuration-Signature"))
|
|
40
|
+
value = response.json()
|
|
41
|
+
from .models import ResourcePolicy
|
|
42
|
+
resources = tuple(ResourcePolicy.from_dict(item) for item in value.get("resources", []))
|
|
43
|
+
environment = str(value.get("environment"))
|
|
44
|
+
network = str(value.get("network"))
|
|
45
|
+
pay_to = str(value.get("pay_to") or "")
|
|
46
|
+
if value.get("x402_version") != 2 or value.get("installation_id") != self.installation_id or environment not in ("test", "live") or not network or not pay_to:
|
|
47
|
+
raise ValueError("Invalid Access402 configuration")
|
|
48
|
+
return Configuration(str(value.get("configuration_version")), str(value.get("generated_at")), environment, network, pay_to, resources)
|
|
49
|
+
|
|
50
|
+
async def settle(self, policy: Any, payment: str, requirements: dict[str, Any], mode: str) -> dict[str, Any]:
|
|
51
|
+
idempotency = hashlib.sha256(f"{policy.local_rule_id}|{payment}".encode()).hexdigest()
|
|
52
|
+
response = await self.http.post(
|
|
53
|
+
f"{self.base_url}/payments/settle", headers={**self.headers(mode), "Content-Type": "application/json"},
|
|
54
|
+
json={"rule_id": policy.local_rule_id, "resource_key": policy.resource_key,
|
|
55
|
+
"payment_payload": payment, "payment_requirements": requirements, "idempotency_key": idempotency},
|
|
56
|
+
)
|
|
57
|
+
if response.status_code >= 500:
|
|
58
|
+
raise RuntimeError("Access402 settlement service unavailable")
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
return response.json()
|
|
61
|
+
|
|
62
|
+
async def publish_event(self, mode: str, event: dict[str, Any]) -> None:
|
|
63
|
+
try:
|
|
64
|
+
await self.http.post(f"{self.base_url}/adapter/events", headers={**self.headers(mode), "Content-Type": "application/json"}, json={"events": [event]})
|
|
65
|
+
except Exception:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def stable_hash(value: Any) -> str:
|
|
70
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ResourcePolicy:
|
|
9
|
+
id: str
|
|
10
|
+
local_rule_id: str
|
|
11
|
+
resource_key: str
|
|
12
|
+
resource_url: str
|
|
13
|
+
display_name: str
|
|
14
|
+
description: str | None
|
|
15
|
+
price: str
|
|
16
|
+
asset: str
|
|
17
|
+
mime_type: str | None
|
|
18
|
+
access_type: str
|
|
19
|
+
access_duration_seconds: int | None
|
|
20
|
+
enabled: bool
|
|
21
|
+
discovery_enabled: bool
|
|
22
|
+
discovery_method: str
|
|
23
|
+
discovery_metadata: dict[str, Any]
|
|
24
|
+
route_method: str
|
|
25
|
+
route_template: str
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, value: dict[str, Any]) -> "ResourcePolicy":
|
|
29
|
+
required = ("id", "local_rule_id", "resource_key", "resource_url", "route_method", "route_template")
|
|
30
|
+
if any(not isinstance(value.get(key), str) or not value[key] for key in required):
|
|
31
|
+
raise ValueError("Invalid Access402 resource policy")
|
|
32
|
+
price = value.get("price")
|
|
33
|
+
if not isinstance(price, (str, int, float)) or not str(price):
|
|
34
|
+
raise ValueError("Invalid Access402 resource policy price")
|
|
35
|
+
return cls(
|
|
36
|
+
id=value["id"], local_rule_id=value["local_rule_id"], resource_key=value["resource_key"],
|
|
37
|
+
resource_url=value["resource_url"], display_name=str(value.get("display_name", "")),
|
|
38
|
+
description=value.get("description") if isinstance(value.get("description"), str) else None,
|
|
39
|
+
price=str(price), asset=str(value.get("asset", "USDC")),
|
|
40
|
+
mime_type=value.get("mime_type") if isinstance(value.get("mime_type"), str) else None,
|
|
41
|
+
access_type=str(value.get("access_type", "per_request")),
|
|
42
|
+
access_duration_seconds=value.get("access_duration_seconds") if isinstance(value.get("access_duration_seconds"), int) else None,
|
|
43
|
+
enabled=value.get("enabled") is True, discovery_enabled=value.get("discovery_enabled") is True,
|
|
44
|
+
discovery_method=str(value.get("discovery_method", "GET")),
|
|
45
|
+
discovery_metadata=value.get("discovery_metadata") if isinstance(value.get("discovery_metadata"), dict) else {},
|
|
46
|
+
route_method=value["route_method"].upper(), route_template=value["route_template"],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Configuration:
|
|
52
|
+
version: str
|
|
53
|
+
generated_at: str
|
|
54
|
+
environment: str
|
|
55
|
+
network: str
|
|
56
|
+
pay_to: str
|
|
57
|
+
resources: tuple[ResourcePolicy, ...] = field(default_factory=tuple)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import unquote
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
AMBIGUOUS_ENCODING = re.compile(br"%2f|%5c", re.IGNORECASE)
|
|
14
|
+
PARAMETER = re.compile(r"^\{[A-Za-z_][A-Za-z0-9_]*\}$")
|
|
15
|
+
PATH_PARAMETER = re.compile(r"^\{[A-Za-z_][A-Za-z0-9_]*:path\}$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def base64url(data: bytes) -> str:
|
|
19
|
+
return base64.urlsafe_b64encode(data).decode().rstrip("=")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_configuration_signature(api_key: str, body: bytes, header: str | None) -> None:
|
|
23
|
+
if not header or not header.startswith("v1="):
|
|
24
|
+
raise ValueError("Missing Access402 configuration signature")
|
|
25
|
+
expected = base64url(hmac.new(api_key.encode(), body, hashlib.sha256).digest())
|
|
26
|
+
if not hmac.compare_digest(expected, header[3:]):
|
|
27
|
+
raise ValueError("Invalid Access402 configuration signature")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def canonical_request_path(raw_path: bytes) -> str:
|
|
31
|
+
if not raw_path.startswith(b"/") or b"\\" in raw_path or AMBIGUOUS_ENCODING.search(raw_path):
|
|
32
|
+
raise ValueError("Ambiguous request path")
|
|
33
|
+
try:
|
|
34
|
+
path = unquote(raw_path.decode("ascii"), errors="strict")
|
|
35
|
+
except (UnicodeDecodeError, ValueError):
|
|
36
|
+
raise ValueError("Invalid request path") from None
|
|
37
|
+
if "\\" in path or "//" in path or any(segment in (".", "..") for segment in path.split("/")):
|
|
38
|
+
raise ValueError("Ambiguous request path")
|
|
39
|
+
return path[:-1] if len(path) > 1 and path.endswith("/") else path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def template_matches(template: str, path: str) -> bool:
|
|
43
|
+
normalized = template[:-1] if len(template) > 1 and template.endswith("/") else template
|
|
44
|
+
template_parts, path_parts = normalized.split("/"), path.split("/")
|
|
45
|
+
for index, part in enumerate(template_parts):
|
|
46
|
+
if PATH_PARAMETER.match(part):
|
|
47
|
+
return index < len(path_parts) and bool("/".join(path_parts[index:]))
|
|
48
|
+
if index >= len(path_parts):
|
|
49
|
+
return False
|
|
50
|
+
if PARAMETER.match(part):
|
|
51
|
+
if not path_parts[index]:
|
|
52
|
+
return False
|
|
53
|
+
elif part != path_parts[index]:
|
|
54
|
+
return False
|
|
55
|
+
return len(template_parts) == len(path_parts)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sign_grant(api_key: str, rule_id: str, payer: str, expires_at: int | None) -> str:
|
|
59
|
+
payload = base64url(json.dumps({"v": 1, "r": rule_id, "p": payer, "e": expires_at}, separators=(",", ":"), sort_keys=True).encode())
|
|
60
|
+
signature = base64url(hmac.new(api_key.encode(), payload.encode(), hashlib.sha256).digest())
|
|
61
|
+
return f"{payload}.{signature}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def valid_grant(api_key: str, token: str | None, rule_id: str) -> bool:
|
|
65
|
+
if not token or token.count(".") != 1:
|
|
66
|
+
return False
|
|
67
|
+
payload, supplied = token.split(".", 1)
|
|
68
|
+
expected = base64url(hmac.new(api_key.encode(), payload.encode(), hashlib.sha256).digest())
|
|
69
|
+
if not hmac.compare_digest(expected, supplied):
|
|
70
|
+
return False
|
|
71
|
+
try:
|
|
72
|
+
value: dict[str, Any] = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
|
73
|
+
except Exception:
|
|
74
|
+
return False
|
|
75
|
+
expires = value.get("e")
|
|
76
|
+
return value.get("v") == 1 and value.get("r") == rule_id and (
|
|
77
|
+
expires is None or isinstance(expires, int) and expires >= int(time.time())
|
|
78
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
from fastapi import FastAPI
|
|
12
|
+
|
|
13
|
+
from access402_fastapi import Access402, Access402Settings
|
|
14
|
+
from access402_fastapi.models import Configuration, ResourcePolicy
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def policy(**changes):
|
|
18
|
+
values = dict(
|
|
19
|
+
id="22222222-2222-4222-8222-222222222222",
|
|
20
|
+
local_rule_id="11111111-1111-4111-8111-111111111111",
|
|
21
|
+
resource_key="rest_route:fastapi:" + "a" * 64,
|
|
22
|
+
resource_url="https://api.example.com/reports/{report_id}",
|
|
23
|
+
display_name="Read report", description="Read one report", price="0.01", asset="USDC",
|
|
24
|
+
mime_type="application/json", access_type="per_request", access_duration_seconds=None,
|
|
25
|
+
enabled=True, discovery_enabled=False, discovery_method="GET", discovery_metadata={},
|
|
26
|
+
route_method="GET", route_template="/reports/{report_id}",
|
|
27
|
+
)
|
|
28
|
+
values.update(changes)
|
|
29
|
+
return ResourcePolicy(**values)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def configured_app(handler=None, **policy_changes):
|
|
33
|
+
async def backend(request: httpx.Request):
|
|
34
|
+
if handler:
|
|
35
|
+
return await handler(request)
|
|
36
|
+
return httpx.Response(200, json={"settled": True, "payer": "0xpayer", "payment_event_id": "33333333-3333-4333-8333-333333333333", "payment_response": "receipt"})
|
|
37
|
+
|
|
38
|
+
app = FastAPI()
|
|
39
|
+
@app.get("/reports/{report_id}")
|
|
40
|
+
async def report(report_id: str):
|
|
41
|
+
return {"id": report_id}
|
|
42
|
+
settings = Access402Settings("installation", "a402_000000000000_" + "a" * 43, "https://api.example.com", allow_live=True)
|
|
43
|
+
manager = Access402(settings, transport=httpx.MockTransport(backend))
|
|
44
|
+
manager.configuration = Configuration("v1", "now", "test", "eip155:84532", "0x0000000000000000000000000000000000000001", (policy(**policy_changes),))
|
|
45
|
+
manager._loaded_at = time.monotonic()
|
|
46
|
+
manager._catalog_at = time.monotonic()
|
|
47
|
+
manager.install(app)
|
|
48
|
+
return app
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
async def test_returns_x402_v2_challenge():
|
|
53
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=configured_app()), base_url="https://api.example.com") as client:
|
|
54
|
+
response = await client.get("/reports/42")
|
|
55
|
+
assert response.status_code == 402
|
|
56
|
+
challenge = json.loads(base64.b64decode(response.headers["PAYMENT-REQUIRED"]))
|
|
57
|
+
assert challenge["x402Version"] == 2
|
|
58
|
+
assert challenge["resource"]["url"] == "https://api.example.com/reports/42"
|
|
59
|
+
assert challenge["accepts"][0]["network"] == "eip155:84532"
|
|
60
|
+
assert challenge["accepts"][0]["amount"] == "10000"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_rejects_double_slash_bypass():
|
|
65
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=configured_app()), base_url="https://api.example.com") as client:
|
|
66
|
+
response = await client.get("https://api.example.com//reports/42")
|
|
67
|
+
assert response.status_code == 400
|
|
68
|
+
assert response.json()["error"] == "ambiguous_request_path"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_successful_settlement_releases_route_and_receipt():
|
|
73
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=configured_app()), base_url="https://api.example.com") as client:
|
|
74
|
+
response = await client.get("/reports/42", headers={"PAYMENT-SIGNATURE": "payload"})
|
|
75
|
+
assert response.status_code == 200
|
|
76
|
+
assert response.json() == {"id": "42"}
|
|
77
|
+
assert response.headers["PAYMENT-RESPONSE"] == "receipt"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.asyncio
|
|
81
|
+
async def test_failed_settlement_fails_closed():
|
|
82
|
+
async def handler(_request):
|
|
83
|
+
return httpx.Response(503, json={"error": "payment_service_unavailable"})
|
|
84
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=configured_app(handler)), base_url="https://api.example.com") as client:
|
|
85
|
+
response = await client.get("/reports/42", headers={"PAYMENT-SIGNATURE": "bad"})
|
|
86
|
+
assert response.status_code == 503
|
|
87
|
+
assert response.json()["error"] == "settlement_service_unavailable"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_reusable_grant_avoids_second_settlement():
|
|
92
|
+
calls = 0
|
|
93
|
+
async def handler(_request):
|
|
94
|
+
nonlocal calls
|
|
95
|
+
calls += 1
|
|
96
|
+
return httpx.Response(200, json={"settled": True, "payer": "0xpayer", "payment_event_id": "33333333-3333-4333-8333-333333333333"})
|
|
97
|
+
app = configured_app(handler, access_type="wallet_once")
|
|
98
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as client:
|
|
99
|
+
first = await client.get("/reports/42", headers={"PAYMENT-SIGNATURE": "payload"})
|
|
100
|
+
second = await client.get("/reports/42", headers={"X-Access402-Grant": first.headers["X-Access402-Grant"]})
|
|
101
|
+
assert first.status_code == second.status_code == 200
|
|
102
|
+
assert calls == 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.asyncio
|
|
106
|
+
async def test_cold_start_fails_closed_when_configuration_is_unavailable():
|
|
107
|
+
async def handler(_request):
|
|
108
|
+
return httpx.Response(503, json={"error": "unavailable"})
|
|
109
|
+
app = FastAPI()
|
|
110
|
+
@app.get("/public-looking-route")
|
|
111
|
+
async def route():
|
|
112
|
+
return {"secret": True}
|
|
113
|
+
settings = Access402Settings("installation", "a402_000000000000_" + "a" * 43, "https://api.example.com")
|
|
114
|
+
Access402(settings, transport=httpx.MockTransport(handler)).install(app)
|
|
115
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as client:
|
|
116
|
+
response = await client.get("/public-looking-route")
|
|
117
|
+
assert response.status_code == 503
|
|
118
|
+
assert response.json()["error"] == "access402_configuration_unavailable"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.asyncio
|
|
122
|
+
async def test_configuration_rejects_the_wrong_installation_scope():
|
|
123
|
+
api_key = "a402_000000000000_" + "a" * 43
|
|
124
|
+
async def handler(request):
|
|
125
|
+
if request.url.path.endswith("/adapter/catalog"):
|
|
126
|
+
return httpx.Response(200, json={"configuration_version": "v1"})
|
|
127
|
+
body = json.dumps({
|
|
128
|
+
"x402_version": 2, "installation_id": "other-installation", "configuration_version": "v1",
|
|
129
|
+
"generated_at": "now", "environment": "test", "network": "eip155:84532",
|
|
130
|
+
"pay_to": "0x0000000000000000000000000000000000000001", "resources": [],
|
|
131
|
+
}, separators=(",", ":")).encode()
|
|
132
|
+
signature = base64.urlsafe_b64encode(hmac.new(api_key.encode(), body, hashlib.sha256).digest()).decode().rstrip("=")
|
|
133
|
+
return httpx.Response(200, content=body, headers={"X-Access402-Configuration-Signature": f"v1={signature}"})
|
|
134
|
+
app = FastAPI()
|
|
135
|
+
@app.get("/route")
|
|
136
|
+
async def route():
|
|
137
|
+
return {}
|
|
138
|
+
manager = Access402(Access402Settings("installation", api_key, "https://api.example.com"), transport=httpx.MockTransport(handler))
|
|
139
|
+
manager.install(app)
|
|
140
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as client:
|
|
141
|
+
response = await client.get("/route")
|
|
142
|
+
assert response.status_code == 503
|