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 = (fn: () => Promise): Promise => { 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 { 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 { 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, }); } }