|
| 1 | +import 'dart:math'; |
| 2 | +import 'dart:typed_data'; |
| 3 | + |
| 4 | +import 'package:convert/convert.dart'; |
| 5 | +import 'package:drift/drift.dart'; |
| 6 | +import 'package:flutter/widgets.dart'; |
| 7 | +import 'package:url_launcher/url_launcher.dart'; |
| 8 | + |
| 9 | +import '../../api/route/realm.dart'; |
| 10 | +import '../../log.dart'; |
| 11 | +import '../../model/store.dart'; |
| 12 | +import '../app.dart'; |
| 13 | +import '../login.dart'; |
| 14 | +import '../store.dart'; |
| 15 | + |
| 16 | +/// An InheritedWidget to co-ordinate the browser auth flow |
| 17 | +/// |
| 18 | +/// The provided [navigatorKey] by this object should be attached to |
| 19 | +/// the main app widget so that when the browser redirects to the app |
| 20 | +/// using the universal link this widget can use it to access the current |
| 21 | +/// navigator instance. |
| 22 | +/// |
| 23 | +/// This object also stores the temporarily generated OTP required for |
| 24 | +/// the completion of the flow. |
| 25 | +class BrowserLoginWidget extends InheritedWidget { |
| 26 | + BrowserLoginWidget({super.key, required super.child}); |
| 27 | + |
| 28 | + final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); |
| 29 | + |
| 30 | + // TODO: Maybe store these on local DB too, because OS can close the |
| 31 | + // app while user is using the browser during the auth flow. |
| 32 | + |
| 33 | + // Temporary mobile_flow_otp, that was generated while initiating a browser auth flow. |
| 34 | + final Map<Uri, String> _tempAuthOtp = {}; |
| 35 | + // Temporary server settngs, that was stored while initiating a browser auth flow. |
| 36 | + final Map<Uri, GetServerSettingsResult> _tempServerSettings = {}; |
| 37 | + |
| 38 | + @override |
| 39 | + bool updateShouldNotify(covariant BrowserLoginWidget oldWidget) => |
| 40 | + !identical(oldWidget.navigatorKey, navigatorKey) |
| 41 | + && !identical(oldWidget._tempAuthOtp, _tempAuthOtp) |
| 42 | + && !identical(oldWidget._tempServerSettings, _tempServerSettings); |
| 43 | + |
| 44 | + static BrowserLoginWidget of(BuildContext context) { |
| 45 | + final widget = context.dependOnInheritedWidgetOfExactType<BrowserLoginWidget>(); |
| 46 | + assert(widget != null, 'No BrowserLogin ancestor'); |
| 47 | + return widget!; |
| 48 | + } |
| 49 | + |
| 50 | + Future<void> openLoginUrl(GetServerSettingsResult serverSettings, String loginUrl) async { |
| 51 | + // Generate a temporary otp and store it for later use - for decoding the |
| 52 | + // api key returned by server which will be XOR-ed with this otp. |
| 53 | + final otp = _generateMobileFlowOtp(); |
| 54 | + _tempAuthOtp[serverSettings.realmUri] = otp; |
| 55 | + _tempServerSettings[serverSettings.realmUri] = serverSettings; |
| 56 | + |
| 57 | + // Open the browser |
| 58 | + await launchUrl(serverSettings.realmUri.replace( |
| 59 | + path: loginUrl, |
| 60 | + queryParameters: {'mobile_flow_otp': otp}, |
| 61 | + )); |
| 62 | + } |
| 63 | + |
| 64 | + Future<void> loginFromExternalRoute(BuildContext context, Uri uri) async { |
| 65 | + final globalStore = GlobalStoreWidget.of(context); |
| 66 | + |
| 67 | + // Parse the query params from the browser redirect url |
| 68 | + final String otpEncryptedApiKey; |
| 69 | + final String email; |
| 70 | + final int userId; |
| 71 | + final Uri realm; |
| 72 | + try { |
| 73 | + if (uri.queryParameters case { |
| 74 | + 'otp_encrypted_api_key': final String otpEncryptedApiKeyStr, |
| 75 | + 'email': final String emailStr, |
| 76 | + 'user_id': final String userIdStr, |
| 77 | + 'realm': final String realmStr, |
| 78 | + }) { |
| 79 | + if (otpEncryptedApiKeyStr.isEmpty || emailStr.isEmpty || userIdStr.isEmpty || realmStr.isEmpty) { |
| 80 | + throw 'Got invalid query params from browser redirect url'; |
| 81 | + } |
| 82 | + otpEncryptedApiKey = otpEncryptedApiKeyStr; |
| 83 | + realm = Uri.parse(realmStr); |
| 84 | + userId = int.parse(userIdStr); |
| 85 | + email = emailStr; |
| 86 | + } else { |
| 87 | + throw 'Got invalid query params from browser redirect url'; |
| 88 | + } |
| 89 | + } catch (e, st) { |
| 90 | + // TODO: Log error to Sentry |
| 91 | + debugLog('$e\n$st'); |
| 92 | + return; |
| 93 | + } |
| 94 | + |
| 95 | + // Get the previously temporarily stored otp & serverSettings. |
| 96 | + final GetServerSettingsResult serverSettings; |
| 97 | + final String apiKey; |
| 98 | + try { |
| 99 | + final otp = _tempAuthOtp[realm]; |
| 100 | + _tempAuthOtp.clear(); |
| 101 | + final settings = _tempServerSettings[realm]; |
| 102 | + _tempServerSettings.clear(); |
| 103 | + if (otp == null) { |
| 104 | + throw 'Failed to find the previously generated mobile_auth_otp'; |
| 105 | + } |
| 106 | + if (settings == null) { |
| 107 | + // TODO: Maybe try refetching instead of error-ing out. |
| 108 | + throw 'Failed to find the previously stored serverSettings'; |
| 109 | + } |
| 110 | + |
| 111 | + // Decode the otp XOR-ed api key |
| 112 | + apiKey = _decodeApiKey(otp, otpEncryptedApiKey); |
| 113 | + serverSettings = settings; |
| 114 | + } catch (e, st) { |
| 115 | + // TODO: Log error to Sentry |
| 116 | + debugLog('$e\n$st'); |
| 117 | + return; |
| 118 | + } |
| 119 | + |
| 120 | + // TODO(#108): give feedback to user on SQL exception, like dupe realm+user |
| 121 | + final accountId = await globalStore.insertAccount(AccountsCompanion.insert( |
| 122 | + realmUrl: serverSettings.realmUri, |
| 123 | + email: email, |
| 124 | + apiKey: apiKey, |
| 125 | + userId: userId, |
| 126 | + zulipFeatureLevel: serverSettings.zulipFeatureLevel, |
| 127 | + zulipVersion: serverSettings.zulipVersion, |
| 128 | + zulipMergeBase: Value(serverSettings.zulipMergeBase), |
| 129 | + )); |
| 130 | + |
| 131 | + if (!context.mounted) { |
| 132 | + return; |
| 133 | + } |
| 134 | + navigatorKey.currentState?.pushAndRemoveUntil( |
| 135 | + HomePage.buildRoute(accountId: accountId), |
| 136 | + (route) => (route is! LoginSequenceRoute), |
| 137 | + ); |
| 138 | + } |
| 139 | +} |
| 140 | + |
| 141 | +/// Generates a `mobile_flow_otp` to be used by the server for |
| 142 | +/// mobile login flow, server XOR's the api key with the otp hex |
| 143 | +/// and returns the resulting value. So, the same otp that was passed |
| 144 | +/// to the server can be used again to decode the actual api key. |
| 145 | +String _generateMobileFlowOtp() { |
| 146 | + final rand = Random.secure(); |
| 147 | + return hex.encode(rand.nextBytes(32)); |
| 148 | +} |
| 149 | + |
| 150 | +String _decodeApiKey(String otp, String otpEncryptedApiKey) { |
| 151 | + final otpHex = hex.decode(otp); |
| 152 | + final otpEncryptedApiKeyHex = hex.decode(otpEncryptedApiKey); |
| 153 | + return String.fromCharCodes(otpHex ^ otpEncryptedApiKeyHex); |
| 154 | +} |
| 155 | + |
| 156 | +// TODO: Remove this when upstream issue is fixed |
| 157 | +// https://github.com/dart-lang/sdk/issues/53339 |
| 158 | +extension _RandomNextBytes on Random { |
| 159 | + static const int _pow2_32 = 0x100000000; |
| 160 | + Uint8List nextBytes(int length) { |
| 161 | + if ((length % 4) != 0) { |
| 162 | + throw ArgumentError('\'length\' must be a multiple of 4'); |
| 163 | + } |
| 164 | + final result = Uint32List(length); |
| 165 | + for (int i = 0; i < length; i++) { |
| 166 | + result[i] = nextInt(_pow2_32); |
| 167 | + } |
| 168 | + return result.buffer.asUint8List(0, length); |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +extension _IntListOpXOR on List<int> { |
| 173 | + Iterable<int> operator ^(List<int> other) sync* { |
| 174 | + if (length != other.length) { |
| 175 | + throw ArgumentError('Both lists must have the same length'); |
| 176 | + } |
| 177 | + for (var i = 0; i < length; i++) { |
| 178 | + yield this[i] ^ other[i]; |
| 179 | + } |
| 180 | + } |
| 181 | +} |
0 commit comments