Scope session cookies by grid path

This commit is contained in:
Jage9
2026-03-08 22:59:59 -04:00
parent 54a7a3085b
commit 19b593b1aa
5 changed files with 33 additions and 14 deletions

View File

@@ -1,6 +1,6 @@
// Maintainer-controlled web client version metadata.
window.CHGRID_RELEASE_VERSION = "0.1.0";
window.CHGRID_BUILD_REVISION = "R342";
window.CHGRID_BUILD_REVISION = "R343";
window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`;
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -96,7 +96,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
- `auth_required.authPolicy`: server auth limits advertised before login/register submit.
- `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses.
- `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue `Set-Cookie: chgrid_session_token=...; HttpOnly`.
- `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue an instance-scoped `HttpOnly` session cookie.
- `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing.
- `welcome.worldConfig.movementTickMs`: server movement-rate window used for client movement pacing.
- `welcome.worldConfig.movementMaxStepsPerTick`: max allowed grid steps per movement window.
@@ -127,7 +127,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- Server also supports websocket handshake cookie resume:
- accepts browser sockets only when websocket `Origin` matches `CHGRID_HOST_ORIGIN`
- websocket and auth helper routes are scoped under the configured `server.base_path`
- reads `chgrid_session_token` from websocket `Cookie` header
- reads the instance-scoped session cookie from the websocket `Cookie` header
- attempts resume before sending `auth_required`
- exposes `GET <base_path>auth/session/clear` to expire the `HttpOnly` cookie (`X-Chgrid-Auth-Client: 1` and matching `Origin` required)
- Server applies auth hardening before accepting login/register/resume:

View File

@@ -5,7 +5,7 @@
1. User clicks connect.
2. Client validates auth form and sets up local media.
3. Client connects signaling websocket from the configured app origin.
4. Server accepts the socket only on the configured instance websocket path and when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from websocket handshake cookie (`chgrid_session_token`).
4. Server accepts the socket only on the configured instance websocket path and when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from the instance-scoped websocket handshake cookie.
5. If resume does not authenticate, server sends `auth_required`.
- includes `authPolicy` limits for username/password.
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).

View File

@@ -193,6 +193,7 @@ class SignalingServer:
self.server_version = self._resolve_server_version()
self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None
self.base_path = self._normalize_base_path(base_path)
self.auth_session_cookie_name = self._session_cookie_name_for_base_path(self.base_path)
self.websocket_path = self._base_path_join(WEBSOCKET_PATH)
self.auth_session_cookie_set_path = self._base_path_join(AUTH_SESSION_COOKIE_SET_PATH)
self.auth_session_cookie_clear_path = self._base_path_join(AUTH_SESSION_COOKIE_CLEAR_PATH)
@@ -287,6 +288,17 @@ class SignalingServer:
return f"/{token}"
return f"{self.base_path}{token}"
@staticmethod
def _session_cookie_name_for_base_path(base_path: str) -> str:
"""Return one deterministic session cookie name for the configured instance path."""
if base_path == "/":
return AUTH_SESSION_COOKIE_NAME
suffix = re.sub(r"[^a-z0-9]+", "_", base_path.strip("/").casefold()).strip("_")
if not suffix:
return AUTH_SESSION_COOKIE_NAME
return f"chgrid_session_{suffix}"
def _session_cookie_secure(self, request: HttpRequest | None = None) -> bool:
"""Return True when session cookies should be marked Secure."""
@@ -302,7 +314,7 @@ class SignalingServer:
secure = "; Secure" if self._session_cookie_secure(request) else ""
return (
f"{AUTH_SESSION_COOKIE_NAME}={token}; Path={self.base_path}; HttpOnly; SameSite=Lax; "
f"{self.auth_session_cookie_name}={token}; Path={self.base_path}; HttpOnly; SameSite=Lax; "
f"Max-Age={AUTH_SESSION_COOKIE_MAX_AGE_SECONDS}{secure}"
)
@@ -310,7 +322,7 @@ class SignalingServer:
"""Build Set-Cookie header value that expires the session cookie."""
secure = "; Secure" if self._session_cookie_secure(request) else ""
return f"{AUTH_SESSION_COOKIE_NAME}=; Path={self.base_path}; HttpOnly; SameSite=Lax; Max-Age=0{secure}"
return f"{self.auth_session_cookie_name}=; Path={self.base_path}; HttpOnly; SameSite=Lax; Max-Age=0{secure}"
def _origin_allowed(self, request: HttpRequest) -> bool:
"""Return whether one auth helper HTTP request comes from the configured app origin."""
@@ -364,7 +376,7 @@ class SignalingServer:
if path == self.auth_session_cookie_check_path:
cookie_header = str(request.headers.get("Cookie", "")).strip()
token = self._cookie_value(cookie_header, AUTH_SESSION_COOKIE_NAME)
token = self._cookie_value(cookie_header, self.auth_session_cookie_name)
if not token:
return HttpResponse(401, "Unauthorized", headers, b"missing session")
try:
@@ -400,7 +412,7 @@ class SignalingServer:
cookie_header = str(headers.get("Cookie", "")).strip()
if not cookie_header:
return ""
return self._cookie_value(cookie_header, AUTH_SESSION_COOKIE_NAME)
return self._cookie_value(cookie_header, self.auth_session_cookie_name)
def _build_admin_menu_actions_for_client(self, client: ClientConnection | None) -> list[dict[str, str]]:
"""Build server-authored admin menu actions allowed for one client."""

View File

@@ -11,7 +11,6 @@ from app.server import (
AUTH_SESSION_COOKIE_CLIENT_HEADER,
AUTH_SESSION_COOKIE_CHECK_PATH,
AUTH_SESSION_COOKIE_CLEAR_PATH,
AUTH_SESSION_COOKIE_NAME,
AUTH_SESSION_COOKIE_SET_PATH,
SignalingServer,
)
@@ -47,7 +46,7 @@ async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None:
assert response is not None
assert response.status_code == 200
set_cookie = response.headers.get("Set-Cookie", "")
assert f"{AUTH_SESSION_COOKIE_NAME}=" in set_cookie
assert f"{server.auth_session_cookie_name}=" in set_cookie
assert "Path=/chgrid/" in set_cookie
assert "HttpOnly" in set_cookie
assert "SameSite=Lax" in set_cookie
@@ -66,7 +65,7 @@ async def test_session_cookie_clear_endpoint_expires_cookie() -> None:
assert response is not None
assert response.status_code == 200
set_cookie = response.headers.get("Set-Cookie", "")
assert f"{AUTH_SESSION_COOKIE_NAME}=" in set_cookie
assert f"{server.auth_session_cookie_name}=" in set_cookie
assert "Max-Age=0" in set_cookie
assert "HttpOnly" in set_cookie
@@ -80,7 +79,7 @@ async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None:
server.auth_session_cookie_check_path,
headers={
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
"Cookie": f"{AUTH_SESSION_COOKIE_NAME}={session.token}",
"Cookie": f"{server.auth_session_cookie_name}={session.token}",
"Origin": "https://example.com",
},
)
@@ -120,13 +119,21 @@ async def test_session_cookie_helpers_reject_wrong_origin() -> None:
def test_session_token_from_websocket_cookie_reads_named_cookie() -> None:
server = SignalingServer("127.0.0.1", 8765, None, None)
server = SignalingServer("127.0.0.1", 8765, None, None, base_path="/chgrid/")
websocket = SimpleNamespace(
request=SimpleNamespace(
headers=Headers({"Cookie": f"foo=bar; {AUTH_SESSION_COOKIE_NAME}=abc123; hello=world"})
headers=Headers({"Cookie": f"foo=bar; {server.auth_session_cookie_name}=abc123; hello=world"})
)
)
token = server._session_token_from_websocket_cookie(websocket)
assert token == "abc123"
def test_session_cookie_name_scopes_to_base_path() -> None:
root_server = SignalingServer("127.0.0.1", 8765, None, None, base_path="/")
nested_server = SignalingServer("127.0.0.1", 8765, None, None, base_path="/ttgrid/")
assert root_server.auth_session_cookie_name == "chgrid_session_token"
assert nested_server.auth_session_cookie_name == "chgrid_session_ttgrid"