fix(auth): SSO confirmation works in the desktop app; allow switching to SSO #474
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<UIAFlow>(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 (
|
||||
<UIAFlowOverlay
|
||||
currentStep={completed.length + 1}
|
||||
stepCount={ongoingFlow.stages.length}
|
||||
stepCount={flow.stages.length}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
{stageToComplete.type === AuthType.Password && (
|
||||
@@ -50,6 +62,21 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
||||
submitAuthDict={action}
|
||||
/>
|
||||
)}
|
||||
{other && otherLabel && completed.length === 0 && (
|
||||
<Box justifyContent="Center" style={{ marginTop: 8 }}>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => setFlow(other)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
{otherLabel}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</UIAFlowOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ export function SSOStage({
|
||||
}) {
|
||||
const { errorCode, error, session } = stageData;
|
||||
const [ssoWindow, setSSOWindow] = useState<Window>();
|
||||
// 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"
|
||||
>
|
||||
<Text size="T200">
|
||||
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.'}
|
||||
</Text>
|
||||
{errorCode && (
|
||||
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
|
||||
@@ -85,12 +94,19 @@ export function SSOStage({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ssoWindow ? (
|
||||
<Button variant="Primary" onClick={handleSubmit}>
|
||||
<Text as="span" size="B400">
|
||||
Continue
|
||||
</Text>
|
||||
</Button>
|
||||
{started ? (
|
||||
<Box direction="Column" gap="200">
|
||||
<Button variant="Primary" onClick={handleSubmit}>
|
||||
<Text as="span" size="B400">
|
||||
Continue
|
||||
</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="None" onClick={handleContinue}>
|
||||
<Text as="span" size="B400">
|
||||
Open the sign-in page again
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Button variant="Primary" onClick={handleContinue}>
|
||||
<Text as="span" size="B400">
|
||||
|
||||
Reference in New Issue
Block a user