Spaces:
Running
Running
AuthorBot Cursor commited on
Commit ·
c5db49d
1
Parent(s): 937692a
Fix false session-expired errors on admin books load.
Browse filesHandle HF cold-start and multi-tab refresh races without clearing tokens, fail-open Redis on refresh blacklist check, and redirect to login instead of inline books error.
Co-authored-by: Cursor <cursoragent@cursor.com>
- app/admin/templates/admin.html +22 -1
- app/api/schemas_router.py +8 -2
- static/auth-client.js +93 -18
app/admin/templates/admin.html
CHANGED
|
@@ -1597,11 +1597,25 @@
|
|
| 1597 |
<script>
|
| 1598 |
/* ── GLOBALS ── */
|
| 1599 |
const API = window.location.origin + '/api';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1600 |
const auth = new AuthorBotAuthClient({
|
| 1601 |
apiBase: API,
|
| 1602 |
accessKey: 'access_token',
|
| 1603 |
refreshKey: 'refresh_token',
|
| 1604 |
slugKey: 'author_slug',
|
|
|
|
| 1605 |
});
|
| 1606 |
let token = auth.getAccessToken();
|
| 1607 |
let authorSlug = auth.getAuthorSlug();
|
|
@@ -1851,7 +1865,14 @@ async function loadBooks() {
|
|
| 1851 |
const data = await apiGet(`/admin/${authorSlug}/books`);
|
| 1852 |
allBooks = data.books || [];
|
| 1853 |
renderBooks(allBooks);
|
| 1854 |
-
} catch(e) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1855 |
}
|
| 1856 |
function renderBooks(books) {
|
| 1857 |
const tbody = document.getElementById('books-body');
|
|
|
|
| 1597 |
<script>
|
| 1598 |
/* ── GLOBALS ── */
|
| 1599 |
const API = window.location.origin + '/api';
|
| 1600 |
+
|
| 1601 |
+
function handleSessionExpired() {
|
| 1602 |
+
token = null;
|
| 1603 |
+
authorSlug = null;
|
| 1604 |
+
document.getElementById('auth-screen').style.display = '';
|
| 1605 |
+
document.getElementById('dashboard').style.display = 'none';
|
| 1606 |
+
const msg = document.getElementById('login-msg');
|
| 1607 |
+
if (msg) {
|
| 1608 |
+
msg.textContent = 'Your session expired. Please sign in again.';
|
| 1609 |
+
msg.style.color = 'var(--red)';
|
| 1610 |
+
}
|
| 1611 |
+
}
|
| 1612 |
+
|
| 1613 |
const auth = new AuthorBotAuthClient({
|
| 1614 |
apiBase: API,
|
| 1615 |
accessKey: 'access_token',
|
| 1616 |
refreshKey: 'refresh_token',
|
| 1617 |
slugKey: 'author_slug',
|
| 1618 |
+
onSessionExpired: handleSessionExpired,
|
| 1619 |
});
|
| 1620 |
let token = auth.getAccessToken();
|
| 1621 |
let authorSlug = auth.getAuthorSlug();
|
|
|
|
| 1865 |
const data = await apiGet(`/admin/${authorSlug}/books`);
|
| 1866 |
allBooks = data.books || [];
|
| 1867 |
renderBooks(allBooks);
|
| 1868 |
+
} catch(e) {
|
| 1869 |
+
if (e.message.includes('Session expired')) return;
|
| 1870 |
+
if (e.message.includes('Cannot reach server')) {
|
| 1871 |
+
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--amber);text-align:center;padding:40px;"><div style="font-size:32px;margin-bottom:12px;">⏳</div>Server is waking up<div style="font-size:12px;color:var(--light);margin-top:6px;">${e.message}</div></td></tr>`;
|
| 1872 |
+
return;
|
| 1873 |
+
}
|
| 1874 |
+
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--red);text-align:center;padding:40px;"><div style="font-size:32px;margin-bottom:12px;">⚠️</div>Failed to load books<div style="font-size:12px;color:var(--light);margin-top:6px;">${e.message}</div></td></tr>`;
|
| 1875 |
+
}
|
| 1876 |
}
|
| 1877 |
function renderBooks(books) {
|
| 1878 |
const tbody = document.getElementById('books-body');
|
app/api/schemas_router.py
CHANGED
|
@@ -90,8 +90,14 @@ async def refresh(
|
|
| 90 |
if refresh_payload.get("type") != "refresh":
|
| 91 |
raise HTTPException(401, "Not a refresh token")
|
| 92 |
refresh_jti = refresh_payload.get("jti")
|
| 93 |
-
if refresh_jti
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
except HTTPException:
|
| 96 |
raise
|
| 97 |
except Exception:
|
|
|
|
| 90 |
if refresh_payload.get("type") != "refresh":
|
| 91 |
raise HTTPException(401, "Not a refresh token")
|
| 92 |
refresh_jti = refresh_payload.get("jti")
|
| 93 |
+
if refresh_jti:
|
| 94 |
+
try:
|
| 95 |
+
if await is_blacklisted(redis, refresh_jti):
|
| 96 |
+
raise HTTPException(401, "Refresh token has been revoked")
|
| 97 |
+
except HTTPException:
|
| 98 |
+
raise
|
| 99 |
+
except Exception:
|
| 100 |
+
pass # Redis down: fail-open (same as access-token blacklist check)
|
| 101 |
except HTTPException:
|
| 102 |
raise
|
| 103 |
except Exception:
|
static/auth-client.js
CHANGED
|
@@ -32,7 +32,16 @@
|
|
| 32 |
this.accessKey = options.accessKey || 'access_token';
|
| 33 |
this.refreshKey = options.refreshKey || 'refresh_token';
|
| 34 |
this.slugKey = options.slugKey || null;
|
|
|
|
| 35 |
this._refreshing = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
AuthClient.prototype.getAccessToken = function () {
|
|
@@ -59,31 +68,67 @@
|
|
| 59 |
if (this.slugKey) localStorage.removeItem(this.slugKey);
|
| 60 |
};
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
AuthClient.prototype.hasSession = function () {
|
| 63 |
return !!(this.getAccessToken() || this.getRefreshToken());
|
| 64 |
};
|
| 65 |
|
| 66 |
-
AuthClient.prototype.refreshAccessToken = function () {
|
| 67 |
var self = this;
|
|
|
|
| 68 |
if (this._refreshing) return this._refreshing;
|
| 69 |
|
| 70 |
-
var refresh = this.getRefreshToken();
|
| 71 |
if (!refresh) return Promise.resolve(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
var body = JSON.stringify({ refresh_token: refresh });
|
| 73 |
|
| 74 |
this._refreshing = fetch(this.apiBase + '/auth/refresh', Object.assign({}, FETCH_OPTS, {
|
| 75 |
method: 'POST',
|
| 76 |
-
headers:
|
| 77 |
body: body,
|
| 78 |
}))
|
| 79 |
.then(function (res) {
|
| 80 |
-
// Only clear session on explicit auth rejection (401/403).
|
| 81 |
-
// 5xx / network errors = server cold-starting — keep tokens and retry later.
|
| 82 |
if (res.status === 401 || res.status === 403) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
self.clearSession();
|
| 84 |
return false;
|
| 85 |
}
|
| 86 |
-
if (!res.ok) return false;
|
| 87 |
return res.json();
|
| 88 |
})
|
| 89 |
.then(function (data) {
|
|
@@ -174,8 +219,13 @@
|
|
| 174 |
|
| 175 |
return this.ensureValidAccessToken().then(function (ok) {
|
| 176 |
if (!ok) {
|
| 177 |
-
self.
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
}
|
| 180 |
|
| 181 |
var token = self.getAccessToken();
|
|
@@ -195,8 +245,13 @@
|
|
| 195 |
if (res.status === 401 && !retried) {
|
| 196 |
return self.refreshAccessToken().then(function (refreshed) {
|
| 197 |
if (!refreshed) {
|
| 198 |
-
self.
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
}
|
| 201 |
return self.request(method, path, body, true);
|
| 202 |
});
|
|
@@ -244,8 +299,13 @@
|
|
| 244 |
|
| 245 |
return this.ensureValidAccessToken().then(function (ok) {
|
| 246 |
if (!ok) {
|
| 247 |
-
self.
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
}
|
| 250 |
|
| 251 |
var token = self.getAccessToken();
|
|
@@ -261,8 +321,13 @@
|
|
| 261 |
if (res.status === 401 && !retried) {
|
| 262 |
return self.refreshAccessToken().then(function (refreshed) {
|
| 263 |
if (!refreshed) {
|
| 264 |
-
self.
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
}
|
| 267 |
return self.upload(path, formData, true);
|
| 268 |
});
|
|
@@ -277,8 +342,13 @@
|
|
| 277 |
|
| 278 |
return this.ensureValidAccessToken().then(function (ok) {
|
| 279 |
if (!ok) {
|
| 280 |
-
self.
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
}
|
| 283 |
|
| 284 |
var token = self.getAccessToken();
|
|
@@ -292,8 +362,13 @@
|
|
| 292 |
if (res.status === 401 && !retried) {
|
| 293 |
return self.refreshAccessToken().then(function (refreshed) {
|
| 294 |
if (!refreshed) {
|
| 295 |
-
self.
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
}
|
| 298 |
return self.download(path, true);
|
| 299 |
});
|
|
|
|
| 32 |
this.accessKey = options.accessKey || 'access_token';
|
| 33 |
this.refreshKey = options.refreshKey || 'refresh_token';
|
| 34 |
this.slugKey = options.slugKey || null;
|
| 35 |
+
this.onSessionExpired = options.onSessionExpired || null;
|
| 36 |
this._refreshing = null;
|
| 37 |
+
var self = this;
|
| 38 |
+
if (typeof window !== 'undefined') {
|
| 39 |
+
window.addEventListener('storage', function (e) {
|
| 40 |
+
if (e.key === self.accessKey || e.key === self.refreshKey) {
|
| 41 |
+
self._refreshing = null;
|
| 42 |
+
}
|
| 43 |
+
});
|
| 44 |
+
}
|
| 45 |
}
|
| 46 |
|
| 47 |
AuthClient.prototype.getAccessToken = function () {
|
|
|
|
| 68 |
if (this.slugKey) localStorage.removeItem(this.slugKey);
|
| 69 |
};
|
| 70 |
|
| 71 |
+
AuthClient.prototype._notifySessionExpired = function () {
|
| 72 |
+
if (typeof this.onSessionExpired === 'function') {
|
| 73 |
+
try { this.onSessionExpired(); } catch (e) { /* ignore */ }
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
AuthClient.prototype._hasValidAccessInStorage = function () {
|
| 78 |
+
return !!this.getAccessToken() && !isAccessTokenExpired(this.getAccessToken());
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
AuthClient.prototype._sessionGone = function () {
|
| 82 |
+
this.clearSession();
|
| 83 |
+
this._notifySessionExpired();
|
| 84 |
+
throw new Error('Session expired — please sign in again');
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
AuthClient.prototype._transientAuthFailure = function () {
|
| 88 |
+
if (this.getRefreshToken()) {
|
| 89 |
+
throw new Error('Cannot reach server. Your session is still active — try again shortly.');
|
| 90 |
+
}
|
| 91 |
+
return this._sessionGone();
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
AuthClient.prototype.hasSession = function () {
|
| 95 |
return !!(this.getAccessToken() || this.getRefreshToken());
|
| 96 |
};
|
| 97 |
|
| 98 |
+
AuthClient.prototype.refreshAccessToken = function (opts) {
|
| 99 |
var self = this;
|
| 100 |
+
opts = opts || {};
|
| 101 |
if (this._refreshing) return this._refreshing;
|
| 102 |
|
| 103 |
+
var refresh = opts.refreshToken || this.getRefreshToken();
|
| 104 |
if (!refresh) return Promise.resolve(false);
|
| 105 |
+
|
| 106 |
+
var access = this.getAccessToken();
|
| 107 |
+
var headers = { 'Content-Type': 'application/json' };
|
| 108 |
+
if (access) headers.Authorization = 'Bearer ' + access;
|
| 109 |
var body = JSON.stringify({ refresh_token: refresh });
|
| 110 |
|
| 111 |
this._refreshing = fetch(this.apiBase + '/auth/refresh', Object.assign({}, FETCH_OPTS, {
|
| 112 |
method: 'POST',
|
| 113 |
+
headers: headers,
|
| 114 |
body: body,
|
| 115 |
}))
|
| 116 |
.then(function (res) {
|
|
|
|
|
|
|
| 117 |
if (res.status === 401 || res.status === 403) {
|
| 118 |
+
if (!opts.storageRetry) {
|
| 119 |
+
var latestRefresh = localStorage.getItem(self.refreshKey);
|
| 120 |
+
var latestAccess = localStorage.getItem(self.accessKey);
|
| 121 |
+
if (latestRefresh && latestRefresh !== refresh) {
|
| 122 |
+
return self.refreshAccessToken({ refreshToken: latestRefresh, storageRetry: true });
|
| 123 |
+
}
|
| 124 |
+
if (latestAccess && latestAccess !== access && !isAccessTokenExpired(latestAccess)) {
|
| 125 |
+
return true;
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
self.clearSession();
|
| 129 |
return false;
|
| 130 |
}
|
| 131 |
+
if (!res.ok) return false;
|
| 132 |
return res.json();
|
| 133 |
})
|
| 134 |
.then(function (data) {
|
|
|
|
| 219 |
|
| 220 |
return this.ensureValidAccessToken().then(function (ok) {
|
| 221 |
if (!ok) {
|
| 222 |
+
if (self._hasValidAccessInStorage()) {
|
| 223 |
+
return;
|
| 224 |
+
}
|
| 225 |
+
if (!self.getRefreshToken()) {
|
| 226 |
+
return self._sessionGone();
|
| 227 |
+
}
|
| 228 |
+
return self._transientAuthFailure();
|
| 229 |
}
|
| 230 |
|
| 231 |
var token = self.getAccessToken();
|
|
|
|
| 245 |
if (res.status === 401 && !retried) {
|
| 246 |
return self.refreshAccessToken().then(function (refreshed) {
|
| 247 |
if (!refreshed) {
|
| 248 |
+
if (self._hasValidAccessInStorage()) {
|
| 249 |
+
return self.request(method, path, body, true);
|
| 250 |
+
}
|
| 251 |
+
if (!self.getRefreshToken()) {
|
| 252 |
+
return self._sessionGone();
|
| 253 |
+
}
|
| 254 |
+
return self._transientAuthFailure();
|
| 255 |
}
|
| 256 |
return self.request(method, path, body, true);
|
| 257 |
});
|
|
|
|
| 299 |
|
| 300 |
return this.ensureValidAccessToken().then(function (ok) {
|
| 301 |
if (!ok) {
|
| 302 |
+
if (self._hasValidAccessInStorage()) {
|
| 303 |
+
return;
|
| 304 |
+
}
|
| 305 |
+
if (!self.getRefreshToken()) {
|
| 306 |
+
return self._sessionGone();
|
| 307 |
+
}
|
| 308 |
+
return self._transientAuthFailure();
|
| 309 |
}
|
| 310 |
|
| 311 |
var token = self.getAccessToken();
|
|
|
|
| 321 |
if (res.status === 401 && !retried) {
|
| 322 |
return self.refreshAccessToken().then(function (refreshed) {
|
| 323 |
if (!refreshed) {
|
| 324 |
+
if (self._hasValidAccessInStorage()) {
|
| 325 |
+
return self.upload(path, formData, true);
|
| 326 |
+
}
|
| 327 |
+
if (!self.getRefreshToken()) {
|
| 328 |
+
return self._sessionGone();
|
| 329 |
+
}
|
| 330 |
+
return self._transientAuthFailure();
|
| 331 |
}
|
| 332 |
return self.upload(path, formData, true);
|
| 333 |
});
|
|
|
|
| 342 |
|
| 343 |
return this.ensureValidAccessToken().then(function (ok) {
|
| 344 |
if (!ok) {
|
| 345 |
+
if (self._hasValidAccessInStorage()) {
|
| 346 |
+
return;
|
| 347 |
+
}
|
| 348 |
+
if (!self.getRefreshToken()) {
|
| 349 |
+
return self._sessionGone();
|
| 350 |
+
}
|
| 351 |
+
return self._transientAuthFailure();
|
| 352 |
}
|
| 353 |
|
| 354 |
var token = self.getAccessToken();
|
|
|
|
| 362 |
if (res.status === 401 && !retried) {
|
| 363 |
return self.refreshAccessToken().then(function (refreshed) {
|
| 364 |
if (!refreshed) {
|
| 365 |
+
if (self._hasValidAccessInStorage()) {
|
| 366 |
+
return self.download(path, true);
|
| 367 |
+
}
|
| 368 |
+
if (!self.getRefreshToken()) {
|
| 369 |
+
return self._sessionGone();
|
| 370 |
+
}
|
| 371 |
+
return self._transientAuthFailure();
|
| 372 |
}
|
| 373 |
return self.download(path, true);
|
| 374 |
});
|