import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../dto/backend.dart'; import '../dto/results.dart'; import '../mappers/backend.dart'; import '../mappers/results.dart'; import '../models/auth.dart'; import '../models/session.dart'; import '../models/two_factor.dart'; import '../models/verification.dart'; import 'contracts.dart'; import 'guarded_login.dart'; import 'http/client.dart'; import 'two_factor.dart'; import 'verification.dart'; /// Real authentication; wallet/settings operations remain separate adapters. class BackendAuthServices implements PendingLoginService, SignupService, VerificationService, TwoFactorService, SimulationInfo { BackendAuthServices({ required this.baseUri, required this.clientId, http.Client? client, }) : _client = client ?? createClient() { if (clientId.isEmpty || !baseUri.hasAuthority || (baseUri.scheme != 'https' && !(baseUri.scheme == 'http' && ['localhost', '127.0.0.1'].contains(baseUri.host))) || baseUri.hasQuery || baseUri.hasFragment || baseUri.userInfo.isNotEmpty) { throw ArgumentError('Valid API URL and client ID required'); } } final Uri baseUri; final String clientId; final http.Client _client; final String _deviceId = _key(); final _pending = {}; final _grants = {}; final _retryKeys = {}; AuthSession? _session; AuthSession? get session => _session; @override bool get isSimulated => false; static String _key() => base64UrlEncode( List.generate(24, (_) => Random.secure().nextInt(256)), ); Future _post( String path, Map body, { bool factor = false, bool verification = false, }) async { try { final uri = baseUri.replace( path: '${baseUri.path.replaceFirst(RegExp(r'/$'), '')}/accounts/$path', ); final res = await _client .post( uri, headers: { 'Content-Type': 'application/json', if (kIsWeb) 'X-Auth-Transport': 'cookie', if (kIsWeb) 'X-CSRF-Protection': '1', }, body: jsonEncode(body), ) .timeout(const Duration(seconds: 15)); final dto = BackendDTO.decode(res.body); if (res.statusCode < 200 || res.statusCode >= 300) { if (verification) throw BackendMapper.verificationFailure(dto); throw factor ? BackendMapper.factorFailure(res.statusCode, dto) : BackendMapper.authFailure(res.statusCode, dto); } return dto; } on AuthException { rethrow; } on FactorException { rethrow; } on VerificationException { rethrow; } catch (_) { if (verification) throw const VerificationException(VerificationError.internal); if (factor) throw const FactorException(FactorError.unavailable); throw const AuthException(AuthError.unavailable); } } @override Future signup(SignupRequest r) async { final email = r.email.trim().toLowerCase(); final key = _retryKeys.putIfAbsent('signup:$email', _key); final dto = await _post('signup', { 'name': r.name, 'email': email, 'password': r.password, 'locale': r.locale, 'idempotencyKey': key, }); final result = BackendMapper.signup(dto, email: email); _retryKeys.remove('signup:$email'); return result; } @override Future login(LoginRequest r) async { _pending.clear(); _grants.clear(); _session = null; final dto = await _post('login', { 'login': {'login': r.email, 'password': r.password}, 'clientId': clientId, 'deviceId': _deviceId, }); final id = _key(); final challenge = BackendMapper.challenge(dto, id); _pending[id] = (token: dto.pendingToken, challengeId: dto.challengeId); return AuthResult(userId: '', isSimulated: false, challenge: challenge); } @override Future send({ required String email, required String locale, }) async { final normalized = email.trim().toLowerCase(); final key = _retryKeys.putIfAbsent('activation:$normalized', _key); try { await _post('verification/send', { 'email': normalized, 'idempotencyKey': key, }, verification: true); _retryKeys.remove('activation:$normalized'); return VerificationMapper.fromDTO( const VerificationDTO(simulated: false), ); } on VerificationException catch (e) { if (e.code == VerificationError.expired || e.code == VerificationError.invalid) { _retryKeys.remove('activation:$normalized'); } rethrow; } catch (_) { throw const VerificationException(VerificationError.internal); } } @override Future confirm({required String token}) async { final dto = await _post('verification/confirm', { 'token': token, }, verification: true); return BackendMapper.activation(dto); } @override Future resend(String challengeId) async { final pending = _pending[challengeId]; if (pending == null) throw const FactorException(FactorError.expired); final key = _retryKeys.putIfAbsent('login:$challengeId', _key); final dto = await _post('login/resend', { 'pendingToken': pending.token, 'challengeId': pending.challengeId, 'idempotencyKey': key, }, factor: true); final challenge = BackendMapper.challenge(dto, challengeId); if (dto.challengeId != pending.challengeId) { throw const FactorException(FactorError.unavailable); } _retryKeys.remove('login:$challengeId'); return challenge; } @override Future verify(String challengeId, String code) async { final pending = _pending[challengeId]; if (pending == null) throw const FactorException(FactorError.expired); final dto = await _post('login/verify', { 'pendingToken': pending.token, 'challengeId': pending.challengeId, 'code': code, }, factor: true); final session = BackendMapper.session(dto); _pending.remove(challengeId); _grants[challengeId] = session; return FactorGrant(challengeId); } @override Future completeLogin( FactorChallenge challenge, FactorGrant grant, ) async { if (challenge.id != grant.token || challenge.purpose != FactorPurpose.login) { throw const AuthException(AuthError.factorRequired); } final accepted = _grants.remove(grant.token); if (accepted == null) throw const AuthException(AuthError.factorRequired); _session = accepted; return AuthResult(userId: accepted.userId, isSimulated: false); } // Unsupported settings and money operations fail closed until their own // backend adapters exist. They cannot borrow an authentication grant. @override Future fetch() async => throw const FactorException(FactorError.unavailable); @override Future begin( FactorPurpose purpose, String operation, ) async => throw const FactorException(FactorError.unavailable); @override Future authorize( FactorPurpose purpose, String operation, String? grant, ) async => throw const FactorException(FactorError.required); @override Future setEnabled(bool enabled, String grant) async => throw const FactorException(FactorError.unavailable); void close() { _client.close(); _pending.clear(); _grants.clear(); _retryKeys.clear(); _session = null; } }