Skip to content

Commit 42ffaa1

Browse files
login: Implement browser login
Sets up the Flutter deeplink support for android in order to capture the browser redirect. Adds the BrowserLoginWidget which is used to co-ordinate the auth flow, storing the otp temporarily, and finally handling the browser redirect to complete the auth flow. Fixes #36
1 parent fecbcfd commit 42ffaa1

File tree

6 files changed

+230
-17
lines changed

6 files changed

+230
-17
lines changed

android/app/src/main/AndroidManifest.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
<action android:name="android.intent.action.MAIN"/>
2626
<category android:name="android.intent.category.LAUNCHER"/>
2727
</intent-filter>
28+
29+
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
30+
<intent-filter>
31+
<action android:name="android.intent.action.VIEW" />
32+
<category android:name="android.intent.category.DEFAULT" />
33+
<category android:name="android.intent.category.BROWSABLE" />
34+
<data android:scheme="zulip" android:host="login" />
35+
</intent-filter>
2836
</activity>
2937
<!-- Don't delete the meta-data below.
3038
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

lib/widgets/app.dart

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
33
import '../model/narrow.dart';
44
import 'about_zulip.dart';
55
import 'login.dart';
6+
import 'login/browser_login.dart';
67
import 'message_list.dart';
78
import 'page.dart';
89
import 'recent_dm_conversations.dart';
@@ -25,10 +26,29 @@ class ZulipApp extends StatelessWidget {
2526
// https://m3.material.io/theme-builder#/custom
2627
colorScheme: ColorScheme.fromSeed(seedColor: kZulipBrandColor));
2728
return GlobalStoreWidget(
28-
child: MaterialApp(
29-
title: 'Zulip',
30-
theme: theme,
31-
home: const ChooseAccountPage()));
29+
child: BrowserLoginWidget(
30+
child: Builder(
31+
builder: (context) => MaterialApp(
32+
title: 'Zulip',
33+
theme: theme,
34+
home: const ChooseAccountPage(),
35+
navigatorKey: BrowserLoginWidget.of(context).navigatorKey,
36+
// TODO: Migrate to `MaterialApp.router` & `Router`, so that we can receive
37+
// a full Uri instead of just path+query components and also maybe
38+
// remove the InheritedWidget + navigatorKey hack.
39+
// See docs:
40+
// https://api.flutter.dev/flutter/widgets/Router-class.html
41+
onGenerateRoute: (settings) {
42+
if (settings.name == null) return null;
43+
final uri = Uri.parse(settings.name!);
44+
if (uri.queryParameters.containsKey('otp_encrypted_api_key')) {
45+
BrowserLoginWidget.of(context).loginFromExternalRoute(context, uri);
46+
return null;
47+
}
48+
return null;
49+
})),
50+
),
51+
);
3252
}
3353
}
3454

lib/widgets/login.dart

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import '../model/store.dart';
99
import 'app.dart';
1010
import 'dialog.dart';
1111
import 'input.dart';
12+
import 'login/browser_login.dart';
1213
import 'page.dart';
1314
import 'store.dart';
1415

15-
class _LoginSequenceRoute extends MaterialWidgetRoute<void> {
16-
_LoginSequenceRoute({
16+
class LoginSequenceRoute extends MaterialWidgetRoute<void> {
17+
LoginSequenceRoute({
1718
required super.page,
1819
});
1920
}
@@ -102,7 +103,7 @@ class AddAccountPage extends StatefulWidget {
102103
const AddAccountPage({super.key});
103104

104105
static Route<void> buildRoute() {
105-
return _LoginSequenceRoute(page: const AddAccountPage());
106+
return LoginSequenceRoute(page: const AddAccountPage());
106107
}
107108

108109
@override
@@ -230,7 +231,7 @@ class AuthMethodsPage extends StatefulWidget {
230231
final GetServerSettingsResult serverSettings;
231232

232233
static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) {
233-
return _LoginSequenceRoute(
234+
return LoginSequenceRoute(
234235
page: AuthMethodsPage(serverSettings: serverSettings));
235236
}
236237

@@ -243,10 +244,12 @@ class _AuthMethodsPageState extends State<AuthMethodsPage> {
243244
// or update to add a new method.
244245
static const Set<String> _testedAuthMethods = {
245246
'github',
247+
'gitlab',
246248
'google',
247249
};
248250

249-
Future<void> _openBrowserLogin(ExternalAuthenticationMethod method) async {}
251+
Future<void> _openBrowserLogin(ExternalAuthenticationMethod method) =>
252+
BrowserLoginWidget.of(context).openLoginUrl(widget.serverSettings, method.loginUrl);
250253

251254
@override
252255
Widget build(BuildContext context) {
@@ -304,7 +307,7 @@ class PasswordLoginPage extends StatefulWidget {
304307
final GetServerSettingsResult serverSettings;
305308

306309
static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) {
307-
return _LoginSequenceRoute(
310+
return LoginSequenceRoute(
308311
page: PasswordLoginPage(serverSettings: serverSettings));
309312
}
310313

@@ -396,7 +399,7 @@ class _PasswordLoginPageState extends State<PasswordLoginPage> {
396399

397400
Navigator.of(context).pushAndRemoveUntil(
398401
HomePage.buildRoute(accountId: accountId),
399-
(route) => (route is! _LoginSequenceRoute),
402+
(route) => (route is! LoginSequenceRoute),
400403
);
401404
} finally {
402405
setState(() {

lib/widgets/login/browser_login.dart

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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+
}

pubspec.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ packages:
186186
source: hosted
187187
version: "1.18.0"
188188
convert:
189-
dependency: transitive
189+
dependency: "direct main"
190190
description:
191191
name: convert
192192
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
@@ -921,18 +921,18 @@ packages:
921921
dependency: "direct main"
922922
description:
923923
name: url_launcher
924-
sha256: "781bd58a1eb16069412365c98597726cd8810ae27435f04b3b4d3a470bacd61e"
924+
sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27"
925925
url: "https://pub.dev"
926926
source: hosted
927-
version: "6.1.12"
927+
version: "6.1.14"
928928
url_launcher_android:
929929
dependency: transitive
930930
description:
931931
name: url_launcher_android
932-
sha256: "3dd2388cc0c42912eee04434531a26a82512b9cb1827e0214430c9bcbddfe025"
932+
sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330
933933
url: "https://pub.dev"
934934
source: hosted
935-
version: "6.0.38"
935+
version: "6.1.0"
936936
url_launcher_ios:
937937
dependency: transitive
938938
description:

pubspec.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ dependencies:
5656
image_picker: ^1.0.0
5757
package_info_plus: ^4.0.1
5858
collection: ^1.17.2
59-
url_launcher: ^6.1.11
59+
url_launcher: ^6.1.14
60+
convert: ^3.1.1
6061

6162
dev_dependencies:
6263
flutter_test:

0 commit comments

Comments
 (0)