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