diff --git a/client/public/version.js b/client/public/version.js index 9fd78f2..cffa6dc 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -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"; diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 022e3e0..1d260bc 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -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 auth/session/set` (`Authorization: Bearer `, `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 auth/session/set` (`Authorization: Bearer `, `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 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: diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 891e47d..9fa6c0b 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -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). diff --git a/server/app/server.py b/server/app/server.py index 3cc8a69..281d85a 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -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.""" diff --git a/server/tests/test_http_session_cookie.py b/server/tests/test_http_session_cookie.py index bbe89f4..f1fc8f0 100644 --- a/server/tests/test_http_session_cookie.py +++ b/server/tests/test_http_session_cookie.py @@ -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"