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

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

View File

@@ -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<IncomingMessage, { type: 'auth_required' }>): 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<IncomingMessage, { type: 'auth_
dom.registerPassword.value = '';
dom.registerPasswordConfirm.value = '';
if (message.message.toLowerCase().includes('session')) {
authSessionToken = '';
settings.saveAuthSessionToken('');
void clearHttpOnlySessionCookie();
}
applyAuthPermissions('user', []);
applyServerAdminMenuActions([]);
@@ -1531,8 +1531,7 @@ async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_
}
if (message.sessionToken) {
authSessionToken = message.sessionToken;
settings.saveAuthSessionToken(message.sessionToken);
void persistHttpOnlySessionCookie(message.sessionToken);
}
if (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. */
function logOutAccount(): void {
authSessionToken = '';
authUsername = '';
settings.saveAuthSessionToken('');
void clearHttpOnlySessionCookie();
settings.saveAuthUsername('');
applyAuthPermissions('user', []);
applyServerAdminMenuActions([]);
@@ -1571,6 +1569,41 @@ function logOutAccount(): void {
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. */
function handleAuthPermissions(message: Extract<IncomingMessage, { type: 'auth_permissions' }>): void {
const hadVoiceSend = voiceSendAllowed;

View File

@@ -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<Record<'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off', number>> | 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 {