Arag / static /auth-client.js
AuthorBot
Fix false session-expired errors on admin books load.
c5db49d
Raw
History Blame Contribute Delete
13.7 kB
/**
* AuthorBot Auth Client — shared by admin + superadmin SPAs.
* Stores access + refresh tokens in localStorage, httponly refresh cookie as backup.
* Auto-refreshes on load and on 401 — survives page refresh and tab idle.
*/
(function (global) {
'use strict';
var FETCH_OPTS = { credentials: 'include' };
function decodeJwtPayload(token) {
try {
var part = token.split('.')[1];
if (!part) return null;
var json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(json);
} catch (e) {
return null;
}
}
function isAccessTokenExpired(token, skewSeconds) {
skewSeconds = skewSeconds || 60;
if (!token) return true;
var payload = decodeJwtPayload(token);
if (!payload || !payload.exp) return true;
return (payload.exp - skewSeconds) <= Date.now() / 1000;
}
function AuthClient(options) {
this.apiBase = options.apiBase;
this.accessKey = options.accessKey || 'access_token';
this.refreshKey = options.refreshKey || 'refresh_token';
this.slugKey = options.slugKey || null;
this.onSessionExpired = options.onSessionExpired || null;
this._refreshing = null;
var self = this;
if (typeof window !== 'undefined') {
window.addEventListener('storage', function (e) {
if (e.key === self.accessKey || e.key === self.refreshKey) {
self._refreshing = null;
}
});
}
}
AuthClient.prototype.getAccessToken = function () {
return localStorage.getItem(this.accessKey);
};
AuthClient.prototype.getRefreshToken = function () {
return localStorage.getItem(this.refreshKey);
};
AuthClient.prototype.getAuthorSlug = function () {
return this.slugKey ? localStorage.getItem(this.slugKey) : null;
};
AuthClient.prototype.saveSession = function (data) {
if (data.access_token) localStorage.setItem(this.accessKey, data.access_token);
if (data.refresh_token) localStorage.setItem(this.refreshKey, data.refresh_token);
if (this.slugKey && data.author_slug) localStorage.setItem(this.slugKey, data.author_slug);
};
AuthClient.prototype.clearSession = function () {
localStorage.removeItem(this.accessKey);
localStorage.removeItem(this.refreshKey);
if (this.slugKey) localStorage.removeItem(this.slugKey);
};
AuthClient.prototype._notifySessionExpired = function () {
if (typeof this.onSessionExpired === 'function') {
try { this.onSessionExpired(); } catch (e) { /* ignore */ }
}
};
AuthClient.prototype._hasValidAccessInStorage = function () {
return !!this.getAccessToken() && !isAccessTokenExpired(this.getAccessToken());
};
AuthClient.prototype._sessionGone = function () {
this.clearSession();
this._notifySessionExpired();
throw new Error('Session expired — please sign in again');
};
AuthClient.prototype._transientAuthFailure = function () {
if (this.getRefreshToken()) {
throw new Error('Cannot reach server. Your session is still active — try again shortly.');
}
return this._sessionGone();
};
AuthClient.prototype.hasSession = function () {
return !!(this.getAccessToken() || this.getRefreshToken());
};
AuthClient.prototype.refreshAccessToken = function (opts) {
var self = this;
opts = opts || {};
if (this._refreshing) return this._refreshing;
var refresh = opts.refreshToken || this.getRefreshToken();
if (!refresh) return Promise.resolve(false);
var access = this.getAccessToken();
var headers = { 'Content-Type': 'application/json' };
if (access) headers.Authorization = 'Bearer ' + access;
var body = JSON.stringify({ refresh_token: refresh });
this._refreshing = fetch(this.apiBase + '/auth/refresh', Object.assign({}, FETCH_OPTS, {
method: 'POST',
headers: headers,
body: body,
}))
.then(function (res) {
if (res.status === 401 || res.status === 403) {
if (!opts.storageRetry) {
var latestRefresh = localStorage.getItem(self.refreshKey);
var latestAccess = localStorage.getItem(self.accessKey);
if (latestRefresh && latestRefresh !== refresh) {
return self.refreshAccessToken({ refreshToken: latestRefresh, storageRetry: true });
}
if (latestAccess && latestAccess !== access && !isAccessTokenExpired(latestAccess)) {
return true;
}
}
self.clearSession();
return false;
}
if (!res.ok) return false;
return res.json();
})
.then(function (data) {
if (!data || !data.access_token) return false;
self.saveSession(data);
return true;
})
.catch(function () {
// Network error — do NOT clear session. User may just be offline.
return false;
})
.finally(function () { self._refreshing = null; });
return this._refreshing;
};
AuthClient.prototype.ensureValidAccessToken = function () {
var self = this;
var access = this.getAccessToken();
var refresh = this.getRefreshToken();
if (access && !isAccessTokenExpired(access)) {
return Promise.resolve(true);
}
if (!refresh) {
return Promise.resolve(false);
}
return this.refreshAccessToken();
};
AuthClient.prototype.restoreSession = function (validateFn) {
var self = this;
if (!this.hasSession()) {
return Promise.resolve(false);
}
function loadProfile() {
return self.get('/auth/me').then(function (user) {
if (self.slugKey && user && user.id) {
localStorage.setItem(self.slugKey, user.id);
}
if (validateFn && !validateFn(user)) {
return false;
}
return true;
});
}
return this.ensureValidAccessToken()
.then(function (ok) {
if (!ok) {
// Token refresh returned false — but only clear if refresh token itself is gone
// or server explicitly rejected it (401). Transient failures return false too,
// so we check: if we still have a refresh token, keep session and let user retry.
if (!self.getRefreshToken()) {
self.clearSession();
return false;
}
// Refresh token exists but refresh failed (server cold-starting?) — keep session.
// The access token may still be valid; try loading profile anyway.
var access = self.getAccessToken();
if (!access) return false;
return loadProfile();
}
return loadProfile();
})
.catch(function (err) {
// Network error / server down — DON'T clear session.
// Tokens are still valid; user just can't reach the server right now.
var msg = (err && err.message) || '';
var isAuthError = msg.indexOf('Session expired') !== -1
|| msg.indexOf('401') !== -1
|| msg.indexOf('403') !== -1;
if (isAuthError) {
// Explicit auth rejection — clear session
self.clearSession();
return false;
}
// Transient error (502, network, timeout, cold start) — keep session alive.
console.warn('restoreSession: transient error, keeping session:', msg);
return true;
});
};
AuthClient.prototype.request = function (method, path, body, retried) {
var self = this;
return this.ensureValidAccessToken().then(function (ok) {
if (!ok) {
if (self._hasValidAccessInStorage()) {
return;
}
if (!self.getRefreshToken()) {
return self._sessionGone();
}
return self._transientAuthFailure();
}
var token = self.getAccessToken();
var headers = { Authorization: 'Bearer ' + token };
var opts = Object.assign({}, FETCH_OPTS, { method: method, headers: headers });
if (body !== undefined && body !== null) {
headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
return fetch(self.apiBase + path, opts)
.catch(function () {
throw new Error('Cannot reach server. Check your connection and try again.');
})
.then(function (res) {
if (res.status === 401 && !retried) {
return self.refreshAccessToken().then(function (refreshed) {
if (!refreshed) {
if (self._hasValidAccessInStorage()) {
return self.request(method, path, body, true);
}
if (!self.getRefreshToken()) {
return self._sessionGone();
}
return self._transientAuthFailure();
}
return self.request(method, path, body, true);
});
}
return res;
});
});
};
AuthClient.prototype._parse = function (res) {
return res.text().then(function (text) {
var data = {};
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { detail: text }; }
if (!res.ok) {
var detail = data.detail;
if (Array.isArray(detail)) detail = detail.map(function (e) { return e.msg || JSON.stringify(e); }).join(', ');
throw new Error(detail || ('HTTP ' + res.status));
}
return data;
});
};
AuthClient.prototype.get = function (path) {
return this.request('GET', path).then(this._parse.bind(this));
};
AuthClient.prototype.post = function (path, body) {
return this.request('POST', path, body).then(this._parse.bind(this));
};
AuthClient.prototype.put = function (path, body) {
return this.request('PUT', path, body).then(this._parse.bind(this));
};
AuthClient.prototype.patch = function (path, body) {
return this.request('PATCH', path, body).then(this._parse.bind(this));
};
AuthClient.prototype.delete = function (path) {
return this.request('DELETE', path).then(this._parse.bind(this));
};
AuthClient.prototype.upload = function (path, formData, retried) {
var self = this;
return this.ensureValidAccessToken().then(function (ok) {
if (!ok) {
if (self._hasValidAccessInStorage()) {
return;
}
if (!self.getRefreshToken()) {
return self._sessionGone();
}
return self._transientAuthFailure();
}
var token = self.getAccessToken();
return fetch(self.apiBase + path, Object.assign({}, FETCH_OPTS, {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: formData,
}))
.catch(function () {
throw new Error('Cannot reach server. Check your connection and try again.');
})
.then(function (res) {
if (res.status === 401 && !retried) {
return self.refreshAccessToken().then(function (refreshed) {
if (!refreshed) {
if (self._hasValidAccessInStorage()) {
return self.upload(path, formData, true);
}
if (!self.getRefreshToken()) {
return self._sessionGone();
}
return self._transientAuthFailure();
}
return self.upload(path, formData, true);
});
}
return res;
});
});
};
AuthClient.prototype.download = function (path, retried) {
var self = this;
return this.ensureValidAccessToken().then(function (ok) {
if (!ok) {
if (self._hasValidAccessInStorage()) {
return;
}
if (!self.getRefreshToken()) {
return self._sessionGone();
}
return self._transientAuthFailure();
}
var token = self.getAccessToken();
return fetch(self.apiBase + path, Object.assign({}, FETCH_OPTS, {
headers: { Authorization: 'Bearer ' + token },
}))
.catch(function () {
throw new Error('Cannot reach server. Check your connection and try again.');
})
.then(function (res) {
if (res.status === 401 && !retried) {
return self.refreshAccessToken().then(function (refreshed) {
if (!refreshed) {
if (self._hasValidAccessInStorage()) {
return self.download(path, true);
}
if (!self.getRefreshToken()) {
return self._sessionGone();
}
return self._transientAuthFailure();
}
return self.download(path, true);
});
}
return res;
});
});
};
AuthClient.prototype.login = function (email, password) {
var self = this;
return fetch(this.apiBase + '/auth/login', Object.assign({}, FETCH_OPTS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, password: password }),
}))
.then(function (res) { return res.json().then(function (data) { return { res: res, data: data }; }); })
.then(function (_ref) {
var res = _ref.res, data = _ref.data;
if (!res.ok) {
var detail = data.detail;
if (Array.isArray(detail)) detail = detail.map(function (e) { return e.msg || JSON.stringify(e); }).join(', ');
throw new Error(detail || 'Login failed');
}
self.saveSession(data);
return data;
});
};
AuthClient.prototype.logout = function () {
var self = this;
return this.post('/auth/logout').catch(function () {}).finally(function () {
self.clearSession();
});
};
global.AuthorBotAuthClient = AuthClient;
})(window);