UNPKG

@turnkey/react-wallet-kit

Version:

The easiest and most powerful way to integrate Turnkey's Embedded Wallets into your React applications.

1,005 lines (1,003 loc) 177 kB
'use client'; 'use strict'; var jsxRuntime = require('react/jsx-runtime'); var sha2 = require('@noble/hashes/sha2'); var utils$1 = require('@noble/hashes/utils'); var storage = require('../../utils/oauth/storage.js'); require('../../utils/oauth/config.js'); var url = require('../../utils/oauth/url.js'); var completion = require('../../utils/oauth/completion.js'); var pkce = require('../../utils/oauth/pkce.js'); var helpers = require('../../utils/oauth/helpers.js'); var utils = require('../../utils/utils.js'); var timers = require('../../utils/timers.js'); var core = require('@turnkey/core'); var react = require('react'); var sdkTypes = require('@turnkey/sdk-types'); var Hook = require('../modal/Hook.js'); var base = require('../../types/base.js'); var index = require('../../components/auth/index.js'); var reactFontawesome = require('@fortawesome/react-fontawesome'); var Action = require('../../components/auth/Action.js'); var Message = require('../../components/sign/Message.js'); var Export = require('../../components/export/Export.js'); var Import = require('../../components/import/Import.js'); var Success = require('../../components/design/Success.js'); var UpdateEmail = require('../../components/user/UpdateEmail.js'); var UpdatePhoneNumber = require('../../components/user/UpdatePhoneNumber.js'); var UpdateUserName = require('../../components/user/UpdateUserName.js'); var RemoveOAuthProvider = require('../../components/user/RemoveOAuthProvider.js'); var RemovePasskey = require('../../components/user/RemovePasskey.js'); var ConnectWallet = require('../../components/user/ConnectWallet.js'); var Types = require('./Types.js'); var WalletConnectProvider = require('../WalletConnectProvider.js'); var OTP = require('../../components/auth/OTP.js'); var RemoveEmail = require('../../components/user/RemoveEmail.js'); var RemovePhoneNumber = require('../../components/user/RemovePhoneNumber.js'); var Verify = require('../../components/verify/Verify.js'); var OnRamp = require('../../components/onramp/OnRamp.js'); var Svg = require('../../components/design/Svg.js'); var SendTransaction = require('../../components/send-transaction/SendTransaction.js'); var helpers$1 = require('../../components/send-transaction/helpers.js'); /** * Provides Turnkey client authentication, session management, wallet operations, and user profile management * for the React Wallet Kit SDK. This context provider encapsulates all core authentication flows (Passkey, Wallet, OTP, OAuth), * session lifecycle (creation, expiration, refresh), wallet connecting/import/export, and user profile updates (email, phone, name). * * The provider automatically initializes the Turnkey client, fetches configuration (including proxy auth config if needed), * and synchronizes session and authentication state. It exposes a comprehensive set of methods for authentication flows, * wallet management, and user profile operations, as well as UI handlers for modal-driven flows. * * Features: * - Passkey, Wallet, OTP (Email/SMS), and OAuth (Google, Apple, Facebook, X, Discord) authentication and sign-up flows. * - Session management: creation, expiration scheduling, refresh, and clearing. * - Wallet management: fetch, connect, import, export, account management. * - User profile management: email, phone, name, OAuth provider, and passkey linking/removal. * - Modal-driven UI flows for authentication, wallet connecting, and profile updates. * - Error handling and callback integration for custom error and event responses. * * Usage: * Wrap your application with `TurnkeyProvider` to enable authentication and wallet features via context. * * @param config - The Turnkey provider configuration object. * @param children - React children to be rendered within the provider. * @param callbacks - Optional callbacks for error handling and session events. * * @returns A React context provider exposing authentication, wallet, and user management methods and state. */ const ClientProvider = ({ config, children, callbacks }) => { const [client, setClient] = react.useState(undefined); const [session, setSession] = react.useState(undefined); const [masterConfig, setMasterConfig] = react.useState(undefined); const [wallets, setWallets] = react.useState([]); const [user, setUser] = react.useState(undefined); const [clientState, setClientState] = react.useState(); const [authState, setAuthState] = react.useState(base.AuthState.Unauthenticated); // if there is no authProxyConfigId or if autoFetchWalletKitConfig is specifically // set to false, we don't need to fetch the config const shouldFetchWalletKitConfig = !!config.authProxyConfigId && (config.autoFetchWalletKitConfig ?? true); // we use this custom hook to only update the state if the value is different // this is so our useEffect that calls `initializeWalletProviderListeners()` only runs when it needs to const [walletProviders, setWalletProviders] = utils.useWalletProviderState(); const expiryTimeoutsRef = react.useRef({}); const proxyAuthConfigRef = react.useRef(null); const [allSessions, setAllSessions] = react.useState(undefined); const { isMobile, pushPage, popPage, closeModal } = Hook.useModal(); const completeRedirectOauth = async () => { // Since we use localStorage (see storage.ts), we always clean up OAuth data // when this runs — even if there are no OAuth params in the URL (e.g. the // user canceled at the provider and came back manually) try { // Check for either hash or search parameters that could indicate an OAuth redirect if (!window.location.hash && !window.location.search) { // No OAuth redirect parameters found, nothing to do return; } /** * Wraps an OAuth completion action with optional modal UI. * This is the key difference between redirect and popup flows: * - Popup: No modal needed (the popup window itself is the UI) * - Redirect: Optional modal to show loading/success states on return */ const withModalWrapper = async params => { const { provider, isAddProvider, metadata, openModal, action } = params; const providerDisplayName = helpers.capitalizeProviderName(provider); const icon = helpers.getProviderIcon(provider); if (openModal === "true") { // Show modal UI for the completion await new Promise((resolve, reject) => { pushPage({ key: `${providerDisplayName} OAuth`, content: jsxRuntime.jsx(Action.ActionPage, { closeOnComplete: isAddProvider ? false : true, title: isAddProvider ? `Adding ${providerDisplayName} provider...` : `Authenticating with ${providerDisplayName}...`, action: async () => { try { await action(); if (isAddProvider && metadata) { // Don't show success for auth. Not needed pushPage({ key: "OAuth Provider Added", content: jsxRuntime.jsx(Success.SuccessPage, { text: `Successfully added ${providerDisplayName} OAuth provider!`, duration: metadata.successPageDuration ?? 2000, onComplete: () => { closeModal(); } }), preventBack: true, showTitle: false }); } resolve(); } catch (err) { reject(err); popPage(); } }, icon: jsxRuntime.jsx(reactFontawesome.FontAwesomeIcon, { icon: icon, size: "3x" }) }), showTitle: false, onClose: () => { reject(new sdkTypes.TurnkeyError(isAddProvider ? `User canceled the ${providerDisplayName} add provider process.` : `User canceled the ${providerDisplayName} authentication process.`, sdkTypes.TurnkeyErrorCodes.USER_CANCELED)); } }); }); } else { // No modal - execute directly await action(); } }; // Handle PKCE-based OAuth redirects (Facebook, Discord, X) with code in search parameters if (window.location.search && window.location.search.includes("code=") && window.location.search.includes("state=")) { // Parse the URL using our unified helper const result = url.parseOAuthResponse(window.location.href); if (!result || !result.authCode || !result.publicKey) { return; } if (result.flow !== "redirect") { // To complete OAuth we need redirect flow return; } const { authCode: code, provider, publicKey, oauthIntent, sessionKey, nonce, openModal } = result; const isAddProvider = oauthIntent === storage.OAUTH_INTENT_ADD_PROVIDER; const metadata = isAddProvider ? storage.getOAuthAddProviderMetadata() : null; /** * Helper to complete PKCE redirect flow with optional modal wrapper. * Uses handlePKCEFlow from oauth utils for the core logic. * We put this in a separate function to avoid duplicating for each provider. */ const completePKCERedirect = async (providerName, exchangeCodeFn, secondaryClientIds) => { const action = async () => { try { await completion.completePKCEFlow({ publicKey, providerName, sessionKey: sessionKey ?? undefined, callbacks, completeOauth: completionParams => { const existingCreateSubOrgParams = masterConfig?.auth?.createSuborgParams?.oauth; const secondaryProviders = core.buildSecondaryOauthProviders(completionParams.oidcToken, completionParams.providerName ?? providerName, secondaryClientIds); return completeOauth({ ...completionParams, ...(secondaryProviders.length > 0 && { createSubOrgParams: { ...existingCreateSubOrgParams, oauthProviders: [...(existingCreateSubOrgParams?.oauthProviders ?? []), ...secondaryProviders] } }) }); }, onAddProvider: // Only set onAddProvider if we are adding a provider and have metadata isAddProvider && metadata ? async oidcToken => { const oidcClaims = core.buildSecondaryOidcClaims(oidcToken, secondaryClientIds); await addOauthProvider({ providerName: provider, oidcToken, ...(oidcClaims.length > 0 && { oidcClaims }), organizationId: metadata.organizationId, userId: metadata.userId, ...(metadata.stampWith && { stampWith: metadata.stampWith }) }); } : undefined, exchangeCodeForToken: exchangeCodeFn }); } catch (err) { if (callbacks?.onError) { const providerDisplayName = helpers.capitalizeProviderName(providerName); callbacks.onError(err instanceof sdkTypes.TurnkeyError ? err : new sdkTypes.TurnkeyError(`${providerDisplayName} authentication failed`, sdkTypes.TurnkeyErrorCodes.OAUTH_SIGNUP_ERROR, err)); } throw err; } }; await withModalWrapper({ provider: providerName, isAddProvider, metadata, openModal, action }); // Clean up URL after successful completion url.cleanupOAuthUrl(); }; // FACEBOOK if (provider === sdkTypes.OAuthProviders.FACEBOOK) { const clientId = masterConfig?.auth?.oauthConfig?.facebook?.primaryClientId; const secondaryClientIds = masterConfig?.auth?.oauthConfig?.facebook?.secondaryClientIds ?? []; const redirectURI = masterConfig?.auth?.oauthConfig?.oauthRedirectUri; const hasVerifier = storage.hasPKCEVerifier(sdkTypes.OAuthProviders.FACEBOOK); if (clientId && redirectURI && hasVerifier) { await completePKCERedirect(sdkTypes.OAuthProviders.FACEBOOK, async codeVerifier => { const tokenResponse = await pkce.exchangeFacebookCodeForToken(clientId, redirectURI, code, codeVerifier); const oidcToken = tokenResponse?.id_token; if (!oidcToken) { throw new sdkTypes.TurnkeyError("Missing OIDC token", sdkTypes.TurnkeyErrorCodes.OAUTH_LOGIN_ERROR); } return oidcToken; }, secondaryClientIds); } return; } // DISCORD if (provider === sdkTypes.OAuthProviders.DISCORD) { const clientId = masterConfig?.auth?.oauthConfig?.discord?.primaryClientId; const secondaryClientIds = masterConfig?.auth?.oauthConfig?.discord?.secondaryClientIds ?? []; const redirectURI = masterConfig?.auth?.oauthConfig?.oauthRedirectUri; const hasVerifier = storage.hasPKCEVerifier(sdkTypes.OAuthProviders.DISCORD); if (clientId && redirectURI && hasVerifier && nonce) { await completePKCERedirect(sdkTypes.OAuthProviders.DISCORD, async codeVerifier => { const resp = await client?.httpClient.proxyOAuth2Authenticate({ provider: "OAUTH2_PROVIDER_DISCORD", authCode: code, redirectUri: redirectURI, codeVerifier, clientId, nonce }); const oidcToken = resp?.oidcToken; if (!oidcToken) { throw new sdkTypes.TurnkeyError("Missing OIDC token", sdkTypes.TurnkeyErrorCodes.OAUTH_LOGIN_ERROR); } return oidcToken; }, secondaryClientIds); } return; } // X (Twitter) if (provider === sdkTypes.OAuthProviders.X) { const clientId = masterConfig?.auth?.oauthConfig?.x?.primaryClientId; const secondaryClientIds = masterConfig?.auth?.oauthConfig?.x?.secondaryClientIds ?? []; const redirectURI = masterConfig?.auth?.oauthConfig?.oauthRedirectUri; const hasVerifier = storage.hasPKCEVerifier(sdkTypes.OAuthProviders.X); if (clientId && redirectURI && hasVerifier && nonce) { await completePKCERedirect(sdkTypes.OAuthProviders.X, async codeVerifier => { const resp = await client?.httpClient.proxyOAuth2Authenticate({ provider: "OAUTH2_PROVIDER_X", authCode: code, redirectUri: redirectURI, codeVerifier, clientId, nonce }); const oidcToken = resp?.oidcToken; if (!oidcToken) { throw new sdkTypes.TurnkeyError("Missing OIDC token", sdkTypes.TurnkeyErrorCodes.OAUTH_LOGIN_ERROR); } return oidcToken; }, secondaryClientIds); } return; } } // Handle Google/Apple redirects (uses hash with idToken - non-PKCE) if (window.location.hash) { // Parse the URL using our unified helper const result = url.parseOAuthResponse(window.location.href); if (!result || !result.idToken || result.flow !== "redirect" || !result.publicKey) { // idToken and publicKey are required to complete OAuth. These are both in the hash for non-PKCE providers return; } const { idToken, provider, publicKey, openModal, sessionKey, oauthIntent } = result; const isAddProvider = oauthIntent === storage.OAUTH_INTENT_ADD_PROVIDER; const metadata = isAddProvider ? storage.getOAuthAddProviderMetadata() : null; const resolvedProvider = provider || sdkTypes.OAuthProviders.GOOGLE; // Grab Google/Apple secondary client IDs from config for use in completion const secondaryClientIds = (resolvedProvider === sdkTypes.OAuthProviders.GOOGLE ? masterConfig?.auth?.oauthConfig?.google?.secondaryClientIds : resolvedProvider === sdkTypes.OAuthProviders.APPLE ? masterConfig?.auth?.oauthConfig?.apple?.secondaryClientIds : undefined) ?? []; // Use completeOAuthFlow from utils for the core completion logic const action = async () => { await completion.completeOAuthFlow({ provider: resolvedProvider, publicKey, oidcToken: idToken, sessionKey: sessionKey ?? undefined, callbacks, completeOauth: completionParams => { const existingCreateSubOrgParams = masterConfig?.auth?.createSuborgParams?.oauth; const secondaryProviders = core.buildSecondaryOauthProviders(completionParams.oidcToken, completionParams.providerName ?? resolvedProvider, secondaryClientIds); return completeOauth({ ...completionParams, ...(secondaryProviders.length > 0 && { createSubOrgParams: { ...existingCreateSubOrgParams, oauthProviders: [...(existingCreateSubOrgParams?.oauthProviders ?? []), ...secondaryProviders] } }) }); }, onAddProvider: isAddProvider && metadata ? async oidcToken => { const oidcClaims = core.buildSecondaryOidcClaims(oidcToken, secondaryClientIds); await addOauthProvider({ providerName: resolvedProvider, oidcToken, ...(oidcClaims.length > 0 && { oidcClaims }), organizationId: metadata.organizationId, userId: metadata.userId, ...(metadata.stampWith && { stampWith: metadata.stampWith }) }); } : undefined }); }; await withModalWrapper({ provider: resolvedProvider, isAddProvider, metadata, openModal: openModal ?? undefined, action }); // Clean up the URL after processing url.cleanupOAuthUrlPreserveSearch(); } } finally { storage.clearAllOAuthData(); } }; const buildConfig = proxyAuthConfig => { // Juggle the local overrides with the values set in the dashboard (proxyAuthConfig). const resolvedMethods = { emailOtpAuthEnabled: config.ui?.authModal?.methods?.emailOtpAuthEnabled ?? proxyAuthConfig?.enabledProviders.includes("email"), smsOtpAuthEnabled: config.ui?.authModal?.methods?.smsOtpAuthEnabled ?? proxyAuthConfig?.enabledProviders.includes("sms"), passkeyAuthEnabled: config.ui?.authModal?.methods?.passkeyAuthEnabled ?? proxyAuthConfig?.enabledProviders.includes("passkey"), walletAuthEnabled: config.ui?.authModal?.methods?.walletAuthEnabled ?? proxyAuthConfig?.enabledProviders.includes("wallet"), googleOauthEnabled: config.ui?.authModal?.methods?.googleOauthEnabled ?? proxyAuthConfig?.enabledProviders.includes("google"), xOauthEnabled: config.ui?.authModal?.methods?.xOauthEnabled ?? proxyAuthConfig?.enabledProviders.includes("x"), discordOauthEnabled: config.ui?.authModal?.methods?.discordOauthEnabled ?? proxyAuthConfig?.enabledProviders.includes("discord"), appleOauthEnabled: config.ui?.authModal?.methods?.appleOauthEnabled ?? proxyAuthConfig?.enabledProviders.includes("apple"), facebookOauthEnabled: config.ui?.authModal?.methods?.facebookOauthEnabled ?? proxyAuthConfig?.enabledProviders.includes("facebook") }; const resolvedOauthProviders = { google: { primaryClientId: config.auth?.oauthConfig?.google?.primaryClientId ?? proxyAuthConfig?.oauthClientIds?.google, secondaryClientIds: config.auth?.oauthConfig?.google?.secondaryClientIds ?? [] }, apple: { primaryClientId: config.auth?.oauthConfig?.apple?.primaryClientId ?? proxyAuthConfig?.oauthClientIds?.apple, secondaryClientIds: config.auth?.oauthConfig?.apple?.secondaryClientIds ?? [] }, facebook: { primaryClientId: config.auth?.oauthConfig?.facebook?.primaryClientId ?? proxyAuthConfig?.oauthClientIds?.facebook, secondaryClientIds: config.auth?.oauthConfig?.facebook?.secondaryClientIds ?? [] }, x: { primaryClientId: config.auth?.oauthConfig?.x?.primaryClientId ?? proxyAuthConfig?.oauthClientIds?.x, secondaryClientIds: config.auth?.oauthConfig?.x?.secondaryClientIds ?? [] }, discord: { primaryClientId: config.auth?.oauthConfig?.discord?.primaryClientId ?? proxyAuthConfig?.oauthClientIds?.discord, secondaryClientIds: config.auth?.oauthConfig?.discord?.secondaryClientIds ?? [] } }; const redirectUrl = config.auth?.oauthConfig?.oauthRedirectUri ?? proxyAuthConfig?.oauthRedirectUrl; // Set a default ordering for the oAuth methods const oauthOrder = config.ui?.authModal?.oauthOrder ?? ["google", "apple", "x", "discord", "facebook"].filter(provider => resolvedMethods[`${provider}OauthEnabled`]); // Set a default ordering for the overall auth methods const methodOrder = config.ui?.authModal?.methodOrder ?? [oauthOrder.length > 0 ? "socials" : null, resolvedMethods.emailOtpAuthEnabled ? "email" : null, resolvedMethods.smsOtpAuthEnabled ? "sms" : null, resolvedMethods.passkeyAuthEnabled ? "passkey" : null, resolvedMethods.walletAuthEnabled ? "wallet" : null].filter(Boolean); // Warn if they are trying to set auth proxy only settings directly if (proxyAuthConfig) { if (config.auth?.sessionExpirationSeconds) { console.warn("Turnkey SDK warning. You have set sessionExpirationSeconds directly in the TurnkeyProvider. This setting will be ignored because you are using an auth proxy. Please configure session expiration in the Turnkey dashboard."); } if (config.auth?.otpAlphanumeric !== undefined) { console.warn("Turnkey SDK warning. You have set otpAlphanumeric directly in the TurnkeyProvider. This setting will be ignored because you are using an auth proxy. Please configure OTP settings in the Turnkey dashboard."); } if (config.auth?.otpLength) { console.warn("Turnkey SDK warning. You have set otpLength directly in the TurnkeyProvider. This setting will be ignored because you are using an auth proxy. Please configure OTP settings in the Turnkey dashboard."); } } // These are settings that, if using the auth proxy, must be set in the dashboard. They override any local settings unless they are not using auth proxy or are not fetching the auth proxy config. const authProxyPrioSettings = { sessionExpirationSeconds: proxyAuthConfig?.sessionExpirationSeconds ?? config.auth?.sessionExpirationSeconds, otpAlphanumeric: proxyAuthConfig?.otpAlphanumeric ?? config.auth?.otpAlphanumeric ?? true, otpLength: proxyAuthConfig?.otpLength ?? config.auth?.otpLength ?? "6" }; const scopePasskeyToUser = config.auth?.scopePasskeyToUser ?? true; return { ...config, scopePasskeyToUser, // Overrides: auth: { ...config.auth, ...authProxyPrioSettings, oauthConfig: { ...config.auth?.oauthConfig, ...resolvedOauthProviders, oauthRedirectUri: redirectUrl, // on mobile we default to true since many mobile browsers // (e.g. Safari) block popups openOauthInPage: isMobile ? true : config.auth?.oauthConfig?.openOauthInPage }, autoRefreshSession: config.auth?.autoRefreshSession ?? true, scopePasskeyToUser }, ui: { ...config.ui, authModal: { ...config.ui?.authModal, methods: resolvedMethods, methodOrder, oauthOrder } }, autoRefreshManagedState: config.autoRefreshManagedState ?? true, walletConfig: { ...config.walletConfig, features: { ...config.walletConfig?.features, auth: // If walletAuthEnabled is not set, default to true. Wallet auth can be enabled/disabled in the dashboard or by explicitly changing the walletAuthEnabled / walletConfig auth feature. resolvedMethods.walletAuthEnabled ?? config.walletConfig?.features?.auth ?? true, connecting: config.walletConfig?.features?.connecting ?? true // Default connecting to true if not set. We don't care about auth settings here. }, chains: { ...config.walletConfig?.chains, ethereum: { ...config.walletConfig?.chains?.ethereum, // keep user's value if provided; default only when undefined native: config.walletConfig?.chains?.ethereum?.native ?? true }, solana: { ...config.walletConfig?.chains?.solana, // keep user's value if provided; default only when undefined native: config.walletConfig?.chains?.solana?.native ?? true } } }, importIframeUrl: config.importIframeUrl ?? "https://import.turnkey.com", exportIframeUrl: config.exportIframeUrl ?? "https://export.turnkey.com" }; }; /** * Initializes the Turnkey client with the provided configuration. * This function sets up the client, fetches the proxy auth config if needed, * and prepares the client for use in authentication and wallet operations. * * @internal */ const initializeClient = async () => { if (!masterConfig || client || clientState == base.ClientState.Loading) return; try { setClientState(base.ClientState.Loading); const turnkeyClient = new core.TurnkeyClient({ apiBaseUrl: masterConfig.apiBaseUrl, authProxyUrl: masterConfig.authProxyUrl, authProxyConfigId: masterConfig.authProxyConfigId, organizationId: masterConfig.organizationId, // Define passkey and wallet config here. If we don't pass it into the client, Mr. Client will assume that we don't want to use passkeys/wallets and not create the stamper! passkeyConfig: { rpId: masterConfig.passkeyConfig?.rpId, timeout: masterConfig.passkeyConfig?.timeout || 60000, // 60 seconds userVerification: masterConfig.passkeyConfig?.userVerification || "preferred", allowCredentials: masterConfig.passkeyConfig?.allowCredentials || [] }, walletConfig: { features: { ...masterConfig.walletConfig?.features }, chains: { ...masterConfig.walletConfig?.chains }, ...(masterConfig.walletConfig?.walletConnect && { walletConnect: masterConfig.walletConfig.walletConnect }) }, defaultStamperType: masterConfig.defaultStamperType, scopePasskeyToUser: masterConfig.scopePasskeyToUser ?? true }); await turnkeyClient.init(); setClient(turnkeyClient); // Don't set clientState to ready until we fetch the proxy auth config (See other fetchProxyAuthConfig useEffect) } catch (error) { setClientState(base.ClientState.Error); if (error instanceof sdkTypes.TurnkeyError || error instanceof sdkTypes.TurnkeyNetworkError) { callbacks?.onError?.(error); } else { callbacks?.onError?.(new sdkTypes.TurnkeyError(`Failed to initialize Turnkey client`, sdkTypes.TurnkeyErrorCodes.INITIALIZE_CLIENT_ERROR, error)); } } }; /** * Initializes the user sessions by fetching all active sessions and setting up their state. * @internal */ const initializeSessions = async () => { setSession(undefined); setAllSessions(undefined); try { const allLocalStorageSessions = await getAllSessions(); if (!allLocalStorageSessions) return; await Promise.all(Object.keys(allLocalStorageSessions).map(async sessionKey => { const session = allLocalStorageSessions?.[sessionKey]; if (!utils.isValidSession(session)) { await clearSession({ sessionKey }); if (sessionKey === (await getActiveSessionKey())) { setSession(undefined); } delete allLocalStorageSessions[sessionKey]; return; } scheduleSessionExpiration({ sessionKey, expiry: session.expiry }); })); setAllSessions(allLocalStorageSessions || undefined); const activeSessionKey = await client?.getActiveSessionKey(); if (activeSessionKey) { // If we have an active session key, set if (!allLocalStorageSessions[activeSessionKey]) { return; } setSession(allLocalStorageSessions[activeSessionKey]); // we use `maybeFetchWallets()` instead of `maybeRefreshWallets()` here to avoid a race condition // specifically, if WalletConnect finishes initializing before this promise resolves, // `maybeRefreshWallets()` could overwrite the WalletConnect wallet state with an outdated // list of wallets that doesn’t yet include the WalletConnect wallets const [user, wallets] = await Promise.all([maybeRefreshUser(), (() => { if (!masterConfig?.autoRefreshManagedState) return []; return fetchWallets(); })()]); // the prev wallets should only ever be WalletConnect wallets if (wallets) { setWallets(prev => utils.mergeWalletsWithoutDuplicates(prev, wallets)); } if (client) await core.applyPasskeyScope(client, client.config.passkeyConfig, user, masterConfig?.auth?.scopePasskeyToUser); return; } } catch (error) { if (error instanceof sdkTypes.TurnkeyError || error instanceof sdkTypes.TurnkeyNetworkError) { callbacks?.onError?.(error); } else { callbacks?.onError?.(new sdkTypes.TurnkeyError(`Failed to initialize sessions`, sdkTypes.TurnkeyErrorCodes.INITIALIZE_SESSION_ERROR, error)); } } }; /** * @internal * Attach listeners for connected wallet providers so we can refresh state on changes. * * - Ethereum: listens for disconnect and chain/account changes to trigger a refresh. * - Solana: listens for disconnect via Wallet Standard `change` events to trigger a refresh. * - WalletConnect: * - listens for disconnect and other state changes via its unified `change` event * - additionally listens for `pairingExpired` events (proposal expiration), in which * case we re-fetch providers to refresh the pairing URI for the UI. * * Notes: * - Only connected providers are bound, except WalletConnect which must always register * to handle proposal expiration even if no active session exists. * - Since all WalletConnect providers share the same session, we only attach to one. * * @param walletProviders - Discovered providers; only connected ones are bound. * @param onUpdateState - Invoked when a relevant provider event occurs. * @returns Cleanup function that removes all listeners registered by this call. */ async function initializeWalletProviderListeners(walletProviders, onUpdateState) { if (walletProviders.length === 0) return () => {}; const cleanups = []; // we only want to initialize these listeners for connected walletProviders const nativeOnly = provider => provider.interfaceType !== core.WalletInterfaceType.WalletConnect; const ethProviders = masterConfig?.walletConfig?.chains.ethereum?.native ? walletProviders.filter(provider => provider.chainInfo.namespace === core.Chain.Ethereum && nativeOnly(provider) && provider.connectedAddresses.length > 0) : []; const solProviders = masterConfig?.walletConfig?.chains.solana?.native ? walletProviders.filter(provider => provider.chainInfo.namespace === core.Chain.Solana && nativeOnly(provider) && provider.connectedAddresses.length > 0) : []; // WalletConnect is excluded from native event wiring. Instead, // it uses a unified `change` event exposed by our custom wrapper // // unlike native providers, we register listeners for WalletConnect // even if it’s not currently “connected”. This is required so we // can detect proposal expiration events and display the new regenerated // URI for the UI const wcProviders = walletProviders.filter(p => p.interfaceType === core.WalletInterfaceType.WalletConnect); // since all WalletConnect providers share the same underlying session // and emit identical events, we only attach listeners to a single provider const wcProvider = wcProviders.find(p => p.interfaceType === core.WalletInterfaceType.WalletConnect); function attachEthereumListeners(provider, onUpdateState) { if (typeof provider.on !== "function") return; const handleChainChanged = async _chainId => onUpdateState(); const handleAccountsChanged = async accounts => { if (accounts.length === 0) onUpdateState(); }; const handleDisconnect = () => onUpdateState(); provider.on("chainChanged", handleChainChanged); provider.on("accountsChanged", handleAccountsChanged); provider.on("disconnect", handleDisconnect); return () => { provider.removeListener("chainChanged", handleChainChanged); provider.removeListener("accountsChanged", handleAccountsChanged); provider.removeListener("disconnect", handleDisconnect); }; } function attachSolanaListeners(provider, onUpdateState) { const cleanups = []; const walletEvents = provider?.features?.["standard:events"]; if (walletEvents?.on) { const offChange = walletEvents.on("change", async _evt => { await onUpdateState(); }); cleanups.push(offChange); } return () => cleanups.forEach(fn => fn()); } ethProviders.forEach(p => { const cleanup = attachEthereumListeners(p.provider, onUpdateState); if (cleanup) cleanups.push(cleanup); }); solProviders.forEach(p => { const cleanup = attachSolanaListeners(p.provider, onUpdateState); if (cleanup) cleanups.push(cleanup); }); if (wcProvider) { const standardEvents = wcProvider.provider?.features?.["standard:events"]; if (standardEvents?.on) { if (standardEvents?.on) { const unsubscribe = standardEvents.on("change", async evt => { // if the event is a proposalExpired, we want to re-fetch the providers // to refresh the uri, there is no need to refresh the wallets state if (evt?.type === "pairingExpired") { debouncedFetchWalletProviders(); return; } // if WalletConnect initialization failed, refresh providers // (the failed provider will be removed from the list) if (evt?.type === "failed") { debouncedFetchWalletProviders(); callbacks?.onError?.(new sdkTypes.TurnkeyError(`WalletConnect initialization failed: ${evt.error || "Unknown error"}`, sdkTypes.TurnkeyErrorCodes.WALLET_CONNECT_INITIALIZATION_ERROR, evt.error)); return; } if (evt?.type === "initialized") { // this updates our walletProvider state const providers = await debouncedFetchWalletProviders(); // if we have an active session, we need to restore any possibly connected // WalletConnect wallets since its now initialized const currentSession = await getSession(); if (currentSession) { const wcProviders = providers?.filter(p => p.interfaceType === core.WalletInterfaceType.WalletConnect); const wcWallets = await fetchWallets({ walletProviders: wcProviders, connectedOnly: true }); if (wcWallets.length > 0) { setWallets(prev => utils.mergeWalletsWithoutDuplicates(prev, wcWallets)); } } return; } // any other event (disconnect, chain switch, accounts changed) // we refresh the wallets state await onUpdateState(); }); cleanups.push(unsubscribe); } } } return () => { cleanups.forEach(remove => remove()); }; } /** * Clears all scheduled session timers (warning + expiry). * * - Removes all active timers managed by this client. * - Useful on re-init or logout to avoid stale timers. * * @throws {TurnkeyError} If an error occurs while clearing the timers. */ function clearSessionTimeouts(sessionKeys) { try { if (sessionKeys) { timers.clearKeys(expiryTimeoutsRef.current, sessionKeys); } else { timers.clearAll(expiryTimeoutsRef.current); // clears & deletes everything } } catch (error) { if (error instanceof sdkTypes.TurnkeyError || error instanceof sdkTypes.TurnkeyNetworkError) { callbacks?.onError?.(error); } else { callbacks?.onError?.(new sdkTypes.TurnkeyError("Failed to clear session timeouts", sdkTypes.TurnkeyErrorCodes.CLEAR_SESSION_TIMEOUTS_ERROR, error)); } } } /** * @internal * Handles the post-authentication flow. * * - This function is called after a successful authentication (login or sign-up) via any supported method (Passkey, Wallet, OTP, OAuth). * - It fetches the active session and all sessions, updates the session state, and schedules session expiration and warning timeouts. * - It also refreshes the user's wallets and profile information to ensure the provider state is up to date. * - This function is used internally after all authentication flows to synchronize state and trigger any necessary callbacks. * * @returns A void promise. * @throws {TurnkeyError} If the client is not initialized or if there is an error during the process. */ const handlePostAuth = async params => { const { method, action, identifier, appProofs } = params; try { const sessionKey = await getActiveSessionKey(); const session = await getSession({ ...(sessionKey && { sessionKey }) }); if (session && sessionKey) await scheduleSessionExpiration({ sessionKey, expiry: session.expiry }); const allSessions = await client.getAllSessions(); setSession(session); setAllSessions(allSessions); const [, user] = await Promise.all([maybeRefreshWallets(), maybeRefreshUser()]); if (client) await core.applyPasskeyScope(client, client.config.passkeyConfig, user, masterConfig?.auth?.scopePasskeyToUser); if (masterConfig?.auth?.verifyWalletOnSignup === true && appProofs && appProofs.length > 0 && action === sdkTypes.AuthAction.SIGNUP) { // On signup, if we have appProofs, verify them await handleVerifyAppProofs({ appProofs }); } callbacks?.onAuthenticationSuccess?.({ session, method, action, identifier }); } catch (error) { if (error instanceof sdkTypes.TurnkeyError || error instanceof sdkTypes.TurnkeyNetworkError) { callbacks?.onError?.(error); } else { callbacks?.onError?.(new sdkTypes.TurnkeyError(`Failed to handle post-authentication`, sdkTypes.TurnkeyErrorCodes.HANDLE_POST_AUTH_ERROR, error)); } } }; /** * @internal * Handles the post-logout flow. * * - This function is called after a successful logout or session clear. * - It clears all scheduled session expiration and warning timeouts associated to the session key to prevent memory leaks. * - It resets the session state, removes user data from memory, the logged out session from all sessions state, and clears the wallets list. * - This ensures that all sensitive information is removed from the provider state after logout. * - Called internally after logout or when all sessions are cleared. * * @returns void * @throws {TurnkeyError} If there is an error during the post-logout process. */ const handlePostLogout = react.useCallback(async sessionKey => { try { const timeoutKeys = sessionKey ? [sessionKey, `${sessionKey}-warning`] : undefined; clearSessionTimeouts(timeoutKeys); setAllSessions(prev => { if (!prev) return prev; if (sessionKey) { const next = { ...prev }; delete next[sessionKey]; return next; } return {}; }); setSession(undefined); setUser(undefined); setWallets([]); if (client) await core.resetPasskeyScope(client, client.config.passkeyConfig); } catch (error) { callbacks?.onError?.(new sdkTypes.TurnkeyError(`Failed to handle post logout`, sdkTypes.TurnkeyErrorCodes.HANDLE_POST_LOGOUT_ERROR, error)); } }, [callbacks, clearSessionTimeouts, client, masterConfig?.passkeyConfig]); const createHttpClient = react.useCallback(params => { if (!client) { throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); } return client.createHttpClient(params); }, [client]); const overrideApiKeyStamper = react.useCallback(params => { if (!client) { throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); } return client.overrideApiKeyStamper(params); }, [client]); const overridePasskeyStamper = react.useCallback(params => { if (!client) { throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); } return client.overridePasskeyStamper(params); }, [client]); const getActiveSessionKey = react.useCallback(async () => { if (!client) throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); return utils.withTurnkeyErrorHandling(() => client.getActiveSessionKey(), undefined, callbacks, "Failed to get active session key"); }, [client, callbacks]); const logout = react.useCallback(async params => { if (!client) { throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); } await utils.withTurnkeyErrorHandling(async () => { // If no sessionKey is provided, we try to get the active one. let sessionKey = params?.sessionKey; if (!sessionKey) sessionKey = await getActiveSessionKey(); await client.logout(params); // We only handle post logout if the sessionKey is defined since that means we actually logged out of a session. if (sessionKey) await handlePostLogout(sessionKey); }, undefined, callbacks, "Failed to logout"); return; }, [client, callbacks, getActiveSessionKey, handlePostLogout]); const getSession = react.useCallback(async params => { if (!client) throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); return utils.withTurnkeyErrorHandling(() => client.getSession(params), undefined, callbacks, "Failed to get session"); }, [client, callbacks]); const getAllSessions = react.useCallback(async () => { if (!client) throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); return utils.withTurnkeyErrorHandling(() => client.getAllSessions(), undefined, callbacks, "Failed to get all sessions"); }, [client, callbacks]); const createPasskey = react.useCallback(async params => { if (!client) { throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); } return utils.withTurnkeyErrorHandling(() => client.createPasskey({ ...params }), undefined, callbacks, "Failed to create passkey"); }, [client, callbacks]); const fetchUser = react.useCallback(async params => { if (!client) throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); return utils.withTurnkeyErrorHandling(() => client.fetchUser(params), () => logout(), callbacks, "Failed to fetch user"); }, [client, callbacks, logout]); const fetchWalletProviders = react.useCallback(async chain => { if (!client) { throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); } return utils.withTurnkeyErrorHandling(async () => { const newProviders = await client.fetchWalletProviders(chain); // we update state with the latest providers // we keep this state so that initializeWalletProviderListeners() re-runs // whenever the list of connected providers changes // this ensures we attach disconnect listeners for each connected provider setWalletProviders(newProviders); return newProviders; }, undefined, callbacks, "Failed to fetch wallet providers"); }, [client, callbacks]); const fetchWallets = react.useCallback(async params => { if (!client) { throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); } return utils.withTurnkeyErrorHandling(() => client.fetchWallets(params), () => logout(), callbacks, "Failed to fetch wallets"); }, [client, callbacks, logout]); const refreshUser = react.useCallback(async params => { const { stampWith, organizationId, userId } = params || {}; if (!client) throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); const user = await utils.withTurnkeyErrorHandling(() => fetchUser({ stampWith, ...(organizationId && { organizationId }), ...(userId && { userId }) }), () => logout(), callbacks, "Failed to refresh user"); if (user) { setUser(user); } return user; }, [client, callbacks, fetchUser, logout]); /** * @internal * Auto-refresh user only if enabled in config. This is only used internally. * * @param params.organizationId - organization ID to specify the sub-organization (defaults to the current session's organizationId). * @param params.userId - user ID to fetch specific user details (defaults to the current session's userId). * @param params.stampWith - parameter to stamp the request with a specific stamper (StamperType.Passkey, StamperType.ApiKey, or StamperType.Wallet). * @returns A promise that resolves when the user is refreshed, or does nothing if auto-refresh is disabled */ const maybeRefreshUser = react.useCallback(async params => { if (!masterConfig?.autoRefreshManagedState) return undefined; return refreshUser(params); }, [masterConfig, refreshUser]); const refreshWallets = react.useCallback(async params => { const { stampWith, organizationId, userId } = params || {}; if (!client) throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); const walletProviders = await utils.withTurnkeyErrorHandling(() => fetchWalletProviders(), undefined, callbacks, "Failed to refresh wallets"); const wallets = await utils.withTurnkeyErrorHandling(() => fetchWallets({ stampWith, walletProviders, ...(organizationId && { organizationId }), ...(userId && { userId }) }), () => logout(), callbacks, "Failed to refresh wallets"); if (wallets) { setWallets(wallets); } return wallets; }, [client, callbacks, fetchWalletProviders, fetchWallets, logout]); /** * @internal * Auto-refresh wallets only if enabled in config. This is only used internally. * * @param params.organizationId - organization ID to specify the sub-organization (defaults to the current session's organizationId). * @param params.userId - user ID to fetch specific user details (defaults to the current session's userId). * @param params.stampWith - parameter to stamp the request with a specific stamper (StamperType.Passkey, StamperType.ApiKey, or StamperType.Wallet). * @returns A promise that resolves to an array of wallets, or an empty array if auto-refresh is disabled */ const maybeRefreshWallets = react.useCallback(async params => { if (!masterConfig?.autoRefreshManagedState) return []; return refreshWallets(params); }, [masterConfig, refreshWallets]); const clearSession = react.useCallback(async params => { if (!client) throw new sdkTypes.TurnkeyError("Client is not initialized.", sdkTypes.TurnkeyErrorCodes.CLIENT_NOT_INITIALIZED); const activeSessionKey = await getActiveSessionKey(); await utils.withTurnkeyErrorHandling(async () => client.clearSession(params), undefined, callbacks, "Failed to clear session"); const sessionKey = params?.sessionKey ?? active