From a2b1b262cbefc2be8dc3f87d4a987f0f096c356e Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 14 Apr 2026 05:10:16 +0200 Subject: [PATCH] Fix: revoked sessions now actually log the other device out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server-side revoke was always working — it deletes user_sessions rows, and middleware correctly returned 401 on the revoked device's next /api/* request. The bug was entirely client-side: individual fetch handlers swallowed the 401 (rendering "no sessions found" or empty data) and nothing redirected to the login screen. So the revoked device looked like it stayed signed in. Added public/js/authFetch.js: a global fetch interceptor that watches every /api/* response. On 401 from a non-auth endpoint (i.e. not /login, /register, /logout, /me, etc.), it clears any cached token/user state and reloads the page. The reload's boot flow lands on /api/auth/me → 401 → login screen as usual. Guarded against false positives: only triggers when the app believes the user is currently logged in (AUTH_TOKEN set or main-app visible) so a pre-login 401 doesn't accidentally flash the screen. Loaded before auth.js in index.html. --- public/index.html | 1 + public/js/authFetch.js | 85 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 public/js/authFetch.js diff --git a/public/index.html b/public/index.html index e1104db..7db03c8 100644 --- a/public/index.html +++ b/public/index.html @@ -429,6 +429,7 @@ + diff --git a/public/js/authFetch.js b/public/js/authFetch.js new file mode 100644 index 0000000..2d957e7 --- /dev/null +++ b/public/js/authFetch.js @@ -0,0 +1,85 @@ +// ============================================================ +// Global fetch interceptor for auth failures +// ============================================================ +// When the server responds 401 to any /api/ request, the user's +// session has been revoked or expired. Previously this would leave +// the app UI on screen silently because each fetch handled its own +// errors. Now we detect it once, clear local state, and bounce to +// the login screen. +// ============================================================ + +(function() { + if (window.__fetchAuthIntercepted) return; + window.__fetchAuthIntercepted = true; + + var _fetch = window.fetch.bind(window); + var handlingLogout = false; + + function handleAuthFailure(status, reason) { + if (handlingLogout) return; + handlingLogout = true; + try { + // Clear any cached token + user info + window.AUTH_TOKEN = null; + window.CURRENT_USER = null; + if (window.SecureStorage) { + try { window.SecureStorage.remove('ped_scribe_token'); } catch(e) {} + try { window.SecureStorage.remove('ped_scribe_user'); } catch(e) {} + try { window.SecureStorage.remove('ped_session_id'); } catch(e) {} + } + try { + localStorage.removeItem('ped_scribe_token'); + localStorage.removeItem('ped_scribe_user'); + localStorage.removeItem('ped_session_id'); + } catch(e) {} + + // Brief user-visible notice before the reload swap + try { + if (typeof window.showToast === 'function') { + window.showToast(reason || 'Your session has ended. Please sign in again.', 'info'); + } + } catch(e) {} + + // Reload — the boot flow in auth.js will call /api/auth/me, get a + // fresh 401 (no cookie / no token), and show the login screen. + setTimeout(function() { window.location.reload(); }, 800); + } catch (e) { + // Last resort + window.location.reload(); + } + } + + window.fetch = function(input, init) { + return _fetch(input, init).then(function(resp) { + // Only act on same-origin /api/ calls; don't interfere with login + // or logout endpoints themselves (they're allowed to return 401). + try { + var url = typeof input === 'string' ? input : (input && input.url) || ''; + var isApiCall = url.indexOf('/api/') !== -1; + var isAuthEndpoint = url.indexOf('/api/auth/login') !== -1 + || url.indexOf('/api/auth/register') !== -1 + || url.indexOf('/api/auth/logout') !== -1 + || url.indexOf('/api/auth/forgot-password') !== -1 + || url.indexOf('/api/auth/reset-password') !== -1 + || url.indexOf('/api/auth/verify-email') !== -1 + || url.indexOf('/api/auth/resend-verification') !== -1 + || url.indexOf('/api/auth/oidc') !== -1 + || url.indexOf('/api/auth/me') !== -1 + || url.indexOf('/api/auth/2fa') !== -1; + if (resp.status === 401 && isApiCall && !isAuthEndpoint) { + // Only treat as global session failure if the app thinks the user + // was logged in (AUTH_TOKEN set OR main-app visible). Avoid + // flashing the login screen on public-endpoint 401s during the + // pre-login phase. + var authed = !!window.AUTH_TOKEN + || document.documentElement.classList.contains('has-session') + || (document.getElementById('main-app') && document.getElementById('main-app').style.display === 'block'); + if (authed) { + handleAuthFailure(resp.status, 'Your session was ended by another device. Please sign in again.'); + } + } + } catch(e) {} + return resp; + }); + }; +})();