Files
cinny/e2e/sso-uia.spec.ts
T
Lotus CIandClaude Opus 5.5 f7f6059b33
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
fix(auth): SSO confirmation works in the desktop app; allow switching to SSO
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
2026-10-26 11:49:52 -04:00

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();
});
});