mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-23 01:01:27 +00:00
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
This commit is contained in:
parent
969163313a
commit
ff8851bd04
14
CHANGELOG.md
14
CHANGELOG.md
@ -6,6 +6,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
- Centralized Authentication System with Advanced Security
|
||||||
|
- Implemented new AuthService for unified authentication management
|
||||||
|
- Added support for multiple authentication methods (private key, external signer, ephemeral)
|
||||||
|
- Created secure logout protocol to prevent unexpected state during sign-out
|
||||||
|
- Implemented SigningQueue for better transaction handling and atomicity
|
||||||
|
- Added AuthStateManager for centralized state management
|
||||||
|
- Created AuthProvider component for React integration
|
||||||
|
- Implemented feature flag system for gradual rollout
|
||||||
|
- Added test page for verification of authentication features
|
||||||
|
- Enhanced security with proper error propagation and state handling
|
||||||
|
- Created clear documentation for the new authentication architecture
|
||||||
|
- Built with TypeScript for type safety and developer experience
|
||||||
|
- Added backward compatibility with legacy authentication
|
||||||
|
g
|
||||||
- Enhanced Avatar System with Robohash Integration
|
- Enhanced Avatar System with Robohash Integration
|
||||||
- Consolidated avatar implementation into ui/avatar.tsx component
|
- Consolidated avatar implementation into ui/avatar.tsx component
|
||||||
- Added RobohashAvatar and RobohashFallback components
|
- Added RobohashAvatar and RobohashFallback components
|
||||||
|
@ -53,14 +53,16 @@ export default function LibraryLayout() {
|
|||||||
component={TemplatesScreen}
|
component={TemplatesScreen}
|
||||||
options={{ title: 'Templates' }}
|
options={{ title: 'Templates' }}
|
||||||
/>
|
/>
|
||||||
{/* Only show Programs tab in development builds */}
|
{/* Only show Development tab in development builds */}
|
||||||
{!IS_PRODUCTION && (
|
{!IS_PRODUCTION && (
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="programs"
|
name="programs"
|
||||||
component={ProgramsScreen}
|
component={ProgramsScreen}
|
||||||
options={{ title: 'Programs' }}
|
options={{ title: 'Development' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auth Test tab temporarily removed - see auth information in Development tab instead */}
|
||||||
</Tab.Navigator>
|
</Tab.Navigator>
|
||||||
</TabScreen>
|
</TabScreen>
|
||||||
);
|
);
|
||||||
|
@ -497,9 +497,56 @@ export default function ProgramsScreen() {
|
|||||||
<Zap size={18} className={`mr-2 ${activeTab === 'nostr' ? 'text-white' : 'text-foreground'}`} />
|
<Zap size={18} className={`mr-2 ${activeTab === 'nostr' ? 'text-white' : 'text-foreground'}`} />
|
||||||
<Text className={activeTab === 'nostr' ? 'text-white' : 'text-foreground'}>Nostr</Text>
|
<Text className={activeTab === 'nostr' ? 'text-white' : 'text-foreground'}>Nostr</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`flex-1 flex-row items-center justify-center py-3 ${activeTab === 'auth' ? 'bg-primary' : ''}`}
|
||||||
|
onPress={() => setActiveTab('auth')}
|
||||||
|
>
|
||||||
|
<AlertCircle size={18} className={`mr-2 ${activeTab === 'auth' ? 'text-white' : 'text-foreground'}`} />
|
||||||
|
<Text className={activeTab === 'auth' ? 'text-white' : 'text-foreground'}>Auth</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'auth' && (
|
||||||
|
<ScrollView className="flex-1 p-4">
|
||||||
|
<View className="py-4 space-y-4">
|
||||||
|
<Text className="text-lg font-semibold text-center mb-4 text-foreground">Authentication System Test</Text>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex-row items-center gap-2">
|
||||||
|
<AlertCircle size={20} className="text-foreground" />
|
||||||
|
<Text className="text-lg font-semibold">Auth Test Available</Text>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Text className="text-foreground mb-4">
|
||||||
|
The Centralized Authentication System has been implemented and is ready for testing.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mb-4">
|
||||||
|
You can access the standalone test page by adding the AuthProvider to the root app layout, which
|
||||||
|
is not included in this development tab to avoid conflicts with the existing authentication system.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mb-4">
|
||||||
|
The new authentication system includes:
|
||||||
|
</Text>
|
||||||
|
<View className="space-y-2 ml-4 mb-4">
|
||||||
|
<Text className="text-foreground">• Support for multiple authentication methods</Text>
|
||||||
|
<Text className="text-foreground">• Secure logout protocol with proper state handling</Text>
|
||||||
|
<Text className="text-foreground">• SigningQueue for atomic Nostr event signing</Text>
|
||||||
|
<Text className="text-foreground">• Feature flag system for controlled rollout</Text>
|
||||||
|
<Text className="text-foreground">• Type-safe interfaces with improved error handling</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-muted-foreground">
|
||||||
|
The standalone test page (app/test/auth-test.tsx) can be accessed directly when the AuthProvider
|
||||||
|
is enabled in the app's root layout.
|
||||||
|
</Text>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
{activeTab === 'database' && (
|
{activeTab === 'database' && (
|
||||||
<ScrollView className="flex-1 p-4">
|
<ScrollView className="flex-1 p-4">
|
||||||
<View className="py-4 space-y-4">
|
<View className="py-4 space-y-4">
|
||||||
@ -895,4 +942,4 @@ export default function ProgramsScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -17,9 +17,10 @@ import { SettingsDrawerProvider } from '@/lib/contexts/SettingsDrawerContext';
|
|||||||
import SettingsDrawer from '@/components/SettingsDrawer';
|
import SettingsDrawer from '@/components/SettingsDrawer';
|
||||||
import RelayInitializer from '@/components/RelayInitializer';
|
import RelayInitializer from '@/components/RelayInitializer';
|
||||||
import OfflineIndicator from '@/components/OfflineIndicator';
|
import OfflineIndicator from '@/components/OfflineIndicator';
|
||||||
import { useNDKStore } from '@/lib/stores/ndk';
|
import { useNDKStore, FLAGS } from '@/lib/stores/ndk';
|
||||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||||
import { ConnectivityService } from '@/lib/db/services/ConnectivityService';
|
import { ConnectivityService } from '@/lib/db/services/ConnectivityService';
|
||||||
|
import { AuthProvider } from '@/lib/auth/AuthProvider';
|
||||||
// Import splash screens with improved fallback mechanism
|
// Import splash screens with improved fallback mechanism
|
||||||
let SplashComponent: React.ComponentType<{onFinish: () => void}>;
|
let SplashComponent: React.ComponentType<{onFinish: () => void}>;
|
||||||
let useVideoSplash = false;
|
let useVideoSplash = false;
|
||||||
@ -226,11 +227,29 @@ export default function RootLayout() {
|
|||||||
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
|
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
|
||||||
{/* Ensure SettingsDrawerProvider wraps everything */}
|
{/* Ensure SettingsDrawerProvider wraps everything */}
|
||||||
<SettingsDrawerProvider>
|
<SettingsDrawerProvider>
|
||||||
{/* Add RelayInitializer here - it loads relay data once NDK is available */}
|
{/* Add AuthProvider when using new auth system */}
|
||||||
<RelayInitializer />
|
{(() => {
|
||||||
|
const ndk = useNDKStore.getState().ndk;
|
||||||
{/* Add OfflineIndicator to show network status */}
|
if (ndk && FLAGS.useNewAuthSystem) {
|
||||||
<OfflineIndicator />
|
return (
|
||||||
|
<AuthProvider ndk={ndk}>
|
||||||
|
{/* Add RelayInitializer here - it loads relay data once NDK is available */}
|
||||||
|
<RelayInitializer />
|
||||||
|
|
||||||
|
{/* Add OfflineIndicator to show network status */}
|
||||||
|
<OfflineIndicator />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Legacy approach without AuthProvider */}
|
||||||
|
<RelayInitializer />
|
||||||
|
<OfflineIndicator />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
|
||||||
<StatusBar style={isDarkColorScheme ? 'light' : 'dark'} />
|
<StatusBar style={isDarkColorScheme ? 'light' : 'dark'} />
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
226
app/test/auth-test.tsx
Normal file
226
app/test/auth-test.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, StyleSheet, ScrollView, Button, Text, Platform } from 'react-native';
|
||||||
|
import { useAuthState, useAuth } from '@/lib/auth/AuthProvider';
|
||||||
|
import AuthStatus from '@/components/auth/AuthStatus';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test page for the new authentication system
|
||||||
|
*/
|
||||||
|
export default function AuthTestPage() {
|
||||||
|
const { top, bottom } = useSafeAreaInsets();
|
||||||
|
const authState = useAuthState();
|
||||||
|
const { authService } = useAuth();
|
||||||
|
const [privateKey, setPrivateKey] = React.useState('');
|
||||||
|
|
||||||
|
// Login with private key
|
||||||
|
const handleLoginWithPrivateKey = async () => {
|
||||||
|
try {
|
||||||
|
// For testing, just use a generated key or a newly generated one
|
||||||
|
if (privateKey) {
|
||||||
|
await authService.loginWithPrivateKey(privateKey);
|
||||||
|
} else {
|
||||||
|
await authService.createEphemeralKey();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create ephemeral key
|
||||||
|
const handleCreateEphemeralKey = async () => {
|
||||||
|
try {
|
||||||
|
await authService.createEphemeralKey();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ephemeral key error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate signing operations for testing
|
||||||
|
const handleSimulateSigningOperations = async () => {
|
||||||
|
// We can only test this if we're authenticated
|
||||||
|
if (authState.status !== 'authenticated') {
|
||||||
|
console.log("Can't simulate signing operations when not authenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate signing 3 operations with delays
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
// Create a minimal NostrEvent for testing
|
||||||
|
const event = {
|
||||||
|
id: `event-${i}`,
|
||||||
|
pubkey: authState.user.pubkey,
|
||||||
|
content: `Test event ${i}`,
|
||||||
|
kind: 1,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
sig: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a proper SigningOperation
|
||||||
|
const operation = {
|
||||||
|
event: event as any, // Type assertion to satisfy NostrEvent requirement
|
||||||
|
timestamp: Date.now(),
|
||||||
|
resolve: () => {},
|
||||||
|
reject: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start signing
|
||||||
|
authState.setSigningInProgress(true, operation);
|
||||||
|
|
||||||
|
// After 1 second, complete the operation
|
||||||
|
setTimeout(() => {
|
||||||
|
authState.setSigningInProgress(false, operation);
|
||||||
|
}, 1000 * (i + 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { paddingTop: top, paddingBottom: bottom }]}>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: 'Authentication Test',
|
||||||
|
headerShown: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
|
||||||
|
<ScrollView style={styles.scrollView} contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.title}>Authentication System Test</Text>
|
||||||
|
|
||||||
|
{/* Current authentication status */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Current Status</Text>
|
||||||
|
<AuthStatus />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Authentication actions */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Authentication Actions</Text>
|
||||||
|
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<Button
|
||||||
|
title="Create Ephemeral Key"
|
||||||
|
onPress={handleCreateEphemeralKey}
|
||||||
|
disabled={authState.status === 'authenticating' || authState.status === 'signing'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<Button
|
||||||
|
title="Login with Private Key"
|
||||||
|
onPress={handleLoginWithPrivateKey}
|
||||||
|
disabled={authState.status === 'authenticating' || authState.status === 'signing'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<Button
|
||||||
|
title="Simulate Signing Operations"
|
||||||
|
onPress={handleSimulateSigningOperations}
|
||||||
|
disabled={authState.status !== 'authenticated'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<Button
|
||||||
|
title="Logout"
|
||||||
|
onPress={() => authService.logout()}
|
||||||
|
disabled={!['authenticated', 'error', 'signing'].includes(authState.status)}
|
||||||
|
color="#d32f2f"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* State details for debugging */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Auth State Details</Text>
|
||||||
|
<View style={styles.stateContainer}>
|
||||||
|
<Text style={styles.stateText}>
|
||||||
|
{(() => {
|
||||||
|
// Create base state object with just status
|
||||||
|
const stateObj: any = {
|
||||||
|
status: authState.status
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add properties based on auth state
|
||||||
|
if (authState.status === 'authenticated' || authState.status === 'signing') {
|
||||||
|
stateObj.method = (authState as any).method;
|
||||||
|
if ((authState as any).user) {
|
||||||
|
stateObj.user = {
|
||||||
|
npub: (authState as any).user.npub,
|
||||||
|
pubkey: (authState as any).user.pubkey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState.status === 'signing') {
|
||||||
|
stateObj.operationCount = (authState as any).operationCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState.status === 'authenticating') {
|
||||||
|
stateObj.method = (authState as any).method;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState.status === 'error') {
|
||||||
|
stateObj.error = (authState as any).error?.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(stateObj, null, 2);
|
||||||
|
})()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f5f5f7',
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 24,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
stateContainer: {
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
stateText: {
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
});
|
145
components/auth/AuthStatus.tsx
Normal file
145
components/auth/AuthStatus.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||||
|
import { useAuthState, useAuth } from '@/lib/auth/AuthProvider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays the current authentication status and provides logout functionality
|
||||||
|
*/
|
||||||
|
export default function AuthStatus() {
|
||||||
|
const authState = useAuthState();
|
||||||
|
const { authService } = useAuth();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle logout button press
|
||||||
|
*/
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await authService.logout();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render different UI based on auth state
|
||||||
|
switch (authState.status) {
|
||||||
|
case 'unauthenticated':
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.text}>Not logged in</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'authenticating':
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ActivityIndicator size="small" color="#0066cc" style={styles.spinner} />
|
||||||
|
<Text style={styles.text}>Logging in... ({authState.method})</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'authenticated':
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.userInfo}>
|
||||||
|
<Text style={styles.text}>
|
||||||
|
Logged in as: {authState.user?.npub?.substring(0, 8)}...
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.methodText}>
|
||||||
|
Method: {authState.method}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||||
|
<Text style={styles.logoutText}>Logout</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'signing':
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.userInfo}>
|
||||||
|
<Text style={styles.text}>
|
||||||
|
Logged in as: {authState.user?.npub?.substring(0, 8)}...
|
||||||
|
</Text>
|
||||||
|
<View style={styles.signingContainer}>
|
||||||
|
<ActivityIndicator size="small" color="#0066cc" style={styles.spinner} />
|
||||||
|
<Text style={styles.signingText}>
|
||||||
|
Signing {authState.operationCount} operations...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={[styles.logoutButton, { opacity: 0.5 }]} disabled>
|
||||||
|
<Text style={styles.logoutText}>Logout</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
Error: {authState.error?.message || "Unknown error"}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||||
|
<Text style={styles.logoutText}>Reset</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
methodText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
spinner: {
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
signingContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
signingText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#0066cc',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#d32f2f',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
logoutButton: {
|
||||||
|
backgroundColor: '#d32f2f',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginLeft: 16,
|
||||||
|
},
|
||||||
|
logoutText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
});
|
575
docs/technical/auth/centralized_auth_system.md
Normal file
575
docs/technical/auth/centralized_auth_system.md
Normal file
@ -0,0 +1,575 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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
|
1058
docs/technical/auth/implementation_plan.md
Normal file
1058
docs/technical/auth/implementation_plan.md
Normal file
File diff suppressed because it is too large
Load Diff
376
docs/technical/auth/secure_logout.md
Normal file
376
docs/technical/auth/secure_logout.md
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
# Secure Logout Procedures in Authentication Architecture
|
||||||
|
|
||||||
|
**Last Updated:** 2025-04-02
|
||||||
|
**Status:** Proposed
|
||||||
|
**Authors:** POWR Team
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the secure logout procedures implemented in POWR's centralized authentication system. Proper logout handling is critical for security, especially when dealing with cryptographic keys and external signers like Amber.
|
||||||
|
|
||||||
|
## Security Considerations for Logout
|
||||||
|
|
||||||
|
A comprehensive logout procedure must address several security concerns:
|
||||||
|
|
||||||
|
1. **Cancellation of In-flight Operations**: Any pending signing operations must be properly terminated.
|
||||||
|
2. **Secure Removal of Keys**: Private keys and session data must be securely erased from memory and storage.
|
||||||
|
3. **External Signer Communication**: External signers like Amber must be notified of session termination.
|
||||||
|
4. **Subscription Termination**: NDK subscriptions must be closed to prevent data leakage.
|
||||||
|
5. **Memory Cache Clearing**: Any in-memory caches must be purged.
|
||||||
|
6. **UI State Reset**: The UI must reflect the unauthenticated state immediately.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Core Logout Service
|
||||||
|
|
||||||
|
The `AuthService` class provides a comprehensive logout method:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Performs a secure logout, removing all authentication artifacts
|
||||||
|
* and ensuring no sensitive data remains
|
||||||
|
*/
|
||||||
|
async logout(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// 1. Cancel any pending signing operations
|
||||||
|
if (this.signingQueue) {
|
||||||
|
this.signingQueue.cancelAll('User logged out');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Notify the Amber app of session termination (Android only)
|
||||||
|
if (Platform.OS === 'android' && this.ndk.signer instanceof NDKAmberSigner) {
|
||||||
|
try {
|
||||||
|
await NativeModules.AmberSigner.terminateSession();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Error terminating Amber session:', error);
|
||||||
|
// Continue with logout even if Amber notification fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clear the NDK signer reference
|
||||||
|
this.ndk.signer = undefined;
|
||||||
|
|
||||||
|
// 4. Clear all subscriptions to prevent data leakage after logout
|
||||||
|
this.clearSubscriptions();
|
||||||
|
|
||||||
|
// 5. Clear secure storage and AsyncStorage
|
||||||
|
const storageOperations = [
|
||||||
|
SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY),
|
||||||
|
AsyncStorage.multiRemove([
|
||||||
|
'currentUser',
|
||||||
|
'login',
|
||||||
|
'signer',
|
||||||
|
'auth.last_login',
|
||||||
|
'auth.permissions',
|
||||||
|
'auth.session',
|
||||||
|
'ndkMobileSessionLastEose'
|
||||||
|
])
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(storageOperations);
|
||||||
|
|
||||||
|
// 6. Update auth state
|
||||||
|
AuthStateManager.logout();
|
||||||
|
|
||||||
|
// 7. Clear memory cache if implemented
|
||||||
|
this.clearMemoryCache();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during logout:', error);
|
||||||
|
|
||||||
|
// Even if an error occurs, still try to reset the state
|
||||||
|
AuthStateManager.logout();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all active NDK subscriptions to prevent data leakage
|
||||||
|
*/
|
||||||
|
private clearSubscriptions(): void {
|
||||||
|
if (this.ndk.pool) {
|
||||||
|
// Close all relay connections
|
||||||
|
this.ndk.pool.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears any in-memory cached data
|
||||||
|
*/
|
||||||
|
private clearMemoryCache(): void {
|
||||||
|
// Implementation will depend on your caching strategy
|
||||||
|
// Clear any in-memory caches here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signing Queue Cancellation
|
||||||
|
|
||||||
|
The `SigningQueue` class implements a method to cancel all pending operations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Cancels all pending operations in the queue
|
||||||
|
* @param reason The reason for cancellation
|
||||||
|
*/
|
||||||
|
cancelAll(reason: string): void {
|
||||||
|
const error = new Error(`Signing operations canceled: ${reason}`);
|
||||||
|
|
||||||
|
// Reject all queued operations
|
||||||
|
this.queue.forEach(operation => {
|
||||||
|
operation.reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the queue
|
||||||
|
this.queue = [];
|
||||||
|
|
||||||
|
// Reset processing state
|
||||||
|
this.processing = false;
|
||||||
|
this.activeCount = 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AuthStateManager Logout
|
||||||
|
|
||||||
|
The `AuthStateManager` implements a logout method that ensures the UI is immediately updated:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Android-Specific Considerations
|
||||||
|
|
||||||
|
On Android, the Amber integration requires additional handling during logout:
|
||||||
|
|
||||||
|
1. **Amber Session Termination**: The native Amber module should be extended to include a `terminateSession` method:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@ReactMethod
|
||||||
|
fun terminateSession(promise: Promise) {
|
||||||
|
executorService.execute {
|
||||||
|
try {
|
||||||
|
// Create a "terminate session" intent to notify Amber
|
||||||
|
val intent = Intent("nostrsigner://terminate")
|
||||||
|
intent.setPackage("com.greenart7c3.nostrsigner")
|
||||||
|
|
||||||
|
// Add metadata to the intent
|
||||||
|
intent.putExtra("app", "powr")
|
||||||
|
|
||||||
|
// Launch the intent
|
||||||
|
mainHandler.post {
|
||||||
|
try {
|
||||||
|
currentActivity?.startActivity(intent)
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("E_TERMINATE_ERROR", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("E_PREPARATION_ERROR", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Pending Promises Cleanup**: Any pending promises in the native module should be rejected:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@ReactMethod
|
||||||
|
fun cancelAllPendingOperations(promise: Promise) {
|
||||||
|
val error = "Operations canceled due to logout"
|
||||||
|
pendingPromises.forEach { (_, pendingPromise) ->
|
||||||
|
pendingPromise.reject("E_CANCELED", error)
|
||||||
|
}
|
||||||
|
pendingPromises.clear()
|
||||||
|
promise.resolve(true)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## iOS Considerations
|
||||||
|
|
||||||
|
On iOS, the focus is on secure deletion of private keys:
|
||||||
|
|
||||||
|
1. **Keychain Cleanup**: The private key stored in the iOS Keychain must be securely removed:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// iOS-specific cleanup
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
await SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
||||||
|
// Additional iOS-specific cleanup as needed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Memory Zeroing**: For additional security, any in-memory copies of private keys should be zeroed:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Helper function to securely zero sensitive data in memory
|
||||||
|
function securelyZeroMemory(variableRef: any): void {
|
||||||
|
if (typeof variableRef === 'string') {
|
||||||
|
// Overwrite the string with zeros
|
||||||
|
// Note: JavaScript strings are immutable, so this creates a new string
|
||||||
|
// The garbage collector will eventually clean up the original
|
||||||
|
for (let i = 0; i < variableRef.length; i++) {
|
||||||
|
variableRef = variableRef.substring(0, i) + '0' + variableRef.substring(i + 1);
|
||||||
|
}
|
||||||
|
} else if (ArrayBuffer.isView(variableRef)) {
|
||||||
|
// For typed arrays, we can actually zero the memory
|
||||||
|
const view = new Uint8Array(variableRef.buffer);
|
||||||
|
view.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logout UI Component
|
||||||
|
|
||||||
|
A dedicated logout button component ensures consistent logout behavior across the app:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/LogoutButton.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { TouchableOpacity, Text, ActivityIndicator, StyleSheet } from 'react-native';
|
||||||
|
import { useNDKStore } from '@/lib/stores/ndk';
|
||||||
|
import { useAuthStore } from '@/lib/auth/AuthStateManager';
|
||||||
|
|
||||||
|
export function LogoutButton() {
|
||||||
|
const { logout } = useNDKStore();
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = React.useState(false);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
if (isLoggingOut) return;
|
||||||
|
|
||||||
|
setIsLoggingOut(true);
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during logout:', error);
|
||||||
|
// Show error to user if needed
|
||||||
|
} finally {
|
||||||
|
setIsLoggingOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.button}
|
||||||
|
onPress={handleLogout}
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
>
|
||||||
|
{isLoggingOut ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.text}>Sign Out</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#F44336',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Logout Security
|
||||||
|
|
||||||
|
Thorough testing should verify:
|
||||||
|
|
||||||
|
1. **Complete Removal**: Verify all authentication data is removed after logout
|
||||||
|
2. **State Reset**: Confirm the UI properly reflects the unauthenticated state
|
||||||
|
3. **Failed Attempts**: Test that authentication attempts fail after logout
|
||||||
|
4. **Pending Operations**: Verify pending operations are properly canceled
|
||||||
|
5. **External Signer**: Confirm Amber sessions are terminated
|
||||||
|
|
||||||
|
Example test cases:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// __tests__/auth/logout.test.ts
|
||||||
|
describe('Logout Security', () => {
|
||||||
|
it('should reject pending operations on logout', async () => {
|
||||||
|
// Set up an authenticated state
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Start a signing operation but don't let it complete
|
||||||
|
const signingPromise = authService.sign(mockEvent);
|
||||||
|
|
||||||
|
// Logout before the operation completes
|
||||||
|
authService.logout();
|
||||||
|
|
||||||
|
// Verify the operation was rejected
|
||||||
|
await expect(signingPromise).rejects.toThrow('Authentication session terminated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear all secure storage on logout', async () => {
|
||||||
|
// Set up an authenticated state
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Mock SecureStore to verify deletion
|
||||||
|
const mockDeleteItem = jest.spyOn(SecureStore, 'deleteItemAsync');
|
||||||
|
|
||||||
|
// Perform logout
|
||||||
|
await authService.logout();
|
||||||
|
|
||||||
|
// Verify the private key was deleted
|
||||||
|
expect(mockDeleteItem).toHaveBeenCalledWith(PRIVATE_KEY_STORAGE_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional tests...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **Immediate UI Feedback**: Always update the UI immediately on logout to prevent confusion.
|
||||||
|
2. **Graceful Error Handling**: Continue with logout even if individual cleanup steps fail.
|
||||||
|
3. **Comprehensive Cleanup**: Remove all authentication artifacts, not just the obvious ones.
|
||||||
|
4. **Defensive Programming**: Assume any step might fail and account for it.
|
||||||
|
5. **Session Invalidation**: Notify external services of session termination when possible.
|
||||||
|
6. **Audit Logging**: Log logout events (without PII) for security audit purposes.
|
||||||
|
|
||||||
|
By following these practices, the logout procedure ensures maximum security and a seamless user experience.
|
85
lib/auth/AuthProvider.tsx
Normal file
85
lib/auth/AuthProvider.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { useAuthStore } from './AuthStateManager';
|
||||||
|
import { AuthService } from './AuthService';
|
||||||
|
import NDK from '@nostr-dev-kit/ndk-mobile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context value interface for the Auth context
|
||||||
|
*/
|
||||||
|
interface AuthContextValue {
|
||||||
|
authService: AuthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the Auth context
|
||||||
|
*/
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the AuthProvider component
|
||||||
|
*/
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
ndk: NDK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider component that makes auth service available to the app
|
||||||
|
*/
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children, ndk }) => {
|
||||||
|
// Create a singleton instance of AuthService
|
||||||
|
const [authService] = useState(() => new AuthService(ndk));
|
||||||
|
// Subscribe to auth state for debugging/monitoring purposes
|
||||||
|
const authState = useAuthStore();
|
||||||
|
|
||||||
|
// Initialize auth on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const initAuth = async () => {
|
||||||
|
try {
|
||||||
|
console.log("[AuthProvider] Initializing authentication");
|
||||||
|
await authService.initialize();
|
||||||
|
console.log("[AuthProvider] Authentication initialized");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AuthProvider] Error initializing authentication:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initAuth();
|
||||||
|
|
||||||
|
// No cleanup needed - AuthService instance persists for app lifetime
|
||||||
|
}, [authService]);
|
||||||
|
|
||||||
|
// Debugging: Log auth state changes
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[AuthProvider] Auth state changed:", authState.status);
|
||||||
|
}, [authState.status]);
|
||||||
|
|
||||||
|
// Provide context value
|
||||||
|
const contextValue: AuthContextValue = {
|
||||||
|
authService
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for consuming the auth context in components
|
||||||
|
*/
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that provides direct access to the current auth state
|
||||||
|
*/
|
||||||
|
export const useAuthState = () => {
|
||||||
|
return useAuthStore();
|
||||||
|
};
|
314
lib/auth/AuthService.ts
Normal file
314
lib/auth/AuthService.ts
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
import NDK, { NDKUser, NDKSigner, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk-mobile";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import { AuthMethod } from "./types";
|
||||||
|
import { AuthStateManager } from "./AuthStateManager";
|
||||||
|
import { SigningQueue } from "./SigningQueue";
|
||||||
|
|
||||||
|
// Constants for SecureStore
|
||||||
|
const PRIVATE_KEY_STORAGE_KEY = "powr.private_key";
|
||||||
|
const EXTERNAL_SIGNER_STORAGE_KEY = "nostr_external_signer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service that manages authentication operations
|
||||||
|
* Acts as the central implementation for all auth-related functionality
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
console.log("[AuthService] Initializing...");
|
||||||
|
|
||||||
|
// Try to restore previous auth session
|
||||||
|
const privateKey = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (privateKey) {
|
||||||
|
console.log("[AuthService] Found stored private key, attempting to login");
|
||||||
|
await this.loginWithPrivateKey(privateKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to restore external signer session
|
||||||
|
const externalSignerJson = await SecureStore.getItemAsync(EXTERNAL_SIGNER_STORAGE_KEY);
|
||||||
|
if (externalSignerJson) {
|
||||||
|
try {
|
||||||
|
const signerInfo = JSON.parse(externalSignerJson);
|
||||||
|
if (signerInfo.type === "amber" && signerInfo.pubkey && signerInfo.packageName) {
|
||||||
|
console.log("[AuthService] Found stored external signer info, attempting to login");
|
||||||
|
await this.loginWithAmber(signerInfo.pubkey, signerInfo.packageName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[AuthService] Error parsing external signer info:", error);
|
||||||
|
// Continue to unauthenticated state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[AuthService] No stored credentials found, remaining unauthenticated");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AuthService] Error initializing auth service:", error);
|
||||||
|
AuthStateManager.setError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with a private key
|
||||||
|
*/
|
||||||
|
async loginWithPrivateKey(privateKey: string): Promise<NDKUser> {
|
||||||
|
try {
|
||||||
|
console.log("[AuthService] Starting private key login");
|
||||||
|
AuthStateManager.setAuthenticating("private_key");
|
||||||
|
|
||||||
|
// Clean the input
|
||||||
|
privateKey = privateKey.trim();
|
||||||
|
|
||||||
|
// Configure NDK with private key signer
|
||||||
|
this.ndk.signer = await this.createPrivateKeySigner(privateKey);
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
const user = await this.ndk.signer.user();
|
||||||
|
console.log("[AuthService] Signer created, user retrieved:", user.npub);
|
||||||
|
|
||||||
|
// Fetch profile information if possible
|
||||||
|
try {
|
||||||
|
await user.fetchProfile();
|
||||||
|
console.log("[AuthService] Profile fetched successfully");
|
||||||
|
} catch (profileError) {
|
||||||
|
console.warn("[AuthService] Warning: Could not fetch user profile:", profileError);
|
||||||
|
// Continue even if profile fetch fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store key securely
|
||||||
|
await SecureStore.setItemAsync(PRIVATE_KEY_STORAGE_KEY, privateKey);
|
||||||
|
|
||||||
|
// Update auth state
|
||||||
|
AuthStateManager.setAuthenticated(user, "private_key");
|
||||||
|
console.log("[AuthService] Private key login complete");
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AuthService] Private key login error:", error);
|
||||||
|
AuthStateManager.setError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with Amber signer
|
||||||
|
*/
|
||||||
|
async loginWithAmber(pubkey?: string, packageName?: string): Promise<NDKUser> {
|
||||||
|
try {
|
||||||
|
console.log("[AuthService] Starting Amber login");
|
||||||
|
AuthStateManager.setAuthenticating("amber");
|
||||||
|
|
||||||
|
// Request public key from Amber if not provided
|
||||||
|
let effectivePubkey = pubkey;
|
||||||
|
let effectivePackageName = packageName;
|
||||||
|
|
||||||
|
if (!effectivePubkey || !effectivePackageName) {
|
||||||
|
console.log("[AuthService] No pubkey/packageName provided, requesting from Amber");
|
||||||
|
const info = await this.requestAmberPublicKey();
|
||||||
|
effectivePubkey = info.pubkey;
|
||||||
|
effectivePackageName = info.packageName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an NDKAmberSigner
|
||||||
|
console.log("[AuthService] Creating Amber signer with pubkey:", effectivePubkey);
|
||||||
|
this.ndk.signer = await this.createAmberSigner(effectivePubkey, effectivePackageName);
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
const user = await this.ndk.signer.user();
|
||||||
|
console.log("[AuthService] User fetched from Amber signer");
|
||||||
|
|
||||||
|
// Fetch profile
|
||||||
|
try {
|
||||||
|
await user.fetchProfile();
|
||||||
|
console.log("[AuthService] Profile fetched successfully");
|
||||||
|
} catch (profileError) {
|
||||||
|
console.warn("[AuthService] Warning: Could not fetch user profile:", profileError);
|
||||||
|
// Continue even if profile fetch fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store signer info securely
|
||||||
|
const signerInfo = JSON.stringify({
|
||||||
|
type: "amber",
|
||||||
|
pubkey: effectivePubkey,
|
||||||
|
packageName: effectivePackageName
|
||||||
|
});
|
||||||
|
await SecureStore.setItemAsync(EXTERNAL_SIGNER_STORAGE_KEY, signerInfo);
|
||||||
|
|
||||||
|
// Update auth state
|
||||||
|
AuthStateManager.setAuthenticated(user, "amber");
|
||||||
|
console.log("[AuthService] Amber login complete");
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AuthService] Amber login error:", error);
|
||||||
|
AuthStateManager.setError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ephemeral key (no login)
|
||||||
|
*/
|
||||||
|
async createEphemeralKey(): Promise<NDKUser> {
|
||||||
|
try {
|
||||||
|
console.log("[AuthService] Creating ephemeral key");
|
||||||
|
AuthStateManager.setAuthenticating("ephemeral");
|
||||||
|
|
||||||
|
// Generate a random key
|
||||||
|
this.ndk.signer = await this.createEphemeralSigner();
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
const user = await this.ndk.signer.user();
|
||||||
|
console.log("[AuthService] Ephemeral key created, user npub:", user.npub);
|
||||||
|
|
||||||
|
// Update auth state
|
||||||
|
AuthStateManager.setAuthenticated(user, "ephemeral");
|
||||||
|
console.log("[AuthService] Ephemeral login complete");
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AuthService] Ephemeral key creation error:", error);
|
||||||
|
AuthStateManager.setError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout
|
||||||
|
*/
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log("[AuthService] Logging out");
|
||||||
|
|
||||||
|
// Cancel any pending sign operations
|
||||||
|
this.signingQueue.cancelAll("User logged out");
|
||||||
|
|
||||||
|
// Notify the Amber app of session termination (Android only)
|
||||||
|
if (Platform.OS === "android" && this.ndk.signer) {
|
||||||
|
try {
|
||||||
|
const signerInfo = await SecureStore.getItemAsync(EXTERNAL_SIGNER_STORAGE_KEY);
|
||||||
|
if (signerInfo) {
|
||||||
|
console.log("[AuthService] Notifying Amber of session termination");
|
||||||
|
// This would call the native module method to terminate the Amber session
|
||||||
|
// Will be implemented in the AmberSignerModule.kt
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[AuthService] Error terminating Amber session:", error);
|
||||||
|
// Continue with logout even if Amber notification fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear NDK signer
|
||||||
|
console.log("[AuthService] Clearing NDK signer");
|
||||||
|
this.ndk.signer = undefined;
|
||||||
|
|
||||||
|
// Clear auth state - this will also clear storage
|
||||||
|
await AuthStateManager.logout();
|
||||||
|
|
||||||
|
console.log("[AuthService] Logout complete");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AuthService] Logout error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods for creating specific signers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a private key signer from a hex or nsec string
|
||||||
|
*/
|
||||||
|
private async createPrivateKeySigner(privateKey: string): Promise<NDKSigner> {
|
||||||
|
console.log("[AuthService] Creating private key signer");
|
||||||
|
|
||||||
|
// Handle nsec formatted keys
|
||||||
|
if (privateKey.startsWith("nsec")) {
|
||||||
|
try {
|
||||||
|
const { nip19 } = await import("nostr-tools");
|
||||||
|
const { data } = nip19.decode(privateKey);
|
||||||
|
// Convert the decoded data (Uint8Array) to hex string
|
||||||
|
privateKey = Buffer.from(data as Uint8Array).toString("hex");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AuthService] Error decoding nsec:", error);
|
||||||
|
throw new Error("Invalid nsec format");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure private key is valid hex format
|
||||||
|
if (privateKey.length !== 64 || !/^[0-9a-f]+$/i.test(privateKey)) {
|
||||||
|
throw new Error("Invalid private key format - must be nsec or 64-character hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NDKPrivateKeySigner(privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests a public key from Amber
|
||||||
|
*/
|
||||||
|
private async requestAmberPublicKey(): Promise<{ pubkey: string, packageName: string }> {
|
||||||
|
console.log("[AuthService] Requesting public key from Amber");
|
||||||
|
|
||||||
|
if (Platform.OS !== "android") {
|
||||||
|
throw new Error("Amber signer is only available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We'll dynamically import NDKAmberSigner to avoid circular dependencies
|
||||||
|
const { default: NDKAmberSigner } = await import("@/lib/signers/NDKAmberSigner");
|
||||||
|
// Call the static method to request a public key
|
||||||
|
const { pubkey, packageName } = await NDKAmberSigner.requestPublicKey();
|
||||||
|
|
||||||
|
if (!pubkey || !packageName) {
|
||||||
|
throw new Error("Amber returned invalid pubkey or packageName");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pubkey, packageName };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AuthService] Error requesting public key from Amber:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Amber signer with the given pubkey and package name
|
||||||
|
*/
|
||||||
|
private async createAmberSigner(pubkey: string, packageName: string): Promise<NDKSigner> {
|
||||||
|
console.log("[AuthService] Creating Amber signer");
|
||||||
|
|
||||||
|
if (Platform.OS !== "android") {
|
||||||
|
throw new Error("Amber signer is only available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamically import to avoid circular dependencies
|
||||||
|
const { default: NDKAmberSigner } = await import("@/lib/signers/NDKAmberSigner");
|
||||||
|
return new NDKAmberSigner(pubkey, packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an ephemeral signer with a random keypair
|
||||||
|
*/
|
||||||
|
private async createEphemeralSigner(): Promise<NDKSigner> {
|
||||||
|
console.log("[AuthService] Creating ephemeral signer");
|
||||||
|
|
||||||
|
// Generate a new random keypair
|
||||||
|
const { generateSecretKey } = await import("nostr-tools");
|
||||||
|
const secretKeyBytes = generateSecretKey();
|
||||||
|
// Convert to hex for the private key signer
|
||||||
|
const privateKey = Array.from(secretKeyBytes)
|
||||||
|
.map(byte => byte.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return new NDKPrivateKeySigner(privateKey);
|
||||||
|
}
|
||||||
|
}
|
177
lib/auth/AuthStateManager.ts
Normal file
177
lib/auth/AuthStateManager.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { NDKUser } from "@nostr-dev-kit/ndk";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import {
|
||||||
|
AuthState,
|
||||||
|
AuthActions,
|
||||||
|
AuthMethod,
|
||||||
|
SigningOperation
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const PRIVATE_KEY_STORAGE_KEY = "powr.private_key";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand store that manages the authentication state
|
||||||
|
* Acts as a state machine to ensure consistent transitions
|
||||||
|
*/
|
||||||
|
export const useAuthStore = create<AuthState & AuthActions>((set, get) => ({
|
||||||
|
status: 'unauthenticated',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the state to authenticating with the specified method
|
||||||
|
*/
|
||||||
|
setAuthenticating: (method) => {
|
||||||
|
console.log(`[Auth] Setting state to authenticating with method: ${method}`);
|
||||||
|
set({
|
||||||
|
status: 'authenticating',
|
||||||
|
method
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the state to authenticated with the specified user and method
|
||||||
|
*/
|
||||||
|
setAuthenticated: (user, method) => {
|
||||||
|
console.log(`[Auth] Setting state to authenticated for user: ${user.npub}`);
|
||||||
|
set({
|
||||||
|
status: 'authenticated',
|
||||||
|
user,
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages transitions to and from the signing state
|
||||||
|
* When inProgress is true, adds an operation to the signing state
|
||||||
|
* When inProgress is false, removes an operation from the signing state
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
console.log(`[Auth] Adding operation to signing state (total: ${currentState.operationCount + 1})`);
|
||||||
|
set({
|
||||||
|
operationCount: currentState.operationCount + 1,
|
||||||
|
operations: [...currentState.operations, operation]
|
||||||
|
});
|
||||||
|
} else if (currentState.status === 'authenticated') {
|
||||||
|
// Transition from authenticated to signing
|
||||||
|
console.log(`[Auth] Transitioning from authenticated to signing state`);
|
||||||
|
set({
|
||||||
|
status: 'signing',
|
||||||
|
user: currentState.user,
|
||||||
|
method: currentState.method,
|
||||||
|
operationCount: 1,
|
||||||
|
operations: [operation]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Invalid state transition - can only sign when authenticated
|
||||||
|
console.error(`[Auth] Cannot sign: not authenticated (current state: ${currentState.status})`);
|
||||||
|
set({
|
||||||
|
status: 'error',
|
||||||
|
error: new Error(`Cannot sign: not in authenticated state (current: ${currentState.status})`),
|
||||||
|
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
|
||||||
|
console.log(`[Auth] All operations complete, returning to authenticated state`);
|
||||||
|
set({
|
||||||
|
status: 'authenticated',
|
||||||
|
user: currentState.user,
|
||||||
|
method: currentState.method
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Still have pending operations
|
||||||
|
console.log(`[Auth] Operation complete, ${updatedOperations.length} operations remain`);
|
||||||
|
set({
|
||||||
|
operations: updatedOperations,
|
||||||
|
operationCount: updatedOperations.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not in signing state, this is a no-op
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a secure logout, clearing all auth state and secure storage
|
||||||
|
*/
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
console.log(`[Auth] Logging out user`);
|
||||||
|
|
||||||
|
// Cancel any pending operations
|
||||||
|
const currentState = get();
|
||||||
|
if (currentState.status === 'signing') {
|
||||||
|
console.log(`[Auth] Canceling ${currentState.operations.length} pending signing operations`);
|
||||||
|
// Reject any pending operations with cancellation error
|
||||||
|
currentState.operations.forEach(operation => {
|
||||||
|
operation.reject(new Error('Authentication session terminated'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Securely clear all sensitive data from storage
|
||||||
|
const keysToDelete = [
|
||||||
|
PRIVATE_KEY_STORAGE_KEY,
|
||||||
|
'nostr_privkey', // Original key name from ndk store
|
||||||
|
'nostr_external_signer' // External signer info
|
||||||
|
];
|
||||||
|
|
||||||
|
// Delete all secure keys
|
||||||
|
await Promise.all(
|
||||||
|
keysToDelete.map(key => SecureStore.deleteItemAsync(key))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset state to unauthenticated
|
||||||
|
set({
|
||||||
|
status: 'unauthenticated'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the logout event (without PII)
|
||||||
|
console.info('[Auth] User logged out successfully');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Error during logout:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the state to error with the specified error
|
||||||
|
*/
|
||||||
|
setError: (error) => {
|
||||||
|
console.error(`[Auth] Error: ${error.message}`);
|
||||||
|
const currentState = get();
|
||||||
|
set({
|
||||||
|
status: 'error',
|
||||||
|
error,
|
||||||
|
previousState: currentState
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton for easier access to the auth store from non-React contexts
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
};
|
111
lib/auth/SigningQueue.ts
Normal file
111
lib/auth/SigningQueue.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { NostrEvent } from "@nostr-dev-kit/ndk";
|
||||||
|
import { SigningOperation } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A queue for managing Nostr event signing operations.
|
||||||
|
* Prevents UI blocking by processing operations in a controlled manner.
|
||||||
|
*/
|
||||||
|
export class SigningQueue {
|
||||||
|
private queue: SigningOperation[] = [];
|
||||||
|
private processing = false;
|
||||||
|
private maxConcurrent = 1;
|
||||||
|
private activeCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a signing operation to the queue and returns a promise that resolves
|
||||||
|
* when the signature is available
|
||||||
|
*
|
||||||
|
* @param event The NostrEvent to sign
|
||||||
|
* @returns Promise that resolves to the signature string
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the next operation in the queue if conditions allow
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
// The actual signing will be implemented by the specific signer
|
||||||
|
// that uses this queue. This method just prepares the operation.
|
||||||
|
// We'll notify state managers about the operation starting/ending.
|
||||||
|
|
||||||
|
// NOTE: The actual signing is handled externally by the signer
|
||||||
|
// that uses this queue. This operation will remain pending until
|
||||||
|
// the signer completes it and calls the resolve/reject callbacks.
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Signing operation error:", error);
|
||||||
|
operation.reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
} finally {
|
||||||
|
this.activeCount--;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.processing = false;
|
||||||
|
// Continue processing if items remain
|
||||||
|
if (this.queue.length > 0) {
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels all pending operations in the queue
|
||||||
|
*
|
||||||
|
* @param reason The reason for cancellation
|
||||||
|
*/
|
||||||
|
cancelAll(reason: string): void {
|
||||||
|
const error = new Error(`Signing operations canceled: ${reason}`);
|
||||||
|
|
||||||
|
// Reject all queued operations
|
||||||
|
this.queue.forEach(operation => {
|
||||||
|
operation.reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the queue
|
||||||
|
this.queue = [];
|
||||||
|
|
||||||
|
// Reset processing state
|
||||||
|
this.processing = false;
|
||||||
|
this.activeCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of operations currently in the queue
|
||||||
|
*/
|
||||||
|
get length(): number {
|
||||||
|
return this.queue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the queue is currently processing
|
||||||
|
*/
|
||||||
|
get isProcessing(): boolean {
|
||||||
|
return this.processing || this.activeCount > 0;
|
||||||
|
}
|
||||||
|
}
|
31
lib/auth/types.ts
Normal file
31
lib/auth/types.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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: () => Promise<boolean>;
|
||||||
|
setError: (error: Error) => void;
|
||||||
|
}
|
@ -11,6 +11,12 @@ import NDK, {
|
|||||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import { RelayService } from '@/lib/db/services/RelayService';
|
import { RelayService } from '@/lib/db/services/RelayService';
|
||||||
|
import { AuthService } from '@/lib/auth/AuthService';
|
||||||
|
|
||||||
|
// Feature flag for new auth system
|
||||||
|
export const FLAGS = {
|
||||||
|
useNewAuthSystem: false, // Temporarily disabled until fully implemented
|
||||||
|
};
|
||||||
|
|
||||||
// Constants for SecureStore
|
// Constants for SecureStore
|
||||||
const PRIVATE_KEY_STORAGE_KEY = 'nostr_privkey';
|
const PRIVATE_KEY_STORAGE_KEY = 'nostr_privkey';
|
||||||
@ -116,17 +122,31 @@ export const useNDKStore = create<NDKStoreState & NDKStoreActions>((set, get) =>
|
|||||||
|
|
||||||
set({ ndk, relayStatus });
|
set({ ndk, relayStatus });
|
||||||
|
|
||||||
// Check for saved private key
|
// Authentication initialization:
|
||||||
const privateKeyHex = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
// Use new auth system when enabled by feature flag, otherwise use legacy approach
|
||||||
if (privateKeyHex) {
|
if (FLAGS.useNewAuthSystem) {
|
||||||
console.log('[NDK] Found saved private key, initializing signer');
|
console.log('[NDK] Using new authentication system');
|
||||||
|
// The AuthService will handle loading saved credentials
|
||||||
|
// This is just to initialize the NDK store state, actual auth will be handled by AuthProvider
|
||||||
|
// component using the AuthService
|
||||||
|
const authService = new AuthService(ndk);
|
||||||
|
|
||||||
try {
|
// We don't call authService.initialize() here because that should be done
|
||||||
await get().login(privateKeyHex);
|
// by the AuthProvider to avoid duplicate initialization
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error('[NDK] Error initializing with saved key:', error);
|
console.log('[NDK] Using legacy authentication system');
|
||||||
// Remove invalid key
|
// Legacy: Check for saved private key
|
||||||
await SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
const privateKeyHex = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
||||||
|
if (privateKeyHex) {
|
||||||
|
console.log('[NDK] Found saved private key, initializing signer');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await get().login(privateKeyHex);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[NDK] Error initializing with saved key:', error);
|
||||||
|
// Remove invalid key
|
||||||
|
await SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ import { COLORS } from './colors';
|
|||||||
* For local development, keep this as false
|
* For local development, keep this as false
|
||||||
* For TestFlight/App Store builds, set to true
|
* For TestFlight/App Store builds, set to true
|
||||||
*/
|
*/
|
||||||
export const IS_PRODUCTION = true;
|
export const IS_PRODUCTION = false; // Temporarily set to false for development
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App version information
|
* App version information
|
||||||
|
26
package-lock.json
generated
26
package-lock.json
generated
@ -52,7 +52,7 @@
|
|||||||
"expo": "~52.0.42",
|
"expo": "~52.0.42",
|
||||||
"expo-av": "~15.0.2",
|
"expo-av": "~15.0.2",
|
||||||
"expo-crypto": "~14.0.2",
|
"expo-crypto": "~14.0.2",
|
||||||
"expo-dev-client": "~5.0.16",
|
"expo-dev-client": "~5.0.18",
|
||||||
"expo-file-system": "~18.0.12",
|
"expo-file-system": "~18.0.12",
|
||||||
"expo-linking": "~7.0.4",
|
"expo-linking": "~7.0.4",
|
||||||
"expo-navigation-bar": "~4.0.9",
|
"expo-navigation-bar": "~4.0.9",
|
||||||
@ -12052,13 +12052,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-dev-client": {
|
"node_modules/expo-dev-client": {
|
||||||
"version": "5.0.16",
|
"version": "5.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-5.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-5.0.18.tgz",
|
||||||
"integrity": "sha512-+iEsOOZL3zGB0wiM0a0vU0X/E4uWGQW7wCKOVulsVXOZeNxvAw1Uu/SAiOyqPttnrzN0msPK56oY2eA7+tlyFw==",
|
"integrity": "sha512-bYuDhnnVkytqz4n4Ow3+AIj3k06Dm6h8Ubs/9R4PfmifRb6AwK5j5nW6Vsa2DJMvJbIwuJXAPm8/ZENtaBj36A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"expo-dev-launcher": "5.0.32",
|
"expo-dev-launcher": "5.0.33",
|
||||||
"expo-dev-menu": "6.0.22",
|
"expo-dev-menu": "6.0.23",
|
||||||
"expo-dev-menu-interface": "1.9.3",
|
"expo-dev-menu-interface": "1.9.3",
|
||||||
"expo-manifests": "~0.15.7",
|
"expo-manifests": "~0.15.7",
|
||||||
"expo-updates-interface": "~1.0.0"
|
"expo-updates-interface": "~1.0.0"
|
||||||
@ -12068,13 +12068,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-dev-launcher": {
|
"node_modules/expo-dev-launcher": {
|
||||||
"version": "5.0.32",
|
"version": "5.0.33",
|
||||||
"resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-5.0.32.tgz",
|
"resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-5.0.33.tgz",
|
||||||
"integrity": "sha512-Inb8DAx5vRPd/8okx0Auu1jdUJW4f3gEzJwJKbkRl7kL4VTjFBFnkRpt4VEKTb3qAtm8BsqeLhEedW0PAVJmfA==",
|
"integrity": "sha512-z+gwOkYW08f+KMhYare9YvwAP7BbStbarUaEzhJwGjTZT8XohIpmJwViWjPHQ2ctE4G7iSmmtWEh4brHGJo0bA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "8.11.0",
|
"ajv": "8.11.0",
|
||||||
"expo-dev-menu": "6.0.22",
|
"expo-dev-menu": "6.0.23",
|
||||||
"expo-manifests": "~0.15.7",
|
"expo-manifests": "~0.15.7",
|
||||||
"resolve-from": "^5.0.0"
|
"resolve-from": "^5.0.0"
|
||||||
},
|
},
|
||||||
@ -12099,9 +12099,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-dev-menu": {
|
"node_modules/expo-dev-menu": {
|
||||||
"version": "6.0.22",
|
"version": "6.0.23",
|
||||||
"resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-6.0.22.tgz",
|
"resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-6.0.23.tgz",
|
||||||
"integrity": "sha512-3NuBgGerUzwLLrz5Y7w/fPDUZ5GfREO9ntvU/b/srPWEOmtUYMIvKTuLX+SbMJBZdQGilfF7xxo93WxIYSp/rw==",
|
"integrity": "sha512-bO1FhbrQrSVGzAm36xV0SC+vAbhXFswx+w8iAF8osm+zniPNhRv1PQH/GWeet/YVorZoxyiS0juwLqP49rirEw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"expo-dev-menu-interface": "1.9.3"
|
"expo-dev-menu-interface": "1.9.3"
|
||||||
|
@ -66,7 +66,7 @@
|
|||||||
"expo": "~52.0.42",
|
"expo": "~52.0.42",
|
||||||
"expo-av": "~15.0.2",
|
"expo-av": "~15.0.2",
|
||||||
"expo-crypto": "~14.0.2",
|
"expo-crypto": "~14.0.2",
|
||||||
"expo-dev-client": "~5.0.16",
|
"expo-dev-client": "~5.0.18",
|
||||||
"expo-file-system": "~18.0.12",
|
"expo-file-system": "~18.0.12",
|
||||||
"expo-linking": "~7.0.4",
|
"expo-linking": "~7.0.4",
|
||||||
"expo-navigation-bar": "~4.0.9",
|
"expo-navigation-bar": "~4.0.9",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user