From b16fb7cb0bce47e07878aa48b0d29c0226409156 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 17 Oct 2026 09:47:23 +0100 Subject: [PATCH] fix(flutter): sign in with OAuth2 on Windows and Linux through a loopback token callback flutter_web_auth_2 only accepts an http://localhost:{port} callback on Windows and Linux, so the appwrite-callback scheme failed outright. A localhost success URL got past that, but the server only adds key and secret to its own default success page, so the session could never reach the app. The token flow returns userId and secret to any allowed success URL, so desktop now opens the token endpoint on a loopback callback (a free port when no success URL is given) and exchanges the result with createSession. Saving the mobile session also waited on init() without waiting for an init already in flight, and did not await the cookie write. --- src/SDK/Language/Flutter.php | 5 + templates/flutter/lib/src/client_io.dart.twig | 169 ++++++++++++------ .../flutter/test/src/client_io_test.dart.twig | 157 ++++++++++++++++ 3 files changed, 277 insertions(+), 54 deletions(-) create mode 100644 templates/flutter/test/src/client_io_test.dart.twig 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']); + }); +}