Introduction

This guide will show you how to add MFA to your app using your own custom UI. You can also refer to the general MFA guide here to learn more about this feature (note that this guide is for account level MFA, rather than transaction level MFA).

For a working example, see: https://codesandbox.io/p/sandbox/kh8g8s

General Flow

  1. User logs in
  2. User is redirected to the MFA view
  3. User adds a device
  4. User is redirected to the OTP view
  5. User enters the OTP
  6. User is redirected to the backup codes view
  7. User acknowledges the backup codes

The user must accept the backup codes before the MFA flow is complete.

Step-by-Step Implementation

Step 1: Import Necessary Modules and Components

Start by importing the required modules and components:

import { EthereumWalletConnectors } from "@dynamic-labs/ethereum";

import { FC, useEffect, useState, ReactElement, useMemo, useRef } from "react";
import {
  DynamicContextProvider,
  DynamicWidget,
  useDynamicContext,
  useIsLoggedIn,
  useMfa,
  useSyncMfaFlow,
} from "@dynamic-labs/sdk-react-core";
import { MFADevice } from "@dynamic-labs/sdk-api-core";

import QRCodeUtil from "qrcode";

Step 2: Define Types

Define the types for the MFA registration data.

type MfaRegisterData = {
  uri: string;
  secret: string;
};

Step 3: Create the MfaView Component

This component handles the main logic for MFA, including device management, QR code generation, OTP submission, and backup codes.

export const MfaView = () => {
  const [userDevices, setUserDevices] = useState<MFADevice[]>([]);
  const [mfaRegisterData, setMfaRegisterData] = useState<MfaRegisterData>();
  const [currentView, setCurrentView] = useState<string>("devices");
  const [backupCodes, setBackupCodes] = useState<string[]>([]);
  const [error, setError] = useState<string>();

  const isLogged = useIsLoggedIn();
  const {
    addDevice,
    authDevice,
    getUserDevices,
    getRecoveryCodes,
    completeAcknowledgement,
  } = useMfa();

  const refreshUserDevices = async () => {
    const devices = await getUserDevices();
    setUserDevices(devices);
  };

  const { userWithMissingInfo, handleLogOut } = useDynamicContext();
  useEffect(() => {
    if (isLogged) {
      refreshUserDevices();
    }
  }, [isLogged]);

  useSyncMfaFlow({
    handler: async () => {
      if (userWithMissingInfo?.scope?.includes("requiresAdditionalAuth")) {
        getUserDevices().then(async (devices) => {
          if (devices.length === 0) {
            setError(undefined);
            const { uri, secret } = await addDevice();
            setMfaRegisterData({ secret, uri });
            setCurrentView("qr-code");
          } else {
            setError(undefined);
            setMfaRegisterData(undefined);
            setCurrentView("otp");
          }
        });
      } else {
        getRecoveryCodes().then(setBackupCodes);
        setCurrentView("backup-codes");
      }
    },
  });

  const onAddDevice = async () => {
    setError(undefined);
    const { uri, secret } = await addDevice();
    setMfaRegisterData({ secret, uri });
    setCurrentView("qr-code");
  };

  const onQRCodeContinue = async () => {
    setError(undefined);
    setMfaRegisterData(undefined);
    setCurrentView("otp");
  };

  const onOtpSubmit = async (code: string) => {
    try {
      await authDevice(code);
      getRecoveryCodes().then(setBackupCodes);
      setCurrentView("backup-codes");
      refreshUserDevices();
    } catch (e) {
      setError(e.message);
    }
  };

  return (
    <div className="headless-mfa">
      <DynamicWidget />
      <button onClick={handleLogOut}>log out</button>
      {error && <div className="headless-mfa__section error">{error}</div>}
      {currentView === "devices" && (
        <div className="headless-mfa__section">
          <p>
            <b>Devices</b>
          </p>
          <pre>{JSON.stringify(userDevices, null, 2)}</pre>
          <button onClick={() => onAddDevice()}>Add Device</button>
        </div>
      )}
      {currentView === "qr-code" && mfaRegisterData && (
        <QRCodeView data={mfaRegisterData} onContinue={onQRCodeContinue} />
      )}
      {currentView === "otp" && <OTPView onSubmit={onOtpSubmit} />}
      {currentView === "backup-codes" && (
        <BackupCodesView
          codes={backupCodes}
          onAccept={completeAcknowledgement}
        />
      )}
      <button
        onClick={async () => {
          const codes = await getRecoveryCodes(true);
          setBackupCodes(codes);
          setCurrentView("backup-codes");
        }}
      >
        Generate Recovery Codes
      </button>
    </div>
  );
};

