From b8843e7c214656f7ec687121e763eb74401e2e40 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 28 Feb 2026 04:29:57 -0500 Subject: [PATCH] Move auth session persistence to true HttpOnly cookies --- README.md | 1 + client/public/version.js | 2 +- client/src/main.ts | 101 ++++++++++++++------- client/src/settings/settingsStore.ts | 39 +------- docs/local.md | 1 + docs/protocol-notes.md | 5 ++ docs/runtime-flow.md | 14 +-- server/app/server.py | 109 +++++++++++++++++++++-- server/tests/test_http_session_cookie.py | 74 +++++++++++++++ 9 files changed, 261 insertions(+), 85 deletions(-) create mode 100644 server/tests/test_http_session_cookie.py diff --git a/README.md b/README.md index c28e8da..8bf5690 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Notes: - Server bind/port defaults are `127.0.0.1:8765` unless changed in config or CLI flags. - Client dev defaults to Vite local host/port (`localhost:5173`) unless flags override. - Auth requires `CHGRID_AUTH_SECRET` in server environment; `deploy/scripts/install_server.sh` creates `server/.env` with this value automatically if missing. +- Saved login/session persistence uses a server-set `HttpOnly` cookie (`chgrid_session_token`). Common server overrides: - `uv run python main.py --config /path/to/config.toml` diff --git a/client/public/version.js b/client/public/version.js index edbf503..48b68c3 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.28 R315"; +window.CHGRID_WEB_VERSION = "2026.02.28 R316"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index 27625eb..dbcdb21 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -238,6 +238,9 @@ const SYSTEM_SOUND_URLS = { logout: withBase('sounds/logout.ogg'), notify: withBase('sounds/notify.ogg'), } as const; +const AUTH_SESSION_COOKIE_SET_URL = withBase('auth/session/set'); +const AUTH_SESSION_COOKIE_CLEAR_URL = withBase('auth/session/clear'); +const AUTH_SESSION_COOKIE_CLIENT_HEADER = 'X-Chgrid-Auth-Client'; const ACTION_SOUND_URL = withBase('sounds/action.ogg'); const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`)); const FOOTSTEP_GAIN = 0.7; @@ -259,7 +262,6 @@ let lastAnnouncementText = ''; let lastAnnouncementAt = 0; let outputMode = settings.loadOutputMode(); let authMode: 'login' | 'register' = 'login'; -let authSessionToken = settings.loadAuthSessionToken(); let authUsername = settings.loadAuthUsername(); let authPolicy: AuthPolicy | null = null; let authRole = 'user'; @@ -623,8 +625,7 @@ function applyVoiceSendPermission(): void { /** Enables/disables the connect button based on state and nickname validity. */ function updateConnectAvailability(): void { - const hasSessionToken = authSessionToken.trim().length > 0; - const showLogout = state.running || hasSessionToken; + const showLogout = state.running; dom.logoutButton.classList.toggle('hidden', !showLogout); dom.logoutButton.disabled = !showLogout; if (state.running) { @@ -635,22 +636,12 @@ function updateConnectAvailability(): void { dom.authSessionView.classList.add('hidden'); return; } - if (hasSessionToken) { - const label = sanitizeAuthUsername(authUsername) || 'current account'; - dom.authSessionText.textContent = `Logged in as ${label}.`; - dom.showRegisterButton.classList.add('hidden'); - dom.authModeSeparator.classList.add('hidden'); - dom.loginView.classList.add('hidden'); - dom.registerView.classList.add('hidden'); - dom.authSessionView.classList.remove('hidden'); - } else { - dom.showRegisterButton.classList.remove('hidden'); - dom.authModeSeparator.classList.remove('hidden'); - dom.showRegisterButton.textContent = authMode === 'login' ? 'Register' : 'Login'; - dom.loginView.classList.toggle('hidden', authMode !== 'login'); - dom.registerView.classList.toggle('hidden', authMode !== 'register'); - dom.authSessionView.classList.add('hidden'); - } + dom.showRegisterButton.classList.remove('hidden'); + dom.authModeSeparator.classList.remove('hidden'); + dom.showRegisterButton.textContent = authMode === 'login' ? 'Register' : 'Login'; + dom.loginView.classList.toggle('hidden', authMode !== 'login'); + dom.registerView.classList.toggle('hidden', authMode !== 'register'); + dom.authSessionView.classList.add('hidden'); const usernameMin = authPolicy?.usernameMinLength ?? 1; const passwordMin = authPolicy?.passwordMinLength ?? 1; const hasLoginCredentials = @@ -659,8 +650,9 @@ function updateConnectAvailability(): void { sanitizeAuthUsername(dom.registerUsername.value).length >= usernameMin && dom.registerPassword.value.trim().length >= passwordMin && dom.registerPassword.value === dom.registerPasswordConfirm.value; - const authReady = hasSessionToken || (authMode === 'login' ? hasLoginCredentials : hasRegisterCredentials); - dom.connectButton.textContent = hasSessionToken ? 'Connect' : authMode === 'login' ? 'Log In & Connect' : 'Register & Connect'; + const authReady = authMode === 'login' ? true : hasRegisterCredentials; + dom.connectButton.textContent = + authMode === 'register' ? 'Register & Connect' : hasLoginCredentials ? 'Log In & Connect' : 'Connect'; dom.connectButton.disabled = mediaSession.isConnecting() || !authReady; } @@ -1466,12 +1458,8 @@ function setAuthMode(mode: 'login' | 'register'): void { updateConnectAvailability(); } -/** Builds outbound auth packet from local token or active auth form. */ +/** Builds outbound auth packet from active login/register form fields. */ function buildAuthRequestPacket(): OutgoingMessage | null { - const token = authSessionToken.trim(); - if (token) { - return { type: 'auth_resume', sessionToken: token }; - } if (authMode === 'register') { const username = sanitizeAuthUsername(dom.registerUsername.value); const password = dom.registerPassword.value; @@ -1489,10 +1477,10 @@ function buildAuthRequestPacket(): OutgoingMessage | null { function sendAuthRequest(): void { const packet = buildAuthRequestPacket(); if (!packet) { - setConnectionStatus('Enter username and password.'); + pendingAuthRequest = false; + setConnectionStatus('Attempting saved session...'); mediaSession.setConnecting(false); updateConnectAvailability(); - signaling.disconnect(); return; } pendingAuthRequest = true; @@ -1502,11 +1490,24 @@ function sendAuthRequest(): void { /** Handles server auth-required prompts prior to world welcome. */ function handleAuthRequired(message: Extract): void { + const hadPendingRequest = pendingAuthRequest; + pendingAuthRequest = false; applyAuthPolicy(message.authPolicy); applyAuthPermissions('user', []); applyServerAdminMenuActions([]); setConnectionStatus('Authentication required.'); updateStatus(message.message); + if (!hadPendingRequest) { + const packet = buildAuthRequestPacket(); + if (packet) { + pendingAuthRequest = true; + setConnectionStatus('Authenticating...'); + signaling.send(packet); + return; + } + mediaSession.setConnecting(false); + updateConnectAvailability(); + } } /** Applies auth result state and terminates failed auth attempts quickly. */ @@ -1518,8 +1519,7 @@ async function handleAuthResult(message: Extract { + const token = sessionToken.trim(); + if (!token) return; + try { + await fetch(AUTH_SESSION_COOKIE_SET_URL, { + method: 'GET', + credentials: 'include', + headers: { + Authorization: `Bearer ${token}`, + [AUTH_SESSION_COOKIE_CLIENT_HEADER]: '1', + }, + cache: 'no-store', + }); + } catch (error) { + console.warn('Unable to persist auth cookie.', error); + } +} + +/** Clears server-managed HttpOnly auth session cookie. */ +async function clearHttpOnlySessionCookie(): Promise { + try { + await fetch(AUTH_SESSION_COOKIE_CLEAR_URL, { + method: 'GET', + credentials: 'include', + headers: { + [AUTH_SESSION_COOKIE_CLIENT_HEADER]: '1', + }, + cache: 'no-store', + }); + } catch (error) { + console.warn('Unable to clear auth cookie.', error); + } +} + /** Handles server-pushed role/permission refresh events for the current session. */ function handleAuthPermissions(message: Extract): void { const hadVoiceSend = voiceSendAllowed; diff --git a/client/src/settings/settingsStore.ts b/client/src/settings/settingsStore.ts index 6dc299b..a18a377 100644 --- a/client/src/settings/settingsStore.ts +++ b/client/src/settings/settingsStore.ts @@ -13,8 +13,6 @@ const PEER_LISTEN_GAINS_STORAGE_KEY = 'chatGridPeerListenGains'; const NICKNAME_STORAGE_KEY = 'spatialChatNickname'; const AUTH_USERNAME_STORAGE_KEY = 'chatGridAuthUsername'; const LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY = 'chatGridAuthSessionToken'; -const AUTH_SESSION_COOKIE_NAME = 'chgrid_session_token'; -const AUTH_SESSION_MAX_AGE_SECONDS = 14 * 24 * 60 * 60; type DevicePreference = { id: string; @@ -30,33 +28,6 @@ type AudioDevicePreferences = { * Wraps localStorage reads/writes for client user settings. */ export class SettingsStore { - private readCookie(name: string): string { - const cookie = document.cookie || ''; - const parts = cookie.split(';'); - for (const part of parts) { - const [rawKey, ...rest] = part.trim().split('='); - if (rawKey !== name) continue; - const rawValue = rest.join('='); - try { - return decodeURIComponent(rawValue); - } catch { - return rawValue; - } - } - return ''; - } - - private writeCookie(name: string, value: string, maxAgeSeconds: number): void { - const encoded = encodeURIComponent(value); - const secure = window.location.protocol === 'https:' ? '; Secure' : ''; - document.cookie = `${name}=${encoded}; Path=/; Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}; SameSite=Lax${secure}`; - } - - private clearCookie(name: string): void { - const secure = window.location.protocol === 'https:' ? '; Secure' : ''; - document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax${secure}`; - } - loadEffectLevels(): Partial> | null { const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY); if (!raw) return null; @@ -145,18 +116,14 @@ export class SettingsStore { } loadAuthSessionToken(): string { - // Session token is persisted in cookie storage (not localStorage). + // Session token now lives in an HttpOnly cookie managed by the server. localStorage.removeItem(LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY); - return this.readCookie(AUTH_SESSION_COOKIE_NAME); + return ''; } saveAuthSessionToken(token: string): void { + void token; localStorage.removeItem(LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY); - if (token) { - this.writeCookie(AUTH_SESSION_COOKIE_NAME, token, AUTH_SESSION_MAX_AGE_SECONDS); - return; - } - this.clearCookie(AUTH_SESSION_COOKIE_NAME); } loadAuthUsername(): string { diff --git a/docs/local.md b/docs/local.md index 363ff85..4a1742a 100644 --- a/docs/local.md +++ b/docs/local.md @@ -22,6 +22,7 @@ Defaults: - Server defaults to TLS-required unless you set `network.allow_insecure_ws=true` or pass `--allow-insecure-ws` for local/dev. - Client dev default is `localhost:5173`. - Auth requires `CHGRID_AUTH_SECRET` in environment. +- Saved login uses server-managed `HttpOnly` cookie (`chgrid_session_token`) via `GET /auth/session/set` and `GET /auth/session/clear` (both require `X-Chgrid-Auth-Client: 1`). ## Quick Restarts diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 44a6ddf..d39451b 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -87,6 +87,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 server HTTP endpoint `GET /auth/session/set` (`Authorization: Bearer `, `X-Chgrid-Auth-Client: 1`) so the server can issue `Set-Cookie: chgrid_session_token=...; HttpOnly`. - `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. @@ -111,6 +112,10 @@ This is a behavior guide for packet semantics beyond raw schemas. - Server is authoritative for all action validation and normalization. - Server is authoritative for movement acceptance (bounds + rate/delta checks). - Server persists account state (last nickname + last position) and restores spawn from that state on auth login/resume. +- Server also supports websocket handshake cookie resume: + - reads `chgrid_session_token` from websocket `Cookie` header + - attempts resume before sending `auth_required` + - exposes `GET /auth/session/clear` to expire the `HttpOnly` cookie (`X-Chgrid-Auth-Client: 1` required) - Server applies auth hardening before accepting login/register/resume: - login/register PBKDF2 work runs off the event loop in bounded worker concurrency - repeated auth failures are rate-limited by IP and IP+identity windows diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index a8d12f6..3896c24 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -3,15 +3,17 @@ ## Connect Flow 1. User clicks connect. -2. Client validates auth form/session token and sets up local media. +2. Client validates auth form and sets up local media. 3. Client connects signaling websocket. -4. Server sends `auth_required`. +4. Server attempts cookie-based session resume from websocket handshake cookie (`chgrid_session_token`). +5. If resume does not authenticate, server sends `auth_required`. - includes `authPolicy` limits for username/password. -5. Client sends `auth_login`, `auth_register`, or `auth_resume`. -6. Server sends `auth_result`. +6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller). +7. Server sends `auth_result`. - includes role + permissions for authenticated session. -7. Server sends `welcome` with users/items snapshot. -8. Client: +8. Client persists authenticated session into a server-managed `HttpOnly` cookie via `GET /auth/session/set` (`Authorization: Bearer `, `X-Chgrid-Auth-Client: 1`), and clears it via `GET /auth/session/clear` (`X-Chgrid-Auth-Client: 1`) on logout/session errors. +9. Server sends `welcome` with users/items snapshot. +10. Client: - applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering - applies `welcome.worldConfig.movementTickMs` as movement pacing guidance - applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity diff --git a/server/app/server.py b/server/app/server.py index f243c79..4ecf9fc 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -26,6 +26,8 @@ from zoneinfo import ZoneInfo from pydantic import ValidationError, TypeAdapter from websockets.asyncio.server import ServerConnection, serve +from websockets.datastructures import Headers +from websockets.http11 import Request as HttpRequest, Response as HttpResponse from .auth_service import AuthError, AuthService from .client import ClientConnection @@ -118,6 +120,11 @@ AUTH_FAILURE_JITTER_MAX_MS = 0.08 RADIO_METADATA_POLL_INTERVAL_S = 10.0 RADIO_METADATA_TIMEOUT_S = 6.0 CLOCK_ANNOUNCE_POLL_INTERVAL_S = 1.0 +AUTH_SESSION_COOKIE_NAME = "chgrid_session_token" +AUTH_SESSION_COOKIE_MAX_AGE_SECONDS = 14 * 24 * 60 * 60 +AUTH_SESSION_COOKIE_SET_PATH = "/auth/session/set" +AUTH_SESSION_COOKIE_CLEAR_PATH = "/auth/session/clear" +AUTH_SESSION_COOKIE_CLIENT_HEADER = "X-Chgrid-Auth-Client" ADMIN_MENU_ACTION_DEFINITIONS: tuple[dict[str, str], ...] = ( {"id": "manage_roles", "label": "Role management", "permission": "role.manage"}, {"id": "change_user_role", "label": "Change user role", "permission": "user.change_role"}, @@ -246,6 +253,84 @@ class SignalingServer: "passwordMaxLength": self.auth_service.password_max_length, } + def _session_cookie_secure(self, request: HttpRequest | None = None) -> bool: + """Return True when session cookies should be marked Secure.""" + + if self._ssl_context is not None: + return True + if request is None: + return False + forwarded = str(request.headers.get("X-Forwarded-Proto", "")).split(",", 1)[0].strip().lower() + return forwarded == "https" + + def _session_cookie_header(self, token: str, *, request: HttpRequest | None = None) -> str: + """Build Set-Cookie header value for a valid session token.""" + + secure = "; Secure" if self._session_cookie_secure(request) else "" + return ( + f"{AUTH_SESSION_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; " + f"Max-Age={AUTH_SESSION_COOKIE_MAX_AGE_SECONDS}{secure}" + ) + + def _clear_session_cookie_header(self, *, request: HttpRequest | None = None) -> str: + """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=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}" + + @staticmethod + def _cookie_value(cookie_header: str, name: str) -> str: + """Extract one cookie value by name from a Cookie header.""" + + for segment in cookie_header.split(";"): + key, separator, raw_value = segment.strip().partition("=") + if separator and key == name: + return raw_value.strip() + return "" + + async def _process_http_request(self, _connection: ServerConnection, request: HttpRequest) -> HttpResponse | None: + """Handle lightweight same-origin auth cookie set/clear HTTP endpoints.""" + + path = request.path.split("?", 1)[0] + if path not in {AUTH_SESSION_COOKIE_SET_PATH, AUTH_SESSION_COOKIE_CLEAR_PATH}: + return None + + headers = Headers() + headers["Content-Type"] = "text/plain; charset=utf-8" + headers["Cache-Control"] = "no-store" + client_header = str(request.headers.get(AUTH_SESSION_COOKIE_CLIENT_HEADER, "")).strip() + if client_header != "1": + return HttpResponse(400, "Bad Request", headers, b"missing client header") + + if path == AUTH_SESSION_COOKIE_CLEAR_PATH: + headers["Set-Cookie"] = self._clear_session_cookie_header(request=request) + return HttpResponse(200, "OK", headers, b"cleared") + + authorization = str(request.headers.get("Authorization", "")).strip() + if not authorization.lower().startswith("bearer "): + return HttpResponse(400, "Bad Request", headers, b"missing bearer token") + token = authorization[7:].strip() + if not token: + return HttpResponse(400, "Bad Request", headers, b"missing bearer token") + try: + session = self.auth_service.resume(token) + except AuthError: + return HttpResponse(401, "Unauthorized", headers, b"invalid session") + headers["Set-Cookie"] = self._session_cookie_header(session.token, request=request) + return HttpResponse(200, "OK", headers, b"ok") + + def _session_token_from_websocket_cookie(self, websocket: ServerConnection) -> str: + """Read session token from websocket handshake Cookie header.""" + + request = getattr(websocket, "request", None) + headers = getattr(request, "headers", None) + if headers is None: + return "" + cookie_header = str(headers.get("Cookie", "")).strip() + if not cookie_header: + return "" + return self._cookie_value(cookie_header, 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.""" @@ -1217,6 +1302,7 @@ class SignalingServer: self.port, ssl=self._ssl_context, max_size=self.max_message_size, + process_request=self._process_http_request, ): await asyncio.Future() finally: @@ -1245,14 +1331,21 @@ class SignalingServer: LOGGER.info("websocket opened id=%s", client.id) try: - await self._send( - websocket, - AuthRequiredPacket( - type="auth_required", - message="Authentication required.", - authPolicy=self._auth_policy(), - ), - ) + cookie_token = self._session_token_from_websocket_cookie(websocket) + if cookie_token: + await self._handle_auth_packet( + client, + AuthResumePacket(type="auth_resume", sessionToken=cookie_token), + ) + if not client.authenticated: + await self._send( + websocket, + AuthRequiredPacket( + type="auth_required", + message="Authentication required.", + authPolicy=self._auth_policy(), + ), + ) async for raw_message in websocket: await self._handle_message(client, raw_message) except Exception: diff --git a/server/tests/test_http_session_cookie.py b/server/tests/test_http_session_cookie.py new file mode 100644 index 0000000..dd51bb5 --- /dev/null +++ b/server/tests/test_http_session_cookie.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from types import SimpleNamespace +import uuid + +import pytest +from websockets.datastructures import Headers +from websockets.http11 import Request + +from app.server import ( + AUTH_SESSION_COOKIE_CLIENT_HEADER, + AUTH_SESSION_COOKIE_CLEAR_PATH, + AUTH_SESSION_COOKIE_NAME, + AUTH_SESSION_COOKIE_SET_PATH, + SignalingServer, +) + + +def _request(path: str, headers: dict[str, str] | None = None) -> Request: + values = Headers() + for key, value in (headers or {}).items(): + values[key] = value + return Request(path=path, headers=values) + + +@pytest.mark.asyncio +async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + username = f"user_{uuid.uuid4().hex[:8]}" + session = server.auth_service.register(username, "password99") + request = _request( + AUTH_SESSION_COOKIE_SET_PATH, + headers={ + AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", + "Authorization": f"Bearer {session.token}", + }, + ) + + response = await server._process_http_request(SimpleNamespace(), request) + + 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 "HttpOnly" in set_cookie + assert "SameSite=Lax" in set_cookie + + +@pytest.mark.asyncio +async def test_session_cookie_clear_endpoint_expires_cookie() -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + request = _request(AUTH_SESSION_COOKIE_CLEAR_PATH, headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1"}) + + response = await server._process_http_request(SimpleNamespace(), request) + + 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 "Max-Age=0" in set_cookie + assert "HttpOnly" in set_cookie + + +def test_session_token_from_websocket_cookie_reads_named_cookie() -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + websocket = SimpleNamespace( + request=SimpleNamespace( + headers=Headers({"Cookie": f"foo=bar; {AUTH_SESSION_COOKIE_NAME}=abc123; hello=world"}) + ) + ) + + token = server._session_token_from_websocket_cookie(websocket) + + assert token == "abc123"