2026-10-24 00:27:44 -04:00
|
|
|
import type { AccessTokens } from 'matrix-js-sdk';
|
2026-09-12 19:46:05 -04:00
|
|
|
import { getFallbackSession, OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
|
|
|
|
|
|
2026-10-24 00:27:44 -04:00
|
|
|
// 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).
|
|
|
|
|
|
2026-09-12 19:46:05 -04:00
|
|
|
// Web Lock name serialising OIDC refreshes across tabs (Gitea #16). Every tab
|
2026-10-24 00:27:44 -04:00
|
|
|
// runs its own client against the SAME stored refresh token; with rotating
|
2026-09-12 19:46:05 -04:00
|
|
|
// 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);
|
|
|
|
|
};
|
2026-06-30 16:12:13 -04:00
|
|
|
|
2026-10-24 00:27:44 -04:00
|
|
|
export type OidcIdentity = {
|
|
|
|
|
userId: string;
|
|
|
|
|
deviceId: string;
|
|
|
|
|
baseUrl: string;
|
|
|
|
|
oidc: OidcSessionMeta;
|
|
|
|
|
};
|
2026-06-30 16:12:13 -04:00
|
|
|
|
2026-10-24 00:27:44 -04:00
|
|
|
/** 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,
|
|
|
|
|
});
|
|
|
|
|
};
|
2026-06-30 16:12:13 -04:00
|
|
|
|
2026-10-24 00:27:44 -04:00
|
|
|
/** 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;
|
|
|
|
|
};
|
2026-06-30 16:12:13 -04:00
|
|
|
|
2026-10-24 00:27:44 -04:00
|
|
|
/**
|
|
|
|
|
* #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);
|
2026-09-12 19:46:05 -04:00
|
|
|
return withRefreshLock(async () => {
|
|
|
|
|
const stored = getFallbackSession();
|
|
|
|
|
if (
|
|
|
|
|
stored &&
|
2026-10-24 00:27:44 -04:00
|
|
|
stored.userId === id.userId &&
|
|
|
|
|
stored.deviceId === id.deviceId &&
|
2026-09-12 19:46:05 -04:00
|
|
|
stored.refreshToken &&
|
2026-10-24 00:27:44 -04:00
|
|
|
stored.refreshToken !== spending
|
2026-09-12 19:46:05 -04:00
|
|
|
) {
|
2026-10-24 00:27:44 -04:00
|
|
|
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' } },
|
|
|
|
|
);
|
2026-09-12 19:46:05 -04:00
|
|
|
}
|
2026-10-24 00:27:44 -04:00
|
|
|
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;
|
2026-09-12 19:46:05 -04:00
|
|
|
});
|
2026-10-24 00:27:44 -04:00
|
|
|
};
|
2026-09-12 19:46:05 -04:00
|
|
|
|
2026-10-24 00:27:44 -04:00
|
|
|
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);
|
|
|
|
|
};
|