diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6052f6804da..936cbd039e3 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -25,6 +25,13 @@
+
+
+
+
+
+
+
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 787a3ab1d34..ffade0f4a9d 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -63,6 +63,14 @@
"@actionSheetOptionUnstarMessage": {
"description": "Label for unstar button on action sheet."
},
+ "errorWebAuthOperationalErrorTitle": "Operational error",
+ "@errorWebAuthOperationalErrorTitle": {
+ "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)."
+ },
+ "errorWebAuthOperationalError": "An unexpected error occurred.",
+ "@errorWebAuthOperationalError": {
+ "description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)."
+ },
"errorAccountLoggedInTitle": "Account already logged in",
"@errorAccountLoggedInTitle": {
"description": "Error title on attempting to log into an account that's already logged in."
@@ -281,6 +289,17 @@
"@loginFormSubmitLabel": {
"description": "Button text to submit login credentials."
},
+ "loginMethodDivider": "OR",
+ "@loginMethodDivider": {
+ "description": "Text on the divider between the username/password form and the third-party login options, like Google. Uppercase (for languages with letter case)."
+ },
+ "signInWithFoo": "Sign in with {method}",
+ "@signInWithFoo": {
+ "description": "Button to use {method} to sign in to the app.",
+ "placeholders": {
+ "method": {"type": "String", "example": "Google"}
+ }
+ },
"loginAddAnAccountPageTitle": "Add an account",
"@loginAddAnAccountPageTitle": {
"description": "Page title for screen to add a Zulip account."
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 8f08f0cddaf..5f5d9c5ea27 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -22,8 +22,21 @@
$(FLUTTER_BUILD_NAME)
CFBundleSignature
????
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ com.zulip.flutter
+ CFBundleURLSchemes
+
+ zulip
+
+
+
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
+ FlutterDeepLinkingEnabled
+
ITSAppUsesNonExemptEncryption
LSRequiresIPhoneOS
diff --git a/lib/api/model/web_auth.dart b/lib/api/model/web_auth.dart
new file mode 100644
index 00000000000..e8bfcb9e9ec
--- /dev/null
+++ b/lib/api/model/web_auth.dart
@@ -0,0 +1,88 @@
+import 'dart:math';
+
+import 'package:convert/convert.dart';
+import 'package:flutter/foundation.dart';
+
+/// The authentication information contained in the zulip:// redirect URL.
+class WebAuthPayload {
+ final String otpEncryptedApiKey;
+ final String email;
+ final int? userId; // TODO(server-5) new in FL 108
+ final Uri realm;
+
+ WebAuthPayload._({
+ required this.otpEncryptedApiKey,
+ required this.email,
+ required this.userId,
+ required this.realm,
+ });
+
+ factory WebAuthPayload.parse(Uri url) {
+ if (
+ url case Uri(
+ scheme: 'zulip',
+ host: 'login',
+ queryParameters: {
+ 'email': String(isEmpty: false) && var email,
+ 'realm': String(isEmpty: false) && var realmStr,
+ 'otp_encrypted_api_key': String(isEmpty: false) && var otpEncryptedApiKey,
+ },
+ )
+ ) {
+ // TODO(server-5) require in queryParameters (new in FL 108)
+ final userIdStr = url.queryParameters['user_id'];
+ int? userId;
+ if (userIdStr != null) {
+ final maybeParsed = int.tryParse(userIdStr, radix: 10);
+ if (maybeParsed == null) {
+ throw const FormatException();
+ }
+ userId = maybeParsed;
+ }
+
+ final Uri realm;
+ final maybeParsedRealm = Uri.tryParse(realmStr);
+ if (maybeParsedRealm == null) {
+ throw const FormatException();
+ }
+ realm = maybeParsedRealm;
+
+ return WebAuthPayload._(
+ otpEncryptedApiKey: otpEncryptedApiKey,
+ email: email,
+ userId: userId,
+ realm: realm,
+ );
+ } else {
+ throw const FormatException();
+ }
+ }
+
+ String decodeApiKey(String otp) {
+ final otpBytes = hex.decode(otp);
+ final otpEncryptedApiKeyBytes = hex.decode(otpEncryptedApiKey);
+ if (otpBytes.length != otpEncryptedApiKeyBytes.length) {
+ throw const FormatException();
+ }
+ return String.fromCharCodes(Iterable.generate(otpBytes.length,
+ (i) => otpBytes[i] ^ otpEncryptedApiKeyBytes[i]));
+ }
+}
+
+String generateOtp() {
+ final rand = Random.secure();
+ final Uint8List bytes = Uint8List.fromList(
+ List.generate(32, (_) => rand.nextInt(256)));
+ return hex.encode(bytes);
+}
+
+/// For tests, create an OTP-encrypted API key.
+@visibleForTesting
+String debugEncodeApiKey(String apiKey, String otp) {
+ final apiKeyBytes = apiKey.codeUnits;
+ assert(apiKeyBytes.every((byte) => byte <= 0xff));
+ final otpBytes = hex.decode(otp);
+ assert(apiKeyBytes.length == otpBytes.length);
+ return hex.encode(List.generate(otpBytes.length,
+ (i) => apiKeyBytes[i] ^ otpBytes[i]));
+}
diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart
index e88fec864a8..c2231be6a82 100644
--- a/lib/widgets/app.dart
+++ b/lib/widgets/app.dart
@@ -83,7 +83,30 @@ class ZulipApp extends StatefulWidget {
State createState() => _ZulipAppState();
}
-class _ZulipAppState extends State {
+class _ZulipAppState extends State with WidgetsBindingObserver {
+ @override
+ Future didPushRouteInformation(routeInformation) async {
+ if (routeInformation case RouteInformation(
+ uri: Uri(scheme: 'zulip', host: 'login') && var url)
+ ) {
+ await LoginPage.lastBuiltKey.currentState?.handleWebAuthUrl(url);
+ return true;
+ }
+ return super.didPushRouteInformation(routeInformation);
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
@override
Widget build(BuildContext context) {
final theme = ThemeData(
diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart
index ae5034cdf0c..41d1f8acd4f 100644
--- a/lib/widgets/login.dart
+++ b/lib/widgets/login.dart
@@ -1,16 +1,22 @@
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
+import 'package:url_launcher/url_launcher.dart';
import '../api/exception.dart';
+import '../api/model/web_auth.dart';
import '../api/route/account.dart';
import '../api/route/realm.dart';
import '../api/route/users.dart';
+import '../log.dart';
+import '../model/binding.dart';
import '../model/store.dart';
import 'app.dart';
import 'dialog.dart';
import 'input.dart';
import 'page.dart';
import 'store.dart';
+import 'text.dart';
class _LoginSequenceRoute extends MaterialWidgetRoute {
_LoginSequenceRoute({
@@ -176,7 +182,6 @@ class _AddAccountPageState extends State {
return;
}
- // TODO(#36): support login methods beyond username/password
Navigator.push(context,
LoginPage.buildRoute(serverSettings: serverSettings));
} finally {
@@ -238,11 +243,14 @@ class _AddAccountPageState extends State {
class LoginPage extends StatefulWidget {
const LoginPage({super.key, required this.serverSettings});
+ /// A key for the page from the last [buildRoute] call.
+ static final lastBuiltKey = GlobalKey();
+
final GetServerSettingsResult serverSettings;
static Route buildRoute({required GetServerSettingsResult serverSettings}) {
return _LoginSequenceRoute(
- page: LoginPage(serverSettings: serverSettings));
+ page: LoginPage(serverSettings: serverSettings, key: lastBuiltKey));
}
@override
@@ -252,6 +260,83 @@ class LoginPage extends StatefulWidget {
class LoginPageState extends State {
bool _inProgress = false;
+ /// The OTP to use, instead of an app-generated one, for testing.
+ @visibleForTesting
+ static String? debugOtpOverride;
+ String? _otp;
+ static const LaunchMode _webAuthLaunchMode = LaunchMode.inAppBrowserView;
+
+ /// Log in using the payload of a web-auth URL like zulip://login?…
+ Future handleWebAuthUrl(Uri url) async {
+ setState(() {
+ _inProgress = true;
+ });
+ try {
+ assert (await ZulipBinding.instance.supportsCloseForLaunchMode(_webAuthLaunchMode));
+ await ZulipBinding.instance.closeInAppWebView();
+ if ((debugOtpOverride ?? _otp) == null) {
+ throw Error();
+ }
+ final payload = WebAuthPayload.parse(url);
+ final apiKey = payload.decodeApiKey((debugOtpOverride ?? _otp)!);
+ await _tryInsertAccountAndNavigate(
+ // TODO(server-5): Rely on userId from payload.
+ userId: payload.userId ?? await _getUserId(payload.email, apiKey),
+ email: payload.email,
+ apiKey: apiKey,
+ );
+ } catch (e) {
+ assert(debugLog(e.toString()));
+ if (!mounted) return;
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ // Could show different error messages for different failure modes.
+ await showErrorDialog(context: context,
+ title: zulipLocalizations.errorWebAuthOperationalErrorTitle,
+ message: zulipLocalizations.errorWebAuthOperationalError);
+ } finally {
+ setState(() {
+ _inProgress = false;
+ _otp = null;
+ });
+ }
+ }
+
+ Future _beginWebAuth(ExternalAuthenticationMethod method) async {
+ _otp = generateOtp();
+ try {
+ final url = widget.serverSettings.realmUrl.resolve(method.loginUrl)
+ .replace(queryParameters: {'mobile_flow_otp': (debugOtpOverride ?? _otp)!});
+ if (!(await ZulipBinding.instance.canLaunchUrl(url))) {
+ throw Error();
+ }
+
+ // Could set [_inProgress]… but we'd need to unset it if the web-auth
+ // attempt is aborted (by the user closing the browser, for example),
+ // and I don't think we can reliably know when that happens.
+ await ZulipBinding.instance.launchUrl(url, mode: _webAuthLaunchMode);
+ } catch (e) {
+ assert(debugLog(e.toString()));
+
+ if (e is PlatformException && e.message != null && e.message!.startsWith('Error while launching')) {
+ // Ignore; I've seen this on my iPhone even when auth succeeds.
+ // Specifically, Apple web auth…which on iOS should be replaced by
+ // Apple native auth; that's #462.
+ // Possibly related:
+ // https://github.com/flutter/flutter/issues/91660
+ // but in that issue, people report authentication not succeeding.
+ // TODO(#462) remove this?
+ return;
+ }
+
+ if (!mounted) return;
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ // Could show different error messages for different failure modes.
+ await showErrorDialog(context: context,
+ title: zulipLocalizations.errorWebAuthOperationalErrorTitle,
+ message: zulipLocalizations.errorWebAuthOperationalError);
+ }
+ }
+
Future _tryInsertAccountAndNavigate({
required String email,
required String apiKey,
@@ -312,6 +397,8 @@ class LoginPageState extends State {
assert(!PerAccountStoreWidget.debugExistsOf(context));
final zulipLocalizations = ZulipLocalizations.of(context);
+ final externalAuthenticationMethods = widget.serverSettings.externalAuthenticationMethods;
+
return Scaffold(
appBar: AppBar(title: Text(zulipLocalizations.loginPageTitle),
bottom: _inProgress
@@ -330,7 +417,23 @@ class LoginPageState extends State {
// left or the right of this box
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
- child: _UsernamePasswordForm(loginPageState: this)))))));
+ child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
+ _UsernamePasswordForm(loginPageState: this),
+ if (externalAuthenticationMethods.isNotEmpty) ...[
+ const OrDivider(),
+ ...externalAuthenticationMethods.map((method) {
+ final icon = method.displayIcon;
+ return OutlinedButton.icon(
+ icon: icon != null
+ ? Image.network(icon, width: 24, height: 24)
+ : null,
+ onPressed: !_inProgress
+ ? () => _beginWebAuth(method)
+ : null,
+ label: Text(zulipLocalizations.signInWithFoo(method.displayName)));
+ }),
+ ],
+ ])))))));
}
}
@@ -495,3 +598,31 @@ class _UsernamePasswordFormState extends State<_UsernamePasswordForm> {
])));
}
}
+
+// Loosely based on the corresponding element in the web app.
+class OrDivider extends StatelessWidget {
+ const OrDivider({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+
+ const divider = Expanded(
+ child: Divider(color: Color(0xffdedede), thickness: 2));
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 10),
+ child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
+ divider,
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 5),
+ child: Text(zulipLocalizations.loginMethodDivider,
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ color: Color(0xff575757),
+ height: 1.5,
+ ).merge(weightVariableTextStyle(context, wght: 600)))),
+ divider,
+ ]));
+ }
+}
diff --git a/test/widgets/login_test.dart b/test/widgets/login_test.dart
index 6fb883010e6..e7b4383bb43 100644
--- a/test/widgets/login_test.dart
+++ b/test/widgets/login_test.dart
@@ -1,18 +1,25 @@
import 'package:checks/checks.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
+import 'package:zulip/api/model/web_auth.dart';
import 'package:zulip/api/route/account.dart';
import 'package:zulip/api/route/realm.dart';
+import 'package:zulip/model/binding.dart';
import 'package:zulip/model/localizations.dart';
import 'package:zulip/widgets/app.dart';
import 'package:zulip/widgets/login.dart';
+import 'package:zulip/widgets/page.dart';
import '../api/fake_api.dart';
import '../example_data.dart' as eg;
import '../model/binding.dart';
import '../stdlib_checks.dart';
+import '../test_images.dart';
+import '../test_navigation.dart';
import 'dialog_checks.dart';
+import 'page_checks.dart';
void main() {
TestZulipBinding.ensureInitialized();
@@ -58,6 +65,16 @@ void main() {
group('LoginPage', () {
late FakeApiConnection connection;
+ late List> pushedRoutes;
+
+ void takeStartingRoutes() {
+ final expected = [
+ (Subject it) => it.isA().page.isA(),
+ (Subject it) => it.isA().page.isA(),
+ ];
+ check(pushedRoutes.take(expected.length)).deepEquals(expected);
+ pushedRoutes.removeRange(0, expected.length);
+ }
Future prepare(WidgetTester tester,
GetServerSettingsResult serverSettings) async {
@@ -67,11 +84,16 @@ void main() {
realmUrl: serverSettings.realmUrl,
zulipFeatureLevel: serverSettings.zulipFeatureLevel);
- await tester.pumpWidget(const ZulipApp());
+ pushedRoutes = [];
+ final testNavObserver = TestNavigatorObserver()
+ ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
+ await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver],));
await tester.pump();
final navigator = await ZulipApp.navigator;
navigator.push(LoginPage.buildRoute(serverSettings: serverSettings));
await tester.pumpAndSettle();
+ takeStartingRoutes();
+ check(pushedRoutes).isEmpty();
}
final findUsernameInput = find.byWidgetPredicate((widget) =>
@@ -140,5 +162,59 @@ void main() {
// TODO test handling failure in fetchApiKey request
// TODO test _inProgress logic
});
+
+ group('web auth', () {
+ testWidgets('basic happy case', (tester) async {
+ final method = ExternalAuthenticationMethod(
+ name: 'google',
+ displayName: 'Google',
+ displayIcon: eg.realmUrl.resolve('/static/images/authentication_backends/googl_e-icon.png').toString(),
+ loginUrl: '/accounts/login/social/google',
+ signupUrl: '/accounts/register/social/google',
+ );
+ final serverSettings = eg.serverSettings(
+ externalAuthenticationMethods: [method]);
+ prepareBoringImageHttpClient(); // icon on social-auth button
+ await prepare(tester, serverSettings);
+ check(testBinding.globalStore.accounts).isEmpty();
+
+ LoginPageState.debugOtpOverride = '186f6d085a5621ebaf1ccfc05033e8acba57dae03f061705ac1e58c402c30a31';
+ await tester.tap(find.textContaining('Google'));
+
+ final expectedUrl = eg.realmUrl.resolve(method.loginUrl)
+ .replace(queryParameters: {'mobile_flow_otp': LoginPageState.debugOtpOverride});
+ check(testBinding.takeCanLaunchUrlCalls()).deepEquals([expectedUrl]);
+ check(testBinding.takeLaunchUrlCalls())
+ .deepEquals([(url: expectedUrl, mode: UrlLaunchMode.inAppBrowserView)]);
+
+ // TODO test _inProgress logic?
+
+ final encoded = debugEncodeApiKey(eg.selfAccount.apiKey, LoginPageState.debugOtpOverride!);
+ final url = Uri(scheme: 'zulip', host: 'login', queryParameters: {
+ 'otp_encrypted_api_key': encoded,
+ 'email': eg.selfAccount.email,
+ 'user_id': eg.selfAccount.userId.toString(),
+ 'realm': eg.selfAccount.realmUrl.toString(),
+ });
+
+ final ByteData message = const JSONMethodCodec().encodeMethodCall(
+ MethodCall('pushRouteInformation', {'location': url.toString()}));
+ tester.binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/navigation', message, null);
+
+ await tester.idle();
+ check(testBinding.takeCloseInAppWebViewCallCount()).equals(1);
+
+ final account = testBinding.globalStore.accounts.single;
+ check(account).equals(eg.selfAccount.copyWith(id: account.id));
+ check(pushedRoutes).single.isA()
+ ..accountId.equals(account.id)
+ ..page.isA();
+
+ debugNetworkImageHttpClientProvider = null;
+ });
+
+ // other cases: invalid loginUrl; URL can't be launched; etc.
+ });
});
}