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
235 lines
9.2 KiB
TypeScript
235 lines
9.2 KiB
TypeScript
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();
|
|
});
|
|
});
|