Skip to content

Commit 6ac0536

Browse files
committed
login: Support web-based auth methods
Fixes: #36
1 parent 08825f2 commit 6ac0536

File tree

7 files changed

+362
-5
lines changed

7 files changed

+362
-5
lines changed

android/app/src/main/AndroidManifest.xml

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

assets/l10n/app_en.arb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@
6363
"@actionSheetOptionUnstarMessage": {
6464
"description": "Label for unstar button on action sheet."
6565
},
66+
"errorWebAuthOperationalErrorTitle": "Operational error",
67+
"@errorWebAuthOperationalErrorTitle": {
68+
"description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)."
69+
},
70+
"errorWebAuthOperationalError": "An unexpected error occurred.",
71+
"@errorWebAuthOperationalError": {
72+
"description": "Error message when third-party authentication has an operational error (not necessarily caused by invalid credentials)."
73+
},
6674
"errorAccountLoggedInTitle": "Account already logged in",
6775
"@errorAccountLoggedInTitle": {
6876
"description": "Error title on attempting to log into an account that's already logged in."
@@ -281,6 +289,17 @@
281289
"@loginFormSubmitLabel": {
282290
"description": "Button text to submit login credentials."
283291
},
292+
"loginMethodDivider": "OR",
293+
"@loginMethodDivider": {
294+
"description": "Text on the divider between the username/password form and the third-party login options, like Google. Uppercase (for languages with letter case)."
295+
},
296+
"signInWithFoo": "Sign in with {method}",
297+
"@signInWithFoo": {
298+
"description": "Button to use {method} to sign in to the app.",
299+
"placeholders": {
300+
"method": {"type": "String", "example": "Google"}
301+
}
302+
},
284303
"loginAddAnAccountPageTitle": "Add an account",
285304
"@loginAddAnAccountPageTitle": {
286305
"description": "Page title for screen to add a Zulip account."

ios/Runner/Info.plist

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,21 @@
2222
<string>$(FLUTTER_BUILD_NAME)</string>
2323
<key>CFBundleSignature</key>
2424
<string>????</string>
25+
<key>CFBundleURLTypes</key>
26+
<array>
27+
<dict>
28+
<key>CFBundleURLName</key>
29+
<string>com.zulip.flutter</string>
30+
<key>CFBundleURLSchemes</key>
31+
<array>
32+
<string>zulip</string>
33+
</array>
34+
</dict>
35+
</array>
2536
<key>CFBundleVersion</key>
2637
<string>$(FLUTTER_BUILD_NUMBER)</string>
38+
<key>FlutterDeepLinkingEnabled</key>
39+
<true/>
2740
<key>ITSAppUsesNonExemptEncryption</key>
2841
<false/>
2942
<key>LSRequiresIPhoneOS</key>

lib/api/model/web_auth.dart

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import 'dart:math';
2+
3+
import 'package:convert/convert.dart';
4+
import 'package:flutter/foundation.dart';
5+
6+
/// The authentication information contained in the zulip:// redirect URL.
7+
class WebAuthPayload {
8+
final String otpEncryptedApiKey;
9+
final String email;
10+
final int? userId; // TODO(server-5) new in FL 108
11+
final Uri realm;
12+
13+
WebAuthPayload._({
14+
required this.otpEncryptedApiKey,
15+
required this.email,
16+
required this.userId,
17+
required this.realm,
18+
});
19+
20+
factory WebAuthPayload.parse(Uri url) {
21+
if (
22+
url case Uri(
23+
scheme: 'zulip',
24+
host: 'login',
25+
queryParameters: {
26+
'email': String(isEmpty: false) && var email,
27+
'realm': String(isEmpty: false) && var realmStr,
28+
'otp_encrypted_api_key': String(isEmpty: false) && var otpEncryptedApiKey,
29+
},
30+
)
31+
) {
32+
// TODO(server-5) require in queryParameters (new in FL 108)
33+
final userIdStr = url.queryParameters['user_id'];
34+
int? userId;
35+
if (userIdStr != null) {
36+
final maybeParsed = int.tryParse(userIdStr, radix: 10);
37+
if (maybeParsed == null) {
38+
throw const FormatException();
39+
}
40+
userId = maybeParsed;
41+
}
42+
43+
final Uri realm;
44+
final maybeParsedRealm = Uri.tryParse(realmStr);
45+
if (maybeParsedRealm == null) {
46+
throw const FormatException();
47+
}
48+
realm = maybeParsedRealm;
49+
50+
return WebAuthPayload._(
51+
otpEncryptedApiKey: otpEncryptedApiKey,
52+
email: email,
53+
userId: userId,
54+
realm: realm,
55+
);
56+
} else {
57+
throw const FormatException();
58+
}
59+
}
60+
61+
String decodeApiKey(String otp) {
62+
final otpBytes = hex.decode(otp);
63+
final otpEncryptedApiKeyBytes = hex.decode(otpEncryptedApiKey);
64+
if (otpBytes.length != otpEncryptedApiKeyBytes.length) {
65+
throw const FormatException();
66+
}
67+
return String.fromCharCodes(Iterable.generate(otpBytes.length,
68+
(i) => otpBytes[i] ^ otpEncryptedApiKeyBytes[i]));
69+
}
70+
}
71+
72+
String generateOtp() {
73+
final rand = Random.secure();
74+
final Uint8List bytes = Uint8List.fromList(
75+
List.generate(32, (_) => rand.nextInt(256)));
76+
return hex.encode(bytes);
77+
}
78+
79+
/// For tests, create an OTP-encrypted API key.
80+
@visibleForTesting
81+
String debugEncodeApiKey(String apiKey, String otp) {
82+
final apiKeyBytes = apiKey.codeUnits;
83+
assert(apiKeyBytes.every((byte) => byte <= 0xff));
84+
final otpBytes = hex.decode(otp);
85+
assert(apiKeyBytes.length == otpBytes.length);
86+
return hex.encode(List.generate(otpBytes.length,
87+
(i) => apiKeyBytes[i] ^ otpBytes[i]));
88+
}

lib/widgets/app.dart

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,30 @@ class ZulipApp extends StatefulWidget {
8383
State<ZulipApp> createState() => _ZulipAppState();
8484
}
8585

86-
class _ZulipAppState extends State<ZulipApp> {
86+
class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
87+
@override
88+
Future<bool> didPushRouteInformation(routeInformation) async {
89+
if (routeInformation case RouteInformation(
90+
uri: Uri(scheme: 'zulip', host: 'login') && var url)
91+
) {
92+
await LoginPage.lastBuiltKey.currentState?.handleWebAuthUrl(url);
93+
return true;
94+
}
95+
return super.didPushRouteInformation(routeInformation);
96+
}
97+
98+
@override
99+
void initState() {
100+
super.initState();
101+
WidgetsBinding.instance.addObserver(this);
102+
}
103+
104+
@override
105+
void dispose() {
106+
WidgetsBinding.instance.removeObserver(this);
107+
super.dispose();
108+
}
109+
87110
@override
88111
Widget build(BuildContext context) {
89112
final theme = ThemeData(

lib/widgets/login.dart

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
23
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
4+
import 'package:url_launcher/url_launcher.dart';
35

46
import '../api/exception.dart';
7+
import '../api/model/web_auth.dart';
58
import '../api/route/account.dart';
69
import '../api/route/realm.dart';
710
import '../api/route/users.dart';
11+
import '../log.dart';
12+
import '../model/binding.dart';
813
import '../model/store.dart';
914
import 'app.dart';
1015
import 'dialog.dart';
1116
import 'input.dart';
1217
import 'page.dart';
1318
import 'store.dart';
19+
import 'text.dart';
1420

1521
class _LoginSequenceRoute extends MaterialWidgetRoute<void> {
1622
_LoginSequenceRoute({
@@ -176,7 +182,6 @@ class _AddAccountPageState extends State<AddAccountPage> {
176182
return;
177183
}
178184

179-
// TODO(#36): support login methods beyond username/password
180185
Navigator.push(context,
181186
LoginPage.buildRoute(serverSettings: serverSettings));
182187
} finally {
@@ -238,11 +243,14 @@ class _AddAccountPageState extends State<AddAccountPage> {
238243
class LoginPage extends StatefulWidget {
239244
const LoginPage({super.key, required this.serverSettings});
240245

246+
/// A key for the page from the last [buildRoute] call.
247+
static final lastBuiltKey = GlobalKey<LoginPageState>();
248+
241249
final GetServerSettingsResult serverSettings;
242250

243251
static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) {
244252
return _LoginSequenceRoute(
245-
page: LoginPage(serverSettings: serverSettings));
253+
page: LoginPage(serverSettings: serverSettings, key: lastBuiltKey));
246254
}
247255

248256
@override
@@ -252,6 +260,83 @@ class LoginPage extends StatefulWidget {
252260
class LoginPageState extends State<LoginPage> {
253261
bool _inProgress = false;
254262

263+
/// The OTP to use, instead of an app-generated one, for testing.
264+
@visibleForTesting
265+
static String? debugOtpOverride;
266+
String? _otp;
267+
static const LaunchMode _webAuthLaunchMode = LaunchMode.inAppBrowserView;
268+
269+
/// Log in using the payload of a web-auth URL like zulip://login?…
270+
Future<void> handleWebAuthUrl(Uri url) async {
271+
setState(() {
272+
_inProgress = true;
273+
});
274+
try {
275+
assert (await ZulipBinding.instance.supportsCloseForLaunchMode(_webAuthLaunchMode));
276+
await ZulipBinding.instance.closeInAppWebView();
277+
if ((debugOtpOverride ?? _otp) == null) {
278+
throw Error();
279+
}
280+
final payload = WebAuthPayload.parse(url);
281+
final apiKey = payload.decodeApiKey((debugOtpOverride ?? _otp)!);
282+
await _tryInsertAccountAndNavigate(
283+
// TODO(server-5): Rely on userId from payload.
284+
userId: payload.userId ?? await _getUserId(payload.email, apiKey),
285+
email: payload.email,
286+
apiKey: apiKey,
287+
);
288+
} catch (e) {
289+
assert(debugLog(e.toString()));
290+
if (!mounted) return;
291+
final zulipLocalizations = ZulipLocalizations.of(context);
292+
// Could show different error messages for different failure modes.
293+
await showErrorDialog(context: context,
294+
title: zulipLocalizations.errorWebAuthOperationalErrorTitle,
295+
message: zulipLocalizations.errorWebAuthOperationalError);
296+
} finally {
297+
setState(() {
298+
_inProgress = false;
299+
_otp = null;
300+
});
301+
}
302+
}
303+
304+
Future<void> _beginWebAuth(ExternalAuthenticationMethod method) async {
305+
_otp = generateOtp();
306+
try {
307+
final url = widget.serverSettings.realmUrl.resolve(method.loginUrl)
308+
.replace(queryParameters: {'mobile_flow_otp': (debugOtpOverride ?? _otp)!});
309+
if (!(await ZulipBinding.instance.canLaunchUrl(url))) {
310+
throw Error();
311+
}
312+
313+
// Could set [_inProgress]… but we'd need to unset it if the web-auth
314+
// attempt is aborted (by the user closing the browser, for example),
315+
// and I don't think we can reliably know when that happens.
316+
await ZulipBinding.instance.launchUrl(url, mode: _webAuthLaunchMode);
317+
} catch (e) {
318+
assert(debugLog(e.toString()));
319+
320+
if (e is PlatformException && e.message != null && e.message!.startsWith('Error while launching')) {
321+
// Ignore; I've seen this on my iPhone even when auth succeeds.
322+
// Specifically, Apple web auth…which on iOS should be replaced by
323+
// Apple native auth; that's #462.
324+
// Possibly related:
325+
// https://github.com/flutter/flutter/issues/91660
326+
// but in that issue, people report authentication not succeeding.
327+
// TODO(#462) remove this?
328+
return;
329+
}
330+
331+
if (!mounted) return;
332+
final zulipLocalizations = ZulipLocalizations.of(context);
333+
// Could show different error messages for different failure modes.
334+
await showErrorDialog(context: context,
335+
title: zulipLocalizations.errorWebAuthOperationalErrorTitle,
336+
message: zulipLocalizations.errorWebAuthOperationalError);
337+
}
338+
}
339+
255340
Future<void> _tryInsertAccountAndNavigate({
256341
required String email,
257342
required String apiKey,
@@ -312,6 +397,8 @@ class LoginPageState extends State<LoginPage> {
312397
assert(!PerAccountStoreWidget.debugExistsOf(context));
313398
final zulipLocalizations = ZulipLocalizations.of(context);
314399

400+
final externalAuthenticationMethods = widget.serverSettings.externalAuthenticationMethods;
401+
315402
return Scaffold(
316403
appBar: AppBar(title: Text(zulipLocalizations.loginPageTitle),
317404
bottom: _inProgress
@@ -330,7 +417,23 @@ class LoginPageState extends State<LoginPage> {
330417
// left or the right of this box
331418
child: ConstrainedBox(
332419
constraints: const BoxConstraints(maxWidth: 400),
333-
child: _UsernamePasswordForm(loginPageState: this)))))));
420+
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
421+
_UsernamePasswordForm(loginPageState: this),
422+
if (externalAuthenticationMethods.isNotEmpty) ...[
423+
const OrDivider(),
424+
...externalAuthenticationMethods.map((method) {
425+
final icon = method.displayIcon;
426+
return OutlinedButton.icon(
427+
icon: icon != null
428+
? Image.network(icon, width: 24, height: 24)
429+
: null,
430+
onPressed: !_inProgress
431+
? () => _beginWebAuth(method)
432+
: null,
433+
label: Text(zulipLocalizations.signInWithFoo(method.displayName)));
434+
}),
435+
],
436+
])))))));
334437
}
335438
}
336439

@@ -495,3 +598,31 @@ class _UsernamePasswordFormState extends State<_UsernamePasswordForm> {
495598
])));
496599
}
497600
}
601+
602+
// Loosely based on the corresponding element in the web app.
603+
class OrDivider extends StatelessWidget {
604+
const OrDivider({super.key});
605+
606+
@override
607+
Widget build(BuildContext context) {
608+
final zulipLocalizations = ZulipLocalizations.of(context);
609+
610+
const divider = Expanded(
611+
child: Divider(color: Color(0xffdedede), thickness: 2));
612+
613+
return Padding(
614+
padding: const EdgeInsets.symmetric(vertical: 10),
615+
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
616+
divider,
617+
Padding(
618+
padding: const EdgeInsets.symmetric(horizontal: 5),
619+
child: Text(zulipLocalizations.loginMethodDivider,
620+
textAlign: TextAlign.center,
621+
style: const TextStyle(
622+
color: Color(0xff575757),
623+
height: 1.5,
624+
).merge(weightVariableTextStyle(context, wght: 600)))),
625+
divider,
626+
]));
627+
}
628+
}

0 commit comments

Comments
 (0)