Soft logout keeps local data; MAS front hides register and reset #145
21 changed files with 920 additions and 97 deletions
11
src/app/pages/auth/MasAccountNotice.tsx
Normal file
11
src/app/pages/auth/MasAccountNotice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
src/app/pages/auth/authFront.test.tsx
Normal file
113
src/app/pages/auth/authFront.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
20
src/app/pages/auth/login/SoftLogoutNotice.tsx
Normal file
20
src/app/pages/auth/login/SoftLogoutNotice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
38
src/app/pages/auth/masAccount.test.ts
Normal file
38
src/app/pages/auth/masAccount.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
18
src/app/pages/auth/masAccount.ts
Normal file
18
src/app/pages/auth/masAccount.ts
Normal 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());
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
221
src/app/state/softLogout.test.ts
Normal file
221
src/app/state/softLogout.test.ts
Normal 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
125
src/app/state/softLogout.ts
Normal 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;
|
||||
};
|
||||
16
src/app/utils/secureUrl.ts
Normal file
16
src/app/utils/secureUrl.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
108
src/client/wipeLocalData.test.ts
Normal file
108
src/client/wipeLocalData.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
72
src/client/wipeLocalData.ts
Normal file
72
src/client/wipeLocalData.ts
Normal 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
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
Reference in a new issue