1import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 2import 'package:flutter/foundation.dart'; 3import 'package:shared_preferences/shared_preferences.dart'; 4 5import '../services/oauth_service.dart'; 6 7/// Authentication Provider 8/// 9/// Manages authentication state using the new atproto_oauth_flutter package. 10/// Uses ChangeNotifier for reactive state updates across the app. 11/// 12/// Key improvements: 13/// ✅ Uses OAuthSession from the new package (with built-in token management) 14/// ✅ Stores only the DID in SharedPreferences (public info, not sensitive) 15/// ✅ Tokens are stored securely by the package (iOS Keychain / Android EncryptedSharedPreferences) 16/// ✅ Automatic token refresh handled by the package 17class AuthProvider with ChangeNotifier { 18 /// Constructor with optional OAuthService for dependency injection (testing) 19 AuthProvider({OAuthService? oauthService}) 20 : _oauthService = oauthService ?? OAuthService(); 21 final OAuthService _oauthService; 22 23 // SharedPreferences keys for storing session info 24 // The DID and handle are public information, so SharedPreferences is fine 25 // The actual tokens are stored securely by the atproto_oauth_flutter package 26 static const String _prefKeyDid = 'current_user_did'; 27 static const String _prefKeyHandle = 'current_user_handle'; 28 29 // Session state 30 OAuthSession? _session; 31 bool _isAuthenticated = false; 32 bool _isLoading = true; 33 String? _error; 34 35 // User info 36 String? _did; 37 String? _handle; 38 39 // Getters 40 OAuthSession? get session => _session; 41 bool get isAuthenticated => _isAuthenticated; 42 bool get isLoading => _isLoading; 43 String? get error => _error; 44 String? get did => _did; 45 String? get handle => _handle; 46 47 /// Get the current access token 48 /// 49 /// This fetches the token from the session's token set. 50 /// The token is automatically refreshed if expired. 51 /// If token refresh fails (e.g., revoked server-side), signs out the user. 52 Future<String?> getAccessToken() async { 53 if (_session == null) { 54 return null; 55 } 56 57 try { 58 // Access the session getter to get the token set 59 final session = await _session!.sessionGetter.get(_session!.sub); 60 return session.tokenSet.accessToken; 61 } on Exception catch (e) { 62 if (kDebugMode) { 63 print('❌ Failed to get access token: $e'); 64 print('🔄 Token refresh failed - signing out user'); 65 } 66 67 // Token refresh failed (likely revoked or expired beyond refresh) 68 // Sign out user to clear invalid session 69 await signOut(); 70 return null; 71 } 72 } 73 74 /// Get the user's PDS URL 75 /// 76 /// Returns the URL of the user's Personal Data Server from the OAuth session. 77 /// This is needed for direct XRPC calls to the PDS (e.g., createRecord). 78 /// 79 /// The PDS URL is stored in serverMetadata['issuer'] from the OAuth session. 80 String? getPdsUrl() { 81 if (_session == null) { 82 return null; 83 } 84 85 return _session!.serverMetadata['issuer'] as String?; 86 } 87 88 /// Initialize the provider and restore any existing session 89 /// 90 /// This is called on app startup to: 91 /// 1. Initialize the OAuth service 92 /// 2. Check if there's a stored DID (from previous session) 93 /// 3. Restore the session if found (with automatic token refresh) 94 Future<void> initialize() async { 95 try { 96 _isLoading = true; 97 _error = null; 98 notifyListeners(); 99 100 // Initialize OAuth service 101 await _oauthService.initialize(); 102 103 // Check if we have a stored DID from a previous session 104 final prefs = await SharedPreferences.getInstance(); 105 final storedDid = prefs.getString(_prefKeyDid); 106 final storedHandle = prefs.getString(_prefKeyHandle); 107 108 if (storedDid != null) { 109 if (kDebugMode) { 110 print('Found stored DID: $storedDid'); 111 print('Found stored handle: $storedHandle'); 112 } 113 114 // Try to restore the session 115 // The package will automatically refresh tokens if needed 116 final restoredSession = await _oauthService.restoreSession(storedDid); 117 118 if (restoredSession != null) { 119 _session = restoredSession; 120 _isAuthenticated = true; 121 _did = restoredSession.sub; 122 _handle = storedHandle; // Restore handle from preferences 123 124 if (kDebugMode) { 125 print('✅ Successfully restored session'); 126 print(' DID: ${restoredSession.sub}'); 127 print(' Handle: $storedHandle'); 128 } 129 } else { 130 // Failed to restore - clear the stored data 131 await prefs.remove(_prefKeyDid); 132 await prefs.remove(_prefKeyHandle); 133 if (kDebugMode) { 134 print('⚠️ Could not restore session - cleared stored data'); 135 } 136 } 137 } else { 138 if (kDebugMode) { 139 print('No stored DID found - user not logged in'); 140 } 141 } 142 } on Exception catch (e) { 143 _error = e.toString(); 144 if (kDebugMode) { 145 print('❌ Failed to initialize auth: $e'); 146 } 147 } finally { 148 _isLoading = false; 149 notifyListeners(); 150 } 151 } 152 153 /// Sign in with an atProto handle 154 /// 155 /// This works with ANY handle on ANY PDS: 156 /// - alice.bsky.social → Bluesky PDS 157 /// - bob.custom-pds.com → Custom PDS 158 /// - did:plc:abc123 → Direct DID 159 /// 160 /// The package handles: 161 /// - Handle → DID resolution 162 /// - PDS discovery 163 /// - OAuth authorization 164 /// - Token storage 165 Future<void> signIn(String handle) async { 166 try { 167 _isLoading = true; 168 _error = null; 169 notifyListeners(); 170 171 // Validate handle format 172 final trimmedHandle = handle.trim(); 173 if (trimmedHandle.isEmpty) { 174 throw Exception('Please enter a valid handle'); 175 } 176 177 // Perform OAuth sign in with the new package 178 final session = await _oauthService.signIn(trimmedHandle); 179 180 // Update state 181 _session = session; 182 _isAuthenticated = true; 183 _did = session.sub; 184 _handle = trimmedHandle; 185 186 // Store the DID and handle in SharedPreferences so we can restore 187 // on next launch 188 final prefs = await SharedPreferences.getInstance(); 189 await prefs.setString(_prefKeyDid, session.sub); 190 await prefs.setString(_prefKeyHandle, trimmedHandle); 191 192 if (kDebugMode) { 193 print('✅ Successfully signed in'); 194 print(' Handle: $trimmedHandle'); 195 print(' DID: ${session.sub}'); 196 } 197 } catch (e) { 198 _error = e.toString(); 199 _isAuthenticated = false; 200 _session = null; 201 _did = null; 202 _handle = null; 203 204 if (kDebugMode) { 205 print('❌ Sign in failed: $e'); 206 } 207 208 rethrow; 209 } finally { 210 _isLoading = false; 211 notifyListeners(); 212 } 213 } 214 215 /// Sign out and clear session 216 /// 217 /// This: 218 /// 1. Calls the server's token revocation endpoint (best-effort) 219 /// 2. Deletes session from secure storage 220 /// 3. Clears the stored DID from SharedPreferences 221 /// 4. Resets the provider state 222 Future<void> signOut() async { 223 try { 224 _isLoading = true; 225 notifyListeners(); 226 227 // Get the current DID before clearing state 228 final currentDid = _did; 229 230 if (currentDid != null) { 231 // Call the new package's revoke method 232 // This handles server-side revocation + local storage cleanup 233 await _oauthService.signOut(currentDid); 234 } 235 236 // Clear the stored DID and handle from SharedPreferences 237 final prefs = await SharedPreferences.getInstance(); 238 await prefs.remove(_prefKeyDid); 239 await prefs.remove(_prefKeyHandle); 240 241 // Clear state 242 _session = null; 243 _isAuthenticated = false; 244 _did = null; 245 _handle = null; 246 _error = null; 247 248 if (kDebugMode) { 249 print('✅ Successfully signed out'); 250 } 251 } on Exception catch (e) { 252 _error = e.toString(); 253 if (kDebugMode) { 254 print('⚠️ Sign out failed: $e'); 255 } 256 257 // Even if revocation fails, clear local state 258 _session = null; 259 _isAuthenticated = false; 260 _did = null; 261 _handle = null; 262 } finally { 263 _isLoading = false; 264 notifyListeners(); 265 } 266 } 267 268 /// Clear error message 269 void clearError() { 270 _error = null; 271 notifyListeners(); 272 } 273 274 /// Dispose resources 275 @override 276 void dispose() { 277 _oauthService.dispose(); 278 super.dispose(); 279 } 280}