From d22244c1cebef3a9648e87012bdb8c579598c1fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?txb=C3=AC?= <46839250+0xTxbi@users.noreply.github.com> Date: Fri, 8 Nov 2024 22:31:55 +0100 Subject: [PATCH] fix(unlock-app): refactor waas util and clean up sessions (#15046) * refactor keyUtil * improve google sign in component * sign out nextauth session upon successful privy login * cleanup --- .../components/legacy-auth/ConnectToPrivy.tsx | 11 +- .../components/legacy-auth/SignInWithCode.tsx | 191 ++++++++++++------ .../legacy-auth/SignInWithGoogle.tsx | 185 ++++++++++------- 3 files changed, 249 insertions(+), 138 deletions(-) diff --git a/unlock-app/src/components/legacy-auth/ConnectToPrivy.tsx b/unlock-app/src/components/legacy-auth/ConnectToPrivy.tsx index 8e064058cf7..e3f609245f6 100644 --- a/unlock-app/src/components/legacy-auth/ConnectToPrivy.tsx +++ b/unlock-app/src/components/legacy-auth/ConnectToPrivy.tsx @@ -3,6 +3,7 @@ import { useLogin } from '@privy-io/react-auth' import { useEffect, useRef } from 'react' import { LoginModal } from '@privy-io/react-auth' +import { signOut as nextAuthSignOut, useSession } from 'next-auth/react' interface ConnectToPrivyProps { userEmail: string @@ -13,9 +14,15 @@ export default function ConnectToPrivy({ userEmail, onNext, }: ConnectToPrivyProps) { + const { data: session } = useSession() + const { login } = useLogin({ - onComplete: () => { - // When login is complete, proceed to next step (tab) + onComplete: async () => { + // When Privy login is complete, sign out of next-auth session + if (session) { + await nextAuthSignOut({ redirect: false }) + } + // Then proceed to next step onNext() }, onError: (error) => { diff --git a/unlock-app/src/components/legacy-auth/SignInWithCode.tsx b/unlock-app/src/components/legacy-auth/SignInWithCode.tsx index a70a9adacb5..4b6e35850cf 100644 --- a/unlock-app/src/components/legacy-auth/SignInWithCode.tsx +++ b/unlock-app/src/components/legacy-auth/SignInWithCode.tsx @@ -7,6 +7,7 @@ import { InitializeWaas, PrivateKeyFormat, RawPrivateKey, + Waas, } from '@coinbase/waas-sdk-web' import { config } from '~/config/app' import { getUserWaasUuid } from '~/utils/getUserWaasUuid' @@ -15,64 +16,124 @@ import { Button } from '@unlock-protocol/ui' import { getSession } from 'next-auth/react' import ReCAPTCHA from 'react-google-recaptcha' -// TODO: finish testing this works in a "real" environment (can't test with existing accounts on a different domain) -export const getPrivateKeyFromWaas = async ( - captcha: string, - accountType: UserAccountType -) => { - const waas = await InitializeWaas({ +// Singleton instance for WAAS +let waasInstance: Waas | null = null + +/** + * Initialize and return the WAAS instance. + * Ensures that WAAS is initialized only once (Singleton Pattern). + */ +const getWaasInstance = async (): Promise => { + if (waasInstance) { + return waasInstance + } + + waasInstance = await InitializeWaas({ collectAndReportMetrics: true, enableHostedBackups: true, prod: config.env === 'prod', projectId: config.coinbaseProjectId, }) - const user = await waas.auth.login({ - provideAuthToken: async () => { - const nextAuthSession = await getSession() - const waasToken = await getUserWaasUuid( - captcha, - nextAuthSession?.user?.email as string, - accountType, - nextAuthSession?.user?.token as string - ) - return waasToken! - }, - }) + return waasInstance +} - console.log('user from waas', user) - - let wallet - - if (waas.wallets.wallet) { - // Resuming wallet - wallet = waas.wallets.wallet - } else if (user.hasWallet) { - // Restoring wallet - console.log('restoring wallet') - wallet = await waas.wallets.restoreFromHostedBackup() - console.log('wallet from waas', wallet) - } else { - console.log('creating a waas wallet (for debugging only!)', wallet) - // Creating wallet - wallet = await waas.wallets.create() - } +// TODO: finish testing this works in a "real" environment (can't test with existing accounts on a different domain) +/** + * Retrieves the private key from WAAS. + * @param captcha - The CAPTCHA value. + * @param accountType - The type of user account. + * @returns The private key string or null if not found. + */ +export const getPrivateKeyFromWaas = async ( + captcha: string, + accountType: UserAccountType +): Promise => { + try { + const waas = await getWaasInstance() - if (!wallet) { - console.error('No wallet linked to that user. It cannot be migrated.') - return - } + const user = await waas.auth.login({ + provideAuthToken: async () => { + const nextAuthSession = await getSession() + if ( + !nextAuthSession || + !nextAuthSession.user || + !nextAuthSession.user.email || + !nextAuthSession.user.token + ) { + throw new Error('Invalid session data') + } - const exportedKeys = await wallet.exportKeysFromHostedBackup( - undefined, - 'RAW' as PrivateKeyFormat - ) - // use the first key's private key (ecKeyPrivate) - if (exportedKeys.length > 0) { - const firstKey = exportedKeys[0] as RawPrivateKey - return firstKey.ecKeyPrivate - } else { - console.error('No private keys found in wallet, so it cannot be migrated.') + const waasToken = await getUserWaasUuid( + captcha, + nextAuthSession.user.email, + accountType, + nextAuthSession.user.token + ) + + if (!waasToken) { + throw new Error('Failed to retrieve WAAS token') + } + + return waasToken + }, + }) + + // Conditionally log based on environment + if (config.env !== 'prod') { + console.log('user from waas', user) + } + + let wallet: any = null + + if (waas.wallets.wallet) { + // Resuming wallet + wallet = waas.wallets.wallet + } else if (user.hasWallet) { + // Restoring wallet + if (config.env !== 'prod') { + console.log('restoring wallet') + } + wallet = await waas.wallets.restoreFromHostedBackup() + + if (config.env !== 'prod') { + console.log('wallet from waas', wallet) + } + } else { + if (config.env !== 'prod') { + console.log('creating a waas wallet (for debugging only!)') + } + // Creating wallet + wallet = await waas.wallets.create() + } + + if (!wallet) { + console.error('No wallet linked to that user. It cannot be migrated.') + return null + } + + const exportedKeys = await wallet.exportKeysFromHostedBackup( + undefined, + PrivateKeyFormat.RAW + ) + + if (config.env !== 'prod') { + console.log('exportedKeys', exportedKeys) + } + + // Use the first key's private key (ecKeyPrivate) + if (exportedKeys.length > 0) { + const firstKey = exportedKeys[0] as RawPrivateKey + return firstKey.ecKeyPrivate + } else { + console.error( + 'No private keys found in wallet, so it cannot be migrated.' + ) + return null + } + } catch (error) { + console.error('Error in getPrivateKeyFromWaas:', error) + return null } } @@ -89,24 +150,38 @@ export const SignInWithCode = ({ const sendEmailCode = async () => { try { const captcha = await getCaptchaValue() + if (!captcha) { + ToastHelper.error('CAPTCHA verification failed') + return + } await locksmith.sendVerificationCode(captcha, email) ToastHelper.success('Email code sent!') setCodeSent(true) } catch (error) { - console.error(error) + console.error('Error sending email code:', error) ToastHelper.error('Error sending email code, try again later') } } const onCodeCorrect = async () => { - const privateKey = await getPrivateKeyFromWaas( - await getCaptchaValue(), - UserAccountType.EmailCodeAccount - ) - if (privateKey) { - onNext(privateKey) - } else { - ToastHelper.error('Error getting private key from WAAS') + try { + const captcha = await getCaptchaValue() + if (!captcha) { + ToastHelper.error('CAPTCHA verification failed') + return + } + const privateKey = await getPrivateKeyFromWaas( + captcha, + UserAccountType.EmailCodeAccount + ) + if (privateKey) { + onNext(privateKey) + } else { + ToastHelper.error('Error getting private key from WAAS') + } + } catch (error) { + console.error('Error in onCodeCorrect:', error) + ToastHelper.error('Error processing the code') } } diff --git a/unlock-app/src/components/legacy-auth/SignInWithGoogle.tsx b/unlock-app/src/components/legacy-auth/SignInWithGoogle.tsx index 39b3a38fcb4..cfd00635eec 100644 --- a/unlock-app/src/components/legacy-auth/SignInWithGoogle.tsx +++ b/unlock-app/src/components/legacy-auth/SignInWithGoogle.tsx @@ -1,7 +1,5 @@ import SvgComponents from '../interface/svg' - import { popupCenter } from '~/utils/popup' - import { ConnectButton } from '../interface/connect/Custom' import { getSession } from 'next-auth/react' import { ToastHelper } from '../helpers/toast.helper' @@ -10,6 +8,7 @@ import { UserAccountType } from '~/utils/userAccountType' import { getPrivateKeyFromWaas } from './SignInWithCode' import ReCAPTCHA from 'react-google-recaptcha' import { config } from '~/config/app' +import { useState, useEffect, useRef } from 'react' export interface SignInWithGoogleProps { onNext: (privateKey: string) => void @@ -17,95 +16,124 @@ export interface SignInWithGoogleProps { export const SignInWithGoogle = ({ onNext }: SignInWithGoogleProps) => { const { getCaptchaValue, recaptchaRef } = useCaptcha() + const [isAuthenticating, setIsAuthenticating] = useState(false) + const cleanupRef = useRef<() => void>(() => {}) + + useEffect(() => { + // Cleanup on unmount + return () => { + cleanupRef.current() + } + }, []) const handleSignWithGoogle = async () => { - try { - console.log('Starting Google sign in process') - const popup = popupCenter('/google-sign-in', 'Google Sign In') - if (!popup) { - throw new Error('Failed to open popup') + if (isAuthenticating) return + setIsAuthenticating(true) + + let sessionCheckInterval: NodeJS.Timeout | null = null + let popupWindow: Window | null = null + + // Cleanup function + const cleanup = () => { + if (sessionCheckInterval) { + clearInterval(sessionCheckInterval) + sessionCheckInterval = null } + if (popupWindow && !popupWindow.closed) { + popupWindow.close() + popupWindow = null + } + window.removeEventListener('message', messageHandler) + } - await new Promise((resolve, reject) => { - let sessionCheckInterval: NodeJS.Timeout - let isResolved = false + cleanupRef.current = cleanup - const messageHandler = async (event: MessageEvent) => { - console.log('Received message:', event.data) - if (event.origin !== window.location.origin) { - console.log('Ignoring message from different origin:', event.origin) - return - } + // Handle message from the popup + const messageHandler = async (event: MessageEvent) => { + if (event.origin !== window.location.origin) { + console.log('Ignoring message from different origin:', event.origin) + return + } + + if (event.data === 'nextAuthGoogleSignInComplete') { + console.log('Received completion message') + if (sessionCheckInterval) return // Already polling - if (event.data === 'nextAuthGoogleSignInComplete') { - console.log('Received completion message') - // Start checking for session after receiving completion message - console.debug('Starting to poll for user session') - sessionCheckInterval = setInterval(async () => { - console.debug('Polling for user session...') - const session = await getSession() - if (session?.user && !isResolved) { - console.debug('User session found') - isResolved = true - clearInterval(sessionCheckInterval) - window.removeEventListener('message', messageHandler) - resolve() + sessionCheckInterval = setInterval(async () => { + console.debug('Polling for user session...') + const session = await getSession() + if (session?.user) { + console.debug('User session found') + cleanup() + try { + const captcha = await getCaptchaValue() + if (!captcha) { + throw new Error('CAPTCHA verification failed') } - }, 500) // Check every 500ms + const privateKey = await getPrivateKeyFromWaas( + captcha, + UserAccountType.GoogleAccount + ) + if (privateKey) { + onNext(privateKey) + } else { + ToastHelper.error('Error getting private key from WAAS') + } + } catch (error) { + console.error('Error during Google sign in:', error) + ToastHelper.error('Error during Google sign in') + } finally { + setIsAuthenticating(false) + } } + }, 500) // Check every 500ms + } + } + + window.addEventListener('message', messageHandler) + + try { + console.log('Starting Google sign in process') + popupWindow = popupCenter('/google-sign-in', 'Google Sign In') + if (!popupWindow) { + throw new Error('Failed to open popup') + } + + // Monitor popup closure + const popupCheckInterval = setInterval(() => { + if (popupWindow && popupWindow.closed) { + console.log('Popup closed, starting cleanup delay') + clearInterval(popupCheckInterval) + setTimeout(() => { + if (isAuthenticating) { + cleanup() + ToastHelper.error('Authentication was cancelled') + setIsAuthenticating(false) + } + }, 1000) } + }, 1000) - window.addEventListener('message', messageHandler) - - const cleanup = setInterval(() => { - if (popup.closed) { - console.log('Popup closed, starting cleanup delay') - clearInterval(cleanup) - - setTimeout(() => { - if (!isResolved) { - console.log( - 'No successful resolution after popup closed, rejecting' - ) - clearInterval(sessionCheckInterval) - window.removeEventListener('message', messageHandler) - reject(new Error('Authentication failed or was cancelled')) - } - }, 1000) - } - }, 1000) - - // Timeout after 2 minutes - setTimeout(() => { - if (!isResolved) { - isResolved = true - clearInterval(cleanup) - clearInterval(sessionCheckInterval) - window.removeEventListener('message', messageHandler) - reject(new Error('Authentication timeout')) - } - }, 120000) - }) - - console.log('Promise resolved successfully') - // At this point we have a confirmed session - try { - const privateKey = await getPrivateKeyFromWaas( - await getCaptchaValue(), - UserAccountType.GoogleAccount - ) - if (privateKey) { - onNext(privateKey) - } else { - ToastHelper.error('Error getting private key from WAAS') + // Timeout after 2 minutes + const timeout = setTimeout(() => { + if (isAuthenticating) { + cleanup() + ToastHelper.error('Authentication timeout') + setIsAuthenticating(false) } - } catch (error) { - console.error(error) - ToastHelper.error('Error during Google sign in') + }, 120000) + + // Update cleanup to include timeout + cleanupRef.current = () => { + clearInterval(popupCheckInterval) + clearTimeout(timeout) + cleanup() } } catch (error) { - console.error(error) + console.error('Error initiating Google sign in:', error) ToastHelper.error('Google sign in was cancelled') + setIsAuthenticating(false) + cleanup() } } @@ -121,8 +149,9 @@ export const SignInWithGoogle = ({ onNext }: SignInWithGoogleProps) => { className="w-full" icon={} onClick={handleSignWithGoogle} + disabled={isAuthenticating} > - Sign in with Google + {isAuthenticating ? 'Authenticating...' : 'Sign in with Google'} )