From 4a7ff96a8b687d41bb34946b00711957d6184e08 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 30 Nov 2025 14:51:12 +0800 Subject: [PATCH] :sparkles: Support new local connect auth --- lib/pods/web_auth/web_auth_providers.dart | 53 ++++++ lib/pods/web_auth/web_auth_server.dart | 186 ++++++++++++++++++++++ lib/widgets/app_wrapper.dart | 2 + 3 files changed, 241 insertions(+) create mode 100644 lib/pods/web_auth/web_auth_providers.dart create mode 100644 lib/pods/web_auth/web_auth_server.dart diff --git a/lib/pods/web_auth/web_auth_providers.dart b/lib/pods/web_auth/web_auth_providers.dart new file mode 100644 index 00000000..c7e79283 --- /dev/null +++ b/lib/pods/web_auth/web_auth_providers.dart @@ -0,0 +1,53 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'web_auth_server.dart'; + +class WebAuthServerState { + final bool isRunning; + final int? port; + final Object? error; + + WebAuthServerState({this.isRunning = false, this.port, this.error}); + + WebAuthServerState copyWith({ + bool? isRunning, + int? port, + Object? error, + bool clearError = false, + }) { + return WebAuthServerState( + isRunning: isRunning ?? this.isRunning, + port: port ?? this.port, + error: clearError ? null : error ?? this.error, + ); + } +} + +class WebAuthServerNotifier extends StateNotifier { + final WebAuthServer _server; + + WebAuthServerNotifier(this._server) : super(WebAuthServerState()); + + Future start() async { + try { + final port = await _server.start(); + state = state.copyWith(isRunning: true, port: port, clearError: true); + } catch (e) { + state = state.copyWith(isRunning: false, error: e); + } + } + + void stop() { + _server.stop(); + state = state.copyWith(isRunning: false, port: null); + } +} + +final webAuthServerProvider = Provider((ref) { + return WebAuthServer(ref); +}); + +final webAuthServerStateProvider = + StateNotifierProvider((ref) { + final server = ref.watch(webAuthServerProvider); + return WebAuthServerNotifier(server); +}); diff --git a/lib/pods/web_auth/web_auth_server.dart b/lib/pods/web_auth/web_auth_server.dart new file mode 100644 index 00000000..f7976ee6 --- /dev/null +++ b/lib/pods/web_auth/web_auth_server.dart @@ -0,0 +1,186 @@ +import 'dart:io'; +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/talker.dart'; + +class WebAuthServer { + final Ref _ref; + HttpServer? _server; + String? _challenge; + DateTime? _challengeTimestamp; + + final _challengeTtl = const Duration(seconds: 30); + + WebAuthServer(this._ref); + + Future start() async { + if (_server != null) { + talker.warning('Web auth server already running.'); + return _server!.port; + } + + final port = await _findUnusedPort(40000, 41000); + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + talker.info('Web auth server started on http://127.0.0.1:$port'); + + _server!.listen(_handleRequest); + return port; + } + + void stop() { + _server?.close(force: true); + _server = null; + talker.info('Web auth server stopped.'); + } + + Future _findUnusedPort(int start, int end) async { + for (var port = start; port <= end; port++) { + try { + var socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, port); + await socket.close(); + return port; + } catch (e) { + // Port is in use, try next + } + } + throw Exception('No unused port found in range $start-$end'); + } + + String _generateChallenge() { + final random = Random.secure(); + final values = List.generate(32, (i) => random.nextInt(256)); + return base64Url.encode(values); + } + + void _addCorsHeaders(HttpResponse response) { + const webUrl = 'https://app.solian.fr'; + + response.headers.add('Access-Control-Allow-Origin', webUrl); + response.headers.add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + response.headers.add('Access-Control-Allow-Headers', '*'); + } + + Future _handleRequest(HttpRequest request) async { + try { + _addCorsHeaders(request.response); + + if (request.method == 'OPTIONS') { + request.response.statusCode = HttpStatus.noContent; + await request.response.close(); + return; + } + + talker.info('Web auth request: ${request.method} ${request.uri.path}'); + + if (request.method == 'GET' && request.uri.path == '/alive') { + await _handleAlive(request); + } else if (request.method == 'POST' && request.uri.path == '/exchange') { + await _handleExchange(request); + } else { + request.response.statusCode = HttpStatus.notFound; + request.response.write(jsonEncode({'error': 'Not Found'})); + await request.response.close(); + } + } catch (e, st) { + talker.handle(e, st, 'Error handling web auth request'); + try { + request.response.statusCode = HttpStatus.internalServerError; + request.response.write(jsonEncode({'error': 'Internal Server Error'})); + await request.response.close(); + } catch (e2) { + talker.error('Failed to send error response: $e2'); + } + } + } + + Future _handleAlive(HttpRequest request) async { + _challenge = _generateChallenge(); + _challengeTimestamp = DateTime.now(); + + final response = { + 'status': 'ok', + 'challenge': _challenge, + }; + + request.response.statusCode = HttpStatus.ok; + request.response.headers.contentType = ContentType.json; + request.response.write(jsonEncode(response)); + await request.response.close(); + } + + Future _handleExchange(HttpRequest request) async { + if (_challenge == null || + _challengeTimestamp == null || + DateTime.now().difference(_challengeTimestamp!) > _challengeTtl) { + request.response.statusCode = HttpStatus.badRequest; + request.response.write(jsonEncode({ + 'error': 'Invalid or expired challenge. Please call /alive first.' + })); + await request.response.close(); + return; + } + + final requestBody = await utf8.decodeStream(request); + final Map data; + try { + data = jsonDecode(requestBody); + } catch (e) { + request.response.statusCode = HttpStatus.badRequest; + request.response.write(jsonEncode({'error': 'Invalid JSON body'})); + await request.response.close(); + return; + } + + final String? signedChallenge = data['signedChallenge']; + final Map? deviceInfo = data['deviceInfo']; + + if (signedChallenge == null) { + request.response.statusCode = HttpStatus.badRequest; + request.response.write(jsonEncode({'error': 'Missing signedChallenge'})); + await request.response.close(); + return; + } + + final currentChallenge = _challenge!; + _challenge = null; + _challengeTimestamp = null; + + try { + final dio = _ref.read(apiClientProvider); + + final response = await dio.post( + '/pass/auth/login/session', + data: { + 'signedChallenge': signedChallenge, + 'challenge': currentChallenge, + ...?deviceInfo, + }, + ); + + if (response.statusCode == 200 && response.data != null) { + final webToken = response.data['token']; + request.response.statusCode = HttpStatus.ok; + request.response.write(jsonEncode({'token': webToken})); + } else { + throw Exception( + 'Backend exchange failed with status ${response.statusCode}'); + } + } on DioException catch (e) { + talker.error('Backend exchange failed: ${e.response?.data}'); + request.response.statusCode = + e.response?.statusCode ?? HttpStatus.internalServerError; + request.response.write( + jsonEncode(e.response?.data ?? {'error': 'Backend communication failed'})); + } catch (e, st) { + talker.handle(e, st, 'Error during backend exchange'); + request.response.statusCode = HttpStatus.internalServerError; + request.response + .write(jsonEncode({'error': 'An unexpected error occurred'})); + } finally { + await request.response.close(); + } + } +} diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart index 31cd9309..8db36857 100644 --- a/lib/widgets/app_wrapper.dart +++ b/lib/widgets/app_wrapper.dart @@ -9,6 +9,7 @@ import 'package:island/pods/websocket.dart'; import 'package:island/route.dart'; import 'package:island/screens/tray_manager.dart'; import 'package:island/services/event_bus.dart'; +import 'package:island/pods/web_auth/web_auth_providers.dart'; import 'package:island/services/notify.dart'; import 'package:island/services/sharing_intent.dart'; import 'package:island/services/update_service.dart'; @@ -44,6 +45,7 @@ class _AppWrapperState extends ConsumerState TrayService.instance.initialize(this); ref.read(rpcServerStateProvider.notifier).start(); + ref.read(webAuthServerStateProvider.notifier).start(); final initialUrl = await protocolHandler.getInitialUrl(); if (initialUrl != null && mounted) {