Soft logout keeps local data; MAS front hides register and reset #145

Merged
void merged 5 commits from void/mas-client-fixes into dev 2026-11-13 19:32:10 +00:00
21 changed files with 920 additions and 97 deletions

View file

@ -0,0 +1,11 @@
import { Text } from 'folds';
import React from 'react';
export function MasAccountNotice({ url }: { url: string }) {
return (
<Text align="Center">
Accounts here are managed by your sign-in service. Create or recover an account on its{' '}
<a href={url}>account page</a>.
</Text>
);
}

View file

@ -0,0 +1,113 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderToString } from 'react-dom/server';
import { MemoryRouter } from 'react-router-dom';
import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo';
import { AuthServerProvider } from '../../hooks/useAuthServer';
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { ClientConfigProvider } from '../../hooks/useClientConfig';
import type { ClientConfig } from '../../hooks/useClientConfig';
import type { AutoDiscoveryInfo } from '../../cs-api';
import { setSoftLogout } from '../../state/softLogout';
import { Login } from './login/Login';
import { ResetPassword } from './reset-password/ResetPassword';
vi.mock('../../../client/initMatrix', () => ({ clearLoginData: vi.fn() }));
// folds' Overlay portals into document, which the server renderer has none of.
vi.mock('folds', async (importOriginal) => ({
...(await importOriginal<typeof import('folds')>()),
Overlay: () => null,
}));
vi.mock('./reset-password/PasswordResetForm', () => ({
PasswordResetForm: () => <div>in-client reset form</div>,
}));
const plain: AutoDiscoveryInfo = { 'm.homeserver': { base_url: 'https://hs.example' } };
const mas: AutoDiscoveryInfo = {
...plain,
'org.matrix.msc2965.authentication': { account: 'https://auth.example/account' },
};
const entryConfig: ClientConfig = {
registration: { enabled: true, url: 'https://accounts.example/join' },
};
const flows = {
loginFlows: { flows: [{ type: 'm.login.password' }] },
registerFlows: { status: 400 },
} as never;
const render = (ui: React.ReactElement, info: AutoDiscoveryInfo, config: ClientConfig = {}) =>
renderToString(
<MemoryRouter>
<AutoDiscoveryInfoProvider value={info}>
<AuthServerProvider value="hs.example">
<AuthFlowsProvider value={flows}>
<ClientConfigProvider value={config}>{ui}</ClientConfigProvider>
</AuthFlowsProvider>
</AuthServerProvider>
</AutoDiscoveryInfoProvider>
</MemoryRouter>
);
const store = new Map<string, string>();
beforeEach(() => {
store.clear();
vi.stubGlobal('window', { location: { origin: 'https://app.example', search: '' } });
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('Login register entries', () => {
it('non-MAS without an entry: the bare Register link, no notice', () => {
const html = render(<Login />, plain);
expect(html).toContain('Register</a>');
expect(html).not.toContain('managed by your sign-in service');
});
it('non-MAS with an entry: the entry and its notice', () => {
const html = render(<Login />, plain, entryConfig);
expect(html).toContain('href="https://accounts.example/join"');
});
it('MAS without an entry: the account page notice, no bare Register link', () => {
const html = render(<Login />, mas);
expect(html).toContain('managed by your sign-in service');
expect(html).toContain('href="https://auth.example/account"');
expect(html).not.toContain('Register</a>');
});
it('MAS with an entry: the entry stays, no MAS notice', () => {
const html = render(<Login />, mas, entryConfig);
expect(html).toContain('href="https://accounts.example/join"');
expect(html).not.toContain('managed by your sign-in service');
});
});
describe('PasswordLoginForm through Login', () => {
it('shows Forgot Password only without MAS', () => {
expect(render(<Login />, plain)).toContain('Forget Password?');
expect(render(<Login />, mas)).not.toContain('Forget Password?');
});
it('fixes the username and offers someone else only with a soft record', () => {
expect(render(<Login />, plain)).not.toMatch(/readonly/i);
expect(render(<Login />, plain)).not.toContain('Sign in as someone else');
setSoftLogout({ userId: '@a:hs.example', deviceId: 'DEV' });
const html = render(<Login />, plain);
expect(html).toContain('value="@a:hs.example"');
expect(html).toMatch(/readonly/i);
expect(html).toContain('Sign in as someone else');
});
});
describe('ResetPassword', () => {
it('shows the notice under MAS and the form otherwise', () => {
const under = render(<ResetPassword />, mas);
expect(under).toContain('href="https://auth.example/account"');
expect(under).not.toContain('in-client reset form');
const plainHtml = render(<ResetPassword />, plain);
expect(plainHtml).toContain('in-client reset form');
expect(plainHtml).not.toContain('managed by your sign-in service');
});
});

View file

@ -15,6 +15,10 @@ import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils'
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
import { LoginPathSearchParams } from '../../paths';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { useMasAccountUrl } from '../masAccount';
import { MasAccountNotice } from '../MasAccountNotice';
import { SoftLogoutNotice } from './SoftLogoutNotice';
import { useSoftLogout } from '../../../state/softLogout';
import { noSignInMethodMessage } from '../authMessages';
const getLoginTokenSearchParam = () => {
@ -57,12 +61,16 @@ export function Login() {
}
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
const masUrl = useMasAccountUrl();
const registerEntry = registrationEntry(config);
const softLogout = useSoftLogout();
return (
<Box direction="Column" gap="500">
<Text size="H2" priority="400">
Login
</Text>
{softLogout && <SoftLogoutNotice record={softLogout} />}
{parsedFlows.token && loginSearchParams.loginToken && (
<TokenLogin token={loginSearchParams.loginToken} />
)}
@ -93,9 +101,9 @@ export function Login() {
<span data-spacing-node />
</>
)}
{registrationEntry(config) ? (
<RegisterEntry />
) : (
{registerEntry && <RegisterEntry />}
{!registerEntry && masUrl && <MasAccountNotice url={masUrl} />}
{!registerEntry && !masUrl && (
<Text align="Center">
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
</Text>

View file

@ -19,7 +19,7 @@ import {
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { Link } from 'react-router-dom';
import { MatrixError } from 'matrix-js-sdk';
import { LoginRequest, MatrixError } from 'matrix-js-sdk';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
import { EMAIL_REGEX } from '../../../utils/regex';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
@ -39,6 +39,8 @@ import { loginErrorMessage } from '../authMessages';
import { ForgotLink } from '../styles.css';
import { getResetPasswordPath } from '../../pathUtils';
import { stopPropagation } from '../../../utils/keyboard';
import { useSoftLogout, withSoftLogoutDevice } from '../../../state/softLogout';
import { useMasAccountUrl } from '../masAccount';
import { PhoneHit } from '../../../styles/PhoneHit.css';
function UsernameHint({ server }: { server: string }) {
@ -118,6 +120,10 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
const server = useAuthServer();
const clientConfig = useClientConfig();
const softLogout = useSoftLogout();
const masUrl = useMasAccountUrl();
const withDevice = (request: LoginRequest) => withSoftLogoutDevice(request, softLogout);
const serverDiscovery = useAutoDiscoveryInfo();
const baseUrl = serverDiscovery['m.homeserver'].base_url;
@ -130,15 +136,18 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined);
const handleUsernameLogin = (username: string, password: string) => {
startLogin(baseUrl, {
type: 'm.login.password',
identifier: {
type: 'm.id.user',
user: username,
},
password,
initial_device_display_name: 'Ratatoskr Web',
});
startLogin(
baseUrl,
withDevice({
type: 'm.login.password',
identifier: {
type: 'm.id.user',
user: username,
},
password,
initial_device_display_name: 'Ratatoskr Web',
})
);
};
const handleMxIdLogin = async (mxId: string, password: string) => {
@ -148,27 +157,33 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
const getBaseUrl = factoryGetBaseUrl(clientConfig, mxIdServer);
startLogin(getBaseUrl, {
type: 'm.login.password',
identifier: {
type: 'm.id.user',
user: mxIdUsername,
},
password,
initial_device_display_name: 'Ratatoskr Web',
});
startLogin(
getBaseUrl,
withDevice({
type: 'm.login.password',
identifier: {
type: 'm.id.user',
user: mxIdUsername,
},
password,
initial_device_display_name: 'Ratatoskr Web',
})
);
};
const handleEmailLogin = (email: string, password: string) => {
startLogin(baseUrl, {
type: 'm.login.password',
identifier: {
type: 'm.id.thirdparty',
medium: 'email',
address: email,
},
password,
initial_device_display_name: 'Ratatoskr Web',
});
startLogin(
baseUrl,
withDevice({
type: 'm.login.password',
identifier: {
type: 'm.id.thirdparty',
medium: 'email',
address: email,
},
password,
initial_device_display_name: 'Ratatoskr Web',
})
);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@ -207,7 +222,8 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
Username
</Text>
<Input
defaultValue={defaultUsername ?? defaultEmail}
defaultValue={softLogout?.userId ?? defaultUsername ?? defaultEmail}
readOnly={softLogout !== undefined}
style={{ paddingRight: config.space.S300 }}
name="usernameInput"
variant="Background"
@ -252,13 +268,15 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
)}
</>
)}
<Box grow="Yes" shrink="No" justifyContent="End">
<Text as="span" size="T200" priority="400" align="Right">
<Link className={ForgotLink} to={getResetPasswordPath(server)}>
Forget Password?
</Link>
</Text>
</Box>
{!masUrl && (
<Box grow="Yes" shrink="No" justifyContent="End">
<Text as="span" size="T200" priority="400" align="Right">
<Link className={ForgotLink} to={getResetPasswordPath(server)}>
Forget Password?
</Link>
</Text>
</Box>
)}
</Box>
</Box>
<Button type="submit" variant="Primary" size="500">

View file

@ -0,0 +1,20 @@
import { Box, Button, Text } from 'folds';
import React from 'react';
import { clearLoginData } from '../../../../client/initMatrix';
import type { SoftLogoutRecord } from '../../../state/softLogout';
// Sign-in after the server dropped the token but not the device. "Someone else" is the hard path.
export function SoftLogoutNotice({ record }: { record: SoftLogoutRecord }) {
return (
<Box direction="Column" gap="200" alignItems="Start">
<Text size="T300">
Your session expired. Your local data was kept; sign in as {record.userId} to pick it up.
</Text>
<Button type="button" variant="Secondary" fill="Soft" size="300" onClick={clearLoginData}>
<Text as="span" size="B300">
Sign in as someone else
</Text>
</Button>
</Box>
);
}

View file

@ -14,6 +14,7 @@ import React, { useCallback, useEffect } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useSoftLogout, withSoftLogoutDevice } from '../../../state/softLogout';
import { CustomLoginResponse, LoginError, login, useLoginComplete } from './loginUtil';
function LoginTokenError({ message }: { message: string }) {
@ -46,6 +47,7 @@ type TokenLoginProps = {
export function TokenLogin({ token }: TokenLoginProps) {
const discovery = useAutoDiscoveryInfo();
const baseUrl = discovery['m.homeserver'].base_url;
const softLogout = useSoftLogout();
const [loginState, startLogin] = useAsyncCallback<
CustomLoginResponse,
@ -54,12 +56,14 @@ export function TokenLogin({ token }: TokenLoginProps) {
>(useCallback(login, []));
useEffect(() => {
startLogin(baseUrl, {
type: 'm.login.token',
token,
initial_device_display_name: 'Ratatoskr Web',
});
}, [baseUrl, token, startLogin]);
startLogin(
baseUrl,
withSoftLogoutDevice(
{ type: 'm.login.token', token, initial_device_display_name: 'Ratatoskr Web' },
softLogout
)
);
}, [baseUrl, token, softLogout, startLogin]);
useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined);

View file

@ -1,6 +1,6 @@
import to from 'await-to-js';
import { LoginRequest, LoginResponse, MatrixError, createClient } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig';
import { autoDiscovery, specVersions } from '../../../cs-api';
@ -11,6 +11,8 @@ import {
} from '../../afterLoginRedirectPath';
import { getHomePath } from '../../pathUtils';
import { setFallbackSession } from '../../../state/sessions';
import { settleLocalData } from '../../../state/softLogout';
import { wipeLocalData } from '../../../../client/wipeLocalData';
export enum GetBaseUrlError {
NotAllow = 'NotAllow',
@ -110,14 +112,20 @@ export const login = async (
export const useLoginComplete = (data?: CustomLoginResponse) => {
const navigate = useNavigate();
const settled = useRef<CustomLoginResponse>();
useEffect(() => {
if (data) {
const { response: loginRes, baseUrl: loginBaseUrl } = data;
setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
if (!data || settled.current === data) return;
settled.current = data;
const { response: loginRes, baseUrl: loginBaseUrl } = data;
const finish = async () => {
const afterLoginRedirectUrl = getAfterLoginRedirectPath();
// A soft logout left stores behind; they stay only for the same user on the same device.
await settleLocalData(loginRes, () => wipeLocalData());
setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
deleteAfterLoginRedirectPath();
navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true });
}
};
finish();
}, [data, navigate]);
};

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { masAccountUrl } from './masAccount';
import type { AutoDiscoveryInfo } from '../../cs-api';
const info = (auth?: unknown): AutoDiscoveryInfo =>
({
'm.homeserver': { base_url: 'https://hs.example' },
...(auth === undefined ? {} : { 'org.matrix.msc2965.authentication': auth }),
} as AutoDiscoveryInfo);
describe('masAccountUrl', () => {
it('prefers the account url, then the issuer', () => {
expect(
masAccountUrl(info({ account: 'https://a.example/acc', issuer: 'https://a.example/' }))
).toBe('https://a.example/acc');
expect(masAccountUrl(info({ issuer: 'https://a.example/' }))).toBe('https://a.example/');
});
it('is undefined when nothing is advertised', () => {
expect(masAccountUrl(info())).toBeUndefined();
expect(masAccountUrl(info({}))).toBeUndefined();
});
it('accepts http only for localhost', () => {
expect(masAccountUrl(info({ account: 'http://a.example/acc' }))).toBeUndefined();
expect(masAccountUrl(info({ account: 'http://localhost:8080/acc' }))).toBe(
'http://localhost:8080/acc'
);
});
it('skips an unusable account url and falls to a usable issuer', () => {
expect(masAccountUrl(info({ account: 'not a url', issuer: 'https://a.example/' }))).toBe(
'https://a.example/'
);
});
it('rejects non-strings and non-http schemes', () => {
expect(masAccountUrl(info({ account: 42, issuer: { x: 1 } }))).toBeUndefined();
expect(masAccountUrl(info({ account: 'data:text/html,x' }))).toBeUndefined();
expect(masAccountUrl(info({ account: 'ftp://a.example' }))).toBeUndefined();
});
});

View file

@ -0,0 +1,18 @@
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { isSecureOrLocalUrl } from '../../utils/secureUrl';
import type { AutoDiscoveryInfo } from '../../cs-api';
const usable = (value: unknown): string | undefined =>
typeof value === 'string' && isSecureOrLocalUrl(value) ? value : undefined;
/**
* The account page of the sign-in service fronting this homeserver (MAS), when discovery
* advertises one: the account url, else the issuer, the same preference the devices page uses.
* Undefined means the homeserver manages its own accounts.
*/
export const masAccountUrl = (info: AutoDiscoveryInfo): string | undefined => {
const auth = info['org.matrix.msc2965.authentication'];
return usable(auth?.account) ?? usable(auth?.issuer);
};
export const useMasAccountUrl = (): string | undefined => masAccountUrl(useAutoDiscoveryInfo());

View file

@ -3,16 +3,26 @@ import { describe, expect, it, vi } from 'vitest';
import { renderToString } from 'react-dom/server';
import { ClientConfigProvider } from '../../../hooks/useClientConfig';
import type { ClientConfig } from '../../../hooks/useClientConfig';
import { AutoDiscoveryInfoProvider } from '../../../hooks/useAutoDiscoveryInfo';
import type { AutoDiscoveryInfo } from '../../../cs-api';
import { RegisterRoute } from './RegisterRoute';
// The in-client form needs a homeserver; the route test only needs to know whether it was asked for.
vi.mock('./Register', () => ({ Register: () => <div>in-client register form</div> }));
const render = (config: ClientConfig) =>
const plain: AutoDiscoveryInfo = { 'm.homeserver': { base_url: 'https://hs.example' } };
const mas: AutoDiscoveryInfo = {
...plain,
'org.matrix.msc2965.authentication': { account: 'https://auth.example/account' },
};
const render = (config: ClientConfig, info: AutoDiscoveryInfo = plain) =>
renderToString(
<ClientConfigProvider value={config}>
<RegisterRoute />
</ClientConfigProvider>
<AutoDiscoveryInfoProvider value={info}>
<ClientConfigProvider value={config}>
<RegisterRoute />
</ClientConfigProvider>
</AutoDiscoveryInfoProvider>
);
describe('the /register route', () => {
@ -23,15 +33,31 @@ describe('the /register route', () => {
});
it('with the switch off shows the in-client form', () => {
expect(render({ registration: { enabled: false, url: 'https://accounts.example/join' } })).toContain(
'in-client register form'
);
expect(
render({ registration: { enabled: false, url: 'https://accounts.example/join' } })
).toContain('in-client register form');
expect(render({})).toContain('in-client register form');
});
it('with the switch on but an unusable url falls back to the form', () => {
expect(render({ registration: { enabled: true, url: 'ftp://accounts.example/join' } })).toContain(
'in-client register form'
expect(
render({ registration: { enabled: true, url: 'ftp://accounts.example/join' } })
).toContain('in-client register form');
});
it('under MAS with no configured entry shows the account page notice, not the form', () => {
const html = render({}, mas);
expect(html).toContain('href="https://auth.example/account"');
expect(html).not.toContain('in-client register form');
});
it('under MAS a configured entry keeps its own page, not the notice or the form', () => {
const html = render(
{ registration: { enabled: true, url: 'https://accounts.example/join' } },
mas
);
expect(html).toContain('href="https://accounts.example/join"');
expect(html).not.toContain('auth.example/account');
expect(html).not.toContain('in-client register form');
});
});

View file

@ -2,6 +2,8 @@ import { Box, Text } from 'folds';
import React, { useEffect } from 'react';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { registrationEntry } from '../registrationEntry';
import { useMasAccountUrl } from '../masAccount';
import { MasAccountNotice } from '../MasAccountNotice';
import { Register } from './Register';
// With the Register switch on, a stranger gets one way to register: the account service's own
@ -9,9 +11,22 @@ import { Register } from './Register';
// the switch off, the in-client form is the way.
export function RegisterRoute() {
const entry = registrationEntry(useClientConfig());
const masUrl = useMasAccountUrl();
useEffect(() => {
if (entry) window.location.replace(entry.url);
}, [entry]);
// Under MAS the in-client sign-up (UIA) cannot work; with no configured entry the account page
// is the only way. A configured entry keeps its own page and its training notice.
if (!entry && masUrl) {
return (
<Box direction="Column" gap="300">
<Text size="H2" priority="400">
Register
</Text>
<MasAccountNotice url={masUrl} />
</Box>
);
}
if (!entry) return <Register />;
return (
<Box direction="Column" gap="300">

View file

@ -6,7 +6,7 @@ import {
RegisterRequest,
RegisterResponse,
} from 'matrix-js-sdk';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { LoginPathSearchParams } from '../../paths';
import { ErrorCode } from '../../../cs-errorcode';
@ -17,6 +17,8 @@ import {
import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils';
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
import { setFallbackSession } from '../../../state/sessions';
import { settleLocalData } from '../../../state/softLogout';
import { wipeLocalData } from '../../../../client/wipeLocalData';
import type { RegisterTrouble } from '../authMessages';
export enum RegisterError {
@ -131,9 +133,11 @@ export const register = async (
export const useRegisterComplete = (data?: CustomRegisterResponse) => {
const navigate = useNavigate();
const settled = useRef<CustomRegisterResponse>();
useEffect(() => {
if (data) {
if (data && settled.current !== data) {
settled.current = data;
const { response, baseUrl } = data;
const userId = response.user_id;
@ -141,10 +145,15 @@ export const useRegisterComplete = (data?: CustomRegisterResponse) => {
const deviceId = response.device_id;
if (accessToken && deviceId) {
setFallbackSession(accessToken, deviceId, userId, baseUrl);
const afterLoginRedirectPath = getAfterLoginRedirectPath();
deleteAfterLoginRedirectPath();
navigate(afterLoginRedirectPath ?? getHomePath(), { replace: true });
// A new account never boots on a soft-logged-out user's stores.
settleLocalData({ user_id: userId, device_id: deviceId }, () => wipeLocalData()).then(
() => {
setFallbackSession(accessToken, deviceId, userId, baseUrl);
deleteAfterLoginRedirectPath();
navigate(afterLoginRedirectPath ?? getHomePath(), { replace: true });
}
);
} else {
const username = getMxIdLocalPart(userId);
const userServer = getMxIdServer(userId);

View file

@ -1,15 +1,9 @@
import type { ClientConfig } from '../../hooks/useClientConfig';
import { isSecureOrLocalUrl } from '../../utils/secureUrl';
import { TRAINING_NOTICE_VERSION, trainingNoticeText } from '../../utils/trainingNotice';
export type RegistrationEntry = { url: string; notice: string; noticeVersion: number };
// Plain http is for a developer's own machine only; a stranger's sign-up page is https.
const isLocalHost = (hostname: string): boolean =>
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname === '127.0.0.1' ||
hostname === '[::1]';
/**
* The sign-up entry the sign-in page may show: the account service's registration page, when the
* switch is on and the configured url is an https address (http only for localhost). Anything else is no entry. The
@ -18,12 +12,7 @@ const isLocalHost = (hostname: string): boolean =>
export const registrationEntry = (config: ClientConfig): RegistrationEntry | undefined => {
const { enabled, url } = config.registration ?? {};
if (enabled !== true || typeof url !== 'string') return undefined;
try {
const { protocol, hostname } = new URL(url);
if (protocol !== 'https:' && !(protocol === 'http:' && isLocalHost(hostname))) return undefined;
} catch {
return undefined;
}
if (!isSecureOrLocalUrl(url)) return undefined;
return {
url,
notice: trainingNoticeText(config.previewDemo),

View file

@ -4,6 +4,8 @@ import { Link, useSearchParams } from 'react-router-dom';
import { getLoginPath } from '../../pathUtils';
import { useAuthServer } from '../../../hooks/useAuthServer';
import { PasswordResetForm } from './PasswordResetForm';
import { useMasAccountUrl } from '../masAccount';
import { MasAccountNotice } from '../MasAccountNotice';
import { ResetPasswordPathSearchParams } from '../../paths';
const useResetPasswordSearchParams = (
@ -18,6 +20,7 @@ const useResetPasswordSearchParams = (
export function ResetPassword() {
const server = useAuthServer();
const masUrl = useMasAccountUrl();
const [searchParams] = useSearchParams();
const resetPasswordSearchParams = useResetPasswordSearchParams(searchParams);
@ -26,7 +29,11 @@ export function ResetPassword() {
<Text size="H2" priority="400">
Reset Password
</Text>
<PasswordResetForm defaultEmail={resetPasswordSearchParams.email} />
{masUrl ? (
<MasAccountNotice url={masUrl} />
) : (
<PasswordResetForm defaultEmail={resetPasswordSearchParams.email} />
)}
<span data-spacing-node />
<Text align="Center">

View file

@ -23,6 +23,7 @@ import {
logoutClient,
startClient,
} from '../../../client/initMatrix';
import { wipeLocalData } from '../../../client/wipeLocalData';
import { SplashScreen } from '../../components/splash-screen';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
@ -35,6 +36,8 @@ import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { createLogoutHandler } from '../../state/softLogout';
import { pushSessionToSW } from '../../../sw-session';
import { AutoDiscovery } from './AutoDiscovery';
import { PhoneHit } from '../../styles/PhoneHit.css';
@ -127,12 +130,15 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
const useLogoutListener = (mx?: MatrixClient) => {
useEffect(() => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
mx?.stopClient();
await mx?.clearStores();
window.localStorage.clear();
window.location.reload();
};
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = createLogoutHandler(
{
storage: window.localStorage,
stop: () => mx?.stopClient(),
wipe: () => wipeLocalData(mx),
reload: () => window.location.reload(),
forgetSessionInWorker: () => pushSessionToSW(),
}
);
mx?.on(HttpApiEvent.SessionLoggedOut, handleLogout);
return () => {

View file

@ -0,0 +1,221 @@
import { describe, expect, it } from 'vitest';
import { MatrixError } from 'matrix-js-sdk';
import { setFallbackSession, getFallbackSession } from './sessions';
import {
beginSoftLogout,
classifyLogout,
clearSoftLogout,
createLogoutHandler,
settleLocalData,
decideLoginOutcome,
getSoftLogout,
setSoftLogout,
withSoftLogoutDevice,
} from './softLogout';
const memoryStorage = (): Storage => {
const m = new Map<string, string>();
return {
get length() {
return m.size;
},
clear: () => m.clear(),
getItem: (k) => m.get(k) ?? null,
key: (i) => [...m.keys()][i] ?? null,
removeItem: (k) => {
m.delete(k);
},
setItem: (k, v) => {
m.set(k, String(v));
},
};
};
const record = { userId: '@a:hs', deviceId: 'DEV' };
describe('classifyLogout', () => {
it('is soft only for soft_logout === true', () => {
expect(classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN', soft_logout: true }))).toBe(
'soft'
);
expect(classifyLogout({ data: { soft_logout: true } })).toBe('soft');
});
it('is hard for false, absent, string-ish, and non-MatrixError values', () => {
expect(
classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN', soft_logout: false }))
).toBe('hard');
expect(classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN' }))).toBe('hard');
expect(classifyLogout({ data: { soft_logout: 'true' } })).toBe('hard');
expect(classifyLogout(undefined)).toBe('hard');
expect(classifyLogout(null)).toBe('hard');
expect(classifyLogout('boom')).toBe('hard');
expect(classifyLogout(new Error('x'))).toBe('hard');
});
});
describe('the soft logout record', () => {
it('round-trips and clears', () => {
const s = memoryStorage();
expect(getSoftLogout(s)).toBeUndefined();
setSoftLogout(record, s);
expect(getSoftLogout(s)).toEqual(record);
clearSoftLogout(s);
expect(getSoftLogout(s)).toBeUndefined();
});
it('treats garbage and partial records as absent', () => {
const s = memoryStorage();
s.setItem('ratatoskr_soft_logout', 'not json');
expect(getSoftLogout(s)).toBeUndefined();
s.setItem('ratatoskr_soft_logout', JSON.stringify({ userId: '@a:hs', deviceId: '' }));
expect(getSoftLogout(s)).toBeUndefined();
});
});
describe('beginSoftLogout', () => {
it('keeps the device keys and the record, drops only the access token', () => {
const s = memoryStorage();
const real = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', { value: s, configurable: true });
try {
setFallbackSession('tok', 'DEV', '@a:hs', 'https://hs.example');
s.setItem('other_key', 'kept');
} finally {
Object.defineProperty(globalThis, 'localStorage', { value: real, configurable: true });
}
expect(beginSoftLogout(s)).toBe(true);
expect(getFallbackSession(s)).toBeUndefined();
expect(s.getItem('cinny_access_token')).toBeNull();
expect(s.getItem('cinny_device_id')).toBe('DEV');
expect(s.getItem('other_key')).toBe('kept');
expect(getSoftLogout(s)).toEqual(record);
});
it('does nothing without a session', () => {
const s = memoryStorage();
expect(beginSoftLogout(s)).toBe(false);
expect(getSoftLogout(s)).toBeUndefined();
});
});
describe('withSoftLogoutDevice', () => {
const req = { type: 'm.login.password', password: 'p' } as const;
it('asks for the recorded device on password and token requests alike', () => {
expect(withSoftLogoutDevice(req, record)).toEqual({ ...req, device_id: 'DEV' });
const tok = { type: 'm.login.token', token: 't' };
expect(withSoftLogoutDevice(tok, record)).toEqual({ ...tok, device_id: 'DEV' });
});
it('leaves the request alone without a record', () => {
expect(withSoftLogoutDevice(req, undefined)).toBe(req);
});
});
describe('decideLoginOutcome', () => {
it('is plain without a record', () => {
expect(decideLoginOutcome(undefined, { user_id: '@a:hs', device_id: 'DEV' })).toBe('plain');
});
it('keeps for the same user and device', () => {
expect(decideLoginOutcome(record, { user_id: '@a:hs', device_id: 'DEV' })).toBe('keep');
});
it('wipes for another user or another device', () => {
expect(decideLoginOutcome(record, { user_id: '@b:hs', device_id: 'DEV' })).toBe('wipe');
expect(decideLoginOutcome(record, { user_id: '@a:hs', device_id: 'NEW' })).toBe('wipe');
});
});
describe('the logout handler', () => {
const rig = (soft: boolean) => {
const storage = memoryStorage();
storage.setItem('cinny_access_token', 'tok');
storage.setItem('cinny_device_id', 'DEV');
storage.setItem('cinny_user_id', '@a:hs');
storage.setItem('cinny_hs_base_url', 'https://hs.example');
const calls = { stop: 0, wipe: 0, reload: 0, worker: 0 };
const handler = createLogoutHandler({
storage,
stop: () => {
calls.stop += 1;
},
wipe: async () => {
calls.wipe += 1;
},
reload: () => {
calls.reload += 1;
},
forgetSessionInWorker: () => {
calls.worker += 1;
},
});
const err = { data: { soft_logout: soft } };
return { storage, calls, handler, err };
};
it('soft twice: one begin, no wipe, record kept', async () => {
const { storage, calls, handler, err } = rig(true);
await handler(err);
await handler(err);
expect(calls).toEqual({ stop: 1, wipe: 0, reload: 1, worker: 1 });
expect(getSoftLogout(storage)).toEqual(record);
expect(storage.getItem('cinny_device_id')).toBe('DEV');
});
it('soft then hard: the later event does not wipe', async () => {
const { calls, handler } = rig(true);
await handler({ data: { soft_logout: true } });
await handler({ data: {} });
expect(calls.wipe).toBe(0);
});
it('a record already standing swallows a fresh handler too', async () => {
const { storage, calls, handler } = rig(false);
setSoftLogout(record, storage);
await handler({ data: {} });
expect(calls).toEqual({ stop: 0, wipe: 0, reload: 0, worker: 0 });
});
it('soft with no session in storage: one wipe, no record', async () => {
const { storage, calls, handler } = rig(true);
storage.removeItem('cinny_access_token');
await handler({ data: { soft_logout: true } });
expect(calls).toEqual({ stop: 1, wipe: 1, reload: 1, worker: 0 });
expect(getSoftLogout(storage)).toBeUndefined();
});
it('hard twice: one wipe', async () => {
const { calls, handler, err } = rig(false);
await handler(err);
await handler(err);
expect(calls).toEqual({ stop: 1, wipe: 1, reload: 1, worker: 0 });
});
});
describe('settleLocalData', () => {
const run = async (
rec: typeof record | undefined,
res: { user_id: string; device_id: string }
) => {
const s = memoryStorage();
if (rec) setSoftLogout(rec, s);
let wipes = 0;
const outcome = await settleLocalData(
res,
async () => {
wipes += 1;
},
s
);
return { outcome, wipes, left: getSoftLogout(s) };
};
it('same user and device: keep stores, retire the record', async () => {
expect(await run(record, { user_id: '@a:hs', device_id: 'DEV' })).toEqual({
outcome: 'keep',
wipes: 0,
left: undefined,
});
});
it('another user or device (a new account too): wipe once, retire the record', async () => {
expect(await run(record, { user_id: '@new:hs', device_id: 'X' })).toEqual({
outcome: 'wipe',
wipes: 1,
left: undefined,
});
expect((await run(record, { user_id: '@a:hs', device_id: 'OTHER' })).wipes).toBe(1);
});
it('no record: plain, no wipe', async () => {
expect((await run(undefined, { user_id: '@a:hs', device_id: 'D' })).wipes).toBe(0);
});
});

125
src/app/state/softLogout.ts Normal file
View file

@ -0,0 +1,125 @@
import { useState } from 'react';
import type { LoginRequest } from 'matrix-js-sdk';
import { getFallbackSession } from './sessions';
// A soft logout is the server saying "this token is dead, the device is not": the local stores
// stay, only the access token goes, and this record says whose device to sign back in as.
const SOFT_LOGOUT_KEY = 'ratatoskr_soft_logout';
const ACCESS_TOKEN_KEY = 'cinny_access_token';
export type SoftLogoutRecord = {
userId: string;
deviceId: string;
};
export const getSoftLogout = (storage: Storage = localStorage): SoftLogoutRecord | undefined => {
const raw = storage.getItem(SOFT_LOGOUT_KEY);
if (!raw) return undefined;
try {
const { userId, deviceId } = JSON.parse(raw) as Partial<SoftLogoutRecord>;
if (typeof userId === 'string' && typeof deviceId === 'string' && userId && deviceId) {
return { userId, deviceId };
}
} catch {
// an unreadable record is no record
}
return undefined;
};
export const setSoftLogout = (record: SoftLogoutRecord, storage: Storage = localStorage) => {
storage.setItem(SOFT_LOGOUT_KEY, JSON.stringify(record));
};
export const clearSoftLogout = (storage: Storage = localStorage) => {
storage.removeItem(SOFT_LOGOUT_KEY);
};
export type LogoutKind = 'soft' | 'hard';
/** Soft only when the server said so: soft_logout === true. Anything else is hard. */
export const classifyLogout = (err: unknown): LogoutKind => {
const data = (err as { data?: { soft_logout?: unknown } } | null | undefined)?.data;
return data?.soft_logout === true ? 'soft' : 'hard';
};
/**
* Enter the soft-logout state: remember the device, drop only the access token. Returns false
* (and changes nothing) when there is no session to remember, which the caller treats as hard.
*/
export const beginSoftLogout = (storage: Storage = localStorage): boolean => {
const session = getFallbackSession(storage);
if (!session) return false;
setSoftLogout({ userId: session.userId, deviceId: session.deviceId }, storage);
storage.removeItem(ACCESS_TOKEN_KEY);
return true;
};
/** Every login request while a record stands asks for the same device back. */
export const withSoftLogoutDevice = (
request: LoginRequest,
record: SoftLogoutRecord | undefined
): LoginRequest => (record ? { ...request, device_id: record.deviceId } : request);
export type LoginOutcome = 'plain' | 'keep' | 'wipe';
/**
* What a finished login does to local data. No record: a plain login, as ever. A record that
* names this very user and device: keep the stores. Any other answer: the stores belong to
* someone else, wipe them first.
*/
export const decideLoginOutcome = (
record: SoftLogoutRecord | undefined,
response: { user_id: string; device_id: string }
): LoginOutcome => {
if (!record) return 'plain';
return record.userId === response.user_id && record.deviceId === response.device_id
? 'keep'
: 'wipe';
};
export const useSoftLogout = (): SoftLogoutRecord | undefined => useState(() => getSoftLogout())[0];
export type LogoutHandlerPorts = {
storage: Storage;
stop: () => void;
wipe: () => Promise<void>;
reload: () => void;
forgetSessionInWorker: () => void;
};
/**
* The SessionLoggedOut handler. The SDK emits once per failing request and in-flight requests
* outlive stopClient, so the first event decides and every later one is ignored: a second soft
* event must never find the token already gone and fall into the wipe.
*/
export const createLogoutHandler = (ports: LogoutHandlerPorts) => {
let fired = false;
return async (err: unknown): Promise<void> => {
if (fired || getSoftLogout(ports.storage)) return;
fired = true;
ports.stop();
// Soft needs a session to remember; without one nothing can vouch for the stores, so wipe.
if (classifyLogout(err) === 'soft' && beginSoftLogout(ports.storage)) {
ports.forgetSessionInWorker();
ports.reload();
return;
}
await ports.wipe();
ports.reload();
};
};
/**
* Settle local data when a login or registration hands back a session: wipe first when a soft
* logout left stores that belong to someone else, then retire the record.
*/
export const settleLocalData = async (
response: { user_id: string; device_id: string },
wipe: () => Promise<void>,
storage: Storage = localStorage
): Promise<LoginOutcome> => {
const outcome = decideLoginOutcome(getSoftLogout(storage), response);
if (outcome === 'wipe') await wipe();
clearSoftLogout(storage);
return outcome;
};

View file

@ -0,0 +1,16 @@
// Plain http is for a developer's own machine only; a stranger's page is https.
const isLocalHost = (hostname: string): boolean =>
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname === '127.0.0.1' ||
hostname === '[::1]';
/** True for an https url, or an http url on localhost. Anything else, including non-urls, is false. */
export const isSecureOrLocalUrl = (value: string): boolean => {
try {
const { protocol, hostname } = new URL(value);
return protocol === 'https:' || (protocol === 'http:' && isLocalHost(hostname));
} catch {
return false;
}
};

View file

@ -7,6 +7,7 @@ import {
SyncState,
} from 'matrix-js-sdk';
import { wipeLocalData } from './wipeLocalData';
import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
import { pushSessionToSW } from '../sw-session';
@ -119,21 +120,11 @@ export const logoutClient = async (mx: MatrixClient) => {
} catch {
// ignore if failed to logout
}
await mx.clearStores();
window.localStorage.clear();
await wipeLocalData(mx);
window.location.reload();
};
export const clearLoginData = async () => {
const dbs = await window.indexedDB.databases();
dbs.forEach((idbInfo) => {
const { name } = idbInfo;
if (name) {
window.indexedDB.deleteDatabase(name);
}
});
window.localStorage.clear();
await wipeLocalData();
window.location.reload();
};

View file

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { KNOWN_DATABASES, wipeLocalData } from './wipeLocalData';
type Mode = 'success' | 'blocked' | 'error' | 'throws';
const rig = (opts: { databases?: () => Promise<{ name?: string }[]>; mode?: Mode }) => {
const deleted: string[] = [];
let cleared = 0;
const indexedDB = {
databases: opts.databases,
deleteDatabase: (name: string) => {
deleted.push(name);
if (opts.mode === 'throws') throw new Error('sync boom');
const req: Record<string, (() => void) | undefined> = {};
setTimeout(() => {
if (opts.mode === 'blocked') req.onblocked?.();
else if (opts.mode === 'error') req.onerror?.();
else req.onsuccess?.();
}, 0);
return req as unknown as IDBOpenDBRequest;
},
};
const storage = {
clear: () => {
cleared += 1;
},
};
return { deleted, cleared: () => cleared, ports: { indexedDB, storage } };
};
describe('wipeLocalData', () => {
it('deletes every listed database and clears storage', async () => {
const r = rig({ databases: async () => [{ name: 'a' }, { name: 'b' }, {}] });
await wipeLocalData(undefined, r.ports);
expect(r.deleted).toEqual(['a', 'b']);
expect(r.cleared()).toBe(1);
});
it('falls back to the known names when databases() is absent or throws', async () => {
const absent = rig({});
await wipeLocalData(undefined, absent.ports);
expect(absent.deleted).toEqual(KNOWN_DATABASES);
expect(KNOWN_DATABASES).toEqual([
'web-sync-store',
'crypto-store',
'matrix-js-sdk::matrix-sdk-crypto',
'matrix-js-sdk::matrix-sdk-crypto-meta',
]);
expect(absent.cleared()).toBe(1);
const throws = rig({
databases: async () => {
throw new Error('nope');
},
});
await wipeLocalData(undefined, throws.ports);
expect(throws.deleted).toEqual(KNOWN_DATABASES);
expect(throws.cleared()).toBe(1);
});
it('a synchronous deleteDatabase throw does not abort the rest or the storage clear', async () => {
const r = rig({ databases: async () => [{ name: 'a' }, { name: 'b' }], mode: 'throws' });
await expect(wipeLocalData(undefined, r.ports)).resolves.toBeUndefined();
expect(r.deleted).toEqual(['a', 'b']);
expect(r.cleared()).toBe(1);
});
it('blocked and errored deletions settle and still clear storage', async () => {
await Promise.all(
(['blocked', 'error'] as const).map(async (mode) => {
const r = rig({ databases: async () => [{ name: 'a' }], mode });
await wipeLocalData(undefined, r.ports);
expect(r.cleared()).toBe(1);
})
);
});
it('a throwing clearStores does not stop the wipe', async () => {
const r = rig({ databases: async () => [{ name: 'a' }] });
const mx = {
clearStores: async () => {
throw new Error('x');
},
};
await wipeLocalData(mx as never, r.ports);
expect(r.deleted).toEqual(['a']);
expect(r.cleared()).toBe(1);
});
it('a storage that refuses to clear does not make the wipe reject', async () => {
const r = rig({ databases: async () => [] });
const ports = {
...r.ports,
storage: {
clear: () => {
throw new Error('SecurityError');
},
},
};
await expect(wipeLocalData(undefined, ports)).resolves.toBeUndefined();
});
it('a client store that never closes holds the wipe only for the timeout, then storage is cleared', async () => {
const r = rig({ databases: async () => [{ name: 'a' }] });
const mx = {
clearStores: () =>
new Promise<void>(() => {
// never settles
}),
} as never;
await wipeLocalData(mx, { ...r.ports, clearStoresTimeoutMs: 10 });
expect(r.deleted).toEqual(['a']);
expect(r.cleared()).toBe(1);
});
});

View file

@ -0,0 +1,72 @@
import type { MatrixClient } from 'matrix-js-sdk';
// The rust crypto databases the SDK names, for browsers without indexedDB.databases().
export const KNOWN_DATABASES = [
'web-sync-store',
'crypto-store',
'matrix-js-sdk::matrix-sdk-crypto',
'matrix-js-sdk::matrix-sdk-crypto-meta',
];
export type WipePorts = {
indexedDB: Pick<IDBFactory, 'deleteDatabase'> & {
databases?: () => Promise<{ name?: string }[]>;
};
storage: Pick<Storage, 'clear'>;
/** How long a client store that will not close may hold the wipe up. */
clearStoresTimeoutMs?: number;
};
const CLEAR_STORES_TIMEOUT_MS = 5000;
const deleteDatabase = (idb: WipePorts['indexedDB'], name: string) =>
new Promise<void>((resolve) => {
try {
const req = idb.deleteDatabase(name);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
} catch {
// one database failing must not stop the rest
resolve();
}
});
/**
* The one wipe of everything this browser holds for the account: client stores, every IndexedDB
* database, localStorage. Hard logout, a soft logout answered by another user or device, and
* "sign in as someone else" all end here. It never rejects, and localStorage (the access token
* with it) is cleared whatever the databases do; a client store that hangs is given
* `clearStoresTimeoutMs`, then the wipe goes on. Callers reload afterwards.
*/
export const wipeLocalData = async (
mx?: MatrixClient,
ports: WipePorts = { indexedDB: window.indexedDB, storage: window.localStorage }
): Promise<void> => {
try {
try {
await Promise.race([
mx?.clearStores(),
new Promise<void>((resolve) => {
setTimeout(resolve, ports.clearStoresTimeoutMs ?? CLEAR_STORES_TIMEOUT_MS);
}),
]);
} catch {
// the databases go below regardless
}
let names = KNOWN_DATABASES;
try {
const listed = await ports.indexedDB.databases?.();
if (listed) names = listed.map((db) => db.name).filter((n): n is string => !!n);
} catch {
// keep the known names
}
await Promise.all(names.map((name) => deleteDatabase(ports.indexedDB, name)));
} finally {
try {
ports.storage.clear();
} catch {
// storage that refuses to clear cannot be helped; the reload still has to follow
}
}
};