import { Platform } from 'react-native'; import { TokenStorage, DPOP_STORAGE_KEYS, AUTH_STORAGE_KEYS } from '@explorins/pers-sdk/core'; import { AsyncStorageTokenStorage } from './async-storage-token-storage'; // Conditionally require Keychain to avoid Web bundler errors let Keychain: any; if (Platform.OS !== 'web') { try { Keychain = require('react-native-keychain'); } catch (e) { console.warn('ReactNativeSecureStorage: Failed to load react-native-keychain', e); } } /** * Secure Storage implementation for React Native * Uses Keychain/Keystore for sensitive data and AsyncStorage for non-sensitive data. */ export class ReactNativeSecureStorage implements TokenStorage { // Set to false because Keychain stores strings, so we need extractable (serializable) keys readonly supportsObjects = false; // Define which keys MUST go to secure hardware storage private readonly SECURE_KEYS = new Set([ DPOP_STORAGE_KEYS.PRIVATE, AUTH_STORAGE_KEYS.ACCESS_TOKEN, AUTH_STORAGE_KEYS.REFRESH_TOKEN, AUTH_STORAGE_KEYS.PROVIDER_TOKEN ]); private fallbackStorage: AsyncStorageTokenStorage; constructor(private keyPrefix: string = 'pers_tokens_') { this.fallbackStorage = new AsyncStorageTokenStorage(keyPrefix); } async set(key: string, value: unknown): Promise { const prefixedKey = this.getKeyName(key); const stringValue = typeof value === 'string' ? value : JSON.stringify(value); // Cast key to any to bypass strict literal type checking of the Set.has method // In runtime, 'key' is just a string, so this is safe. if (this.SECURE_KEYS.has(key as any)) { // Store in Keychain/Keystore try { if (Keychain) { // Service name acts as the key identifier in simple usage await Keychain.setGenericPassword(prefixedKey, stringValue, { service: prefixedKey }); } else { throw new Error('RN Keychain not available'); } } catch (e) { console.warn(`[ReactNativeSecureStorage] Keychain set failed for ${key}, falling back to AsyncStorage`, e); // Fallback to AsyncStorage if Keychain fails await this.fallbackStorage.set(key, stringValue); } } else { // Store standard config/metadata in AsyncStorage await this.fallbackStorage.set(key, stringValue); } } async get(key: string): Promise { const prefixedKey = this.getKeyName(key); if (this.SECURE_KEYS.has(key as any)) { try { if (Keychain) { const credentials = await Keychain.getGenericPassword({ service: prefixedKey }); if (credentials) { return this.tryParse(credentials.password); } } } catch (e) { console.warn(`[ReactNativeSecureStorage] Failed to access keychain for ${key}`, e); // Continue to fallback check... } // Fallback: Check AsyncStorage if not found in Keychain or Keychain failed try { const val = await this.fallbackStorage.get(key); return val ? this.tryParse(val) : null; } catch (e) { return null; } } else { try { const val = await this.fallbackStorage.get(key); return val ? this.tryParse(val) : null; } catch (e) { console.warn(`[ReactNativeSecureStorage] Failed to access AsyncStorage for ${key}`, e); return null; } } } async remove(key: string): Promise { const prefixedKey = this.getKeyName(key); if (this.SECURE_KEYS.has(key as any)) { try { if (Keychain) { await Keychain.resetGenericPassword({ service: prefixedKey }); } } catch (e) { console.warn(`[ReactNativeSecureStorage] Failed to reset keychain for ${key}`, e); } // Always remove from fallback storage too, just in case await this.fallbackStorage.remove(key); } else { await this.fallbackStorage.remove(key); } } async clear(): Promise { const clearPromises: Promise[] = []; // Clear all known secure keys with retry logic for (const key of this.SECURE_KEYS) { clearPromises.push( (async () => { try { if (Keychain) { await Keychain.resetGenericPassword({ service: this.getKeyName(key) }); } } catch (e) { console.warn(`[ReactNativeSecureStorage] Retry clearing ${key}`); // Retry once try { if (Keychain) { await Keychain.resetGenericPassword({ service: this.getKeyName(key) }); } } catch (retryError) { console.error(`[ReactNativeSecureStorage] Failed to clear ${key} after retry:`, retryError); } } })() ); } // Wait for all Keychain clearing to complete (or fail) await Promise.allSettled(clearPromises); // Always clear AsyncStorage fallback try { await this.fallbackStorage.clear(); } catch (e) { console.error('[ReactNativeSecureStorage] Failed to clear AsyncStorage:', e); } } private getKeyName(key: string): string { // For Keychain, we might want to avoid prefixes if we want to share across apps, // but for simple isolation, prefix is fine to avoid collisions if multiple instances exist. // However, Keychain services are global to the app bundle ID usually. return `${this.keyPrefix}${key}`; } private tryParse(val: string): unknown { try { return JSON.parse(val); } catch { return val; } } }