@@ -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.
2429let accessExpiresAt = null ;
30+ // When to refresh, in this browser's clock (ms).
31+ let refreshAt = null ;
32+ let lastSession = null ;
2533let 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
3271function 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 */
43106export 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).
66141api . 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