Move auth session persistence to true HttpOnly cookies

This commit is contained in:
Jage9
2026-02-28 04:29:57 -05:00
parent 9f7d573557
commit b8843e7c21
9 changed files with 261 additions and 85 deletions

View File

@@ -28,6 +28,7 @@ Notes:
- Server bind/port defaults are `127.0.0.1:8765` unless changed in config or CLI flags. - 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. - 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. - 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: Common server overrides:
- `uv run python main.py --config /path/to/config.toml` - `uv run python main.py --config /path/to/config.toml`

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // 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. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -238,6 +238,9 @@ const SYSTEM_SOUND_URLS = {
logout: withBase('sounds/logout.ogg'), logout: withBase('sounds/logout.ogg'),
notify: withBase('sounds/notify.ogg'), notify: withBase('sounds/notify.ogg'),
} as const; } 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 ACTION_SOUND_URL = withBase('sounds/action.ogg');
const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`)); const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`));
const FOOTSTEP_GAIN = 0.7; const FOOTSTEP_GAIN = 0.7;
@@ -259,7 +262,6 @@ let lastAnnouncementText = '';
let lastAnnouncementAt = 0; let lastAnnouncementAt = 0;
let outputMode = settings.loadOutputMode(); let outputMode = settings.loadOutputMode();
let authMode: 'login' | 'register' = 'login'; let authMode: 'login' | 'register' = 'login';
let authSessionToken = settings.loadAuthSessionToken();
let authUsername = settings.loadAuthUsername(); let authUsername = settings.loadAuthUsername();
let authPolicy: AuthPolicy | null = null; let authPolicy: AuthPolicy | null = null;
let authRole = 'user'; let authRole = 'user';
@@ -623,8 +625,7 @@ function applyVoiceSendPermission(): void {
/** Enables/disables the connect button based on state and nickname validity. */ /** Enables/disables the connect button based on state and nickname validity. */
function updateConnectAvailability(): void { function updateConnectAvailability(): void {
const hasSessionToken = authSessionToken.trim().length > 0; const showLogout = state.running;
const showLogout = state.running || hasSessionToken;
dom.logoutButton.classList.toggle('hidden', !showLogout); dom.logoutButton.classList.toggle('hidden', !showLogout);
dom.logoutButton.disabled = !showLogout; dom.logoutButton.disabled = !showLogout;
if (state.running) { if (state.running) {
@@ -635,22 +636,12 @@ function updateConnectAvailability(): void {
dom.authSessionView.classList.add('hidden'); dom.authSessionView.classList.add('hidden');
return; 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.showRegisterButton.classList.remove('hidden');
dom.authModeSeparator.classList.remove('hidden'); dom.authModeSeparator.classList.remove('hidden');
dom.showRegisterButton.textContent = authMode === 'login' ? 'Register' : 'Login'; dom.showRegisterButton.textContent = authMode === 'login' ? 'Register' : 'Login';
dom.loginView.classList.toggle('hidden', authMode !== 'login'); dom.loginView.classList.toggle('hidden', authMode !== 'login');
dom.registerView.classList.toggle('hidden', authMode !== 'register'); dom.registerView.classList.toggle('hidden', authMode !== 'register');
dom.authSessionView.classList.add('hidden'); dom.authSessionView.classList.add('hidden');
}
const usernameMin = authPolicy?.usernameMinLength ?? 1; const usernameMin = authPolicy?.usernameMinLength ?? 1;
const passwordMin = authPolicy?.passwordMinLength ?? 1; const passwordMin = authPolicy?.passwordMinLength ?? 1;
const hasLoginCredentials = const hasLoginCredentials =
@@ -659,8 +650,9 @@ function updateConnectAvailability(): void {
sanitizeAuthUsername(dom.registerUsername.value).length >= usernameMin && sanitizeAuthUsername(dom.registerUsername.value).length >= usernameMin &&
dom.registerPassword.value.trim().length >= passwordMin && dom.registerPassword.value.trim().length >= passwordMin &&
dom.registerPassword.value === dom.registerPasswordConfirm.value; dom.registerPassword.value === dom.registerPasswordConfirm.value;
const authReady = hasSessionToken || (authMode === 'login' ? hasLoginCredentials : hasRegisterCredentials); const authReady = authMode === 'login' ? true : hasRegisterCredentials;
dom.connectButton.textContent = hasSessionToken ? 'Connect' : authMode === 'login' ? 'Log In & Connect' : 'Register & Connect'; dom.connectButton.textContent =
authMode === 'register' ? 'Register & Connect' : hasLoginCredentials ? 'Log In & Connect' : 'Connect';
dom.connectButton.disabled = mediaSession.isConnecting() || !authReady; dom.connectButton.disabled = mediaSession.isConnecting() || !authReady;
} }
@@ -1466,12 +1458,8 @@ function setAuthMode(mode: 'login' | 'register'): void {
updateConnectAvailability(); 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 { function buildAuthRequestPacket(): OutgoingMessage | null {
const token = authSessionToken.trim();
if (token) {
return { type: 'auth_resume', sessionToken: token };
}
if (authMode === 'register') { if (authMode === 'register') {
const username = sanitizeAuthUsername(dom.registerUsername.value); const username = sanitizeAuthUsername(dom.registerUsername.value);
const password = dom.registerPassword.value; const password = dom.registerPassword.value;
@@ -1489,10 +1477,10 @@ function buildAuthRequestPacket(): OutgoingMessage | null {
function sendAuthRequest(): void { function sendAuthRequest(): void {
const packet = buildAuthRequestPacket(); const packet = buildAuthRequestPacket();
if (!packet) { if (!packet) {
setConnectionStatus('Enter username and password.'); pendingAuthRequest = false;
setConnectionStatus('Attempting saved session...');
mediaSession.setConnecting(false); mediaSession.setConnecting(false);
updateConnectAvailability(); updateConnectAvailability();
signaling.disconnect();
return; return;
} }
pendingAuthRequest = true; pendingAuthRequest = true;
@@ -1502,11 +1490,24 @@ function sendAuthRequest(): void {
/** Handles server auth-required prompts prior to world welcome. */ /** Handles server auth-required prompts prior to world welcome. */
function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void { function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
const hadPendingRequest = pendingAuthRequest;
pendingAuthRequest = false;
applyAuthPolicy(message.authPolicy); applyAuthPolicy(message.authPolicy);
applyAuthPermissions('user', []); applyAuthPermissions('user', []);
applyServerAdminMenuActions([]); applyServerAdminMenuActions([]);
setConnectionStatus('Authentication required.'); setConnectionStatus('Authentication required.');
updateStatus(message.message); 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. */ /** Applies auth result state and terminates failed auth attempts quickly. */
@@ -1518,8 +1519,7 @@ async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_
dom.registerPassword.value = ''; dom.registerPassword.value = '';
dom.registerPasswordConfirm.value = ''; dom.registerPasswordConfirm.value = '';
if (message.message.toLowerCase().includes('session')) { if (message.message.toLowerCase().includes('session')) {
authSessionToken = ''; void clearHttpOnlySessionCookie();
settings.saveAuthSessionToken('');
} }
applyAuthPermissions('user', []); applyAuthPermissions('user', []);
applyServerAdminMenuActions([]); applyServerAdminMenuActions([]);
@@ -1531,8 +1531,7 @@ async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_
} }
if (message.sessionToken) { if (message.sessionToken) {
authSessionToken = message.sessionToken; void persistHttpOnlySessionCookie(message.sessionToken);
settings.saveAuthSessionToken(message.sessionToken);
} }
if (message.username) { if (message.username) {
authUsername = message.username; authUsername = message.username;
@@ -1556,9 +1555,8 @@ async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_
/** Clears stored auth session and returns UI to login mode. */ /** Clears stored auth session and returns UI to login mode. */
function logOutAccount(): void { function logOutAccount(): void {
authSessionToken = '';
authUsername = ''; authUsername = '';
settings.saveAuthSessionToken(''); void clearHttpOnlySessionCookie();
settings.saveAuthUsername(''); settings.saveAuthUsername('');
applyAuthPermissions('user', []); applyAuthPermissions('user', []);
applyServerAdminMenuActions([]); applyServerAdminMenuActions([]);
@@ -1571,6 +1569,41 @@ function logOutAccount(): void {
updateConnectAvailability(); updateConnectAvailability();
} }
/** Persists active auth session in a server-managed HttpOnly cookie. */
async function persistHttpOnlySessionCookie(sessionToken: string): Promise<void> {
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<void> {
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. */ /** Handles server-pushed role/permission refresh events for the current session. */
function handleAuthPermissions(message: Extract<IncomingMessage, { type: 'auth_permissions' }>): void { function handleAuthPermissions(message: Extract<IncomingMessage, { type: 'auth_permissions' }>): void {
const hadVoiceSend = voiceSendAllowed; const hadVoiceSend = voiceSendAllowed;

View File

@@ -13,8 +13,6 @@ const PEER_LISTEN_GAINS_STORAGE_KEY = 'chatGridPeerListenGains';
const NICKNAME_STORAGE_KEY = 'spatialChatNickname'; const NICKNAME_STORAGE_KEY = 'spatialChatNickname';
const AUTH_USERNAME_STORAGE_KEY = 'chatGridAuthUsername'; const AUTH_USERNAME_STORAGE_KEY = 'chatGridAuthUsername';
const LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY = 'chatGridAuthSessionToken'; 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 = { type DevicePreference = {
id: string; id: string;
@@ -30,33 +28,6 @@ type AudioDevicePreferences = {
* Wraps localStorage reads/writes for client user settings. * Wraps localStorage reads/writes for client user settings.
*/ */
export class SettingsStore { 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<Record<'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off', number>> | null { loadEffectLevels(): Partial<Record<'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off', number>> | null {
const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY); const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY);
if (!raw) return null; if (!raw) return null;
@@ -145,18 +116,14 @@ export class SettingsStore {
} }
loadAuthSessionToken(): string { 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); localStorage.removeItem(LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY);
return this.readCookie(AUTH_SESSION_COOKIE_NAME); return '';
} }
saveAuthSessionToken(token: string): void { saveAuthSessionToken(token: string): void {
void token;
localStorage.removeItem(LEGACY_AUTH_SESSION_TOKEN_STORAGE_KEY); 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 { loadAuthUsername(): string {

View File

@@ -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. - 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`. - Client dev default is `localhost:5173`.
- Auth requires `CHGRID_AUTH_SECRET` in environment. - 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 ## Quick Restarts

View File

@@ -87,6 +87,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`) - `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
- `auth_required.authPolicy`: server auth limits advertised before login/register submit. - `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.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 <sessionToken>`, `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.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.movementTickMs`: server movement-rate window used for client movement pacing.
- `welcome.worldConfig.movementMaxStepsPerTick`: max allowed grid steps per movement window. - `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 all action validation and normalization.
- Server is authoritative for movement acceptance (bounds + rate/delta checks). - 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 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: - Server applies auth hardening before accepting login/register/resume:
- login/register PBKDF2 work runs off the event loop in bounded worker concurrency - 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 - repeated auth failures are rate-limited by IP and IP+identity windows

View File

@@ -3,15 +3,17 @@
## Connect Flow ## Connect Flow
1. User clicks connect. 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. 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. - includes `authPolicy` limits for username/password.
5. Client sends `auth_login`, `auth_register`, or `auth_resume`. 6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
6. Server sends `auth_result`. 7. Server sends `auth_result`.
- includes role + permissions for authenticated session. - includes role + permissions for authenticated session.
7. Server sends `welcome` with users/items snapshot. 8. Client persists authenticated session into a server-managed `HttpOnly` cookie via `GET /auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`), and clears it via `GET /auth/session/clear` (`X-Chgrid-Auth-Client: 1`) on logout/session errors.
8. Client: 9. Server sends `welcome` with users/items snapshot.
10. Client:
- applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering - applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering
- applies `welcome.worldConfig.movementTickMs` as movement pacing guidance - applies `welcome.worldConfig.movementTickMs` as movement pacing guidance
- applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity - applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity

View File

@@ -26,6 +26,8 @@ from zoneinfo import ZoneInfo
from pydantic import ValidationError, TypeAdapter from pydantic import ValidationError, TypeAdapter
from websockets.asyncio.server import ServerConnection, serve 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 .auth_service import AuthError, AuthService
from .client import ClientConnection from .client import ClientConnection
@@ -118,6 +120,11 @@ AUTH_FAILURE_JITTER_MAX_MS = 0.08
RADIO_METADATA_POLL_INTERVAL_S = 10.0 RADIO_METADATA_POLL_INTERVAL_S = 10.0
RADIO_METADATA_TIMEOUT_S = 6.0 RADIO_METADATA_TIMEOUT_S = 6.0
CLOCK_ANNOUNCE_POLL_INTERVAL_S = 1.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], ...] = ( ADMIN_MENU_ACTION_DEFINITIONS: tuple[dict[str, str], ...] = (
{"id": "manage_roles", "label": "Role management", "permission": "role.manage"}, {"id": "manage_roles", "label": "Role management", "permission": "role.manage"},
{"id": "change_user_role", "label": "Change user role", "permission": "user.change_role"}, {"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, "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]]: 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.""" """Build server-authored admin menu actions allowed for one client."""
@@ -1217,6 +1302,7 @@ class SignalingServer:
self.port, self.port,
ssl=self._ssl_context, ssl=self._ssl_context,
max_size=self.max_message_size, max_size=self.max_message_size,
process_request=self._process_http_request,
): ):
await asyncio.Future() await asyncio.Future()
finally: finally:
@@ -1245,6 +1331,13 @@ class SignalingServer:
LOGGER.info("websocket opened id=%s", client.id) LOGGER.info("websocket opened id=%s", client.id)
try: try:
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( await self._send(
websocket, websocket,
AuthRequiredPacket( AuthRequiredPacket(

View File

@@ -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"