Files
cinny/src/client/oidcTokenRefresher.ts
T
jaredandClaude Opus 5 d0dccdeb67 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
2026-09-12 19:46:05 -04:00

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,
});
}
}