Files
App/lib/pods/web_auth/web_auth_server.dart

187 lines
5.8 KiB
Dart

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();
}
}
}