diff --git a/lib/components/keyserver-connection-handler.js b/lib/components/keyserver-connection-handler.js index 3f161da1e..1115a2011 100644 --- a/lib/components/keyserver-connection-handler.js +++ b/lib/components/keyserver-connection-handler.js @@ -1,354 +1,354 @@ // @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 { CANCELLED_ERROR, type CallKeyserverEndpoint, } from '../keyserver-conn/keyserver-conn-types.js'; import { useKeyserverRecoveryLogIn } from '../keyserver-conn/recovery-utils.js'; import { filterThreadIDsInFilterList } from '../reducers/calendar-filters-reducer.js'; import { connectionSelector, cookieSelector, deviceTokenSelector, } 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 { 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 } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken, relyingOnAuthoritativeKeyserver, } 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 && - keyserverID === authoritativeKeyserverID && + keyserverID === authoritativeKeyserverID() && relyingOnAuthoritativeKeyserver ) { void dispatchActionPromise(logOutActionTypes, callLogOut()); } }, [callLogOut, hasConnectionIssue, dispatchActionPromise, keyserverID]); 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() && relyingOnAuthoritativeKeyserver ) { 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, ); // 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 keyserverRecoveryLogIn = useKeyserverRecoveryLogIn(keyserverID); const performRecovery = React.useCallback( (recoveryActionSource: RecoveryActionSource) => { setAuthInProgress(true); let cancelled = false; const cancel = () => { cancelled = true; setAuthInProgress(false); }; const hasBeenCancelled = () => cancelled; const promise = (async () => { try { await keyserverRecoveryLogIn( recoveryActionSource, innerPerformRecovery(recoveryActionSource, hasBeenCancelled), hasBeenCancelled, ); } finally { if (!cancelled) { setAuthInProgress(false); } } })(); return [promise, cancel]; }, [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 dd68df823..287b7324d 100644 --- a/lib/keyserver-conn/recovery-utils.js +++ b/lib/keyserver-conn/recovery-utils.js @@ -1,248 +1,263 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { setNewSession, type CallKeyserverEndpoint, } from './keyserver-conn-types.js'; +import { logOutActionTypes, useLogOut } from '../actions/user-actions.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, genericCookieInvalidation, type PreRequestUserState, } from '../types/session-types.js'; +import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.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 { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector, useDispatch } from '../utils/redux-utils.js'; -import { usingCommServicesAccessToken } from '../utils/services-utils.js'; +import { + usingCommServicesAccessToken, + relyingOnAuthoritativeKeyserver, +} 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)); + const logOut = useLogOut(); + const dispatchActionPromise = useDispatchActionPromise(); + const invalidateKeyserverSession = React.useCallback( ( source: RecoveryActionSource, sessionChange: ClientSessionChange, userStateBeforeRecovery: PreRequestUserState, ) => { setNewSession( dispatch, sessionChange, userStateBeforeRecovery, null, source, keyserverID, ); + if ( + keyserverID === authoritativeKeyserverID() && + relyingOnAuthoritativeKeyserver + ) { + void dispatchActionPromise(logOutActionTypes, logOut()); + } }, - [dispatch, keyserverID], + [dispatch, keyserverID, dispatchActionPromise, logOut], ); 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=') ) { invalidateKeyserverSession( source, sessionChange, userStateBeforeRecovery, ); } } catch (e) { if (hasBeenCancelled()) { return; } console.log( `Error during recovery login with keyserver ${keyserverID}`, e, ); invalidateKeyserverSession( source, genericCookieInvalidation, userStateBeforeRecovery, ); throw e; } }, [keyserverID, dispatch, cookie, urlPrefix, invalidateKeyserverSession], ); } export { canResolveKeyserverSessionInvalidation, resolveKeyserverSessionInvalidation, useKeyserverRecoveryLogIn, };