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:
goyban
2026-07-05 19:29:56 +00:00
parent 7c602b8e95
commit 3c26eb4ba5
2 changed files with 50 additions and 7 deletions
+44 -7
View File
@@ -471,10 +471,21 @@ function connectSocket(onReady) {
if (!socket) {
socket = io({ auth: { token: authToken } });
initSocketHandlers();
} else {
// Reuse the socket but make sure it carries the current auth token
socket.auth = { token: authToken };
}
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() {
// Persistent reconnect handler — auto-rejoin after any network drop mid-game
socket.on('connect', () => {
@@ -555,20 +566,23 @@ function initSocketHandlers() {
}
// ─── 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() {
localStorage.setItem('shelem_room', myRoomId);
localStorage.setItem('shelem_seat', mySeat);
localStorage.setItem('shelem_token', myToken);
localStorage.setItem('shelem_room', myRoomId);
localStorage.setItem('shelem_seat', mySeat);
localStorage.setItem('shelem_seat_token', myToken);
}
function clearSession() {
localStorage.removeItem('shelem_room');
localStorage.removeItem('shelem_seat');
localStorage.removeItem('shelem_token');
localStorage.removeItem('shelem_seat_token');
}
function tryRejoin() {
const room = localStorage.getItem('shelem_room');
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;
// Set inside the callback so the persistent 'connect' handler doesn't
// also emit rejoin on the very first connection (would be a duplicate)
@@ -1235,7 +1249,7 @@ async function doLogin() {
localStorage.setItem('shelem_user', authUser);
hide('overlay-auth');
updateAuthBar();
if (socket) socket.auth = { token: authToken };
reauthSocket();
} catch { $('auth-login-error').textContent = 'Network error'; }
}
@@ -1257,6 +1271,7 @@ async function doRegister() {
localStorage.setItem('shelem_user', authUser);
hide('overlay-auth');
updateAuthBar();
reauthSocket();
} 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 ─────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
initLobby();
wireGameEvents();
applyBarBottom();
// Drop any stale/corrupted auth token before we use it to connect
await validateAuth();
// Pre-fill name from auth
if (authUser) $('input-name').value = authUser;
+6
View File
@@ -136,6 +136,12 @@ app.get('/api/config', (_req, res) => {
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) => {
if (!config.signupsOpen)
return res.status(403).json({ error: 'New registrations are currently closed.' });