diff --git a/lib/keyserver-conn/keyserver-auth.js b/lib/keyserver-conn/keyserver-auth.js index 8a8d480ce..dfd2b2dfb 100644 --- a/lib/keyserver-conn/keyserver-auth.js +++ b/lib/keyserver-conn/keyserver-auth.js @@ -1,174 +1,187 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; +import { useCallKeyserverEndpointContext } from './call-keyserver-endpoint-provider.react.js'; import { extractKeyserverIDFromID } from './keyserver-call-utils.js'; import { CANCELLED_ERROR, type CallKeyserverEndpoint, } from './keyserver-conn-types.js'; import { keyserverAuthActionTypes, keyserverAuthRawAction, } from '../actions/user-actions.js'; import { filterThreadIDsInFilterList } from '../reducers/calendar-filters-reducer.js'; import { cookieSelector, deviceTokenSelector, } from '../selectors/keyserver-selectors.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import type { AuthActionSource } from '../types/account-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import { getConfig } from '../utils/config.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import sleep from '../utils/sleep.js'; const AUTH_RETRY_DELAY_MS = 60000; type KeyserverAuthInputs = { +authActionSource: AuthActionSource, +setInProgress: boolean => mixed, +hasBeenCancelled: () => boolean, +doNotRegister: boolean, }; type RawKeyserverAuthFunc = KeyserverAuthInputs => CallKeyserverEndpoint => Promise; function useRawKeyserverAuth(keyserverID: string): RawKeyserverAuthFunc { 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]); const cookie = useSelector(cookieSelector(keyserverID)); const keyserverDeviceToken = useSelector(deviceTokenSelector(keyserverID)); // We have an assumption that we should be always connected to the // authoritative 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 authoritativeKeyserverDeviceToken = useSelector( deviceTokenSelector(authoritativeKeyserverID()), ); const deviceToken = keyserverDeviceToken ?? authoritativeKeyserverDeviceToken; const dispatchActionPromise = useDispatchActionPromise(); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { identityClient, getAuthMetadata } = identityContext; const { olmAPI } = getConfig(); const currentUserInfo = useSelector(state => state.currentUserInfo); return React.useCallback( (inputs: KeyserverAuthInputs) => async (innerCallKeyserverEndpoint: CallKeyserverEndpoint) => { const { authActionSource, setInProgress, hasBeenCancelled, doNotRegister, } = inputs; try { const [keyserverKeys] = await Promise.all([ identityClient.getKeyserverKeys(keyserverID), olmAPI.initializeCryptoAccount(), ]); if (hasBeenCancelled()) { throw new Error(CANCELLED_ERROR); } const [notifsSession, contentSession, { userID, deviceID }] = await Promise.all([ olmAPI.notificationsSessionCreator( cookie, keyserverKeys.identityKeysBlob.notificationIdentityPublicKeys, keyserverKeys.notifInitializationInfo, keyserverID, ), olmAPI.contentOutboundSessionCreator( keyserverKeys.identityKeysBlob.primaryIdentityPublicKeys, keyserverKeys.contentInitializationInfo, ), getAuthMetadata(), ]); invariant(userID, 'userID should be set'); invariant(deviceID, 'deviceID should be set'); const deviceTokenUpdateInput = deviceToken ? { [keyserverID]: { deviceToken } } : {}; if (hasBeenCancelled()) { throw new Error(CANCELLED_ERROR); } const authPromise = keyserverAuthRawAction( innerCallKeyserverEndpoint, )({ userID, deviceID, doNotRegister, calendarQuery, deviceTokenUpdateInput, authActionSource, keyserverData: { [keyserverID]: { initialContentEncryptedMessage: contentSession.encryptedData.message, initialNotificationsEncryptedMessage: notifsSession, }, }, preRequestUserInfo: currentUserInfo, }); await dispatchActionPromise(keyserverAuthActionTypes, authPromise); } catch (e) { if (hasBeenCancelled()) { return; } console.log( `Error while authenticating to keyserver with id ${keyserverID}`, e, ); throw e; } finally { if (!hasBeenCancelled()) { void (async () => { await sleep(AUTH_RETRY_DELAY_MS); setInProgress(false); })(); } } }, [ calendarQuery, cookie, deviceToken, dispatchActionPromise, getAuthMetadata, identityClient, keyserverID, olmAPI, currentUserInfo, ], ); } -export { useRawKeyserverAuth }; +type KeyserverAuthFunc = KeyserverAuthInputs => Promise; + +function useKeyserverAuth(keyserverID: string): KeyserverAuthFunc { + const rawKeyserverAuth = useRawKeyserverAuth(keyserverID); + const { callKeyserverEndpoint } = useCallKeyserverEndpointContext(); + return React.useCallback( + (inputs: KeyserverAuthInputs) => + rawKeyserverAuth(inputs)(callKeyserverEndpoint), + [rawKeyserverAuth, callKeyserverEndpoint], + ); +} + +export { useRawKeyserverAuth, useKeyserverAuth }; diff --git a/native/account/registration/registration-server-call.js b/native/account/registration/registration-server-call.js index 2d23cc2cb..c1e519c4e 100644 --- a/native/account/registration/registration-server-call.js +++ b/native/account/registration/registration-server-call.js @@ -1,351 +1,410 @@ // @flow import * as React from 'react'; import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js'; import { setSyncedMetadataEntryActionType } from 'lib/actions/synced-metadata-actions.js'; import { keyserverRegisterActionTypes, keyserverRegister, useIdentityPasswordRegister, identityRegisterActionTypes, } from 'lib/actions/user-actions.js'; +import { useKeyserverAuth } from 'lib/keyserver-conn/keyserver-auth.js'; import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; import { isLoggedInToKeyserver } from 'lib/selectors/user-selectors.js'; -import type { LogInStartingPayload } from 'lib/types/account-types.js'; +import { + type LogInStartingPayload, + logInActionSources, +} from 'lib/types/account-types.js'; import { syncedMetadataNames } from 'lib/types/synced-metadata-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; import { setURLPrefix } from 'lib/utils/url-utils.js'; import type { RegistrationServerCallInput, UsernameAccountSelection, AvatarData, } from './registration-types.js'; import { authoritativeKeyserverID } from '../../authoritative-keyserver.js'; import { useNativeSetUserAvatar, useUploadSelectedMedia, } from '../../avatars/avatar-hooks.js'; import { commCoreModule } from '../../native-modules.js'; import { useSelector } from '../../redux/redux-utils.js'; import { nativeLogInExtraInfoSelector } from '../../selectors/account-selectors.js'; import { AppOutOfDateAlertDetails, UsernameReservedAlertDetails, UsernameTakenAlertDetails, UnknownErrorAlertDetails, } from '../../utils/alert-messages.js'; import Alert from '../../utils/alert.js'; import { setNativeCredentials } from '../native-credentials.js'; import { useLegacySIWEServerCall, useIdentityWalletRegisterCall, } from '../siwe-hooks.js'; // We can't just do everything in one async callback, since the server calls // would get bound to Redux state from before the registration. The registration // flow has multiple steps where critical Redux state is changed, where // subsequent steps depend on accessing the updated Redux state. // To address this, we break the registration process up into multiple steps. // When each step completes we update the currentStep state, and we have Redux // selectors that trigger useEffects for subsequent steps when relevant data // starts to appear in Redux. type CurrentStep = | { +step: 'inactive' } | { - +step: 'waiting_for_registration_call', + +step: 'identity_registration_dispatched', + +clearCachedSelections: () => void, + +avatarData: ?AvatarData, + +resolve: () => void, + +reject: Error => void, + } + | { + +step: 'authoritative_keyserver_registration_dispatched', +clearCachedSelections: () => void, +avatarData: ?AvatarData, +resolve: () => void, +reject: Error => void, }; const inactiveStep = { step: 'inactive' }; function useRegistrationServerCall(): RegistrationServerCallInput => Promise { const [currentStep, setCurrentStep] = React.useState(inactiveStep); // STEP 1: ACCOUNT REGISTRATION const logInExtraInfo = useSelector(nativeLogInExtraInfoSelector); const dispatchActionPromise = useDispatchActionPromise(); const callKeyserverRegister = useLegacyAshoatKeyserverCall(keyserverRegister); const callIdentityPasswordRegister = useIdentityPasswordRegister(); const identityRegisterUsernameAccount = React.useCallback( async ( accountSelection: UsernameAccountSelection, farcasterID: ?string, ) => { const identityRegisterPromise = (async () => { try { const result = await callIdentityPasswordRegister( accountSelection.username, accountSelection.password, farcasterID, ); await setNativeCredentials({ username: accountSelection.username, password: accountSelection.password, }); return result; } catch (e) { if (e.message === 'username reserved') { Alert.alert( UsernameReservedAlertDetails.title, UsernameReservedAlertDetails.message, ); } else if (e.message === 'username already exists') { Alert.alert( UsernameTakenAlertDetails.title, UsernameTakenAlertDetails.message, ); } else if (e.message === 'Unsupported version') { Alert.alert( AppOutOfDateAlertDetails.title, AppOutOfDateAlertDetails.message, ); } else { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, ); } throw e; } })(); void dispatchActionPromise( identityRegisterActionTypes, identityRegisterPromise, ); await identityRegisterPromise; }, [callIdentityPasswordRegister, dispatchActionPromise], ); const keyserverRegisterUsernameAccount = React.useCallback( async ( accountSelection: UsernameAccountSelection, keyserverURL: string, ) => { const extraInfo = await logInExtraInfo(); const keyserverRegisterPromise = (async () => { try { const result = await callKeyserverRegister( { ...extraInfo, username: accountSelection.username, password: accountSelection.password, }, { urlPrefixOverride: keyserverURL, }, ); await setNativeCredentials({ username: result.currentUserInfo.username, password: accountSelection.password, }); return result; } catch (e) { if (e.message === 'username_reserved') { Alert.alert( UsernameReservedAlertDetails.title, UsernameReservedAlertDetails.message, ); } else if (e.message === 'username_taken') { Alert.alert( UsernameTakenAlertDetails.title, UsernameTakenAlertDetails.message, ); } else if (e.message === 'client_version_unsupported') { Alert.alert( AppOutOfDateAlertDetails.title, AppOutOfDateAlertDetails.message, ); } else { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, ); } throw e; } })(); void dispatchActionPromise( keyserverRegisterActionTypes, keyserverRegisterPromise, undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); await keyserverRegisterPromise; }, [logInExtraInfo, callKeyserverRegister, dispatchActionPromise], ); const legacySiweServerCall = useLegacySIWEServerCall(); const identityWalletRegisterCall = useIdentityWalletRegisterCall(); const dispatch = useDispatch(); const returnedFunc = React.useCallback( (input: RegistrationServerCallInput) => new Promise( // eslint-disable-next-line no-async-promise-executor async (resolve, reject) => { try { if (currentStep.step !== 'inactive') { return; } const { accountSelection, avatarData, keyserverURL, farcasterID, siweBackupSecrets, clearCachedSelections, } = input; if ( accountSelection.accountType === 'username' && !usingCommServicesAccessToken ) { await keyserverRegisterUsernameAccount( accountSelection, keyserverURL, ); } else if (accountSelection.accountType === 'username') { await identityRegisterUsernameAccount( accountSelection, farcasterID, ); } else if (!usingCommServicesAccessToken) { try { await legacySiweServerCall(accountSelection, { urlPrefixOverride: keyserverURL, }); } catch (e) { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, ); throw e; } } else { try { await identityWalletRegisterCall({ address: accountSelection.address, message: accountSelection.message, signature: accountSelection.signature, fid: farcasterID, }); } catch (e) { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, ); throw e; } } dispatch({ type: setURLPrefix, payload: keyserverURL, }); if (farcasterID) { dispatch({ type: setSyncedMetadataEntryActionType, payload: { name: syncedMetadataNames.CURRENT_USER_FID, data: farcasterID, }, }); } if (siweBackupSecrets) { await commCoreModule.setSIWEBackupSecrets(siweBackupSecrets); } setCurrentStep({ - step: 'waiting_for_registration_call', + step: 'identity_registration_dispatched', avatarData, clearCachedSelections, resolve, reject, }); } catch (e) { reject(e); } }, ), [ currentStep, keyserverRegisterUsernameAccount, identityRegisterUsernameAccount, legacySiweServerCall, dispatch, identityWalletRegisterCall, ], ); - // STEP 2: SETTING AVATAR + // STEP 2: REGISTERING ON AUTHORITATIVE KEYSERVER + + const keyserverAuth = useKeyserverAuth(authoritativeKeyserverID); + + const isRegisteredOnIdentity = useSelector( + state => + !!state.commServicesAccessToken && + !!state.currentUserInfo && + !state.currentUserInfo.anonymous, + ); + + const registeringOnAuthoritativeKeyserverRef = React.useRef(false); + React.useEffect(() => { + if ( + !isRegisteredOnIdentity || + currentStep.step !== 'identity_registration_dispatched' || + registeringOnAuthoritativeKeyserverRef.current + ) { + return; + } + registeringOnAuthoritativeKeyserverRef.current = true; + const { avatarData, clearCachedSelections, resolve, reject } = currentStep; + void (async () => { + try { + await keyserverAuth({ + authActionSource: process.env.BROWSER + ? logInActionSources.keyserverAuthFromWeb + : logInActionSources.keyserverAuthFromNative, + setInProgress: () => {}, + hasBeenCancelled: () => false, + doNotRegister: false, + }); + setCurrentStep({ + step: 'authoritative_keyserver_registration_dispatched', + avatarData, + clearCachedSelections, + resolve, + reject, + }); + } catch (e) { + reject(e); + setCurrentStep(inactiveStep); + } finally { + registeringOnAuthoritativeKeyserverRef.current = false; + } + })(); + }, [currentStep, isRegisteredOnIdentity, keyserverAuth]); + + // STEP 3: SETTING AVATAR const uploadSelectedMedia = useUploadSelectedMedia(); const nativeSetUserAvatar = useNativeSetUserAvatar(); const isLoggedInToAuthoritativeKeyserver = useSelector( isLoggedInToKeyserver(authoritativeKeyserverID), ); const avatarBeingSetRef = React.useRef(false); React.useEffect(() => { if ( !isLoggedInToAuthoritativeKeyserver || - currentStep.step !== 'waiting_for_registration_call' || + currentStep.step !== 'authoritative_keyserver_registration_dispatched' || avatarBeingSetRef.current ) { return; } avatarBeingSetRef.current = true; const { avatarData, resolve, clearCachedSelections } = currentStep; void (async () => { try { if (!avatarData) { return; } let updateUserAvatarRequest; if (!avatarData.needsUpload) { ({ updateUserAvatarRequest } = avatarData); } else { const { mediaSelection } = avatarData; updateUserAvatarRequest = await uploadSelectedMedia(mediaSelection); if (!updateUserAvatarRequest) { return; } } await nativeSetUserAvatar(updateUserAvatarRequest); } finally { dispatch({ type: setDataLoadedActionType, payload: { dataLoaded: true, }, }); clearCachedSelections(); setCurrentStep(inactiveStep); avatarBeingSetRef.current = false; resolve(); } })(); }, [ currentStep, isLoggedInToAuthoritativeKeyserver, uploadSelectedMedia, nativeSetUserAvatar, dispatch, ]); return returnedFunc; } export { useRegistrationServerCall };