diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 91d594f30d..3b32727aeb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,14 @@ + + + + + + + + diff --git a/lib/api/route/realm.dart b/lib/api/route/realm.dart index 890709316e..7b6f9c2b7f 100644 --- a/lib/api/route/realm.dart +++ b/lib/api/route/realm.dart @@ -28,10 +28,32 @@ Future getServerSettings({ } } +@JsonSerializable(fieldRename: FieldRename.snake) +class ExternalAuthenticationMethod { + final String name; + final String displayName; + final String? displayIcon; + final String loginUrl; + final String signupUrl; + + ExternalAuthenticationMethod({ + required this.name, + required this.displayName, + this.displayIcon, + required this.loginUrl, + required this.signupUrl, + }); + + factory ExternalAuthenticationMethod.fromJson(Map json) => + _$ExternalAuthenticationMethodFromJson(json); + + Map toJson() => _$ExternalAuthenticationMethodToJson(this); +} + @JsonSerializable(fieldRename: FieldRename.snake) class GetServerSettingsResult { final Map authenticationMethods; - // final List external_authentication_methods; // TODO handle + final List externalAuthenticationMethods; final int zulipFeatureLevel; final String zulipVersion; @@ -44,12 +66,13 @@ class GetServerSettingsResult { final bool requireEmailFormatUsernames; final Uri realmUri; final String realmName; - final String realmIcon; + final Uri? realmIcon; final String realmDescription; final bool? realmWebPublicAccessEnabled; // TODO(server-5) GetServerSettingsResult({ required this.authenticationMethods, + required this.externalAuthenticationMethods, required this.zulipFeatureLevel, required this.zulipVersion, this.zulipMergeBase, diff --git a/lib/api/route/realm.g.dart b/lib/api/route/realm.g.dart index a7bea0c493..cd571ab410 100644 --- a/lib/api/route/realm.g.dart +++ b/lib/api/route/realm.g.dart @@ -8,11 +8,36 @@ part of 'realm.dart'; // JsonSerializableGenerator // ************************************************************************** +ExternalAuthenticationMethod _$ExternalAuthenticationMethodFromJson( + Map json) => + ExternalAuthenticationMethod( + name: json['name'] as String, + displayName: json['display_name'] as String, + displayIcon: json['display_icon'] as String?, + loginUrl: json['login_url'] as String, + signupUrl: json['signup_url'] as String, + ); + +Map _$ExternalAuthenticationMethodToJson( + ExternalAuthenticationMethod instance) => + { + 'name': instance.name, + 'display_name': instance.displayName, + 'display_icon': instance.displayIcon, + 'login_url': instance.loginUrl, + 'signup_url': instance.signupUrl, + }; + GetServerSettingsResult _$GetServerSettingsResultFromJson( Map json) => GetServerSettingsResult( authenticationMethods: Map.from(json['authentication_methods'] as Map), + externalAuthenticationMethods: (json['external_authentication_methods'] + as List) + .map((e) => + ExternalAuthenticationMethod.fromJson(e as Map)) + .toList(), zulipFeatureLevel: json['zulip_feature_level'] as int, zulipVersion: json['zulip_version'] as String, zulipMergeBase: json['zulip_merge_base'] as String?, @@ -23,7 +48,9 @@ GetServerSettingsResult _$GetServerSettingsResultFromJson( json['require_email_format_usernames'] as bool, realmUri: Uri.parse(json['realm_uri'] as String), realmName: json['realm_name'] as String, - realmIcon: json['realm_icon'] as String, + realmIcon: json['realm_icon'] == null + ? null + : Uri.parse(json['realm_icon'] as String), realmDescription: json['realm_description'] as String, realmWebPublicAccessEnabled: json['realm_web_public_access_enabled'] as bool?, @@ -33,6 +60,7 @@ Map _$GetServerSettingsResultToJson( GetServerSettingsResult instance) => { 'authentication_methods': instance.authenticationMethods, + 'external_authentication_methods': instance.externalAuthenticationMethods, 'zulip_feature_level': instance.zulipFeatureLevel, 'zulip_version': instance.zulipVersion, 'zulip_merge_base': instance.zulipMergeBase, @@ -42,7 +70,7 @@ Map _$GetServerSettingsResultToJson( 'require_email_format_usernames': instance.requireEmailFormatUsernames, 'realm_uri': instance.realmUri.toString(), 'realm_name': instance.realmName, - 'realm_icon': instance.realmIcon, + 'realm_icon': instance.realmIcon?.toString(), 'realm_description': instance.realmDescription, 'realm_web_public_access_enabled': instance.realmWebPublicAccessEnabled, }; diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 3d054ad0c6..83b381af11 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -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'; @@ -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; + })), + ), + ); } } diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index ec140c0474..a765a6b327 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -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 { - _LoginSequenceRoute({ +class LoginSequenceRoute extends MaterialWidgetRoute { + LoginSequenceRoute({ required super.page, }); } @@ -102,7 +103,7 @@ class AddAccountPage extends StatefulWidget { const AddAccountPage({super.key}); static Route buildRoute() { - return _LoginSequenceRoute(page: const AddAccountPage()); + return LoginSequenceRoute(page: const AddAccountPage()); } @override @@ -167,9 +168,8 @@ class _AddAccountPageState extends State { return; } - // TODO(#36): support login methods beyond username/password Navigator.push(context, - PasswordLoginPage.buildRoute(serverSettings: serverSettings)); + AuthMethodsPage.buildRoute(serverSettings: serverSettings)); } finally { setState(() { _inProgress = false; @@ -225,13 +225,89 @@ class _AddAccountPageState extends State { } } +class AuthMethodsPage extends StatefulWidget { + const AuthMethodsPage({super.key, required this.serverSettings}); + + final GetServerSettingsResult serverSettings; + + static Route buildRoute({required GetServerSettingsResult serverSettings}) { + return LoginSequenceRoute( + page: AuthMethodsPage(serverSettings: serverSettings)); + } + + @override + State createState() => _AuthMethodsPageState(); +} + +class _AuthMethodsPageState extends State { + // TODO: Remove this list when all the methods are tested, + // or update to add a new method. + static const Set _testedAuthMethods = { + 'github', + 'gitlab', + 'google', + }; + + Future _openBrowserLogin(ExternalAuthenticationMethod method) => + BrowserLoginWidget.of(context).openLoginUrl(widget.serverSettings, method.loginUrl); + + @override + Widget build(BuildContext context) { + // 'realmIcon' for chat.zulip.org, only contains the path component. + // So, resolve it to the 'realmUri' to get the full Uri with host. + final Uri? iconUrl = widget.serverSettings.realmIcon != null + ? widget.serverSettings.realmUri.resolveUri(widget.serverSettings.realmIcon!) + : null; + + return Scaffold( + appBar: AppBar(title: const Text('Log in')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(8), + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (iconUrl != null) ...[ + Image.network( + iconUrl.toString(), + key: const Key('realm_icon'), + width: 48, + height: 48), + const SizedBox(width: 8), + ], + Text(widget.serverSettings.realmName, style: const TextStyle(fontSize: 20)), + ]), + ), + if (widget.serverSettings.emailAuthEnabled) + OutlinedButton( + onPressed: () => Navigator.push(context, PasswordLoginPage.buildRoute(serverSettings: widget.serverSettings)), + child: const Text('Sign in with password')), + ...widget.serverSettings.externalAuthenticationMethods.map( + (authMethod) => switch (authMethod.displayIcon) { + null || '' => OutlinedButton( + onPressed: _testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null, + child: Text('Sign in with ${authMethod.displayName}'), + ), + final displayIcon => OutlinedButton.icon( + onPressed: _testedAuthMethods.contains(authMethod.name) ? () => _openBrowserLogin(authMethod) : null, + icon: Image.network(displayIcon, width: 24, height: 24), + label: Text('Sign in with ${authMethod.displayName}'), + ), + }).toList(), + ]))); + } +} + class PasswordLoginPage extends StatefulWidget { const PasswordLoginPage({super.key, required this.serverSettings}); final GetServerSettingsResult serverSettings; static Route buildRoute({required GetServerSettingsResult serverSettings}) { - return _LoginSequenceRoute( + return LoginSequenceRoute( page: PasswordLoginPage(serverSettings: serverSettings)); } @@ -323,7 +399,7 @@ class _PasswordLoginPageState extends State { Navigator.of(context).pushAndRemoveUntil( HomePage.buildRoute(accountId: accountId), - (route) => (route is! _LoginSequenceRoute), + (route) => (route is! LoginSequenceRoute), ); } finally { setState(() { diff --git a/lib/widgets/login/browser_login.dart b/lib/widgets/login/browser_login.dart new file mode 100644 index 0000000000..75eebce4eb --- /dev/null +++ b/lib/widgets/login/browser_login.dart @@ -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 navigatorKey = GlobalKey(); + + // 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 _tempAuthOtp = {}; + // Temporary server settngs, that was stored while initiating a browser auth flow. + final Map _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(); + assert(widget != null, 'No BrowserLogin ancestor'); + return widget!; + } + + Future 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 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 { + Iterable operator ^(List 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]; + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 320c58b783..82e78b4985 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -186,7 +186,7 @@ packages: source: hosted version: "1.18.0" convert: - dependency: transitive + dependency: "direct main" description: name: convert sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 57b235bee9..b69a30edb4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/example_data.dart b/test/example_data.dart index ac329161ec..e4452671a2 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1,5 +1,6 @@ import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/realm.dart'; import 'package:zulip/model/store.dart'; import 'api/fake_api.dart'; @@ -277,3 +278,53 @@ PerAccountStore store() { initialSnapshot: initialSnapshot(), ); } + +GetServerSettingsResult serverSettings({ + Map? authenticationMethods, + List? externalAuthenticationMethods, + int? zulipFeatureLevel, + String? zulipVersion, + String? zulipMergeBase, + bool? pushNotificationsEnabled, + bool? isIncompatible, + bool? emailAuthEnabled, + bool? requireEmailFormatUsernames, + Uri? realmUri, + String? realmName, + Uri? realmIcon, + String? realmDescription, + bool? realmWebPublicAccessEnabled, + }) { + return GetServerSettingsResult( + authenticationMethods: authenticationMethods ?? {}, + externalAuthenticationMethods: externalAuthenticationMethods ?? [], + zulipFeatureLevel: zulipFeatureLevel ?? recentZulipFeatureLevel, + zulipVersion: zulipVersion ?? recentZulipVersion, + zulipMergeBase: zulipMergeBase ?? recentZulipVersion, + pushNotificationsEnabled: pushNotificationsEnabled ?? false, + isIncompatible: isIncompatible ?? false, + emailAuthEnabled: emailAuthEnabled ?? true, + requireEmailFormatUsernames: requireEmailFormatUsernames ?? true, + realmUri: realmUri ?? realmUrl, + realmName: realmName ?? '', + realmIcon: realmIcon, + realmDescription: realmDescription ?? '', + realmWebPublicAccessEnabled: realmWebPublicAccessEnabled ?? false, + ); +} + +ExternalAuthenticationMethod externalAuthenticationMethod({ + String? name, + String? displayName, + String? displayIcon, + String? loginUrl, + String? signupUrl, +}) { + return ExternalAuthenticationMethod( + name: name ?? '', + displayName: displayName ?? '', + displayIcon: displayIcon, + loginUrl: loginUrl ?? '', + signupUrl: signupUrl ?? '', + ); +} diff --git a/test/widgets/login_test.dart b/test/widgets/login_test.dart index 081dbc6c5b..8ed8963227 100644 --- a/test/widgets/login_test.dart +++ b/test/widgets/login_test.dart @@ -1,10 +1,108 @@ import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/route/realm.dart'; import 'package:zulip/widgets/login.dart'; +import '../model/binding.dart'; import '../stdlib_checks.dart'; +import '../example_data.dart' as eg; void main() { + TestZulipBinding.ensureInitialized(); + + group('AuthMethodsPage', () { + Future setupPage(WidgetTester tester, { + required GetServerSettingsResult serverSettings, + }) async { + await tester.pumpWidget( + MaterialApp( + home: AuthMethodsPage(serverSettings: serverSettings))); + } + + testWidgets('shows all the external methods', (tester) async { + final methods = [ + eg.externalAuthenticationMethod( + name: 'some_new_method', + displayName: 'Some new method', + ), + eg.externalAuthenticationMethod( + name: 'github', + displayName: 'Github', + ), + ]; + await setupPage( + tester, + serverSettings: eg.serverSettings( + emailAuthEnabled: false, // don't show password method + externalAuthenticationMethods: methods)); + + final widgets = tester.widgetList( + find.ancestor( + of: find.textContaining('Sign in with'), + matching: find.byType(OutlinedButton)) + ); + check(widgets.length).equals(methods.length); + }); + + testWidgets('shows all the methods', (tester) async { + final methods = [ + eg.externalAuthenticationMethod( + name: 'some_new_method', + displayName: 'Some new method', + ), + eg.externalAuthenticationMethod( + name: 'github', + displayName: 'Github', + ), + ]; + await setupPage( + tester, + serverSettings: eg.serverSettings( + emailAuthEnabled: true, // show password method + externalAuthenticationMethods: methods)); + + final widgets = tester.widgetList( + find.ancestor( + of: find.textContaining('Sign in with'), + matching: find.byType(OutlinedButton)) + ); + check(widgets.length).equals(methods.length + 1); + }); + + testWidgets('untested methods disabled', (tester) async { + final untestedMethod = eg.externalAuthenticationMethod( + name: 'some_new_method', + displayName: 'Some new method', + ); + await setupPage( + tester, + serverSettings: eg.serverSettings(externalAuthenticationMethods: [untestedMethod])); + + final button = tester.widget( + find.ancestor( + of: find.text('Sign in with ${untestedMethod.displayName}'), + matching: find.byType(OutlinedButton))); + check(button.enabled).isFalse(); + }); + + testWidgets('tested methods enabled', (tester) async { + final testedMethod = eg.externalAuthenticationMethod( + name: 'github', + displayName: 'Github', + ); + await setupPage( + tester, + serverSettings: eg.serverSettings(externalAuthenticationMethods: [testedMethod])); + + final button = tester.firstWidget( + find.ancestor( + of: find.text('Sign in with ${testedMethod.displayName}'), + matching: find.byType(OutlinedButton))); + check(button.enabled).isTrue(); + }); + }); + group('ServerUrlTextEditingController.tryParse', () { final controller = ServerUrlTextEditingController();