diff --git a/lib/components/keyserver-connection-handler.js b/lib/components/keyserver-connection-handler.js index 990a6ae36..675bf46af 100644 --- a/lib/components/keyserver-connection-handler.js +++ b/lib/components/keyserver-connection-handler.js @@ -1,415 +1,343 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { keyserverAuthActionTypes, logOutActionTypes, useKeyserverAuth, useLogOut, } from '../actions/user-actions.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { - setNewSession, CANCELLED_ERROR, type CallKeyserverEndpoint, } from '../keyserver-conn/keyserver-conn-types.js'; -import { resolveKeyserverSessionInvalidation } from '../keyserver-conn/recovery-utils.js'; +import { useKeyserverRecoveryLogIn } from '../keyserver-conn/recovery-utils.js'; import { filterThreadIDsInFilterList } from '../reducers/calendar-filters-reducer.js'; import { connectionSelector, cookieSelector, deviceTokenSelector, - urlPrefixSelector, - sessionIDSelector, } from '../selectors/keyserver-selectors.js'; import { isLoggedInToKeyserver } from '../selectors/user-selectors.js'; import { useInitialNotificationsEncryptedMessage } from '../shared/crypto-utils.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { OlmSessionCreatorContext } from '../shared/olm-session-creator-context.js'; import type { BaseSocketProps } from '../socket/socket.react.js'; import { logInActionSources, type RecoveryActionSource, } from '../types/account-types.js'; -import { genericCookieInvalidation } from '../types/session-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import type { CallSingleKeyserverEndpoint } from '../utils/call-single-keyserver-endpoint.js'; import { getConfig } from '../utils/config.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; -import { useSelector, useDispatch } from '../utils/redux-utils.js'; +import { useSelector } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken } from '../utils/services-utils.js'; import sleep from '../utils/sleep.js'; type Props = { ...BaseSocketProps, +socketComponent: React.ComponentType, }; const AUTH_RETRY_DELAY_MS = 60000; function KeyserverConnectionHandler(props: Props) { const { socketComponent: Socket, ...socketProps } = props; const { keyserverID } = props; const dispatchActionPromise = useDispatchActionPromise(); const callLogOut = useLogOut(); const keyserverAuth = useKeyserverAuth(); const hasConnectionIssue = useSelector( state => !!connectionSelector(keyserverID)(state)?.connectionIssue, ); const cookie = useSelector(cookieSelector(keyserverID)); const dataLoaded = useSelector(state => state.dataLoaded); const keyserverDeviceToken = useSelector(deviceTokenSelector(keyserverID)); // We have an assumption that we should be always connected to Ashoat's // keyserver. It is possible that a token which it has is correct, so we can // try to use it. In worst case it is invalid and our push-handler will try // to fix it. const ashoatKeyserverDeviceToken = useSelector( deviceTokenSelector(authoritativeKeyserverID()), ); const deviceToken = keyserverDeviceToken ?? ashoatKeyserverDeviceToken; const navInfo = useSelector(state => state.navInfo); const calendarFilters = useSelector(state => state.calendarFilters); const calendarQuery = React.useMemo(() => { const filters = filterThreadIDsInFilterList( calendarFilters, (threadID: string) => extractKeyserverIDFromID(threadID) === keyserverID, ); return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters, }; }, [calendarFilters, keyserverID, navInfo.endDate, navInfo.startDate]); React.useEffect(() => { if (hasConnectionIssue && !usingCommServicesAccessToken) { void dispatchActionPromise(logOutActionTypes, callLogOut()); } }, [callLogOut, hasConnectionIssue, dispatchActionPromise]); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { identityClient, getAuthMetadata } = identityContext; const olmSessionCreator = React.useContext(OlmSessionCreatorContext); invariant(olmSessionCreator, 'Olm session creator should be set'); const [authInProgress, setAuthInProgress] = React.useState(false); const performAuth = React.useCallback(() => { setAuthInProgress(true); let cancelled = false; const cancel = () => { cancelled = true; setAuthInProgress(false); }; const promise = (async () => { try { const keyserverKeys = await identityClient.getKeyserverKeys(keyserverID); if (cancelled) { throw new Error(CANCELLED_ERROR); } const [notifsSession, contentSession, { userID, deviceID }] = await Promise.all([ olmSessionCreator.notificationsSessionCreator( cookie, keyserverKeys.identityKeysBlob.notificationIdentityPublicKeys, keyserverKeys.notifInitializationInfo, keyserverID, ), olmSessionCreator.contentSessionCreator( keyserverKeys.identityKeysBlob.primaryIdentityPublicKeys, keyserverKeys.contentInitializationInfo, ), getAuthMetadata(), ]); invariant(userID, 'userID should be set'); invariant(deviceID, 'deviceID should be set'); const deviceTokenUpdateInput = deviceToken ? { [keyserverID]: { deviceToken } } : {}; if (cancelled) { throw new Error(CANCELLED_ERROR); } await dispatchActionPromise( keyserverAuthActionTypes, keyserverAuth({ userID, deviceID, doNotRegister: false, calendarQuery, deviceTokenUpdateInput, authActionSource: process.env.BROWSER ? logInActionSources.keyserverAuthFromWeb : logInActionSources.keyserverAuthFromNative, keyserverData: { [keyserverID]: { initialContentEncryptedMessage: contentSession, initialNotificationsEncryptedMessage: notifsSession, }, }, }), ); } catch (e) { if (cancelled) { return; } console.log( `Error while authenticating to keyserver with id ${keyserverID}`, e, ); if (!dataLoaded && keyserverID === authoritativeKeyserverID()) { await dispatchActionPromise(logOutActionTypes, callLogOut()); } } finally { if (!cancelled) { await sleep(AUTH_RETRY_DELAY_MS); setAuthInProgress(false); } } })(); return [promise, cancel]; }, [ calendarQuery, callLogOut, cookie, dataLoaded, deviceToken, dispatchActionPromise, getAuthMetadata, identityClient, keyserverAuth, keyserverID, olmSessionCreator, ]); const activeSessionRecovery = useSelector( state => state.keyserverStore.keyserverInfos[keyserverID]?.connection .activeSessionRecovery, ); - const preRequestUserInfo = useSelector(state => state.currentUserInfo); - const sessionID = useSelector(sessionIDSelector(keyserverID)); - const preRequestUserState = React.useMemo( - () => ({ - currentUserInfo: preRequestUserInfo, - cookiesAndSessions: { - [keyserverID]: { - cookie, - sessionID, - }, - }, - }), - [preRequestUserInfo, keyserverID, cookie, sessionID], - ); - - // We only need to do a "spot check" on this value below. - // - To avoid regenerating performRecovery whenever it changes, we want to - // make sure it's not in that function's dep list. - // - If we exclude it from that function's dep list, we'll end up binding in - // the value of preRequestUserState at the time performRecovery is updated. - // Instead, by assigning to a ref, we are able to use the latest value. - const preRequestUserStateRef = React.useRef(preRequestUserState); - preRequestUserStateRef.current = preRequestUserState; - // This async function asks the keyserver for its keys, whereas performAuth // above gets the keyserver's keys from the identity service const getInitialNotificationsEncryptedMessageForRecovery = useInitialNotificationsEncryptedMessage(keyserverID); const { resolveKeyserverSessionInvalidationUsingNativeCredentials } = getConfig(); const innerPerformRecovery = React.useCallback( ( recoveryActionSource: RecoveryActionSource, hasBeenCancelled: () => boolean, ) => async ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, callKeyserverEndpoint: CallKeyserverEndpoint, ) => { if (!resolveKeyserverSessionInvalidationUsingNativeCredentials) { return; } await resolveKeyserverSessionInvalidationUsingNativeCredentials( callSingleKeyserverEndpoint, callKeyserverEndpoint, dispatchActionPromise, recoveryActionSource, keyserverID, getInitialNotificationsEncryptedMessageForRecovery, hasBeenCancelled, ); }, [ resolveKeyserverSessionInvalidationUsingNativeCredentials, dispatchActionPromise, keyserverID, getInitialNotificationsEncryptedMessageForRecovery, ], ); - const dispatch = useDispatch(); - const urlPrefix = useSelector(urlPrefixSelector(keyserverID)); + const keyserverRecoveryLogIn = useKeyserverRecoveryLogIn(keyserverID); const performRecovery = React.useCallback( (recoveryActionSource: RecoveryActionSource) => { - invariant( - urlPrefix, - `urlPrefix for ${keyserverID} should be set during performRecovery`, - ); - setAuthInProgress(true); let cancelled = false; const cancel = () => { cancelled = true; setAuthInProgress(false); }; const hasBeenCancelled = () => cancelled; const promise = (async () => { - const userStateBeforeRecovery = preRequestUserStateRef.current; try { - const recoverySessionChange = - await resolveKeyserverSessionInvalidation( - dispatch, - cookie, - urlPrefix, - recoveryActionSource, - keyserverID, - innerPerformRecovery(recoveryActionSource, hasBeenCancelled), - ); - const sessionChange = - recoverySessionChange ?? genericCookieInvalidation; - if ( - sessionChange.cookieInvalidated || - !sessionChange.cookie || - !sessionChange.cookie.startsWith('user=') - ) { - setNewSession( - dispatch, - sessionChange, - userStateBeforeRecovery, - null, - recoveryActionSource, - keyserverID, - ); - } - } catch (e) { - if (cancelled) { - return; - } - - console.log( - `Error while recovering session with keyserver id ${keyserverID}`, - e, - ); - - setNewSession( - dispatch, - genericCookieInvalidation, - userStateBeforeRecovery, - null, + await keyserverRecoveryLogIn( recoveryActionSource, - keyserverID, + innerPerformRecovery(recoveryActionSource, hasBeenCancelled), + hasBeenCancelled, ); } finally { if (!cancelled) { setAuthInProgress(false); } } })(); + return [promise, cancel]; }, - [dispatch, cookie, urlPrefix, keyserverID, innerPerformRecovery], + [keyserverRecoveryLogIn, innerPerformRecovery], ); const cancelPendingAuth = React.useRef void>(null); const prevPerformAuth = React.useRef(performAuth); const isUserAuthenticated = useSelector(isLoggedInToKeyserver(keyserverID)); const hasAccessToken = useSelector(state => !!state.commServicesAccessToken); const cancelPendingRecovery = React.useRef void>(null); const prevPerformRecovery = React.useRef(performRecovery); React.useEffect(() => { if (activeSessionRecovery && isUserAuthenticated) { cancelPendingAuth.current?.(); cancelPendingAuth.current = null; if (prevPerformRecovery.current !== performRecovery) { cancelPendingRecovery.current?.(); cancelPendingRecovery.current = null; prevPerformRecovery.current = performRecovery; } if (!authInProgress) { const [, cancel] = performRecovery(activeSessionRecovery); cancelPendingRecovery.current = cancel; } return; } cancelPendingRecovery.current?.(); cancelPendingRecovery.current = null; if (!hasAccessToken) { cancelPendingAuth.current?.(); cancelPendingAuth.current = null; } if ( !usingCommServicesAccessToken || isUserAuthenticated || !hasAccessToken ) { return; } if (prevPerformAuth.current !== performAuth) { cancelPendingAuth.current?.(); cancelPendingAuth.current = null; } prevPerformAuth.current = performAuth; if (authInProgress) { return; } const [, cancel] = performAuth(); cancelPendingAuth.current = cancel; }, [ activeSessionRecovery, authInProgress, performRecovery, hasAccessToken, isUserAuthenticated, performAuth, ]); return ; } const Handler: React.ComponentType = React.memo( KeyserverConnectionHandler, ); export default Handler; diff --git a/lib/keyserver-conn/recovery-utils.js b/lib/keyserver-conn/recovery-utils.js index 2fcbf50d7..3ca9faa03 100644 --- a/lib/keyserver-conn/recovery-utils.js +++ b/lib/keyserver-conn/recovery-utils.js @@ -1,121 +1,235 @@ // @flow +import invariant from 'invariant'; +import * as React from 'react'; + import { setNewSession, type CallKeyserverEndpoint, } from './keyserver-conn-types.js'; +import { + cookieSelector, + sessionIDSelector, + urlPrefixSelector, +} from '../selectors/keyserver-selectors.js'; import type { RecoveryActionSource } from '../types/account-types.js'; import type { Endpoint } from '../types/endpoints.js'; import type { Dispatch } from '../types/redux-types.js'; -import type { ClientSessionChange } from '../types/session-types.js'; +import { + type ClientSessionChange, + genericCookieInvalidation, +} from '../types/session-types.js'; import callSingleKeyServerEndpoint from '../utils/call-single-keyserver-endpoint.js'; import type { CallSingleKeyserverEndpoint, CallSingleKeyserverEndpointOptions, } from '../utils/call-single-keyserver-endpoint.js'; import { getConfig } from '../utils/config.js'; import { promiseAll } from '../utils/promises.js'; +import { useSelector, useDispatch } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken } from '../utils/services-utils.js'; // This function is a shortcut that tells us whether it's worth even trying to // call resolveKeyserverSessionInvalidation function canResolveKeyserverSessionInvalidation(): boolean { if (usingCommServicesAccessToken) { // We can always try to resolve a keyserver session invalidation // automatically using the Olm auth responder return true; } const { resolveKeyserverSessionInvalidationUsingNativeCredentials } = getConfig(); // If we can't use the Olm auth responder, then we can only resolve a // keyserver session invalidation on native, where we have access to the // user's native credentials. Note that we can't do this for ETH users, but we // don't know if the user is an ETH user from this function return !!resolveKeyserverSessionInvalidationUsingNativeCredentials; } // This function attempts to resolve an invalid keyserver session. A session can // become invalid when a keyserver invalidates it, or due to inconsistent client // state. If the client is usingCommServicesAccessToken, then the invalidation // recovery will try to go through the keyserver's Olm auth responder. // Otherwise, it will attempt to use the user's credentials to log in with the // legacy auth responder, which won't work on web and won't work for ETH users. async function resolveKeyserverSessionInvalidation( dispatch: Dispatch, cookie: ?string, urlPrefix: string, recoveryActionSource: RecoveryActionSource, keyserverID: string, actionFunc: ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, callKeyserverEndpoint: CallKeyserverEndpoint, ) => Promise, ): Promise { let newSessionChange = null; const boundCallSingleKeyserverEndpoint = async ( endpoint: Endpoint, data: { +[key: string]: mixed }, options?: ?CallSingleKeyserverEndpointOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; setNewSession( dispatch, sessionChange, null, error, recoveryActionSource, keyserverID, ); }; return await callSingleKeyServerEndpoint( cookie, innerBoundSetNewSession, () => new Promise(r => r(null)), () => new Promise(r => r(null)), urlPrefix, null, false, null, null, endpoint, data, dispatch, options, false, keyserverID, ); }; const boundCallKeyserverEndpoint = ( endpoint: Endpoint, requests: { +[keyserverID: string]: ?{ +[string]: mixed } }, options?: ?CallSingleKeyserverEndpointOptions, ) => { if (requests[keyserverID]) { const promises = { [keyserverID]: boundCallSingleKeyserverEndpoint( endpoint, requests[keyserverID], options, ), }; return promiseAll(promises); } return Promise.resolve({}); }; await actionFunc( boundCallSingleKeyserverEndpoint, boundCallKeyserverEndpoint, ); return newSessionChange; } +function useKeyserverRecoveryLogIn( + keyserverID: string, +): ( + source: RecoveryActionSource, + actionFunc: ( + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, + callKeyserverEndpoint: CallKeyserverEndpoint, + ) => Promise, + hasBeenCancelled: () => boolean, +) => Promise { + const preRequestUserInfo = useSelector(state => state.currentUserInfo); + const cookie = useSelector(cookieSelector(keyserverID)); + const sessionID = useSelector(sessionIDSelector(keyserverID)); + const preRequestUserState = React.useMemo( + () => ({ + currentUserInfo: preRequestUserInfo, + cookiesAndSessions: { + [keyserverID]: { + cookie, + sessionID, + }, + }, + }), + [preRequestUserInfo, keyserverID, cookie, sessionID], + ); + + // We only need to do a "spot check" on this value below. + // - To avoid regenerating performRecovery whenever it changes, we want to + // make sure it's not in that function's dep list. + // - If we exclude it from that function's dep list, we'll end up binding in + // the value of preRequestUserState at the time performRecovery is updated. + // Instead, by assigning to a ref, we are able to use the latest value. + const preRequestUserStateRef = React.useRef(preRequestUserState); + preRequestUserStateRef.current = preRequestUserState; + + const dispatch = useDispatch(); + const urlPrefix = useSelector(urlPrefixSelector(keyserverID)); + + return React.useCallback( + async ( + source: RecoveryActionSource, + actionFunc: ( + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, + callKeyserverEndpoint: CallKeyserverEndpoint, + ) => Promise, + hasBeenCancelled: () => boolean, + ) => { + invariant( + urlPrefix, + `urlPrefix for ${keyserverID} should be set during recovery login`, + ); + + const userStateBeforeRecovery = preRequestUserStateRef.current; + try { + const recoverySessionChange = await resolveKeyserverSessionInvalidation( + dispatch, + cookie, + urlPrefix, + source, + keyserverID, + actionFunc, + ); + const sessionChange = + recoverySessionChange ?? genericCookieInvalidation; + if ( + sessionChange.cookieInvalidated || + !sessionChange.cookie || + !sessionChange.cookie.startsWith('user=') + ) { + setNewSession( + dispatch, + sessionChange, + userStateBeforeRecovery, + null, + source, + keyserverID, + ); + } + } catch (e) { + if (hasBeenCancelled()) { + return; + } + console.log( + `Error during recovery login with keyserver ${keyserverID}`, + e, + ); + setNewSession( + dispatch, + genericCookieInvalidation, + userStateBeforeRecovery, + null, + source, + keyserverID, + ); + throw e; + } + }, + [keyserverID, dispatch, cookie, urlPrefix], + ); +} + export { canResolveKeyserverSessionInvalidation, resolveKeyserverSessionInvalidation, + useKeyserverRecoveryLogIn, };