101 lines
2.9 KiB
JavaScript
101 lines
2.9 KiB
JavaScript
// Session: the short-lived access token lives in sessionStorage; the refresh token is an
|
|
// httpOnly cookie that the browser sends to /auth/* by itself (script code can't read it).
|
|
|
|
const API = '/api/v1';
|
|
const STORAGE_KEY = 'access';
|
|
// Refresh a bit before the expiry so that a request doesn't go out with a dying token
|
|
const MARGIN_MS = 30_000;
|
|
|
|
function stored() {
|
|
try {
|
|
return JSON.parse(sessionStorage.getItem(STORAGE_KEY));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function store(tokens) {
|
|
const access = { token: tokens.access_token, expiresAt: Date.now() + tokens.expires_in * 1000 };
|
|
try {
|
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(access));
|
|
} catch {
|
|
// sessionStorage is unavailable: the token is then only valid for this call chain
|
|
}
|
|
return access;
|
|
}
|
|
|
|
export function clearSession() {
|
|
try {
|
|
sessionStorage.removeItem(STORAGE_KEY);
|
|
} catch {
|
|
// nothing to clear
|
|
}
|
|
}
|
|
|
|
async function post(path, body) {
|
|
return fetch(API + path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
/** Signs in; throws an Error with `status` (401: wrong email or password). */
|
|
export async function login(email, password) {
|
|
const response = await post('/auth/login/', { email, password, client: 'web' });
|
|
if (!response.ok) {
|
|
const error = new Error(`HTTP ${response.status}`);
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
store(await response.json());
|
|
}
|
|
|
|
// One refresh at a time: the refresh token works once, two parallel requests would
|
|
// make the second one look like a stolen token
|
|
let refreshing = null;
|
|
|
|
/** Exchanges the cookie for a new access token. false: the session is over, sign in again. */
|
|
export function refresh() {
|
|
refreshing ??= (async () => {
|
|
try {
|
|
const response = await post('/auth/refresh/');
|
|
if (!response.ok) {
|
|
clearSession();
|
|
return false;
|
|
}
|
|
store(await response.json());
|
|
return true;
|
|
} finally {
|
|
refreshing = null;
|
|
}
|
|
})();
|
|
return refreshing;
|
|
}
|
|
|
|
/** A valid access token, refreshed when needed; null when there is no session. */
|
|
export async function accessToken() {
|
|
let access = stored();
|
|
if (!access || access.expiresAt - Date.now() < MARGIN_MS) {
|
|
if (!(await refresh())) {
|
|
return null;
|
|
}
|
|
access = stored();
|
|
}
|
|
return access?.token ?? null;
|
|
}
|
|
|
|
export async function logout() {
|
|
try {
|
|
await post('/auth/logout/');
|
|
} finally {
|
|
clearSession();
|
|
}
|
|
}
|
|
|
|
/** Never settles: stops the page code that follows while the browser goes to the login page */
|
|
export function redirectToLogin() {
|
|
const next = location.pathname + location.search;
|
|
location.href = `login.html?next=${encodeURIComponent(next)}`;
|
|
return new Promise(() => {});
|
|
}
|