From 4f97d6b75a25920942f2651905a94cef924f380b Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 25 Jul 2026 16:17:01 +0200 Subject: [PATCH] fix: submit the m.oauth stage type when completing OAuth UIA --- .changeset/oauth-uia-stage-type.md | 5 + src/app/components/uia-stages/OAuthStage.tsx | 2 + tests/e2e/device-verification.spec.ts | 99 ++++++++++++++++++++ tests/e2e/fixtures/continuwuity.ts | 58 ++++++------ 4 files changed, 137 insertions(+), 27 deletions(-) create mode 100644 .changeset/oauth-uia-stage-type.md create mode 100644 tests/e2e/device-verification.spec.ts diff --git a/.changeset/oauth-uia-stage-type.md b/.changeset/oauth-uia-stage-type.md new file mode 100644 index 0000000000..d8b8216022 --- /dev/null +++ b/.changeset/oauth-uia-stage-type.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix device verification setup hanging on OAuth homeservers by submitting the `m.oauth` stage type. diff --git a/src/app/components/uia-stages/OAuthStage.tsx b/src/app/components/uia-stages/OAuthStage.tsx index 73dc2c20e4..1603d66917 100644 --- a/src/app/components/uia-stages/OAuthStage.tsx +++ b/src/app/components/uia-stages/OAuthStage.tsx @@ -2,6 +2,7 @@ import { Box, Button, color, config, Dialog, Header, IconButton, Text } from 'fo import { Warning, composerIcon, sizedIcon, X } from '$components/icons/phosphor'; import { useCallback, useEffect, useRef, useState } from 'react'; import { isTauri } from '@tauri-apps/api/core'; +import { AuthType } from '$types/matrix-sdk'; import type { StageComponentProps } from './types'; const POLL_COOLDOWN_MS = 1500; @@ -15,6 +16,7 @@ export function OAuthStage({ stageData, submitAuthDict, onCancel }: StageCompone const handleSubmit = useCallback(() => { submitAuthDict({ + type: AuthType.OAuth, session, }); }, [submitAuthDict, session]); diff --git a/tests/e2e/device-verification.spec.ts b/tests/e2e/device-verification.spec.ts new file mode 100644 index 0000000000..801488a65c --- /dev/null +++ b/tests/e2e/device-verification.spec.ts @@ -0,0 +1,99 @@ +import { readFile } from 'node:fs/promises'; +import { test, expect, type Page } from '@playwright/test'; +import { registerUser } from './fixtures/continuwuity'; + +const UPLOAD_ROUTE = '**/_matrix/client/*/keys/device_signing/upload'; +const TICKET_PATH = '/e2e-oauth-ticket'; +const SESSION = 'e2e-oauth-session'; + +type AuthDict = { type?: string; session?: string }; +type InjectedSession = { + baseUrl: string; + userId: string; + deviceId: string; + accessToken: string; +}; + +async function homeserverBaseUrl(storageStatePath: string): Promise { + const state = JSON.parse(await readFile(storageStatePath, 'utf8')) as { + origins: { localStorage: { name: string; value: string }[] }[]; + }; + const entry = state.origins[0]!.localStorage.find((item) => item.name === 'matrixSessions')!; + return (JSON.parse(entry.value) as InjectedSession[])[0]!.baseUrl; +} + +async function loginAsFreshUser(page: Page, baseUrl: string, name: string): Promise { + const user = await registerUser(baseUrl, name, 'test-passw0rd'); + const session: InjectedSession = { + baseUrl, + userId: user.userId, + deviceId: user.deviceId, + accessToken: user.accessToken, + }; + await page.addInitScript((injected: InjectedSession) => { + localStorage.setItem('matrixSessions', JSON.stringify([injected])); + localStorage.setItem('matrixActiveSession', JSON.stringify(injected.userId)); + localStorage.setItem('dismissNotice', 'true'); + }, session); +} + +test.describe('device verification setup', () => { + test('completes the m.oauth UIA stage and reveals the recovery key', async ({ + page, + baseURL, + }, testInfo) => { + const storageStatePath = testInfo.project.use.storageState as string; + const hsBaseUrl = await homeserverBaseUrl(storageStatePath); + await loginAsFreshUser(page, hsBaseUrl, `oauth-uia-${testInfo.project.name}-${process.pid}`); + + const ticketUrl = `${baseURL}${TICKET_PATH}`; + const authDicts: (AuthDict | undefined)[] = []; + + await page.route(`**${TICKET_PATH}`, (route) => + route.fulfill({ contentType: 'text/html', body: 'ticket' }) + ); + + await page.route(UPLOAD_ROUTE, async (route) => { + const { auth } = route.request().postDataJSON() as { auth?: AuthDict }; + authDicts.push(auth); + + if (auth?.type === 'm.oauth' && auth.session === SESSION) { + await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); + return; + } + + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ + flows: [{ stages: ['m.oauth'] }], + params: { 'm.oauth': { url: ticketUrl } }, + session: SESSION, + }), + }); + }); + + await page.goto('/settings/devices'); + + await page.getByRole('button', { name: 'Enable' }).click({ timeout: 180_000 }); + await expect(page.getByText('Setup Device Verification')).toBeVisible(); + await page.locator('form').getByRole('button', { name: 'Continue' }).click(); + + await expect(page.getByText('Account Authorization')).toBeVisible({ timeout: 120_000 }); + + const popup = page.waitForEvent('popup'); + await page.getByRole('button', { name: 'Continue in Browser' }).click(); + await (await popup).close(); + + await page.getByRole('button', { name: 'Continue', exact: true, disabled: false }).click(); + + await expect(page.getByRole('button', { name: 'Copy', exact: true })).toBeVisible({ + timeout: 120_000, + }); + await expect(page.getByText('Account Authorization')).toBeHidden(); + + expect(authDicts.length).toBeGreaterThanOrEqual(2); + expect(authDicts[authDicts.length - 1]).toEqual({ type: 'm.oauth', session: SESSION }); + expect(authDicts.filter((auth) => auth?.session && !auth.type)).toEqual([]); + }); +}); diff --git a/tests/e2e/fixtures/continuwuity.ts b/tests/e2e/fixtures/continuwuity.ts index 06212f0071..cdd507acc0 100644 --- a/tests/e2e/fixtures/continuwuity.ts +++ b/tests/e2e/fixtures/continuwuity.ts @@ -38,41 +38,45 @@ export async function startContinuwuity(): Promise { const baseUrl = `http://${container.getHost()}:${container.getMappedPort(CS_PORT)}`; - const register = async (username: string, password: string): Promise => { - const url = `${baseUrl}/_matrix/client/v3/register?kind=user`; - const probe = await fetch(url, { - method: 'POST', - body: JSON.stringify({ username, password }), - }); - - let done = probe; - if (probe.status === 401) { - const { session } = (await probe.json()) as { session: string }; - done = await fetch(url, { - method: 'POST', - body: JSON.stringify({ username, password, auth: { type: 'm.login.dummy', session } }), - }); - } - if (!done.ok) { - throw new Error(`register failed: ${done.status} ${await done.text()}`); - } - const body = (await done.json()) as { - user_id: string; - access_token: string; - device_id: string; - }; - return { userId: body.user_id, accessToken: body.access_token, deviceId: body.device_id }; - }; - return { baseUrl, - register, + register: (username, password) => registerUser(baseUrl, username, password), stop: async () => { await container.stop(); }, }; } +export async function registerUser( + baseUrl: string, + username: string, + password: string +): Promise { + const url = `${baseUrl}/_matrix/client/v3/register?kind=user`; + const probe = await fetch(url, { + method: 'POST', + body: JSON.stringify({ username, password }), + }); + + let done = probe; + if (probe.status === 401) { + const { session } = (await probe.json()) as { session: string }; + done = await fetch(url, { + method: 'POST', + body: JSON.stringify({ username, password, auth: { type: 'm.login.dummy', session } }), + }); + } + if (!done.ok) { + throw new Error(`register failed: ${done.status} ${await done.text()}`); + } + const body = (await done.json()) as { + user_id: string; + access_token: string; + device_id: string; + }; + return { userId: body.user_id, accessToken: body.access_token, deviceId: body.device_id }; +} + export async function createRoom( baseUrl: string, token: string,