Main coves client
1import 'package:flutter/foundation.dart';
2
3import '../models/coves_session.dart';
4import '../services/coves_auth_service.dart';
5
6/// Authentication Provider
7///
8/// Manages authentication state using the Coves backend OAuth flow.
9/// Uses ChangeNotifier for reactive state updates across the app.
10///
11/// Key features:
12/// - Uses CovesAuthService for backend-managed OAuth
13/// - Tokens are sealed (AES-256-GCM encrypted) and opaque to the client
14/// - Backend handles DPoP, PKCE, and token refresh internally
15/// - Session stored securely (iOS Keychain / Android EncryptedSharedPreferences)
16class AuthProvider with ChangeNotifier {
17 /// Constructor with optional auth service for dependency injection
18 AuthProvider({CovesAuthService? authService})
19 : _authService = authService ?? CovesAuthService();
20 final CovesAuthService _authService;
21
22 // Session state
23 CovesSession? _session;
24 bool _isAuthenticated = false;
25 bool _isLoading = true;
26 String? _error;
27
28 // Getters
29 CovesSession? get session => _session;
30 bool get isAuthenticated => _isAuthenticated;
31 bool get isLoading => _isLoading;
32 String? get error => _error;
33 String? get did => _session?.did;
34 String? get handle => _session?.handle;
35
36 /// Get the current access token (sealed token)
37 ///
38 /// Returns the sealed token for API authentication.
39 /// The token is opaque to the client - backend handles everything.
40 ///
41 /// If token refresh fails, attempts to refresh automatically.
42 /// If refresh fails, signs out the user.
43 Future<String?> getAccessToken() async {
44 if (_session == null) {
45 return null;
46 }
47
48 // Return the sealed token directly
49 // Token refresh is handled by the backend when the token is used
50 return _session!.token;
51 }
52
53 /// Initialize the provider and restore any existing session
54 ///
55 /// This is called on app startup to:
56 /// 1. Initialize the auth service
57 /// 2. Restore session from secure storage if available
58 Future<void> initialize() async {
59 try {
60 _isLoading = true;
61 _error = null;
62 notifyListeners();
63
64 // Initialize auth service
65 await _authService.initialize();
66
67 // Try to restore a previous session from secure storage
68 final restoredSession = await _authService.restoreSession();
69
70 if (restoredSession != null) {
71 _session = restoredSession;
72 _isAuthenticated = true;
73
74 if (kDebugMode) {
75 print('Restored session');
76 print(' DID: ${restoredSession.did}');
77 print(' Handle: ${restoredSession.handle}');
78 }
79 } else {
80 if (kDebugMode) {
81 print('No stored session found - user not logged in');
82 }
83 }
84 } catch (e) {
85 // Catch all errors to prevent app crashes during initialization
86 _error = e.toString();
87 if (kDebugMode) {
88 print('Failed to initialize auth: $e');
89 }
90 } finally {
91 _isLoading = false;
92 notifyListeners();
93 }
94 }
95
96 /// Sign in with an atProto handle
97 ///
98 /// Opens the system browser to the backend's OAuth endpoint.
99 /// The backend handles:
100 /// - Handle -> DID resolution
101 /// - PDS discovery
102 /// - OAuth authorization with PKCE/DPoP
103 /// - Token sealing
104 ///
105 /// Works with ANY handle on ANY PDS:
106 /// - alice.bsky.social -> Bluesky PDS
107 /// - bob.custom-pds.com -> Custom PDS
108 /// - did:plc:abc123 -> Direct DID
109 Future<void> signIn(String handle) async {
110 try {
111 _isLoading = true;
112 _error = null;
113 notifyListeners();
114
115 // Validate handle format
116 final trimmedHandle = handle.trim();
117 if (trimmedHandle.isEmpty) {
118 throw Exception('Please enter a valid handle');
119 }
120
121 // Perform OAuth sign in via backend
122 final session = await _authService.signIn(trimmedHandle);
123
124 // Update state
125 _session = session;
126 _isAuthenticated = true;
127
128 if (kDebugMode) {
129 print('Successfully signed in');
130 print(' Handle: ${session.handle ?? trimmedHandle}');
131 print(' DID: ${session.did}');
132 }
133 } catch (e) {
134 _error = e.toString();
135 _isAuthenticated = false;
136 _session = null;
137
138 if (kDebugMode) {
139 print('Sign in failed: $e');
140 }
141
142 rethrow;
143 } finally {
144 _isLoading = false;
145 notifyListeners();
146 }
147 }
148
149 /// Sign out and clear session
150 ///
151 /// This:
152 /// 1. Calls the backend's logout endpoint (revokes session server-side)
153 /// 2. Clears session from secure storage
154 /// 3. Resets the provider state
155 Future<void> signOut() async {
156 try {
157 _isLoading = true;
158 notifyListeners();
159
160 // Call auth service signOut (handles server + local cleanup)
161 await _authService.signOut();
162
163 // Clear state
164 _session = null;
165 _isAuthenticated = false;
166 _error = null;
167
168 if (kDebugMode) {
169 print('Successfully signed out');
170 }
171 } on Exception catch (e) {
172 _error = e.toString();
173 if (kDebugMode) {
174 print('Sign out failed: $e');
175 }
176
177 // Even if server revocation fails, clear local state
178 _session = null;
179 _isAuthenticated = false;
180 } finally {
181 _isLoading = false;
182 notifyListeners();
183 }
184 }
185
186 /// Refresh the current session token
187 ///
188 /// Calls the backend's /oauth/refresh endpoint.
189 /// The backend handles the actual PDS token refresh internally.
190 ///
191 /// Returns true if refresh succeeded, false otherwise.
192 Future<bool> refreshToken() async {
193 if (_session == null) {
194 return false;
195 }
196
197 try {
198 final refreshedSession = await _authService.refreshToken();
199 _session = refreshedSession;
200 notifyListeners();
201
202 if (kDebugMode) {
203 print('Token refreshed successfully');
204 }
205
206 return true;
207 } on Exception catch (e) {
208 if (kDebugMode) {
209 print('Token refresh failed: $e');
210 }
211
212 // If refresh fails, sign out the user
213 await signOut();
214 return false;
215 }
216 }
217
218 /// Clear error message
219 void clearError() {
220 _error = null;
221 notifyListeners();
222 }
223}