CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 1m50s
CI / Build & Quality Checks (pull_request) Successful in 3m15s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 18m29s
Reported by a desktop user: signing out other sessions with SSO looped. Server logs: the SSO step completed every time, but each delete_devices retry came without the session (a fresh 401 challenge), 4 attempts, none succeeded. The app is the Windows desktop build (WebView2) and the SSO pages opened in the system browser. - SSOStage only offered "Continue" once window.open returned a window. The desktop app opens the page in the system browser (no window back), and a popup can lose its opener, so "authDone" never arrives and the only way on was starting over. Now "Continue" (plus "Open the sign-in page again") appears as soon as the page was opened; authDone still auto-continues. - ActionUIA: when the server offers password and SSO, the first flow was forced (often password, which an SSO-only account may not know). Offer "Use SSO instead" / "Use your password instead". e2e: desktop (window.open returns null) completes with the session; with password offered first, SSO is one click away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
84 lines
3.7 KiB
TypeScript
84 lines
3.7 KiB
TypeScript
import { test, expect, Page } from '@playwright/test';
|
|
import { api, ensureUser, hsReachable, loginUI, PASSWORD } from './localHs';
|
|
|
|
// Signing out other devices with SSO confirmation. The desktop app opens the SSO
|
|
// page in the system browser (window.open gives no window back), so the dialog
|
|
// must still offer "Continue" — before the fix the only way on was starting
|
|
// over, in a loop (reported by a desktop SSO user). The server is mocked: the
|
|
// retry succeeds only if it carries the challenge's session.
|
|
const openDevicesAndSignOutOther = async (page: Page, flows: string[][]) => {
|
|
const sent: unknown[] = [];
|
|
await page.route('**/delete_devices', async (route) => {
|
|
const body = JSON.parse(route.request().postData() ?? '{}');
|
|
sent.push(body.auth ?? null);
|
|
if (body.auth?.session === 'S1') {
|
|
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
|
}
|
|
return route.fulfill({
|
|
status: 401,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
session: 'S1',
|
|
flows: flows.map((stages) => ({ stages })),
|
|
params: {},
|
|
}),
|
|
});
|
|
});
|
|
await page.getByRole('button', { name: 'User Settings' }).click();
|
|
const dialog = page.getByRole('dialog').first();
|
|
await dialog.getByText('Privacy & security', { exact: true }).first().click();
|
|
const card = dialog
|
|
.getByText('Old laptop', { exact: true })
|
|
.locator('xpath=ancestor::*[.//button][1]');
|
|
await card.getByRole('button').nth(1).click(); // the delete chip
|
|
const bar = dialog.getByText(/Logout from selected devices/);
|
|
await bar
|
|
.locator('xpath=ancestor::*[.//button[normalize-space()="Logout"]][1]')
|
|
.getByRole('button', { name: 'Logout', exact: true })
|
|
.click();
|
|
return { sent, bar };
|
|
};
|
|
|
|
test.describe('SSO confirmation for signing out devices', () => {
|
|
test.beforeAll(async () => {
|
|
test.skip(!(await hsReachable()), 'local homeserver not running');
|
|
});
|
|
|
|
test('desktop: no popup window, Continue still completes it', async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
await page.addInitScript(() => {
|
|
window.open = () => null; // the desktop app hands the page to the system browser
|
|
});
|
|
const user = await ensureUser(`ssod${Date.now().toString(36)}`);
|
|
await api('POST', '/_matrix/client/v3/login', undefined, {
|
|
type: 'm.login.password',
|
|
identifier: { type: 'm.id.user', user: user.localpart },
|
|
password: PASSWORD,
|
|
initial_device_display_name: 'Old laptop',
|
|
});
|
|
await loginUI(page, user);
|
|
const { sent, bar } = await openDevicesAndSignOutOther(page, [['m.login.sso']]);
|
|
await page.getByRole('button', { name: 'Continue with SSO' }).click();
|
|
await page.getByRole('button', { name: 'Continue', exact: true }).click();
|
|
await expect(bar).toHaveCount(0);
|
|
expect(sent).toEqual([null, { session: 'S1' }]);
|
|
});
|
|
|
|
test('password offered first: SSO is still one click away', async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const user = await ensureUser(`ssop${Date.now().toString(36)}`);
|
|
await api('POST', '/_matrix/client/v3/login', undefined, {
|
|
type: 'm.login.password',
|
|
identifier: { type: 'm.id.user', user: user.localpart },
|
|
password: PASSWORD,
|
|
initial_device_display_name: 'Old laptop',
|
|
});
|
|
await loginUI(page, user);
|
|
await openDevicesAndSignOutOther(page, [['m.login.password'], ['m.login.sso']]);
|
|
await expect(page.getByText('Account Password').first()).toBeVisible();
|
|
await page.getByRole('button', { name: 'Use SSO instead' }).click();
|
|
await expect(page.getByRole('button', { name: 'Continue with SSO' })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Use your password instead' })).toBeVisible();
|
|
});
|
|
});
|