mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: OAuth loopback port selection

+174 -52
+141 -23
lib/features/auth/data/auth_repository.dart
··· 1 1 import 'dart:async'; 2 + import 'dart:convert'; 2 3 import 'dart:io'; 3 4 4 5 import 'package:atproto/atproto.dart' as atp; ··· 6 7 import 'package:atproto_oauth/atproto_oauth.dart'; 7 8 import 'package:bluesky/bluesky.dart'; 8 9 import 'package:drift/drift.dart'; 10 + import 'package:http/http.dart' as http; 9 11 import 'package:lazurite/core/database/app_database.dart'; 10 12 import 'package:lazurite/core/logging/app_logger.dart'; 11 13 import 'package:lazurite/features/auth/data/models/auth_models.dart'; ··· 15 17 AuthRepository({required AppDatabase database}) : _database = database; 16 18 17 19 static const String kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; 20 + static const String _oauthService = 'bsky.social'; 18 21 static const String _fallbackService = 'bsky.social'; 19 22 20 23 final AppDatabase _database; ··· 23 26 Completer<AuthTokens?>? _oauthCompleter; 24 27 OAuthClient? _pendingOAuthClient; 25 28 OAuthContext? _pendingOAuthContext; 26 - Uri? _pendingRedirectUri; 27 29 String? _pendingHandle; 28 30 String? _pendingService; 29 31 ··· 51 53 } 52 54 53 55 Future<AuthTokens?> restoreSession() async { 56 + log.d('AuthRepository: Restoring stored session'); 54 57 final storedSession = await getStoredSession(); 55 58 if (storedSession == null) { 59 + log.d('AuthRepository: No stored session found'); 56 60 return null; 57 61 } 58 62 59 63 if (!storedSession.isExpired) { 64 + log.i('AuthRepository: Restored valid stored session for ${storedSession.handle}'); 60 65 return storedSession; 61 66 } 62 67 63 68 if (storedSession.refreshToken == null) { 69 + log.w('AuthRepository: Stored session expired without refresh token, clearing session'); 64 70 await clearSession(); 65 71 return null; 66 72 } 67 73 68 74 try { 75 + log.i('AuthRepository: Stored session expired, attempting refresh for ${storedSession.handle}'); 69 76 return await refreshSession(storedSession); 70 - } catch (_) { 77 + } catch (error, stackTrace) { 78 + log.e('AuthRepository: Failed to restore expired session', error: error, stackTrace: stackTrace); 71 79 await clearSession(); 72 80 return null; 73 81 } ··· 99 107 try { 100 108 _oauthCompleter = Completer<AuthTokens?>(); 101 109 _pendingHandle = handle.trim(); 102 - _pendingService = await _resolveServiceForIdentifier(_pendingHandle!); 110 + _pendingService = _oauthService; 111 + log.i('AuthRepository: Starting OAuth login for ${_pendingHandle!}'); 103 112 104 113 final metadata = await getClientMetadata(kClientId); 105 - final redirectUri = Uri.parse(metadata.redirectUris.first); 106 - final oauthClient = OAuthClient(metadata, service: _pendingService!); 114 + log.d('AuthRepository: Loaded client metadata with redirect URIs: ${metadata.redirectUris.join(', ')}'); 115 + final redirectUriTemplate = Uri.parse(metadata.redirectUris.first); 116 + final redirectUri = await _startCallbackServer(redirectUriTemplate); 117 + final oauthClient = OAuthClient( 118 + metadata.copyWith(redirectUris: [redirectUri.toString()]), 119 + service: _pendingService!, 120 + ); 107 121 final (authorizationUrl, context) = await oauthClient.authorize(_pendingHandle); 108 122 109 123 _pendingOAuthClient = oauthClient; 110 124 _pendingOAuthContext = context; 111 - _pendingRedirectUri = redirectUri; 112 - 113 - await _startCallbackServer(redirectUri); 125 + log.i('AuthRepository: OAuth PAR completed, launching browser to ${_sanitizeUriForLog(authorizationUrl)}'); 114 126 await _launchUrl(authorizationUrl); 115 127 116 128 return await _oauthCompleter!.future; 117 - } catch (error) { 129 + } catch (error, stackTrace) { 130 + log.e('AuthRepository: OAuth login failed', error: error, stackTrace: stackTrace); 118 131 await _stopCallbackServer(); 119 132 _resetPendingOAuthState(); 120 133 throw Exception('Failed to login with OAuth: $error'); ··· 123 136 124 137 Future<AuthTokens?> loginWithAppPassword(String handle, String appPassword) async { 125 138 try { 139 + log.i('AuthRepository: Starting app password login for ${handle.trim()}'); 126 140 final service = await _resolveServiceForIdentifier(handle); 141 + log.d('AuthRepository: Resolved app password login service to $service'); 127 142 final session = await atp.createSession(identifier: handle, password: appPassword, service: service); 128 143 129 144 final tokens = AuthTokens( ··· 138 153 ); 139 154 140 155 await saveSession(tokens); 156 + log.i('AuthRepository: App password login succeeded for ${tokens.handle}'); 141 157 return tokens; 142 - } catch (error) { 158 + } catch (error, stackTrace) { 159 + log.e('AuthRepository: App password login failed', error: error, stackTrace: stackTrace); 143 160 throw Exception('Failed to login with app password: $error'); 144 161 } 145 162 } ··· 150 167 } 151 168 152 169 if (currentSession.usesOAuth) { 170 + log.i('AuthRepository: Refreshing OAuth session for ${currentSession.handle}'); 153 171 final publicKey = currentSession.dpopPublicKey; 154 172 final privateKey = currentSession.dpopPrivateKey; 155 173 if (publicKey == null || privateKey == null) { ··· 175 193 ); 176 194 177 195 await saveSession(refreshedTokens); 196 + log.i('AuthRepository: OAuth session refresh succeeded for ${refreshedTokens.handle}'); 178 197 return refreshedTokens; 179 - } catch (error) { 198 + } catch (error, stackTrace) { 199 + log.e('AuthRepository: OAuth session refresh failed', error: error, stackTrace: stackTrace); 180 200 await clearSession(); 181 201 throw Exception('Failed to refresh OAuth session: $error'); 182 202 } 183 203 } 184 204 185 205 try { 206 + log.i('AuthRepository: Refreshing app password session for ${currentSession.handle}'); 186 207 final refreshed = await atp.refreshSession( 187 208 refreshJwt: currentSession.refreshToken!, 188 209 service: currentSession.service, ··· 200 221 ); 201 222 202 223 await saveSession(tokens); 224 + log.i('AuthRepository: App password session refresh succeeded for ${tokens.handle}'); 203 225 return tokens; 204 - } catch (error) { 226 + } catch (error, stackTrace) { 227 + log.e('AuthRepository: App password session refresh failed', error: error, stackTrace: stackTrace); 205 228 await clearSession(); 206 229 throw Exception('Failed to refresh session: $error'); 207 230 } ··· 209 232 210 233 Future<void> logout() async { 211 234 final storedSession = await getStoredSession(); 235 + log.i('AuthRepository: Logging out ${storedSession?.handle ?? 'current user'}'); 212 236 213 237 try { 214 238 if (storedSession?.refreshToken != null && storedSession?.usesOAuth == false) { ··· 216 240 } 217 241 } finally { 218 242 await clearSession(); 243 + log.i('AuthRepository: Logout complete'); 219 244 } 220 245 } 221 246 222 - Future<void> _startCallbackServer(Uri redirectUri) async { 223 - final requestedPort = redirectUri.hasPort ? redirectUri.port : 80; 247 + Future<Uri> _startCallbackServer(Uri redirectUriTemplate) async { 248 + if (!_isSupportedLoopbackRedirect(redirectUriTemplate)) { 249 + throw UnsupportedError( 250 + 'Unsupported OAuth redirect URI: $redirectUriTemplate. ' 251 + 'Lazurite currently supports only loopback HTTP redirects.', 252 + ); 253 + } 254 + 255 + final requestedPort = _requestedCallbackPort(redirectUriTemplate); 256 + log.d( 257 + 'AuthRepository: Binding OAuth callback server to ' 258 + '${InternetAddress.loopbackIPv4.address}:${requestedPort == 0 ? 'ephemeral' : requestedPort} ' 259 + 'for ${redirectUriTemplate.path}', 260 + ); 224 261 225 262 _callbackServer = await HttpServer.bind(InternetAddress.loopbackIPv4, requestedPort); 263 + final redirectUri = redirectUriTemplate.replace( 264 + host: InternetAddress.loopbackIPv4.address, 265 + port: _callbackServer!.port, 266 + ); 267 + log.i('AuthRepository: OAuth callback server listening on ${_sanitizeUriForLog(redirectUri)}'); 226 268 227 269 unawaited( 228 270 _callbackServer!.forEach((request) async { 229 271 final uri = request.requestedUri; 272 + log.d( 273 + 'AuthRepository: OAuth callback request received at ' 274 + '${uri.path} with query keys: ${uri.queryParameters.keys.join(', ')}', 275 + ); 230 276 231 277 if (uri.path != redirectUri.path) { 232 278 request.response.statusCode = HttpStatus.notFound; ··· 240 286 ..write(_callbackPageHtml); 241 287 await request.response.close(); 242 288 243 - final callbackUrl = uri.replace( 244 - scheme: redirectUri.scheme, 245 - host: redirectUri.host, 246 - port: _pendingRedirectUri?.hasPort == true ? _pendingRedirectUri!.port : null, 247 - ); 289 + final callbackUrl = uri.replace(scheme: redirectUri.scheme, host: redirectUri.host, port: redirectUri.port); 248 290 249 291 await _stopCallbackServer(); 250 292 251 293 try { 294 + log.i('AuthRepository: Processing OAuth callback'); 252 295 final tokens = await _handleOAuthCallback(callbackUrl.toString()); 253 296 _oauthCompleter?.complete(tokens); 254 - } catch (error) { 297 + } catch (error, stackTrace) { 298 + log.e('AuthRepository: OAuth callback handling failed', error: error, stackTrace: stackTrace); 255 299 _oauthCompleter?.completeError(error); 256 300 } finally { 257 301 _resetPendingOAuthState(); 258 302 } 259 303 }), 260 304 ); 305 + 306 + return redirectUri; 261 307 } 262 308 263 309 Future<AuthTokens> _handleOAuthCallback(String callbackUrl) async { ··· 270 316 throw StateError('OAuth callback received without an active auth flow'); 271 317 } 272 318 319 + final callbackUri = Uri.parse(callbackUrl); 320 + log.d( 321 + 'AuthRepository: Exchanging OAuth callback for session using ' 322 + '${callbackUri.path} with query keys: ${callbackUri.queryParameters.keys.join(', ')}', 323 + ); 273 324 final oauthSession = await oauthClient.callback(callbackUrl, oauthContext); 325 + log.i('AuthRepository: OAuth token exchange succeeded for DID ${oauthSession.sub}'); 274 326 final tokens = await _buildOAuthTokens(oauthSession, fallbackHandle: fallbackHandle, service: service); 275 327 await saveSession(tokens); 328 + log.i('AuthRepository: OAuth login completed for ${tokens.handle}'); 276 329 return tokens; 277 330 } 278 331 ··· 283 336 }) async { 284 337 var resolvedHandle = fallbackHandle; 285 338 String? displayName; 339 + log.d('AuthRepository: Building OAuth tokens for DID ${session.sub}'); 286 340 287 341 try { 288 342 final authSession = await atp.ATProto.fromOAuthSession(session, service: service).server.getSession(); ··· 318 372 } 319 373 320 374 Future<void> _stopCallbackServer() async { 375 + if (_callbackServer != null) { 376 + log.d('AuthRepository: Stopping OAuth callback server on port ${_callbackServer!.port}'); 377 + } 321 378 await _callbackServer?.close(force: true); 322 379 _callbackServer = null; 323 380 } 324 381 325 382 Future<String> _resolveServiceForIdentifier(String identifier) async { 383 + log.d('AuthRepository: Resolving AT Protocol service for $identifier'); 326 384 final client = atp.ATProto.anonymous(service: _fallbackService); 327 385 328 386 final did = identifier.startsWith('did:') 329 387 ? identifier 330 388 : (await client.identity.resolveHandle(handle: identifier)).data.did; 389 + log.d('AuthRepository: Resolved identifier $identifier to DID $did'); 331 390 332 - final didDoc = (await client.identity.resolveDid(did: did)).data.didDoc; 333 - return _extractServiceEndpoint(didDoc) ?? _fallbackService; 391 + final didDoc = await _resolveDidDocument(did); 392 + final serviceEndpoint = _extractServiceEndpoint(didDoc) ?? _fallbackService; 393 + log.d('AuthRepository: Resolved DID $did to service endpoint $serviceEndpoint'); 394 + return serviceEndpoint; 395 + } 396 + 397 + Future<Map<String, dynamic>> _resolveDidDocument(String did) async { 398 + final uri = _didDocumentUri(did); 399 + log.d('AuthRepository: Fetching DID document from ${_sanitizeUriForLog(uri)}'); 400 + final response = await http.get(uri); 401 + 402 + if (response.statusCode != HttpStatus.ok) { 403 + throw Exception('Failed to resolve DID document for $did: ${response.statusCode}'); 404 + } 405 + 406 + final json = jsonDecode(response.body); 407 + if (json is! Map<String, dynamic>) { 408 + throw Exception('Invalid DID document for $did'); 409 + } 410 + 411 + return json; 412 + } 413 + 414 + Uri _didDocumentUri(String did) { 415 + if (did.startsWith('did:plc:')) { 416 + return Uri.https('plc.directory', '/$did'); 417 + } 418 + 419 + if (did.startsWith('did:web:')) { 420 + final encodedSegments = did.substring('did:web:'.length).split(':'); 421 + if (encodedSegments.isEmpty || encodedSegments.first.isEmpty) { 422 + throw Exception('Invalid did:web identifier: $did'); 423 + } 424 + 425 + final host = Uri.decodeComponent(encodedSegments.first); 426 + final pathSegments = encodedSegments.skip(1).map(Uri.decodeComponent).toList(); 427 + final path = pathSegments.isEmpty ? '/.well-known/did.json' : '/${pathSegments.join('/')}/did.json'; 428 + return Uri.https(host, path); 429 + } 430 + 431 + throw Exception('Unsupported DID method for service resolution: $did'); 334 432 } 335 433 336 434 String? _extractServiceEndpoint(Map<String, dynamic> didDoc) { ··· 358 456 } 359 457 360 458 Future<void> _launchUrl(Uri url) async { 459 + log.d('AuthRepository: Launching external URL ${_sanitizeUriForLog(url)}'); 361 460 if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { 362 461 throw Exception('Could not launch $url'); 363 462 } 364 463 } 365 464 465 + bool _isSupportedLoopbackRedirect(Uri redirectUri) { 466 + return redirectUri.scheme == 'http' && _isLoopbackHost(redirectUri.host); 467 + } 468 + 469 + bool _isLoopbackHost(String host) { 470 + return host == '127.0.0.1' || host == 'localhost'; 471 + } 472 + 473 + int _requestedCallbackPort(Uri redirectUri) { 474 + if (!redirectUri.hasPort) { 475 + return 0; 476 + } 477 + 478 + return redirectUri.port < 1024 ? 0 : redirectUri.port; 479 + } 480 + 481 + String _sanitizeUriForLog(Uri uri) { 482 + return uri.replace(query: null, fragment: null).toString(); 483 + } 484 + 366 485 void _resetPendingOAuthState() { 367 486 _pendingOAuthClient = null; 368 487 _pendingOAuthContext = null; 369 - _pendingRedirectUri = null; 370 488 _pendingHandle = null; 371 489 _pendingService = null; 372 490 }
+31 -28
lib/main.dart
··· 96 96 child: BlocBuilder<AuthBloc, AuthState>( 97 97 builder: (context, authState) { 98 98 final bluesky = _createBluesky(authState); 99 + final appShell = BlocBuilder<SettingsCubit, SettingsState>( 100 + builder: (context, settingsState) { 101 + final themeMode = settingsState.useSystemTheme 102 + ? ThemeMode.system 103 + : (settingsState.themeVariant == AppThemeVariant.light ? ThemeMode.light : ThemeMode.dark); 104 + 105 + final lightTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.light); 106 + final darkTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.dark); 107 + 108 + return MaterialApp.router( 109 + title: 'Lazurite', 110 + debugShowCheckedModeBanner: false, 111 + theme: lightTheme, 112 + darkTheme: darkTheme, 113 + themeMode: themeMode, 114 + routerConfig: AppRouter(authBloc: authBloc, navigatorObserver: _navigatorObserver).router, 115 + ); 116 + }, 117 + ); 118 + 119 + if (bluesky == null) { 120 + return appShell; 121 + } 99 122 100 123 return MultiBlocProvider( 101 124 providers: [ 102 - if (bluesky != null) ...[ 103 - BlocProvider( 104 - create: (_) => ProfileBloc( 105 - profileRepository: ProfileRepository(database: database, bluesky: bluesky), 106 - ), 107 - ), 108 - BlocProvider( 109 - create: (_) => FeedBloc(feedRepository: FeedRepository(bluesky: bluesky)), 125 + BlocProvider( 126 + create: (_) => ProfileBloc( 127 + profileRepository: ProfileRepository(database: database, bluesky: bluesky), 110 128 ), 111 - ], 129 + ), 130 + BlocProvider( 131 + create: (_) => FeedBloc(feedRepository: FeedRepository(bluesky: bluesky)), 132 + ), 112 133 ], 113 - child: BlocBuilder<SettingsCubit, SettingsState>( 114 - builder: (context, settingsState) { 115 - final themeMode = settingsState.useSystemTheme 116 - ? ThemeMode.system 117 - : (settingsState.themeVariant == AppThemeVariant.light ? ThemeMode.light : ThemeMode.dark); 118 - 119 - final lightTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.light); 120 - final darkTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.dark); 121 - 122 - return MaterialApp.router( 123 - title: 'Lazurite', 124 - debugShowCheckedModeBanner: false, 125 - theme: lightTheme, 126 - darkTheme: darkTheme, 127 - themeMode: themeMode, 128 - routerConfig: AppRouter(authBloc: authBloc, navigatorObserver: _navigatorObserver).router, 129 - ); 130 - }, 131 - ), 134 + child: appShell, 132 135 ); 133 136 }, 134 137 ),
+1 -1
pubspec.lock
··· 465 465 source: hosted 466 466 version: "0.2.0" 467 467 http: 468 - dependency: transitive 468 + dependency: "direct main" 469 469 description: 470 470 name: http 471 471 sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+1
pubspec.yaml
··· 30 30 google_fonts: ^6.2.1 31 31 logger: ^2.6.2 32 32 share_plus: ^10.1.4 33 + http: ^1.2.2 33 34 34 35 dev_dependencies: 35 36 flutter_test: