diff --git a/src/lib/testFlags.ts b/src/lib/testFlags.ts index 19198fb8e..a789a5088 100644 --- a/src/lib/testFlags.ts +++ b/src/lib/testFlags.ts @@ -68,6 +68,10 @@ class TestFlags { return this.booleanFlag(this.queryParams.enable_turnkey); } + get enablePasskeyAuth() { + return this.booleanFlag(this.queryParams.passkey_auth); + } + get spot() { return this.booleanFlag(this.queryParams.spot); } diff --git a/src/providers/TurnkeyAuthProvider.tsx b/src/providers/TurnkeyAuthProvider.tsx index a8086507b..57638a110 100644 --- a/src/providers/TurnkeyAuthProvider.tsx +++ b/src/providers/TurnkeyAuthProvider.tsx @@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } import { logBonsaiError, logBonsaiInfo } from '@/bonsai/logs'; import { selectIndexerUrl } from '@/bonsai/socketSelectors'; import { useMutation } from '@tanstack/react-query'; -import { TurnkeyIndexedDbClient } from '@turnkey/sdk-browser'; +import { SessionType, TurnkeyIndexedDbClient } from '@turnkey/sdk-browser'; import { useTurnkey } from '@turnkey/sdk-react'; import { jwtDecode } from 'jwt-decode'; import { useSearchParams } from 'react-router-dom'; @@ -69,7 +69,7 @@ const useTurnkeyAuthContext = () => { const stringGetter = useStringGetter(); const indexerUrl = useAppSelector(selectIndexerUrl); const sourceAccount = useAppSelector(getSourceAccount); - const { indexedDbClient, authIframeClient } = useTurnkey(); + const { indexedDbClient, authIframeClient, passkeyClient, turnkey } = useTurnkey(); const { dydxAddress: connectedDydxAddress, setWalletFromSignature, selectWallet } = useAccounts(); const [searchParams, setSearchParams] = useSearchParams(); const [emailToken, setEmailToken] = useState(); @@ -85,6 +85,7 @@ const useTurnkeyAuthContext = () => { onboardDydx, targetPublicKeys, getUploadAddressPayload, + fetchCredentialId, } = useTurnkeyWallet(); /* ----------------------------- Upload Address ----------------------------- */ @@ -217,7 +218,9 @@ const useTurnkeyAuthContext = () => { handleEmailResponse({ userEmail, response }); setEmailSignInStatus('idle'); break; - case LoginMethod.Passkey: // TODO: handle passkey response + case LoginMethod.Passkey: + handlePasskeyResponse({ response }); + break; default: throw new Error('Current unsupported login method'); } @@ -332,6 +335,49 @@ const useTurnkeyAuthContext = () => { [onboardDydx, indexedDbClient, setWalletFromSignature, uploadAddress] ); + const handlePasskeyResponse = useCallback( + async ({ response }: { response: TurnkeyOAuthResponse }) => { + const { salt, dydxAddress: uploadedDydxAddress } = response as { + salt?: string; + dydxAddress?: string; + }; + + if (!passkeyClient) { + throw new Error('Passkey client is not available'); + } + const derivedDydxAddress = await onboardDydx({ + salt, + setWalletFromSignature, + tkClient: indexedDbClient, + }); + + if (uploadedDydxAddress === '' && derivedDydxAddress) { + try { + await uploadAddress({ tkClient: indexedDbClient, dydxAddress: derivedDydxAddress }); + } catch (uploadAddressError) { + if ( + uploadAddressError instanceof Error && + !uploadAddressError.message.includes('Dydx address already uploaded') + ) { + throw uploadAddressError; + } + } + } + + setEmailSignInStatus('success'); + setEmailSignInError(undefined); + }, + [ + onboardDydx, + indexedDbClient, + setWalletFromSignature, + uploadAddress, + setEmailSignInStatus, + setEmailSignInError, + passkeyClient, + ] + ); + /* ----------------------------- Email Sign In ----------------------------- */ const handleEmailResponse = useCallback( @@ -524,6 +570,82 @@ const useTurnkeyAuthContext = () => { setEmailSignInError(undefined); }, [searchParams, setSearchParams]); + /* ----------------------------- Passkey Sign In ----------------------------- */ + + const registerPasskey = useCallback(async () => { + try { + if (!passkeyClient || !indexedDbClient || !turnkey) { + throw new Error('Passkey client is not available'); + } + + const passkey = await passkeyClient.createUserPasskey({ + rp: { + id: 'dydx.trade', + name: '2FA Passkey', + }, + }); + + const session = await turnkey.getSession(); + if (!session || !passkey?.encodedChallenge || !passkey?.attestation) { + throw new Error('No session found'); + } + + await indexedDbClient.addUserAuth({ + userId: session.userId, + authenticators: [ + { + authenticatorName: 'Passkey', + challenge: passkey.encodedChallenge, + attestation: passkey.attestation, + }, + ], + }); + } catch (error) { + logBonsaiError('TurnkeyOnboarding', 'Error registering passkey', { error }); + } + }, [passkeyClient, indexedDbClient, turnkey]); + + const signInWithPasskey = useCallback(async () => { + try { + if (!passkeyClient) { + throw new Error('Passkey client is not available'); + } + + await indexedDbClient!.resetKeyPair(); + const pubKey = await indexedDbClient!.getPublicKey(); + if (!pubKey) { + throw new Error('No public key available for passkey session'); + } + // Authenticate with the user's passkey for the returned sub-organization + await passkeyClient.loginWithPasskey({ + sessionType: SessionType.READ_WRITE, + publicKey: pubKey, + expirationSeconds: (60 * 15).toString(), // 15 minutes + }); + + const credentialId = await fetchCredentialId(indexedDbClient); + if (!credentialId) { + throw new Error('No user found'); + } + + // dummy body used to get salt. + const bodyWithAttestation: SignInBody = { + signinMethod: 'passkey', + challenge: credentialId, + attestation: { + credentialId, + }, + }; + + sendSignInRequest({ + body: JSON.stringify(bodyWithAttestation), + loginMethod: LoginMethod.Passkey, + }); + } catch (error) { + logBonsaiError('TurnkeyOnboarding', 'Error signing in with passkey', { error }); + } + }, [passkeyClient, indexedDbClient, fetchCredentialId, sendSignInRequest]); + /* ----------------------------- Side Effects ----------------------------- */ /** @@ -592,6 +714,8 @@ const useTurnkeyAuthContext = () => { isUploadingAddress, signInWithOauth, signInWithOtp, + signInWithPasskey, + registerPasskey, resetEmailSignInStatus, }; }; diff --git a/src/providers/TurnkeyWalletProvider.tsx b/src/providers/TurnkeyWalletProvider.tsx index 45e2668f4..240ba0511 100644 --- a/src/providers/TurnkeyWalletProvider.tsx +++ b/src/providers/TurnkeyWalletProvider.tsx @@ -123,6 +123,33 @@ const useTurnkeyWalletContext = () => { }, [authIframeClient]); /* ----------------------------- Onboarding Functions ----------------------------- */ + + // used to fetch the credential id for the passkey sign in + const fetchCredentialId = useCallback( + async (tkClient?: TurnkeyIndexedDbClient): Promise => { + if (turnkey == null || tkClient == null) { + return undefined; + } + // Try and get the current user + const token = await turnkey.getSession(); + + // If the user is not found, we assume the user is not logged in + if (!token?.expiry || token.expiry > Date.now()) { + return undefined; + } + + const { user: indexedDbUser } = await tkClient.getUser({ + organizationId: token.organizationId, + userId: token.userId, + }); + if (indexedDbUser.authenticators.length === 0) { + return undefined; + } + return indexedDbUser.authenticators[0]?.credentialId; + }, + [turnkey] + ); + const fetchUser = useCallback( async (tkClient?: TurnkeyIndexedDbClient): Promise => { const isIndexedDbFlow = tkClient instanceof TurnkeyIndexedDbClient; @@ -202,7 +229,6 @@ const useTurnkeyWalletContext = () => { tkClient?: TurnkeyIndexedDbClient; }) => { const selectedTurnkeyWallet = primaryTurnkeyWallet ?? (await getPrimaryUserWallets(tkClient)); - const ethAccount = selectedTurnkeyWallet?.accounts.find( (account) => account.addressFormat === AddressFormat.Ethereum ); @@ -341,6 +367,7 @@ const useTurnkeyWalletContext = () => { isNewTurnkeyUser, endTurnkeySession, + fetchCredentialId, onboardDydx, getUploadAddressPayload, setIsNewTurnkeyUser, diff --git a/src/types/turnkey.ts b/src/types/turnkey.ts index 2539e6d37..0b50f120a 100644 --- a/src/types/turnkey.ts +++ b/src/types/turnkey.ts @@ -49,7 +49,7 @@ export type GoogleIdTokenPayload = { export type SignInBody = | { - signinMethod: 'social' | 'passkey'; + signinMethod: 'social'; targetPublicKey: string; provider: 'google' | 'apple'; oidcToken: string; @@ -57,9 +57,27 @@ export type SignInBody = } | { signinMethod: 'email'; - targetPublicKey: string; + targetPublicKey?: string; userEmail: string; magicLink: string; + } + | { + signinMethod: 'passkey'; + // In a full implementation these are required; left optional to allow + // initiating the flow and handling multi-step server responses. + challenge?: string; + attestation?: { + transports?: Array< + | 'AUTHENTICATOR_TRANSPORT_BLE' + | 'AUTHENTICATOR_TRANSPORT_INTERNAL' + | 'AUTHENTICATOR_TRANSPORT_NFC' + | 'AUTHENTICATOR_TRANSPORT_USB' + | 'AUTHENTICATOR_TRANSPORT_HYBRID' + >; + attestationObject?: string; // base64url + clientDataJson?: string; // base64url + credentialId: string; // base64url + }; }; export type TurnkeyEmailOnboardingData = { diff --git a/src/views/dialogs/EmailSignInStatusDialog.tsx b/src/views/dialogs/EmailSignInStatusDialog.tsx index 67a349126..f00980e0e 100644 --- a/src/views/dialogs/EmailSignInStatusDialog.tsx +++ b/src/views/dialogs/EmailSignInStatusDialog.tsx @@ -22,6 +22,7 @@ import { openDialog } from '@/state/dialogs'; import { getSourceAccount, getTurnkeyEmailOnboardingData } from '@/state/walletSelectors'; import { calc } from '@/lib/do'; +import { testFlags } from '@/lib/testFlags'; import { sleep } from '@/lib/timeUtils'; export const EmailSignInStatusDialog = ({ @@ -204,7 +205,17 @@ export const EmailSignInStatusDialog = ({ if (showWelcomeContent) { await sleep(0); - dispatch(openDialog(DialogTypes.Deposit2({}))); + if (testFlags.enablePasskeyAuth) { + dispatch( + openDialog( + DialogTypes.SetupPasskey({ + onClose: () => dispatch(openDialog(DialogTypes.Deposit2({}))), + }) + ) + ); + } else { + dispatch(openDialog(DialogTypes.Deposit2({}))); + } } }} > diff --git a/src/views/dialogs/OnboardingDialog/SignIn.tsx b/src/views/dialogs/OnboardingDialog/SignIn.tsx index 721defc6f..3599db221 100644 --- a/src/views/dialogs/OnboardingDialog/SignIn.tsx +++ b/src/views/dialogs/OnboardingDialog/SignIn.tsx @@ -31,6 +31,7 @@ import { AppTheme } from '@/state/appUiConfigs'; import { getAppTheme } from '@/state/appUiConfigsSelectors'; import { isValidEmail } from '@/lib/emailUtils'; +import { testFlags } from '@/lib/testFlags'; import { AppleAuth } from './AuthButtons/AppleAuth'; import { GoogleAuth } from './AuthButtons/GoogleAuth'; @@ -51,7 +52,7 @@ export const SignIn = ({ const [email, setEmail] = useState(''); const [isLoading, setIsLoading] = useState(false); const { authIframeClient } = useTurnkey(); - const { signInWithOtp } = useTurnkeyAuth(); + const { signInWithOtp, signInWithPasskey } = useTurnkeyAuth(); const appTheme = useAppSelector(getAppTheme); const { tos, privacy } = useURLConfigs(); const displayedWallets = useDisplayedWallets(); @@ -137,19 +138,21 @@ export const SignIn = ({ <$HorizontalSeparatorFiller $isLightMode={appTheme === AppTheme.Light} /> - {/* <$OtherOptionButton - type={ButtonType.Button} - action={ButtonAction.Base} - size={ButtonSize.BasePlus} - onClick={onSignInWithPasskey} - > -
- - {stringGetter({ key: STRING_KEYS.SIGN_IN_PASSKEY })} -
+ {testFlags.enablePasskeyAuth && ( + <$OtherOptionButton + type={ButtonType.Button} + action={ButtonAction.Base} + size={ButtonSize.BasePlus} + onClick={signInWithPasskey} + > +
+ + {stringGetter({ key: STRING_KEYS.SIGN_IN_PASSKEY })} +
- - */} + + + )} {displayedWallets .filter( diff --git a/src/views/dialogs/SetupPasskeyDialog.tsx b/src/views/dialogs/SetupPasskeyDialog.tsx index 90efe8d32..afd8ad953 100644 --- a/src/views/dialogs/SetupPasskeyDialog.tsx +++ b/src/views/dialogs/SetupPasskeyDialog.tsx @@ -1,6 +1,10 @@ import { ReactNode, useCallback, useMemo } from 'react'; import { ButtonAction, ButtonSize, ButtonType } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; + +import { useStringGetter } from '@/hooks/useStringGetter'; +import { useTurnkeyAuth } from '@/providers/TurnkeyAuthProvider'; import { Button } from '@/components/Button'; import { Dialog } from '@/components/Dialog'; @@ -13,6 +17,8 @@ export const SetupPasskeyDialog = ({ onClose: () => void; setIsOpen: (isOpen: boolean) => void; }) => { + const { registerPasskey } = useTurnkeyAuth(); + const stringGetter = useStringGetter(); const modifiedSetIsOpen = useCallback( (isOpen: boolean) => { setIsOpen(isOpen); @@ -59,7 +65,9 @@ export const SetupPasskeyDialog = ({ }} >
-

Setup Passkey

+

+ {stringGetter({ key: STRING_KEYS.REGISTER_PASSKEY, fallback: 'Setup Passkey' })} +

Passkeys are a secure alternative to passwords

@@ -83,9 +91,15 @@ export const SetupPasskeyDialog = ({ css={{ color: 'var(--dialog-backgroundColor, var(--text-color-text-0))', }} + onClick={() => { + registerPasskey(); + modifiedSetIsOpen(false); + }} > - Setup Passkey + + {stringGetter({ key: STRING_KEYS.REGISTER_PASSKEY, fallback: 'Setup Passkey' })} +