mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-06 18:31:03 +00:00
1059 lines
28 KiB
Markdown
1059 lines
28 KiB
Markdown
![]() |
# Authentication System Implementation Plan
|
||
|
|
||
|
**Last Updated:** 2025-04-02
|
||
|
**Status:** Proposed
|
||
|
**Authors:** POWR Team
|
||
|
|
||
|
## Overview
|
||
|
|
||
|
This document outlines the detailed implementation plan for the Centralized Authentication System described in `centralized_auth_system.md`. It provides concrete steps, code structure, and a timeline for implementing the new authentication architecture.
|
||
|
|
||
|
## Phase 1: Core Infrastructure (3-4 days)
|
||
|
|
||
|
### 1.1 Authentication State Types
|
||
|
|
||
|
First, create the core type definitions for the authentication state machine:
|
||
|
|
||
|
```typescript
|
||
|
// lib/auth/types.ts
|
||
|
import { NDKUser, NostrEvent } from "@nostr-dev-kit/ndk";
|
||
|
|
||
|
export type AuthMethod = 'private_key' | 'amber' | 'ephemeral';
|
||
|
|
||
|
export type SigningOperation = {
|
||
|
event: NostrEvent;
|
||
|
resolve: (signature: string) => void;
|
||
|
reject: (error: Error) => void;
|
||
|
timestamp: number;
|
||
|
};
|
||
|
|
||
|
export type AuthState =
|
||
|
| { status: 'unauthenticated' }
|
||
|
| { status: 'authenticating', method: AuthMethod }
|
||
|
| { status: 'authenticated', user: NDKUser, method: AuthMethod }
|
||
|
| {
|
||
|
status: 'signing',
|
||
|
user: NDKUser,
|
||
|
method: AuthMethod,
|
||
|
operationCount: number,
|
||
|
operations: SigningOperation[]
|
||
|
}
|
||
|
| { status: 'error', error: Error, previousState?: AuthState };
|
||
|
|
||
|
export interface AuthActions {
|
||
|
setAuthenticating: (method: AuthMethod) => void;
|
||
|
setAuthenticated: (user: NDKUser, method: AuthMethod) => void;
|
||
|
setSigningInProgress: (inProgress: boolean, operation: SigningOperation) => void;
|
||
|
logout: () => void;
|
||
|
setError: (error: Error) => void;
|
||
|
}
|
||
|
```
|
||
|
|
||
|
### 1.2 Signing Queue Implementation
|
||
|
|
||
|
Create the queue system to manage signing operations:
|
||
|
|
||
|
```typescript
|
||
|
// lib/auth/SigningQueue.ts
|
||
|
import { NostrEvent } from "@nostr-dev-kit/ndk";
|
||
|
import { SigningOperation } from "./types";
|
||
|
import { AuthStateManager } from "./AuthStateManager";
|
||
|
|
||
|
export class SigningQueue {
|
||
|
private queue: SigningOperation[] = [];
|
||
|
private processing = false;
|
||
|
private maxConcurrent = 1;
|
||
|
private activeCount = 0;
|
||
|
|
||
|
async enqueue(event: NostrEvent): Promise<string> {
|
||
|
return new Promise((resolve, reject) => {
|
||
|
// Create signing operation with timestamp for ordering
|
||
|
const operation: SigningOperation = {
|
||
|
event,
|
||
|
resolve,
|
||
|
reject,
|
||
|
timestamp: Date.now()
|
||
|
};
|
||
|
|
||
|
// Add to queue and process
|
||
|
this.queue.push(operation);
|
||
|
this.processQueue();
|
||
|
});
|
||
|
}
|
||
|
|
||
|
private async processQueue() {
|
||
|
if (this.processing || this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.processing = true;
|
||
|
|
||
|
try {
|
||
|
// Sort queue by timestamp (oldest first)
|
||
|
this.queue.sort((a, b) => a.timestamp - b.timestamp);
|
||
|
|
||
|
const operation = this.queue.shift()!;
|
||
|
this.activeCount++;
|
||
|
|
||
|
try {
|
||
|
// Update state to show signing in progress
|
||
|
AuthStateManager.setSigningInProgress(true, operation);
|
||
|
|
||
|
// Delegate to the appropriate signing method based on event type
|
||
|
// This will be implemented by each specific signer
|
||
|
const signature = await this.performSigning(operation.event);
|
||
|
|
||
|
operation.resolve(signature);
|
||
|
} catch (error) {
|
||
|
console.error("Signing error:", error);
|
||
|
operation.reject(error instanceof Error ? error : new Error(String(error)));
|
||
|
} finally {
|
||
|
this.activeCount--;
|
||
|
AuthStateManager.setSigningInProgress(false, operation);
|
||
|
}
|
||
|
} finally {
|
||
|
this.processing = false;
|
||
|
// Continue processing if items remain
|
||
|
if (this.queue.length > 0) {
|
||
|
this.processQueue();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private async performSigning(event: NostrEvent): Promise<string> {
|
||
|
// This is a placeholder - the actual signing will be done
|
||
|
// by the NDKAmberSigner or other signers
|
||
|
throw new Error("Must be implemented by specific signer implementation");
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
### 1.3 Auth State Manager
|
||
|
|
||
|
Implement the centralized state manager:
|
||
|
|
||
|
```typescript
|
||
|
// lib/auth/AuthStateManager.ts
|
||
|
import { create } from "zustand";
|
||
|
import { NDKUser } from "@nostr-dev-kit/ndk";
|
||
|
import {
|
||
|
AuthState,
|
||
|
AuthActions,
|
||
|
AuthMethod,
|
||
|
SigningOperation
|
||
|
} from "./types";
|
||
|
import * as SecureStore from "expo-secure-store";
|
||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||
|
|
||
|
const PRIVATE_KEY_STORAGE_KEY = "powr.private_key";
|
||
|
|
||
|
export const useAuthStore = create<AuthState & AuthActions>((set, get) => ({
|
||
|
status: 'unauthenticated',
|
||
|
|
||
|
setAuthenticating: (method) => {
|
||
|
set({
|
||
|
status: 'authenticating',
|
||
|
method
|
||
|
});
|
||
|
},
|
||
|
|
||
|
setAuthenticated: (user, method) => {
|
||
|
set({
|
||
|
status: 'authenticated',
|
||
|
user,
|
||
|
method,
|
||
|
});
|
||
|
},
|
||
|
|
||
|
setSigningInProgress: (inProgress, operation) => {
|
||
|
const currentState = get();
|
||
|
|
||
|
if (inProgress) {
|
||
|
// Handle transition to signing state
|
||
|
if (currentState.status === 'signing') {
|
||
|
// Already in signing state, update the operations list and count
|
||
|
set({
|
||
|
operationCount: currentState.operationCount + 1,
|
||
|
operations: [...currentState.operations, operation]
|
||
|
});
|
||
|
} else if (currentState.status === 'authenticated') {
|
||
|
// Transition from authenticated to signing
|
||
|
set({
|
||
|
status: 'signing',
|
||
|
user: currentState.user,
|
||
|
method: currentState.method,
|
||
|
operationCount: 1,
|
||
|
operations: [operation]
|
||
|
});
|
||
|
} else {
|
||
|
// Invalid state transition - can only sign when authenticated
|
||
|
set({
|
||
|
status: 'error',
|
||
|
error: new Error('Cannot sign: not authenticated'),
|
||
|
previousState: currentState
|
||
|
});
|
||
|
}
|
||
|
} else {
|
||
|
// Handle transition from signing state
|
||
|
if (currentState.status === 'signing') {
|
||
|
// Remove the completed operation
|
||
|
const updatedOperations = currentState.operations.filter(
|
||
|
op => op !== operation
|
||
|
);
|
||
|
|
||
|
if (updatedOperations.length === 0) {
|
||
|
// No more operations, return to authenticated state
|
||
|
set({
|
||
|
status: 'authenticated',
|
||
|
user: currentState.user,
|
||
|
method: currentState.method
|
||
|
});
|
||
|
} else {
|
||
|
// Still have pending operations
|
||
|
set({
|
||
|
operations: updatedOperations,
|
||
|
operationCount: updatedOperations.length
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
// If not in signing state, this is a no-op
|
||
|
}
|
||
|
},
|
||
|
|
||
|
logout: async () => {
|
||
|
try {
|
||
|
// Cancel any pending operations
|
||
|
const currentState = get();
|
||
|
if (currentState.status === 'signing') {
|
||
|
// Reject any pending operations with cancellation error
|
||
|
currentState.operations.forEach(operation => {
|
||
|
operation.reject(new Error('Authentication session terminated'));
|
||
|
});
|
||
|
}
|
||
|
|
||
|
// Clear NDK signer (will be handled by AuthService)
|
||
|
|
||
|
// Securely clear all sensitive data from storage
|
||
|
await SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
||
|
await AsyncStorage.multiRemove([
|
||
|
'currentUser',
|
||
|
'login',
|
||
|
'signer',
|
||
|
'auth.last_login',
|
||
|
'auth.permissions',
|
||
|
'auth.session',
|
||
|
'ndkMobileSessionLastEose'
|
||
|
]);
|
||
|
|
||
|
// Reset state to unauthenticated
|
||
|
set({
|
||
|
status: 'unauthenticated'
|
||
|
});
|
||
|
|
||
|
// Log the logout event (without PII)
|
||
|
console.info('User logged out successfully');
|
||
|
|
||
|
return true;
|
||
|
} catch (error) {
|
||
|
console.error('Error during logout:', error);
|
||
|
return false;
|
||
|
}
|
||
|
},
|
||
|
|
||
|
setError: (error) => {
|
||
|
const currentState = get();
|
||
|
set({
|
||
|
status: 'error',
|
||
|
error,
|
||
|
previousState: currentState
|
||
|
});
|
||
|
}
|
||
|
}));
|
||
|
|
||
|
// Export a singleton for easier access
|
||
|
export const AuthStateManager = {
|
||
|
getState: useAuthStore.getState,
|
||
|
setState: useAuthStore,
|
||
|
setAuthenticating: useAuthStore.getState().setAuthenticating,
|
||
|
setAuthenticated: useAuthStore.getState().setAuthenticated,
|
||
|
setSigningInProgress: useAuthStore.getState().setSigningInProgress,
|
||
|
logout: useAuthStore.getState().logout,
|
||
|
setError: useAuthStore.getState().setError
|
||
|
};
|
||
|
```
|
||
|
|
||
|
### 1.4 Auth Service
|
||
|
|
||
|
Create the service layer to manage authentication operations:
|
||
|
|
||
|
```typescript
|
||
|
// lib/auth/AuthService.ts
|
||
|
import { NDKUser, NDK, NDKSigner } from "@nostr-dev-kit/ndk";
|
||
|
import {
|
||
|
AuthMethod,
|
||
|
SigningOperation
|
||
|
} from "./types";
|
||
|
import { AuthStateManager } from "./AuthStateManager";
|
||
|
import { SigningQueue } from "./SigningQueue";
|
||
|
import * as SecureStore from "expo-secure-store";
|
||
|
|
||
|
const PRIVATE_KEY_STORAGE_KEY = "powr.private_key";
|
||
|
|
||
|
export class AuthService {
|
||
|
private ndk: NDK;
|
||
|
private signingQueue = new SigningQueue();
|
||
|
|
||
|
constructor(ndk: NDK) {
|
||
|
this.ndk = ndk;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Initialize from stored state
|
||
|
*/
|
||
|
async initialize(): Promise<void> {
|
||
|
try {
|
||
|
// Try to restore previous auth session
|
||
|
const privateKey = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
||
|
|
||
|
if (privateKey) {
|
||
|
await this.loginWithPrivateKey(privateKey);
|
||
|
}
|
||
|
} catch (error) {
|
||
|
console.error("Error initializing auth service:", error);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Login with a private key
|
||
|
*/
|
||
|
async loginWithPrivateKey(privateKey: string): Promise<NDKUser> {
|
||
|
try {
|
||
|
AuthStateManager.setAuthenticating('private_key');
|
||
|
|
||
|
// Configure NDK with private key signer
|
||
|
this.ndk.signer = await this.createPrivateKeySigner(privateKey);
|
||
|
|
||
|
// Get user
|
||
|
const user = await this.ndk.signer.user();
|
||
|
|
||
|
// Store key securely
|
||
|
await SecureStore.setItemAsync(PRIVATE_KEY_STORAGE_KEY, privateKey);
|
||
|
|
||
|
// Update auth state
|
||
|
AuthStateManager.setAuthenticated(user, 'private_key');
|
||
|
|
||
|
return user;
|
||
|
} catch (error) {
|
||
|
AuthStateManager.setError(error instanceof Error ? error : new Error(String(error)));
|
||
|
throw error;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Login with Amber signer
|
||
|
*/
|
||
|
async loginWithAmber(): Promise<NDKUser> {
|
||
|
try {
|
||
|
AuthStateManager.setAuthenticating('amber');
|
||
|
|
||
|
// Request public key from Amber
|
||
|
const { pubkey, packageName } = await this.requestAmberPublicKey();
|
||
|
|
||
|
// Create an NDKAmberSigner
|
||
|
this.ndk.signer = await this.createAmberSigner(pubkey, packageName);
|
||
|
|
||
|
// Get user
|
||
|
const user = await this.ndk.signer.user();
|
||
|
|
||
|
// Update auth state
|
||
|
AuthStateManager.setAuthenticated(user, 'amber');
|
||
|
|
||
|
return user;
|
||
|
} catch (error) {
|
||
|
AuthStateManager.setError(error instanceof Error ? error : new Error(String(error)));
|
||
|
throw error;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Create ephemeral key (no login)
|
||
|
*/
|
||
|
async createEphemeralKey(): Promise<NDKUser> {
|
||
|
try {
|
||
|
AuthStateManager.setAuthenticating('ephemeral');
|
||
|
|
||
|
// Generate a random key
|
||
|
this.ndk.signer = await this.createEphemeralSigner();
|
||
|
|
||
|
// Get user
|
||
|
const user = await this.ndk.signer.user();
|
||
|
|
||
|
// Update auth state
|
||
|
AuthStateManager.setAuthenticated(user, 'ephemeral');
|
||
|
|
||
|
return user;
|
||
|
} catch (error) {
|
||
|
AuthStateManager.setError(error instanceof Error ? error : new Error(String(error)));
|
||
|
throw error;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Logout
|
||
|
*/
|
||
|
async logout(): Promise<void> {
|
||
|
// Clear NDK signer
|
||
|
this.ndk.signer = undefined;
|
||
|
|
||
|
// Clear auth state
|
||
|
AuthStateManager.logout();
|
||
|
}
|
||
|
|
||
|
// Private helper methods for creating specific signers
|
||
|
private async createPrivateKeySigner(privateKey: string): Promise<NDKSigner> {
|
||
|
// Implementation
|
||
|
return null!;
|
||
|
}
|
||
|
|
||
|
private async requestAmberPublicKey(): Promise<{ pubkey: string, packageName: string }> {
|
||
|
// Implementation
|
||
|
return { pubkey: "", packageName: "" };
|
||
|
}
|
||
|
|
||
|
private async createAmberSigner(pubkey: string, packageName: string): Promise<NDKSigner> {
|
||
|
// Implementation
|
||
|
return null!;
|
||
|
}
|
||
|
|
||
|
private async createEphemeralSigner(): Promise<NDKSigner> {
|
||
|
// Implementation
|
||
|
return null!;
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
### 1.5 React Context Provider
|
||
|
|
||
|
Create the React context provider for components to consume:
|
||
|
|
||
|
```typescript
|
||
|
// lib/auth/AuthProvider.tsx
|
||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||
|
import { useAuthStore } from './AuthStateManager';
|
||
|
import { AuthService } from './AuthService';
|
||
|
import { NDK } from '@nostr-dev-kit/ndk';
|
||
|
|
||
|
// Create context
|
||
|
interface AuthContextValue {
|
||
|
authService: AuthService;
|
||
|
// Add any additional context properties needed
|
||
|
}
|
||
|
|
||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||
|
|
||
|
// Provider component
|
||
|
interface AuthProviderProps {
|
||
|
children: React.ReactNode;
|
||
|
ndk: NDK;
|
||
|
}
|
||
|
|
||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children, ndk }) => {
|
||
|
const [authService] = useState(() => new AuthService(ndk));
|
||
|
const authState = useAuthStore();
|
||
|
|
||
|
// Initialize on mount
|
||
|
useEffect(() => {
|
||
|
authService.initialize();
|
||
|
}, [authService]);
|
||
|
|
||
|
// Provide context value
|
||
|
const contextValue: AuthContextValue = {
|
||
|
authService
|
||
|
};
|
||
|
|
||
|
return (
|
||
|
<AuthContext.Provider value={contextValue}>
|
||
|
{children}
|
||
|
</AuthContext.Provider>
|
||
|
);
|
||
|
};
|
||
|
|
||
|
// Hook for consuming context
|
||
|
export const useAuth = () => {
|
||
|
const context = useContext(AuthContext);
|
||
|
if (!context) {
|
||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||
|
}
|
||
|
return context;
|
||
|
};
|
||
|
```
|
||
|
|
||
|
## Phase 2: Amber Signer Enhancements (2-3 days)
|
||
|
|
||
|
### 2.1 Native Module Updates
|
||
|
|
||
|
Update the Kotlin module to use background processing:
|
||
|
|
||
|
```kotlin
|
||
|
// android/app/src/main/java/com/powr/app/AmberSignerModule.kt
|
||
|
package com.powr.app
|
||
|
|
||
|
import android.app.Activity
|
||
|
import android.content.Intent
|
||
|
import android.os.Handler
|
||
|
import android.os.Looper
|
||
|
import com.facebook.react.bridge.*
|
||
|
import java.util.UUID
|
||
|
import java.util.concurrent.ConcurrentHashMap
|
||
|
import java.util.concurrent.Executors
|
||
|
|
||
|
class AmberSignerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||
|
private val executorService = Executors.newFixedThreadPool(2)
|
||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||
|
private val pendingPromises = ConcurrentHashMap<String, Promise>()
|
||
|
|
||
|
companion object {
|
||
|
const val NAME = "AmberSigner"
|
||
|
const val REQUEST_CODE_PUBLIC_KEY = 1
|
||
|
const val REQUEST_CODE_SIGN = 2
|
||
|
}
|
||
|
|
||
|
override fun getName(): String = NAME
|
||
|
|
||
|
@ReactMethod
|
||
|
fun requestPublicKey(promise: Promise) {
|
||
|
executorService.execute {
|
||
|
try {
|
||
|
val intent = createRequestPublicKeyIntent()
|
||
|
|
||
|
// 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_PUBLIC_KEY)
|
||
|
} catch (e: Exception) {
|
||
|
pendingPromises.remove(requestId)?.reject("E_LAUNCH_ERROR", e.message)
|
||
|
}
|
||
|
}
|
||
|
} catch (e: Exception) {
|
||
|
promise.reject("E_PREPARATION_ERROR", e.message)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@ReactMethod
|
||
|
fun signEvent(eventJson: String, currentUserPubkey: String, eventId: String?, promise: Promise) {
|
||
|
executorService.execute {
|
||
|
try {
|
||
|
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)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Handle activity results
|
||
|
fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
|
||
|
when (requestCode) {
|
||
|
REQUEST_CODE_PUBLIC_KEY -> handlePublicKeyResult(resultCode, data)
|
||
|
REQUEST_CODE_SIGN -> handleSignResult(resultCode, data)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private fun handlePublicKeyResult(resultCode: Int, data: Intent?) {
|
||
|
// Implementation
|
||
|
}
|
||
|
|
||
|
private fun handleSignResult(resultCode: Int, data: Intent?) {
|
||
|
// Implementation
|
||
|
}
|
||
|
|
||
|
private fun createRequestPublicKeyIntent(): Intent {
|
||
|
// Implementation
|
||
|
return Intent()
|
||
|
}
|
||
|
|
||
|
private fun createSignEventIntent(eventJson: String, currentUserPubkey: String, eventId: String?): Intent {
|
||
|
// Implementation
|
||
|
return Intent()
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
### 2.2 Enhanced NDKAmberSigner
|
||
|
|
||
|
Create an enhanced NDKAmberSigner that uses the queue:
|
||
|
|
||
|
```typescript
|
||
|
// lib/signers/NDKAmberSigner.ts
|
||
|
import { NDKSigner, NDKUser, NostrEvent } from "@nostr-dev-kit/ndk";
|
||
|
import { SigningQueue } from "../auth/SigningQueue";
|
||
|
import { NativeModules } from "react-native";
|
||
|
|
||
|
const { AmberSigner } = NativeModules;
|
||
|
|
||
|
export default class NDKAmberSigner implements NDKSigner {
|
||
|
private static signingQueue = new SigningQueue();
|
||
|
private _pubkey: string;
|
||
|
private _user?: NDKUser;
|
||
|
private packageName: string;
|
||
|
|
||
|
constructor(pubkey: string, packageName: string) {
|
||
|
this._pubkey = pubkey;
|
||
|
this.packageName = packageName;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Static method to request a public key from Amber
|
||
|
*/
|
||
|
static async requestPublicKey(): Promise<{ pubkey: string, packageName: string }> {
|
||
|
try {
|
||
|
const result = await AmberSigner.requestPublicKey();
|
||
|
return {
|
||
|
pubkey: result.pubkey,
|
||
|
packageName: result.packageName
|
||
|
};
|
||
|
} catch (error) {
|
||
|
console.error('Error requesting public key from Amber:', error);
|
||
|
throw error;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Blocks until the signer is ready
|
||
|
*/
|
||
|
async blockUntilReady(): Promise<NDKUser> {
|
||
|
if (this._user) return this._user;
|
||
|
|
||
|
this._user = new NDKUser({ pubkey: this._pubkey });
|
||
|
return this._user;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns the NDKUser for this signer
|
||
|
*/
|
||
|
async user(): Promise<NDKUser> {
|
||
|
return this.blockUntilReady();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Signs the given Nostr event using the queue-based approach
|
||
|
*/
|
||
|
async sign(event: NostrEvent): Promise<string> {
|
||
|
return new Promise((resolve, reject) => {
|
||
|
// Add to signing queue instead of blocking
|
||
|
NDKAmberSigner.signingQueue.enqueue({
|
||
|
event,
|
||
|
resolve,
|
||
|
reject,
|
||
|
timestamp: Date.now(),
|
||
|
execute: async () => {
|
||
|
try {
|
||
|
console.debug('Signing event with Amber:', event.id);
|
||
|
|
||
|
// Use the native module to sign
|
||
|
const result = await AmberSigner.signEvent(
|
||
|
JSON.stringify(event),
|
||
|
this._pubkey,
|
||
|
event.id
|
||
|
);
|
||
|
|
||
|
return result.signature;
|
||
|
} catch (error) {
|
||
|
console.error('Error signing with Amber:', error);
|
||
|
throw error;
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns the pubkey
|
||
|
*/
|
||
|
getPublicKey(): string {
|
||
|
return this._pubkey;
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
## Phase 3: NDK Store Integration (2-3 days)
|
||
|
|
||
|
### 3.1 Update NDK Store to Use Auth System
|
||
|
|
||
|
```typescript
|
||
|
// lib/stores/ndk.ts
|
||
|
import NDK, { NDKConstructorParams, NDKEvent, NDKSigner, NDKUser } from "@nostr-dev-kit/ndk";
|
||
|
import { create } from "zustand";
|
||
|
import { AuthService } from "../auth/AuthService";
|
||
|
import { useAuthStore } from "../auth/AuthStateManager";
|
||
|
import * as SecureStore from "expo-secure-store";
|
||
|
|
||
|
export type InitNDKParams = NDKConstructorParams & {
|
||
|
// Any additional params
|
||
|
}
|
||
|
|
||
|
type State = {
|
||
|
ndk: NDK;
|
||
|
authService: AuthService | null;
|
||
|
initialParams: InitNDKParams;
|
||
|
}
|
||
|
|
||
|
type Actions = {
|
||
|
init: (ndk: NDK) => void;
|
||
|
login: (privateKey: string) => Promise<NDKUser>;
|
||
|
loginWithAmber: () => Promise<NDKUser>;
|
||
|
createEphemeralUser: () => Promise<NDKUser>;
|
||
|
logout: () => void;
|
||
|
}
|
||
|
|
||
|
export const useNDKStore = create<State & Actions>((set, get) => ({
|
||
|
ndk: undefined,
|
||
|
authService: null,
|
||
|
initialParams: undefined,
|
||
|
|
||
|
init: (ndk: NDK) => {
|
||
|
const authService = new AuthService(ndk);
|
||
|
|
||
|
set({
|
||
|
ndk,
|
||
|
authService,
|
||
|
});
|
||
|
|
||
|
// Initialize auth service
|
||
|
authService.initialize();
|
||
|
},
|
||
|
|
||
|
login: async (privateKey: string) => {
|
||
|
const { authService } = get();
|
||
|
if (!authService) throw new Error('Auth service not initialized');
|
||
|
|
||
|
return authService.loginWithPrivateKey(privateKey);
|
||
|
},
|
||
|
|
||
|
loginWithAmber: async () => {
|
||
|
const { authService } = get();
|
||
|
if (!authService) throw new Error('Auth service not initialized');
|
||
|
|
||
|
return authService.loginWithAmber();
|
||
|
},
|
||
|
|
||
|
createEphemeralUser: async () => {
|
||
|
const { authService } = get();
|
||
|
if (!authService) throw new Error('Auth service not initialized');
|
||
|
|
||
|
return authService.createEphemeralKey();
|
||
|
},
|
||
|
|
||
|
logout: () => {
|
||
|
const { authService } = get();
|
||
|
if (!authService) return;
|
||
|
|
||
|
authService.logout();
|
||
|
}
|
||
|
}));
|
||
|
```
|
||
|
|
||
|
## Phase 4: Component Integration (5-7 days)
|
||
|
|
||
|
### 4.1 Update Login Sheet
|
||
|
|
||
|
```typescript
|
||
|
// components/sheets/NostrLoginSheet.tsx
|
||
|
import React, { useState, useEffect } from 'react';
|
||
|
import { View, Text, TouchableOpacity, Platform } from 'react-native';
|
||
|
import { useNDKStore } from '@/lib/stores/ndk';
|
||
|
import { useAuthStore } from '@/lib/auth/AuthStateManager';
|
||
|
import { ExternalSignerUtils } from '@/utils/ExternalSignerUtils';
|
||
|
|
||
|
export default function NostrLoginSheet() {
|
||
|
const { login, loginWithAmber, createEphemeralUser } = useNDKStore();
|
||
|
const [privateKey, setPrivateKey] = useState('');
|
||
|
const [isExternalSignerAvailable, setIsExternalSignerAvailable] = useState(false);
|
||
|
const authState = useAuthStore();
|
||
|
|
||
|
// Check for external signer availability
|
||
|
useEffect(() => {
|
||
|
const checkExternalSigner = async () => {
|
||
|
if (Platform.OS === 'android') {
|
||
|
const available = await ExternalSignerUtils.isExternalSignerInstalled();
|
||
|
setIsExternalSignerAvailable(available);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
checkExternalSigner();
|
||
|
}, []);
|
||
|
|
||
|
// Handle private key login
|
||
|
const handlePrivateKeyLogin = async () => {
|
||
|
try {
|
||
|
await login(privateKey);
|
||
|
// Handle success (close sheet, etc.)
|
||
|
} catch (error) {
|
||
|
// Handle error
|
||
|
console.error('Login error:', error);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// Handle Amber login
|
||
|
const handleAmberLogin = async () => {
|
||
|
try {
|
||
|
await loginWithAmber();
|
||
|
// Handle success (close sheet, etc.)
|
||
|
} catch (error) {
|
||
|
// Handle error
|
||
|
console.error('Amber login error:', error);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// Handle ephemeral login
|
||
|
const handleEphemeralLogin = async () => {
|
||
|
try {
|
||
|
await createEphemeralUser();
|
||
|
// Handle success (close sheet, etc.)
|
||
|
} catch (error) {
|
||
|
// Handle error
|
||
|
console.error('Ephemeral login error:', error);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// Show loading state when authenticating
|
||
|
if (authState.status === 'authenticating') {
|
||
|
return (
|
||
|
<View>
|
||
|
<Text>Authenticating...</Text>
|
||
|
{/* Add a spinner or other loading indicator */}
|
||
|
</View>
|
||
|
);
|
||
|
}
|
||
|
|
||
|
return (
|
||
|
<View>
|
||
|
{/* Private key input */}
|
||
|
{/* ... */}
|
||
|
|
||
|
{/* Login buttons */}
|
||
|
<TouchableOpacity onPress={handlePrivateKeyLogin}>
|
||
|
<Text>Login with Private Key</Text>
|
||
|
</TouchableOpacity>
|
||
|
|
||
|
{isExternalSignerAvailable && (
|
||
|
<TouchableOpacity onPress={handleAmberLogin}>
|
||
|
<Text>Login with Amber</Text>
|
||
|
</TouchableOpacity>
|
||
|
)}
|
||
|
|
||
|
<TouchableOpacity onPress={handleEphemeralLogin}>
|
||
|
<Text>Continue without Login</Text>
|
||
|
</TouchableOpacity>
|
||
|
|
||
|
{/* Error display */}
|
||
|
{authState.status === 'error' && (
|
||
|
<Text style={{ color: 'red' }}>{authState.error.message}</Text>
|
||
|
)}
|
||
|
</View>
|
||
|
);
|
||
|
}
|
||
|
```
|
||
|
|
||
|
### 4.2 Auth Status Component
|
||
|
|
||
|
Create a component to display auth status:
|
||
|
|
||
|
```typescript
|
||
|
// components/AuthStatus.tsx
|
||
|
import React from 'react';
|
||
|
import { View, Text, ActivityIndicator } from 'react-native';
|
||
|
import { useAuthStore } from '@/lib/auth/AuthStateManager';
|
||
|
|
||
|
export default function AuthStatus() {
|
||
|
const authState = useAuthStore();
|
||
|
|
||
|
// Show different status based on auth state
|
||
|
switch (authState.status) {
|
||
|
case 'unauthenticated':
|
||
|
return (
|
||
|
<View>
|
||
|
<Text>Not logged in</Text>
|
||
|
</View>
|
||
|
);
|
||
|
|
||
|
case 'authenticating':
|
||
|
return (
|
||
|
<View>
|
||
|
<ActivityIndicator size="small" />
|
||
|
<Text>Logging in...</Text>
|
||
|
</View>
|
||
|
);
|
||
|
|
||
|
case 'authenticated':
|
||
|
return (
|
||
|
<View>
|
||
|
<Text>Logged in as: {authState.user.npub}</Text>
|
||
|
</View>
|
||
|
);
|
||
|
|
||
|
case 'signing':
|
||
|
return (
|
||
|
<View>
|
||
|
<ActivityIndicator size="small" />
|
||
|
<Text>Signing {authState.operationCount} operations...</Text>
|
||
|
</View>
|
||
|
);
|
||
|
|
||
|
case 'error':
|
||
|
return (
|
||
|
<View>
|
||
|
<Text style={{ color: 'red' }}>Error: {authState.error.message}</Text>
|
||
|
</View>
|
||
|
);
|
||
|
|
||
|
default:
|
||
|
return null;
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
## Testing Strategy
|
||
|
|
||
|
### Unit Tests
|
||
|
|
||
|
Create unit tests for core components:
|
||
|
|
||
|
```typescript
|
||
|
// __tests__/auth/AuthStateManager.test.ts
|
||
|
import { AuthStateManager } from '@/lib/auth/AuthStateManager';
|
||
|
import { NDKUser } from '@nostr-dev-kit/ndk';
|
||
|
|
||
|
describe('AuthStateManager', () => {
|
||
|
beforeEach(() => {
|
||
|
// Reset state between tests
|
||
|
AuthStateManager.logout();
|
||
|
});
|
||
|
|
||
|
test('initial state is unauthenticated', () => {
|
||
|
const state = AuthStateManager.getState();
|
||
|
expect(state.status).toBe('unauthenticated');
|
||
|
});
|
||
|
|
||
|
test('setAuthenticating updates state', () => {
|
||
|
AuthStateManager.setAuthenticating('private_key');
|
||
|
const state = AuthStateManager.getState();
|
||
|
expect(state.status).toBe('authenticating');
|
||
|
expect(state.method).toBe('private_key');
|
||
|
});
|
||
|
|
||
|
// Additional tests...
|
||
|
});
|
||
|
```
|
||
|
|
||
|
### Integration Tests
|
||
|
|
||
|
Test the integration between components:
|
||
|
|
||
|
```typescript
|
||
|
// __tests__/integration/AuthFlow.test.tsx
|
||
|
import React from 'react';
|
||
|
import { render, fireEvent, waitFor } from '@testing-library/react-native';
|
||
|
import { AuthProvider } from '@/lib/auth/AuthProvider';
|
||
|
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
|
||
|
import { NDK } from '@nostr-dev-kit/ndk';
|
||
|
|
||
|
describe('Authentication Flow', () => {
|
||
|
let ndk: NDK;
|
||
|
|
||
|
beforeEach(() => {
|
||
|
ndk = new NDK();
|
||
|
});
|
||
|
|
||
|
test('login flow works correctly', async () => {
|
||
|
const { getByText, getByPlaceholderText } = render(
|
||
|
<AuthProvider ndk={ndk}>
|
||
|
<NostrLoginSheet />
|
||
|
</AuthProvider>
|
||
|
);
|
||
|
|
||
|
// Fill in private key
|
||
|
fireEvent.changeText(
|
||
|
getByPlaceholderText('Enter private key'),
|
||
|
'test-private-key'
|
||
|
);
|
||
|
|
||
|
// Press login button
|
||
|
fireEvent.press(getByText('Login with Private Key'));
|
||
|
|
||
|
// Verify loading state appears
|
||
|
await waitFor(() => {
|
||
|
expect(getByText('Authenticating...')).toBeTruthy();
|
||
|
});
|
||
|
|
||
|
// Verify success state (this would require mocking the NDK signer)
|
||
|
// ...
|
||
|
});
|
||
|
|
||
|
// Additional tests...
|
||
|
});
|
||
|
```
|
||
|
|
||
|
## Migration Path
|
||
|
|
||
|
### Feature Flag Implementation
|
||
|
|
||
|
Add a feature flag to toggle the new auth system:
|
||
|
|
||
|
```typescript
|
||
|
// lib/flags.ts
|
||
|
export const FLAGS = {
|
||
|
useNewAuthSystem: true, // Toggle this for testing
|
||
|
};
|
||
|
|
||
|
// In NDK store init
|
||
|
import { FLAGS } from '@/lib/flags';
|
||
|
|
||
|
// ...
|
||
|
|
||
|
init: (ndk: NDK) => {
|
||
|
if (FLAGS.useNewAuthSystem) {
|
||
|
// Use new auth system
|
||
|
const authService = new AuthService(ndk);
|
||
|
set({
|
||
|
ndk,
|
||
|
authService,
|
||
|
});
|
||
|
authService.initialize();
|
||
|
} else {
|
||
|
// Use existing implementation
|
||
|
set({
|
||
|
ndk,
|
||
|
authService: null,
|
||
|
});
|
||
|
|
||
|
// Legacy initialization
|
||
|
const key = settingsStore?.getSync('login');
|
||
|
if (key) get().login(key);
|
||
|
}
|
||
|
},
|
||
|
```
|
||
|
|
||
|
### Gradual Component Migration
|
||
|
|
||
|
1. Create HOCs to support both auth systems:
|