Main coves client
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}