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) 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
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
45c3b16694
commit
5206730c72
@@ -0,0 +1,234 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { test, expect, Page, Route, BrowserContext } from '@playwright/test';
|
||||
import { HS, PASSWORD, api, enc, ensureUser, hsReachable, uniq } from './localHs';
|
||||
|
||||
// Native OAuth sign-in on matrix-js-sdk 44 (Gitea #201 SDK upgrade): login,
|
||||
// refresh on an expired token, the #16 two-tab refresh race, and logout
|
||||
// revocation. The homeserver is the real local Synapse; the auth server is
|
||||
// faked here and hands out REAL Synapse tokens for the device the client asked
|
||||
// for, so sync works after every step. "Expiry" is simulated by answering
|
||||
// M_UNKNOWN_TOKEN for a token we mark expired.
|
||||
const AUTH = 'https://auth.fake.test';
|
||||
|
||||
type Fake = {
|
||||
registered: number;
|
||||
tokenCalls: { grant: string; refresh?: string }[];
|
||||
revoked: string[];
|
||||
expired: Set<string>;
|
||||
issuedAccess: string[];
|
||||
};
|
||||
|
||||
const b64url = (buf: Buffer) =>
|
||||
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
async function synapseToken(localpart: string, deviceId: string): Promise<string> {
|
||||
const res = await api<{ access_token: string }>('POST', '/_matrix/client/v3/login', undefined, {
|
||||
type: 'm.login.password',
|
||||
identifier: { type: 'm.id.user', user: localpart },
|
||||
password: PASSWORD,
|
||||
device_id: deviceId,
|
||||
});
|
||||
return res.access_token;
|
||||
}
|
||||
|
||||
async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fake> {
|
||||
const fake: Fake = {
|
||||
registered: 0,
|
||||
tokenCalls: [],
|
||||
revoked: [],
|
||||
expired: new Set(),
|
||||
issuedAccess: [],
|
||||
};
|
||||
const codes = new Map<string, { challenge: string; deviceId: string }>();
|
||||
const refreshes = new Map<string, string>(); // live refresh token -> deviceId (rotating)
|
||||
let n = 0;
|
||||
const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
|
||||
|
||||
const issue = async (deviceId: string) => {
|
||||
n += 1;
|
||||
const access = await synapseToken(localpart, deviceId);
|
||||
const refresh = `r${n}`;
|
||||
refreshes.set(refresh, deviceId);
|
||||
fake.issuedAccess.push(access);
|
||||
return { token_type: 'Bearer', access_token: access, refresh_token: refresh, expires_in: 300 };
|
||||
};
|
||||
|
||||
// Homeserver: advertise the issuer, serve auth metadata, and "expire" tokens.
|
||||
await context.route(`${HS}/.well-known/matrix/client`, (route) =>
|
||||
route.fulfill({
|
||||
headers: cors,
|
||||
json: { 'm.homeserver': { base_url: HS }, 'm.authentication': { issuer: `${AUTH}/` } },
|
||||
}),
|
||||
);
|
||||
await context.route(`${HS}/_matrix/client/v1/auth_metadata`, (route) =>
|
||||
route.fulfill({
|
||||
headers: cors,
|
||||
json: {
|
||||
issuer: `${AUTH}/`,
|
||||
authorization_endpoint: `${AUTH}/authorize`,
|
||||
token_endpoint: `${AUTH}/token`,
|
||||
registration_endpoint: `${AUTH}/register`,
|
||||
revocation_endpoint: `${AUTH}/revoke`,
|
||||
response_types_supported: ['code'],
|
||||
response_modes_supported: ['query', 'fragment'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await context.route(`${HS}/_matrix/**`, async (route: Route) => {
|
||||
const auth = route.request().headers().authorization ?? '';
|
||||
const token = auth.replace(/^Bearer /, '');
|
||||
if (token && fake.expired.has(token)) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
headers: cors,
|
||||
json: { errcode: 'M_UNKNOWN_TOKEN', error: 'Token expired', soft_logout: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
// Auth server.
|
||||
await context.route(`${AUTH}/**`, async (route: Route) => {
|
||||
const req = route.request();
|
||||
const url = new URL(req.url());
|
||||
if (req.method() === 'OPTIONS') {
|
||||
await route.fulfill({ status: 204, headers: cors });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/register') {
|
||||
fake.registered += 1;
|
||||
await route.fulfill({ headers: cors, json: { client_id: 'fake-client' } });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/authorize') {
|
||||
const deviceId = /urn:matrix:client:device:(\S+)/.exec(
|
||||
url.searchParams.get('scope') ?? '',
|
||||
)?.[1];
|
||||
expect(url.searchParams.get('response_mode')).toBe('query');
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
const code = `code-${Date.now()}`;
|
||||
codes.set(code, { challenge: url.searchParams.get('code_challenge')!, deviceId: deviceId! });
|
||||
const back = new URL(url.searchParams.get('redirect_uri')!);
|
||||
back.searchParams.set('code', code);
|
||||
back.searchParams.set('state', url.searchParams.get('state')!);
|
||||
await route.fulfill({ status: 302, headers: { location: back.href } });
|
||||
return;
|
||||
}
|
||||
const form = new URLSearchParams(req.postData() ?? '');
|
||||
if (url.pathname === '/token') {
|
||||
const grant = form.get('grant_type') ?? '';
|
||||
fake.tokenCalls.push({ grant, refresh: form.get('refresh_token') ?? undefined });
|
||||
if (grant === 'authorization_code') {
|
||||
const pending = codes.get(form.get('code') ?? '');
|
||||
const verifier = form.get('code_verifier') ?? '';
|
||||
if (
|
||||
!pending ||
|
||||
b64url(createHash('sha256').update(verifier).digest()) !== pending.challenge
|
||||
) {
|
||||
await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } });
|
||||
return;
|
||||
}
|
||||
codes.delete(form.get('code')!);
|
||||
await route.fulfill({ headers: cors, json: await issue(pending.deviceId) });
|
||||
return;
|
||||
}
|
||||
if (grant === 'refresh_token') {
|
||||
const deviceId = refreshes.get(form.get('refresh_token') ?? '');
|
||||
if (!deviceId) {
|
||||
// rotating refresh tokens: a used one is dead
|
||||
await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } });
|
||||
return;
|
||||
}
|
||||
refreshes.delete(form.get('refresh_token')!);
|
||||
await route.fulfill({ headers: cors, json: await issue(deviceId) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (url.pathname === '/revoke') {
|
||||
fake.revoked.push(form.get('token') ?? '');
|
||||
await route.fulfill({ status: 200, headers: cors, body: '' });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 404, headers: cors, body: '' });
|
||||
});
|
||||
return fake;
|
||||
}
|
||||
|
||||
const storedSession = (page: Page) =>
|
||||
page.evaluate(() => JSON.parse(localStorage.getItem('cinny_session_v1') ?? 'null'));
|
||||
|
||||
async function oauthLogin(page: Page) {
|
||||
await page.goto(`/login/${enc(HS)}/`);
|
||||
await page.getByRole('button', { name: /Continue with/ }).click();
|
||||
await page.waitForURL(/\/home/, { timeout: 60_000 });
|
||||
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||
}
|
||||
|
||||
test.describe('OAuth sign-in on SDK 44', () => {
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||
});
|
||||
|
||||
test('login, refresh on expiry, two-tab race, logout revokes', async ({ context }) => {
|
||||
test.setTimeout(240_000);
|
||||
const user = await ensureUser(uniq('oa'));
|
||||
const fake = await fakeOAuth(context, user.localpart);
|
||||
const page = await context.newPage();
|
||||
|
||||
// 1. Login: registration, PKCE-checked code exchange, session persisted.
|
||||
await oauthLogin(page);
|
||||
expect(fake.registered).toBe(1);
|
||||
expect(fake.tokenCalls.map((c) => c.grant)).toEqual(['authorization_code']);
|
||||
let s = await storedSession(page);
|
||||
expect(s.userId).toBe(user.userId);
|
||||
expect(s.refreshToken).toBe('r1');
|
||||
expect(s.oidc.clientId).toBe('fake-client');
|
||||
const t1 = s.accessToken;
|
||||
|
||||
// 2. Second tab on the same session.
|
||||
const page2 = await context.newPage();
|
||||
await page2.goto('/home/');
|
||||
await page2.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||
|
||||
// 3. Token "expires": both tabs get M_UNKNOWN_TOKEN on their next request
|
||||
// and refresh at once. Exactly one refresh may reach the auth server
|
||||
// (r1 is single-use); the other tab must adopt its tokens.
|
||||
// Sync long-polls continuously, so both tabs hit it within ~30 s.
|
||||
fake.expired.add(t1);
|
||||
await expect
|
||||
.poll(async () => (await storedSession(page)).accessToken, { timeout: 90_000 })
|
||||
.not.toBe(t1);
|
||||
await page.waitForTimeout(5000);
|
||||
const refreshCalls = fake.tokenCalls.filter((c) => c.grant === 'refresh_token');
|
||||
expect(refreshCalls.map((c) => c.refresh)).toEqual(['r1']);
|
||||
s = await storedSession(page);
|
||||
expect(s.refreshToken).toBe('r2');
|
||||
await Promise.all(
|
||||
[page, page2].map((p) =>
|
||||
expect(p.getByText(/Username or email|Sign-in failed|logged out/i)).toHaveCount(0),
|
||||
),
|
||||
);
|
||||
|
||||
// 4. Logout through the UI revokes both tokens at the auth server
|
||||
// (the SDK does it for OAuth sessions instead of calling /logout).
|
||||
await page2.close();
|
||||
const latest = await storedSession(page);
|
||||
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Logout' }).first().click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Logout', exact: true })
|
||||
.last()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByLabel('Username or email').or(page.getByRole('button', { name: /Continue with/ })),
|
||||
).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(fake.revoked.sort()).toEqual([latest.accessToken, latest.refreshToken].sort());
|
||||
expect(await storedSession(page)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user