Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/lib/testFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
96 changes: 93 additions & 3 deletions src/providers/TurnkeyAuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string>();
Expand All @@ -85,6 +85,7 @@ const useTurnkeyAuthContext = () => {
onboardDydx,
targetPublicKeys,
getUploadAddressPayload,
fetchCredentialId,
} = useTurnkeyWallet();

/* ----------------------------- Upload Address ----------------------------- */
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 ----------------------------- */

/**
Expand Down Expand Up @@ -592,6 +681,7 @@ const useTurnkeyAuthContext = () => {
isUploadingAddress,
signInWithOauth,
signInWithOtp,
signInWithPasskey,
resetEmailSignInStatus,
};
};
29 changes: 28 additions & 1 deletion src/providers/TurnkeyWalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> => {
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<UserSession | undefined> => {
const isIndexedDbFlow = tkClient instanceof TurnkeyIndexedDbClient;
Expand Down Expand Up @@ -202,7 +229,6 @@ const useTurnkeyWalletContext = () => {
tkClient?: TurnkeyIndexedDbClient;
}) => {
const selectedTurnkeyWallet = primaryTurnkeyWallet ?? (await getPrimaryUserWallets(tkClient));

const ethAccount = selectedTurnkeyWallet?.accounts.find(
(account) => account.addressFormat === AddressFormat.Ethereum
);
Expand Down Expand Up @@ -341,6 +367,7 @@ const useTurnkeyWalletContext = () => {
isNewTurnkeyUser,

endTurnkeySession,
fetchCredentialId,
onboardDydx,
getUploadAddressPayload,
setIsNewTurnkeyUser,
Expand Down
22 changes: 20 additions & 2 deletions src/types/turnkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,35 @@ export type GoogleIdTokenPayload = {

export type SignInBody =
| {
signinMethod: 'social' | 'passkey';
signinMethod: 'social';
targetPublicKey: string;
provider: 'google' | 'apple';
oidcToken: string;
userEmail?: string;
}
| {
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 = {
Expand Down
28 changes: 15 additions & 13 deletions src/views/dialogs/OnboardingDialog/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -137,19 +137,21 @@ export const SignIn = ({
<$HorizontalSeparatorFiller $isLightMode={appTheme === AppTheme.Light} />
</div>

{/* <$OtherOptionButton
type={ButtonType.Button}
action={ButtonAction.Base}
size={ButtonSize.BasePlus}
onClick={onSignInWithPasskey}
>
<div tw="row gap-0.5">
<Icon iconName={IconName.Passkey} />
{stringGetter({ key: STRING_KEYS.SIGN_IN_PASSKEY })}
</div>
{testFlags.enablePasskeyAuth && (
<$OtherOptionButton
type={ButtonType.Button}
action={ButtonAction.Base}
size={ButtonSize.BasePlus}
onClick={signInWithPasskey}
>
<div tw="row gap-0.5">
<Icon iconName={IconName.Passkey} />
{stringGetter({ key: STRING_KEYS.SIGN_IN_PASSKEY })}
</div>

<Icon tw="text-color-layer-7" iconName={IconName.ChevronRight} />
</$OtherOptionButton> */}
<Icon tw="text-color-layer-7" iconName={IconName.ChevronRight} />
</$OtherOptionButton>
)}

{displayedWallets
.filter(
Expand Down
Loading