import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:ui' show AppLifecycleState; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart'; void main() { const providerPage = 'https://id.example.org/authorize?client_id=app'; late int port; late String callback; late Completer pageOpened; late int broughtToFront; late LoopbackWebAuth webAuth; /// The loopback part with a fake browser that only records the page it /// opens; [opens] is whether opening it works. LoopbackWebAuth loopback({ bool opens = true, Future Function()? bringToFront, Duration timeout = const Duration(minutes: 1), }) => LoopbackWebAuth( appName: 'My App', launch: (url) { pageOpened.complete(url); return Future.value(opens); }, bringToFront: bringToFront ?? () { broughtToFront++; return Future.value(); }, timeout: timeout, ); setUp(() async { // A port that is free right now, so the tests never meet a running app. final probe = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); port = probe.port; await probe.close(); callback = 'http://127.0.0.1:$port/oidc-callback'; pageOpened = Completer(); broughtToFront = 0; webAuth = loopback(); }); tearDown(() => webAuth.cancel()); /// Starts a sign-in as flutter_web_auth_2 hands it over. Future signIn() => webAuth.authenticate( url: providerPage, callbackUrlScheme: callback, options: const {}, ); /// The browser asking this machine's [address] for [pathAndQuery], as when /// the provider sends it back. Over a raw socket, so that the fake HTTP of /// Flutter's test binding can't get in between. Future<({int status, String body})> browserAsks( String pathAndQuery, { InternetAddress? address, }) async { final socket = await Socket.connect( address ?? InternetAddress.loopbackIPv4, port, timeout: const Duration(seconds: 2), ); socket.write( 'GET $pathAndQuery HTTP/1.1\r\n' 'Host: 127.0.0.1:$port\r\n' 'Connection: close\r\n\r\n', ); final response = await utf8.decoder.bind(socket).join(); socket.destroy(); return ( status: int.parse(response.split(' ')[1]), body: response.substring(response.indexOf('\r\n\r\n') + 4), ); } test( "opens the provider's page and returns where the browser comes back to", () async { final result = signIn(); expect(await pageOpened.future, Uri.parse(providerPage)); final response = await browserAsks('/oidc-callback?code=c&state=s'); expect(await result, '$callback?code=c&state=s'); expect(response.status, HttpStatus.ok); expect(response.body, contains('return to My App')); expect(broughtToFront, 1); }, ); test( "keeps waiting when the app's window regains focus", () async { // The way the bug went: flutter_web_auth_2's front ends every pending // sign-in when the app is resumed, and on the desktop the window // regaining focus resumes it. TestWidgetsFlutterBinding.ensureInitialized(); FlutterWebAuth2Platform.instance = webAuth; final result = FlutterWebAuth2.authenticate( url: providerPage, callbackUrlScheme: callback, ); await pageOpened.future; // The browser takes the focus; then the person looks back at the app. await _embedderReports(AppLifecycleState.inactive); await _embedderReports(AppLifecycleState.resumed); final response = await browserAsks('/oidc-callback?code=c&state=s'); expect(response.status, HttpStatus.ok); expect(await result, '$callback?code=c&state=s'); }, // Elsewhere, flutter_web_auth_2 insists on a URL scheme as the callback. skip: !(Platform.isLinux || Platform.isWindows), ); test('a cancel ends the wait as cancelled and stops listening', () async { final result = signIn(); await pageOpened.future; await webAuth.cancel(); await expectLater(result, throwsA(_cancelled)); await expectLater( browserAsks('/oidc-callback?code=c&state=s'), throwsA(isA()), ); }); test('gives up after the timeout and stops listening', () async { webAuth = loopback(timeout: const Duration(milliseconds: 50)); await expectLater(signIn(), throwsA(isA())); await expectLater( browserAsks('/oidc-callback?code=c&state=s'), throwsA(isA()), ); }); test('a second sign-in ends the first as cancelled', () async { final first = signIn(); await pageOpened.future; pageOpened = Completer(); final second = signIn(); await expectLater(first, throwsA(_cancelled)); await pageOpened.future; await browserAsks('/oidc-callback?code=c&state=s'); expect(await second, '$callback?code=c&state=s'); }); test('answers other paths with "not found" and keeps waiting', () async { final result = signIn(); await pageOpened.future; final stray = await browserAsks('/favicon.ico'); final response = await browserAsks('/oidc-callback?code=c&state=s'); expect(stray.status, HttpStatus.notFound); expect(response.status, HttpStatus.ok); expect(await result, '$callback?code=c&state=s'); }); test('listens on the loopback address only', () async { final otherAddresses = [ for (final interface in await NetworkInterface.list()) for (final address in interface.addresses) if (!address.isLoopback) address, ]; final result = signIn(); await pageOpened.future; for (final address in otherAddresses) { await expectLater( browserAsks('/oidc-callback?code=c&state=s', address: address), throwsA(isA()), reason: '$address', ); } await browserAsks('/oidc-callback?code=c&state=s'); expect(await result, '$callback?code=c&state=s'); }); test("stops listening when the browser can't be opened", () async { webAuth = loopback(opens: false); await expectLater(signIn(), throwsA(isA())); await expectLater( browserAsks('/oidc-callback?code=c&state=s'), throwsA(isA()), ); }); test("signs in even when the window can't come to the front", () async { webAuth = loopback( bringToFront: () => Future.error(MissingPluginException()), ); final result = signIn(); await pageOpened.future; await browserAsks('/oidc-callback?code=c&state=s'); expect(await result, '$callback?code=c&state=s'); }); test( 'refuses a callback other than a path on 127.0.0.1 with a port', () async { for (final address in [ 'http://0.0.0.0:$port/oidc-callback', 'http://localhost:$port/oidc-callback', 'http://example.com:$port/oidc-callback', 'https://127.0.0.1:$port/oidc-callback', 'http://127.0.0.1/oidc-callback', 'http://127.0.0.1:$port', ]) { await expectLater( webAuth.authenticate( url: providerPage, callbackUrlScheme: address, options: const {}, ), throwsArgumentError, reason: address, ); } expect(pageOpened.isCompleted, isFalse); }, ); } /// How flutter_web_auth_2 reports a cancelled sign-in, which is how the /// browser half's callers learn of it. final _cancelled = isA().having( (error) => error.code, 'code', 'CANCELED', ); /// The embedder reporting the app's lifecycle, as it does when the window /// gains or loses focus. Future _embedderReports(AppLifecycleState state) => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( SystemChannels.lifecycle.name, SystemChannels.lifecycle.codec.encodeMessage(state.toString()), (_) {}, );