CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
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
119 lines
3.9 KiB
TypeScript
119 lines
3.9 KiB
TypeScript
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');
|
|
});
|