diff --git a/src/SDK/Language/Flutter.php b/src/SDK/Language/Flutter.php index 9d6205ed6d..b691b37552 100644 --- a/src/SDK/Language/Flutter.php +++ b/src/SDK/Language/Flutter.php @@ -438,6 +438,11 @@ public function getFiles(): array 'destination' => '/test/channel_test.dart', 'template' => 'flutter/test/src/channel_test.dart.twig', ], + [ + 'scope' => 'default', + 'destination' => '/test/src/client_io_test.dart', + 'template' => 'flutter/test/src/client_io_test.dart.twig', + ], [ 'scope' => 'default', 'destination' => '/test/src/cookie_manager_test.dart', diff --git a/templates/flutter/lib/src/client_io.dart.twig b/templates/flutter/lib/src/client_io.dart.twig index a1643b7aa8..cf2cf26303 100644 --- a/templates/flutter/lib/src/client_io.dart.twig +++ b/templates/flutter/lib/src/client_io.dart.twig @@ -204,6 +204,15 @@ class ClientIO extends ClientBase with ClientMixin { _initProgress = false; } + Future _ready() async { + while (!_initialized && _initProgress) { + await Future.delayed(Duration(milliseconds: 10)); + } + if (!_initialized) { + await init(); + } + } + Future _interceptRequest(http.BaseRequest request) async { final body = (request is http.Request) ? request.body : ''; for (final i in _interceptors) { @@ -442,59 +451,116 @@ class ClientIO extends ClientBase with ClientMixin { return finalResponse ?? lastResponse; } - bool get _customSchemeAllowed => Platform.isWindows || Platform.isLinux; + bool get _loopbackCallback => + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.linux; @override - Future webAuth(Uri url, {String? callbackUrlScheme}) { - return FlutterWebAuth2.authenticate( - url: url.toString(), - callbackUrlScheme: callbackUrlScheme != null && _customSchemeAllowed - ? callbackUrlScheme - : "{{spec.info.title | caseLower}}-callback-${config['project']!}", - options: const FlutterWebAuth2Options( - useWebview: false, + Future webAuth(Uri url, {String? callbackUrlScheme}) async { + if (!_loopbackCallback) { + return _saveSession( + await _authenticate( + url, + "{{spec.info.title | caseLower}}-callback-${config['project']!}", + ), + ); + } + + final callback = callbackUrlScheme ?? await _loopbackUrl(); + final exchange = url.path.contains(_sessionOAuth2Path); + final result = await _authenticate( + url.replace( + path: exchange + ? url.path.replaceFirst(_sessionOAuth2Path, _tokenOAuth2Path) + : url.path, + queryParameters: { + ...url.queryParametersAll, + 'success': [callback], + 'failure': url.queryParametersAll['failure'] ?? [callback], + }, ), - ).then((value) async { - Uri url = Uri.parse(value); - final key = url.queryParameters['key']; - final secret = url.queryParameters['secret']; - if (key == null || secret == null) { - final error = url.queryParameters['error']; - if (error != null && error.isNotEmpty) { - try { - final parsed = jsonDecode(error); - if (parsed is Map) { - final codeValue = parsed['code']; - final int? code = codeValue is int - ? codeValue - : int.tryParse('${codeValue ?? ''}'); - throw {{spec.info.title | caseUcfirst}}Exception( - parsed['message']?.toString() ?? error, - code, - parsed['type']?.toString(), - error, - ); - } - } on {{spec.info.title | caseUcfirst}}Exception { - rethrow; - } catch (_) { - // Fall through to a plain-string error when JSON is malformed. - } - throw {{spec.info.title | caseUcfirst}}Exception(error); + callback, + ); + if (!exchange) { + return _saveSession(result); + } + + final userId = result.queryParameters['userId']; + final secret = result.queryParameters['secret']; + if (userId == null || secret == null) { + _throwCallbackError(result); + } + await call( + HttpMethod.post, + path: _tokenSessionPath, + params: {'userId': userId, 'secret': secret}, + ); + } + + static const String _sessionOAuth2Path = '/account/sessions/oauth2/'; + static const String _tokenOAuth2Path = '/account/tokens/oauth2/'; + static const String _tokenSessionPath = '/account/sessions/token'; + + Future _loopbackUrl() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = socket.port; + await socket.close(); + return 'http://localhost:$port/'; + } + + Future _authenticate(Uri url, String callbackUrlScheme) async { + return Uri.parse( + await FlutterWebAuth2.authenticate( + url: url.toString(), + callbackUrlScheme: callbackUrlScheme, + options: const FlutterWebAuth2Options(useWebview: false), + ), + ); + } + + Future _saveSession(Uri url) async { + final key = url.queryParameters['key']; + final secret = url.queryParameters['secret']; + if (key == null || secret == null) { + _throwCallbackError(url); + } + Cookie cookie = Cookie(key, secret); + cookie.domain = Uri.parse(_endPoint).host; + cookie.httpOnly = true; + cookie.path = '/'; + List cookies = [cookie]; + await _ready(); + await _cookieJar.saveFromResponse(Uri.parse(_endPoint), cookies); + } + + Never _throwCallbackError(Uri url) { + final error = url.queryParameters['error']; + if (error != null && error.isNotEmpty) { + try { + final parsed = jsonDecode(error); + if (parsed is Map) { + final codeValue = parsed['code']; + final int? code = codeValue is int + ? codeValue + : int.tryParse('${codeValue ?? ''}'); + throw {{spec.info.title | caseUcfirst}}Exception( + parsed['message']?.toString() ?? error, + code, + parsed['type']?.toString(), + error, + ); } - throw {{spec.info.title | caseUcfirst}}Exception( - "Invalid OAuth2 Response. Key and Secret not available.", - 500, - ); + } on {{spec.info.title | caseUcfirst}}Exception { + rethrow; + } catch (_) { + // Fall through to a plain-string error when JSON is malformed. } - Cookie cookie = Cookie(key, secret); - cookie.domain = Uri.parse(_endPoint).host; - cookie.httpOnly = true; - cookie.path = '/'; - List cookies = [cookie]; - await init(); - _cookieJar.saveFromResponse(Uri.parse(_endPoint), cookies); - }); + throw {{spec.info.title | caseUcfirst}}Exception(error); + } + throw {{spec.info.title | caseUcfirst}}Exception( + "Invalid OAuth2 Response. Key and Secret not available.", + 500, + ); } @override @@ -505,12 +571,7 @@ class ClientIO extends ClientBase with ClientMixin { Map params = const {}, ResponseType? responseType, }) async { - while (!_initialized && _initProgress) { - await Future.delayed(Duration(milliseconds: 10)); - } - if (!_initialized) { - await init(); - } + await _ready(); late http.Response res; http.BaseRequest request = prepareRequest( diff --git a/templates/flutter/test/src/client_io_test.dart.twig b/templates/flutter/test/src/client_io_test.dart.twig new file mode 100644 index 0000000000..4bca664364 --- /dev/null +++ b/templates/flutter/test/src/client_io_test.dart.twig @@ -0,0 +1,157 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:{{ language.params.packageName }}/src/client_io.dart'; +import 'package:{{ language.params.packageName }}/src/exception.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + late HttpServer server; + late Directory documents; + late ClientIO client; + late List> authentications; + late List requests; + late String Function(String callback) respond; + + setUp(() async { + HttpOverrides.global = null; + documents = await Directory.systemTemp.createTemp(); + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => documents.path, + ); + authentications = []; + messenger.setMockMethodCallHandler( + const MethodChannel('flutter_web_auth_2'), + (call) async { + final arguments = Map.from(call.arguments as Map); + authentications.add(arguments); + return respond(arguments['callbackUrlScheme'] as String); + }, + ); + + requests = []; + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) async { + requests.add( + '${request.method} ${request.uri.path} ${await utf8.decoder.bind(request).join()}', + ); + request.response + ..headers.contentType = ContentType.json + ..cookies.add(Cookie('a_session_project', 'session')) + ..write('{}'); + await request.response.close(); + }); + + client = ClientIO(endPoint: 'http://127.0.0.1:${server.port}/v1'); + client.config['project'] = 'project'; + }); + + tearDown(() async { + debugDefaultTargetPlatformOverride = null; + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + null, + ); + messenger.setMockMethodCallHandler( + const MethodChannel('flutter_web_auth_2'), + null, + ); + await server.close(force: true); + await documents.delete(recursive: true); + }); + + Uri authorization() => Uri.parse( + 'http://127.0.0.1:${server.port}/v1/account/sessions/oauth2/github?scopes%5B%5D=user&scopes%5B%5D=repo&project=project', + ); + + Future> cookies() async => + (await client.cookieJar.loadForRequest( + Uri.parse('http://127.0.0.1:${server.port}/v1'), + )).map((cookie) => '${cookie.name}=${cookie.value}').toList(); + + for (final platform in [TargetPlatform.linux, TargetPlatform.windows]) { + group('webAuth on ${platform.name}', () { + setUp(() => debugDefaultTargetPlatformOverride = platform); + + test('signs in through a loopback token callback', () async { + respond = (callback) => '$callback?userId=user&secret=token'; + + await client.webAuth(authorization()); + + final callback = authentications.single['callbackUrlScheme'] as String; + expect(callback, matches(RegExp(r'^http://localhost:\d+/$'))); + final url = Uri.parse(authentications.single['url'] as String); + expect(url.path, '/v1/account/tokens/oauth2/github'); + expect(url.queryParametersAll['scopes[]'], ['user', 'repo']); + expect(url.queryParameters['project'], 'project'); + expect(url.queryParameters['success'], callback); + expect(url.queryParameters['failure'], callback); + expect(requests, [ + 'POST /v1/account/sessions/token {"userId":"user","secret":"token"}', + ]); + expect(await cookies(), ['a_session_project=session']); + }); + + test('keeps the success and failure URLs it is given', () async { + respond = (callback) => '$callback?userId=user&secret=token'; + + await client.webAuth( + authorization().replace( + queryParameters: { + ...authorization().queryParametersAll, + 'success': 'http://localhost:4000/auth', + 'failure': 'http://localhost:4000/failed', + }, + ), + callbackUrlScheme: 'http://localhost:4000/auth', + ); + + expect( + authentications.single['callbackUrlScheme'], + 'http://localhost:4000/auth', + ); + final url = Uri.parse(authentications.single['url'] as String); + expect(url.queryParameters['success'], 'http://localhost:4000/auth'); + expect(url.queryParameters['failure'], 'http://localhost:4000/failed'); + expect(requests, hasLength(1)); + }); + + test('throws the error the callback carries', () async { + respond = (callback) => + '$callback?error=${Uri.encodeComponent('{"message":"Denied","code":401,"type":"user_oauth2_unauthorized"}')}'; + + await expectLater( + client.webAuth(authorization()), + throwsA( + isA<{{ spec.info.title | caseUcfirst }}Exception>() + .having((error) => error.message, 'message', 'Denied') + .having((error) => error.code, 'code', 401), + ), + ); + expect(requests, isEmpty); + }); + }); + } + + test('webAuth on android saves the session from the app callback', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + respond = (callback) => '$callback://?key=a_session_project&secret=session'; + + await client.webAuth(authorization()); + + expect( + authentications.single['callbackUrlScheme'], + '{{ spec.info.title | caseLower }}-callback-project', + ); + expect(authentications.single['url'], authorization().toString()); + expect(requests, isEmpty); + expect(await cookies(), ['a_session_project=session']); + }); +}