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/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