import type { AccessTokens } from 'matrix-js-sdk'; import { getFallbackSession, OidcSessionMeta, setFallbackSession } from '../app/state/sessions'; // Since matrix-js-sdk 43 the SDK refreshes OAuth tokens itself (MatrixClient // `oauthClientId` + `onTokenRefresh`). This module keeps the two things Lotus // added on top: rotated tokens are persisted (F5), and refreshes are // serialised across tabs (Gitea #16). // Web Lock name serialising OIDC refreshes across tabs (Gitea #16). Every tab // runs its own client 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); }; export type OidcIdentity = { userId: string; deviceId: string; baseUrl: string; oidc: OidcSessionMeta; }; /** F5 — keep the stored session (and its expiry) in step with every refresh. */ export const persistRefreshedTokens = (id: OidcIdentity, tokens: AccessTokens): void => { const expiresInMs = tokens.expiry instanceof Date ? Math.max(0, tokens.expiry.getTime() - Date.now()) : undefined; setFallbackSession(tokens.accessToken, id.deviceId, id.userId, id.baseUrl, { refreshToken: tokens.refreshToken, oidc: id.oidc, expiresInMs, }); }; /** The refresh token a request is spending, if it is an OAuth refresh grant. */ export const refreshGrantToken = (init?: RequestInit): string | undefined => { if (!init || (init.method ?? 'GET').toUpperCase() !== 'POST') return undefined; const { body } = init; let params: URLSearchParams | undefined; if (body instanceof URLSearchParams) params = body; else if (typeof body === 'string') params = new URLSearchParams(body); if (params?.get('grant_type') !== 'refresh_token') return undefined; return params.get('refresh_token') ?? undefined; }; /** * #16 — wrap `fetch` so OAuth refresh grants run one at a time across tabs. * Inside the lock: if storage already holds a DIFFERENT refresh token for this * user + device, a sibling tab won the race, so answer with its tokens (a * well-formed token response) instead of spending a consumed refresh token. * Comparing refresh tokens (not access tokens) can never adopt the token that * just failed, so this cannot loop. A real refresh is persisted BEFORE the lock * is released, so the next tab in line sees it. Every other request passes * straight through. */ export const guardRefreshFetch = (inner: typeof fetch, id: OidcIdentity): typeof fetch => (input, init) => { const spending = refreshGrantToken(init); if (!spending) return inner(input, init); return withRefreshLock(async () => { const stored = getFallbackSession(); if ( stored && stored.userId === id.userId && stored.deviceId === id.deviceId && stored.refreshToken && stored.refreshToken !== spending ) { return new Response( JSON.stringify({ token_type: 'Bearer', access_token: stored.accessToken, refresh_token: stored.refreshToken, ...(typeof stored.expiresInMs === 'number' ? { expires_in: Math.floor(stored.expiresInMs / 1000) } : {}), }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ); } const started = Date.now(); const res = await inner(input, init); if (res.ok) { try { const body = (await res.clone().json()) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown; }; if (typeof body.access_token === 'string') { persistRefreshedTokens(id, { accessToken: body.access_token, refreshToken: typeof body.refresh_token === 'string' ? body.refresh_token : undefined, expiry: typeof body.expires_in === 'number' ? new Date(started + body.expires_in * 1000) : undefined, }); } } catch { // unreadable body: the SDK will report the failure itself } } return res; }); }; let unguardedFetch: typeof fetch | undefined; /** * Install the #16 guard on `globalThis.fetch`. The SDK's OAuth calls use the * global fetch directly (not the client's `fetchFn`), so this is the only * place to catch them. Idempotent: re-installing replaces the guard. */ export const installRefreshGuard = (id: OidcIdentity): void => { unguardedFetch ??= globalThis.fetch.bind(globalThis); globalThis.fetch = guardRefreshFetch(unguardedFetch, id); };