Fix auth token being clobbered by game seat token (userId always null)
The login JWT and the per-seat rejoin token shared the localStorage key 'shelem_token', so joining a game overwrote the auth token with the random seat token. The socket then reconnected as an unauthenticated guest, so every seat was recorded with userId=null and no stats or history were attributed. - Seat token now uses its own key 'shelem_seat_token'; 'shelem_token' is auth-only - Add GET /api/me + boot-time validateAuth() to drop stale/corrupted tokens instead of running as a "logged in" ghost - reauthSocket() re-authenticates the live socket on login/register Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+42
-5
@@ -471,10 +471,21 @@ function connectSocket(onReady) {
|
|||||||
if (!socket) {
|
if (!socket) {
|
||||||
socket = io({ auth: { token: authToken } });
|
socket = io({ auth: { token: authToken } });
|
||||||
initSocketHandlers();
|
initSocketHandlers();
|
||||||
|
} else {
|
||||||
|
// Reuse the socket but make sure it carries the current auth token
|
||||||
|
socket.auth = { token: authToken };
|
||||||
}
|
}
|
||||||
socket.once('connect', onReady);
|
socket.once('connect', onReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-authenticate an existing socket after login/register so the server sees
|
||||||
|
// the user on this same connection (auth is only read at (re)connect time).
|
||||||
|
function reauthSocket() {
|
||||||
|
if (!socket) return;
|
||||||
|
socket.auth = { token: authToken };
|
||||||
|
if (socket.connected) socket.disconnect().connect();
|
||||||
|
}
|
||||||
|
|
||||||
function initSocketHandlers() {
|
function initSocketHandlers() {
|
||||||
// Persistent reconnect handler — auto-rejoin after any network drop mid-game
|
// Persistent reconnect handler — auto-rejoin after any network drop mid-game
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
@@ -555,20 +566,23 @@ function initSocketHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Session persistence (localStorage so it survives tab/app close) ──────────
|
// ─── Session persistence (localStorage so it survives tab/app close) ──────────
|
||||||
|
// NOTE: the game-session (seat) token uses its OWN key. It must never share a
|
||||||
|
// key with the auth JWT ('shelem_token'), or joining a game would overwrite the
|
||||||
|
// login token and drop the player to an unauthenticated guest.
|
||||||
function saveSession() {
|
function saveSession() {
|
||||||
localStorage.setItem('shelem_room', myRoomId);
|
localStorage.setItem('shelem_room', myRoomId);
|
||||||
localStorage.setItem('shelem_seat', mySeat);
|
localStorage.setItem('shelem_seat', mySeat);
|
||||||
localStorage.setItem('shelem_token', myToken);
|
localStorage.setItem('shelem_seat_token', myToken);
|
||||||
}
|
}
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
localStorage.removeItem('shelem_room');
|
localStorage.removeItem('shelem_room');
|
||||||
localStorage.removeItem('shelem_seat');
|
localStorage.removeItem('shelem_seat');
|
||||||
localStorage.removeItem('shelem_token');
|
localStorage.removeItem('shelem_seat_token');
|
||||||
}
|
}
|
||||||
function tryRejoin() {
|
function tryRejoin() {
|
||||||
const room = localStorage.getItem('shelem_room');
|
const room = localStorage.getItem('shelem_room');
|
||||||
const seat = localStorage.getItem('shelem_seat');
|
const seat = localStorage.getItem('shelem_seat');
|
||||||
const token = localStorage.getItem('shelem_token');
|
const token = localStorage.getItem('shelem_seat_token');
|
||||||
if (!room || seat === null || !token) return;
|
if (!room || seat === null || !token) return;
|
||||||
// Set inside the callback so the persistent 'connect' handler doesn't
|
// Set inside the callback so the persistent 'connect' handler doesn't
|
||||||
// also emit rejoin on the very first connection (would be a duplicate)
|
// also emit rejoin on the very first connection (would be a duplicate)
|
||||||
@@ -1235,7 +1249,7 @@ async function doLogin() {
|
|||||||
localStorage.setItem('shelem_user', authUser);
|
localStorage.setItem('shelem_user', authUser);
|
||||||
hide('overlay-auth');
|
hide('overlay-auth');
|
||||||
updateAuthBar();
|
updateAuthBar();
|
||||||
if (socket) socket.auth = { token: authToken };
|
reauthSocket();
|
||||||
} catch { $('auth-login-error').textContent = 'Network error'; }
|
} catch { $('auth-login-error').textContent = 'Network error'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1257,6 +1271,7 @@ async function doRegister() {
|
|||||||
localStorage.setItem('shelem_user', authUser);
|
localStorage.setItem('shelem_user', authUser);
|
||||||
hide('overlay-auth');
|
hide('overlay-auth');
|
||||||
updateAuthBar();
|
updateAuthBar();
|
||||||
|
reauthSocket();
|
||||||
} catch { $('auth-reg-error').textContent = 'Network error'; }
|
} catch { $('auth-reg-error').textContent = 'Network error'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1666,12 +1681,34 @@ function wireGameEvents() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the stored auth token before trusting it. Catches tokens that are
|
||||||
|
// expired, or corrupted by the old localStorage key collision, so we don't run
|
||||||
|
// around as a "logged in" ghost whose socket is actually an unauthenticated guest.
|
||||||
|
async function validateAuth() {
|
||||||
|
if (!authToken) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/me', { headers: { Authorization: `Bearer ${authToken}` } });
|
||||||
|
if (!r.ok) throw new Error('invalid');
|
||||||
|
const d = await r.json();
|
||||||
|
authUser = d.username;
|
||||||
|
localStorage.setItem('shelem_user', authUser);
|
||||||
|
} catch {
|
||||||
|
authToken = null; authUser = null;
|
||||||
|
localStorage.removeItem('shelem_token');
|
||||||
|
localStorage.removeItem('shelem_user');
|
||||||
|
}
|
||||||
|
updateAuthBar();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Boot ─────────────────────────────────────────────────────
|
// ─── Boot ─────────────────────────────────────────────────────
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
initLobby();
|
initLobby();
|
||||||
wireGameEvents();
|
wireGameEvents();
|
||||||
applyBarBottom();
|
applyBarBottom();
|
||||||
|
|
||||||
|
// Drop any stale/corrupted auth token before we use it to connect
|
||||||
|
await validateAuth();
|
||||||
|
|
||||||
// Pre-fill name from auth
|
// Pre-fill name from auth
|
||||||
if (authUser) $('input-name').value = authUser;
|
if (authUser) $('input-name').value = authUser;
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ app.get('/api/config', (_req, res) => {
|
|||||||
res.json({ signupsOpen: config.signupsOpen });
|
res.json({ signupsOpen: config.signupsOpen });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Validate the stored auth token; the client uses this at startup to detect a
|
||||||
|
// stale/corrupted token and drop the fake "logged in" state.
|
||||||
|
app.get('/api/me', requireAuth, (req, res) => {
|
||||||
|
res.json({ id: req.user.id, username: req.user.username });
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/register', (req, res) => {
|
app.post('/api/register', (req, res) => {
|
||||||
if (!config.signupsOpen)
|
if (!config.signupsOpen)
|
||||||
return res.status(403).json({ error: 'New registrations are currently closed.' });
|
return res.status(403).json({ error: 'New registrations are currently closed.' });
|
||||||
|
|||||||
Reference in New Issue
Block a user