POWR/docs/technical/auth/centralized_auth_system.md
DocNR ff8851bd04 feat(auth): implement auth system core infrastructure (Phase 1)
- Add state machine for authentication state management
- Add signing queue for background processing
- Add auth service for centralized authentication logic
- Add React context provider for components
- Keep feature flag disabled for now
2025-04-02 23:40:54 -04:00

18 KiB

Centralized Authentication System

Last Updated: 2025-04-02
Status: Proposed
Authors: POWR Team

Problem Statement

POWR's current authentication implementation experiences performance issues, particularly on Android with Amber integration:

  1. UI Thread Blocking: The current implementation uses synchronous startActivityForResult calls that block the main UI thread while waiting for Amber to respond, causing the app to freeze during signing operations.

  2. Login Performance: Initial login with Amber is slow due to the synchronous nature of the calls.

  3. Event Signing Freezes: Each event signing operation (kind 1, 1301, 33401, 33402, etc.) blocks the UI thread, making the app unresponsive when signing events.

  4. Lack of State Management: The current authentication system uses boolean flags rather than proper state management, causing cascading issues when authentication state changes.

  5. React Hook Inconsistencies: Components that conditionally use hooks based on authentication state can encounter ordering issues during state transitions.

Architecture Solution

We propose a comprehensive refactoring of the authentication system with three core components:

1. State Machine Pattern

Replace the current boolean-based authentication state with a formal state machine:

type AuthState = 
  | { status: 'unauthenticated' }
  | { status: 'authenticating', method: 'private_key' | 'amber' | 'ephemeral' }
  | { status: 'authenticated', user: NDKUser, method: 'private_key' | 'amber' | 'ephemeral' }
  | { status: 'signing', operationCount: number, operations: SigningOperation[] }
  | { status: 'error', error: Error };

This approach provides:

  • Clear, defined transitions between authentication states
  • Prevention of invalid state transitions
  • Consistent state management across components
  • Better error handling and recovery

2. Background Processing & Queue Management

Implement a non-blocking operation model for Amber communications:

  1. Thread Pool in Native Layer: Create a dedicated thread pool for Amber operations in the Kotlin layer
  2. Operation Queue in JS Layer: Implement a queue system to manage signing operations
  3. Asynchronous Bridge: Convert the current synchronous API to a fully asynchronous model

This approach eliminates UI freezing by moving all Amber operations off the main thread.

3. Centralized Authentication Service

Create a new service layer to centralize authentication logic:

lib/
├── auth/                              # New centralized auth directory
│   ├── AuthProvider.tsx               # React Context Provider 
│   ├── AuthService.ts                 # Core authentication service
│   ├── SigningQueue.ts                # Background queue for signing operations
│   ├── AuthStateManager.ts            # Authentication state machine
│   └── types.ts                       # Type definitions

This provides:

  • Single source of truth for authentication state
  • Clean separation of concerns
  • Consistent interface for components
  • Improved testability

Implementation Details

Native Layer Changes (Kotlin)

Modify AmberSignerModule.kt to use a background thread pool:

private val executorService = Executors.newFixedThreadPool(2)
private val mainHandler = Handler(Looper.getMainLooper())
private val pendingPromises = ConcurrentHashMap<String, Promise>()

@ReactMethod
fun signEvent(eventJson: String, currentUserPubkey: String, eventId: String?, promise: Promise) {
    // Execute in background thread pool
    executorService.execute {
        try {
            // Create intent - similar to current code
            val intent = createSignEventIntent(eventJson, currentUserPubkey, eventId)
            
            // Store promise with a unique ID for correlation
            val requestId = UUID.randomUUID().toString()
            pendingPromises[requestId] = promise
            intent.putExtra("requestId", requestId)
            
            // Launch activity from main thread
            mainHandler.post {
                try {
                    currentActivity?.startActivityForResult(intent, REQUEST_CODE_SIGN)
                } catch (e: Exception) {
                    pendingPromises.remove(requestId)?.reject("E_LAUNCH_ERROR", e.message)
                }
            }
        } catch (e: Exception) {
            promise.reject("E_PREPARATION_ERROR", e.message)
        }
    }
}

JavaScript Layer: SigningQueue Implementation

