Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/polite-experts-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openfort/react-native": patch
---

add passkey support
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
}
},
"dependencies": {
"@openfort/openfort-js": "^1.1.4"
"@openfort/openfort-js": "^1.1.5",
"react-native-passkeys": "0.4.0"
},
"peerDependencies": {
"expo-apple-authentication": "*",
Expand Down Expand Up @@ -94,7 +95,10 @@
"size-limit": [
{
"path": "dist/index.js",
"limit": "250 KB"
"limit": "250 KB",
"ignore": [
"expo-modules-core"
]
}
],
"pnpm": {
Expand Down
1,201 changes: 608 additions & 593 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

37 changes: 30 additions & 7 deletions src/core/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { validateEnvironment } from '../lib/environmentValidation'
import { getEmbeddedStateName, logger } from '../lib/logger'
import { EmbeddedWalletWebView, WebViewUtils } from '../native'
import { EmbeddedWalletWebView, NativePasskeyHandler, WebViewUtils } from '../native'
import type { OAuthFlowState, PasswordFlowState, RecoveryFlowState, SiweFlowState } from '../types'
import { createOpenfortClient, setDefaultClient } from './client'
import { OpenfortContext, type OpenfortContextValue } from './context'
Expand All @@ -24,8 +24,12 @@ export type CommonEmbeddedWalletConfiguration = {
ethereumProviderPolicyId?: PolicyConfig
accountType?: AccountTypeEnum
debug?: boolean
/** Recovery method for the embedded wallet: 'automatic' or 'password' */
recoveryMethod?: 'automatic' | 'password'
/** Recovery method for the embedded wallet: 'automatic', 'password', or 'passkey' */
recoveryMethod?: 'automatic' | 'password' | 'passkey'
/** Passkey Relying Party ID (domain) for passkey-based recovery */
passkeyRpId?: string
/** Passkey Relying Party Name for passkey-based recovery */
passkeyRpName?: string
}

/**
Expand Down Expand Up @@ -248,7 +252,21 @@ export const OpenfortProvider = ({
logger.setVerbose(verbose)
}, [verbose])

// Create or use provided client
// Create passkey handler if passkey recovery is configured (single instance for SDK and WebView)
const passkeyHandler = useMemo(() => {
if (walletConfig?.passkeyRpId) {
if (!walletConfig.passkeyRpName) {
logger.warn('passkeyRpName is required when passkeyRpId is provided for passkey recovery')
}
return new NativePasskeyHandler({
rpId: walletConfig.passkeyRpId,
rpName: walletConfig.passkeyRpName ?? walletConfig.passkeyRpId,
})
}
return undefined
}, [walletConfig?.passkeyRpId, walletConfig?.passkeyRpName])

// Create client with passkeyHandler in overrides when configured
const client = useMemo(() => {
const newClient = createOpenfortClient({
baseConfiguration: {
Expand All @@ -258,15 +276,19 @@ export const OpenfortProvider = ({
? new ShieldConfiguration({
shieldPublishableKey: walletConfig.shieldPublishableKey,
shieldDebug: walletConfig.debug,
passkeyRpId: walletConfig.passkeyRpId,
passkeyRpName: walletConfig.passkeyRpName,
})
: undefined,
overrides,
overrides: {
...overrides,
...(passkeyHandler && { passkeyHandler }),
},
thirdPartyAuth,
})

setDefaultClient(newClient)
return newClient
}, [publishableKey, walletConfig, overrides])
}, [publishableKey, walletConfig, overrides, thirdPartyAuth, passkeyHandler])

// Embedded state
const [embeddedState, setEmbeddedState] = useState<EmbeddedState>(EmbeddedState.NONE)
Expand Down Expand Up @@ -464,6 +486,7 @@ export const OpenfortProvider = ({
<EmbeddedWalletWebView
client={client}
isClientReady={isReady}
debug={walletConfig?.debug}
onProxyStatusChange={(status: 'loading' | 'loaded' | 'reloading') => {
// Handle WebView status changes for debugging
if (verbose) {
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ export { useOpenfort } from './useOpenfort'

export { useOpenfortClient } from './useOpenfortClient'

export { usePasskeySupport } from './usePasskeySupport'

export { useUser } from './useUser'
36 changes: 36 additions & 0 deletions src/hooks/core/usePasskeySupport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react'
import { isPasskeySupported } from '../../native/passkey'

/**
* Hook to detect if the platform supports passkeys (WebAuthn).
*
* Note: This only checks basic passkey support, not PRF extension support.
* PRF support can only be determined during passkey creation via the
* `clientExtensionResults.prf.enabled` field in the response.
*
* @returns Object with `isSupported` boolean and `isLoading` state
*/
export function usePasskeySupport() {
const [isSupported, setIsSupported] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(true)

useEffect(() => {
async function checkSupport() {
try {
const available = await isPasskeySupported()
setIsSupported(available)
} catch {
setIsSupported(false)
} finally {
setIsLoading(false)
}
}

checkSupport()
}, [])

return {
isSupported,
isLoading,
}
}
93 changes: 84 additions & 9 deletions src/hooks/wallet/useEmbeddedEthereumWallet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { AccountTypeEnum, ChainTypeEnum, type EmbeddedAccount, EmbeddedState } from '@openfort/openfort-js'
import {
AccountTypeEnum,
ChainTypeEnum,
type EmbeddedAccount,
EmbeddedState,
RecoveryMethod,
} from '@openfort/openfort-js'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useOpenfortContext } from '../../core/context'
import { onError, onSuccess } from '../../lib/hookConsistency'
Expand Down Expand Up @@ -299,10 +305,19 @@ export function useEmbeddedEthereumWallet(options: UseEmbeddedEthereumWalletOpti
}, [] as EmbeddedAccount[])

