fix: harden auth with refresh token session binding, logging, and router state

- Add session_id to refresh tokens and enforce it during validation,
  preventing stolen refresh tokens from being usable without the
  originating browser session
- Set router.isAuthenticated via auth:login event after successful
  login (previously only set at page load)
- Add console.warn logging to WS message parse/handler errors
- Improve _refreshPromise error handling in token refresh flow
- Document rate limiter in-memory limitation and CSP connect-src
  same-origin requirement
- Add 3 tests for session-bound refresh token validation
This commit is contained in:
2026-08-12 15:53:17 +00:00
parent 76300e281f
commit 6404508519
8 changed files with 95 additions and 38 deletions
+34 -31
View File
@@ -83,41 +83,44 @@ let _refreshPromise = null;
*
* @returns {Promise<boolean>} ``true`` if refresh succeeded
*/
async function tryRefreshToken() {
_refreshPromise = _refreshPromise || (async () => {
try {
const stored = getStoredAuth();
if (!stored.refresh) return false;
async function tryRefreshToken() {
if (!_refreshPromise) {
_refreshPromise = (async () => {
try {
const stored = getStoredAuth();
if (!stored.refresh) return false;
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ refresh_token: stored.refresh }),
credentials: 'same-origin',
});
if (res.status !== 200) {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ refresh_token: stored.refresh, session_id: stored.session_id }),
credentials: 'same-origin',
});
if (res.status !== 200) {
clearAuthTokens();
return false;
}
const json = await res.json();
if (!json.ok || !json.data?.tokens) {
clearAuthTokens();
return false;
}
const tokens = json.data.tokens;
setAuthToken(tokens.access_token);
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
sessionStorage.setItem('vw:session_id', tokens.session_id);
if (json.data.user) {
sessionStorage.setItem('vw:user', JSON.stringify(json.data.user));
}
return true;
} catch (err) {
console.warn('[Auth] Token refresh failed:', err);
clearAuthTokens();
return false;
}
const json = await res.json();
if (!json.ok || !json.data?.tokens) {
clearAuthTokens();
return false;
}
const tokens = json.data.tokens;
setAuthToken(tokens.access_token);
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
sessionStorage.setItem('vw:session_id', tokens.session_id);
if (json.data.user) {
sessionStorage.setItem('vw:user', JSON.stringify(json.data.user));
}
return true;
} catch {
clearAuthTokens();
return false;
}
})();
})();
}
return _refreshPromise.finally(() => { _refreshPromise = null; });
}
+7
View File
@@ -130,6 +130,13 @@ export function handleLoginSuccess(data, redirectPath = '/dashboard') {
}
scheduleTokenRefresh();
}
// Notify app.js that auth is established (used to set router.isAuthenticated)
window.dispatchEvent(new CustomEvent('auth:login', {
detail: {
permissions: sessionStorage.getItem('vw:permissions') ?
JSON.parse(sessionStorage.getItem('vw:permissions')) : {},
},
}));
window.location.hash = redirectPath;
}
+4 -2
View File
@@ -91,7 +91,9 @@ function _wsConnect() {
try {
const msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;
handleMessage(msg);
} catch (_) {}
} catch (err) {
console.warn('[WS] Failed to parse message:', err);
}
};
}
@@ -124,7 +126,7 @@ function handleMessage(msg) {
for (const h of _directHandlers) {
if (h.unsubscribed) continue;
if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) {
try { h.handler(msg); } catch (_) {}
try { h.handler(msg); } catch (err) { console.warn('[WS] Handler error:', err); }
}
}
}