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.

feat: custom scheme for auth

+209 -26
+8
android/app/src/main/AndroidManifest.xml
··· 37 37 38 38 <data android:scheme="lazurite" android:host="auth-complete" /> 39 39 </intent-filter> 40 + <intent-filter> 41 + <action android:name="android.intent.action.VIEW" /> 42 + 43 + <category android:name="android.intent.category.DEFAULT" /> 44 + <category android:name="android.intent.category.BROWSABLE" /> 45 + 46 + <data android:scheme="org.stormlightlabs.lazurite" android:path="/oauth/callback" /> 47 + </intent-filter> 40 48 </activity> 41 49 <!-- Don't delete the meta-data below. 42 50 This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+1
ios/Runner/Info.plist
··· 29 29 <key>CFBundleURLSchemes</key> 30 30 <array> 31 31 <string>lazurite</string> 32 + <string>org.stormlightlabs.lazurite</string> 32 33 </array> 33 34 </dict> 34 35 </array>
+13 -2
lib/core/router/app_router.dart
··· 15 15 import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; 16 16 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 17 17 import 'package:lazurite/features/auth/presentation/login_screen.dart'; 18 + import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; 18 19 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 19 20 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 20 21 import 'package:lazurite/features/compose/presentation/compose_screen.dart'; ··· 101 102 redirect: (context, state) { 102 103 final isAuthenticated = authBloc.state.isAuthenticated; 103 104 final path = state.uri.path; 104 - final publicPaths = {'/login', '/terms', '/privacy'}; 105 + final publicPaths = {'/login', '/terms', '/privacy', OAuthCallbackScreen.routePath}; 105 106 final isLoggingIn = path == '/login'; 107 + final isOAuthCallback = path == OAuthCallbackScreen.routePath; 106 108 final isPublicPath = publicPaths.contains(path); 107 109 108 110 if (!isAuthenticated && !isPublicPath) { 109 111 return '/login'; 110 112 } 111 113 112 - if (isAuthenticated && isLoggingIn) { 114 + if (isAuthenticated && (isLoggingIn || isOAuthCallback)) { 113 115 return '/'; 114 116 } 115 117 ··· 117 119 }, 118 120 routes: [ 119 121 GoRoute(path: '/login', pageBuilder: (context, state) => _page(context, state, const LoginScreen())), 122 + GoRoute( 123 + path: OAuthCallbackScreen.routePath, 124 + parentNavigatorKey: _rootNavigatorKey, 125 + pageBuilder: (context, state) => _page( 126 + context, 127 + state, 128 + OAuthCallbackScreen(callbackUri: state.uri), 129 + ), 130 + ), 120 131 GoRoute(path: '/terms', pageBuilder: (context, state) => _page(context, state, const TermsOfServiceScreen())), 121 132 GoRoute(path: '/privacy', pageBuilder: (context, state) => _page(context, state, const PrivacyPolicyScreen())), 122 133 GoRoute(path: '/notifications', redirect: (_, _) => '/alerts'),
+2
lib/features/auth/bloc/auth_bloc.dart
··· 19 19 20 20 final AuthRepository _authRepository; 21 21 22 + Future<bool> handleOAuthRedirectUri(Uri uri) => _authRepository.completeOAuthCallbackFromUri(uri); 23 + 22 24 Future<void> _onLoginRequested(LoginRequested event, Emitter<AuthState> emit) async { 23 25 emit(const AuthState.authenticating()); 24 26
+112 -21
lib/features/auth/data/auth_repository.dart
··· 56 56 static const String kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; 57 57 static const String _oauthService = 'bsky.social'; 58 58 static const String _fallbackService = 'bsky.social'; 59 + static const String _mobileOAuthRedirectScheme = 'org.stormlightlabs.lazurite'; 60 + static const String _mobileOAuthRedirectPath = '/oauth/callback'; 61 + static final Uri _mobileOAuthRedirectUri = Uri.parse('$_mobileOAuthRedirectScheme:$_mobileOAuthRedirectPath'); 59 62 static final Uri _appReopenUri = Uri.parse('lazurite://auth-complete'); 60 63 61 64 final AppDatabase _database; ··· 190 193 191 194 final metadata = await _loadClientMetadata(kClientId); 192 195 log.d('AuthRepository: Loaded client metadata with redirect URIs: ${metadata.redirectUris.join(', ')}'); 193 - final redirectUriTemplate = Uri.parse(metadata.redirectUris.first); 194 - final redirectUri = await _startCallbackServer(redirectUriTemplate); 196 + final redirectUriTemplate = _selectOAuthRedirectUriTemplate(metadata.redirectUris); 197 + final usesLoopbackRedirect = _isSupportedLoopbackRedirect(redirectUriTemplate); 198 + final redirectUri = 199 + usesLoopbackRedirect ? await _startCallbackServer(redirectUriTemplate) : redirectUriTemplate; 200 + if (!usesLoopbackRedirect) { 201 + log.i('AuthRepository: Using custom-scheme OAuth callback redirect ${_sanitizeUriForLog(redirectUri)}'); 202 + } 195 203 196 204 Object? lastAttemptError; 197 205 StackTrace? lastAttemptStackTrace; ··· 211 219 log.i('AuthRepository: OAuth PAR completed, launching browser to ${_sanitizeUriForLog(authorizationUrl)}'); 212 220 await _launchUrl(authorizationUrl); 213 221 214 - return await _oauthCompleter!.future; 222 + return await _oauthCompleter!.future.timeout( 223 + const Duration(minutes: 3), 224 + onTimeout: () => throw TimeoutException( 225 + 'Timed out waiting for OAuth callback on ' 226 + '${usesLoopbackRedirect ? 'local loopback listener' : 'custom scheme redirect'}', 227 + ), 228 + ); 215 229 } catch (error, stackTrace) { 216 230 lastAttemptError = error; 217 231 lastAttemptStackTrace = stackTrace; ··· 470 484 471 485 final callbackUrl = uri.replace(scheme: redirectUri.scheme, host: redirectUri.host, port: redirectUri.port); 472 486 473 - try { 474 - log.i('AuthRepository: Processing OAuth callback'); 475 - final tokens = await _handleOAuthCallback(callbackUrl.toString()); 476 - if (_oauthCompleter?.isCompleted == false) { 477 - _oauthCompleter?.complete(tokens); 478 - } 479 - } catch (error, stackTrace) { 480 - log.e('AuthRepository: OAuth callback handling failed', error: error, stackTrace: stackTrace); 481 - if (_oauthCompleter?.isCompleted == false) { 482 - _oauthCompleter?.completeError(error, stackTrace); 483 - } 484 - } finally { 485 - await _stopCallbackServer(); 486 - _resetPendingOAuthState(); 487 - } 487 + await completeOAuthCallbackFromUri(callbackUrl); 488 488 } 489 489 490 490 Future<AuthTokens> _handleOAuthCallback(String callbackUrl) async { ··· 515 515 return tokens; 516 516 } 517 517 518 + Future<bool> completeOAuthCallbackFromUri(Uri callbackUri) async { 519 + final pendingOAuthFlow = 520 + _pendingOAuthClient != null && _pendingOAuthContext != null && _pendingHandle != null && _pendingService != null; 521 + if (!pendingOAuthFlow) { 522 + log.w( 523 + 'AuthRepository: Ignoring OAuth callback without active flow ' 524 + '(${_sanitizeUriForLog(callbackUri)})', 525 + ); 526 + return false; 527 + } 528 + 529 + final normalizedCallbackUri = _normalizeOAuthCallbackUri(callbackUri); 530 + if (normalizedCallbackUri == null) { 531 + log.w('AuthRepository: Ignoring unsupported OAuth callback URI ${_sanitizeUriForLog(callbackUri)}'); 532 + return false; 533 + } 534 + 535 + try { 536 + log.i('AuthRepository: Processing OAuth callback URI ${_sanitizeUriForLog(normalizedCallbackUri)}'); 537 + final tokens = await _handleOAuthCallback(normalizedCallbackUri.toString()); 538 + if (_oauthCompleter?.isCompleted == false) { 539 + _oauthCompleter?.complete(tokens); 540 + } 541 + return true; 542 + } catch (error, stackTrace) { 543 + log.e('AuthRepository: OAuth callback URI handling failed', error: error, stackTrace: stackTrace); 544 + if (_oauthCompleter?.isCompleted == false) { 545 + _oauthCompleter?.completeError(error, stackTrace); 546 + } 547 + return false; 548 + } finally { 549 + await _stopCallbackServer(); 550 + _resetPendingOAuthState(); 551 + } 552 + } 553 + 518 554 Future<AuthTokens> _buildOAuthTokens( 519 555 OAuthSession session, { 520 556 required String fallbackHandle, ··· 815 851 } 816 852 817 853 return switch (platform) { 818 - // Keep Android OAuth in-process so the temporary loopback callback listener 819 - // is not vulnerable to background process reclamation during auth redirects. 820 - TargetPlatform.android => LaunchMode.inAppWebView, 854 + // ATProto OAuth providers can enforce browser-like fetch metadata semantics 855 + // that are not always met by embedded WebViews. Prefer browser tab UX. 856 + TargetPlatform.android => LaunchMode.inAppBrowserView, 821 857 TargetPlatform.iOS => LaunchMode.inAppBrowserView, 822 858 _ => LaunchMode.externalApplication, 823 859 }; ··· 831 867 832 868 bool _isSupportedLoopbackRedirect(Uri redirectUri) { 833 869 return redirectUri.scheme == 'http' && _isLoopbackHost(redirectUri.host); 870 + } 871 + 872 + bool _isSupportedCustomSchemeRedirect(Uri redirectUri) { 873 + return redirectUri.scheme == _mobileOAuthRedirectScheme && redirectUri.path == _mobileOAuthRedirectPath; 874 + } 875 + 876 + Uri? _normalizeOAuthCallbackUri(Uri callbackUri) { 877 + if (_isSupportedLoopbackRedirect(callbackUri) || _isSupportedCustomSchemeRedirect(callbackUri)) { 878 + return callbackUri; 879 + } 880 + 881 + if (!callbackUri.hasScheme && callbackUri.path == _mobileOAuthRedirectPath) { 882 + return Uri( 883 + scheme: _mobileOAuthRedirectScheme, 884 + path: callbackUri.path, 885 + query: callbackUri.hasQuery ? callbackUri.query : null, 886 + fragment: callbackUri.hasFragment ? callbackUri.fragment : null, 887 + ); 888 + } 889 + 890 + return null; 891 + } 892 + 893 + Uri _selectOAuthRedirectUriTemplate(List<String> redirectUris) { 894 + final candidates = redirectUris.map(Uri.parse).toList(growable: false); 895 + if (candidates.isEmpty) { 896 + throw UnsupportedError('OAuth client metadata does not declare any redirect URIs.'); 897 + } 898 + 899 + final prefersCustomScheme = 900 + !kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); 901 + if (prefersCustomScheme) { 902 + for (final candidate in candidates) { 903 + if (_isSupportedCustomSchemeRedirect(candidate)) { 904 + return candidate; 905 + } 906 + } 907 + } 908 + 909 + for (final candidate in candidates) { 910 + if (_isSupportedLoopbackRedirect(candidate)) { 911 + return candidate; 912 + } 913 + } 914 + 915 + if (prefersCustomScheme) { 916 + throw UnsupportedError( 917 + 'No supported OAuth redirect URI found. Mobile builds require ' 918 + '${_mobileOAuthRedirectUri.toString()} or an HTTP loopback redirect.', 919 + ); 920 + } 921 + 922 + throw UnsupportedError( 923 + 'No supported OAuth redirect URI found. Supported redirects are HTTP loopback callbacks.', 924 + ); 834 925 } 835 926 836 927 bool _isLoopbackHost(String host) {
+70
lib/features/auth/presentation/oauth_callback_screen.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:go_router/go_router.dart'; 6 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 7 + 8 + class OAuthCallbackScreen extends StatefulWidget { 9 + const OAuthCallbackScreen({required this.callbackUri, super.key}); 10 + 11 + static const String routePath = '/oauth/callback'; 12 + 13 + final Uri callbackUri; 14 + 15 + @override 16 + State<OAuthCallbackScreen> createState() => _OAuthCallbackScreenState(); 17 + } 18 + 19 + class _OAuthCallbackScreenState extends State<OAuthCallbackScreen> { 20 + bool _submitted = false; 21 + 22 + @override 23 + void initState() { 24 + super.initState(); 25 + unawaited(_consumeCallback()); 26 + } 27 + 28 + Future<void> _consumeCallback() async { 29 + final handled = await context.read<AuthBloc>().handleOAuthRedirectUri(widget.callbackUri); 30 + if (!mounted) { 31 + return; 32 + } 33 + 34 + if (!handled) { 35 + _submitted = true; 36 + context.go('/login'); 37 + return; 38 + } 39 + 40 + _submitted = true; 41 + context.go('/login'); 42 + } 43 + 44 + @override 45 + Widget build(BuildContext context) { 46 + final busy = !_submitted; 47 + return Scaffold( 48 + body: Center( 49 + child: Padding( 50 + padding: const EdgeInsets.all(24), 51 + child: Column( 52 + mainAxisSize: MainAxisSize.min, 53 + children: [ 54 + if (busy) 55 + const SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2.4)) 56 + else 57 + const Icon(Icons.check_circle_outline, size: 30), 58 + const SizedBox(height: 12), 59 + Text( 60 + busy ? 'Finalizing sign in...' : 'Returning to Lazurite...', 61 + style: Theme.of(context).textTheme.titleMedium, 62 + textAlign: TextAlign.center, 63 + ), 64 + ], 65 + ), 66 + ), 67 + ), 68 + ); 69 + } 70 + }
+2 -2
test/features/auth/data/auth_repository_test.dart
··· 374 374 ); 375 375 }); 376 376 377 - test('uses in-app web view on Android', () { 377 + test('uses in-app browser view on Android', () { 378 378 expect( 379 379 AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.android), 380 - equals(LaunchMode.inAppWebView), 380 + equals(LaunchMode.inAppBrowserView), 381 381 ); 382 382 }); 383 383
+1 -1
www/client-metadata.json
··· 2 2 "client_id": "https://lazurite.stormlightlabs.org/client-metadata.json", 3 3 "client_name": "Lazurite", 4 4 "client_uri": "https://lazurite.stormlightlabs.org", 5 - "redirect_uris": ["http://127.0.0.1/callback"], 5 + "redirect_uris": ["org.stormlightlabs.lazurite:/oauth/callback", "http://127.0.0.1/callback"], 6 6 "scope": "atproto transition:generic transition:chat.bsky", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"],