AuthorBot Cursor commited on
Commit
c5db49d
·
1 Parent(s): 937692a

Fix false session-expired errors on admin books load.

Browse files

Handle 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 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) { 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>`; }
 
 
 
 
 
 
 
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 and await is_blacklisted(redis, refresh_jti):
94
- raise HTTPException(401, "Refresh token has been revoked")
 
 
 
 
 
 
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: { 'Content-Type': 'application/json' },
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; // 5xx etc — keep tokens, 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.clearSession();
178
- throw new Error('Session expired — please sign in again');
 
 
 
 
 
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.clearSession();
199
- throw new Error('Session expired please sign in again');
 
 
 
 
 
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.clearSession();
248
- throw new Error('Session expired — please sign in again');
 
 
 
 
 
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.clearSession();
265
- throw new Error('Session expired — please sign in again');
 
 
 
 
 
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.clearSession();
281
- throw new Error('Session expired — please sign in again');
 
 
 
 
 
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.clearSession();
296
- throw new Error('Session expired — please sign in again');
 
 
 
 
 
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
  });