diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -32,6 +32,7 @@ } from '../shared/crypto-utils.js'; import { fetchLatestDeviceList } from '../shared/device-list-utils.js'; import { useProcessAndSendDMOperation } from '../shared/dm-ops/process-dm-ops.js'; +import type { AuthMetadata } from '../shared/identity-client-context'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import threadWatcher from '../shared/thread-watcher.js'; import { threadSpecs } from '../shared/threads/thread-specs.js'; @@ -153,7 +154,10 @@ function useBaseLogOut( options: UseLogOutOptions, -): (keyserverIDs?: $ReadOnlyArray) => Promise { +): ( + preRequestAuthMetadata?: AuthMetadata, + keyserverIDs?: $ReadOnlyArray, +) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; @@ -175,7 +179,10 @@ const { logOutType, skipIdentityLogOut, handleUseNewFlowResponse } = options; return React.useCallback( - async (keyserverIDs?: $ReadOnlyArray) => { + async ( + preRequestAuthMetadata?: AuthMetadata, + keyserverIDs?: $ReadOnlyArray, + ) => { const holdersPromise = (async () => { try { await removeAllHolders(); @@ -203,14 +210,18 @@ ); } callIdentityClientLogOut = async () => { - await logOutPrimaryDevice(ownedKeyserverDeviceID); + await logOutPrimaryDevice( + ownedKeyserverDeviceID, + preRequestAuthMetadata, + ); }; } else if (logOutType === 'secondary_device') { - callIdentityClientLogOut = identityClient.logOutSecondaryDevice; + callIdentityClientLogOut = async () => + identityClient.logOutSecondaryDevice(preRequestAuthMetadata); } else { callIdentityClientLogOut = async () => { try { - await identityClient.logOut(); + await identityClient.logOut(preRequestAuthMetadata); } catch (e) { const errorMessage = getMessageForException(e); if (errorMessage !== 'use_new_flow') { @@ -222,7 +233,7 @@ // primary, so this is now a secondary device which still uses // old flow try { - await sendLogoutMessage(); + await sendLogoutMessage(preRequestAuthMetadata); } catch (err) { console.log('Failed to send logout message:', err); } @@ -279,7 +290,9 @@ const legacyLogOutOptions: UseLogOutOptions = Object.freeze({ logOutType: 'legacy', }); -function useLogOut(): () => Promise { +function useLogOut(): ( + preRequestAuthMetadata?: AuthMetadata, +) => Promise { const callLegacyLogOut = useBaseLogOut(legacyLogOutOptions); const callPrimaryDeviceLogOut = usePrimaryDeviceLogOut(); const callSecondaryDeviceLogOut = useSecondaryDeviceLogOut(); @@ -287,22 +300,27 @@ const checkIfPrimaryDevice = useCheckIfPrimaryDevice(); const usingRestoreFlow = useIsRestoreFlowEnabled(); - return React.useCallback(async () => { - if (usingRestoreFlow) { - const isPrimaryDevice = await checkIfPrimaryDevice(); - return isPrimaryDevice - ? callPrimaryDeviceLogOut() - : callSecondaryDeviceLogOut(); - } else { - return callLegacyLogOut(); - } - }, [ - callLegacyLogOut, - callPrimaryDeviceLogOut, - callSecondaryDeviceLogOut, - checkIfPrimaryDevice, - usingRestoreFlow, - ]); + return React.useCallback( + async preRequestAuthMetadata => { + if (usingRestoreFlow) { + const isPrimaryDevice = await checkIfPrimaryDevice( + preRequestAuthMetadata, + ); + return isPrimaryDevice + ? callPrimaryDeviceLogOut(preRequestAuthMetadata) + : callSecondaryDeviceLogOut(preRequestAuthMetadata); + } else { + return callLegacyLogOut(preRequestAuthMetadata); + } + }, + [ + callLegacyLogOut, + callPrimaryDeviceLogOut, + callSecondaryDeviceLogOut, + checkIfPrimaryDevice, + usingRestoreFlow, + ], + ); } function useIdentityLogOut( @@ -396,7 +414,9 @@ logOutType: 'primary_device', }); -function usePrimaryDeviceLogOut(): () => Promise { +function usePrimaryDeviceLogOut(): ( + preRequestAuthMetadata?: AuthMetadata, +) => Promise { const identityContext = React.useContext(IdentityClientContext); if (!identityContext) { throw new Error('Identity service client is not initialized'); @@ -413,122 +433,138 @@ ); const logOut = useBaseLogOut(primaryDeviceLogOutOptions); - return React.useCallback(async () => { - const authMetadata = await identityContext.getAuthMetadata(); - const { userID, deviceID: thisDeviceID } = authMetadata; - if (!thisDeviceID || !userID) { - throw new Error('No auth metadata'); - } - const [primaryDeviceID, ...secondaryDevices] = ownPeerDevices.map( - it => it.deviceID, - ); - if (thisDeviceID !== primaryDeviceID) { - throw new Error('Used primary device logout on a non-primary device'); - } + return React.useCallback( + async preRequestAuthMetadata => { + let authMetadata = preRequestAuthMetadata; + if (!authMetadata) { + authMetadata = await identityContext.getAuthMetadata(); + } + const { userID, deviceID: thisDeviceID } = authMetadata; + if (!thisDeviceID || !userID) { + throw new Error('No auth metadata'); + } + const [primaryDeviceID, ...secondaryDevices] = ownPeerDevices.map( + it => it.deviceID, + ); + if (thisDeviceID !== primaryDeviceID) { + throw new Error('Used primary device logout on a non-primary device'); + } - const messageContents: DeviceLogoutP2PMessage = { - type: userActionsP2PMessageTypes.LOG_OUT_DEVICE, - }; - const recipients = secondaryDevices - .filter(deviceID => deviceID !== ownedKeyserverDeviceID) - .map(deviceID => ({ userID, deviceID })); - await broadcastEphemeralMessage( - JSON.stringify(messageContents), - recipients, - authMetadata, - ); + const messageContents: DeviceLogoutP2PMessage = { + type: userActionsP2PMessageTypes.LOG_OUT_DEVICE, + }; + const recipients = secondaryDevices + .filter(deviceID => deviceID !== ownedKeyserverDeviceID) + .map(deviceID => ({ userID, deviceID })); + await broadcastEphemeralMessage( + JSON.stringify(messageContents), + recipients, + authMetadata, + ); - // - logOut() performs device list update by calling Identity RPC - // - broadcastDeviceListUpdates asks peers to download it from identity - // so we need to call them in this order to make sure peers have latest - // device list. - // We're relying on Tunnelbroker session stil existing after calling logout - // and auth metadata not yet cleared at this point. - const logOutResult = await logOut(); - await broadcastDeviceListUpdates(foreignPeerDevices); - return logOutResult; - }, [ - broadcastDeviceListUpdates, - broadcastEphemeralMessage, - foreignPeerDevices, - identityContext, - logOut, - ownPeerDevices, - ownedKeyserverDeviceID, - ]); + // - logOut() performs device list update by calling Identity RPC + // - broadcastDeviceListUpdates asks peers to download it from identity + // so we need to call them in this order to make sure peers have latest + // device list. + // We're relying on Tunnelbroker session stil existing after calling + // logout and auth metadata not yet cleared at this point. + const logOutResult = await logOut(preRequestAuthMetadata); + await broadcastDeviceListUpdates(foreignPeerDevices); + return logOutResult; + }, + [ + broadcastDeviceListUpdates, + broadcastEphemeralMessage, + foreignPeerDevices, + identityContext, + logOut, + ownPeerDevices, + ownedKeyserverDeviceID, + ], + ); } function useSendLogoutMessageToPrimaryDevice( mandatory?: boolean = true, -): () => Promise { +): (preRequestAuthMetadata?: AuthMetadata) => Promise { const identityContext = React.useContext(IdentityClientContext); if (!identityContext) { throw new Error('Identity service client is not initialized'); } const peerToPeerContext = React.useContext(PeerToPeerContext); - return React.useCallback(async () => { - if (!peerToPeerContext) { - if (mandatory) { - throw new Error('PeerToPeerContext not set'); + return React.useCallback( + async authMetadata => { + if (!peerToPeerContext) { + if (mandatory) { + throw new Error('PeerToPeerContext not set'); + } + return; } - return; - } - const { broadcastEphemeralMessage } = peerToPeerContext; - const { identityClient, getAuthMetadata } = identityContext; - const authMetadata = await getAuthMetadata(); - const { userID, deviceID } = authMetadata; - if (!deviceID || !userID) { - throw new Error('No auth metadata'); - } + const { broadcastEphemeralMessage } = peerToPeerContext; + const { identityClient, getAuthMetadata } = identityContext; + if (!authMetadata) { + authMetadata = await getAuthMetadata(); + } + const { userID, deviceID } = authMetadata; + if (!deviceID || !userID) { + throw new Error('No auth metadata'); + } - // get current device list and primary device ID - const { devices } = await fetchLatestDeviceList(identityClient, userID); - const primaryDeviceID = devices[0]; - if (deviceID === primaryDeviceID) { - throw new Error('Used secondary device logout on primary device'); - } + // get current device list and primary device ID + const { devices } = await fetchLatestDeviceList(identityClient, userID); + const primaryDeviceID = devices[0]; + if (deviceID === primaryDeviceID) { + throw new Error('Used secondary device logout on primary device'); + } - // create and send Olm Tunnelbroker message to primary device - const { olmAPI } = getConfig(); - await olmAPI.initializeCryptoAccount(); - const messageContents: SecondaryDeviceLogoutP2PMessage = { - type: userActionsP2PMessageTypes.LOG_OUT_SECONDARY_DEVICE, - }; - const recipient = { userID, deviceID: primaryDeviceID }; - await broadcastEphemeralMessage( - JSON.stringify(messageContents), - [recipient], - authMetadata, - ); - }, [identityContext, peerToPeerContext, mandatory]); + // create and send Olm Tunnelbroker message to primary device + const { olmAPI } = getConfig(); + await olmAPI.initializeCryptoAccount(); + const messageContents: SecondaryDeviceLogoutP2PMessage = { + type: userActionsP2PMessageTypes.LOG_OUT_SECONDARY_DEVICE, + }; + const recipient = { userID, deviceID: primaryDeviceID }; + await broadcastEphemeralMessage( + JSON.stringify(messageContents), + [recipient], + authMetadata, + ); + }, + [identityContext, peerToPeerContext, mandatory], + ); } const secondaryDeviceLogOutOptions = Object.freeze({ logOutType: 'secondary_device', }); -function useSecondaryDeviceLogOut(): () => Promise { +function useSecondaryDeviceLogOut(): ( + preRequestAuthMetadata?: AuthMetadata, +) => Promise { const logOut = useBaseLogOut(secondaryDeviceLogOutOptions); const sendLogoutMessage = useSendLogoutMessageToPrimaryDevice(); - return React.useCallback(async () => { - try { - await promiseWithTimeout( - sendLogoutMessage(), - logoutTimeout, - 'send logout message', - ); - } catch (e) { - console.log( - `Failed to send logout message: ${getMessageForException(e) ?? ''}`, - ); - } + return React.useCallback( + async preRequestAuthMetadata => { + try { + await promiseWithTimeout( + sendLogoutMessage(preRequestAuthMetadata), + logoutTimeout, + 'send logout message', + ); + } catch (e) { + console.log( + `Failed to send logout message: ${getMessageForException(e) ?? ''}`, + ); + } - // log out of identity service, keyserver and visually - return logOut(); - }, [sendLogoutMessage, logOut]); + // log out of identity service, keyserver and visually + return logOut(preRequestAuthMetadata); + }, + [sendLogoutMessage, logOut], + ); } const claimUsernameActionTypes = Object.freeze({ diff --git a/lib/hooks/primary-device-hooks.js b/lib/hooks/primary-device-hooks.js --- a/lib/hooks/primary-device-hooks.js +++ b/lib/hooks/primary-device-hooks.js @@ -4,24 +4,35 @@ import * as React from 'react'; import { getOwnPeerDevices, isLoggedIn } from '../selectors/user-selectors.js'; -import { IdentityClientContext } from '../shared/identity-client-context.js'; +import { + IdentityClientContext, + type AuthMetadata, +} from '../shared/identity-client-context.js'; import { useSelector } from '../utils/redux-utils.js'; -function useCheckIfPrimaryDevice(): () => Promise { +function useCheckIfPrimaryDevice(): ( + preRequestAuthMetadata?: AuthMetadata, +) => Promise { const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'identity context not set'); const { getAuthMetadata } = identityContext; const userDevicesInfos = useSelector(getOwnPeerDevices); - return React.useCallback(async () => { - if (userDevicesInfos.length === 0) { - return false; - } - const primaryDeviceID = userDevicesInfos[0].deviceID; - const { deviceID } = await getAuthMetadata(); - return primaryDeviceID === deviceID; - }, [getAuthMetadata, userDevicesInfos]); + return React.useCallback( + async authMetadata => { + if (userDevicesInfos.length === 0) { + return false; + } + const primaryDeviceID = userDevicesInfos[0].deviceID; + if (!authMetadata) { + authMetadata = await getAuthMetadata(); + } + const { deviceID } = authMetadata; + return primaryDeviceID === deviceID; + }, + [getAuthMetadata, userDevicesInfos], + ); } type DeviceKind = 'primary' | 'secondary' | 'unknown'; diff --git a/lib/types/identity-service-types.js b/lib/types/identity-service-types.js --- a/lib/types/identity-service-types.js +++ b/lib/types/identity-service-types.js @@ -20,6 +20,7 @@ currentUserInfoValidator, type CurrentUserInfo, } from './user-types.js'; +import { type AuthMetadata } from '../shared/identity-client-context.js'; import { values } from '../utils/objects.js'; import { tUserID, tShape } from '../utils/validation-utils.js'; @@ -126,11 +127,16 @@ // Only a primary device can initiate account deletion, and web cannot be a // primary device +deletePasswordUser?: (password: string) => Promise; - +logOut: () => Promise; + +logOut: (preRequestAuthMetadata?: AuthMetadata) => Promise; // This log out type is specific to primary device, and web cannot be a // primary device - +logOutPrimaryDevice?: (keyserverDeviceID: ?string) => Promise; - +logOutSecondaryDevice: () => Promise; + +logOutPrimaryDevice?: ( + keyserverDeviceID: ?string, + preRequestAuthMetadata?: AuthMetadata, + ) => Promise; + +logOutSecondaryDevice: ( + preRequestAuthMetadata?: AuthMetadata, + ) => Promise; +getKeyserverKeys: string => Promise; // Users cannot register from web +registerPasswordUser?: ( diff --git a/native/identity-service/identity-service-context-provider.react.js b/native/identity-service/identity-service-context-provider.react.js --- a/native/identity-service/identity-service-context-provider.react.js +++ b/native/identity-service/identity-service-context-provider.react.js @@ -149,22 +149,31 @@ commRustModule.deletePasswordUser(userID, deviceID, token, password), ); }, - logOut: async () => { - const { - deviceID, - userID, - accessToken: token, - } = await getAuthMetadata(); + logOut: async (preRequestAuthMetadata?: AuthMetadata) => { + let authMetadata = preRequestAuthMetadata; + if (!authMetadata) { + authMetadata = await getAuthMetadata(); + } + const { deviceID, userID, accessToken: token } = authMetadata; + if (!userID || !deviceID || !token) { + throw new Error('Auth metadata is partial or missing'); + } return authVerifiedEndpoint( commRustModule.logOut(userID, deviceID, token), ); }, - logOutPrimaryDevice: async (keyserverDeviceID: ?string) => { - const { - deviceID, - userID, - accessToken: token, - } = await getAuthMetadata(); + logOutPrimaryDevice: async ( + keyserverDeviceID: ?string, + preRequestAuthMetadata?: AuthMetadata, + ) => { + let authMetadata = preRequestAuthMetadata; + if (!authMetadata) { + authMetadata = await getAuthMetadata(); + } + const { deviceID, userID, accessToken: token } = authMetadata; + if (!userID || !deviceID || !token) { + throw new Error('Auth metadata is partial or missing'); + } const signedDeviceList = await createAndSignSingletonDeviceList( deviceID, keyserverDeviceID, @@ -178,12 +187,15 @@ ), ); }, - logOutSecondaryDevice: async () => { - const { - deviceID, - userID, - accessToken: token, - } = await getAuthMetadata(); + logOutSecondaryDevice: async (preRequestAuthMetadata?: AuthMetadata) => { + let authMetadata = preRequestAuthMetadata; + if (!authMetadata) { + authMetadata = await getAuthMetadata(); + } + const { deviceID, userID, accessToken: token } = authMetadata; + if (!userID || !deviceID || !token) { + throw new Error('Auth metadata is partial or missing'); + } return authVerifiedEndpoint( commRustModule.logOutSecondaryDevice(userID, deviceID, token), );