Skip to content

Commit 59c3c5e

Browse files
committed
fix axidental logouts
1 parent 013b7b1 commit 59c3c5e

2 files changed

Lines changed: 150 additions & 36 deletions

File tree

‎src/context/AuthProvider.jsx‎

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createContext, useState, useEffect, useCallback, useMemo } from "react";
22
import { openobserveRum } from "@openobserve/browser-rum";
3-
import api, { apiUrl, refreshSession, rememberSession } from "../helper/api";
3+
import api, { apiUrl, isTransientAuthError, refreshSession, rememberSession } from "../helper/api";
44
import SessionExpiredDialog from "../components/utils/SessionExpiredDialog";
55

66
const AuthContext = createContext(null);
@@ -31,6 +31,10 @@ function fromSession(session) {
3131

3232
// Re-read the session (role, block status) this often and whenever the tab becomes visible.
3333
const SESSION_CHECK_MS = 5 * 60 * 1000;
34+
// On page load an unreachable backend is retried a few times before showing the visitor view.
35+
const INITIAL_LOAD_RETRIES_MS = [1000, 3000];
36+
37+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3438

3539
export const AuthProvider = ({ children }) => {
3640
// Tokens live in HttpOnly cookies; this state only holds who is logged in.
@@ -45,30 +49,42 @@ export const AuthProvider = ({ children }) => {
4549
setAuthState((prev) => (Object.keys(next).every((k) => prev[k] === next[k]) ? prev : next));
4650
}, []);
4751

52+
/** Login and logout: also tells the other open tabs. */
4853
const setSession = useCallback((session) => {
49-
rememberSession(session);
54+
rememberSession(session, { broadcast: true });
5055
applySession(session);
5156
}, [applySession]);
5257

58+
const fetchSession = useCallback(async () => {
59+
let data = null;
60+
try {
61+
({ data } = await api.get('/auth/session'));
62+
} catch (err) {
63+
// 401: the access cookie is stale or was rejected. The refresh cookie may still be valid.
64+
if (err.response?.status !== 401) throw err;
65+
}
66+
if (data?.authenticated) {
67+
rememberSession(data);
68+
applySession(data);
69+
return;
70+
}
71+
// No valid access cookie: a refresh cookie may still exist (it is HttpOnly, so ask).
72+
const refreshed = await refreshSession();
73+
applySession(refreshed);
74+
}, [applySession]);
75+
5376
const loadSession = useCallback(async () => {
5477
try {
55-
const { data } = await api.get('/auth/session');
56-
if (data?.authenticated) {
57-
setSession(data);
58-
return;
59-
}
60-
// No valid access cookie: a refresh cookie may still exist (it is HttpOnly, so ask).
61-
setSession(await refreshSession());
62-
} catch {
63-
setSession(null);
64-
} finally {
65-
setReady(true);
78+
await fetchSession();
79+
} catch (err) {
80+
// Offline or backend unavailable: keep the session, the next check retries.
81+
if (!isTransientAuthError(err)) setSession(null);
6682
}
67-
}, [setSession]);
83+
}, [fetchSession, setSession]);
6884

