Skip to content

Commit 135559a

Browse files
draft
1 parent 1b44a6f commit 135559a

File tree

8 files changed

+272
-12
lines changed

8 files changed

+272
-12
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/api/route/realm.dart

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,32 @@ Future<GetServerSettingsResult> getServerSettings({
2828
}
2929
}
3030

31+
@JsonSerializable(fieldRename: FieldRename.snake)
32+
class ExternalAuthenticationMethod {
33+
final String name;
34+
final String displayName;
35+
final String? displayIcon;
36+
final String loginUrl;
37+
final String signupUrl;
38+
39+
ExternalAuthenticationMethod({
40+
required this.name,
41+
required this.displayName,
42+
this.displayIcon,
43+
required this.loginUrl,
44+
required this.signupUrl,
45+
});
46+
47+
factory ExternalAuthenticationMethod.fromJson(Map<String, dynamic> json) =>
48+
_$ExternalAuthenticationMethodFromJson(json);
49+
50+
Map<String, dynamic> toJson() => _$ExternalAuthenticationMethodToJson(this);
51+
}
52+
3153
@JsonSerializable(fieldRename: FieldRename.snake)
3254
class GetServerSettingsResult {
3355
final Map<String, bool> authenticationMethods;
34-
// final List<ExternalAuthenticationMethod> external_authentication_methods; // TODO handle
56+
final List<ExternalAuthenticationMethod> externalAuthenticationMethods;
3557

3658
final int zulipFeatureLevel;
3759
final String zulipVersion;
@@ -44,12 +66,13 @@ class GetServerSettingsResult {
4466
final bool requireEmailFormatUsernames;
4567
final Uri realmUri;
4668
final String realmName;
47-
final String realmIcon;
69+
final Uri? realmIcon;
4870
final String realmDescription;
4971
final bool? realmWebPublicAccessEnabled; // TODO(server-5)
5072

5173
GetServerSettingsResult({
5274
required this.authenticationMethods,
75+
required this.externalAuthenticationMethods,
5376
required this.zulipFeatureLevel,
5477
required this.zulipVersion,
5578
this.zulipMergeBase,

lib/api/route/realm.g.dart

Lines changed: 30 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/model/store.dart

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,18 @@ export 'database.dart' show Account, AccountsCompanion;
3939
/// we use outside of tests.
4040
abstract class GlobalStore extends ChangeNotifier {
4141
GlobalStore({required Iterable<Account> accounts})
42-
: _accounts = Map.fromEntries(accounts.map((a) => MapEntry(a.id, a)));
42+
: _accounts = Map.fromEntries(accounts.map((a) => MapEntry(a.id, a))),
43+
_authOtp = null;
4344

4445
/// A cache of the [Accounts] table in the underlying data store.
4546
final Map<int, Account> _accounts;
4647

4748
// TODO settings (those that are per-device rather than per-account)
4849
// TODO push token, and other data corresponding to GlobalSessionState
4950

51+
/// Temporary `mobile_flow_otp`, that was generated while initiating an auth flow.
52+
String? _authOtp;
53+
5054
final Map<int, PerAccountStore> _perAccountStores = {};
5155
final Map<int, Future<PerAccountStore>> _perAccountStoresLoading = {};
5256

@@ -128,6 +132,9 @@ abstract class GlobalStore extends ChangeNotifier {
128132
/// Add an account to the underlying data store.
129133
Future<Account> doInsertAccount(AccountsCompanion data);
130134

135+
String? getAuthOtp() => _authOtp;
136+
void setAuthOtp(String? otp) => _authOtp = otp;
137+
131138
// More mutators as needed:
132139
// Future<void> updateAccount...
133140
}

lib/widgets/app.dart

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import 'page.dart';
88
import 'recent_dm_conversations.dart';
99
import 'store.dart';
1010

11+
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
12+
1113
class ZulipApp extends StatelessWidget {
1214
const ZulipApp({super.key});
1315

@@ -25,10 +27,27 @@ class ZulipApp extends StatelessWidget {
2527
// https://m3.material.io/theme-builder#/custom
2628
colorScheme: ColorScheme.fromSeed(seedColor: kZulipBrandColor));
2729
return GlobalStoreWidget(
28-
child: MaterialApp(
29-
title: 'Zulip',
30-
theme: theme,
31-
home: const ChooseAccountPage()));
30+
child: Builder(
31+
builder: (context) {
32+
return MaterialApp(
33+
title: 'Zulip',
34+
theme: theme,
35+
home: const ChooseAccountPage(),
36+
navigatorKey: navigatorKey,
37+
// TODO: migrate to `MaterialApp.router`, so that we can receive
38+
// a complete Uri instead of just path+query components.
39+
// See docs:
40+
// https://api.flutter.dev/flutter/widgets/Router-class.html
41+
onGenerateRoute: (settings) {
42+
if (settings.name == null) return null;
43+
if (settings.name!.startsWith('/?otp_encrypted_api_key=')) {
44+
loginFromIncomingRoute(context, Uri.parse(settings.name!));
45+
return null;
46+
}
47+
return null;
48+
});
49+
}
50+
));
3251
}
3352
}
3453

lib/widgets/login.dart

Lines changed: 176 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import 'dart:math';
2+
3+
import 'package:collection/collection.dart';
14
import 'package:flutter/material.dart';
5+
import 'package:url_launcher/url_launcher.dart';
6+
import 'package:convert/convert.dart';
27

38
import '../api/core.dart';
49
import '../api/exception.dart';
@@ -167,9 +172,8 @@ class _AddAccountPageState extends State<AddAccountPage> {
167172
return;
168173
}
169174

170-
// TODO(#36): support login methods beyond username/password
171175
Navigator.push(context,
172-
PasswordLoginPage.buildRoute(serverSettings: serverSettings));
176+
AuthMethodsPage.buildRoute(serverSettings: serverSettings));
173177
} finally {
174178
setState(() {
175179
_inProgress = false;
@@ -225,6 +229,176 @@ class _AddAccountPageState extends State<AddAccountPage> {
225229
}
226230
}
227231

232+
class AuthMethodsPage extends StatefulWidget {
233+
const AuthMethodsPage({super.key, required this.serverSettings});
234+
235+
final GetServerSettingsResult serverSettings;
236+
237+
static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) {
238+
return _LoginSequenceRoute(
239+
page: AuthMethodsPage(serverSettings: serverSettings));
240+
}
241+
242+
@override
243+
State<AuthMethodsPage> createState() => _AuthMethodsPageState();
244+
}
245+
246+
class _AuthMethodsPageState extends State<AuthMethodsPage> {
247+
// TODO: Remove this list when all the methods are tested,
248+
// or update to add a new one.
249+
static const Set<String> testedAuthMethods = {
250+
'github',
251+
};
252+
253+
Future<void> _openBrowserLogin(ExternalAuthenticationMethod method) async {
254+
final otp = _generateMobileFlowOtp();
255+
GlobalStoreWidget.of(context).setAuthOtp(otp);
256+
await launchUrl(
257+
widget.serverSettings.realmUri.replace(
258+
path: method.loginUrl,
259+
queryParameters: {'mobile_flow_otp': otp},
260+
),
261+
mode: LaunchMode.externalApplication,
262+
);
263+
}
264+
265+
@override
266+
Widget build(BuildContext context) {
267+
Uri? iconUrl = switch (widget.serverSettings.realmIcon) {
268+
final Uri realmIcon => realmIcon.hasAuthority
269+
? realmIcon
270+
: widget.serverSettings.realmUri.replace(pathSegments: realmIcon.pathSegments, queryParameters: realmIcon.queryParameters),
271+
null => null,
272+
};
273+
274+
return Scaffold(
275+
appBar: AppBar(title: const Text('Log in')),
276+
body: SafeArea(
277+
child: ListView(
278+
padding: const EdgeInsets.all(8),
279+
children: [
280+
Padding(
281+
padding: const EdgeInsets.only(bottom: 8),
282+
child: Row(
283+
mainAxisAlignment: MainAxisAlignment.center,
284+
children: [
285+
if (iconUrl != null) ...[
286+
Image.network(
287+
iconUrl.toString(),
288+
width: 48,
289+
height: 48),
290+
const SizedBox(width: 8),
291+
],
292+
Text(widget.serverSettings.realmName, style: const TextStyle(fontSize: 20)),
293+
]),
294+
),
295+
if (widget.serverSettings.emailAuthEnabled)
296+
OutlinedButton(
297+
onPressed: () => Navigator.push(context, AuthMethodsPage.buildRoute(serverSettings: widget.serverSettings)),
298+
child: const Text('Sign in with password')),
299+
...widget.serverSettings.externalAuthenticationMethods.map(
300+
(authMethod) => switch (authMethod.displayIcon) {
301+
null || '' => OutlinedButton(
302+
onPressed: testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null,
303+
child: Text('Sign in with ${authMethod.displayName}'),
304+
),
305+
final displayIcon => OutlinedButton.icon(
306+
onPressed: testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null,
307+
icon: Image.network(displayIcon, width: 24, height: 24),
308+
label: Text('Sign in with ${authMethod.displayName}'),
309+
),
310+
}).toList(),
311+
])));
312+
}
313+
}
314+
315+
/// Generates a `mobile_flow_otp` to be used by the server for
316+
/// mobile login flow, server XOR's the api key with the otp hex
317+
/// and returns the resulting value. So, the same otp that was passed
318+
/// to the server can be used again to decode the actual api key.
319+
String _generateMobileFlowOtp() {
320+
final rand = Random.secure();
321+
return hex.encode(List.generate(32, (_) => rand.nextInt(256), growable: false));
322+
}
323+
324+
extension IntListOpXOR on List<int> {
325+
Iterable<int> operator ^(List<int> other) {
326+
if (length != other.length) {
327+
throw ArgumentError('Both lists must have same length');
328+
}
329+
return mapIndexed((i, x) => x ^ other[i]);
330+
}
331+
}
332+
333+
String _decodeApiKey(String otp, String otpEncryptedApiKey) {
334+
final otpHex = hex.decode(otp);
335+
final otpEncryptedApiKeyHex = hex.decode(otpEncryptedApiKey);
336+
return String.fromCharCodes(otpHex ^ otpEncryptedApiKeyHex);
337+
}
338+
339+
Future<void> loginFromIncomingRoute(BuildContext context, Uri uri) async {
340+
final globalStore = GlobalStoreWidget.of(context);
341+
final otp = globalStore.getAuthOtp();
342+
if (otp == null) return;
343+
globalStore.setAuthOtp(null);
344+
345+
final String apiKey;
346+
final String emailId;
347+
final int userId;
348+
final Uri realmUrl;
349+
if (uri.queryParameters case {
350+
'otp_encrypted_api_key' : final String otpEncryptedApiKey,
351+
'email' : final String email,
352+
'user_id' : final String userIdStr,
353+
'realm' : final String realm,
354+
}) {
355+
if (otpEncryptedApiKey.isEmpty || email.isEmpty || userIdStr.isEmpty || realm.isEmpty) {
356+
// TODO: Log error to Sentry
357+
return;
358+
}
359+
realmUrl = Uri.parse(realm);
360+
userId = int.parse(userIdStr);
361+
emailId = email;
362+
apiKey = _decodeApiKey(otp, otpEncryptedApiKey);
363+
} else {
364+
// TODO: Log error to Sentry
365+
return;
366+
}
367+
368+
final GetServerSettingsResult serverSettings;
369+
try {
370+
serverSettings = await getServerSettings(realmUrl: realmUrl, zulipFeatureLevel: null);
371+
} catch (e) {
372+
if (!context.mounted) {
373+
return;
374+
}
375+
// TODO(#105) give more helpful feedback; see `fetchServerSettings`
376+
// in zulip-mobile's src/message/fetchActions.js.
377+
showErrorDialog(context: context,
378+
title: 'Could not connect', message: 'Failed to connect to server:\n$realmUrl');
379+
return;
380+
}
381+
382+
// TODO(#108): give feedback to user on SQL exception, like dupe realm+user
383+
final accountId = await globalStore.insertAccount(AccountsCompanion.insert(
384+
realmUrl: realmUrl,
385+
email: emailId,
386+
apiKey: apiKey,
387+
userId: userId,
388+
zulipFeatureLevel: serverSettings.zulipFeatureLevel,
389+
zulipVersion: serverSettings.zulipVersion,
390+
zulipMergeBase: Value(serverSettings.zulipMergeBase),
391+
));
392+
393+
if (!context.mounted) {
394+
return;
395+
}
396+
navigatorKey.currentState?.pushAndRemoveUntil(
397+
HomePage.buildRoute(accountId: accountId),
398+
(route) => (route is! _LoginSequenceRoute),
399+
);
400+
}
401+
228402
class PasswordLoginPage extends StatefulWidget {
229403
const PasswordLoginPage({super.key, required this.serverSettings});
230404

0 commit comments

Comments
 (0)