chore(deps): matrix-js-sdk 41.7 -> 44.0 (#201)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m24s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m50s

Picks up three releases of MatrixRTC / delayed-event fixes and
matrix-sdk-crypto-wasm 18.3.1 -> 18.9.0.

SDK 43 moved OAuth (MSC3861) into the SDK and dropped oidc-client-ts, so
the native-OAuth sign-in is ported:
- login discovers the auth server via /auth_metadata, registers with
  OAuth2.registerClient, and keeps the PKCE verifier + device in its own
  sessionStorage entry keyed by `state` (single use; CSRF check);
- refresh is done by the SDK (oauthClientId + onTokenRefresh); the #16
  cross-tab protection is kept by a narrow guard on refresh-token grants
  (the SDK calls the global fetch for OAuth, not fetchFn), which
  serialises them with a Web Lock and adopts a sibling tab's tokens;
- logout and forced logout revoke via mx.logout() (the SDK revokes at
  the auth server for OAuth sessions);
- the callback page parsed its params on every render, re-running the
  single-use code exchange in a loop; parsed once now.

Also: notification_type is deprecated in MSC4075 (ring in DMs, notify
elsewhere when omitted), _onRTCSessionMemberUpdate ->
ensureRecalculateSessionMembers, precache limit 6 -> 10 MB for the
7.8 MB crypto wasm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
Lotus CI
2026-10-24 00:32:13 -04:00
committed by jared
co-authored by Claude Opus 5.5
parent bfed37bf50
commit 70206d6033
19 changed files with 682 additions and 268 deletions
+99 -71
View File
@@ -1,9 +1,13 @@
import { AccessTokens, OidcTokenRefresher } from 'matrix-js-sdk';
import type { IdTokenClaims } from 'oidc-client-ts';
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 refresher against the SAME stored refresh token; with rotating
// 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.
@@ -19,82 +23,106 @@ export const withRefreshLock = <T>(fn: () => Promise<T>): Promise<T> => {
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;
};
/**
* 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.
* #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 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> {
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 === this.userIdRef &&
stored.deviceId === this.deviceIdRef &&
stored.userId === id.userId &&
stored.deviceId === id.deviceId &&
stored.refreshToken &&
stored.refreshToken !== refreshToken
stored.refreshToken !== spending
) {
return {
accessToken: stored.accessToken,
refreshToken: stored.refreshToken,
expiry:
typeof stored.expiresInMs === 'number'
? new Date(Date.now() + stored.expiresInMs)
: undefined,
};
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' } },
);
}
return super.doRefreshAccessToken(refreshToken);
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;
});
}
};
// 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,
});
}
}
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);
};