UNPKG

@frank-auth/react

Version:

Flexible and customizable React UI components for Frank Authentication

811 lines (704 loc) 22.8 kB
/** * @frank-auth/react - useMFA Hook * * Multi-Factor Authentication hook that provides comprehensive MFA management * including TOTP, SMS, email, backup codes, and WebAuthn authentication. */ import {useCallback, useEffect, useMemo, useState} from 'react'; import type { MFAMethod, MFAVerifyRequest, MFAVerifyResponse, SetupMFARequest, VerifyMFASetupRequest, } from '@frank-auth/client'; import {useAuth} from './use-auth'; import {useConfig} from '../provider/config-provider'; import type {AuthError} from '../provider/types'; // ============================================================================ // MFA Hook Interface // ============================================================================ export interface UseMFAReturn { // MFA state mfaMethods: MFAMethod[]; isEnabled: boolean; isRequired: boolean; primaryMethod: MFAMethod | null; backupCodes: string[]; isLoaded: boolean; isLoading: boolean; error: AuthError | null; // MFA setup setupTOTP: () => Promise<MFASetupData>; setupSMS: (phoneNumber: string) => Promise<MFASetupData>; setupEmail: (email?: string) => Promise<MFASetupData>; setupWebAuthn: () => Promise<MFASetupData>; // MFA verification during setup verifySetup: (method: string, code: string, methodId?: string) => Promise<MFAMethod>; // MFA verification during authentication verifyMFA: (method: string, code: string, token: string) => Promise<MFAVerifyResponse>; // Method management removeMFAMethod: (methodId: string) => Promise<void>; setPrimaryMethod: (methodId: string) => Promise<void>; regenerateBackupCodes: () => Promise<string[]>; // MFA status checking hasTOTP: boolean; hasSMS: boolean; hasEmail: boolean; hasWebAuthn: boolean; hasBackupCodes: boolean; // Method availability availableMethods: MFAMethodType[]; // Convenience methods disable: () => Promise<void>; enable: () => Promise<void>; refreshMethods: () => Promise<void>; } export interface MFASetupData { method: string; qrCode?: string; secret?: string; backupCodes?: string[]; challenge?: any; verificationRequired: boolean; } export type MFAMethodType = 'totp' | 'sms' | 'email' | 'webauthn' | 'backup_codes'; // ============================================================================ // MFA Method Configurations // ============================================================================ export const MFA_METHOD_CONFIGS = { totp: { name: 'Authenticator App', description: 'Use an authenticator app like Google Authenticator or Authy', icon: '📱', setupSteps: [ 'Install an authenticator app on your phone', 'Scan the QR code or enter the secret key', 'Enter the 6-digit code from your app', ], }, sms: { name: 'SMS', description: 'Receive codes via text message', icon: '💬', setupSteps: [ 'Enter your phone number', 'Wait for the verification code', 'Enter the code to confirm', ], }, email: { name: 'Email', description: 'Receive codes via email', icon: '✉️', setupSteps: [ 'Confirm your email address', 'Wait for the verification code', 'Enter the code to confirm', ], }, webauthn: { name: 'Security Key', description: 'Use a hardware security key or biometric authentication', icon: '🔐', setupSteps: [ 'Insert your security key or prepare biometric authentication', 'Follow your browser\'s authentication prompts', 'Confirm the registration', ], }, backup_codes: { name: 'Backup Codes', description: 'Single-use codes for emergency access', icon: '🔢', setupSteps: [ 'Save these codes in a secure location', 'Each code can only be used once', 'Generate new codes when running low', ], }, } as const; // ============================================================================ // Main useMFA Hook // ============================================================================ /** * Multi-Factor Authentication hook providing comprehensive MFA management * * @example Basic MFA setup * ```tsx * import { useMFA } from '@frank-auth/react'; * * function MFASetup() { * const { * isEnabled, * setupTOTP, * verifySetup, * mfaMethods, * isLoading * } = useMFA(); * * const [setupData, setSetupData] = useState(null); * const [verificationCode, setVerificationCode] = useState(''); * * const handleSetupTOTP = async () => { * try { * const data = await setupTOTP(); * setSetupData(data); * } catch (error) { * console.error('Setup failed:', error); * } * }; * * const handleVerifySetup = async () => { * try { * await verifySetup('totp', verificationCode); * alert('MFA setup complete!'); * setSetupData(null); * } catch (error) { * console.error('Verification failed:', error); * } * }; * * if (isEnabled) { * return ( * <div> * <h3>MFA is enabled</h3> * <p>Active methods: {mfaMethods.length}</p> * </div> * ); * } * * return ( * <div> * {!setupData ? ( * <button onClick={handleSetupTOTP} disabled={isLoading}> * Setup Authenticator App * </button> * ) : ( * <div> * <img src={setupData.qrCode} alt="QR Code" /> * <p>Secret: {setupData.secret}</p> * <input * value={verificationCode} * onChange={(e) => setVerificationCode(e.target.value)} * placeholder="Enter 6-digit code" * /> * <button onClick={handleVerifySetup}> * Verify & Enable * </button> * </div> * )} * </div> * ); * } * ``` * * @example MFA verification during login * ```tsx * function MFAVerification({ mfaToken, onSuccess }) { * const { verifyMFA, availableMethods } = useMFA(); * const [selectedMethod, setSelectedMethod] = useState('totp'); * const [code, setCode] = useState(''); * * const handleVerify = async () => { * try { * const result = await verifyMFA(selectedMethod, code, mfaToken); * if (result.success) { * onSuccess(result.session); * } * } catch (error) { * console.error('MFA verification failed:', error); * } * }; * * return ( * <div> * <h3>Enter your verification code</h3> * <select * value={selectedMethod} * onChange={(e) => setSelectedMethod(e.target.value)} * > * {availableMethods.map(method => ( * <option key={method} value={method}> * {MFA_METHOD_CONFIGS[method].name} * </option> * ))} * </select> * <input * value={code} * onChange={(e) => setCode(e.target.value)} * placeholder="Enter code" * /> * <button onClick={handleVerify}>Verify</button> * </div> * ); * } * ``` * * @example MFA method management * ```tsx * function MFAManagement() { * const { * mfaMethods, * removeMFAMethod, * setPrimaryMethod, * regenerateBackupCodes, * backupCodes * } = useMFA(); * * return ( * <div> * <h3>Your MFA Methods</h3> * {mfaMethods.map(method => ( * <div key={method.id}> * <span>{method.type} - {method.name}</span> * {method.isPrimary && <span>(Primary)</span>} * <button onClick={() => setPrimaryMethod(method.id)}> * Set as Primary * </button> * <button onClick={() => removeMFAMethod(method.id)}> * Remove * </button> * </div> * ))} * * <h4>Backup Codes</h4> * <ul> * {backupCodes.map((code, index) => ( * <li key={index}>{code}</li> * ))} * </ul> * <button onClick={regenerateBackupCodes}> * Generate New Backup Codes * </button> * </div> * ); * } * ``` */ export function useMFA(): UseMFAReturn { const {user, session, reload, userType, sdk} = useAuth(); const {apiUrl, publishableKey, features} = useConfig(); const [mfaMethods, setMFAMethods] = useState<MFAMethod[]>([]); const [backupCodes, setBackupCodes] = useState<string[]>([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<AuthError | null>(null); // Check if MFA is available const isMFAAvailable = useMemo(() => features.mfa, [features.mfa]); // Error handler const handleError = useCallback((err: any) => { const authError: AuthError = { code: err.code || 'UNKNOWN_ERROR', message: err.message || 'An unknown error occurred', details: err.details, field: err.field, }; setError(authError); throw authError; }, []); // Load MFA methods and backup codes const loadMFAData = useCallback(async () => { if (!sdk.user || !user || !isMFAAvailable) return; try { setIsLoading(true); setError(null); // Load MFA methods const methods = await sdk.user.getMFAMethods({ orgId: sdk.user.getOrganizationId(), userId: sdk.user.getUserData() }); setMFAMethods(methods.data || []); // Load backup codes if MFA is enabled if (user.mfaEnabled) { try { const codes = await sdk.user.getBackupCodes(); setBackupCodes(codes.codes || []); } catch (backupError) { // Backup codes might not be set up yet console.warn('Could not load backup codes:', backupError); } } } catch (err) { console.error('Failed to load MFA data:', err); setError({ code: 'MFA_LOAD_FAILED', message: 'Failed to load MFA data', }); } finally { setIsLoading(false); } }, [sdk.user, user, isMFAAvailable]); useEffect(() => { loadMFAData(); }, [loadMFAData]); // MFA setup methods const setupTOTP = useCallback(async (): Promise<MFASetupData> => { if (!sdk.user) throw new Error('User not authenticated'); if (!isMFAAvailable) throw new Error('MFA not available'); try { setIsLoading(true); setError(null); const setupRequest: SetupMFARequest = { method: 'totp', }; const response = await sdk.user.setupMFA(setupRequest); return { method: 'totp', qrCode: response.qrCode, secret: response.secret, backupCodes: response.backupCodes, verificationRequired: true, }; } catch (err) { return handleError(err); } finally { setIsLoading(false); } }, [sdk.user, isMFAAvailable, handleError]); const setupSMS = useCallback(async (phoneNumber: string): Promise<MFASetupData> => { if (!sdk.user) throw new Error('User not authenticated'); if (!isMFAAvailable) throw new Error('MFA not available'); try { setIsLoading(true); setError(null); const setupRequest: SetupMFARequest = { method: 'sms', phoneNumber, }; const response = await sdk.user.setupMFA(setupRequest); return { method: 'sms', verificationRequired: true, }; } catch (err) { return handleError(err); } finally { setIsLoading(false); } }, [sdk.user, isMFAAvailable, handleError]); const setupEmail = useCallback(async (email?: string): Promise<MFASetupData> => { if (!sdk.user) throw new Error('User not authenticated'); if (!isMFAAvailable) throw new Error('MFA not available'); try { setIsLoading(true); setError(null); const setupRequest: SetupMFARequest = { method: 'email', email: email || user?.primaryEmailAddress, }; const response = await sdk.user.setupMFA(setupRequest); return { method: 'email', verificationRequired: true, }; } catch (err) { return handleError(err); } finally { setIsLoading(false); } }, [sdk.user, user, isMFAAvailable, handleError]); const setupWebAuthn = useCallback(async (): Promise<MFASetupData> => { if (!isMFAAvailable) throw new Error('MFA not available'); try { setIsLoading(true); setError(null); const setupRequest: SetupMFARequest = { method: 'webauthn', }; const response = await sdk.auth.setupMFA(setupRequest); return { method: 'webauthn', challenge: response.challenge, verificationRequired: true, }; } catch (err) { return handleError(err); } finally { setIsLoading(false); } }, [sdk.user, isMFAAvailable, handleError]); // MFA verification during setup const verifySetup = useCallback(async (method: string, code: string, methodId?: string): Promise<MFAMethod> => { if (!sdk.user) throw new Error('User not authenticated'); try { setIsLoading(true); setError(null); const verifyRequest: VerifyMFASetupRequest = { method, code, methodId, generateBackupCodes: false }; const response = await sdk.user.verifyMFASetup(verifyRequest); // Refresh MFA data and user state await loadMFAData(); await reload(); return response.method; } catch (err) { return handleError(err); } finally { setIsLoading(false); } }, [sdk.user, loadMFAData, reload, handleError]); // MFA verification during authentication const verifyMFA = useCallback(async (method: string, code: string, token: string): Promise<MFAVerifyResponse> => { if (!sdk.user) throw new Error('User not authenticated'); try { setIsLoading(true); setError(null); const verifyRequest: MFAVerifyRequest = { method, code, mfaToken: token, context: 'login', }; const response = await sdk.auth.verifyMFA(verifyRequest); return response; } catch (err) { return handleError(err); } finally { setIsLoading(false); } }, [sdk.user, handleError]); // Method management const removeMFAMethod = useCallback(async (methodId: string): Promise<void> => { if (!sdk.user) throw new Error('User not authenticated'); try { setIsLoading(true); setError(null); await sdk.user.removeMFAMethod(methodId); // Refresh MFA data await loadMFAData(); await reload(); } catch (err) { handleError(err); } finally { setIsLoading(false); } }, [sdk.user, loadMFAData, reload, handleError]); const setPrimaryMethod = useCallback(async (methodId: string): Promise<void> => { if (!sdk.user) throw new Error('User not authenticated'); try { setIsLoading(true); setError(null); await sdk.user.setPrimaryMFAMethod(methodId); // Refresh MFA data await loadMFAData(); } catch (err) { handleError(err); } finally { setIsLoading(false); } }, [sdk.user, loadMFAData, handleError]); const regenerateBackupCodes = useCallback(async (): Promise<string[]> => { if (!sdk.user) throw new Error('User not authenticated'); try { setIsLoading(true); setError(null); const response = await sdk.user.regenerateMFABackupCodes(); setBackupCodes(response.codes); return response.codes; } catch (err) { return handleError(err); } finally { setIsLoading(false); } }, [sdk.user, handleError]); // MFA status and method checking const isEnabled = useMemo(() => user?.mfaEnabled || false, [user]); const isRequired = useMemo(() => { // Check organization settings or user-specific requirements return false; // This would be determined by organization policy }, []); const primaryMethod = useMemo(() => { return mfaMethods.find(method => method.isPrimary) || null; }, [mfaMethods]); const hasTOTP = useMemo(() => mfaMethods.some(method => method.type === 'totp'), [mfaMethods] ); const hasSMS = useMemo(() => mfaMethods.some(method => method.type === 'sms'), [mfaMethods] ); const hasEmail = useMemo(() => mfaMethods.some(method => method.type === 'email'), [mfaMethods] ); const hasWebAuthn = useMemo(() => mfaMethods.some(method => method.type === 'webauthn'), [mfaMethods] ); const hasBackupCodes = useMemo(() => backupCodes.length > 0, [backupCodes] ); // Available methods (configured methods) const availableMethods = useMemo((): MFAMethodType[] => { const methods: MFAMethodType[] = []; if (hasTOTP) methods.push('totp'); if (hasSMS) methods.push('sms'); if (hasEmail) methods.push('email'); if (hasWebAuthn) methods.push('webauthn'); if (hasBackupCodes) methods.push('backup_codes'); return methods; }, [hasTOTP, hasSMS, hasEmail, hasWebAuthn, hasBackupCodes]); // Convenience methods const disable = useCallback(async (): Promise<void> => { if (!sdk.user) throw new Error('User not authenticated'); try { setIsLoading(true); setError(null); await sdk.user.disableMFA(); // Clear local state setMFAMethods([]); setBackupCodes([]); await reload(); } catch (err) { handleError(err); } finally { setIsLoading(false); } }, [sdk.user, reload, handleError]); const enable = useCallback(async (): Promise<void> => { if (!sdk.user) throw new Error('User not authenticated'); try { setIsLoading(true); setError(null); await sdk.user.enableMFA(); await reload(); } catch (err) { handleError(err); } finally { setIsLoading(false); } }, [sdk.user, reload, handleError]); const refreshMethods = useCallback(async (): Promise<void> => { await loadMFAData(); }, [loadMFAData]); return { // MFA state mfaMethods, isEnabled, isRequired, primaryMethod, backupCodes, isLoaded: !!user && isMFAAvailable, isLoading, error, // MFA setup setupTOTP, setupSMS, setupEmail, setupWebAuthn, // MFA verification verifySetup, verifyMFA, // Method management removeMFAMethod, setPrimaryMethod, regenerateBackupCodes, // MFA status checking hasTOTP, hasSMS, hasEmail, hasWebAuthn, hasBackupCodes, // Method availability availableMethods, // Convenience methods disable, enable, refreshMethods, }; } // ============================================================================ // Specialized MFA Hooks // ============================================================================ /** * Hook for TOTP (Time-based One-Time Password) management */ export function useTOTP() { const { setupTOTP, verifySetup, hasTOTP, mfaMethods, removeMFAMethod, isLoading, error, } = useMFA(); const totpMethod = useMemo(() => mfaMethods.find(method => method.type === 'totp'), [mfaMethods] ); return { isEnabled: hasTOTP, method: totpMethod, setup: setupTOTP, verify: (code: string) => verifySetup('totp', code), remove: totpMethod ? () => removeMFAMethod(totpMethod.id) : undefined, isLoading, error, }; } /** * Hook for SMS MFA management */ export function useSMSMFA() { const { setupSMS, verifySetup, hasSMS, mfaMethods, removeMFAMethod, isLoading, error, } = useMFA(); const smsMethod = useMemo(() => mfaMethods.find(method => method.type === 'sms'), [mfaMethods] ); return { isEnabled: hasSMS, method: smsMethod, setup: setupSMS, verify: (code: string) => verifySetup('sms', code), remove: smsMethod ? () => removeMFAMethod(smsMethod.id) : undefined, phoneNumber: smsMethod?.phoneNumber || null, isLoading, error, }; } /** * Hook for backup codes management */ export function useBackupCodes() { const { backupCodes, regenerateBackupCodes, hasBackupCodes, isLoading, error, } = useMFA(); const unusedCodes = useMemo(() => backupCodes.filter(code => !code.used), [backupCodes] ); const usedCodes = useMemo(() => backupCodes.filter(code => code.used), [backupCodes] ); return { codes: backupCodes, unusedCodes, usedCodes, hasBackupCodes, regenerate: regenerateBackupCodes, remainingCodes: unusedCodes.length, totalCodes: backupCodes.length, isRunningLow: unusedCodes.length <= 2, isLoading, error, }; }