Explanation:

  • State Management:

    • userDevices: Stores the list of MFA devices associated with the user.
    • mfaRegisterData: Stores the data required for MFA registration (QR code URI and secret).
    • currentView: Tracks the current view (e.g., ‘devices’, ‘qr-code’, ‘otp’, ‘backup-codes’).
    • backupCodes: Stores the backup codes for MFA.
    • error: Stores any error messages.
  • Hooks:

    • useIsLoggedIn: Checks if the user is logged in.
    • useMfa: Provides functions for managing MFA (e.g., addDevice, authDevice, getUserDevices, getRecoveryCodes, completeAcknowledgement).
    • useDynamicContext: Provides user context and logout function.
    • useEffect: Refreshes user devices when the user logs in.
    • useSyncMfaFlow: Synchronizes the MFA flow based on the user’s authentication status.
  • Functions:

    • refreshUserDevices: Fetches and sets the user’s MFA devices.
    • onAddDevice: Initiates the process of adding a new MFA device.
    • onQRCodeContinue: Proceeds to the OTP view after displaying the QR code.
    • onOtpSubmit: Authenticates the device using the provided OTP and fetches backup codes.
  • Return Statement:

    • Renders the UI components based on the current view and state.

Step 4: Create Supporting Components

Define the supporting components for displaying backup codes, QR code, and OTP input.

const BackupCodesView = ({
  codes,
  onAccept,
}: {
  codes: string[];
  onAccept: () => void;
}) => (
  <>
    <p>Backup Codes</p>
    {codes.map((code) => (
      <p key={code}>{code}</p>
    ))}
    <button onClick={onAccept}>Accept</button>
  </>
);

const LogIn = () => (
  <>
    <p>User not logged in!</p>
    <DynamicWidget />
  </>
);

const QRCodeView = ({
  data,
  onContinue,
}: {
  data: MfaRegisterData;
  onContinue: () => void;
}) => {
  const canvasRef = useRef(null);

  useEffect(() => {
    if (!canvasRef.current) {
      return;
    }
    QRCodeUtil.toCanvas(canvasRef.current, data.uri, function (error: any) {
      if (error) console.error(error);
      console.log("success!");
    });
  });

  return (
    <>
      <div style={{ width: "320px", height: "320px" }}>
        <canvas id="canvas" ref={canvasRef}></canvas>
      </div>
      <p>Secret: {data.secret}</p>
      <button onClick={onContinue}>Continue</button>
    </>
  );
};

const OTPView = ({ onSubmit }: { onSubmit: (code: string) => void }) => (
  <form
    key="sms-form"
    onSubmit={(e) => {
      e.preventDefault();
      onSubmit(e.currentTarget.otp.value);
    }}
    className="headless-mfa__form"
  >
    <div className="headless-mfa__form__section">
      <label htmlFor="otp">OTP:</label>
      <input type="text" name="otp" placeholder="123456" />
    </div>
    <button type="submit">Submit</button>
  </form>
);

Explanation:

  • BackupCodesView:

    • Displays the backup codes and provides an “Accept” button to acknowledge them.
  • LogIn:

    • Displays a message indicating that the user is not logged in and shows the DynamicWidget for login.
  • QRCodeView:

    • Displays the QR code for MFA setup and the secret key. Provides a “Continue” button to proceed to the OTP view.
  • OTPView:

    • Provides a form for the user to enter the OTP. On form submission, it calls the onSubmit function with the entered OTP.

Step 5: Create the Main HeadlessMfaView Component

This component checks if the user is logged in and displays the appropriate view.

export const HeadlessMfaView: FC = () => {
  const { user, userWithMissingInfo } = useDynamicContext();

  return (
    <div className="headless-mfa">
      {user || userWithMissingInfo ? <MfaView /> : <LogIn />}
    </div>
  );
};

export default function App() {
  return (
    <div className="App">
      <DynamicContextProvider
        settings={{
          environmentId: "YOUR_ENV_ID",
          walletConnectors: [EthereumWalletConnectors],
        }}
      >
        <HeadlessMfaView />
      </DynamicContextProvider>
    </div>
  );
}

Explanation:

  • HeadlessMfaView:

    • Checks if the user is logged in or has missing information that requires additional authentication.
    • If the user is logged in, it renders the MfaView component.
    • If the user is not logged in, it renders the LogIn component.
  • App:

    • Your main app entry point.
    • Make sure to set you environment id.

Conclusion

By following this detailed breakdown, you should have a clear understanding of how the provided code implements a headless MFA solution using a custom UI. Each part of the code is explained, including the purpose of each component, hook, and function. This should help you understand the overall flow and how to customize it for your needs.