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.

refactor: remove localhost fallback

+9 -290
+7 -263
lib/features/auth/data/auth_repository.dart
··· 59 59 static const String _mobileOAuthRedirectScheme = 'org.stormlightlabs.lazurite'; 60 60 static const String _mobileOAuthRedirectPath = '/oauth/callback'; 61 61 static final Uri _mobileOAuthRedirectUri = Uri.parse('$_mobileOAuthRedirectScheme:$_mobileOAuthRedirectPath'); 62 - static final Uri _appReopenUri = Uri.parse('lazurite://auth-complete'); 63 62 64 63 final AppDatabase _database; 65 64 final LaunchUrlWithMode _launchUrlWithMode; ··· 73 72 final Future<String> Function(String handle)? _resolveHandleDid; 74 73 final Future<Map<String, dynamic>> Function(String did)? _resolveDidDocumentOverride; 75 74 76 - HttpServer? _callbackServer; 77 - StreamSubscription<HttpRequest>? _callbackSubscription; 78 - int _callbackServerPort = 0; 79 75 Completer<AuthTokens?>? _oauthCompleter; 80 76 OAuthClient? _pendingOAuthClient; 81 77 OAuthContext? _pendingOAuthContext; ··· 193 189 194 190 final metadata = await _loadClientMetadata(kClientId); 195 191 log.d('AuthRepository: Loaded client metadata with redirect URIs: ${metadata.redirectUris.join(', ')}'); 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 - } 192 + final redirectUri = _selectOAuthRedirectUriTemplate(metadata.redirectUris); 193 + log.i('AuthRepository: Using custom-scheme OAuth callback redirect ${_sanitizeUriForLog(redirectUri)}'); 203 194 204 195 Object? lastAttemptError; 205 196 StackTrace? lastAttemptStackTrace; ··· 222 213 return await _oauthCompleter!.future.timeout( 223 214 const Duration(minutes: 3), 224 215 onTimeout: () => throw TimeoutException( 225 - 'Timed out waiting for OAuth callback on ' 226 - '${usesLoopbackRedirect ? 'local loopback listener' : 'custom scheme redirect'}', 216 + 'Timed out waiting for OAuth callback on custom scheme redirect', 227 217 ), 228 218 ); 229 219 } catch (error, stackTrace) { ··· 248 238 ); 249 239 } catch (error, stackTrace) { 250 240 log.e('AuthRepository: OAuth login failed', error: error, stackTrace: stackTrace); 251 - await _stopCallbackServer(); 252 241 _resetPendingOAuthState(); 253 242 throw Exception('Failed to login with OAuth: $error'); 254 243 } finally { ··· 419 408 } 420 409 } 421 410 422 - Future<Uri> _startCallbackServer(Uri redirectUriTemplate) async { 423 - if (!_isSupportedLoopbackRedirect(redirectUriTemplate)) { 424 - throw UnsupportedError( 425 - 'Unsupported OAuth redirect URI: $redirectUriTemplate. ' 426 - 'Lazurite currently supports only loopback HTTP redirects.', 427 - ); 428 - } 429 - 430 - await _stopCallbackServer(); 431 - 432 - final requestedPort = _requestedCallbackPort(redirectUriTemplate); 433 - log.d( 434 - 'AuthRepository: Binding OAuth callback server to ' 435 - '${InternetAddress.loopbackIPv4.address}:${requestedPort == 0 ? 'ephemeral' : requestedPort} ' 436 - 'for ${redirectUriTemplate.path}', 437 - ); 438 - 439 - final callbackServer = await HttpServer.bind(InternetAddress.loopbackIPv4, requestedPort); 440 - _callbackServer = callbackServer; 441 - _callbackServerPort = callbackServer.port; 442 - final redirectUri = redirectUriTemplate.replace( 443 - host: InternetAddress.loopbackIPv4.address, 444 - port: callbackServer.port, 445 - ); 446 - log.i('AuthRepository: OAuth callback server listening on ${_sanitizeUriForLog(redirectUri)}'); 447 - 448 - _callbackSubscription = callbackServer.listen( 449 - (request) { 450 - unawaited(_handleCallbackRequest(request, redirectUri)); 451 - }, 452 - onError: (Object error, StackTrace stackTrace) { 453 - log.e('AuthRepository: OAuth callback server stream failed', error: error, stackTrace: stackTrace); 454 - }, 455 - ); 456 - 457 - return redirectUri; 458 - } 459 - 460 - Future<void> _handleCallbackRequest(HttpRequest request, Uri redirectUri) async { 461 - final uri = request.requestedUri; 462 - log.d( 463 - 'AuthRepository: OAuth callback request received at ' 464 - '${uri.path} with query keys: ${uri.queryParameters.keys.join(', ')}', 465 - ); 466 - 467 - if (uri.path != redirectUri.path) { 468 - request.response.statusCode = HttpStatus.notFound; 469 - await request.response.close(); 470 - return; 471 - } 472 - 473 - await _callbackSubscription?.cancel(); 474 - _callbackSubscription = null; 475 - 476 - final callbackPageHtml = buildCallbackPageHtmlForTest(); 477 - final callbackBody = utf8.encode(callbackPageHtml); 478 - request.response 479 - ..statusCode = HttpStatus.ok 480 - ..headers.contentType = ContentType.html 481 - ..headers.contentLength = callbackBody.length 482 - ..write(callbackPageHtml); 483 - await request.response.close(); 484 - 485 - final callbackUrl = uri.replace(scheme: redirectUri.scheme, host: redirectUri.host, port: redirectUri.port); 486 - 487 - await completeOAuthCallbackFromUri(callbackUrl); 488 - } 489 - 490 411 Future<AuthTokens> _handleOAuthCallback(String callbackUrl) async { 491 412 final oauthClient = _pendingOAuthClient; 492 413 final oauthContext = _pendingOAuthContext; ··· 546 467 } 547 468 return false; 548 469 } finally { 549 - await _stopCallbackServer(); 550 470 _resetPendingOAuthState(); 551 471 } 552 472 } ··· 602 522 dpopPrivateKey: session.$privateKey, 603 523 authMethod: AuthMethod.oauth, 604 524 ); 605 - } 606 - 607 - Future<void> _stopCallbackServer() async { 608 - final callbackSubscription = _callbackSubscription; 609 - final callbackServer = _callbackServer; 610 - final callbackServerPort = _callbackServerPort; 611 - 612 - _callbackSubscription = null; 613 - _callbackServer = null; 614 - _callbackServerPort = 0; 615 - 616 - if (callbackServerPort > 0) { 617 - log.d('AuthRepository: Stopping OAuth callback server on port $callbackServerPort'); 618 - } 619 - await callbackSubscription?.cancel(); 620 - if (callbackServer == null) { 621 - return; 622 - } 623 - 624 - try { 625 - await callbackServer.close(force: true); 626 - } on HttpException catch (error, stackTrace) { 627 - log.w('AuthRepository: OAuth callback server was already closed', error: error, stackTrace: stackTrace); 628 - } 629 525 } 630 526 631 527 Future<String> _resolveServiceForIdentifier(String identifier) async { ··· 865 761 await _dismissOAuthBrowserIfNeeded(); 866 762 } 867 763 868 - bool _isSupportedLoopbackRedirect(Uri redirectUri) { 869 - return redirectUri.scheme == 'http' && _isLoopbackHost(redirectUri.host); 870 - } 871 - 872 764 bool _isSupportedCustomSchemeRedirect(Uri redirectUri) { 873 765 return redirectUri.scheme == _mobileOAuthRedirectScheme && redirectUri.path == _mobileOAuthRedirectPath; 874 766 } 875 767 876 768 Uri? _normalizeOAuthCallbackUri(Uri callbackUri) { 877 - if (_isSupportedLoopbackRedirect(callbackUri) || _isSupportedCustomSchemeRedirect(callbackUri)) { 769 + if (_isSupportedCustomSchemeRedirect(callbackUri)) { 878 770 return callbackUri; 879 771 } 880 772 ··· 896 788 throw UnsupportedError('OAuth client metadata does not declare any redirect URIs.'); 897 789 } 898 790 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 791 for (final candidate in candidates) { 910 - if (_isSupportedLoopbackRedirect(candidate)) { 792 + if (_isSupportedCustomSchemeRedirect(candidate)) { 911 793 return candidate; 912 794 } 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 795 } 921 796 922 797 throw UnsupportedError( 923 - 'No supported OAuth redirect URI found. Supported redirects are HTTP loopback callbacks.', 798 + 'No supported OAuth redirect URI found. Lazurite currently requires ' 799 + '${_mobileOAuthRedirectUri.toString()}.', 924 800 ); 925 - } 926 - 927 - bool _isLoopbackHost(String host) { 928 - return host == '127.0.0.1' || host == 'localhost'; 929 - } 930 - 931 - int _requestedCallbackPort(Uri redirectUri) { 932 - if (!redirectUri.hasPort) { 933 - return 0; 934 - } 935 - 936 - return redirectUri.port < 1024 ? 0 : redirectUri.port; 937 801 } 938 802 939 803 String _sanitizeUriForLog(Uri uri) { ··· 955 819 _pendingService = null; 956 820 _oauthLaunchMode = null; 957 821 } 958 - 959 - @visibleForTesting 960 - String buildCallbackPageHtmlForTest() => _buildCallbackPageHtml(_appReopenUri); 961 822 962 823 OAuthSession _restoreOAuthSession({ 963 824 required AuthTokens currentSession, ··· 1071 932 } 1072 933 1073 934 @visibleForTesting 1074 - Future<Uri> startCallbackServerForTest(Uri redirectUriTemplate) => _startCallbackServer(redirectUriTemplate); 1075 - 1076 - @visibleForTesting 1077 - Future<void> stopCallbackServerForTest() => _stopCallbackServer(); 1078 - 1079 - @visibleForTesting 1080 935 Future<String> resolveServiceForIdentifierForTest(String identifier) => _resolveServiceForIdentifier(identifier); 1081 - 1082 - int get callbackPort => _callbackServerPort; 1083 - } 1084 - 1085 - String _buildCallbackPageHtml(Uri reopenUri) { 1086 - final escapedReopenUrl = const HtmlEscape(HtmlEscapeMode.element).convert(reopenUri.toString()); 1087 - return ''' 1088 - <!DOCTYPE html> 1089 - <html lang="en"> 1090 - <head> 1091 - <meta charset="UTF-8" /> 1092 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 1093 - <title>Lazurite Authentication Complete</title> 1094 - <style> 1095 - body { 1096 - align-items: center; 1097 - background: #161616; 1098 - color: #f2f4f8; 1099 - display: flex; 1100 - font-family: system-ui, sans-serif; 1101 - justify-content: center; 1102 - margin: 0; 1103 - min-height: 100vh; 1104 - padding: 24px; 1105 - text-align: center; 1106 - } 1107 - 1108 - main { 1109 - max-width: 420px; 1110 - } 1111 - 1112 - h1 { 1113 - font-size: 28px; 1114 - margin-bottom: 12px; 1115 - } 1116 - 1117 - p { 1118 - color: #dde1e6; 1119 - line-height: 1.5; 1120 - margin: 0 0 12px; 1121 - } 1122 - 1123 - .button-link { 1124 - appearance: none; 1125 - background: #0f62fe; 1126 - border: 0; 1127 - border-radius: 999px; 1128 - color: white; 1129 - cursor: pointer; 1130 - display: inline-block; 1131 - font: inherit; 1132 - font-weight: 600; 1133 - padding: 12px 20px; 1134 - } 1135 - 1136 - a { text-decoration: none; } 1137 - </style> 1138 - </head> 1139 - <body> 1140 - <main> 1141 - <h1>Authentication Complete</h1> 1142 - <p>Lazurite is finishing sign-in. If it does not reopen automatically, tap the button below.</p> 1143 - <a id="reopen-link" class="button-link" href="$escapedReopenUrl">Return to Lazurite</a> 1144 - </main> 1145 - <iframe 1146 - id="reopen-frame" 1147 - title="Return to Lazurite" 1148 - style="display: none; width: 0; height: 0; border: 0" 1149 - ></iframe> 1150 - <script> 1151 - const reopenUrl = '$escapedReopenUrl'; 1152 - let reopenAttempts = 0; 1153 - 1154 - function attemptReopen() { 1155 - reopenAttempts += 1; 1156 - 1157 - const frame = document.getElementById('reopen-frame'); 1158 - if (frame) { 1159 - frame.src = reopenUrl; 1160 - } 1161 - 1162 - const link = document.getElementById('reopen-link'); 1163 - if (link && reopenAttempts === 1) { 1164 - link.click(); 1165 - } 1166 - 1167 - if (reopenAttempts === 1) { 1168 - window.location.assign(reopenUrl); 1169 - return; 1170 - } 1171 - 1172 - window.location.href = reopenUrl; 1173 - } 1174 - 1175 - window.addEventListener('load', function () { 1176 - window.setTimeout(attemptReopen, 120); 1177 - window.setTimeout(attemptReopen, 480); 1178 - window.setTimeout(attemptReopen, 1000); 1179 - }); 1180 - 1181 - document.addEventListener('visibilitychange', function () { 1182 - if (document.visibilityState === 'hidden') { 1183 - window.setTimeout(function () { 1184 - window.close(); 1185 - }, 300); 1186 - } 1187 - }); 1188 - </script> 1189 - </body> 1190 - </html> 1191 - '''; 1192 936 }
+1 -26
test/features/auth/data/auth_repository_test.dart
··· 341 341 }); 342 342 }); 343 343 344 - group('callback server', () { 345 - test('builds a callback page that can return to the app', () { 346 - final html = authRepository.buildCallbackPageHtmlForTest(); 347 - 348 - expect(html, contains('lazurite://auth-complete')); 349 - expect(html, contains('Return to Lazurite')); 350 - expect(html, contains('id="reopen-link"')); 351 - expect(html, contains('window.location.assign')); 352 - expect(html, contains('visibilitychange')); 353 - }); 354 - 355 - test('can stop the callback server twice without throwing', () async { 356 - final redirectUri = await authRepository.startCallbackServerForTest(Uri.parse('http://127.0.0.1/callback')); 357 - 358 - expect(redirectUri.host, equals('127.0.0.1')); 359 - expect(authRepository.callbackPort, greaterThan(0)); 360 - 361 - await authRepository.stopCallbackServerForTest(); 362 - expect(authRepository.callbackPort, equals(0)); 363 - 364 - await authRepository.stopCallbackServerForTest(); 365 - expect(authRepository.callbackPort, equals(0)); 366 - }); 367 - }); 368 - 369 344 group('oauth browser launch mode', () { 370 345 test('uses in-app browser view on iOS', () { 371 346 expect( ··· 452 427 applicationType: 'native', 453 428 clientName: 'Lazurite Test', 454 429 clientUri: 'https://lazurite.stormlightlabs.org', 455 - redirectUris: ['http://127.0.0.1/callback'], 430 + redirectUris: ['org.stormlightlabs.lazurite:/oauth/callback'], 456 431 responseTypes: ['code'], 457 432 grantTypes: ['authorization_code', 'refresh_token'], 458 433 scope: 'atproto',
+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": ["org.stormlightlabs.lazurite:/oauth/callback", "http://127.0.0.1/callback"], 5 + "redirect_uris": ["org.stormlightlabs.lazurite:/oauth/callback"], 6 6 "scope": "atproto transition:generic transition:chat.bsky", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"],