diff --git a/src/lib/testFlags.ts b/src/lib/testFlags.ts index 19198fb8e8..a789a50884 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 a8086507b5..8174da4d16 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 } = 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,49 @@ const useTurnkeyAuthContext = () => { setEmailSignInError(undefined); }, [searchParams, setSearchParams]); + /* ----------------------------- Passkey Sign In ----------------------------- */ + + 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 +681,7 @@ const useTurnkeyAuthContext = () => { isUploadingAddress, signInWithOauth, signInWithOtp, + signInWithPasskey, resetEmailSignInStatus, }; }; diff --git a/src/providers/TurnkeyWalletProvider.tsx b/src/providers/TurnkeyWalletProvider.tsx index 45e2668f49..240ba0511b 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 2539e6d37f..0b50f120a0 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/OnboardingDialog/SignIn.tsx b/src/views/dialogs/OnboardingDialog/SignIn.tsx index 721defc6f9..19fb163602 100644 --- a/src/views/dialogs/OnboardingDialog/SignIn.tsx +++ b/src/views/dialogs/OnboardingDialog/SignIn.tsx @@ -51,7 +51,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 +137,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(