solvapay 0.1.0.dev4__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.
- solvapay/__init__.py +26 -0
- solvapay/_currencies.py +170 -0
- solvapay/_generated/__init__.py +1 -0
- solvapay/_generated/models.py +3643 -0
- solvapay/_generated/operations.py +1894 -0
- solvapay/_http.py +243 -0
- solvapay/_js.py +114 -0
- solvapay/_tax_ids.py +442 -0
- solvapay/agents/__init__.py +67 -0
- solvapay/agents/calls.py +302 -0
- solvapay/agents/identity.py +293 -0
- solvapay/agents/mcp.py +614 -0
- solvapay/agents/oauth.py +105 -0
- solvapay/business_details.py +124 -0
- solvapay/client.py +107 -0
- solvapay/errors.py +33 -0
- solvapay/mcp/__init__.py +90 -0
- solvapay/mcp/app.py +243 -0
- solvapay/mcp/auth.py +604 -0
- solvapay/mcp/bootstrap.py +494 -0
- solvapay/mcp/intent.py +474 -0
- solvapay/mcp/mcp-app.html +119 -0
- solvapay/mcp/narrate.py +1019 -0
- solvapay/mcp/response.py +220 -0
- solvapay/mcp/server.py +343 -0
- solvapay/mcp/tools.py +273 -0
- solvapay/mcp/transport.py +550 -0
- solvapay/money.py +61 -0
- solvapay/paywall/__init__.py +25 -0
- solvapay/paywall/decide.py +450 -0
- solvapay/paywall/gate.py +176 -0
- solvapay/paywall/state.py +371 -0
- solvapay/pricing.py +281 -0
- solvapay/py.typed +0 -0
- solvapay/solvapay.py +173 -0
- solvapay/webhooks.py +93 -0
- solvapay-0.1.0.dev4.dist-info/METADATA +183 -0
- solvapay-0.1.0.dev4.dist-info/RECORD +39 -0
- solvapay-0.1.0.dev4.dist-info/WHEEL +4 -0
solvapay/mcp/auth.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""Customer sign-in for a SolvaPay MCP server.
|
|
2
|
+
|
|
3
|
+
Ported from ``@solvapay/mcp/fetch`` (``oauth-bridge.ts``, ``cors.ts``, ``handler.ts``),
|
|
4
|
+
``@solvapay/mcp`` (``internal/oauth-error-normalize.ts``) and ``@solvapay/mcp-core``
|
|
5
|
+
(``oauth-discovery.ts``, ``bearer.ts``, ``dcr-diagnostics.ts``).
|
|
6
|
+
|
|
7
|
+
MCP clients sign the customer in with OAuth. The server acts as the
|
|
8
|
+
authorization server they see, and forwards each step to SolvaPay:
|
|
9
|
+
|
|
10
|
+
- ``/.well-known/oauth-protected-resource`` and
|
|
11
|
+
``/.well-known/oauth-authorization-server``: this server, and its endpoints.
|
|
12
|
+
- ``/oauth/register``: dynamic client registration, for this product.
|
|
13
|
+
- ``/oauth/authorize``: a redirect to SolvaPay's sign-in.
|
|
14
|
+
- ``/oauth/token`` and ``/oauth/revoke``: forwarded as received; errors come back
|
|
15
|
+
as RFC 6749 errors.
|
|
16
|
+
|
|
17
|
+
``SignInMiddleware`` guards the MCP endpoint: a request without a bearer token
|
|
18
|
+
whose payload names a customer gets a 401 that points at the protected-resource
|
|
19
|
+
document. Unlike the TypeScript SDK, which reads the customer from the token
|
|
20
|
+
without checking it, ``SolvaPayTokenVerifier`` then checks each token with
|
|
21
|
+
SolvaPay (``/v1/customer/auth/userinfo``): the customer a tool sees has been
|
|
22
|
+
verified by SolvaPay, never read from an unverified token.
|
|
23
|
+
|
|
24
|
+
CORS: only native MCP clients' origins (``cursor://``, ``vscode://``,
|
|
25
|
+
``vscode-webview://``, ``claude://``) are mirrored, never with credentials.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import binascii
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import re
|
|
35
|
+
import time
|
|
36
|
+
from collections import OrderedDict
|
|
37
|
+
from collections.abc import MutableMapping
|
|
38
|
+
from typing import Any
|
|
39
|
+
from urllib.parse import quote
|
|
40
|
+
|
|
41
|
+
import httpx
|
|
42
|
+
from mcp.server.auth.middleware.auth_context import auth_context_var
|
|
43
|
+
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
|
44
|
+
from mcp.server.auth.provider import AccessToken
|
|
45
|
+
from starlette.authentication import AuthCredentials
|
|
46
|
+
from starlette.datastructures import Headers, MutableHeaders
|
|
47
|
+
from starlette.requests import Request
|
|
48
|
+
from starlette.responses import JSONResponse, PlainTextResponse, Response
|
|
49
|
+
from starlette.routing import Route
|
|
50
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
51
|
+
|
|
52
|
+
from ..errors import SolvaPayError
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger("solvapay.mcp")
|
|
55
|
+
|
|
56
|
+
Json = dict[str, Any]
|
|
57
|
+
|
|
58
|
+
DEFAULT_OAUTH_PATHS = {
|
|
59
|
+
"register": "/oauth/register",
|
|
60
|
+
"authorize": "/oauth/authorize",
|
|
61
|
+
"token": "/oauth/token",
|
|
62
|
+
"revoke": "/oauth/revoke",
|
|
63
|
+
}
|
|
64
|
+
PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"
|
|
65
|
+
AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server"
|
|
66
|
+
OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration"
|
|
67
|
+
SCOPES = ["openid", "profile", "email"]
|
|
68
|
+
OAUTH_ERROR_CODES = frozenset(
|
|
69
|
+
{
|
|
70
|
+
"invalid_request",
|
|
71
|
+
"invalid_client",
|
|
72
|
+
"invalid_grant",
|
|
73
|
+
"unauthorized_client",
|
|
74
|
+
"unsupported_grant_type",
|
|
75
|
+
"invalid_scope",
|
|
76
|
+
"server_error",
|
|
77
|
+
"temporarily_unavailable",
|
|
78
|
+
"access_denied",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
CUSTOMER_CLAIMS = ("customerRef", "customer_ref", "sub")
|
|
82
|
+
ALL_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
83
|
+
_MISSING = object()
|
|
84
|
+
_NATIVE_CLIENT_ORIGIN = re.compile(r"^(cursor|vscode|vscode-webview|claude)://.+$")
|
|
85
|
+
# Hop-by-hop or re-encoded headers an upstream answer must not carry on.
|
|
86
|
+
_NOT_FORWARDED = frozenset(
|
|
87
|
+
{"content-length", "content-encoding", "transfer-encoding", "connection"}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _base(url: str) -> str:
|
|
92
|
+
return url.removesuffix("/")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def protected_resource_metadata(public_base_url: str) -> Json:
|
|
96
|
+
"""``getOAuthProtectedResourceResponse``: this server is the resource and its own
|
|
97
|
+
authorization server."""
|
|
98
|
+
resource = _base(public_base_url)
|
|
99
|
+
return {"resource": resource, "authorization_servers": [resource], "scopes_supported": SCOPES}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def authorization_server_metadata(public_base_url: str, paths: dict[str, str]) -> Json:
|
|
103
|
+
"""``getOAuthAuthorizationServerResponse``."""
|
|
104
|
+
base = _base(public_base_url)
|
|
105
|
+
return {
|
|
106
|
+
"issuer": base,
|
|
107
|
+
"authorization_endpoint": f"{base}{paths['authorize']}",
|
|
108
|
+
"token_endpoint": f"{base}{paths['token']}",
|
|
109
|
+
"registration_endpoint": f"{base}{paths['register']}",
|
|
110
|
+
"revocation_endpoint": f"{base}{paths['revoke']}",
|
|
111
|
+
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
|
112
|
+
"response_types_supported": ["code"],
|
|
113
|
+
"grant_types_supported": ["authorization_code", "refresh_token"],
|
|
114
|
+
"scopes_supported": SCOPES,
|
|
115
|
+
"code_challenge_methods_supported": ["S256"],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---- bearer tokens (bearer.ts) ---------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def extract_bearer_token(authorization: str | None) -> str | None:
|
|
123
|
+
"""``extractBearerToken``: the token after ``Bearer `` (case-sensitive), trimmed."""
|
|
124
|
+
if not authorization or not authorization.startswith("Bearer "):
|
|
125
|
+
return None
|
|
126
|
+
return authorization[7:].strip() or None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def decode_jwt_payload(token: str) -> Json | None:
|
|
130
|
+
"""``decodeJwtPayload`` without the signature check: the claims, or None when the
|
|
131
|
+
token is not a JWT with a JSON object payload."""
|
|
132
|
+
parts = token.split(".")
|
|
133
|
+
if len(parts) < 2:
|
|
134
|
+
return None
|
|
135
|
+
segment = parts[1]
|
|
136
|
+
try:
|
|
137
|
+
raw = base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
|
|
138
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
139
|
+
except (binascii.Error, ValueError):
|
|
140
|
+
return None
|
|
141
|
+
return payload if isinstance(payload, dict) else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def customer_ref_claim(payload: Json) -> str | None:
|
|
145
|
+
"""``getCustomerRefFromJwtPayload``: the first non-blank of customerRef, customer_ref, sub."""
|
|
146
|
+
for claim in CUSTOMER_CLAIMS:
|
|
147
|
+
value = payload.get(claim)
|
|
148
|
+
if isinstance(value, str) and value.strip():
|
|
149
|
+
return value.strip()
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class SolvaPayTokenVerifier:
|
|
154
|
+
"""Verifies a customer's bearer token with SolvaPay. Valid tokens are cached briefly."""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
api_base_url: str,
|
|
160
|
+
cache_seconds: float = 60.0,
|
|
161
|
+
max_entries: int = 1000,
|
|
162
|
+
http_client: httpx.AsyncClient | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
self._userinfo = f"{_base(api_base_url)}/v1/customer/auth/userinfo"
|
|
165
|
+
self._cache: OrderedDict[str, tuple[float, AccessToken]] = OrderedDict()
|
|
166
|
+
self._cache_seconds = cache_seconds
|
|
167
|
+
self._max_entries = max_entries
|
|
168
|
+
self._client = http_client or httpx.AsyncClient(timeout=10.0)
|
|
169
|
+
|
|
170
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
171
|
+
cached = self._cache.get(token)
|
|
172
|
+
now = time.monotonic()
|
|
173
|
+
if cached and now - cached[0] < self._cache_seconds:
|
|
174
|
+
return cached[1]
|
|
175
|
+
try:
|
|
176
|
+
response = await self._client.get(
|
|
177
|
+
self._userinfo, headers={"Authorization": f"Bearer {token}"}
|
|
178
|
+
)
|
|
179
|
+
except httpx.TransportError as error:
|
|
180
|
+
raise SolvaPayError(
|
|
181
|
+
f"Could not reach SolvaPay to verify the token: {error}", code="network_error"
|
|
182
|
+
) from error
|
|
183
|
+
if response.status_code in (401, 403):
|
|
184
|
+
self._cache.pop(token, None)
|
|
185
|
+
return None
|
|
186
|
+
if response.status_code != 200:
|
|
187
|
+
raise SolvaPayError(
|
|
188
|
+
f"SolvaPay answered {response.status_code} when verifying a token",
|
|
189
|
+
code="token_verification_failed",
|
|
190
|
+
status=response.status_code,
|
|
191
|
+
)
|
|
192
|
+
claims = response.json()
|
|
193
|
+
subject = claims.get("sub")
|
|
194
|
+
if not isinstance(subject, str) or not subject:
|
|
195
|
+
raise SolvaPayError(
|
|
196
|
+
"SolvaPay's userinfo has no customer (sub)", code="token_verification_failed"
|
|
197
|
+
)
|
|
198
|
+
access = AccessToken(
|
|
199
|
+
token=token,
|
|
200
|
+
client_id=str(claims.get("client_id") or "solvapay"),
|
|
201
|
+
scopes=SCOPES,
|
|
202
|
+
subject=subject,
|
|
203
|
+
claims={**claims, "customer_ref": subject},
|
|
204
|
+
)
|
|
205
|
+
self._cache[token] = (now, access)
|
|
206
|
+
self._cache.move_to_end(token)
|
|
207
|
+
while len(self._cache) > self._max_entries:
|
|
208
|
+
self._cache.popitem(last=False)
|
|
209
|
+
return access
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---- CORS (cors.ts) -------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def is_native_client_origin(origin: str | None) -> bool:
|
|
216
|
+
"""``isNativeClientOrigin``: a native MCP client's scheme, mirrored instead of ``*``."""
|
|
217
|
+
return bool(origin) and _NATIVE_CLIENT_ORIGIN.match(origin or "") is not None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def apply_native_cors(request_headers: Headers, response_headers: MutableHeaders) -> None:
|
|
221
|
+
"""``applyNativeCors``: mirror a native client's origin. Web origins get nothing, and
|
|
222
|
+
credentials are never allowed."""
|
|
223
|
+
origin = request_headers.get("origin")
|
|
224
|
+
if origin and is_native_client_origin(origin):
|
|
225
|
+
response_headers["Access-Control-Allow-Origin"] = origin
|
|
226
|
+
response_headers.append("Vary", "Origin")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _cors(request: Request, response: Response) -> Response:
|
|
230
|
+
apply_native_cors(request.headers, response.headers)
|
|
231
|
+
return response
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def cors_preflight(request_headers: Headers) -> Response:
|
|
235
|
+
"""``corsPreflight``: 204, the requested method and headers echoed, a 600 s max age."""
|
|
236
|
+
method = request_headers.get("access-control-request-method", "POST")
|
|
237
|
+
allowed = request_headers.get("access-control-request-headers", "authorization, content-type")
|
|
238
|
+
response = Response(status_code=204)
|
|
239
|
+
apply_native_cors(request_headers, response.headers)
|
|
240
|
+
response.headers["Access-Control-Allow-Methods"] = f"{method}, OPTIONS"
|
|
241
|
+
response.headers["Access-Control-Allow-Headers"] = allowed
|
|
242
|
+
response.headers["Access-Control-Max-Age"] = "600"
|
|
243
|
+
return response
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def auth_challenge(request_headers: Headers, public_base_url: str, json_rpc_id: Any) -> Response:
|
|
247
|
+
"""``authChallenge``: 401, a JSON-RPC -32001 error, and ``WWW-Authenticate`` pointing at
|
|
248
|
+
the protected-resource document."""
|
|
249
|
+
response = JSONResponse(
|
|
250
|
+
{"jsonrpc": "2.0", "id": json_rpc_id, "error": {"code": -32001, "message": "Unauthorized"}},
|
|
251
|
+
status_code=401,
|
|
252
|
+
)
|
|
253
|
+
apply_native_cors(request_headers, response.headers)
|
|
254
|
+
response.headers["Access-Control-Expose-Headers"] = "WWW-Authenticate"
|
|
255
|
+
response.headers["WWW-Authenticate"] = (
|
|
256
|
+
f'Bearer resource_metadata="{_base(public_base_url)}{PROTECTED_RESOURCE_PATH}"'
|
|
257
|
+
)
|
|
258
|
+
return response
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def not_found() -> Response:
|
|
262
|
+
"""What the TypeScript handler answers for a path or method nothing claims."""
|
|
263
|
+
return PlainTextResponse("not_found", status_code=404)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---- OAuth error bodies (oauth-error-normalize.ts) ------------------------------
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _validation_errors(body: Json) -> list[Json]:
|
|
270
|
+
errors = body.get("errors")
|
|
271
|
+
if not isinstance(errors, list):
|
|
272
|
+
return []
|
|
273
|
+
return [error for error in errors if isinstance(error, dict)]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _touches(errors: list[Json], field: str) -> Json | None:
|
|
277
|
+
for error in errors:
|
|
278
|
+
path = error.get("path")
|
|
279
|
+
if isinstance(path, list) and field in path:
|
|
280
|
+
return error
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def derive_oauth_error_code(status: int, body: Json) -> str:
|
|
285
|
+
"""``deriveOAuthErrorCode``: by status first, then by the validation error's field."""
|
|
286
|
+
if status in (401, 403):
|
|
287
|
+
return "invalid_client"
|
|
288
|
+
if status >= 500:
|
|
289
|
+
return "server_error"
|
|
290
|
+
errors = _validation_errors(body)
|
|
291
|
+
grant_type = _touches(errors, "grant_type")
|
|
292
|
+
if grant_type is not None:
|
|
293
|
+
# A missing `received` is JavaScript's undefined; JSON null is not.
|
|
294
|
+
received = grant_type.get("received", _MISSING)
|
|
295
|
+
if received is not _MISSING and received not in ("undefined", ""):
|
|
296
|
+
return "unsupported_grant_type"
|
|
297
|
+
return "invalid_request"
|
|
298
|
+
if _touches(errors, "code") or _touches(errors, "refresh_token"):
|
|
299
|
+
return "invalid_grant"
|
|
300
|
+
if _touches(errors, "scope"):
|
|
301
|
+
return "invalid_scope"
|
|
302
|
+
if _touches(errors, "client_id") or _touches(errors, "client_secret"):
|
|
303
|
+
return "invalid_client"
|
|
304
|
+
return "invalid_request"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def build_error_description(body: Json) -> str | None:
|
|
308
|
+
"""``buildErrorDescription``: ``path: message; …`` from validation errors, else the
|
|
309
|
+
message (a list joined with ``; ``)."""
|
|
310
|
+
parts = []
|
|
311
|
+
for error in _validation_errors(body):
|
|
312
|
+
path = error.get("path")
|
|
313
|
+
message = error.get("message")
|
|
314
|
+
path_text = (
|
|
315
|
+
".".join(p for p in path if isinstance(p, str)) if isinstance(path, list) else ""
|
|
316
|
+
)
|
|
317
|
+
message_text = message if isinstance(message, str) else ""
|
|
318
|
+
part = (
|
|
319
|
+
f"{path_text}: {message_text}"
|
|
320
|
+
if path_text and message_text
|
|
321
|
+
else path_text or message_text
|
|
322
|
+
)
|
|
323
|
+
if part:
|
|
324
|
+
parts.append(part)
|
|
325
|
+
if parts:
|
|
326
|
+
return "; ".join(parts)
|
|
327
|
+
message = body.get("message")
|
|
328
|
+
if isinstance(message, str):
|
|
329
|
+
return message
|
|
330
|
+
if isinstance(message, list):
|
|
331
|
+
strings = [m for m in message if isinstance(m, str)]
|
|
332
|
+
if strings:
|
|
333
|
+
return "; ".join(strings)
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def to_oauth_error_body(body: Any, text: str, status: int) -> Json:
|
|
338
|
+
"""``toOAuthErrorBody``: a body with a token error code passes through; any other is
|
|
339
|
+
mapped."""
|
|
340
|
+
if isinstance(body, dict) and body.get("error") in OAUTH_ERROR_CODES:
|
|
341
|
+
return body
|
|
342
|
+
if isinstance(body, (dict, list)):
|
|
343
|
+
# Any JSON object (an array too, as in JavaScript) is read as NestJS's shape.
|
|
344
|
+
fields = body if isinstance(body, dict) else {}
|
|
345
|
+
code = derive_oauth_error_code(status, fields)
|
|
346
|
+
description = build_error_description(fields)
|
|
347
|
+
return {"error": code, "error_description": description} if description else {"error": code}
|
|
348
|
+
fallback = "server_error" if status >= 500 else "invalid_request"
|
|
349
|
+
if text and len(text) < 500:
|
|
350
|
+
return {"error": fallback, "error_description": text}
|
|
351
|
+
return {"error": fallback}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _parse_upstream(text: str) -> Any:
|
|
355
|
+
if not text:
|
|
356
|
+
return {}
|
|
357
|
+
try:
|
|
358
|
+
return json.loads(text)
|
|
359
|
+
except ValueError:
|
|
360
|
+
return text
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def log_dcr_failure(product_ref: str, api_base_url: str, status: int, body: str) -> None:
|
|
364
|
+
"""``logDcrFailureDiagnostic``: name the likely cause of a refused registration."""
|
|
365
|
+
unresolved = re.search(r"invalid identifier", body, re.IGNORECASE) or (
|
|
366
|
+
re.search(r"product_ref", body, re.IGNORECASE)
|
|
367
|
+
and re.search(r"mcp_server_id", body, re.IGNORECASE)
|
|
368
|
+
)
|
|
369
|
+
hint = (
|
|
370
|
+
"The platform could not resolve this product_ref (often a wrong or missing product, "
|
|
371
|
+
"or an API base URL mismatch). A 400 'Invalid identifier' here means the product did "
|
|
372
|
+
"not resolve, not that the registration body was malformed."
|
|
373
|
+
if unresolved
|
|
374
|
+
else "SolvaPay rejected the registration."
|
|
375
|
+
)
|
|
376
|
+
logger.warning(
|
|
377
|
+
"OAuth dynamic client registration failed (%s) product_ref=%s api_base_url=%s. %s "
|
|
378
|
+
"Check SOLVAPAY_PRODUCT_REF and SOLVAPAY_API_BASE_URL.",
|
|
379
|
+
status,
|
|
380
|
+
product_ref,
|
|
381
|
+
api_base_url,
|
|
382
|
+
hint,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---- the bridge (oauth-bridge.ts) -----------------------------------------------
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def oauth_bridge_routes(
|
|
390
|
+
*,
|
|
391
|
+
public_base_url: str,
|
|
392
|
+
api_base_url: str,
|
|
393
|
+
product_ref: str,
|
|
394
|
+
paths: dict[str, str] | None = None,
|
|
395
|
+
http_client: httpx.AsyncClient | None = None,
|
|
396
|
+
) -> list[Route]:
|
|
397
|
+
"""The routes that let MCP clients sign a customer in through this server.
|
|
398
|
+
|
|
399
|
+
Each answers every method: one it does not serve is a 404 ``not_found``, as the
|
|
400
|
+
TypeScript handler answers a request no route claims.
|
|
401
|
+
"""
|
|
402
|
+
if not product_ref:
|
|
403
|
+
raise SolvaPayError("The OAuth bridge needs a product reference", code="missing_product")
|
|
404
|
+
resolved = {**DEFAULT_OAUTH_PATHS, **(paths or {})}
|
|
405
|
+
api = _base(api_base_url)
|
|
406
|
+
client = http_client or httpx.AsyncClient(timeout=30.0)
|
|
407
|
+
resource = protected_resource_metadata(public_base_url)
|
|
408
|
+
metadata = authorization_server_metadata(public_base_url, resolved)
|
|
409
|
+
|
|
410
|
+
def document(body: Json) -> Any:
|
|
411
|
+
async def endpoint(request: Request) -> Response:
|
|
412
|
+
if request.method != "GET":
|
|
413
|
+
return not_found()
|
|
414
|
+
return _cors(request, JSONResponse(body))
|
|
415
|
+
|
|
416
|
+
return endpoint
|
|
417
|
+
|
|
418
|
+
async def openid_configuration(request: Request) -> Response:
|
|
419
|
+
if request.method != "GET":
|
|
420
|
+
return not_found()
|
|
421
|
+
return _cors(request, Response(status_code=404))
|
|
422
|
+
|
|
423
|
+
def passthrough(request: Request, upstream: httpx.Response) -> Response:
|
|
424
|
+
response = Response(content=upstream.content, status_code=upstream.status_code)
|
|
425
|
+
for name, value in upstream.headers.multi_items():
|
|
426
|
+
if name.lower() not in _NOT_FORWARDED:
|
|
427
|
+
response.headers.append(name, value)
|
|
428
|
+
return _cors(request, response)
|
|
429
|
+
|
|
430
|
+
def unreachable(request: Request) -> Response:
|
|
431
|
+
return _cors(request, JSONResponse({"error": "upstream_unreachable"}, status_code=502))
|
|
432
|
+
|
|
433
|
+
async def register(request: Request) -> Response:
|
|
434
|
+
if request.method == "OPTIONS":
|
|
435
|
+
return cors_preflight(request.headers)
|
|
436
|
+
if request.method != "POST":
|
|
437
|
+
return not_found()
|
|
438
|
+
url = f"{api}/v1/customer/auth/register?product_ref={quote(product_ref, safe='')}"
|
|
439
|
+
content_type = request.headers.get("content-type", "application/json")
|
|
440
|
+
try:
|
|
441
|
+
upstream = await client.post(
|
|
442
|
+
url, content=await request.body(), headers={"content-type": content_type}
|
|
443
|
+
)
|
|
444
|
+
except httpx.TransportError:
|
|
445
|
+
return unreachable(request)
|
|
446
|
+
if not upstream.is_success:
|
|
447
|
+
log_dcr_failure(product_ref, api, upstream.status_code, upstream.text)
|
|
448
|
+
return passthrough(request, upstream)
|
|
449
|
+
|
|
450
|
+
async def authorize(request: Request) -> Response:
|
|
451
|
+
if request.method == "OPTIONS":
|
|
452
|
+
return cors_preflight(request.headers)
|
|
453
|
+
if request.method != "GET":
|
|
454
|
+
return not_found()
|
|
455
|
+
query = f"?{request.url.query}" if request.url.query else ""
|
|
456
|
+
return _cors(
|
|
457
|
+
request,
|
|
458
|
+
Response(
|
|
459
|
+
status_code=302, headers={"Location": f"{api}/v1/customer/auth/authorize{query}"}
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def form_endpoint(url: str) -> Any:
|
|
464
|
+
async def endpoint(request: Request) -> Response:
|
|
465
|
+
if request.method == "OPTIONS":
|
|
466
|
+
return cors_preflight(request.headers)
|
|
467
|
+
if request.method != "POST":
|
|
468
|
+
return not_found()
|
|
469
|
+
# The raw body, so `+` and `%20` in a PKCE verifier reach SolvaPay unchanged.
|
|
470
|
+
headers = {
|
|
471
|
+
"content-type": request.headers.get(
|
|
472
|
+
"content-type", "application/x-www-form-urlencoded"
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
if "authorization" in request.headers:
|
|
476
|
+
headers["authorization"] = request.headers["authorization"]
|
|
477
|
+
try:
|
|
478
|
+
upstream = await client.post(url, content=await request.body(), headers=headers)
|
|
479
|
+
except httpx.TransportError:
|
|
480
|
+
return unreachable(request)
|
|
481
|
+
if upstream.is_success:
|
|
482
|
+
return passthrough(request, upstream)
|
|
483
|
+
body = to_oauth_error_body(
|
|
484
|
+
_parse_upstream(upstream.text), upstream.text, upstream.status_code
|
|
485
|
+
)
|
|
486
|
+
return _cors(request, JSONResponse(body, status_code=upstream.status_code))
|
|
487
|
+
|
|
488
|
+
return endpoint
|
|
489
|
+
|
|
490
|
+
return [
|
|
491
|
+
Route(OPENID_CONFIGURATION_PATH, openid_configuration, methods=ALL_METHODS),
|
|
492
|
+
Route(PROTECTED_RESOURCE_PATH, document(resource), methods=ALL_METHODS),
|
|
493
|
+
Route(AUTHORIZATION_SERVER_PATH, document(metadata), methods=ALL_METHODS),
|
|
494
|
+
Route(resolved["register"], register, methods=ALL_METHODS),
|
|
495
|
+
Route(resolved["authorize"], authorize, methods=ALL_METHODS),
|
|
496
|
+
Route(
|
|
497
|
+
resolved["token"], form_endpoint(f"{api}/v1/customer/auth/token"), methods=ALL_METHODS
|
|
498
|
+
),
|
|
499
|
+
Route(
|
|
500
|
+
resolved["revoke"], form_endpoint(f"{api}/v1/customer/auth/revoke"), methods=ALL_METHODS
|
|
501
|
+
),
|
|
502
|
+
]
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ---- the MCP endpoint (handler.ts) ----------------------------------------------
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _json_rpc_id(body: bytes) -> Any:
|
|
509
|
+
try:
|
|
510
|
+
message = json.loads(body)
|
|
511
|
+
except ValueError:
|
|
512
|
+
return None
|
|
513
|
+
return message.get("id") if isinstance(message, dict) else None
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class SignInMiddleware:
|
|
517
|
+
"""Guards the MCP endpoint at ``mcp_path``, as the TypeScript fetch handler does.
|
|
518
|
+
|
|
519
|
+
``OPTIONS`` is a CORS preflight; any method but ``POST`` is a 405. A request
|
|
520
|
+
without a bearer token whose payload names a customer, or whose token SolvaPay
|
|
521
|
+
does not accept, gets ``auth_challenge``. A verified request reaches the MCP
|
|
522
|
+
endpoint as that customer (``get_access_token()``), with native CORS on its answer.
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
def __init__(
|
|
526
|
+
self,
|
|
527
|
+
app: ASGIApp,
|
|
528
|
+
*,
|
|
529
|
+
mcp_path: str,
|
|
530
|
+
public_base_url: str,
|
|
531
|
+
verifier: SolvaPayTokenVerifier,
|
|
532
|
+
) -> None:
|
|
533
|
+
self.app = app
|
|
534
|
+
self.mcp_path = mcp_path
|
|
535
|
+
self.public_base_url = public_base_url
|
|
536
|
+
self.verifier = verifier
|
|
537
|
+
|
|
538
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
539
|
+
if scope["type"] != "http" or scope["path"] != self.mcp_path:
|
|
540
|
+
await self.app(scope, receive, send)
|
|
541
|
+
return
|
|
542
|
+
headers = Headers(scope=scope)
|
|
543
|
+
method = scope["method"]
|
|
544
|
+
if method == "OPTIONS":
|
|
545
|
+
await cors_preflight(headers)(scope, receive, send)
|
|
546
|
+
return
|
|
547
|
+
if method != "POST":
|
|
548
|
+
response = Response(status_code=405, headers={"Allow": "POST, OPTIONS"})
|
|
549
|
+
apply_native_cors(headers, response.headers)
|
|
550
|
+
await response(scope, receive, send)
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
body = await _read_body(receive)
|
|
554
|
+
token = extract_bearer_token(headers.get("authorization"))
|
|
555
|
+
payload = decode_jwt_payload(token) if token else None
|
|
556
|
+
access = (
|
|
557
|
+
await self.verifier.verify_token(token)
|
|
558
|
+
if token and payload is not None and customer_ref_claim(payload)
|
|
559
|
+
else None
|
|
560
|
+
)
|
|
561
|
+
if access is None:
|
|
562
|
+
challenge = auth_challenge(headers, self.public_base_url, _json_rpc_id(body))
|
|
563
|
+
await challenge(scope, receive, send)
|
|
564
|
+
return
|
|
565
|
+
|
|
566
|
+
user = AuthenticatedUser(access)
|
|
567
|
+
scope = {**scope, "user": user, "auth": AuthCredentials(access.scopes)}
|
|
568
|
+
|
|
569
|
+
async def send_with_cors(message: Message) -> None:
|
|
570
|
+
if message["type"] == "http.response.start":
|
|
571
|
+
apply_native_cors(headers, MutableHeaders(scope=message))
|
|
572
|
+
await send(message)
|
|
573
|
+
|
|
574
|
+
context = auth_context_var.set(user)
|
|
575
|
+
try:
|
|
576
|
+
await self.app(scope, _replay(body, receive), send_with_cors)
|
|
577
|
+
finally:
|
|
578
|
+
auth_context_var.reset(context)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
async def _read_body(receive: Receive) -> bytes:
|
|
582
|
+
chunks = []
|
|
583
|
+
while True:
|
|
584
|
+
message = await receive()
|
|
585
|
+
if message["type"] != "http.request":
|
|
586
|
+
break
|
|
587
|
+
chunks.append(message.get("body", b""))
|
|
588
|
+
if not message.get("more_body", False):
|
|
589
|
+
break
|
|
590
|
+
return b"".join(chunks)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _replay(body: bytes, receive: Receive) -> Receive:
|
|
594
|
+
"""The body already read, then whatever the client sends next (its disconnect)."""
|
|
595
|
+
sent = False
|
|
596
|
+
|
|
597
|
+
async def replay() -> MutableMapping[str, Any]:
|
|
598
|
+
nonlocal sent
|
|
599
|
+
if not sent:
|
|
600
|
+
sent = True
|
|
601
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
602
|
+
return await receive()
|
|
603
|
+
|
|
604
|
+
return replay
|