Based on insights from the NDK-mobile repo's signer implementations, we'll create an enhanced SigningQueue:

// lib/auth/SigningQueue.ts
export class SigningQueue {
  private queue: SigningOperation[] = [];
  private processing = false;
  private maxConcurrent = 1; // Limit concurrent operations
  private activeCount = 0;

  async enqueue(event: NostrEvent): Promise<string> {
    return new Promise((resolve, reject) => {
      // Add to queue and process
      this.queue.push({ event, resolve, reject });
      this.processQueue();
    });
  }

  private async processQueue() {
    if (this.processing || this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
      return;
    }

    this.processing = true;
    
    try {
      const operation = this.queue.shift()!;
      this.activeCount++;
      
      try {
        // Update state to show signing in progress
        AuthStateManager.setSigningInProgress(true, operation);
        
        // Perform the actual signing operation
        const signature = await ExternalSignerUtils.signEvent(
          operation.event, 
          operation.event.pubkey
        );
        
        operation.resolve(signature);
      } catch (error) {
        operation.reject(error);
      } finally {
        this.activeCount--;
        AuthStateManager.setSigningInProgress(false, operation);
      }
    } finally {
      this.processing = false;
      // Continue processing if items remain
      if (this.queue.length > 0) {
        this.processQueue();
      }
    }
  }
}

Enhanced NDKAmberSigner

Inspired by the NDK-mobile NDKNip55Signer implementation:

// lib/signers/EnhancedNDKAmberSigner.ts
export default class EnhancedNDKAmberSigner implements NDKSigner {
  private static signingQueue = new SigningQueue();
  private _pubkey: string;
  private _user?: NDKUser;
  
  constructor(pubkey: string, packageName: string) {
    this._pubkey = pubkey;
    this.packageName = packageName;
  }
  
  /**
   * Blocks until the signer is ready and returns the associated NDKUser.
   */
  async blockUntilReady(): Promise<NDKUser> {
    if (this._user) return this._user;
    
    this._user = new NDKUser({ pubkey: this._pubkey });
    return this._user;
  }
  
  /**
   * Getter for the user property.
   */
  async user(): Promise<NDKUser> {
    return this.blockUntilReady();
  }
  
  /**
   * Signs the given Nostr event using the queue-based system
   */
  async sign(event: NostrEvent): Promise<string> {
    console.log('AMBER SIGNER SIGNING', event);
    // Use the queue instead of direct signing
    return EnhancedNDKAmberSigner.signingQueue.enqueue(event);
  }
  
  getPublicKey(): string {
    return this._pubkey;
  }
}

Zustand-Based Auth Store

Drawing from the NDK-mobile store implementation:

// lib/auth/AuthStore.ts
export const useAuthStore = create<AuthState & AuthActions>((set, get) => ({
  status: 'unauthenticated',
  user: null,
  method: null,
  signingOperations: [],
  error: null,
  
  setAuthenticating: (method) => {
    set({ 
      status: 'authenticating',
      method 
    });
  },
  
  setAuthenticated: (user, method) => {
    set({
      status: 'authenticated',
      user,
      method,
      error: null
    });
  },
  
  setSigningInProgress: (inProgress, operation) => {
    const currentState = get();
    
    if (inProgress) {
      // Add operation to signing state
      set({
        status: 'signing',
        operationCount: (currentState.status === 'signing' ? currentState.operationCount : 0) + 1,
        signingOperations: [
          ...(currentState.status === 'signing' ? currentState.signingOperations : []),
          operation
        ]
      });
    } else {
      // Remove operation from signing state
      const operations = currentState.status === 'signing' 
        ? currentState.signingOperations.filter(op => op !== operation)
        : [];
        
      if (operations.length === 0) {
        // Return to authenticated state if no more operations
        set({
          status: 'authenticated',
          user: currentState.user,
          method: currentState.method,
        });
      } else {
        // Update count but stay in signing state
        set({
          signingOperations: operations,
          operationCount: operations.length
        });
      }
    }
  },
  
  logout: () => {
    // Clear NDK signer
    if (ndk) ndk.signer = undefined;
    
    // Clear secure storage
    SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY);
    AsyncStorage.multiRemove([
      'currentUser',
      'login',
      'signer',
      'auth.last_login'
    ]);
    
    // Reset state
    set({
      status: 'unauthenticated',
      user: null,
      method: null,
      signingOperations: [],
      error: null
    });
  },
  
  setError: (error) => {
    set({
      status: 'error',
      error
    });
  }
}));

