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; issuedAccess: string[]; }; const b64url = (buf: Buffer) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); async function synapseToken(localpart: string, deviceId: string): Promise { 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 { const fake: Fake = { registered: 0, tokenCalls: [], revoked: [], expired: new Set(), issuedAccess: [], }; const codes = new Map(); const refreshes = new Map(); // 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(); }); });