From 4fd83fb4c415f1be025e0fecbbb35d570fffcf38 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 18:36:51 +0000 Subject: [PATCH] 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 --- src/app/pages/auth/MasAccountNotice.tsx | 11 ++ src/app/pages/auth/login/Login.tsx | 13 +- .../pages/auth/login/PasswordLoginForm.tsx | 92 ++++++++------ src/app/pages/auth/login/SoftLogoutNotice.tsx | 20 +++ src/app/pages/auth/login/TokenLogin.tsx | 16 ++- src/app/pages/auth/login/loginUtil.ts | 16 ++- src/app/pages/auth/masAccount.test.ts | 74 +++++++++++ src/app/pages/auth/masAccount.ts | 32 +++++ .../auth/register/RegisterRoute.test.tsx | 39 ++++-- src/app/pages/auth/register/RegisterRoute.tsx | 18 ++- .../auth/reset-password/ResetPassword.tsx | 9 +- src/app/pages/client/ClientRoot.tsx | 14 +- src/app/state/softLogout.test.ts | 120 ++++++++++++++++++ src/app/state/softLogout.ts | 91 +++++++++++++ src/client/initMatrix.ts | 53 ++++++-- 15 files changed, 541 insertions(+), 77 deletions(-) create mode 100644 src/app/pages/auth/MasAccountNotice.tsx create mode 100644 src/app/pages/auth/login/SoftLogoutNotice.tsx create mode 100644 src/app/pages/auth/masAccount.test.ts create mode 100644 src/app/pages/auth/masAccount.ts create mode 100644 src/app/state/softLogout.test.ts create mode 100644 src/app/state/softLogout.ts diff --git a/src/app/pages/auth/MasAccountNotice.tsx b/src/app/pages/auth/MasAccountNotice.tsx new file mode 100644 index 00000000..b8f506b4 --- /dev/null +++ b/src/app/pages/auth/MasAccountNotice.tsx @@ -0,0 +1,11 @@ +import { Text } from 'folds'; +import React from 'react'; + +export function MasAccountNotice({ url }: { url: string }) { + return ( + + Accounts here are managed by your sign-in service. Create or recover an account on its{' '} + account page. + + ); +} diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index ea08e4f0..05204779 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -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 ( Login + {softLogout && } {parsedFlows.token && loginSearchParams.loginToken && ( )} @@ -93,9 +100,9 @@ export function Login() { )} - {registrationEntry(config) ? ( - - ) : ( + {masUrl && } + {!masUrl && registrationEntry(config) && } + {!masUrl && !registrationEntry(config) && ( Do not have an account? Register diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 6372684c..63c28577 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -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 = (evt) => { @@ -207,7 +222,8 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog Username )} - - - - Forget Password? - - - + {!masUrl && ( + + + + Forget Password? + + + + )} + + ); +} diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx index 0541742d..51a4b46b 100644 --- a/src/app/pages/auth/login/TokenLogin.tsx +++ b/src/app/pages/auth/login/TokenLogin.tsx @@ -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); diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index ba5b0bc9..f4124db7 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -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]); }; diff --git a/src/app/pages/auth/masAccount.test.ts b/src/app/pages/auth/masAccount.test.ts new file mode 100644 index 00000000..c45497f3 --- /dev/null +++ b/src/app/pages/auth/masAccount.test.ts @@ -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 && }'); + 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(' { + 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()'); + }); +}); diff --git a/src/app/pages/auth/masAccount.ts b/src/app/pages/auth/masAccount.ts new file mode 100644 index 00000000..b131a916 --- /dev/null +++ b/src/app/pages/auth/masAccount.ts @@ -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()); diff --git a/src/app/pages/auth/register/RegisterRoute.test.tsx b/src/app/pages/auth/register/RegisterRoute.test.tsx index 043c1174..1fde64cd 100644 --- a/src/app/pages/auth/register/RegisterRoute.test.tsx +++ b/src/app/pages/auth/register/RegisterRoute.test.tsx @@ -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: () =>
in-client register form
})); -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( - - - + + + + + ); 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'); + } ); }); }); diff --git a/src/app/pages/auth/register/RegisterRoute.tsx b/src/app/pages/auth/register/RegisterRoute.tsx index 094bc8fd..fa56527a 100644 --- a/src/app/pages/auth/register/RegisterRoute.tsx +++ b/src/app/pages/auth/register/RegisterRoute.tsx @@ -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 ( + + + Register + + + + ); + } if (!entry) return ; return ( diff --git a/src/app/pages/auth/reset-password/ResetPassword.tsx b/src/app/pages/auth/reset-password/ResetPassword.tsx index c5e1d2ad..b9c2e9a6 100644 --- a/src/app/pages/auth/reset-password/ResetPassword.tsx +++ b/src/app/pages/auth/reset-password/ResetPassword.tsx @@ -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() { Reset Password - + {masUrl ? ( + + ) : ( + + )} diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 565ff058..dd63226a 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -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(); }; diff --git a/src/app/state/softLogout.test.ts b/src/app/state/softLogout.test.ts new file mode 100644 index 00000000..e529288d --- /dev/null +++ b/src/app/state/softLogout.test.ts @@ -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(); + 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'); + }); +}); diff --git a/src/app/state/softLogout.ts b/src/app/state/softLogout.ts new file mode 100644 index 00000000..d7ea1180 --- /dev/null +++ b/src/app/state/softLogout.ts @@ -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; + 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]; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 871c186a..b6d22597 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -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((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(); };