Integration with User Avatars and Robohash

As part of our authentication enhancement, we'll integrate the Robohash service for user avatars:

Avatar Utility Functions

// utils/avatar.ts
import { Platform } from 'react-native';

/**
 * Constants for avatar generation
 */
export const AVATAR_PLACEHOLDER = 'https://robohash.org/placeholder?set=set4';

/**
 * Generates a Robohash URL for a given public key
 */
export function generateRobohashUrl(pubkey: string, size = 150): string {
  // Use the pubkey as the seed for Robohash
  // Set 4 is the "kittens" set, which is more visually appealing than robots
  return `https://robohash.org/${pubkey}?set=set4&size=${size}x${size}`;
}

/**
 * Determines the appropriate avatar URL based on authentication state
 */
export function getAvatarUrl(params: {
  profileImageUrl?: string;
  pubkey?: string;
  isAuthenticated: boolean;
  method?: 'private_key' | 'amber' | 'ephemeral';
}): string {
  const { profileImageUrl, pubkey, isAuthenticated, method } = params;
  
  // If we have a profile image URL and it's valid, use it
  if (profileImageUrl && profileImageUrl.startsWith('http')) {
    return profileImageUrl;
  }
  
  // If user is authenticated but doesn't have a profile image,
  // generate a Robohash based on their pubkey
  if (isAuthenticated && pubkey) {
    return generateRobohashUrl(pubkey);
  }
  
  // Use placeholder for unauthenticated or ephemeral users
  return AVATAR_PLACEHOLDER;
}

/**
 * Creates appropriate caching parameters for avatar images
 */
export function getAvatarCacheOptions() {
  return {
    // Images should be cached for 7 days
    expiresIn: 7 * 24 * 60 * 60 * 1000,
    // Use memory cache for better performance
    immutable: true,
    // Platform-specific cache behavior
    ...Platform.select({
      web: {
        cache: 'force-cache'
      },
      default: {
        // React Native specific cache options
      }
    })
  };
}

Enhanced UserAvatar Component

// components/UserAvatar.tsx
import React, { useEffect, useState } from 'react';
import { Image, View } from 'react-native';
import { Avatar } from 'components/ui/avatar';
import { useAuthStore } from 'lib/auth/AuthStore';
import { getAvatarUrl, getAvatarCacheOptions } from 'utils/avatar';

export interface UserAvatarProps {
  profileImageUrl?: string;
  pubkey?: string;
  size?: 'sm' | 'md' | 'lg' | number;
  onPress?: () => void;
}

const SIZE_MAP = {
  sm: 32,
  md: 48,
  lg: 64
};

export function UserAvatar({ 
  profileImageUrl, 
  pubkey,
  size = 'md',
  onPress
}: UserAvatarProps) {
  const { status, user, method } = useAuthStore();
  const isAuthenticated = status === 'authenticated' || status === 'signing';
  
  // If no pubkey provided, use the authenticated user's pubkey
  const effectivePubkey = pubkey || (user?.pubkey);
  const numericSize = typeof size === 'number' ? size : SIZE_MAP[size];
  
  // Determine the correct avatar URL
  const avatarUrl = getAvatarUrl({
    profileImageUrl,
    pubkey: effectivePubkey,
    isAuthenticated,
    method
  });
  
  return (
    <Avatar
      size={numericSize}
      onPress={onPress}
      source={{
        uri: avatarUrl,
        ...getAvatarCacheOptions()
      }}
      fallback={
        <Image
          source={{ uri: generateRobohashUrl('placeholder', numericSize) }}
          style={{ width: numericSize, height: numericSize, borderRadius: numericSize / 2 }}
        />
      }
    />
  );
}

iOS Considerations

