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
101 lines
4.0 KiB
TypeScript
101 lines
4.0 KiB
TypeScript
import { AccessTokens, OidcTokenRefresher } from 'matrix-js-sdk';
|
|
import type { IdTokenClaims } from 'oidc-client-ts';
|
|
import { getFallbackSession, OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
|
|
|
|
// Web Lock name serialising OIDC refreshes across tabs (Gitea #16). Every tab
|
|
// runs its own refresher against the SAME stored refresh token; with rotating
|
|
// refresh tokens the second tab to hit the issuer gets `invalid_grant` and is
|
|
// signed out. Holding the lock while refreshing (and re-reading storage once
|
|
// inside it) makes the loser adopt the winner's tokens instead.
|
|
const REFRESH_LOCK_NAME = 'lotus-oidc-refresh';
|
|
|
|
/**
|
|
* Run `fn` under the cross-tab refresh lock. Falls back to running it directly
|
|
* when the Web Locks API is unavailable (older browsers, non-secure contexts).
|
|
*/
|
|
export const withRefreshLock = <T>(fn: () => Promise<T>): Promise<T> => {
|
|
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
|
|
if (!locks) return fn();
|
|
return locks.request(REFRESH_LOCK_NAME, fn);
|
|
};
|
|
|
|
/**
|
|
* OidcTokenRefresher that persists rotated tokens back to the fallback session,
|
|
* so a page reload keeps the freshest access/refresh token. The matrix client
|
|
* calls this automatically (reactively on a 401) when a refresh token is set.
|
|
*/
|
|
export class LotusOidcTokenRefresher extends OidcTokenRefresher {
|
|
private readonly deviceIdRef: string;
|
|
|
|
private readonly userIdRef: string;
|
|
|
|
private readonly baseUrlRef: string;
|
|
|
|
private readonly oidcRef: OidcSessionMeta;
|
|
|
|
constructor(oidc: OidcSessionMeta, deviceId: string, userId: string, baseUrl: string) {
|
|
super(
|
|
oidc.issuer,
|
|
oidc.clientId,
|
|
oidc.redirectUri,
|
|
deviceId,
|
|
(oidc.idTokenClaims ?? {}) as unknown as IdTokenClaims,
|
|
);
|
|
this.deviceIdRef = deviceId;
|
|
this.userIdRef = userId;
|
|
this.baseUrlRef = baseUrl;
|
|
this.oidcRef = oidc;
|
|
}
|
|
|
|
// #16 — before touching the issuer, check whether another tab already rotated
|
|
// the tokens. `refreshToken` is exactly what the SDK currently holds, so a
|
|
// DIFFERENT stored refresh token (same user + device) means a sibling tab won
|
|
// the race: adopt its tokens instead of burning a possibly-consumed refresh
|
|
// token. Comparing refresh tokens (not access tokens) can never adopt the very
|
|
// token that just 401'd, so this cannot loop. The whole thing runs under a
|
|
// cross-tab Web Lock so concurrent refreshes serialise and the waiter sees
|
|
// the winner's write.
|
|
public doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
|
|
return withRefreshLock(async () => {
|
|
const stored = getFallbackSession();
|
|
if (
|
|
stored &&
|
|
stored.userId === this.userIdRef &&
|
|
stored.deviceId === this.deviceIdRef &&
|
|
stored.refreshToken &&
|
|
stored.refreshToken !== refreshToken
|
|
) {
|
|
return {
|
|
accessToken: stored.accessToken,
|
|
refreshToken: stored.refreshToken,
|
|
expiry:
|
|
typeof stored.expiresInMs === 'number'
|
|
? new Date(Date.now() + stored.expiresInMs)
|
|
: undefined,
|
|
};
|
|
}
|
|
return super.doRefreshAccessToken(refreshToken);
|
|
});
|
|
}
|
|
|
|
// F5 — persist the new expiry so the stored `expiresAt` stays fresh across
|
|
// reloads instead of going stale. The SDK invokes persistTokens synchronously
|
|
// inside the refresh and passes the freshly-refreshed `expiry` (a Date) on the
|
|
// tokens object at runtime, even though its published type omits it — so read
|
|
// it here directly (a doRefreshAccessToken override would run too late, since
|
|
// persistTokens is called before that method returns).
|
|
protected async persistTokens(tokens: {
|
|
accessToken: string;
|
|
refreshToken?: string;
|
|
expiry?: Date;
|
|
}): Promise<void> {
|
|
const expiresInMs =
|
|
tokens.expiry instanceof Date ? Math.max(0, tokens.expiry.getTime() - Date.now()) : undefined;
|
|
setFallbackSession(tokens.accessToken, this.deviceIdRef, this.userIdRef, this.baseUrlRef, {
|
|
refreshToken: tokens.refreshToken,
|
|
oidc: this.oidcRef,
|
|
expiresInMs,
|
|
});
|
|
}
|
|
}
|