create-caspian-app 0.4.0-rc.0 → 0.4.0-rc.2
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.
|
@@ -101,7 +101,8 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
101
101
|
- Do not move normal file upload or file-manager behavior into `main.py`; keep those actions in the owning route `index.py` and shared helpers in `src/lib/**`.
|
|
102
102
|
- Document route param behavior exactly as implemented here.
|
|
103
103
|
- Do not use `main.py` alone to infer whether optional features are enabled; confirm that in `caspian.config.json` first.
|
|
104
|
-
- Before changing WebSocket behavior, verify `cfg.websocket`, the app's endpoint registration,
|
|
104
|
+
- Before changing WebSocket behavior, verify `cfg.websocket`, the app's endpoint registration, the `authorize_websocket(...)` guard in `src/lib/websocket/websocket_security.py`, idle timeout, maximum message size, close codes, and connection cleanup. HTTP-only middleware does not automatically protect `scope["type"] == "websocket"` connections, so socket auth lives in that guard, not `AuthMiddleware`.
|
|
105
|
+
- Authorize sockets with the single `authorize_websocket(...)` guard, which runs the origin check then delegates to Caspian `Auth` (`Auth.set_request(websocket)` + `is_authenticated`/`get_payload`/`check_role`). Add channels by calling that guard with `require_auth=`/`roles=`; do not re-implement session/`exp`/payload parsing per endpoint. Keep authenticated and guest broadcast pools separate, and treat the socket session as read-only.
|
|
105
106
|
|
|
106
107
|
### `src/lib/**/*.py`
|
|
107
108
|
|
|
@@ -112,7 +113,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
112
113
|
- When `caspian.config.json` has `mcp: true`, keep app-owned MCP tools in `src/lib/mcp/mcp_server.py` and keep the default FastMCP config in `src/lib/mcp/fastmcp.json`. If those locations change, update `settings/restart-mcp.ts` and the MCP docs together.
|
|
113
114
|
- Keep auth policy in `src/lib/auth/auth_config.py`. Keep auth bootstrap and middleware order changes in `main.py`.
|
|
114
115
|
- Do not recreate or customize `src/lib/security/runtime_security.py` for normal application work. Runtime security helpers are package-owned in `casp.runtime_security`; app-specific policy should live in app-owned config or route/helper code instead.
|
|
115
|
-
- Keep reusable WebSocket helpers under `src/lib/websocket/**` when they are shared across socket endpoints or route clients. Common shared helpers include
|
|
116
|
+
- Keep reusable WebSocket helpers under `src/lib/websocket/**` when they are shared across socket endpoints or route clients. Common shared helpers include the `authorize_websocket(...)` guard (origin + `Auth`-delegated auth), origin utilities, connection managers, payload normalization, and broadcast fan-out.
|
|
116
117
|
|
|
117
118
|
### `src/components/**/*.py`
|
|
118
119
|
|
package/dist/AGENTS.md
CHANGED
|
@@ -74,7 +74,8 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
|
|
|
74
74
|
- When `caspian.config.json` has `websocket: true`, WebSocket behavior is app-owned in `main.py`. Do not assume fixed route names or endpoint paths across Caspian projects; define project-specific socket paths in the app, keep shared socket helpers in `src/lib/websocket/**` when needed, and let each route that needs a socket client pass its own `websocket_path` or `websocket_url` into its template.
|
|
75
75
|
- WebSocket pages may be public, authenticated, role-gated, or mixed depending on the app. Keep route-specific browser UI in that route's `src/app/**/index.html`, keep first-render socket URL/path values in the matching `index.py`, and keep reusable session/auth/connection/broadcast helpers out of route files only when they are shared.
|
|
76
76
|
- For WebSocket clients, use PulsePoint for component state and lifecycle but native `new WebSocket(...)` for the transport. Keep the socket in `pp.ref(...)`, close it in a cleanup effect, and keep direct socket event listeners inside the owning route template script.
|
|
77
|
-
- Before changing socket security, verify the app's origin allow-list logic,
|
|
77
|
+
- Before changing socket security, verify the app's origin allow-list logic, auth check, idle timeout, message-size limits, and close codes in the running code. HTTP route privacy and `AuthMiddleware` do not by themselves protect WebSocket scopes: the HTTP middleware stack early-returns on `scope["type"] == "websocket"`, so only `SessionMiddleware` runs and each socket endpoint must authorize itself.
|
|
78
|
+
- Socket authorization in this workspace is a single guard, `authorize_websocket(...)` in `src/lib/websocket/websocket_security.py`, that runs the origin check then delegates auth to Caspian's `Auth` (`Auth.set_request(websocket)` plus `is_authenticated`/`get_payload`/`check_role`). Reuse that guard for new channels and gate them with `require_auth=` / `roles=`; do not re-implement session/`exp`/payload parsing per endpoint. Keep authenticated and guest traffic in separate broadcast pools, and treat the socket session as read-only (mutations are not persisted to the cookie over a WebSocket).
|
|
78
79
|
- Keep route-specific backend logic in that route's `src/app/**/index.py`, including first-render data loading, route-owned `@rpc()` actions, auth checks, redirects, and validation. Move logic to `src/lib/**` only when it is shared by more than one route, feature, component, or integration.
|
|
79
80
|
- For grouped-subtree SPA navigation UX, the current browser runtime keeps unmarked shell scrollers stable and uses `pp-reset-scroll="true"` on the content pane that should reset. Check `pulsepoint.md`, `routing.md`, and `public/js/pp-reactive-v2.js` before changing that behavior.
|
|
80
81
|
- Before updating docs, verify runtime-specific claims such as middleware order, route param injection, `layout()` behavior, `StateManager` persistence, safe public-file serving, response header, or session-secret behavior against the current `main.py` and installed `casp` package, especially `.venv/Lib/site-packages/casp/runtime_security.py`, rather than copying older notes.
|
package/dist/main.py
CHANGED
|
@@ -19,6 +19,8 @@ from fastapi import (
|
|
|
19
19
|
)
|
|
20
20
|
from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
|
|
21
21
|
from starlette.datastructures import MutableHeaders
|
|
22
|
+
from starlette.middleware import Middleware
|
|
23
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
22
24
|
from starlette.middleware.sessions import SessionMiddleware
|
|
23
25
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
24
26
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
@@ -57,6 +59,78 @@ from casp.runtime_security import (
|
|
|
57
59
|
load_dotenv()
|
|
58
60
|
cfg = get_config()
|
|
59
61
|
|
|
62
|
+
# ====
|
|
63
|
+
# CORS configuration (shared .env convention, mirrors casp.rpc origin checks)
|
|
64
|
+
# ====
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _csv_env(name: str) -> list[str]:
|
|
68
|
+
return [item.strip() for item in os.getenv(name, "").split(",") if item.strip()]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _bool_env(name: str, default: bool = False) -> bool:
|
|
72
|
+
raw = os.getenv(name)
|
|
73
|
+
if raw is None:
|
|
74
|
+
return default
|
|
75
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _configured_cors_origins() -> list[str]:
|
|
79
|
+
"""Browser origins allowed to call the app, per the .env convention."""
|
|
80
|
+
origins: list[str] = []
|
|
81
|
+
for raw in (*_csv_env("CORS_ALLOWED_ORIGINS"), os.getenv("APP_BASE_URL", "")):
|
|
82
|
+
value = (raw or "").strip().rstrip("/")
|
|
83
|
+
if value and value not in origins:
|
|
84
|
+
origins.append(value)
|
|
85
|
+
return origins
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _build_mcp_cors_middleware() -> "Middleware":
|
|
89
|
+
"""Build the MCP CORS layer from .env, adding MCP-required headers.
|
|
90
|
+
|
|
91
|
+
Browser MCP clients (e.g. MCP Inspector "Direct") send an OPTIONS preflight
|
|
92
|
+
and rely on the mcp-session-id / mcp-protocol-version headers, which are not
|
|
93
|
+
in the generic CORS_ALLOWED_HEADERS list, so they are merged in here.
|
|
94
|
+
"""
|
|
95
|
+
origins = _configured_cors_origins()
|
|
96
|
+
allow_credentials = _bool_env("CORS_ALLOW_CREDENTIALS")
|
|
97
|
+
|
|
98
|
+
if not origins:
|
|
99
|
+
# The CORS spec forbids "*" together with credentials, so when no
|
|
100
|
+
# explicit origin is configured fall back to open + no credentials.
|
|
101
|
+
origins = ["*"]
|
|
102
|
+
allow_credentials = False
|
|
103
|
+
|
|
104
|
+
methods = _csv_env("CORS_ALLOWED_METHODS") or [
|
|
105
|
+
"GET", "POST", "DELETE", "OPTIONS"]
|
|
106
|
+
|
|
107
|
+
headers = _csv_env("CORS_ALLOWED_HEADERS")
|
|
108
|
+
for required in ("Content-Type", "Accept", "Authorization",
|
|
109
|
+
"mcp-session-id", "mcp-protocol-version"):
|
|
110
|
+
if required.lower() not in {h.lower() for h in headers}:
|
|
111
|
+
headers.append(required)
|
|
112
|
+
|
|
113
|
+
expose = _csv_env("CORS_EXPOSE_HEADERS")
|
|
114
|
+
for required in ("mcp-session-id", "mcp-protocol-version"):
|
|
115
|
+
if required.lower() not in {h.lower() for h in expose}:
|
|
116
|
+
expose.append(required)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
max_age = int(os.getenv("CORS_MAX_AGE", "600"))
|
|
120
|
+
except ValueError:
|
|
121
|
+
max_age = 600
|
|
122
|
+
|
|
123
|
+
return Middleware(
|
|
124
|
+
CORSMiddleware,
|
|
125
|
+
allow_origins=origins,
|
|
126
|
+
allow_credentials=allow_credentials,
|
|
127
|
+
allow_methods=methods,
|
|
128
|
+
allow_headers=headers,
|
|
129
|
+
expose_headers=expose,
|
|
130
|
+
max_age=max_age,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
60
134
|
# ====
|
|
61
135
|
# MCP SERVER (mounted into this app so one deploy serves web + MCP)
|
|
62
136
|
# ====
|
|
@@ -66,7 +140,7 @@ if cfg.mcp:
|
|
|
66
140
|
# caspian.config.json, so suppress the static "module not found" check.
|
|
67
141
|
from src.lib.mcp.mcp_server import mcp # type: ignore[import-not-found]
|
|
68
142
|
# Inner path "/" so the mount prefix below is the full endpoint path.
|
|
69
|
-
mcp_app = mcp.http_app(path="/")
|
|
143
|
+
mcp_app = mcp.http_app(path="/", middleware=[_build_mcp_cors_middleware()])
|
|
70
144
|
|
|
71
145
|
# ====
|
|
72
146
|
# AUTH CONFIGURATION (App behavior - customize here)
|
|
@@ -108,6 +182,11 @@ REQUEST_TIMEOUT_SECONDS = max(
|
|
|
108
182
|
1.0,
|
|
109
183
|
float(os.getenv('CASPIAN_REQUEST_TIMEOUT_SECONDS', 20)),
|
|
110
184
|
)
|
|
185
|
+
# Path prefixes that serve long-lived streaming responses (SSE, etc.) and must
|
|
186
|
+
# not be subject to the per-request timeout. The MCP streamable-HTTP transport
|
|
187
|
+
# keeps GET /mcp/ open indefinitely; wrapping it in asyncio.wait_for cancels the
|
|
188
|
+
# stream mid-response and corrupts the ASGI message sequence.
|
|
189
|
+
STREAMING_PATH_PREFIXES = ('/mcp',)
|
|
111
190
|
MAX_CONTENT_LENGTH_BYTES = max(1, MAX_CONTENT_LENGTH_MB) * 1024 * 1024
|
|
112
191
|
|
|
113
192
|
|
|
@@ -412,6 +491,12 @@ class RequestDiagnosticsMiddleware:
|
|
|
412
491
|
if should_log and not IS_PRODUCTION:
|
|
413
492
|
print(f"[request:start] {method} {path}", flush=True)
|
|
414
493
|
|
|
494
|
+
# Long-lived streaming endpoints (MCP SSE) must bypass the timeout, or
|
|
495
|
+
# asyncio.wait_for cancels the stream and the ASGI send sequence breaks.
|
|
496
|
+
if path.startswith(STREAMING_PATH_PREFIXES):
|
|
497
|
+
await self.app(scope, receive, send)
|
|
498
|
+
return
|
|
499
|
+
|
|
415
500
|
try:
|
|
416
501
|
await asyncio.wait_for(
|
|
417
502
|
self.app(scope, receive, send),
|
|
@@ -462,48 +547,6 @@ MAX_WEBSOCKET_MESSAGE_BYTES = max(
|
|
|
462
547
|
)
|
|
463
548
|
|
|
464
549
|
|
|
465
|
-
def _normalized_origin(value: str) -> str:
|
|
466
|
-
return (value or "").strip().rstrip("/")
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
def _configured_websocket_origins() -> set[str]:
|
|
470
|
-
raw_values = []
|
|
471
|
-
for env_name in (
|
|
472
|
-
"WEBSOCKET_ALLOWED_ORIGINS",
|
|
473
|
-
"CORS_ALLOWED_ORIGINS",
|
|
474
|
-
"APP_BASE_URL",
|
|
475
|
-
):
|
|
476
|
-
raw_values.extend(os.getenv(env_name, "").split(","))
|
|
477
|
-
|
|
478
|
-
return {
|
|
479
|
-
_normalized_origin(origin)
|
|
480
|
-
for origin in raw_values
|
|
481
|
-
if _normalized_origin(origin)
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def _websocket_same_origin(websocket: WebSocket) -> str:
|
|
486
|
-
scheme = "https" if websocket.url.scheme == "wss" else "http"
|
|
487
|
-
return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
def _is_websocket_origin_allowed(websocket: WebSocket) -> bool:
|
|
491
|
-
origin = _normalized_origin(websocket.headers.get("origin", ""))
|
|
492
|
-
if not origin:
|
|
493
|
-
return not IS_PRODUCTION
|
|
494
|
-
|
|
495
|
-
parsed_origin = urlparse(origin)
|
|
496
|
-
if not parsed_origin.scheme or not parsed_origin.netloc:
|
|
497
|
-
return False
|
|
498
|
-
|
|
499
|
-
if not IS_PRODUCTION and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
|
|
500
|
-
return parsed_origin.scheme == "http"
|
|
501
|
-
|
|
502
|
-
allowed_origins = _configured_websocket_origins()
|
|
503
|
-
allowed_origins.add(_websocket_same_origin(websocket))
|
|
504
|
-
return origin in allowed_origins
|
|
505
|
-
|
|
506
|
-
|
|
507
550
|
async def _run_websocket_channel(
|
|
508
551
|
websocket: WebSocket,
|
|
509
552
|
manager: Any,
|
|
@@ -581,30 +624,20 @@ if cfg.websocket:
|
|
|
581
624
|
# Optional, feature-gated module: only generated when websocket is enabled
|
|
582
625
|
# in caspian.config.json, so suppress the static "module not found" check.
|
|
583
626
|
from src.lib.websocket.websocket_security import ( # type: ignore[import-not-found]
|
|
584
|
-
|
|
585
|
-
get_websocket_session,
|
|
627
|
+
authorize_websocket,
|
|
586
628
|
public_websocket_connections,
|
|
587
629
|
websocket_connections,
|
|
588
630
|
)
|
|
589
631
|
|
|
632
|
+
# Both endpoints share ONE guard (`authorize_websocket`) that delegates to
|
|
633
|
+
# Caspian's `Auth`, and ONE transport loop (`_run_websocket_channel`). They
|
|
634
|
+
# differ only by auth policy and broadcast pool. To role-gate a channel,
|
|
635
|
+
# pass `roles=[...]`; to add another channel, add an endpoint that calls the
|
|
636
|
+
# same guard.
|
|
637
|
+
|
|
590
638
|
@app.websocket(WEBSOCKET_PATH)
|
|
591
639
|
async def websocket_live_endpoint(websocket: WebSocket):
|
|
592
|
-
if
|
|
593
|
-
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
594
|
-
return
|
|
595
|
-
|
|
596
|
-
session = get_websocket_session(websocket)
|
|
597
|
-
user_payload = get_authenticated_payload_from_session(
|
|
598
|
-
session,
|
|
599
|
-
Auth.get_instance(),
|
|
600
|
-
)
|
|
601
|
-
if user_payload is None:
|
|
602
|
-
await websocket.accept()
|
|
603
|
-
await websocket.send_json({
|
|
604
|
-
"type": "error",
|
|
605
|
-
"message": "Authentication required.",
|
|
606
|
-
})
|
|
607
|
-
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
640
|
+
if await authorize_websocket(websocket, require_auth=True) is None:
|
|
608
641
|
return
|
|
609
642
|
|
|
610
643
|
await _run_websocket_channel(
|
|
@@ -616,8 +649,7 @@ if cfg.websocket:
|
|
|
616
649
|
|
|
617
650
|
@app.websocket(PUBLIC_WEBSOCKET_PATH)
|
|
618
651
|
async def websocket_public_endpoint(websocket: WebSocket):
|
|
619
|
-
if
|
|
620
|
-
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
652
|
+
if await authorize_websocket(websocket, require_auth=False) is None:
|
|
621
653
|
return
|
|
622
654
|
|
|
623
655
|
await _run_websocket_channel(
|
|
@@ -14,13 +14,13 @@ FILES_LIST_PATH = PROJECT_ROOT / "settings" / "files-list.json"
|
|
|
14
14
|
COMPONENT_MAP_PATH = PROJECT_ROOT / "settings" / "component-map.json"
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
mcp = FastMCP(
|
|
18
|
-
name="Caspian MCP",
|
|
19
|
-
instructions=(
|
|
20
|
-
"Read-only workspace metadata for the Caspian application. "
|
|
21
|
-
"Use these tools for project configuration, generated file inventory, and component discovery."
|
|
22
|
-
),
|
|
23
|
-
)
|
|
17
|
+
mcp = FastMCP(
|
|
18
|
+
name="Caspian MCP",
|
|
19
|
+
instructions=(
|
|
20
|
+
"Read-only workspace metadata for the Caspian application. "
|
|
21
|
+
"Use these tools for project configuration, generated file inventory, and component discovery."
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def _load_json(path: Path, fallback: Any) -> Any:
|
|
@@ -88,4 +88,4 @@ def component_inventory() -> dict[str, Any]:
|
|
|
88
88
|
return {
|
|
89
89
|
"count": len(components),
|
|
90
90
|
"components": components,
|
|
91
|
-
}
|
|
91
|
+
}
|
|
@@ -1,60 +1,130 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
from datetime import datetime, timezone
|
|
3
|
+
import os
|
|
5
4
|
from typing import Any
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
6
|
|
|
7
|
-
from fastapi import WebSocket
|
|
7
|
+
from fastapi import WebSocket, status
|
|
8
8
|
|
|
9
|
+
from casp.auth import Auth
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
# ====
|
|
12
|
+
# WebSocket security: ONE guard that reuses Caspian's `Auth` as the source of
|
|
13
|
+
# truth, so socket auth lines up with HTTP route auth instead of duplicating it.
|
|
14
|
+
#
|
|
15
|
+
# Why this lives here and not in `AuthMiddleware`:
|
|
16
|
+
# HTTP middleware in `main.py` early-returns on every non-`http` scope, so a
|
|
17
|
+
# WebSocket handshake (`scope["type"] == "websocket"`) is never seen by
|
|
18
|
+
# `AuthMiddleware`. Each socket endpoint must therefore authorize itself. We do
|
|
19
|
+
# that by delegating to the same `Auth` instance that powers page privacy.
|
|
20
|
+
# ====
|
|
14
21
|
|
|
15
|
-
scope_session = websocket.scope.get("session")
|
|
16
|
-
if isinstance(scope_session, MutableMapping):
|
|
17
|
-
return scope_session
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
def _is_production() -> bool:
|
|
24
|
+
return os.getenv("APP_ENV") == "production"
|
|
20
25
|
|
|
21
26
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
def _normalized_origin(value: str) -> str:
|
|
28
|
+
return (value or "").strip().rstrip("/")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _configured_websocket_origins() -> set[str]:
|
|
32
|
+
raw_values: list[str] = []
|
|
33
|
+
for env_name in (
|
|
34
|
+
"WEBSOCKET_ALLOWED_ORIGINS",
|
|
35
|
+
"CORS_ALLOWED_ORIGINS",
|
|
36
|
+
"APP_BASE_URL",
|
|
37
|
+
):
|
|
38
|
+
raw_values.extend(os.getenv(env_name, "").split(","))
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
_normalized_origin(origin)
|
|
42
|
+
for origin in raw_values
|
|
43
|
+
if _normalized_origin(origin)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _websocket_same_origin(websocket: WebSocket) -> str:
|
|
48
|
+
scheme = "https" if websocket.url.scheme == "wss" else "http"
|
|
49
|
+
return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_websocket_origin_allowed(websocket: WebSocket) -> bool:
|
|
53
|
+
"""Anti-CSWSH origin check. NOT authentication.
|
|
54
|
+
|
|
55
|
+
A browser cannot forge the `Origin` header, so this blocks cross-site
|
|
56
|
+
script-driven handshakes. A raw client (wscat, python websockets) can send
|
|
57
|
+
any origin, which is exactly why authentication is a separate gate.
|
|
58
|
+
"""
|
|
59
|
+
origin = _normalized_origin(websocket.headers.get("origin", ""))
|
|
60
|
+
if not origin:
|
|
61
|
+
# No Origin header: tolerate local tooling in dev, reject in production.
|
|
62
|
+
return not _is_production()
|
|
28
63
|
|
|
29
|
-
|
|
30
|
-
if not
|
|
64
|
+
parsed_origin = urlparse(origin)
|
|
65
|
+
if not parsed_origin.scheme or not parsed_origin.netloc:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
if not _is_production() and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
|
|
69
|
+
return parsed_origin.scheme == "http"
|
|
70
|
+
|
|
71
|
+
allowed_origins = _configured_websocket_origins()
|
|
72
|
+
allowed_origins.add(_websocket_same_origin(websocket))
|
|
73
|
+
return origin in allowed_origins
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def authorize_websocket(
|
|
77
|
+
websocket: WebSocket,
|
|
78
|
+
*,
|
|
79
|
+
require_auth: bool,
|
|
80
|
+
roles: list[str] | None = None,
|
|
81
|
+
) -> dict[str, Any] | None:
|
|
82
|
+
"""Single entry point for socket authorization.
|
|
83
|
+
|
|
84
|
+
Returns the connecting identity on success, or `None` after closing the
|
|
85
|
+
socket on failure. The return value gates the connection only; do not echo
|
|
86
|
+
it to other clients (it may contain the authenticated user's payload).
|
|
87
|
+
|
|
88
|
+
- `require_auth=True` -> authenticated session required, else close 1008.
|
|
89
|
+
- `require_auth=False` -> guests allowed; authenticated users keep identity.
|
|
90
|
+
- `roles` -> RBAC via `Auth.check_role`, same rule as HTTP routes.
|
|
91
|
+
|
|
92
|
+
Auth is read from the session cookie that `SessionMiddleware` (the one
|
|
93
|
+
middleware that runs on websocket scopes) exposes as `websocket.session`.
|
|
94
|
+
Treat this as read-only: session writes are not persisted back to the cookie
|
|
95
|
+
over a WebSocket, so this guard must not rely on `refresh_session`.
|
|
96
|
+
"""
|
|
97
|
+
# 1. Origin / CSWSH check runs before accept so rejected handshakes never
|
|
98
|
+
# upgrade.
|
|
99
|
+
if not is_websocket_origin_allowed(websocket):
|
|
100
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
31
101
|
return None
|
|
32
102
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
103
|
+
# 2. Reuse Caspian `Auth` as the single source of truth. `Auth` only reads
|
|
104
|
+
# `request.session`, and a WebSocket exposes `.session`, so binding the
|
|
105
|
+
# socket as the request context lets us reuse the exact HTTP auth logic.
|
|
106
|
+
Auth.set_request(websocket) # type: ignore[arg-type]
|
|
107
|
+
auth = Auth.get_instance()
|
|
108
|
+
|
|
109
|
+
if auth.is_authenticated():
|
|
110
|
+
payload = auth.get_payload() or {}
|
|
111
|
+
if roles and not auth.check_role(payload, roles):
|
|
112
|
+
await _reject(websocket, "Forbidden.")
|
|
41
113
|
return None
|
|
114
|
+
return payload
|
|
42
115
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
session.pop(payload_key, None)
|
|
116
|
+
if require_auth:
|
|
117
|
+
await _reject(websocket, "Authentication required.")
|
|
46
118
|
return None
|
|
47
119
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
refreshed_wrapper = dict(payload_wrapper)
|
|
51
|
-
refreshed_wrapper["exp"] = auth_instance._calculate_expiration(
|
|
52
|
-
auth_instance.settings.default_token_validity
|
|
53
|
-
)
|
|
54
|
-
session[payload_key] = refreshed_wrapper
|
|
55
|
-
return payload
|
|
120
|
+
return {"guest": True, "scope": "public"}
|
|
121
|
+
|
|
56
122
|
|
|
57
|
-
|
|
123
|
+
async def _reject(websocket: WebSocket, message: str) -> None:
|
|
124
|
+
"""Accept, surface a JSON error so the browser UI can react, then close."""
|
|
125
|
+
await websocket.accept()
|
|
126
|
+
await websocket.send_json({"type": "error", "message": message})
|
|
127
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
58
128
|
|
|
59
129
|
|
|
60
130
|
class WebSocketConnectionManager:
|
|
@@ -80,5 +150,7 @@ class WebSocketConnectionManager:
|
|
|
80
150
|
self.disconnect(websocket)
|
|
81
151
|
|
|
82
152
|
|
|
83
|
-
|
|
84
|
-
|
|
153
|
+
# Separate pools keep authenticated traffic isolated from guest traffic, so a
|
|
154
|
+
# private broadcast can never fan out to a public (guest) connection.
|
|
155
|
+
websocket_connections = WebSocketConnectionManager()
|
|
156
|
+
public_websocket_connections = WebSocketConnectionManager()
|