While the primary performance issues affect Android due to the Amber integration, the centralized authentication system will also benefit iOS in several ways:

  1. Unified Authentication Experience: The state machine approach ensures consistent authentication behavior across platforms.

  2. Future External Signer Support: As NIP-55 compliant signers become available on iOS, the architecture is ready to support them.

  3. Enhanced State Management: The state machine approach improves React hook ordering issues that can affect both platforms.

  4. Consistent UI Feedback: Authentication state indicators will work consistently across both platforms.

  5. Performance Benefits: Though iOS doesn't use Amber, the queue-based signature system still benefits performance by preventing multiple simultaneous signing operations.

  6. Cleaner Code Structure: The centralized authentication service provides a cleaner approach for all platforms, making iOS-specific code easier to maintain.

Private Key Security

This architecture maintains and enhances the existing security measures for private keys:

iOS Private Key Storage

On iOS, private keys (nsec) are stored securely using expo-secure-store, which leverages iOS's Keychain Services. This provides:

  • Data encrypted at rest using the device's security hardware
  • Protection from other apps accessing the keychain data
  • Automatic removal when the app is uninstalled

The current implementation in lib/stores/ndk.ts already uses SecureStore.setItemAsync(PRIVATE_KEY_STORAGE_KEY, privateKeyHex) to store private keys securely, and this implementation will be maintained in the new architecture.

Android Private Key Storage

On Android, private keys are stored using expo-secure-store, which leverages Android's EncryptedSharedPreferences. This provides:

  • Encryption of both keys and values
  • Integration with Android Keystore
  • Protection by the device's security model

For Amber users, no private key is stored in POWR at all - only the public key is stored. The private key remains exclusively in the Amber app, providing maximum security.

Key Lifecycle

The new architecture enhances key security through clear state transitions:

  1. Key Generation: Generated keys never leave the JavaScript context until stored
  2. Key Storage: Keys are immediately stored in secure storage
  3. Key Retrieval: Keys are loaded directly into the signer without unnecessary exposure
  4. Key Deletion: On logout, keys are completely removed from secure storage

This state machine approach ensures there are no "in-between" states where keys might be exposed.

NDK Integration Insights

From reviewing the NDK and NDK-mobile repositories, we've incorporated several best practices:

  1. Cleaner Signer Interface: Based on NDK-mobile's NDKNip55Signer, our implementation has a cleaner interface with better error handling and logging.

  2. State Management: Adopted Zustand-based state management similar to the NDK-mobile approach, but enhanced with our state machine model.

  3. Authentication Flow: Incorporated the cleaner login/logout flow from NDK store with enhanced persistent state management.

  4. Signer Initialization: Added blockUntilReady pattern to ensure signers are properly initialized before use.

These patterns from the reference repositories provide a solid foundation for our enhanced architecture while addressing the specific performance issues in our application.

Migration Strategy

To implement this architecture while minimizing disruption:

Phased Implementation

  1. Create Core Infrastructure: First implement the AuthStateManager and SigningQueue without changing existing code
  2. Adapt NDK Store: Update the NDK store to use the new authentication system
  3. Update Native Module: Modify AmberSignerModule to use background processing
  4. Component Migration: Gradually update components to use the new system

Feature Flag Approach

Implement a feature flag to toggle between old and new authentication systems:

const useNewAuthSystem = true; // Toggle for testing

// In NDKStore
if (useNewAuthSystem) {
  // Use new AuthService
} else {
  // Use existing implementation
}

This allows for:

  • A/B testing during development
  • Easy rollback if issues are discovered
  • Gradual migration of components

Benefits

This architecture provides significant improvements:

  1. Immediate Performance Benefits: Eliminates UI freezing during authentication and signing
  2. Improved User Experience: Provides visual feedback during signing operations
  3. Enhanced Stability: Proper state management prevents cascading issues
  4. Better Developer Experience: Clean separation of concerns makes the code more maintainable
  5. Future Extensibility: This foundation makes it easier to add features like batch signing
  6. Consistent Avatars: Users always have a visual representation, whether using robohash or custom images

Timeline and Resources

Estimated Timeline

  1. Planning and Design: 1-2 days
  2. Core Infrastructure: 3-4 days
  3. Native Module Updates: 2-3 days
  4. Integration and Testing: 3-5 days
  5. Component Migration: 5-7 days (progressive)

Total: 2-3 weeks for full implementation

Resources Required

  • 1 React Native developer with TypeScript experience
  • 1 Android developer with Kotlin experience
  • Testing devices with Amber installed