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
5 changes: 5 additions & 0 deletions .changeset/react-wallet-kit-on-otp-verified.md
Original file line number Diff line number Diff line change
@@ -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`.
34 changes: 33 additions & 1 deletion packages/react-wallet-kit/src/components/auth/OTP.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<boolean>(false);
const [resending, setResending] = useState<boolean>(false);
Expand All @@ -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<TurnkeyCallbacks["onOtpVerified"]>,
) => {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6536,6 +6536,7 @@ export const ClientProvider: React.FC<ClientProviderProps> = ({
wallets,
walletProviders,
config: masterConfig,
callbacks,
httpClient: client?.httpClient,
createHttpClient,
overrideApiKeyStamper,
Expand Down
3 changes: 3 additions & 0 deletions packages/react-wallet-kit/src/providers/client/Types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
} from "@turnkey/sdk-types";
import type {
TurnkeyProviderConfig,
TurnkeyCallbacks,
AuthState,
ClientState,
} from "../../types/base";
Expand Down Expand Up @@ -78,6 +79,8 @@ export interface ClientContextType
/** @internal */
config?: TurnkeyProviderConfig | undefined;
/** @internal */
callbacks?: TurnkeyCallbacks | undefined;
/** @internal */
user: v1User | undefined;
/** @internal */
wallets: Wallet[];
Expand Down
180 changes: 180 additions & 0 deletions packages/react-wallet-kit/src/tests/otp-test.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>>();
const mockVerifyOtp =
jest.fn<
(
params: unknown,
) => Promise<{ verificationToken: string; publicKey: string }>
>();
const mockDeleteApiKeyPair = jest.fn<(params: unknown) => Promise<void>>();
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(<OtpVerification {...otpParams} otpLength={code.length} />);
});

// 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<TurnkeyCallbacks["onOtpVerified"]>
>(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<TurnkeyCallbacks["onOtpVerified"]>
>(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<TurnkeyCallbacks["onOtpVerified"]>
>(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());
});
});
24 changes: 24 additions & 0 deletions packages/react-wallet-kit/src/types/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
CreateSubOrgParams,
OtpType,
StamperType,
TurnkeySDKClientConfig,
} from "@turnkey/core";
Expand Down Expand Up @@ -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<void>;
beforeSessionExpiry?: (params: { sessionKey: string }) => void;
onSessionExpired?: (params: { sessionKey: string }) => void;
onAuthenticationSuccess?: (params: {
Expand Down