fix(auth): OIDC token rotation no longer reloads every other tab
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
This commit is contained in:
@@ -1,6 +1,23 @@
|
||||
import { OidcTokenRefresher } from 'matrix-js-sdk';
|
||||
import { AccessTokens, OidcTokenRefresher } from 'matrix-js-sdk';
|
||||
import type { IdTokenClaims } from 'oidc-client-ts';
|
||||
import { OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
|
||||
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,
|
||||
@@ -30,6 +47,37 @@ export class LotusOidcTokenRefresher extends OidcTokenRefresher {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user