import '../storage/serial_queue.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:app_links/app_links.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../storage/secure_storage.dart'; import 'hosted_environment.dart'; /// Safe to show as a generic retry prompt. Never carries a URL or server text. enum BrowserAuthFailure { restartLogin, cancelled, unavailable, storage } class BrowserAuthCode { const BrowserAuthCode(this.code, this.verifier, this.intent); final String code; final String verifier; final Map intent; } /// One app-scoped owner per account origin. Subscribe to [codes] and /// [failures] before [initialize]. Callbacks bypass the community deep-link /// FIFO. A consumed code is never replayed automatically: exchange failure /// requires a fresh browser login. /// /// Every origin shares the one registered [callback], so an owner leaves a /// callback whose state belongs to another live owner's attempt to that owner. class BrowserAuth { BrowserAuth({ FlutterSecureStorage? storage, Future Function(Uri)? openBrowser, DateTime Function()? now, Uri? origin, }) : _storage = storage ?? accountSecureStorage, _openBrowser = openBrowser ?? _openPlatformBrowser, _now = now ?? DateTime.now, origin = origin ?? HostedEnvironment.origin { _owners.add(this); } static const callback = 'xyz.block.buzz.mobile:/auth/callback'; static final _owners = {}; /// The account origin this owner signs in to. final Uri origin; late final _attemptKey = 'buzz_browser_auth_attempt_v1_${origin.host}'; late final _intentKey = 'buzz_browser_auth_intent_v1_${origin.host}'; static const _channel = MethodChannel('buzz/browser_auth'); final FlutterSecureStorage _storage; final Future Function(Uri) _openBrowser; final DateTime Function() _now; final _codes = StreamController.broadcast(); final _failures = StreamController.broadcast(); Stream get codes => _codes.stream; Stream get failures => _failures.stream; StreamSubscription? _links; final _queue = SerialQueue(); bool _disposed = false; bool _initialized = false; int _generation = 0; String? _activeState; String? _consumedCallbackHash; String? _consumedState; Future _serial(Future Function() task) { final result = _queue.run(() { if (_disposed) throw BrowserAuthFailure.cancelled; return task(); }); return result; } Future initialize() async { if (_initialized || _disposed) return; _initialized = true; final links = AppLinks(); _links = links.uriLinkStream.listen((uri) { if (uri.scheme == 'xyz.block.buzz.mobile') { unawaited(receive(uri).catchError((Object _) {})); } }, onError: (Object _) => _fail(BrowserAuthFailure.unavailable)); try { final initial = await links.getInitialLink(); if (initial != null && initial.scheme == 'xyz.block.buzz.mobile') { await receive(initial); } } catch (_) { _fail(BrowserAuthFailure.unavailable); } } /// Intent is opaque to authentication; callers validate its relay/challenge /// before acting. Retained separately so missing state never loses navigation. Future?> retainedIntent() => _serial(() async { try { final raw = await _storage.read(key: _intentKey); if (raw == null) return null; return Map.from(jsonDecode(raw) as Map); } catch (_) { throw BrowserAuthFailure.storage; } }); /// Persist setup intent before remote work or acknowledging key transfer. Future retainIntent(Map intent) => _serial(() async { final raw = jsonEncode(intent); if (raw.length > 16384) throw BrowserAuthFailure.storage; await _storage.write(key: _intentKey, value: raw); if (await _storage.read(key: _intentKey) != raw) { throw BrowserAuthFailure.storage; } }); Future start(Map intent) async { final generation = ++_generation; final uri = await _serial(() async { if (jsonEncode(intent).length > 16384) throw BrowserAuthFailure.storage; final random = Random.secure(); String secret() => base64UrlEncode( List.generate(32, (_) => random.nextInt(256)), ).replaceAll('=', ''); final verifier = secret(); final state = secret(); _activeState = state; final record = jsonEncode({ 'verifier': verifier, 'state': state, 'created': _now().toUtc().millisecondsSinceEpoch, 'intent': intent, }); try { await _storage.write(key: _intentKey, value: jsonEncode(intent)); await _storage.write(key: _attemptKey, value: record); if (await _storage.read(key: _attemptKey) != record) { throw BrowserAuthFailure.storage; } } catch (_) { throw BrowserAuthFailure.storage; } return origin.replace( path: '/api/goose/v1/auth/login', queryParameters: { 'type': 'cli', 'product': 'buzz', 'returnTo': callback, 'state': state, 'code_challenge': base64UrlEncode( sha256.convert(ascii.encode(verifier)).bytes, ).replaceAll('=', ''), 'code_challenge_method': 'S256', }, ); }); if (_disposed || generation != _generation) return; try { final result = await _openBrowser(uri); if (!_disposed && generation == _generation && result != null) { await receive(Uri.parse(result)); } } on PlatformException catch (error) { if (generation == _generation) { _fail( error.code == 'cancelled' ? BrowserAuthFailure.cancelled : BrowserAuthFailure.unavailable, ); } } catch (_) { if (generation == _generation) _fail(BrowserAuthFailure.unavailable); } } Future receive(Uri uri) { final generation = _generation; return _serial(() async { if (generation != _generation) return; void fail(BrowserAuthFailure failure) { if (generation == _generation) _fail(failure); } // Reject noncanonical destination and duplicate query fields before reading // secrets. Neither invalid state nor malformed callbacks mutate the session. final callbackHash = sha256 .convert(utf8.encode(uri.toString())) .toString(); if (callbackHash == _consumedCallbackHash) return; final query = uri.queryParametersAll; if (uri.toString().split('?').first != callback || uri.hasFragment || query.values.any((v) => v.length != 1) || query.keys.any((k) => k != 'state' && k != 'code' && k != 'error')) { if (_activeState == null) fail(BrowserAuthFailure.restartLogin); return; } try { final saved = await _storage.read(key: _attemptKey); if (saved == null) { if (!await _peerOwns(query['state']?.single)) { fail(BrowserAuthFailure.restartLogin); } return; } final record = jsonDecode(saved) as Map; final expectedState = record['state']; if (expectedState is! String || !RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(expectedState)) { throw BrowserAuthFailure.storage; } // Another owner's callback never expires this owner's attempt. if (query['state']?.single != expectedState) { if (_activeState != expectedState && !await _peerOwns(query['state']?.single)) { fail(BrowserAuthFailure.restartLogin); } return; } final age = _now().toUtc().millisecondsSinceEpoch - (record['created'] as int); if (age < 0 || age > const Duration(minutes: 10).inMilliseconds) { fail(BrowserAuthFailure.restartLogin); return; } if (generation != _generation) return; final code = query['code']?.single; if (query.containsKey('error') || code == null || code.isEmpty || code.length > 4096) { fail(BrowserAuthFailure.restartLogin); return; } final verifier = record['verifier'] as String; if (!RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(verifier)) { throw BrowserAuthFailure.storage; } final intent = Map.from(record['intent'] as Map); // Claimed before the saved attempt disappears, so a peer checking // ownership concurrently never sees neither. _consumedState = expectedState; await _storage.delete(key: _attemptKey); if (await _storage.read(key: _attemptKey) != null) { throw BrowserAuthFailure.storage; } _activeState = null; _consumedCallbackHash = callbackHash; if (!_disposed && generation == _generation) { _codes.add(BrowserAuthCode(code, verifier, Map.unmodifiable(intent))); } } catch (_) { fail(BrowserAuthFailure.storage); } }); } Future cancel() async { ++_generation; _activeState = null; if (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.android) { try { await _channel.invokeMethod('cancel'); } catch (_) { /* No session may be active. */ } } await _delete(_attemptKey); } /// Call only after completing or explicitly abandoning the retained intent. Future clearIntent() => _delete(_intentKey); Future _delete(String key) => _serial(() async { try { await _storage.delete(key: key); } catch (_) { throw BrowserAuthFailure.storage; } }); /// Whether another live owner has a saved attempt with this state. Future _peerOwns(String? state) async { if (state == null) return false; for (final peer in _owners.toList()) { if (identical(peer, this) || peer._disposed) continue; if (peer._activeState == state || peer._consumedState == state) { return true; } try { final saved = await peer._storage.read(key: peer._attemptKey); if (saved != null && (jsonDecode(saved) as Map)['state'] == state) { return true; } // The peer may have consumed its attempt during the read. if (peer._consumedState == state) return true; } catch (_) { // An unreadable peer attempt is that owner's failure to report. } } return false; } void _fail(BrowserAuthFailure failure) { if (!_disposed) _failures.add(failure); } Future dispose() async { _disposed = true; _owners.remove(this); ++_generation; await _links?.cancel(); await _codes.close(); await _failures.close(); } static Future _openPlatformBrowser(Uri uri) async { if (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.android) { return _channel.invokeMethod('start', { 'url': uri.toString(), if (kDebugMode && const bool.fromEnvironment('BUZZ_DEBUG_PRIVATE_LOGIN')) 'ephemeral': 'true', }); } throw BrowserAuthFailure.unavailable; } }