return deduplicatedAccounts.map((account, index) => ({
id: account.id,
address: account.address,
chainType: ChainTypeEnum.EVM,
chainId: account.chainId,
ownerAddress: account.ownerAddress,
factoryAddress: account.factoryAddress,
salt: account.salt,
accountType: account.accountType,
implementationAddress: account.implementationAddress,
createdAt: account.createdAt,
implementationType: account.implementationType,
chainType: ChainTypeEnum.EVM,
recoveryMethod: account.recoveryMethod,
recoveryMethodDetails: account.recoveryMethodDetails,
walletIndex: index,
getProvider: async () => await getEthereumProvider(),
}))
Expand Down Expand Up @@ -365,7 +380,10 @@ export function useEmbeddedEthereumWallet(options: UseEmbeddedEthereumWalletOpti
})

if (createOptions?.onSuccess) {
createOptions.onSuccess({ account: embeddedAccount, provider: ethProvider })
createOptions.onSuccess({
account: embeddedAccount,
provider: ethProvider,
})
}
if (options.onCreateSuccess) {
options.onCreateSuccess(embeddedAccount, ethProvider)
Expand Down Expand Up @@ -468,8 +486,34 @@ export function useEmbeddedEthereumWallet(options: UseEmbeddedEthereumWalletOpti
throw new OpenfortError(errorMsg, OpenfortErrorType.WALLET_ERROR)
}

// Auto-detect recovery method from account if not explicitly provided
let effectiveRecoveryMethod = setActiveOptions.recoveryMethod
let effectivePasskeyId = setActiveOptions.passkeyId

if (!effectiveRecoveryMethod && embeddedAccountToRecover.recoveryMethod) {
if (embeddedAccountToRecover.recoveryMethod === RecoveryMethod.PASSKEY) {
effectiveRecoveryMethod = 'passkey'
if (!effectivePasskeyId) {
const details = embeddedAccountToRecover.recoveryMethodDetails
if (details && 'passkeyId' in details && typeof details.passkeyId === 'string') {
effectivePasskeyId = details.passkeyId
}
}
} else if (embeddedAccountToRecover.recoveryMethod === RecoveryMethod.PASSWORD) {
effectiveRecoveryMethod = 'password'
}
}

// Build recovery params
const recoveryParams = await buildRecoveryParams({ ...setActiveOptions, userId: user?.id }, walletConfig)
const recoveryParams = await buildRecoveryParams(
{
...setActiveOptions,
userId: user?.id,
recoveryMethod: effectiveRecoveryMethod,
passkeyId: effectivePasskeyId,
},
walletConfig
)

// Recover the embedded wallet
const embeddedAccount = await client.embeddedWallet.recover({
Expand All @@ -489,10 +533,19 @@ export function useEmbeddedEthereumWallet(options: UseEmbeddedEthereumWalletOpti
)

const wallet: ConnectedEmbeddedEthereumWallet = {
id: embeddedAccount.id,
address: embeddedAccount.address,
chainType: ChainTypeEnum.EVM,
chainId: embeddedAccount.chainId,
ownerAddress: embeddedAccount.ownerAddress,
factoryAddress: embeddedAccount.factoryAddress,
salt: embeddedAccount.salt,
accountType: embeddedAccount.accountType,
implementationAddress: embeddedAccount.implementationAddress,
createdAt: embeddedAccount.createdAt,
implementationType: embeddedAccount.implementationType,
chainType: ChainTypeEnum.EVM,
recoveryMethod: embeddedAccount.recoveryMethod,
recoveryMethodDetails: embeddedAccount.recoveryMethodDetails,
walletIndex: walletIndex >= 0 ? walletIndex : 0,
getProvider: async () => ethProvider,
}
Expand Down Expand Up @@ -600,10 +653,19 @@ export function useEmbeddedEthereumWallet(options: UseEmbeddedEthereumWalletOpti
const accountIndex = embeddedAccounts.findIndex((acc) => acc.id === activeWalletId)

return {
id: activeAccount.id,
address: activeAccount.address,
chainType: ChainTypeEnum.EVM,
chainId: activeAccount.chainId,
ownerAddress: activeAccount.ownerAddress,
factoryAddress: activeAccount.factoryAddress,
salt: activeAccount.salt,
accountType: activeAccount.accountType,
implementationAddress: activeAccount.implementationAddress,
createdAt: activeAccount.createdAt,
implementationType: activeAccount.implementationType,
chainType: ChainTypeEnum.EVM,
recoveryMethod: activeAccount.recoveryMethod,
recoveryMethodDetails: activeAccount.recoveryMethodDetails,
walletIndex: accountIndex >= 0 ? accountIndex : 0,
getProvider: async () => await getEthereumProvider(),
}
Expand All @@ -629,11 +691,20 @@ export function useEmbeddedEthereumWallet(options: UseEmbeddedEthereumWalletOpti
}

if (status.status === 'connecting' || status.status === 'reconnecting' || status.status === 'loading') {
return { ...baseActions, status: 'connecting', activeWallet: activeWallet! }
return {
...baseActions,
status: 'connecting',
activeWallet: activeWallet!,
}
}

if (status.status === 'error') {
return { ...baseActions, status: 'error', activeWallet, error: status.error?.message || 'Unknown error' }
return {
...baseActions,
status: 'error',
activeWallet,
error: status.error?.message || 'Unknown error',
}
}

// Priority 2: Check authentication state from context
Expand All @@ -650,7 +721,11 @@ export function useEmbeddedEthereumWallet(options: UseEmbeddedEthereumWalletOpti

if (activeAccount && !provider) {
// Have wallet but provider not initialized yet (mount recovery in progress)
return { ...baseActions, status: 'connecting', activeWallet: activeWallet! }
return {
...baseActions,
status: 'connecting',
activeWallet: activeWallet!,
}
}

// Default: disconnected (authenticated but no wallet selected)
Expand Down
Loading
Loading