1import 'dart:async'; 2import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 3import 'package:flutter/foundation.dart'; 4import '../config/environment_config.dart'; 5import '../config/oauth_config.dart'; 6 7/// OAuth Service for atProto authentication using the new 8/// atproto_oauth_flutter package 9/// 10/// Key improvements over the old implementation: 11/// ✅ Proper decentralized OAuth discovery - works with ANY PDS 12/// (not just bsky.social) 13/// ✅ Built-in session management - no manual token storage 14/// ✅ Automatic token refresh with concurrency control 15/// ✅ Session event streams for updates and deletions 16/// ✅ Secure storage handled internally 17/// (iOS Keychain, Android EncryptedSharedPreferences) 18/// 19/// The new package handles the complete OAuth flow: 20/// 1. Handle/DID resolution 21/// 2. PDS discovery from DID document 22/// 3. Authorization server discovery 23/// 4. PKCE + DPoP generation 24/// 5. Browser-based authorization 25/// 6. Token exchange and storage 26/// 7. Automatic refresh and revocation 27class OAuthService { 28 factory OAuthService() => _instance; 29 OAuthService._internal(); 30 static final OAuthService _instance = OAuthService._internal(); 31 32 FlutterOAuthClient? _client; 33 34 // Session event stream subscriptions 35 StreamSubscription<SessionUpdatedEvent>? _onUpdatedSubscription; 36 StreamSubscription<SessionDeletedEvent>? _onDeletedSubscription; 37 38 /// Initialize the OAuth client 39 /// 40 /// This creates a FlutterOAuthClient with: 41 /// - Discoverable client metadata (HTTPS URL) 42 /// - Custom URL scheme for deep linking 43 /// - DPoP enabled for token security 44 /// - Automatic session management 45 Future<void> initialize() async { 46 try { 47 // Get environment configuration 48 final config = EnvironmentConfig.current; 49 50 // Create client with metadata from config 51 // For local development, use custom resolvers 52 _client = FlutterOAuthClient( 53 clientMetadata: OAuthConfig.createClientMetadata(), 54 plcDirectoryUrl: config.plcDirectoryUrl, 55 handleResolverUrl: config.handleResolverUrl, 56 allowHttp: config.isLocal, // Allow HTTP for local development 57 ); 58 59 // Set up session event listeners 60 _setupEventListeners(); 61 62 if (kDebugMode) { 63 print('✅ FlutterOAuthClient initialized'); 64 print(' Environment: ${config.environment}'); 65 print(' Client ID: ${OAuthConfig.clientId}'); 66 print(' Redirect URI: ${OAuthConfig.customSchemeCallback}'); 67 print(' Scope: ${OAuthConfig.scope}'); 68 print(' Handle Resolver: ${config.handleResolverUrl}'); 69 print(' PLC Directory: ${config.plcDirectoryUrl}'); 70 print(' Allow HTTP: ${config.isLocal}'); 71 } 72 } catch (e) { 73 if (kDebugMode) { 74 print('❌ Failed to initialize OAuth client: $e'); 75 } 76 rethrow; 77 } 78 } 79 80 /// Set up listeners for session events 81 void _setupEventListeners() { 82 if (_client == null) { 83 return; 84 } 85 86 // Listen for session updates (token refresh, etc.) 87 _onUpdatedSubscription = _client!.onUpdated.listen((event) { 88 if (kDebugMode) { 89 print('📝 Session updated for: ${event.sub}'); 90 } 91 }); 92 93 // Listen for session deletions (revoke, expiry, errors) 94 _onDeletedSubscription = _client!.onDeleted.listen((event) { 95 if (kDebugMode) { 96 print('🗑️ Session deleted for: ${event.sub}'); 97 print(' Cause: ${event.cause}'); 98 } 99 }); 100 } 101 102 /// Sign in with an atProto handle 103 /// 104 /// The new package handles the complete OAuth flow: 105 /// 1. Resolves handle → DID (using any handle resolver) 106 /// 2. Fetches DID document to find the user's PDS 107 /// 3. Discovers authorization server from PDS metadata 108 /// 4. Generates PKCE challenge and DPoP keys 109 /// 5. Opens browser for user authorization 110 /// 6. Handles callback and exchanges code for tokens 111 /// 7. Stores session securely (iOS Keychain / Android EncryptedSharedPreferences) 112 /// 113 /// This works with ANY PDS - not just bsky.social! 🎉 114 /// 115 /// Examples: 116 /// - `signIn('alice.bsky.social')` → Bluesky PDS 117 /// - `signIn('bob.custom-pds.com')` → Custom PDS ✅ 118 /// - `signIn('did:plc:abc123')` → Direct DID (skips handle resolution) 119 /// 120 /// Returns the authenticated OAuthSession. 121 Future<OAuthSession> signIn(String input) async { 122 try { 123 if (_client == null) { 124 throw Exception( 125 'OAuth client not initialized. Call initialize() first.', 126 ); 127 } 128 129 // Validate input 130 final trimmedInput = input.trim(); 131 if (trimmedInput.isEmpty) { 132 throw Exception('Please enter a valid handle or DID'); 133 } 134 135 if (kDebugMode) { 136 print('🔐 Starting sign-in for: $trimmedInput'); 137 print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); 138 } 139 140 // Call the new package's signIn method 141 // This does EVERYTHING: handle resolution, PDS discovery, OAuth flow, 142 // token storage 143 if (kDebugMode) { 144 print('📞 Calling FlutterOAuthClient.signIn()...'); 145 } 146 147 final session = await _client!.signIn(trimmedInput); 148 149 if (kDebugMode) { 150 print('✅ Sign-in successful!'); 151 print(' DID: ${session.sub}'); 152 print(' PDS: ${session.serverMetadata['issuer'] ?? 'unknown'}'); 153 print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); 154 } 155 156 return session; 157 } on OAuthCallbackError catch (e, stackTrace) { 158 // OAuth-specific errors (access denied, invalid request, etc.) 159 final errorCode = e.params['error']; 160 final errorDescription = e.params['error_description'] ?? e.message; 161 162 if (kDebugMode) { 163 print('❌ OAuth callback error details:'); 164 print(' Error code: $errorCode'); 165 print(' Description: $errorDescription'); 166 print(' Message: ${e.message}'); 167 print(' All params: ${e.params}'); 168 print(' Exception type: ${e.runtimeType}'); 169 print(' Exception: $e'); 170 print(' Stack trace:'); 171 print('$stackTrace'); 172 } 173 174 if (errorCode == 'access_denied') { 175 throw Exception('Sign in cancelled by user'); 176 } 177 178 throw Exception('OAuth error: $errorDescription'); 179 } catch (e, stackTrace) { 180 // Catch all other errors including user cancellation 181 if (kDebugMode) { 182 print('❌ Sign in failed - detailed error:'); 183 print(' Error type: ${e.runtimeType}'); 184 print(' Error: $e'); 185 print(' Stack trace:'); 186 print('$stackTrace'); 187 } 188 189 // Check if user cancelled (flutter_web_auth_2 throws 190 // PlatformException with "CANCELED" code) 191 if (e.toString().contains('CANCELED') || 192 e.toString().contains('User cancelled')) { 193 throw Exception('Sign in cancelled by user'); 194 } 195 196 throw Exception('Sign in failed: $e'); 197 } 198 } 199 200 /// Restore a previous session if available 201 /// 202 /// The new package handles session restoration automatically: 203 /// - Loads session from secure storage 204 /// - Checks token expiration 205 /// - Automatically refreshes if needed 206 /// - Returns null if no valid session exists 207 /// 208 /// Parameters: 209 /// - `did`: User's DID (e.g., "did:plc:abc123") 210 /// - `refresh`: Token refresh strategy: 211 /// - 'auto' (default): Refresh only if expired 212 /// - true: Force refresh even if not expired 213 /// - false: Use cached tokens even if expired 214 /// 215 /// Returns the restored session or null if no session found. 216 Future<OAuthSession?> restoreSession( 217 String did, { 218 String refresh = 'auto', 219 }) async { 220 try { 221 if (_client == null) { 222 throw Exception( 223 'OAuth client not initialized. Call initialize() first.', 224 ); 225 } 226 227 if (kDebugMode) { 228 print('🔄 Attempting to restore session for: $did'); 229 } 230 231 // Call the new package's restore method 232 final session = await _client!.restore(did, refresh: refresh); 233 234 if (kDebugMode) { 235 print('✅ Session restored successfully'); 236 final info = await session.getTokenInfo(); 237 print(' Token expires: ${info.expiresAt}'); 238 } 239 240 return session; 241 } on Exception catch (e) { 242 if (kDebugMode) { 243 print('⚠️ Failed to restore session: $e'); 244 } 245 return null; 246 } 247 } 248 249 /// Sign out and revoke session 250 /// 251 /// The new package handles revocation properly: 252 /// - Calls server's token revocation endpoint (best-effort) 253 /// - Deletes session from secure storage (always) 254 /// - Emits 'deleted' event 255 /// 256 /// This is a complete sign-out with server-side revocation! 🎉 257 Future<void> signOut(String did) async { 258 try { 259 if (_client == null) { 260 throw Exception( 261 'OAuth client not initialized. Call initialize() first.', 262 ); 263 } 264 265 if (kDebugMode) { 266 print('👋 Signing out: $did'); 267 } 268 269 // Call the new package's revoke method 270 await _client!.revoke(did); 271 272 if (kDebugMode) { 273 print('✅ Sign out successful'); 274 } 275 } catch (e) { 276 if (kDebugMode) { 277 print('⚠️ Sign out failed: $e'); 278 } 279 // Re-throw to let caller handle 280 rethrow; 281 } 282 } 283 284 /// Get the current OAuth client instance 285 /// 286 /// Useful for advanced use cases like: 287 /// - Listening to session events directly 288 /// - Using lower-level OAuth methods 289 FlutterOAuthClient? get client => _client; 290 291 /// Clean up resources 292 void dispose() { 293 _onUpdatedSubscription?.cancel(); 294 _onDeletedSubscription?.cancel(); 295 } 296}