✨ Support new local connect auth
This commit is contained in:
53
lib/pods/web_auth/web_auth_providers.dart
Normal file
53
lib/pods/web_auth/web_auth_providers.dart
Normal file
@@ -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<WebAuthServerState> {
|
||||
final WebAuthServer _server;
|
||||
|
||||
WebAuthServerNotifier(this._server) : super(WebAuthServerState());
|
||||
|
||||
Future<void> 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<WebAuthServer>((ref) {
|
||||
return WebAuthServer(ref);
|
||||
});
|
||||
|
||||
final webAuthServerStateProvider =
|
||||
StateNotifierProvider<WebAuthServerNotifier, WebAuthServerState>((ref) {
|
||||
final server = ref.watch(webAuthServerProvider);
|
||||
return WebAuthServerNotifier(server);
|
||||
});
|
||||
186
lib/pods/web_auth/web_auth_server.dart
Normal file
186
lib/pods/web_auth/web_auth_server.dart
Normal file
@@ -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<int> 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<int> _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<int>.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<void> _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<void> _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<void> _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<String, dynamic> 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<String, dynamic>? 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AppWrapper>
|
||||
TrayService.instance.initialize(this);
|
||||
|
||||
ref.read(rpcServerStateProvider.notifier).start();
|
||||
ref.read(webAuthServerStateProvider.notifier).start();
|
||||
|
||||
final initialUrl = await protocolHandler.getInitialUrl();
|
||||
if (initialUrl != null && mounted) {
|
||||
|
||||
Reference in New Issue
Block a user