From 8fcf7b30d7b2d756a3cbc3ff7ca1bbeae359552c Mon Sep 17 00:00:00 2001 From: Sergei Timoshin Date: Sat, 7 Nov 2026 11:33:24 +0000 Subject: [PATCH] feat(react-wallet-kit): add onOtpVerified callback Lets a host finish an email or SMS OTP login itself once the auth proxy has verified the code, instead of the SDK's account lookup and loginWithOtp/signUpWithOtp. The auth modal verifies the code, hands the verification token and the generated key pair's public key to callbacks.onOtpVerified, closes when it resolves, and shows the error and deletes the key pair when it rejects. Mirrors onOauthRedirect. --- .../react-wallet-kit-on-otp-verified.md | 5 + .../src/components/auth/OTP.tsx | 34 +++- .../src/providers/client/Provider.tsx | 1 + .../src/providers/client/Types.tsx | 3 + .../react-wallet-kit/src/tests/otp-test.tsx | 180 ++++++++++++++++++ packages/react-wallet-kit/src/types/base.ts | 24 +++ 6 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 .changeset/react-wallet-kit-on-otp-verified.md create mode 100644 packages/react-wallet-kit/src/tests/otp-test.tsx diff --git a/.changeset/react-wallet-kit-on-otp-verified.md b/.changeset/react-wallet-kit-on-otp-verified.md new file mode 100644 index 0000000000..4dcd2d3617 --- /dev/null +++ b/.changeset/react-wallet-kit-on-otp-verified.md @@ -0,0 +1,5 @@ +--- +"@turnkey/react-wallet-kit": minor +--- + +Add the `onOtpVerified` callback. When set, the auth modal hands a verified email or SMS code over to the host instead of completing the login in the SDK: the SDK generates the API key pair and verifies the code with the auth proxy, then calls `onOtpVerified({ verificationToken, publicKey, contact, otpType, sessionKey? })`. The host finishes the login itself (for example through its own backend) and stores the session with `storeSession`. The modal closes when the callback resolves; when it rejects, the modal shows the error and the generated key pair is deleted. This is the OTP counterpart of `onOauthRedirect`. diff --git a/packages/react-wallet-kit/src/components/auth/OTP.tsx b/packages/react-wallet-kit/src/components/auth/OTP.tsx index a0d2553d3a..921162d68e 100644 --- a/packages/react-wallet-kit/src/components/auth/OTP.tsx +++ b/packages/react-wallet-kit/src/components/auth/OTP.tsx @@ -9,6 +9,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faEnvelope, faPhone } from "@fortawesome/free-solid-svg-icons"; import clsx from "clsx"; import { useTurnstile } from "./TurnstileWidget"; +import type { TurnkeyCallbacks } from "../../types/base"; interface OtpVerificationProps { contact: string; @@ -31,7 +32,8 @@ export function OtpVerification(props: OtpVerificationProps) { sessionKey, onContinue = null, // Default to null if not provided } = props; - const { initOtp, completeOtp } = useTurnkey(); + const { initOtp, completeOtp, verifyOtp, deleteApiKeyPair, callbacks } = + useTurnkey(); const { closeModal, isMobile } = useModal(); const [submitting, setSubmitting] = useState(false); const [resending, setResending] = useState(false); @@ -57,11 +59,41 @@ export function OtpVerification(props: OtpVerificationProps) { setTimeout(() => setShaking(false), 250); }; + // Hands the verified code over to the host instead of completing the login + // in the SDK, the OTP counterpart of `callbacks.onOauthRedirect`. The host gets + // the verification token bound to a fresh key pair; if it rejects, the key + // pair is deleted again and the error shows in the modal like any other. + const handOverVerifiedOtp = async ( + otpCode: string, + onOtpVerified: NonNullable, + ) => { + const { verificationToken, publicKey } = await verifyOtp({ + otpId, + otpCode, + otpEncryptionTargetBundle, + }); + try { + await onOtpVerified({ + verificationToken, + publicKey, + contact, + otpType, + ...(sessionKey && { sessionKey }), + }); + } catch (error) { + await deleteApiKeyPair({ publicKey }).catch(() => {}); + throw error; + } + }; + const handleContinue = async (otpCode: string) => { try { setSubmitting(true); if (onContinue) { await onContinue(otpCode); + } else if (callbacks?.onOtpVerified) { + await handOverVerifiedOtp(otpCode, callbacks.onOtpVerified); + closeModal(); } else { await completeOtp({ otpId, diff --git a/packages/react-wallet-kit/src/providers/client/Provider.tsx b/packages/react-wallet-kit/src/providers/client/Provider.tsx index 519397214e..79d6e091e2 100644 --- a/packages/react-wallet-kit/src/providers/client/Provider.tsx +++ b/packages/react-wallet-kit/src/providers/client/Provider.tsx @@ -6536,6 +6536,7 @@ export const ClientProvider: React.FC = ({ wallets, walletProviders, config: masterConfig, + callbacks, httpClient: client?.httpClient, createHttpClient, overrideApiKeyStamper, diff --git a/packages/react-wallet-kit/src/providers/client/Types.tsx b/packages/react-wallet-kit/src/providers/client/Types.tsx index 9db9365439..b9f3fa9327 100644 --- a/packages/react-wallet-kit/src/providers/client/Types.tsx +++ b/packages/react-wallet-kit/src/providers/client/Types.tsx @@ -15,6 +15,7 @@ import type { } from "@turnkey/sdk-types"; import type { TurnkeyProviderConfig, + TurnkeyCallbacks, AuthState, ClientState, } from "../../types/base"; @@ -78,6 +79,8 @@ export interface ClientContextType /** @internal */ config?: TurnkeyProviderConfig | undefined; /** @internal */ + callbacks?: TurnkeyCallbacks | undefined; + /** @internal */ user: v1User | undefined; /** @internal */ wallets: Wallet[]; diff --git a/packages/react-wallet-kit/src/tests/otp-test.tsx b/packages/react-wallet-kit/src/tests/otp-test.tsx new file mode 100644 index 0000000000..b2e2f102be --- /dev/null +++ b/packages/react-wallet-kit/src/tests/otp-test.tsx @@ -0,0 +1,180 @@ +/** @jest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { OtpType } from "@turnkey/core"; +import { OtpVerification } from "../components/auth/OTP"; +import type { TurnkeyCallbacks } from "../types/base"; + +const mockCloseModal = jest.fn(); +const mockCompleteOtp = jest.fn<(params: unknown) => Promise>(); +const mockVerifyOtp = + jest.fn< + ( + params: unknown, + ) => Promise<{ verificationToken: string; publicKey: string }> + >(); +const mockDeleteApiKeyPair = jest.fn<(params: unknown) => Promise>(); +let mockCallbacks: TurnkeyCallbacks | undefined; + +// `@turnkey/core` pulls in viem, which needs `TextEncoder` and other globals +// that jsdom lacks. The component only needs these values from it. +jest.mock("@turnkey/core", () => ({ + ...(jest.requireActual("@turnkey/sdk-types") as object), + OtpType: { Email: "OTP_TYPE_EMAIL", Sms: "OTP_TYPE_SMS" }, +})); + +jest.mock("../providers/modal/Hook", () => ({ + useModal: () => ({ + closeModal: mockCloseModal, + isMobile: false, + }), +})); + +jest.mock("../providers/client/Hook", () => ({ + useTurnkey: () => ({ + initOtp: jest.fn(), + completeOtp: mockCompleteOtp, + verifyOtp: mockVerifyOtp, + deleteApiKeyPair: mockDeleteApiKeyPair, + callbacks: mockCallbacks, + }), +})); + +jest.mock("../components/auth/TurnstileWidget", () => ({ + useTurnstile: () => ({ + turnstile: null, + requestToken: async () => ({}), + turnstileConfigured: false, + }), +})); + +const otpParams = { + contact: "user@example.com", + otpId: "otp-id", + otpEncryptionTargetBundle: "bundle", + otpType: OtpType.Email, + sessionKey: "session-key", +}; + +async function renderAndEnterCode(code: string) { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + + await act(async () => { + root.render(); + }); + + // React listens for native "input" events; set the value through the + // prototype setter so React's value tracker sees the change. + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!; + const inputs = container.querySelectorAll("input"); + for (let i = 0; i < code.length; i++) { + await act(async () => { + valueSetter.call(inputs[i], code[i]); + inputs[i]!.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + + return { container, root }; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockCallbacks = undefined; + mockVerifyOtp.mockResolvedValue({ + verificationToken: "verification-token", + publicKey: "public-key", + }); + mockDeleteApiKeyPair.mockResolvedValue(undefined); + mockCompleteOtp.mockResolvedValue({}); +}); + +describe("OtpVerification with callbacks.onOtpVerified", () => { + it("completes the login in the SDK when the callback is not set", async () => { + const { root } = await renderAndEnterCode("123456"); + + expect(mockCompleteOtp).toHaveBeenCalledWith({ + otpId: "otp-id", + otpCode: "123456", + otpEncryptionTargetBundle: "bundle", + contact: "user@example.com", + otpType: OtpType.Email, + sessionKey: "session-key", + }); + expect(mockVerifyOtp).not.toHaveBeenCalled(); + expect(mockCloseModal).toHaveBeenCalledTimes(1); + + await act(async () => root.unmount()); + }); + + it("hands the verified code over to the host and closes the modal", async () => { + const onOtpVerified = jest.fn< + NonNullable + >(async () => {}); + mockCallbacks = { onOtpVerified }; + + const { root } = await renderAndEnterCode("123456"); + + expect(mockVerifyOtp).toHaveBeenCalledWith({ + otpId: "otp-id", + otpCode: "123456", + otpEncryptionTargetBundle: "bundle", + }); + expect(onOtpVerified).toHaveBeenCalledWith({ + verificationToken: "verification-token", + publicKey: "public-key", + contact: "user@example.com", + otpType: OtpType.Email, + sessionKey: "session-key", + }); + expect(mockCompleteOtp).not.toHaveBeenCalled(); + expect(mockDeleteApiKeyPair).not.toHaveBeenCalled(); + expect(mockCloseModal).toHaveBeenCalledTimes(1); + + await act(async () => root.unmount()); + }); + + it("shows the host's error and deletes the key pair when the callback rejects", async () => { + const onOtpVerified = jest.fn< + NonNullable + >(async () => { + throw new Error("Backend rejected the login"); + }); + mockCallbacks = { onOtpVerified }; + + const { container, root } = await renderAndEnterCode("123456"); + + expect(onOtpVerified).toHaveBeenCalledTimes(1); + expect(mockDeleteApiKeyPair).toHaveBeenCalledWith({ + publicKey: "public-key", + }); + expect(mockCloseModal).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Backend rejected the login"); + + await act(async () => root.unmount()); + }); + + it("does not call the host when verification fails", async () => { + const onOtpVerified = jest.fn< + NonNullable + >(async () => {}); + mockCallbacks = { onOtpVerified }; + mockVerifyOtp.mockRejectedValue(new Error("Failed to verify OTP")); + + const { container, root } = await renderAndEnterCode("123456"); + + expect(onOtpVerified).not.toHaveBeenCalled(); + expect(mockDeleteApiKeyPair).not.toHaveBeenCalled(); + expect(mockCloseModal).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Failed to verify OTP"); + + await act(async () => root.unmount()); + }); +}); diff --git a/packages/react-wallet-kit/src/types/base.ts b/packages/react-wallet-kit/src/types/base.ts index e4be56f24d..dc94465951 100644 --- a/packages/react-wallet-kit/src/types/base.ts +++ b/packages/react-wallet-kit/src/types/base.ts @@ -1,5 +1,6 @@ import type { CreateSubOrgParams, + OtpType, StamperType, TurnkeySDKClientConfig, } from "@turnkey/core"; @@ -32,6 +33,29 @@ export interface TurnkeyCallbacks { publicKey: string; sessionKey?: string; }) => void; + /** + * Called from the auth modal once the auth proxy has verified an email or SMS OTP code, + * instead of the SDK finishing the login itself (account lookup, then `loginWithOtp` or `signUpWithOtp`). + * + * - Use this to finish the OTP login through your own backend, like `onOauthRedirect` does for OAuth. + * - The SDK generates the API key pair, verifies the code, and hands over the `verificationToken` bound to the key pair's `publicKey`. + * - Finish the login yourself (for example through your own backend) and store the session you obtain with `storeSession`. + * - When the returned promise resolves, the modal closes. When it rejects, the modal shows the error and the generated key pair is deleted. + * - Only the auth modal consults this callback. Calling `completeOtp` directly still completes the login in the SDK. + * + * @param params.verificationToken - The verification token returned by the auth proxy for the verified code. + * @param params.publicKey - The public key of the generated API key pair that the token is bound to. + * @param params.contact - The email address or phone number the code was sent to. + * @param params.otpType - Whether the code was sent by email or SMS. + * @param params.sessionKey - The session key the login was started with, if any. + */ + onOtpVerified?: (params: { + verificationToken: string; + publicKey: string; + contact: string; + otpType: OtpType; + sessionKey?: string; + }) => Promise; beforeSessionExpiry?: (params: { sessionKey: string }) => void; onSessionExpired?: (params: { sessionKey: string }) => void; onAuthenticationSuccess?: (params: {