feat: Implement authentication and DPoP client

- Added Auth class for handling user login, session management, and token storage using Flutter Secure Storage.
- Introduced DpopHttpClient for making HTTP requests with DPoP proof, including nonce management and JWK thumbprint calculation.
- Created AtprotoSession model for managing session data.
- Updated main.dart to integrate authentication flow and handle sign-in state.
- Enhanced CreateGalleryPage to create galleries and navigate to the GalleryPage upon success.
- Improved error handling and loading states in ProfilePage and SplashPage.
- Added necessary imports and plugin registrations for secure storage across platforms (Linux, macOS, Windows).
- Updated pubspec.yaml to include new dependencies for secure storage, JOSE, and crypto functionalities.

+6
ios/Podfile.lock
··· 1 1 PODS: 2 2 - Flutter (1.0.0) 3 + - flutter_secure_storage (6.0.0): 4 + - Flutter 3 5 - flutter_web_auth_2 (3.0.0): 4 6 - Flutter 5 7 - image_picker_ios (0.0.1): ··· 19 21 20 22 DEPENDENCIES: 21 23 - Flutter (from `Flutter`) 24 + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) 22 25 - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) 23 26 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) 24 27 - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) ··· 30 33 EXTERNAL SOURCES: 31 34 Flutter: 32 35 :path: Flutter 36 + flutter_secure_storage: 37 + :path: ".symlinks/plugins/flutter_secure_storage/ios" 33 38 flutter_web_auth_2: 34 39 :path: ".symlinks/plugins/flutter_web_auth_2/ios" 35 40 image_picker_ios: ··· 47 52 48 53 SPEC CHECKSUMS: 49 54 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 55 + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 50 56 flutter_web_auth_2: 06d500582775790a0d4c323222fcb6d7990f9603 51 57 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 52 58 package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
+161 -174
lib/api.dart
··· 1 1 import 'package:grain/app_logger.dart'; 2 2 import 'package:grain/main.dart'; 3 - import 'package:http/http.dart' as http; 4 - import 'dart:convert'; 3 + import 'package:grain/models/atproto_session.dart'; 5 4 import 'models/profile.dart'; 6 5 import 'models/gallery.dart'; 7 6 import 'models/notification.dart' as grain; 7 + import './auth.dart'; 8 + import 'package:xrpc/xrpc.dart' as xrpc; 9 + import 'package:http/http.dart' as http; 10 + import 'dart:convert'; 11 + import 'package:grain/dpop_client.dart'; 8 12 9 13 class ApiService { 10 14 String? _accessToken; ··· 14 18 15 19 String get _apiUrl => AppConfig.apiUrl; 16 20 17 - void setToken(String token) { 21 + setToken(String? token) { 18 22 _accessToken = token; 19 23 } 20 24 21 - Future<Profile?> fetchProfile({required String did}) async { 25 + Future<AtprotoSession?> fetchSession() async { 22 26 if (_accessToken == null) return null; 23 - appLogger.i('Fetching profile for actor: $did'); 24 27 25 28 final response = await http.get( 26 - Uri.parse('$_apiUrl/xrpc/social.grain.actor.getProfile?actor=$did'), 27 - headers: {'Authorization': 'Bearer $_accessToken'}, 29 + Uri.parse('$_apiUrl/oauth/session'), 30 + headers: { 31 + 'Authorization': 'Bearer $_accessToken', 32 + 'Content-Type': 'application/json', 33 + }, 28 34 ); 29 - if (response.statusCode == 200) { 30 - final data = json.decode(response.body); 31 - loadedProfile = Profile.fromJson(data); 32 - return loadedProfile; 33 - } else { 34 - appLogger.e( 35 - 'Failed to load profile: status ${response.statusCode}, body: ${response.body}', 36 - ); 37 - throw Exception('Failed to load profile: \\${response.statusCode}'); 35 + 36 + if (response.statusCode != 200) { 37 + throw Exception('Failed to fetch session'); 38 38 } 39 + 40 + return AtprotoSession.fromJson(jsonDecode(response.body)); 39 41 } 40 42 41 - Future<List<Gallery>> fetchActorGalleries({required String did}) async { 42 - if (_accessToken == null) return []; 43 - appLogger.i('Fetching galleries for actor: $did'); 43 + Future<Profile?> fetchCurrentUser() async { 44 + final session = await auth.getValidSession(); 44 45 45 - final response = await http.get( 46 - Uri.parse( 47 - '$_apiUrl/xrpc/social.grain.gallery.getActorGalleries?actor=$did', 48 - ), 49 - headers: {'Authorization': 'Bearer $_accessToken'}, 50 - ); 51 - if (response.statusCode == 200) { 52 - final data = json.decode(response.body); 53 - final items = data['items'] as List<dynamic>?; 54 - if (items != null) { 55 - galleries = items.map((item) => Gallery.fromJson(item)).toList(); 56 - } else { 57 - galleries = []; 58 - } 59 - return galleries; 60 - } else { 61 - appLogger.e( 62 - 'Failed to load galleries: status ${response.statusCode}, body: ${response.body}', 63 - ); 64 - throw Exception('Failed to load galleries: ${response.statusCode}'); 46 + if (session == null || session.subject.isEmpty) { 47 + return null; 65 48 } 49 + 50 + final user = await fetchProfile(did: session.subject); 51 + 52 + currentUser = user; 53 + 54 + return user; 66 55 } 67 56 68 - Future<void> fetchCurrentUser() async { 69 - if (_accessToken == null) return; 70 - appLogger.i('Fetching current user'); 57 + Future<Profile?> fetchProfile({required String did}) async { 58 + appLogger.i('Fetching profile for did: $did'); 59 + final response = await xrpc.query( 60 + service: _apiUrl.replaceFirst(RegExp(r'^https?://'), ''), 61 + xrpc.NSID.create('actor.grain.social', 'getProfile'), 62 + parameters: {'actor': did}, 63 + to: Profile.fromJson, 64 + ); 65 + return response.data; 66 + } 71 67 72 - final response = await http.get( 73 - Uri.parse('$_apiUrl/oauth/session'), 74 - headers: {'Authorization': 'Bearer $_accessToken'}, 68 + Future<List<Gallery>> fetchActorGalleries({required String did}) async { 69 + appLogger.i('Fetching galleries for actor did: $did'); 70 + final record = await xrpc.query( 71 + service: _apiUrl.replaceFirst(RegExp(r'^https?://'), ''), 72 + xrpc.NSID.create('gallery.grain.social', 'getActorGalleries'), 73 + parameters: {'actor': did}, 74 + to: (json) => 75 + (json['items'] as List<dynamic>?) 76 + ?.map((item) => Gallery.fromJson(item)) 77 + .toList() ?? 78 + [], 75 79 ); 76 - if (response.statusCode == 200) { 77 - final data = json.decode(response.body); 78 - currentUser = Profile.fromJson(data); 79 - } else { 80 - appLogger.e( 81 - 'Failed to fetch current user: status ${response.statusCode}, body: ${response.body}', 82 - ); 83 - throw Exception('Failed to fetch current user: \\${response.statusCode}'); 84 - } 80 + galleries = record.data; 81 + return galleries; 85 82 } 86 83 87 84 Future<List<Gallery>> getTimeline({String? algorithm}) async { 88 - if (_accessToken == null) return []; 89 - appLogger.i('Fetching timeline'); 90 - 91 - final uri = Uri.parse('$_apiUrl/xrpc/social.grain.feed.getTimeline') 92 - .replace( 93 - queryParameters: algorithm != null ? {'algorithm': algorithm} : null, 94 - ); 95 - 96 - final response = await http.get( 97 - uri, 98 - headers: {'Authorization': 'Bearer $_accessToken'}, 99 - ); 100 - if (response.statusCode == 200) { 101 - final data = json.decode(response.body); 102 - final items = data['feed'] as List<dynamic>?; 103 - if (items != null) { 104 - return items 105 - .map((item) => Gallery.fromJson(item as Map<String, dynamic>)) 106 - .toList(); 107 - } else { 108 - return []; 109 - } 110 - } else { 111 - appLogger.e( 112 - 'Failed to load timeline: status [${response.statusCode}, body: ${response.body}', 113 - ); 114 - throw Exception('Failed to load timeline: ${response.statusCode}'); 85 + if (_accessToken == null) { 86 + return []; 115 87 } 88 + appLogger.i('Fetching timeline with algorithm: ${algorithm ?? 'default'}'); 89 + final record = await xrpc.query( 90 + service: _apiUrl.replaceFirst(RegExp(r'^https?://'), ''), 91 + xrpc.NSID.create('feed.grain.social', 'getTimeline'), 92 + parameters: algorithm != null ? {'algorithm': algorithm} : null, 93 + headers: {'Authorization': "Bearer $_accessToken"}, 94 + to: (json) => 95 + (json['feed'] as List<dynamic>?) 96 + ?.map((item) => Gallery.fromJson(item as Map<String, dynamic>)) 97 + .toList() ?? 98 + [], 99 + ); 100 + return record.data; 116 101 } 117 102 118 103 Future<Gallery?> getGallery({required String uri}) async { 119 - if (_accessToken == null) return null; 120 - appLogger.i('Fetching gallery for URI: $uri'); 121 - 122 - final response = await http.get( 123 - Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getGallery?uri=$uri'), 124 - headers: {'Authorization': 'Bearer $_accessToken'}, 104 + appLogger.i('Fetching gallery for uri: $uri'); 105 + final record = await xrpc.query( 106 + service: _apiUrl.replaceFirst(RegExp(r'^https?://'), ''), 107 + xrpc.NSID.create('gallery.grain.social', 'getGallery'), 108 + parameters: {'uri': uri}, 109 + to: Gallery.fromJson, 125 110 ); 126 - if (response.statusCode == 200) { 127 - final data = json.decode(response.body); 128 - return Gallery.fromJson(data); 129 - } else { 130 - appLogger.e( 131 - 'Failed to load gallery: status ${response.statusCode}, body: ${response.body}', 132 - ); 133 - throw Exception('Failed to load gallery: ${response.statusCode}'); 134 - } 111 + return record.data; 135 112 } 136 113 137 114 Future<Map<String, dynamic>> getGalleryThread({required String uri}) async { 138 - if (_accessToken == null) throw Exception('No access token'); 139 - appLogger.i('Fetching gallery thread for URI: $uri'); 140 - 141 - final response = await http.get( 142 - Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getGalleryThread?uri=$uri'), 143 - headers: {'Authorization': 'Bearer $_accessToken'}, 115 + appLogger.i('Fetching gallery thread for uri: $uri'); 116 + final record = await xrpc.query( 117 + service: _apiUrl.replaceFirst(RegExp(r'^https?://'), ''), 118 + xrpc.NSID.create('gallery.grain.social', 'getGalleryThread'), 119 + parameters: {'uri': uri}, 120 + to: (json) => json as Map<String, dynamic>, 144 121 ); 145 - if (response.statusCode == 200) { 146 - return json.decode(response.body) as Map<String, dynamic>; 147 - } else { 148 - appLogger.e( 149 - 'Failed to load gallery thread: status ${response.statusCode}, body: ${response.body}', 150 - ); 151 - throw Exception('Failed to load gallery thread: ${response.statusCode}'); 152 - } 122 + return record.data; 153 123 } 154 124 155 125 Future<List<grain.Notification>> getNotifications() async { 156 - if (_accessToken == null) return []; 126 + if (_accessToken == null) { 127 + appLogger.w('No access token for getNotifications'); 128 + return []; 129 + } 157 130 appLogger.i('Fetching notifications'); 158 - 159 - final response = await http.get( 160 - Uri.parse('$_apiUrl/xrpc/social.grain.notification.getNotifications'), 161 - headers: {'Authorization': 'Bearer $_accessToken'}, 131 + final record = await xrpc.query( 132 + service: _apiUrl.replaceFirst(RegExp(r'^https?://'), ''), 133 + xrpc.NSID.create('notification.grain.social', 'getNotifications'), 134 + headers: {'Authorization': "Bearer \\$_accessToken"}, 135 + to: (json) => 136 + (json['notifications'] as List<dynamic>?) 137 + ?.map( 138 + (item) => 139 + grain.Notification.fromJson(item as Map<String, dynamic>), 140 + ) 141 + .toList() ?? 142 + [], 162 143 ); 163 - if (response.statusCode == 200) { 164 - final data = json.decode(response.body); 165 - final items = data['notifications'] as List<dynamic>?; 166 - if (items != null) { 167 - return items 168 - .map( 169 - (item) => 170 - grain.Notification.fromJson(item as Map<String, dynamic>), 171 - ) 172 - .toList(); 173 - } else { 174 - return []; 175 - } 176 - } else { 177 - appLogger.e( 178 - 'Failed to load notifications: status ${response.statusCode}, body: ${response.body}', 179 - ); 180 - throw Exception('Failed to load notifications: \\${response.statusCode}'); 181 - } 144 + return record.data; 182 145 } 183 146 184 147 Future<List<Profile>> searchActors(String query) async { 185 - if (_accessToken == null) return []; 186 - appLogger.i('Searching actors for query: $query'); 187 - 188 - final response = await http.get( 189 - Uri.parse('$_apiUrl/xrpc/social.grain.actor.searchActors?q=$query'), 190 - headers: {'Authorization': 'Bearer $_accessToken'}, 148 + if (_accessToken == null) { 149 + appLogger.w('No access token for searchActors'); 150 + return []; 151 + } 152 + appLogger.i('Searching actors with query: $query'); 153 + final record = await xrpc.query( 154 + service: _apiUrl.replaceFirst(RegExp(r'^https?://'), ''), 155 + xrpc.NSID.create('actor.grain.social', 'searchActors'), 156 + parameters: {'q': query}, 157 + to: (json) => 158 + (json['actors'] as List<dynamic>?) 159 + ?.map((item) => Profile.fromJson(item)) 160 + .toList() ?? 161 + [], 191 162 ); 192 - if (response.statusCode == 200) { 193 - final data = json.decode(response.body); 194 - final items = data['actors'] as List<dynamic>?; 195 - if (items != null) { 196 - appLogger.i('Found ${items.length} actors for query: $query'); 197 - return items.map((item) => Profile.fromJson(item)).toList(); 198 - } else { 199 - return []; 200 - } 201 - } else { 202 - appLogger.e( 203 - 'Failed to search actors: status ${response.statusCode}, body: ${response.body}', 204 - ); 205 - throw Exception('Failed to search actors: ${response.statusCode}'); 206 - } 163 + return record.data; 207 164 } 208 165 209 166 Future<List<Gallery>> getActorFavs({required String did}) async { 210 - if (_accessToken == null) return []; 211 - appLogger.i('Fetching favorite galleries for actor: $did'); 167 + appLogger.i('Fetching actor favs for did: $did'); 168 + final record = await xrpc.query( 169 + service: _apiUrl.replaceFirst(RegExp(r'^https?://'), ''), 170 + xrpc.NSID.create('actor.grain.social', 'getActorFavs'), 171 + parameters: {'actor': did}, 172 + to: (json) => 173 + (json['items'] as List<dynamic>?) 174 + ?.map((item) => Gallery.fromJson(item)) 175 + .toList() ?? 176 + [], 177 + ); 178 + return record.data; 179 + } 212 180 213 - final response = await http.get( 214 - Uri.parse('$_apiUrl/xrpc/social.grain.actor.getActorFavs?actor=$did'), 215 - headers: {'Authorization': 'Bearer $_accessToken'}, 181 + Future<String?> createGallery({ 182 + required String title, 183 + required String description, 184 + }) async { 185 + final session = await auth.getValidSession(); 186 + if (session == null) { 187 + appLogger.w('No valid session for createGallery'); 188 + return null; 189 + } 190 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 191 + final issuer = session.issuer; 192 + final did = session.subject; 193 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 194 + final record = { 195 + 'collection': 'social.grain.gallery', 196 + 'repo': did, 197 + 'record': { 198 + 'title': title, 199 + 'description': description, 200 + 'updatedAt': DateTime.now().toUtc().toIso8601String(), 201 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 202 + }, 203 + }; 204 + appLogger.i('Creating gallery: $record'); 205 + final response = await dpopClient.send( 206 + method: 'POST', 207 + url: url, 208 + accessToken: session.accessToken, 209 + body: jsonEncode(record), 216 210 ); 217 - if (response.statusCode == 200) { 218 - final data = json.decode(response.body); 219 - final items = data['items'] as List<dynamic>?; 220 - if (items != null) { 221 - return items.map((item) => Gallery.fromJson(item)).toList(); 222 - } else { 223 - return []; 224 - } 225 - } else { 226 - appLogger.e( 227 - 'Failed to load favorite galleries: status [${response.statusCode}, body: ${response.body}', 228 - ); 229 - throw Exception( 230 - 'Failed to load favorite galleries: ${response.statusCode}', 211 + if (response.statusCode != 200 && response.statusCode != 201) { 212 + appLogger.w( 213 + 'Failed to create gallery: ${response.statusCode} ${response.body}', 231 214 ); 215 + throw Exception('Failed to create gallery: ${response.statusCode}'); 232 216 } 217 + final result = jsonDecode(response.body) as Map<String, dynamic>; 218 + appLogger.i('Created gallery result: $result'); 219 + return result['uri']; 233 220 } 234 221 } 235 222
+77
lib/auth.dart
··· 1 + import 'dart:convert'; 2 + import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 3 + import 'package:grain/api.dart'; 4 + import 'package:grain/app_logger.dart'; 5 + import 'package:grain/main.dart'; 6 + import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; 7 + import 'package:grain/models/atproto_session.dart'; 8 + import 'package:jose/jose.dart'; 9 + import 'package:uuid/uuid.dart'; 10 + import 'package:http/http.dart' as http; 11 + import 'package:crypto/crypto.dart'; 12 + 13 + class Auth { 14 + static const _storage = FlutterSecureStorage(); 15 + Auth(); 16 + 17 + Future<void> login(String handle) async { 18 + final apiUrl = AppConfig.apiUrl; 19 + final redirectedUrl = await FlutterWebAuth2.authenticate( 20 + url: 21 + '$apiUrl/oauth/login?client=native&handle=${Uri.encodeComponent(handle)}', 22 + callbackUrlScheme: 'grainflutter', 23 + ); 24 + final uri = Uri.parse(redirectedUrl); 25 + final token = uri.queryParameters['token']; 26 + 27 + appLogger.i('Redirected URL: $redirectedUrl'); 28 + appLogger.i('User signed in with handle: $handle'); 29 + 30 + apiService.setToken(token); 31 + 32 + final session = await apiService.fetchSession(); 33 + if (session == null) { 34 + throw Exception('Failed to fetch session after login'); 35 + } 36 + 37 + await _saveSession(session); 38 + } 39 + 40 + Future<void> _saveSession(AtprotoSession session) async { 41 + final sessionJson = jsonEncode(session.toJson()); 42 + await _storage.write(key: 'atproto_session', value: sessionJson); 43 + } 44 + 45 + Future<AtprotoSession?> _loadSession() async { 46 + final jsonString = await _storage.read(key: 'atproto_session'); 47 + if (jsonString == null) return null; 48 + 49 + try { 50 + final json = jsonDecode(jsonString); 51 + return AtprotoSession.fromJson(json); 52 + } catch (e) { 53 + // Optionally log or clear storage if corrupted 54 + return null; 55 + } 56 + } 57 + 58 + bool isSessionExpired( 59 + AtprotoSession session, { 60 + Duration tolerance = const Duration(seconds: 30), 61 + }) { 62 + final now = DateTime.now().toUtc(); 63 + return session.expiresAt.subtract(tolerance).isBefore(now); 64 + } 65 + 66 + Future<AtprotoSession?> getValidSession() async { 67 + final session = await _loadSession(); 68 + if (session == null || isSessionExpired(session)) { 69 + appLogger.w('Session is expired or not found'); 70 + // Try refresh or return null 71 + return null; 72 + } 73 + return session; 74 + } 75 + } 76 + 77 + final auth = Auth();
+164
lib/dpop_client.dart
··· 1 + import 'dart:convert'; 2 + import 'package:http/http.dart' as http; 3 + import 'package:jose/jose.dart'; 4 + import 'package:crypto/crypto.dart'; 5 + import 'package:uuid/uuid.dart'; 6 + 7 + class DpopHttpClient { 8 + final JsonWebKey dpopKey; 9 + final Map<String, String> _nonces = {}; // origin -> nonce 10 + 11 + DpopHttpClient({required this.dpopKey}); 12 + 13 + /// Extract origin (scheme + host + port) from a URL 14 + String _extractOrigin(String url) { 15 + final uri = Uri.parse(url); 16 + final portPart = (uri.hasPort && uri.port != 80 && uri.port != 443) 17 + ? ':${uri.port}' 18 + : ''; 19 + return '${uri.scheme}://${uri.host}$portPart'; 20 + } 21 + 22 + /// Strip query and fragment from URL per spec 23 + String _buildHtu(String url) { 24 + final uri = Uri.parse(url); 25 + return '${uri.scheme}://${uri.host}${uri.path}'; 26 + } 27 + 28 + /// Calculate ath claim: base64url(sha256(access_token)) 29 + String _calculateAth(String accessToken) { 30 + final hash = sha256.convert(utf8.encode(accessToken)); 31 + return base64Url.encode(hash.bytes).replaceAll('=', ''); 32 + } 33 + 34 + /// Calculate the JWK Thumbprint for EC or RSA keys per RFC 7638. 35 + /// The input [jwk] is the public part of your key as a Map`<String, dynamic>`. 36 + /// 37 + /// For EC keys, required fields are: crv, kty, x, y 38 + /// For RSA keys, required fields are: e, kty, n 39 + String calculateJwkThumbprint(Map<String, dynamic> jwk) { 40 + late Map<String, String> ordered; 41 + 42 + if (jwk['kty'] == 'EC') { 43 + ordered = { 44 + 'crv': jwk['crv'], 45 + 'kty': jwk['kty'], 46 + 'x': jwk['x'], 47 + 'y': jwk['y'], 48 + }; 49 + } else if (jwk['kty'] == 'RSA') { 50 + ordered = {'e': jwk['e'], 'kty': jwk['kty'], 'n': jwk['n']}; 51 + } else { 52 + throw ArgumentError('Unsupported key type for thumbprint calculation'); 53 + } 54 + 55 + final jsonString = jsonEncode(ordered); 56 + 57 + final digest = sha256.convert(utf8.encode(jsonString)); 58 + return base64Url.encode(digest.bytes).replaceAll('=', ''); 59 + } 60 + 61 + /// Build the DPoP JWT proof 62 + Future<String> _buildProof({ 63 + required String htm, 64 + required String htu, 65 + String? nonce, 66 + String? ath, 67 + }) async { 68 + final now = (DateTime.now().millisecondsSinceEpoch / 1000).floor(); 69 + final jti = Uuid().v4(); 70 + 71 + final publicJwk = Map<String, String>.from(dpopKey.toJson())..remove('d'); 72 + 73 + final payload = { 74 + 'htu': htu, 75 + 'htm': htm, 76 + 'iat': now, 77 + 'jti': jti, 78 + if (nonce != null) 'nonce': nonce, 79 + if (ath != null) 'ath': ath, 80 + }; 81 + 82 + final builder = JsonWebSignatureBuilder() 83 + ..jsonContent = payload 84 + ..addRecipient(dpopKey, algorithm: dpopKey.algorithm) 85 + ..setProtectedHeader('typ', 'dpop+jwt') 86 + ..setProtectedHeader('jwk', publicJwk); 87 + 88 + final jws = builder.build(); 89 + return jws.toCompactSerialization(); 90 + } 91 + 92 + /// Public method to send requests with DPoP proof, retries once on use_dpop_nonce error 93 + Future<http.Response> send({ 94 + required String method, 95 + required Uri url, 96 + required String accessToken, 97 + Map<String, String>? headers, 98 + Object? body, 99 + }) async { 100 + final origin = _extractOrigin(url.toString()); 101 + final nonce = _nonces[origin]; 102 + 103 + final htu = _buildHtu(url.toString()); 104 + final ath = _calculateAth(accessToken); 105 + 106 + final proof = await _buildProof( 107 + htm: method.toUpperCase(), 108 + htu: htu, 109 + nonce: nonce, 110 + ath: ath, 111 + ); 112 + 113 + final requestHeaders = <String, String>{ 114 + 'Authorization': 'DPoP $accessToken', 115 + 'DPoP': proof, 116 + 'Content-Type': 'application/json', 117 + if (headers != null) ...headers, 118 + }; 119 + 120 + http.Response response; 121 + switch (method.toUpperCase()) { 122 + case 'GET': 123 + response = await http.get(url, headers: requestHeaders); 124 + break; 125 + case 'POST': 126 + response = await http.post(url, headers: requestHeaders, body: body); 127 + break; 128 + case 'PUT': 129 + response = await http.put(url, headers: requestHeaders, body: body); 130 + break; 131 + case 'DELETE': 132 + response = await http.delete(url, headers: requestHeaders); 133 + break; 134 + default: 135 + throw UnsupportedError('Unsupported HTTP method: $method'); 136 + } 137 + 138 + final newNonce = response.headers['dpop-nonce']; 139 + if (newNonce != null && newNonce != nonce) { 140 + // Save new nonce for origin 141 + _nonces[origin] = newNonce; 142 + } 143 + 144 + if (response.statusCode == 401) { 145 + final wwwAuth = response.headers['www-authenticate']; 146 + if (wwwAuth != null && 147 + wwwAuth.contains('DPoP') && 148 + wwwAuth.contains('error="use_dpop_nonce"') && 149 + newNonce != null && 150 + newNonce != nonce) { 151 + // Retry once with updated nonce 152 + return send( 153 + method: method, 154 + url: url, 155 + accessToken: accessToken, 156 + headers: headers, 157 + body: body, 158 + ); 159 + } 160 + } 161 + 162 + return response; 163 + } 164 + }
+3 -9
lib/main.dart
··· 6 6 import 'package:grain/app_logger.dart'; 7 7 import 'package:grain/screens/splash_page.dart'; 8 8 import 'package:grain/screens/home_page.dart'; 9 + import 'package:grain/auth.dart'; 9 10 10 11 class AppConfig { 11 12 static late final String apiUrl; ··· 38 39 39 40 class _MyAppState extends State<MyApp> { 40 41 bool isSignedIn = false; 41 - dynamic session; 42 42 String? displayName; 43 43 44 - void handleSignIn(dynamic newSession) async { 45 - // Accept both Map and object with accessToken property 46 - final accessToken = newSession is Map<String, String> 47 - ? newSession['accessToken'] 48 - : newSession.accessToken; 44 + void handleSignIn() async { 49 45 setState(() { 50 46 isSignedIn = true; 51 - session = newSession; 52 47 }); 53 - apiService.setToken(accessToken); 54 48 // Fetch current user profile from /oauth/session after login 49 + appLogger.i('Fetching current user after sign in'); 55 50 await apiService.fetchCurrentUser(); 56 51 } 57 52 58 53 void handleSignOut() { 59 54 setState(() { 60 55 isSignedIn = false; 61 - session = null; 62 56 }); 63 57 } 64 58
+48
lib/models/atproto_session.dart
··· 1 + import 'package:jose/jose.dart'; 2 + 3 + class AtprotoSession { 4 + final String accessToken; 5 + final String? refreshToken; 6 + final String tokenType; 7 + final DateTime expiresAt; 8 + final JsonWebKey dpopJwk; 9 + final String issuer; 10 + final String subject; 11 + 12 + AtprotoSession({ 13 + required this.accessToken, 14 + this.refreshToken, 15 + required this.tokenType, 16 + required this.expiresAt, 17 + required this.dpopJwk, 18 + required this.issuer, 19 + required this.subject, 20 + }); 21 + 22 + factory AtprotoSession.fromJson(Map<String, dynamic> json) { 23 + final token = json['tokenSet'] ?? {}; 24 + final dpopJwkJson = json['dpopJwk'] ?? {}; 25 + 26 + return AtprotoSession( 27 + accessToken: token['access_token'], 28 + refreshToken: token['refresh_token'], 29 + tokenType: token['token_type'], 30 + expiresAt: DateTime.parse(token['expires_at']), 31 + dpopJwk: JsonWebKey.fromJson(dpopJwkJson), 32 + issuer: token['iss'], 33 + subject: token['sub'], 34 + ); 35 + } 36 + 37 + Map<String, dynamic> toJson() => { 38 + 'tokenSet': { 39 + 'access_token': accessToken, 40 + 'refresh_token': refreshToken, 41 + 'token_type': tokenType, 42 + 'expires_at': expiresAt.toIso8601String(), 43 + 'iss': issuer, 44 + 'sub': subject, 45 + }, 46 + 'dpopJwk': dpopJwk.toJson(), 47 + }; 48 + }
+4 -1
lib/screens/home_page.dart
··· 179 179 if (apiService.currentUser == null) { 180 180 return const Scaffold( 181 181 body: Center( 182 - child: CircularProgressIndicator(strokeWidth: 2, Color(0xFF0EA5E9)), 182 + child: CircularProgressIndicator( 183 + strokeWidth: 2, 184 + color: Color(0xFF0EA5E9), 185 + ), 183 186 ), 184 187 ); 185 188 }
+27 -17
lib/screens/profile_page.dart
··· 48 48 49 49 void _onTabChanged() async { 50 50 if (_tabController!.index == 1 && _favs.isEmpty && !_favsLoading) { 51 - setState(() { 52 - _favsLoading = true; 53 - }); 51 + if (mounted) { 52 + setState(() { 53 + _favsLoading = true; 54 + }); 55 + } 54 56 String? did = (_profile ?? widget.profile)?.did; 55 57 if (did != null && did.isNotEmpty) { 56 58 try { ··· 70 72 } 71 73 } 72 74 } else { 73 - setState(() { 74 - _favsLoading = false; 75 - }); 75 + if (mounted) { 76 + setState(() { 77 + _favsLoading = false; 78 + }); 79 + } 76 80 } 77 81 } 78 82 } 79 83 80 84 Future<void> _fetchProfileAndGalleries() async { 81 - setState(() { 82 - _loading = true; 83 - }); 84 - String? did = widget.did ?? widget.profile?.did; 85 - if (did == null || did.isEmpty) { 85 + if (mounted) { 86 86 setState(() { 87 - _loading = false; 87 + _loading = true; 88 88 }); 89 + } 90 + String? did = widget.did ?? widget.profile?.did; 91 + if (did == null || did.isEmpty) { 92 + if (mounted) { 93 + setState(() { 94 + _loading = false; 95 + }); 96 + } 89 97 return; 90 98 } 91 99 final profile = await apiService.fetchProfile(did: did); 92 100 final galleries = await apiService.fetchActorGalleries(did: did); 93 - setState(() { 94 - _profile = profile; 95 - _galleries = galleries; 96 - _loading = false; 97 - }); 101 + if (mounted) { 102 + setState(() { 103 + _profile = profile; 104 + _galleries = galleries; 105 + _loading = false; 106 + }); 107 + } 98 108 } 99 109 100 110 @override
+6 -19
lib/screens/splash_page.dart
··· 1 1 import 'package:flutter/material.dart'; 2 - import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; 3 - import 'package:grain/app_logger.dart'; 4 - import 'package:grain/main.dart'; 2 + import 'package:grain/auth.dart'; 5 3 import 'package:grain/widgets/app_image.dart'; 6 4 7 5 class SplashPage extends StatefulWidget { 8 - final void Function(dynamic session)? onSignIn; 6 + final void Function()? onSignIn; 9 7 const SplashPage({super.key, this.onSignIn}); 10 8 11 9 @override ··· 20 18 21 19 Future<void> _signInWithBluesky(BuildContext context) async { 22 20 final handle = _handleController.text.trim(); 21 + 23 22 if (handle.isEmpty) return; 24 23 setState(() { 25 24 _signingIn = true; 26 25 }); 26 + 27 27 try { 28 - final apiUrl = AppConfig.apiUrl; 29 - final redirectedUrl = await FlutterWebAuth2.authenticate( 30 - url: 31 - '$apiUrl/oauth/login?client=native&handle=${Uri.encodeComponent(handle)}', 32 - callbackUrlScheme: 'grainflutter', 33 - ); 34 - final uri = Uri.parse(redirectedUrl); 35 - final token = uri.queryParameters['token']; 28 + await auth.login(handle); 36 29 37 - appLogger.i('Redirected URL: $redirectedUrl'); 38 - appLogger.i('User signed in with handle: $handle'); 39 - 40 - if (token == null) { 41 - throw Exception('Token not found in redirect URL'); 42 - } 43 30 if (widget.onSignIn != null) { 44 - widget.onSignIn!({'accessToken': token}); 31 + widget.onSignIn!(); 45 32 } 46 33 } finally { 47 34 setState(() {
+9 -6
lib/widgets/app_button.dart
··· 29 29 @override 30 30 Widget build(BuildContext context) { 31 31 final Color primaryColor = const Color(0xFF0EA5E9); // Tailwind sky-500 32 - final Color secondaryColor = Theme.of(context).colorScheme.surfaceContainerHighest; 32 + final Color secondaryColor = Theme.of( 33 + context, 34 + ).colorScheme.surfaceContainerHighest; 33 35 final Color secondaryBorder = Colors.grey[300]!; 34 36 final Color secondaryText = Colors.black87; 35 37 final Color primaryText = Colors.white; ··· 51 53 : BorderSide(color: secondaryBorder, width: 1), 52 54 ), 53 55 padding: padding ?? const EdgeInsets.symmetric(horizontal: 16), 54 - textStyle: TextStyle( 55 - fontWeight: FontWeight.w600, 56 - fontSize: fontSize, 57 - ), 56 + textStyle: TextStyle(fontWeight: FontWeight.w600, fontSize: fontSize), 58 57 ), 59 58 child: loading 60 59 ? SizedBox( ··· 70 69 mainAxisAlignment: MainAxisAlignment.center, 71 70 children: [ 72 71 if (icon != null) ...[ 73 - Icon(icon, size: 20, color: isPrimary ? Colors.white : primaryColor), 72 + Icon( 73 + icon, 74 + size: 20, 75 + color: isPrimary ? Colors.white : primaryColor, 76 + ), 74 77 const SizedBox(width: 8), 75 78 ], 76 79 Text(label),
+4 -1
lib/widgets/plain_text_field.dart
··· 50 50 decoration: InputDecoration( 51 51 hintText: hintText, 52 52 border: InputBorder.none, 53 - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 53 + contentPadding: const EdgeInsets.symmetric( 54 + horizontal: 12, 55 + vertical: 12, 56 + ), 54 57 isDense: true, 55 58 ), 56 59 ),
+4
linux/flutter/generated_plugin_registrant.cc
··· 8 8 9 9 #include <desktop_webview_window/desktop_webview_window_plugin.h> 10 10 #include <file_selector_linux/file_selector_plugin.h> 11 + #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> 11 12 #include <url_launcher_linux/url_launcher_plugin.h> 12 13 #include <window_to_front/window_to_front_plugin.h> 13 14 ··· 18 19 g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = 19 20 fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); 20 21 file_selector_plugin_register_with_registrar(file_selector_linux_registrar); 22 + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = 23 + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); 24 + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); 21 25 g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = 22 26 fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); 23 27 url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
+1
linux/flutter/generated_plugins.cmake
··· 5 5 list(APPEND FLUTTER_PLUGIN_LIST 6 6 desktop_webview_window 7 7 file_selector_linux 8 + flutter_secure_storage_linux 8 9 url_launcher_linux 9 10 window_to_front 10 11 )
+2
macos/Flutter/GeneratedPluginRegistrant.swift
··· 7 7 8 8 import desktop_webview_window 9 9 import file_selector_macos 10 + import flutter_secure_storage_macos 10 11 import flutter_web_auth_2 11 12 import package_info_plus 12 13 import path_provider_foundation ··· 18 19 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 19 20 DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin")) 20 21 FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) 22 + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) 21 23 FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) 22 24 FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) 23 25 PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
+154 -2
pubspec.lock
··· 1 1 # Generated by pub 2 2 # See https://dart.dev/tools/pub/glossary#lockfile 3 3 packages: 4 + asn1lib: 5 + dependency: transitive 6 + description: 7 + name: asn1lib 8 + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" 9 + url: "https://pub.dev" 10 + source: hosted 11 + version: "1.6.5" 4 12 async: 5 13 dependency: transitive 6 14 description: ··· 81 89 url: "https://pub.dev" 82 90 source: hosted 83 91 version: "1.19.1" 92 + convert: 93 + dependency: transitive 94 + description: 95 + name: convert 96 + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 97 + url: "https://pub.dev" 98 + source: hosted 99 + version: "3.1.2" 84 100 cross_file: 85 101 dependency: transitive 86 102 description: ··· 90 106 source: hosted 91 107 version: "0.3.4+2" 92 108 crypto: 93 - dependency: transitive 109 + dependency: "direct main" 94 110 description: 95 111 name: crypto 96 112 sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" 97 113 url: "https://pub.dev" 98 114 source: hosted 99 115 version: "3.0.6" 116 + crypto_keys: 117 + dependency: transitive 118 + description: 119 + name: crypto_keys 120 + sha256: acc19abf34623d990a0e8aec69463d74a824c31f137128f42e2810befc509ad0 121 + url: "https://pub.dev" 122 + source: hosted 123 + version: "0.3.0+1" 100 124 cupertino_icons: 101 125 dependency: "direct main" 102 126 description: ··· 214 238 url: "https://pub.dev" 215 239 source: hosted 216 240 version: "2.0.28" 241 + flutter_secure_storage: 242 + dependency: "direct main" 243 + description: 244 + name: flutter_secure_storage 245 + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" 246 + url: "https://pub.dev" 247 + source: hosted 248 + version: "9.2.4" 249 + flutter_secure_storage_linux: 250 + dependency: transitive 251 + description: 252 + name: flutter_secure_storage_linux 253 + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 254 + url: "https://pub.dev" 255 + source: hosted 256 + version: "1.2.3" 257 + flutter_secure_storage_macos: 258 + dependency: transitive 259 + description: 260 + name: flutter_secure_storage_macos 261 + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" 262 + url: "https://pub.dev" 263 + source: hosted 264 + version: "3.1.3" 265 + flutter_secure_storage_platform_interface: 266 + dependency: transitive 267 + description: 268 + name: flutter_secure_storage_platform_interface 269 + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 270 + url: "https://pub.dev" 271 + source: hosted 272 + version: "1.1.2" 273 + flutter_secure_storage_web: 274 + dependency: transitive 275 + description: 276 + name: flutter_secure_storage_web 277 + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 278 + url: "https://pub.dev" 279 + source: hosted 280 + version: "1.2.1" 281 + flutter_secure_storage_windows: 282 + dependency: transitive 283 + description: 284 + name: flutter_secure_storage_windows 285 + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 286 + url: "https://pub.dev" 287 + source: hosted 288 + version: "3.1.2" 217 289 flutter_test: 218 290 dependency: "direct dev" 219 291 description: flutter ··· 248 320 url: "https://pub.dev" 249 321 source: hosted 250 322 version: "10.8.0" 323 + freezed_annotation: 324 + dependency: transitive 325 + description: 326 + name: freezed_annotation 327 + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 328 + url: "https://pub.dev" 329 + source: hosted 330 + version: "2.4.4" 251 331 google_fonts: 252 332 dependency: "direct main" 253 333 description: ··· 336 416 url: "https://pub.dev" 337 417 source: hosted 338 418 version: "0.2.1+1" 419 + jose: 420 + dependency: "direct main" 421 + description: 422 + name: jose 423 + sha256: "7955ec5d131960104e81fbf151abacb9d835c16c9e793ed394b2809f28b2198d" 424 + url: "https://pub.dev" 425 + source: hosted 426 + version: "0.3.4" 427 + js: 428 + dependency: transitive 429 + description: 430 + name: js 431 + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 432 + url: "https://pub.dev" 433 + source: hosted 434 + version: "0.6.7" 435 + json_annotation: 436 + dependency: transitive 437 + description: 438 + name: json_annotation 439 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" 440 + url: "https://pub.dev" 441 + source: hosted 442 + version: "4.9.0" 339 443 leak_tracker: 340 444 dependency: transitive 341 445 description: ··· 512 616 url: "https://pub.dev" 513 617 source: hosted 514 618 version: "2.1.8" 619 + pointycastle: 620 + dependency: transitive 621 + description: 622 + name: pointycastle 623 + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" 624 + url: "https://pub.dev" 625 + source: hosted 626 + version: "3.9.1" 627 + quiver: 628 + dependency: transitive 629 + description: 630 + name: quiver 631 + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 632 + url: "https://pub.dev" 633 + source: hosted 634 + version: "3.2.2" 515 635 rxdart: 516 636 dependency: transitive 517 637 description: ··· 718 838 source: hosted 719 839 version: "3.1.4" 720 840 uuid: 721 - dependency: transitive 841 + dependency: "direct main" 722 842 description: 723 843 name: uuid 724 844 sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff ··· 749 869 url: "https://pub.dev" 750 870 source: hosted 751 871 version: "1.1.1" 872 + web_socket: 873 + dependency: transitive 874 + description: 875 + name: web_socket 876 + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" 877 + url: "https://pub.dev" 878 + source: hosted 879 + version: "1.0.1" 880 + web_socket_channel: 881 + dependency: transitive 882 + description: 883 + name: web_socket_channel 884 + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 885 + url: "https://pub.dev" 886 + source: hosted 887 + version: "3.0.3" 752 888 win32: 753 889 dependency: transitive 754 890 description: ··· 765 901 url: "https://pub.dev" 766 902 source: hosted 767 903 version: "0.0.3" 904 + x509: 905 + dependency: transitive 906 + description: 907 + name: x509 908 + sha256: cbd1a63846884afd273cda247b0365284c8d85a365ca98e110413f93d105b935 909 + url: "https://pub.dev" 910 + source: hosted 911 + version: "0.2.4+3" 768 912 xdg_directories: 769 913 dependency: transitive 770 914 description: ··· 773 917 url: "https://pub.dev" 774 918 source: hosted 775 919 version: "1.1.0" 920 + xrpc: 921 + dependency: "direct main" 922 + description: 923 + name: xrpc 924 + sha256: bacfa0f6824fdeaa631aad1a5fd064c3f140c771fed94cbd04df3b7d1e008709 925 + url: "https://pub.dev" 926 + source: hosted 927 + version: "0.6.1" 776 928 sdks: 777 929 dart: ">=3.8.1 <4.0.0" 778 930 flutter: ">=3.27.0"
+5
pubspec.yaml
··· 47 47 cached_network_image: ^3.4.1 48 48 image_picker: ^1.1.2 49 49 path_provider: ^2.1.5 50 + flutter_secure_storage: ^9.0.0 51 + jose: ^0.3.4 52 + uuid: ^4.5.1 53 + crypto: ^3.0.6 54 + xrpc: ^0.6.1 50 55 51 56 dev_dependencies: 52 57 flutter_test:
+3
windows/flutter/generated_plugin_registrant.cc
··· 8 8 9 9 #include <desktop_webview_window/desktop_webview_window_plugin.h> 10 10 #include <file_selector_windows/file_selector_windows.h> 11 + #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> 11 12 #include <share_plus/share_plus_windows_plugin_c_api.h> 12 13 #include <url_launcher_windows/url_launcher_windows.h> 13 14 #include <window_to_front/window_to_front_plugin.h> ··· 17 18 registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); 18 19 FileSelectorWindowsRegisterWithRegistrar( 19 20 registry->GetRegistrarForPlugin("FileSelectorWindows")); 21 + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( 22 + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); 20 23 SharePlusWindowsPluginCApiRegisterWithRegistrar( 21 24 registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); 22 25 UrlLauncherWindowsRegisterWithRegistrar(
+1
windows/flutter/generated_plugins.cmake
··· 5 5 list(APPEND FLUTTER_PLUGIN_LIST 6 6 desktop_webview_window 7 7 file_selector_windows 8 + flutter_secure_storage_windows 8 9 share_plus 9 10 url_launcher_windows 10 11 window_to_front