Skip to content

Commit

Permalink
login: Implement browser login
Browse files Browse the repository at this point in the history
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 zulip#36
  • Loading branch information
rajveermalviya committed Sep 1, 2023
1 parent fecbcfd commit 42ffaa1
Show file tree
Hide file tree
Showing 6 changed files with 230 additions and 17 deletions.
8 changes: 8 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>

<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="zulip" android:host="login" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
Expand Down
28 changes: 24 additions & 4 deletions lib/widgets/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../model/narrow.dart';
import 'about_zulip.dart';
import 'login.dart';
import 'login/browser_login.dart';
import 'message_list.dart';
import 'page.dart';
import 'recent_dm_conversations.dart';
Expand All @@ -25,10 +26,29 @@ class ZulipApp extends StatelessWidget {
// https://m3.material.io/theme-builder#/custom
colorScheme: ColorScheme.fromSeed(seedColor: kZulipBrandColor));
return GlobalStoreWidget(
child: MaterialApp(
title: 'Zulip',
theme: theme,
home: const ChooseAccountPage()));
child: BrowserLoginWidget(
child: Builder(
builder: (context) => MaterialApp(
title: 'Zulip',
theme: theme,
home: const ChooseAccountPage(),
navigatorKey: BrowserLoginWidget.of(context).navigatorKey,
// TODO: Migrate to `MaterialApp.router` & `Router`, so that we can receive
// a full Uri instead of just path+query components and also maybe
// remove the InheritedWidget + navigatorKey hack.
// See docs:
// https://api.flutter.dev/flutter/widgets/Router-class.html
onGenerateRoute: (settings) {
if (settings.name == null) return null;
final uri = Uri.parse(settings.name!);
if (uri.queryParameters.containsKey('otp_encrypted_api_key')) {
BrowserLoginWidget.of(context).loginFromExternalRoute(context, uri);
return null;
}
return null;
})),
),
);
}
}

Expand Down
17 changes: 10 additions & 7 deletions lib/widgets/login.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import '../model/store.dart';
import 'app.dart';
import 'dialog.dart';
import 'input.dart';
import 'login/browser_login.dart';
import 'page.dart';
import 'store.dart';

