access402-fastapi 0.1.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.
@@ -0,0 +1,3 @@
1
+ from .adapter import Access402, Access402Settings
2
+
3
+ __all__ = ["Access402", "Access402Settings"]
@@ -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,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,9 @@
1
+ access402_fastapi/__init__.py,sha256=rORLWdSCd2myR-YGb4b5IdtC5377bQk1nwi3pKWwYc0,96
2
+ access402_fastapi/adapter.py,sha256=mIf2yh3QExmAfttRUpEIQRHrUs3LzyTXNNlgTpJz3n0,16926
3
+ access402_fastapi/client.py,sha256=XKoEuHTYJ4ff5owo3vA56auGZFZ7U177JMFJEyB4bec,3493
4
+ access402_fastapi/models.py,sha256=eeuqLrJM3Q0WVR9l1HLAfH2geHpEof4cpT-z2uXGcIY,2407
5
+ access402_fastapi/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
+ access402_fastapi/security.py,sha256=Ou2Tm_x1rHcMB8S7A7FFzpAn7_PxtyUrgHmreOYWXwE,3202
7
+ access402_fastapi-0.1.0.dist-info/METADATA,sha256=MbTo_pTlovL3lg-dVxibG62SRK_RhqO_wVLqFbW0Wus,4322
8
+ access402_fastapi-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ access402_fastapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any