diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8143ed0..9324fb9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,14 @@ + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index b29a826..24bfae7 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -18,6 +18,19 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + org.stormlightlabs.lazurite.auth + CFBundleURLSchemes + + lazurite + + + CFBundleName lazurite CFBundlePackageType diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 0746dfd..7b650f2 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -6,6 +6,7 @@ import 'package:atproto/atproto.dart' as atp; import 'package:atproto_core/atproto_core.dart' as atcore; import 'package:atproto_oauth/atproto_oauth.dart'; import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; @@ -19,11 +20,13 @@ class AuthRepository { static const String kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; static const String _oauthService = 'bsky.social'; static const String _fallbackService = 'bsky.social'; + static final Uri _appReopenUri = Uri.parse('lazurite://auth-complete'); final AppDatabase _database; HttpServer? _callbackServer; StreamSubscription? _callbackSubscription; + int _callbackServerPort = 0; Completer? _oauthCompleter; OAuthClient? _pendingOAuthClient; OAuthContext? _pendingOAuthContext; @@ -263,6 +266,8 @@ class AuthRepository { ); } + await _stopCallbackServer(); + final requestedPort = _requestedCallbackPort(redirectUriTemplate); log.d( 'AuthRepository: Binding OAuth callback server to ' @@ -270,14 +275,16 @@ class AuthRepository { 'for ${redirectUriTemplate.path}', ); - _callbackServer = await HttpServer.bind(InternetAddress.loopbackIPv4, requestedPort); + final callbackServer = await HttpServer.bind(InternetAddress.loopbackIPv4, requestedPort); + _callbackServer = callbackServer; + _callbackServerPort = callbackServer.port; final redirectUri = redirectUriTemplate.replace( host: InternetAddress.loopbackIPv4.address, - port: _callbackServer!.port, + port: callbackServer.port, ); log.i('AuthRepository: OAuth callback server listening on ${_sanitizeUriForLog(redirectUri)}'); - _callbackSubscription = _callbackServer!.listen( + _callbackSubscription = callbackServer.listen( (request) { unawaited(_handleCallbackRequest(request, redirectUri)); }, @@ -305,12 +312,13 @@ class AuthRepository { await _callbackSubscription?.cancel(); _callbackSubscription = null; - final callbackBody = utf8.encode(_callbackPageHtml); + final callbackPageHtml = buildCallbackPageHtmlForTest(); + final callbackBody = utf8.encode(callbackPageHtml); request.response ..statusCode = HttpStatus.ok ..headers.contentType = ContentType.html ..headers.contentLength = callbackBody.length - ..write(_callbackPageHtml); + ..write(callbackPageHtml); await request.response.close(); final callbackUrl = uri.replace(scheme: redirectUri.scheme, host: redirectUri.host, port: redirectUri.port); @@ -402,13 +410,27 @@ class AuthRepository { } Future _stopCallbackServer() async { - if (_callbackServer != null) { - log.d('AuthRepository: Stopping OAuth callback server on port ${_callbackServer!.port}'); - } - await _callbackSubscription?.cancel(); + final callbackSubscription = _callbackSubscription; + final callbackServer = _callbackServer; + final callbackServerPort = _callbackServerPort; + _callbackSubscription = null; - await _callbackServer?.close(); _callbackServer = null; + _callbackServerPort = 0; + + if (callbackServerPort > 0) { + log.d('AuthRepository: Stopping OAuth callback server on port $callbackServerPort'); + } + await callbackSubscription?.cancel(); + if (callbackServer == null) { + return; + } + + try { + await callbackServer.close(force: true); + } on HttpException catch (error, stackTrace) { + log.w('AuthRepository: OAuth callback server was already closed', error: error, stackTrace: stackTrace); + } } Future _resolveServiceForIdentifier(String identifier) async { @@ -529,10 +551,21 @@ class AuthRepository { _pendingService = null; } - int get callbackPort => _callbackServer?.port ?? 0; + @visibleForTesting + String buildCallbackPageHtmlForTest() => _buildCallbackPageHtml(_appReopenUri); + + @visibleForTesting + Future startCallbackServerForTest(Uri redirectUriTemplate) => _startCallbackServer(redirectUriTemplate); + + @visibleForTesting + Future stopCallbackServerForTest() => _stopCallbackServer(); + + int get callbackPort => _callbackServerPort; } -const String _callbackPageHtml = ''' +String _buildCallbackPageHtml(Uri reopenUri) { + final escapedReopenUrl = HtmlEscape(HtmlEscapeMode.element).convert(reopenUri.toString()); + return ''' @@ -584,14 +617,19 @@ const String _callbackPageHtml = '''

Authentication Complete

-

If this page does not close automatically, switch back to Lazurite.

- +

Lazurite is finishing sign-in. If it does not reopen automatically, tap the button below.

+
'''; +} diff --git a/lib/features/connectivity/presentation/connectivity_banner_host.dart b/lib/features/connectivity/presentation/connectivity_banner_host.dart index d4b38dc..42befc4 100644 --- a/lib/features/connectivity/presentation/connectivity_banner_host.dart +++ b/lib/features/connectivity/presentation/connectivity_banner_host.dart @@ -14,15 +14,13 @@ class ConnectivityBannerHost extends StatelessWidget { return Stack( children: [ Positioned.fill(child: child), - Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - bottom: false, - child: AnimatedSlide( - duration: const Duration(milliseconds: 200), - offset: state.isOffline ? Offset.zero : const Offset(0, -1), + if (state.isOffline) + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, child: IgnorePointer( ignoring: true, child: Padding( @@ -59,7 +57,6 @@ class ConnectivityBannerHost extends StatelessWidget { ), ), ), - ), ], ); }, diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index e2f55cb..df8045c 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -148,5 +148,27 @@ void main() { verify(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).called(1); }); }); + + group('callback server', () { + test('builds a callback page that can return to the app', () { + final html = authRepository.buildCallbackPageHtmlForTest(); + + expect(html, contains('lazurite://auth-complete')); + expect(html, contains('Return to Lazurite')); + }); + + test('can stop the callback server twice without throwing', () async { + final redirectUri = await authRepository.startCallbackServerForTest(Uri.parse('http://127.0.0.1/callback')); + + expect(redirectUri.host, equals('127.0.0.1')); + expect(authRepository.callbackPort, greaterThan(0)); + + await authRepository.stopCallbackServerForTest(); + expect(authRepository.callbackPort, equals(0)); + + await authRepository.stopCallbackServerForTest(); + expect(authRepository.callbackPort, equals(0)); + }); + }); }); }