6985
const logout = useCallback(async ({ everywhere = false } = {}) => {
7086
try {
71-
await api.post('/auth/logout', null, { params: everywhere ? { all: true } : undefined });
87+
await api.post('/auth/logout', null, { params: everywhere ? { all: true } : undefined, _skipAuthRetry: true });
7288
} catch (e) {
7389
console.error('Logout error:', e);
7490
} finally {
@@ -79,8 +95,25 @@ export const AuthProvider = ({ children }) => {
7995
useEffect(() => {
8096
// Sessions before the HttpOnly cookies kept tokens in a script-readable cookie; remove it.
8197
document.cookie = 'auth:v1=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict; Secure';
82-
loadSession();
83-
}, [loadSession]);
98+
let cancelled = false;
99+
(async () => {
100+
for (let attempt = 0; ; attempt++) {
101+
try {
102+
await fetchSession();
103+
break;
104+
} catch (err) {
105+
if (!isTransientAuthError(err) || attempt >= INITIAL_LOAD_RETRIES_MS.length) {
106+
if (!cancelled && !isTransientAuthError(err)) setSession(null);
107+
break;
108+
}
109+
await wait(INITIAL_LOAD_RETRIES_MS[attempt]);
110+
if (cancelled) return;
111+
}
112+
}
113+
if (!cancelled) setReady(true);
114+
})();
115+
return () => { cancelled = true; };
116+
}, [fetchSession, setSession]);
84117

85118
// Refreshes triggered by the API layer (expiry, 401) update the state here.
86119
useEffect(() => {
@@ -101,9 +134,12 @@ export const AuthProvider = ({ children }) => {
101134
if (document.visibilityState === 'visible') loadSession();
102135
};
103136
document.addEventListener('visibilitychange', onVisible);
137+
// A check that failed while offline is repeated as soon as the connection is back.
138+
window.addEventListener('online', loadSession);
104139
return () => {
105140
clearInterval(interval);
106141
document.removeEventListener('visibilitychange', onVisible);
142+
window.removeEventListener('online', loadSession);
107143
};
108144
}, [auth.isAuthenticated, loadSession]);
109145

‎src/helper/api.js‎

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,52 @@ const api = axios.create({
2020
headers: { 'X-Requested-With': 'XMLHttpRequest' },
2121
});
2222

23-
// When the current access cookie expires (ms); null while no session is known.
23+
// Refresh this long (at most half the token lifetime) before the access cookie expires.
24+
const REFRESH_MARGIN_MS = 60 * 1000;
25+
// After a refresh failed for a transient reason (offline, 5xx, 429), wait this long before retrying.
26+
const TRANSIENT_RETRY_MS = 10 * 1000;
27+
28+
// When the current access cookie expires, in this browser's clock (ms); null while no session is known.
2429
let accessExpiresAt = null;
30+
// When to refresh, in this browser's clock (ms).
31+
let refreshAt = null;
32+
let lastSession = null;
2533
let refreshPromise = null;
34+
let retryRefreshAfter = 0;
35+
36+
// Tabs share the cookies, so they also share refreshes, logins and logouts.
37+
const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('wr-auth') : null;
38+
39+
/**
40+
* The refresh request failed without ending the session: the browser is offline, the
41+
* backend is restarting or rate-limiting. The cookies are still valid; keep the session.
42+
*/
43+
export function isTransientAuthError(err) {
44+
const status = err?.response?.status;
45+
return !status || status === 429 || status >= 500;
46+
}
2647

27-
/** Remembers the session returned by /auth/login, /auth/oidc, /auth/refresh or /auth/session. */
28-
export function rememberSession(session) {
29-
accessExpiresAt = session?.authenticated ? (session.accessExpiresAt ?? Date.now() + 10 * 60 * 1000) : null;
48+
/**
49+
* Remembers the session returned by /auth/login, /auth/oidc, /auth/refresh or /auth/session.
50+
* The expiry is taken from the remaining lifetime, so a wrong client clock does not matter.
51+
*/
52+
export function rememberSession(session, { broadcast = false, expired = false } = {}) {
53+
if (session?.authenticated) {
54+
const remaining = typeof session.accessExpiresIn === 'number'
55+
? session.accessExpiresIn
56+
: (session.accessExpiresAt ?? Date.now() + 10 * 60 * 1000) - Date.now();
57+
accessExpiresAt = Date.now() + remaining;
58+
refreshAt = accessExpiresAt - Math.min(REFRESH_MARGIN_MS, remaining / 2);
59+
lastSession = session;
60+
retryRefreshAfter = 0;
61+
} else {
62+
accessExpiresAt = null;
63+
refreshAt = null;
64+
lastSession = null;
65+
}
66+
if (broadcast) {
67+
channel?.postMessage({ session: session?.authenticated ? session : { authenticated: false }, accessExpiresAt, refreshAt, expired });
68+
}
3069
}
3170

3271
function announce(session, expired) {
@@ -36,35 +75,72 @@ function announce(session, expired) {
3675
}
3776
}
3877

78+
if (channel) {
79+
channel.onmessage = ({ data }) => {
80+
if (!data?.session) return;
81+
if (data.session.authenticated) {
82+
accessExpiresAt = data.accessExpiresAt;
83+
refreshAt = data.refreshAt;
84+
lastSession = data.session;
85+
retryRefreshAfter = 0;
86+
} else {
87+
accessExpiresAt = null;
88+
refreshAt = null;
89+
lastSession = null;
90+
}
91+
announce(data.session, !!data.expired);
92+
};
93+
}
94+
95+
/** Runs fn while holding a lock shared by all tabs (where the browser supports it). */
96+
function withTabLock(fn) {
97+
return navigator.locks?.request ? navigator.locks.request('wr-auth-refresh', fn) : fn();
98+
}
99+
39100
/**
40101
* Exchanges the refresh cookie for a new access cookie. Concurrent callers share one
41-
* request; the backend rotates the refresh token and tolerates parallel tabs.
102+
* request, and tabs take turns: the backend rotates the refresh token on every use, so
103+
* two tabs presenting the same token in a row could otherwise end the session.
104+
* Rejects with the request error; check isTransientAuthError() before treating it as a logout.
42105
*/
43106
export function refreshSession() {
44107
if (!refreshPromise) {
45-
const hadSession = accessExpiresAt !== null;
46-
refreshPromise = api.post('/auth/refresh', null, { _skipAuthRetry: true })
47-
.then((res) => {
48-
rememberSession(res.data);
49-
announce(res.data, hadSession && !res.data?.authenticated);
108+
const knownExpiry = accessExpiresAt;
109+
refreshPromise = withTabLock(async () => {
110+
// Another tab refreshed while this one waited for the lock: its cookies are ours too.
111+
if (lastSession && accessExpiresAt !== knownExpiry && Date.now() < refreshAt) {
112+
return lastSession;
113+
}
114+
const hadSession = accessExpiresAt !== null;
115+
try {
116+
const res = await api.post('/auth/refresh', null, { _skipAuthRetry: true, timeout: 15000 });
117+
const expired = hadSession && !res.data?.authenticated;
118+
rememberSession(res.data, { broadcast: true, expired });
119+
announce(res.data, expired);
50120
return res.data;
51-
})
52-
.catch((err) => {
53-
rememberSession(null);
121+
} catch (err) {
122+
if (isTransientAuthError(err)) {
123+
retryRefreshAfter = Date.now() + TRANSIENT_RETRY_MS;
124+
throw err;
125+
}
126+
// 401: the backend ended the session and cleared the cookies. 403: blocked or rejected.
127+
const expired = hadSession && err.response?.status !== 403;
128+
rememberSession(null, { broadcast: true, expired });
54129
if (err.response?.status !== 403) {
55-
announce({ authenticated: false }, hadSession);
130+
announce({ authenticated: false }, expired);
56131
}
57132
throw err;
58-
})
59-
.finally(() => { refreshPromise = null; });
133+
}
134+
}).finally(() => { refreshPromise = null; });
60135
}
61136
return refreshPromise;
62137
}
63138

64139
// Refresh shortly before the access cookie expires, so requests never go out
65140
// without it (public endpoints would otherwise silently answer as for a visitor).
66141
api.interceptors.request.use(async (config) => {
67-
if (!config._skipAuthRetry && accessExpiresAt !== null && Date.now() > accessExpiresAt - 30 * 1000) {
142+
if (!config._skipAuthRetry && refreshAt !== null
143+
&& Date.now() >= refreshAt && Date.now() >= retryRefreshAfter) {
68144
try {
69145
await refreshSession();
70146
} catch {
@@ -103,8 +179,10 @@ api.interceptors.response.use(
103179
let session = null;
104180
try {
105181
session = await refreshSession();
106-
} catch {
107-
// Session ended; the backend cleared the cookies.
182+
} catch (refreshError) {
183+
// Offline or backend unavailable: the session may well be fine, report the original error.
184+
if (isTransientAuthError(refreshError)) return Promise.reject(error);
185+
// Otherwise the session ended; the backend cleared the cookies.
108186
}
109187
// With a new session every request is retried. Without one only reads are:
110188
// they succeed as a visitor now that the stale cookie is gone.

0 commit comments

Comments
 (0)