useSessionSync reloaded on any out-of-tab session change, so a routine refresh in one tab hard-reloaded the others mid-call. Classify the change: removed → reload, user/device changed → reload, same device with a new token → swap it into the running client (setAccessToken + the shared refresh token) in place. The refresher takes a Web Lock and adopts tokens another tab already rotated instead of racing the issuer. Unit-tested classifier. Fixes #16 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
77 lines
3.5 KiB
TypeScript
77 lines
3.5 KiB
TypeScript
import { useEffect } from 'react';
|
|
import type { MatrixClient } from 'matrix-js-sdk';
|
|
import { getFallbackSession, Session, subscribeSessionChanges } from '../state/sessions';
|
|
|
|
/** The credential identity this tab is currently running on. */
|
|
export type SessionIdentity = Pick<Session, 'userId' | 'deviceId' | 'accessToken'>;
|
|
|
|
/**
|
|
* What an out-of-tab session change means for this tab:
|
|
* - `none` — nothing credential-relevant changed (metadata-only rewrite,
|
|
* or a duplicate storage event from the dual-write).
|
|
* - `removed` — the session disappeared (logout / localStorage.clear()).
|
|
* - `relogin` — a different user or device signed in.
|
|
* - `rotated` — same user + device, only the access token changed (an OIDC
|
|
* refresh performed by another tab).
|
|
*/
|
|
export type SessionChange = 'none' | 'removed' | 'relogin' | 'rotated';
|
|
|
|
/** Pure: classify a freshly-read session against the one this tab runs on. */
|
|
export const classifySessionChange = (
|
|
current: SessionIdentity | null,
|
|
next: SessionIdentity | null,
|
|
): SessionChange => {
|
|
if (!next) return current ? 'removed' : 'none';
|
|
if (!current) return 'relogin';
|
|
if (next.userId !== current.userId || next.deviceId !== current.deviceId) return 'relogin';
|
|
if (next.accessToken !== current.accessToken) return 'rotated';
|
|
return 'none';
|
|
};
|
|
|
|
/**
|
|
* Keep this tab in sync with session changes performed in other tabs/windows.
|
|
*
|
|
* The coordinator mounts this once inside the authenticated client shell.
|
|
* `storage` events fire only in tabs that did NOT perform the write, so the
|
|
* callback here always represents an out-of-tab change.
|
|
*
|
|
* Default action is the safest one for auth-critical state — a full reload:
|
|
* - session REMOVED elsewhere (logout / localStorage.clear()) → the access
|
|
* token disappears, so we reload; the router bounces to auth on next boot.
|
|
* - session APPEARED or its user/device CHANGED elsewhere (a fresh login) →
|
|
* we reload so the client re-initialises with the new credentials rather
|
|
* than running on a stale/revoked token.
|
|
*
|
|
* #16 — a same-user, same-device access-token ROTATION (a routine OIDC refresh
|
|
* in another tab) must NOT reload: that would drop in-progress calls/uploads
|
|
* every few minutes. Instead the new tokens are swapped into the running
|
|
* client in place. When no client is available yet we fall back to the reload.
|
|
*
|
|
* A change that does not alter the credentials (e.g. an OIDC metadata-only
|
|
* rewrite) is ignored, which also collapses the several storage events emitted
|
|
* by a single dual-write into at most one reaction.
|
|
*/
|
|
export const useSessionSync = (mx?: MatrixClient): void => {
|
|
useEffect(() => {
|
|
// Snapshot the credential this tab runs on; compare against it so we only
|
|
// react to a genuine credential change. Updated on an in-place rotation.
|
|
let current: SessionIdentity | null = getFallbackSession() ?? null;
|
|
|
|
const unsubscribe = subscribeSessionChanges((session) => {
|
|
const change = classifySessionChange(current, session);
|
|
if (change === 'none') return;
|
|
if (change === 'rotated' && mx && session) {
|
|
// Same opts object backs the SDK's TokenRefresher, so updating the
|
|
// refresh token here keeps its next refresh on the rotated token.
|
|
mx.setAccessToken(session.accessToken);
|
|
if (session.refreshToken) mx.http.opts.refreshToken = session.refreshToken;
|
|
current = session;
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, [mx]);
|
|
};
|