Soft logout keeps local data and re-logs in on the same device; MAS front hides register and reset

Soft (soft_logout === true) drops only the access token and records the device; sign-in is fixed to that user and sends device_id; same user and device keeps the stores, anything else wipes via the one wipeLocalData. Hard logout still wipes. When discovery advertises an msc2965 account or issuer, the register and forgot-password entries give way to a notice pointing at the account page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-13 18:36:51 +00:00
commit 4fd83fb4c4
15 changed files with 541 additions and 77 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

@ -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,15 @@ export function Login() {
}
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
const masUrl = useMasAccountUrl();
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 +100,9 @@ export function Login() {
<span data-spacing-node />
</>
)}
{registrationEntry(config) ? (
<RegisterEntry />
) : (
{masUrl && <MasAccountNotice url={masUrl} />}
{!masUrl && registrationEntry(config) && <RegisterEntry />}
{!masUrl && !registrationEntry(config) && (
<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

@ -11,6 +11,8 @@ import {
} from '../../afterLoginRedirectPath';
import { getHomePath } from '../../pathUtils';
import { setFallbackSession } from '../../../state/sessions';
import { clearSoftLogout, decideLoginOutcome, getSoftLogout } from '../../../state/softLogout';
import { wipeLocalData } from '../../../../client/initMatrix';
export enum GetBaseUrlError {
NotAllow = 'NotAllow',
@ -112,12 +114,18 @@ export const useLoginComplete = (data?: CustomLoginResponse) => {
const navigate = useNavigate();
useEffect(() => {
if (data) {
const { response: loginRes, baseUrl: loginBaseUrl } = data;
setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
if (!data) return;
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.
const outcome = decideLoginOutcome(getSoftLogout(), loginRes);
if (outcome === 'wipe') await wipeLocalData();
clearSoftLogout();
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,74 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
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();
});
});
const src = (p: string) => readFileSync(`${process.cwd()}/src/app/pages/auth/${p}`, 'utf8');
describe('MAS front guards', () => {
it('Login hides both register entries behind the MAS predicate', () => {
const s = src('login/Login.tsx');
expect(s).toContain('useMasAccountUrl()');
expect(s).toContain('{masUrl && <MasAccountNotice');
expect(s).toContain('{!masUrl && registrationEntry(config) && <RegisterEntry />}');
expect(s).toContain('{!masUrl && !registrationEntry(config) && (');
});
it('PasswordLoginForm hides the forgot link behind the MAS predicate', () => {
const s = src('login/PasswordLoginForm.tsx');
expect(s).toContain('useMasAccountUrl()');
expect(s).toMatch(/\{!masUrl && \([\s\S]*getResetPasswordPath/);
});
it('RegisterRoute and ResetPassword show the notice under MAS', () => {
['register/RegisterRoute.tsx', 'reset-password/ResetPassword.tsx'].forEach((p) => {
const s = src(p);
expect(s).toContain('useMasAccountUrl()');
expect(s).toContain('<MasAccountNotice');
});
});
it('every login request path carries the soft-logout device', () => {
expect(src('login/PasswordLoginForm.tsx').match(/withDevice\(\{/g)).toHaveLength(3);
expect(src('login/TokenLogin.tsx')).toContain('withSoftLogoutDevice(');
});
it('the logout listener and login completion defer to the one wipe', () => {
const root = readFileSync(`${process.cwd()}/src/app/pages/client/ClientRoot.tsx`, 'utf8');
expect(root).toContain('classifyLogout(err)');
expect(root).toContain('wipeLocalData(mx)');
expect(root).not.toContain('localStorage.clear');
expect(src('login/loginUtil.ts')).toContain('wipeLocalData()');
});
});

View file

@ -0,0 +1,32 @@
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import type { AutoDiscoveryInfo } from '../../cs-api';
// Plain http is for a developer's own machine only.
const isLocalHost = (hostname: string): boolean =>
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname === '127.0.0.1' ||
hostname === '[::1]';
const usable = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined;
try {
const { protocol, hostname } = new URL(value);
if (protocol === 'https:' || (protocol === 'http:' && isLocalHost(hostname))) return value;
} catch {
// not a url
}
return 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,26 @@ 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 shows the account page notice, not the form, with the switch on or off', () => {
[{}, { registration: { enabled: true, url: 'https://accounts.example/join' } }].forEach(
(config) => {
const html = render(config, mas);
expect(html).toContain('href="https://auth.example/account"');
expect(html).not.toContain('in-client register form');
expect(html).not.toContain('Continue to sign up');
}
);
});
});

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,21 @@ 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]);
if (entry && !masUrl) window.location.replace(entry.url);
}, [entry, masUrl]);
// Under MAS the in-client sign-up (UIA) cannot work; the account page is the only way.
if (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

@ -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

@ -19,6 +19,7 @@ import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState }
import {
clearCacheAndReload,
clearLoginData,
wipeLocalData,
initClient,
logoutClient,
startClient,
@ -35,6 +36,8 @@ import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { beginSoftLogout, classifyLogout } from '../../state/softLogout';
import { pushSessionToSW } from '../../../sw-session';
import { AutoDiscovery } from './AutoDiscovery';
import { PhoneHit } from '../../styles/PhoneHit.css';
@ -127,10 +130,15 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
const useLogoutListener = (mx?: MatrixClient) => {
useEffect(() => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async (err) => {
mx?.stopClient();
await mx?.clearStores();
window.localStorage.clear();
// Soft: the token is dead, the device is not. Keep the stores and ask for a sign-in.
if (classifyLogout(err) === 'soft' && beginSoftLogout()) {
pushSessionToSW();
window.location.reload();
return;
}
await wipeLocalData(mx);
window.location.reload();
};

View file

@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import { MatrixError } from 'matrix-js-sdk';
import { setFallbackSession, getFallbackSession } from './sessions';
import {
beginSoftLogout,
classifyLogout,
clearSoftLogout,
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', baseUrl: 'https://hs.example' };
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');
});
});

View file

@ -0,0 +1,91 @@
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;
baseUrl: string;
};
export const getSoftLogout = (storage: Storage = localStorage): SoftLogoutRecord | undefined => {
const raw = storage.getItem(SOFT_LOGOUT_KEY);
if (!raw) return undefined;
try {
const { userId, deviceId, baseUrl } = JSON.parse(raw) as Partial<SoftLogoutRecord>;
if (
typeof userId === 'string' &&
typeof deviceId === 'string' &&
typeof baseUrl === 'string' &&
userId &&
deviceId &&
baseUrl
) {
return { userId, deviceId, baseUrl };
}
} 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, baseUrl: session.baseUrl },
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];

View file

@ -111,6 +111,45 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
window.location.reload();
};
// The rust crypto databases the SDK names, for browsers without indexedDB.databases().
const KNOWN_DATABASES = [
'web-sync-store',
'crypto-store',
'matrix-js-sdk::matrix-sdk-crypto',
'matrix-js-sdk::matrix-sdk-crypto-meta',
];
const deleteDatabase = (name: string) =>
new Promise<void>((resolve) => {
const req = window.indexedDB.deleteDatabase(name);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => 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. Callers reload afterwards.
*/
export const wipeLocalData = async (mx?: MatrixClient) => {
try {
await mx?.clearStores();
} catch {
// the databases go below regardless
}
let names: string[];
try {
names = (await window.indexedDB.databases())
.map((db) => db.name)
.filter((name): name is string => !!name);
} catch {
names = KNOWN_DATABASES;
}
await Promise.all(names.map(deleteDatabase));
window.localStorage.clear();
};
export const logoutClient = async (mx: MatrixClient) => {
pushSessionToSW();
mx.stopClient();
@ -119,21 +158,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();
};