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
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:
co-authored by
Claude Opus 5.5
parent
bfed37bf50
commit
70206d6033
+17
-13
@@ -3,8 +3,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
|
||||
import { cryptoCallbacks } from './secretStorageKeys';
|
||||
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
||||
import { getFallbackSession, removeFallbackSession, Session } from '../app/state/sessions';
|
||||
import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
|
||||
import { revokeOidcTokens } from './oidcLogout';
|
||||
import { installRefreshGuard, OidcIdentity, persistRefreshedTokens } from './oidcTokenRefresher';
|
||||
import { pushSessionToSW } from '../sw-session';
|
||||
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
|
||||
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
||||
@@ -62,12 +61,19 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
||||
|
||||
const legacyCryptoStore = new IndexedDBCryptoStore(globalThis.indexedDB, 'crypto-store');
|
||||
|
||||
// OIDC/next-gen-auth sessions carry a refresh token; wire automatic refresh
|
||||
// (the client calls this reactively on a 401) and persist rotated tokens.
|
||||
const oidcRefresher =
|
||||
// OIDC/next-gen-auth sessions carry a refresh token. Since SDK 43 the client
|
||||
// refreshes them itself; we persist the rotated tokens and serialise
|
||||
// refreshes across tabs (oidcTokenRefresher).
|
||||
const oidcIdentity: OidcIdentity | undefined =
|
||||
session.refreshToken && session.oidc
|
||||
? new LotusOidcTokenRefresher(session.oidc, session.deviceId, session.userId, session.baseUrl)
|
||||
? {
|
||||
userId: session.userId,
|
||||
deviceId: session.deviceId,
|
||||
baseUrl: session.baseUrl,
|
||||
oidc: session.oidc,
|
||||
}
|
||||
: undefined;
|
||||
if (oidcIdentity) installRefreshGuard(oidcIdentity);
|
||||
|
||||
const mx = createClient({
|
||||
baseUrl: session.baseUrl,
|
||||
@@ -84,8 +90,9 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
||||
cryptoCallbacks: cryptoCallbacks as any,
|
||||
// SAS (emoji) + QR-code verification (show/scan/reciprocate).
|
||||
verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'],
|
||||
tokenRefreshFunction: oidcRefresher
|
||||
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
|
||||
oauthClientId: oidcIdentity?.oidc.clientId,
|
||||
onTokenRefresh: oidcIdentity
|
||||
? (tokens) => persistRefreshedTokens(oidcIdentity, tokens)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -130,11 +137,8 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
|
||||
export const logoutClient = async (mx: MatrixClient) => {
|
||||
pushSessionToSW();
|
||||
mx.stopClient();
|
||||
// For OIDC sessions, revoke the tokens at the issuer too (best-effort).
|
||||
const session = getFallbackSession();
|
||||
if (session?.oidc) {
|
||||
await revokeOidcTokens(session);
|
||||
}
|
||||
// For OIDC sessions the SDK revokes the tokens at the auth server instead of
|
||||
// calling /logout (matrix-js-sdk 43+); best-effort either way.
|
||||
try {
|
||||
await mx.logout();
|
||||
} catch {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { discoverAndValidateOIDCIssuerWellKnown } from 'matrix-js-sdk';
|
||||
import { Session } from '../app/state/sessions';
|
||||
|
||||
/**
|
||||
* Best-effort revoke the OIDC access + refresh tokens at the issuer's revocation
|
||||
* endpoint during logout. Tolerant of any failure — logout proceeds regardless
|
||||
* (the local session is cleared by the caller either way).
|
||||
*/
|
||||
export const revokeOidcTokens = async (session: Session): Promise<void> => {
|
||||
if (!session.oidc) return;
|
||||
try {
|
||||
const config = await discoverAndValidateOIDCIssuerWellKnown(session.oidc.issuer);
|
||||
const endpoint = config.revocation_endpoint;
|
||||
if (!endpoint) return;
|
||||
const { clientId } = session.oidc;
|
||||
const revoke = (token: string, hint: string): Promise<Response> =>
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ token, token_type_hint: hint, client_id: clientId }),
|
||||
});
|
||||
const requests: Promise<Response>[] = [];
|
||||
if (session.refreshToken) requests.push(revoke(session.refreshToken, 'refresh_token'));
|
||||
if (session.accessToken) requests.push(revoke(session.accessToken, 'access_token'));
|
||||
await Promise.allSettled(requests);
|
||||
} catch {
|
||||
/* issuer unreachable / no revocation endpoint — ignore */
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
};
|
||||
|
||||
const sessions = await import('../app/state/sessions');
|
||||
const { guardRefreshFetch, refreshGrantToken } = await import('./oidcTokenRefresher');
|
||||
|
||||
const id = {
|
||||
userId: '@a:hs.example',
|
||||
deviceId: 'DEV1',
|
||||
baseUrl: 'https://hs.example',
|
||||
oidc: { issuer: 'https://auth.example/', clientId: 'c1', redirectUri: 'https://app/cb' },
|
||||
};
|
||||
const grant = (refresh: string): RequestInit => {
|
||||
const body = new URLSearchParams();
|
||||
body.append('grant_type', 'refresh_token');
|
||||
body.append('client_id', 'c1');
|
||||
body.append('refresh_token', refresh);
|
||||
return { method: 'POST', body };
|
||||
};
|
||||
const persist = (access: string, refresh: string) =>
|
||||
sessions.setFallbackSession(access, id.deviceId, id.userId, id.baseUrl, {
|
||||
refreshToken: refresh,
|
||||
oidc: id.oidc,
|
||||
expiresInMs: 300_000,
|
||||
});
|
||||
|
||||
test('[#16] recognises only refresh-token grants', () => {
|
||||
assert.equal(refreshGrantToken(grant('r1')), 'r1');
|
||||
assert.equal(
|
||||
refreshGrantToken({ method: 'POST', body: 'grant_type=refresh_token&refresh_token=r2' }),
|
||||
'r2',
|
||||
);
|
||||
assert.equal(
|
||||
refreshGrantToken({ method: 'POST', body: 'grant_type=authorization_code&code=x' }),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(refreshGrantToken({ method: 'GET' }), undefined);
|
||||
assert.equal(refreshGrantToken(undefined), undefined);
|
||||
});
|
||||
|
||||
test('[#16] a real refresh is persisted before the lock is released', async () => {
|
||||
store.clear();
|
||||
persist('at1', 'r1');
|
||||
let calls = 0;
|
||||
const inner: typeof fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
token_type: 'Bearer',
|
||||
access_token: 'at2',
|
||||
refresh_token: 'r2',
|
||||
expires_in: 300,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
};
|
||||
const res = await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r1'));
|
||||
assert.equal(calls, 1);
|
||||
assert.equal((await res.json()).access_token, 'at2', 'the SDK still reads the body');
|
||||
const s = sessions.getFallbackSession();
|
||||
assert.equal(s?.accessToken, 'at2');
|
||||
assert.equal(s?.refreshToken, 'r2');
|
||||
assert.ok((s?.expiresInMs ?? 0) > 290_000);
|
||||
});
|
||||
|
||||
test('[#16] the losing tab adopts the winner instead of spending a used token', async () => {
|
||||
store.clear();
|
||||
persist('at-winner', 'r-winner'); // a sibling tab already rotated r-old
|
||||
let calls = 0;
|
||||
const inner: typeof fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response('{"error":"invalid_grant"}', { status: 400 });
|
||||
};
|
||||
const res = await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r-old'));
|
||||
assert.equal(calls, 0, 'never hits the issuer');
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(body.token_type, 'Bearer');
|
||||
assert.equal(body.access_token, 'at-winner');
|
||||
assert.equal(body.refresh_token, 'r-winner');
|
||||
});
|
||||
|
||||
test('[#16] another account in storage is never adopted', async () => {
|
||||
store.clear();
|
||||
sessions.setFallbackSession('at-other', 'DEV9', '@b:hs.example', id.baseUrl, {
|
||||
refreshToken: 'r-other',
|
||||
});
|
||||
let calls = 0;
|
||||
const inner: typeof fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response(JSON.stringify({ token_type: 'Bearer', access_token: 'at2' }), {
|
||||
status: 200,
|
||||
});
|
||||
};
|
||||
await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r1'));
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('[#16] other requests pass straight through', async () => {
|
||||
let seen = '';
|
||||
const inner: typeof fetch = async (input) => {
|
||||
seen = String(input);
|
||||
return new Response('{}');
|
||||
};
|
||||
await guardRefreshFetch(inner, id)('https://hs.example/_matrix/client/v3/sync');
|
||||
assert.equal(seen, 'https://hs.example/_matrix/client/v3/sync');
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user