diff --git a/e2e/sso-uia.spec.ts b/e2e/sso-uia.spec.ts new file mode 100644 index 000000000..bde422da5 --- /dev/null +++ b/e2e/sso-uia.spec.ts @@ -0,0 +1,83 @@ +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(); + }); +}); diff --git a/src/app/components/ActionUIA.tsx b/src/app/components/ActionUIA.tsx index b9cd122fb..26dee8575 100644 --- a/src/app/components/ActionUIA.tsx +++ b/src/app/components/ActionUIA.tsx @@ -1,4 +1,5 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useState } from 'react'; +import { Box, Button, Text } from 'folds'; import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk'; import { getUIAFlowForStages } from '../utils/matrix-uia'; import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows'; @@ -23,7 +24,18 @@ type ActionUIAProps = { export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) { const mx = useMatrixClient(); const completed = useUIACompleted(authData); - const { getStageToComplete } = useUIAFlow(authData, ongoingFlow); + // The server may offer several ways to confirm (e.g. password or SSO). Start + // with the given one but let the user switch: an account that signs in with + // SSO may have no password it knows. + const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES); + const [flow, setFlow] = useState(ongoingFlow); + const { getStageToComplete } = useUIAFlow(authData, flow); + const other = supportedFlows.find((f) => f.stages.join() !== flow.stages.join()); + const otherLabel = other?.stages.includes(AuthType.Sso) + ? 'Use SSO instead' + : other?.stages.includes(AuthType.Password) + ? 'Use your password instead' + : undefined; const stageToComplete = getStageToComplete(); @@ -31,7 +43,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA return ( {stageToComplete.type === AuthType.Password && ( @@ -50,6 +62,21 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA submitAuthDict={action} /> )} + {other && otherLabel && completed.length === 0 && ( + + + + )} ); } diff --git a/src/app/components/uia-stages/SSOStage.tsx b/src/app/components/uia-stages/SSOStage.tsx index d21800435..192e4652d 100644 --- a/src/app/components/uia-stages/SSOStage.tsx +++ b/src/app/components/uia-stages/SSOStage.tsx @@ -12,6 +12,12 @@ export function SSOStage({ }) { const { errorCode, error, session } = stageData; const [ssoWindow, setSSOWindow] = useState(); + // Whether the SSO page was opened. Not the same as having a window handle: + // the desktop app opens it in the system browser and gets none back, and a + // popup can lose its link to us (cross-origin opener policy on the identity + // provider). In both cases "authDone" never arrives, so "Continue" must still + // be offered once the page was opened, or the only way on is starting over. + const [started, setStarted] = useState(false); const handleSubmit = useCallback(() => { submitAuthDict({ @@ -22,6 +28,7 @@ export function SSOStage({ const handleContinue = () => { const w = window.open(ssoRedirectURL, '_blank'); setSSOWindow(w ?? undefined); + setStarted(true); }; useEffect(() => { @@ -74,7 +81,9 @@ export function SSOStage({ gap="400" > - To perform this action you need to authenticate yourself by SSO login. + {started + ? 'Finish signing in on the page that opened, then come back here and select Continue.' + : 'To do this, confirm it’s you by signing in with SSO.'} {errorCode && ( @@ -85,12 +94,19 @@ export function SSOStage({ )} - {ssoWindow ? ( - + {started ? ( + + + + ) : (