class _LoginSequenceRoute extends MaterialWidgetRoute<void> {
_LoginSequenceRoute({
class LoginSequenceRoute extends MaterialWidgetRoute<void> {
LoginSequenceRoute({
required super.page,
});
}
Expand Down Expand Up @@ -102,7 +103,7 @@ class AddAccountPage extends StatefulWidget {
const AddAccountPage({super.key});

static Route<void> buildRoute() {
return _LoginSequenceRoute(page: const AddAccountPage());
return LoginSequenceRoute(page: const AddAccountPage());
}

@override
Expand Down Expand Up @@ -230,7 +231,7 @@ class AuthMethodsPage extends StatefulWidget {
final GetServerSettingsResult serverSettings;

static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) {
return _LoginSequenceRoute(
return LoginSequenceRoute(
page: AuthMethodsPage(serverSettings: serverSettings));
}

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

Future<void> _openBrowserLogin(ExternalAuthenticationMethod method) async {}
Future<void> _openBrowserLogin(ExternalAuthenticationMethod method) =>
BrowserLoginWidget.of(context).openLoginUrl(widget.serverSettings, method.loginUrl);

@override
Widget build(BuildContext context) {
Expand Down Expand Up @@ -304,7 +307,7 @@ class PasswordLoginPage extends StatefulWidget {
final GetServerSettingsResult serverSettings;

static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) {
return _LoginSequenceRoute(
return LoginSequenceRoute(
page: PasswordLoginPage(serverSettings: serverSettings));
}

Expand Down Expand Up @@ -396,7 +399,7 @@ class _PasswordLoginPageState extends State<PasswordLoginPage> {

Navigator.of(context).pushAndRemoveUntil(
HomePage.buildRoute(accountId: accountId),
(route) => (route is! _LoginSequenceRoute),
(route) => (route is! LoginSequenceRoute),
);
} finally {
setState(() {
Expand Down
181 changes: 181 additions & 0 deletions lib/widgets/login/browser_login.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import 'dart:math';
import 'dart:typed_data';

import 'package:convert/convert.dart';
import 'package:drift/drift.dart';
import 'package:flutter/widgets.dart';
import 'package:url_launcher/url_launcher.dart';

import '../../api/route/realm.dart';
import '../../log.dart';
import '../../model/store.dart';
import '../app.dart';
import '../login.dart';
import '../store.dart';

/// An InheritedWidget to co-ordinate the browser auth flow
///
/// The provided [navigatorKey] by this object should be attached to
/// the main app widget so that when the browser redirects to the app
/// using the universal link this widget can use it to access the current
/// navigator instance.
///
/// This object also stores the temporarily generated OTP required for
/// the completion of the flow.
class BrowserLoginWidget extends InheritedWidget {
BrowserLoginWidget({super.key, required super.child});

final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

// TODO: Maybe store these on local DB too, because OS can close the
// app while user is using the browser during the auth flow.

// Temporary mobile_flow_otp, that was generated while initiating a browser auth flow.
final Map<Uri, String> _tempAuthOtp = {};
// Temporary server settngs, that was stored while initiating a browser auth flow.
final Map<Uri, GetServerSettingsResult> _tempServerSettings = {};

@override
bool updateShouldNotify(covariant BrowserLoginWidget oldWidget) =>
!identical(oldWidget.navigatorKey, navigatorKey)
&& !identical(oldWidget._tempAuthOtp, _tempAuthOtp)
&& !identical(oldWidget._tempServerSettings, _tempServerSettings);

static BrowserLoginWidget of(BuildContext context) {
final widget = context.dependOnInheritedWidgetOfExactType<BrowserLoginWidget>();
assert(widget != null, 'No BrowserLogin ancestor');
return widget!;
}

Future<void> openLoginUrl(GetServerSettingsResult serverSettings, String loginUrl) async {
// Generate a temporary otp and store it for later use - for decoding the
// api key returned by server which will be XOR-ed with this otp.
final otp = _generateMobileFlowOtp();
_tempAuthOtp[serverSettings.realmUri] = otp;
_tempServerSettings[serverSettings.realmUri] = serverSettings;

// Open the browser
await launchUrl(serverSettings.realmUri.replace(
path: loginUrl,
queryParameters: {'mobile_flow_otp': otp},
));
}

Future<void> loginFromExternalRoute(BuildContext context, Uri uri) async {
final globalStore = GlobalStoreWidget.of(context);

// Parse the query params from the browser redirect url
final String otpEncryptedApiKey;
final String email;
final int userId;
final Uri realm;
try {
if (uri.queryParameters case {
'otp_encrypted_api_key': final String otpEncryptedApiKeyStr,
'email': final String emailStr,
'user_id': final String userIdStr,
'realm': final String realmStr,
}) {
if (otpEncryptedApiKeyStr.isEmpty || emailStr.isEmpty || userIdStr.isEmpty || realmStr.isEmpty) {
throw 'Got invalid query params from browser redirect url';
}
otpEncryptedApiKey = otpEncryptedApiKeyStr;
realm = Uri.parse(realmStr);
userId = int.parse(userIdStr);
email = emailStr;
} else {
throw 'Got invalid query params from browser redirect url';
}
} catch (e, st) {
// TODO: Log error to Sentry
debugLog('$e\n$st');
return;
}

// Get the previously temporarily stored otp & serverSettings.
final GetServerSettingsResult serverSettings;
final String apiKey;
try {
final otp = _tempAuthOtp[realm];
_tempAuthOtp.clear();
final settings = _tempServerSettings[realm];
_tempServerSettings.clear();
if (otp == null) {
throw 'Failed to find the previously generated mobile_auth_otp';
}
if (settings == null) {
// TODO: Maybe try refetching instead of error-ing out.
throw 'Failed to find the previously stored serverSettings';
}

// Decode the otp XOR-ed api key
apiKey = _decodeApiKey(otp, otpEncryptedApiKey);
serverSettings = settings;
} catch (e, st) {
// TODO: Log error to Sentry
debugLog('$e\n$st');
return;
}

// TODO(#108): give feedback to user on SQL exception, like dupe realm+user
final accountId = await globalStore.insertAccount(AccountsCompanion.insert(
realmUrl: serverSettings.realmUri,
email: email,
apiKey: apiKey,
userId: userId,
zulipFeatureLevel: serverSettings.zulipFeatureLevel,
zulipVersion: serverSettings.zulipVersion,
zulipMergeBase: Value(serverSettings.zulipMergeBase),
));

if (!context.mounted) {
return;
}
navigatorKey.currentState?.pushAndRemoveUntil(
HomePage.buildRoute(accountId: accountId),
(route) => (route is! LoginSequenceRoute),
);
}
}

/// Generates a `mobile_flow_otp` to be used by the server for
/// mobile login flow, server XOR's the api key with the otp hex
/// and returns the resulting value. So, the same otp that was passed
/// to the server can be used again to decode the actual api key.
String _generateMobileFlowOtp() {
final rand = Random.secure();
return hex.encode(rand.nextBytes(32));
}

String _decodeApiKey(String otp, String otpEncryptedApiKey) {
final otpHex = hex.decode(otp);
final otpEncryptedApiKeyHex = hex.decode(otpEncryptedApiKey);
return String.fromCharCodes(otpHex ^ otpEncryptedApiKeyHex);
}

// TODO: Remove this when upstream issue is fixed
// https://github.com/dart-lang/sdk/issues/53339
extension _RandomNextBytes on Random {
static const int _pow2_32 = 0x100000000;
Uint8List nextBytes(int length) {
if ((length % 4) != 0) {
throw ArgumentError('\'length\' must be a multiple of 4');
}
final result = Uint32List(length);
for (int i = 0; i < length; i++) {
result[i] = nextInt(_pow2_32);
}
return result.buffer.asUint8List(0, length);
}
}

extension _IntListOpXOR on List<int> {
Iterable<int> operator ^(List<int> other) sync* {
if (length != other.length) {
throw ArgumentError('Both lists must have the same length');
}
for (var i = 0; i < length; i++) {
yield this[i] ^ other[i];
}
}
}
10 changes: 5 additions & 5 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ packages:
source: hosted
version: "1.18.0"
convert:
dependency: transitive
dependency: "direct main"
description:
name: convert
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
Expand Down Expand Up @@ -921,18 +921,18 @@ packages:
dependency: "direct main"
description:
name: url_launcher
sha256: "781bd58a1eb16069412365c98597726cd8810ae27435f04b3b4d3a470bacd61e"
sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27"
url: "https://pub.dev"
source: hosted
version: "6.1.12"
version: "6.1.14"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "3dd2388cc0c42912eee04434531a26a82512b9cb1827e0214430c9bcbddfe025"
sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330
url: "https://pub.dev"
source: hosted
version: "6.0.38"
version: "6.1.0"
url_launcher_ios:
dependency: transitive
description:
Expand Down
3 changes: 2 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ dependencies:
image_picker: ^1.0.0
package_info_plus: ^4.0.1
collection: ^1.17.2
url_launcher: ^6.1.11
url_launcher: ^6.1.14
convert: ^3.1.1

dev_dependencies:
flutter_test:
Expand Down

0 comments on commit 42ffaa1

Please sign in to comment.