Skip to content

Commit bd2fc3d

Browse files
chrisbobbegnprice
authored andcommitted
login: Support web-based auth methods
Fixes: #36
1 parent 60c5245 commit bd2fc3d

File tree

8 files changed

+473
-5
lines changed

8 files changed

+473
-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": "Something went wrong",
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. 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: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 Uri realm;
9+
final String email;
10+
final int? userId; // TODO(server-5) new in FL 108
11+
final String otpEncryptedApiKey;
12+
13+
WebAuthPayload._({
14+
required this.realm,
15+
required this.email,
16+
required this.userId,
17+
required this.otpEncryptedApiKey,
18+
});
19+
20+
factory WebAuthPayload.parse(Uri url) {
21+
if (
22+
url case Uri(
23+
scheme: 'zulip',
24+
host: 'login',
25+
queryParameters: {
26+
'realm': String realmStr,
27+
'email': String email,
28+
// 'user_id' handled below
29+
'otp_encrypted_api_key': String otpEncryptedApiKey,
30+
},
31+
)
32+
) {
33+
final Uri? realm = Uri.tryParse(realmStr);
34+
if (realm == null) throw const FormatException();
35+
36+
// TODO(server-5) require in queryParameters (new in FL 108)
37+
final userIdStr = url.queryParameters['user_id'];
38+
int? userId;
39+
if (userIdStr != null) {
40+
userId = int.tryParse(userIdStr, radix: 10);
41+
if (userId == null) throw const FormatException();
42+
}
43+
44+
if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(otpEncryptedApiKey)) {
45+
throw const FormatException();
46+
}
47+
48+
return WebAuthPayload._(
49+
otpEncryptedApiKey: otpEncryptedApiKey,
50+
email: email,
51+
userId: userId,
52+
realm: realm,
53+
);
54+
} else {
55+
// TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
56+
throw const FormatException();
57+
}
58+
}
59+
60+
String decodeApiKey(String otp) {
61+
final otpBytes = hex.decode(otp);
62+
final otpEncryptedApiKeyBytes = hex.decode(otpEncryptedApiKey);
63+
if (otpBytes.length != otpEncryptedApiKeyBytes.length) {
64+
throw const FormatException();
65+
}
66+
return String.fromCharCodes(Iterable.generate(otpBytes.length,
67+
(i) => otpBytes[i] ^ otpEncryptedApiKeyBytes[i]));
68+
}
69+
}
70+
71+
String generateOtp() {
72+
final rand = Random.secure();
73+
final Uint8List bytes = Uint8List.fromList(
74+
List.generate(32, (_) => rand.nextInt(256)));
75+
return hex.encode(bytes);
76+
}
77+
78+
/// For tests, create an OTP-encrypted API key.
79+
@visibleForTesting
80+
String debugEncodeApiKey(String apiKey, String otp) {
81+
final apiKeyBytes = apiKey.codeUnits;
82+
assert(apiKeyBytes.every((byte) => byte <= 0xff));
83+
final otpBytes = hex.decode(otp);
84+
assert(apiKeyBytes.length == otpBytes.length);
85+
return hex.encode(List.generate(otpBytes.length,
86+
(i) => apiKeyBytes[i] ^ otpBytes[i]));
87+
}

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.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: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
1+
import 'package:flutter/foundation.dart';
12
import 'package:flutter/material.dart';
3+
import 'package:flutter/services.dart';
24
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
5+
import 'package:url_launcher/url_launcher.dart';
36

47
import '../api/exception.dart';
8+
import '../api/model/web_auth.dart';
59
import '../api/route/account.dart';
610
import '../api/route/realm.dart';
711
import '../api/route/users.dart';
12+
import '../log.dart';
13+
import '../model/binding.dart';
814
import '../model/store.dart';
915
import 'app.dart';
1016
import 'dialog.dart';
1117
import 'input.dart';
1218
import 'page.dart';
1319
import 'store.dart';
20+
import 'text.dart';
1421

1522
class _LoginSequenceRoute extends MaterialWidgetRoute<void> {
1623
_LoginSequenceRoute({
@@ -176,7 +183,6 @@ class _AddAccountPageState extends State<AddAccountPage> {
176183
return;
177184
}
178185

179-
// TODO(#36): support login methods beyond username/password
180186
Navigator.push(context,
181187
LoginPage.buildRoute(serverSettings: serverSettings));
182188
} finally {
@@ -240,18 +246,108 @@ class LoginPage extends StatefulWidget {
240246

241247
static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) {
242248
return _LoginSequenceRoute(
243-
page: LoginPage(serverSettings: serverSettings));
249+
page: LoginPage(serverSettings: serverSettings, key: _lastBuiltKey));
244250
}
245251

246252
final GetServerSettingsResult serverSettings;
247253

254+
/// Log in using the payload of a web-auth URL like zulip://login?…
255+
static Future<void> handleWebAuthUrl(Uri url) async {
256+
return _lastBuiltKey.currentState?.handleWebAuthUrl(url);
257+
}
258+
259+
/// A key for the page from the last [buildRoute] call.
260+
static final _lastBuiltKey = GlobalKey<_LoginPageState>();
261+
262+
/// The OTP to use, instead of an app-generated one, for testing.
263+
@visibleForTesting
264+
static String? debugOtpOverride;
265+
248266
@override
249267
State<LoginPage> createState() => _LoginPageState();
250268
}
251269

252270
class _LoginPageState extends State<LoginPage> {
253271
bool _inProgress = false;
254272

273+
String? get _otp {
274+
String? result;
275+
assert(() {
276+
result = LoginPage.debugOtpOverride;
277+
return true;
278+
}());
279+
return result ?? __otp;
280+
}
281+
String? __otp;
282+
283+
Future<void> handleWebAuthUrl(Uri url) async {
284+
setState(() {
285+
_inProgress = true;
286+
});
287+
try {
288+
await ZulipBinding.instance.closeInAppWebView();
289+
290+
if (_otp == null) throw Error();
291+
final payload = WebAuthPayload.parse(url);
292+
if (payload.realm.origin != widget.serverSettings.realmUrl.origin) throw Error();
293+
final apiKey = payload.decodeApiKey(_otp!);
294+
await _tryInsertAccountAndNavigate(
295+
// TODO(server-5): Rely on userId from payload.
296+
userId: payload.userId ?? await _getUserId(payload.email, apiKey),
297+
email: payload.email,
298+
apiKey: apiKey,
299+
);
300+
} catch (e) {
301+
assert(debugLog(e.toString()));
302+
if (!mounted) return;
303+
final zulipLocalizations = ZulipLocalizations.of(context);
304+
// Could show different error messages for different failure modes.
305+
await showErrorDialog(context: context,
306+
title: zulipLocalizations.errorWebAuthOperationalErrorTitle,
307+
message: zulipLocalizations.errorWebAuthOperationalError);
308+
} finally {
309+
setState(() {
310+
_inProgress = false;
311+
__otp = null;
312+
});
313+
}
314+
}
315+
316+
Future<void> _beginWebAuth(ExternalAuthenticationMethod method) async {
317+
__otp = generateOtp();
318+
try {
319+
final url = widget.serverSettings.realmUrl.resolve(method.loginUrl)
320+
.replace(queryParameters: {'mobile_flow_otp': _otp!});
321+
322+
// Could set [_inProgress]… but we'd need to unset it if the web-auth
323+
// attempt is aborted (by the user closing the browser, for example),
324+
// and I don't think we can reliably know when that happens.
325+
await ZulipBinding.instance.launchUrl(url, mode: LaunchMode.inAppBrowserView);
326+
} catch (e) {
327+
assert(debugLog(e.toString()));
328+
329+
if (e is PlatformException
330+
&& defaultTargetPlatform == TargetPlatform.iOS
331+
&& e.message != null && e.message!.startsWith('Error while launching')) {
332+
// Ignore; I've seen this on my iPhone even when auth succeeds.
333+
// Specifically, Apple web auth…which on iOS should be replaced by
334+
// Apple native auth; that's #462.
335+
// Possibly related:
336+
// https://github.com/flutter/flutter/issues/91660
337+
// but in that issue, people report authentication not succeeding.
338+
// TODO(#462) remove this?
339+
return;
340+
}
341+
342+
if (!mounted) return;
343+
final zulipLocalizations = ZulipLocalizations.of(context);
344+
// Could show different error messages for different failure modes.
345+
await showErrorDialog(context: context,
346+
title: zulipLocalizations.errorWebAuthOperationalErrorTitle,
347+
message: zulipLocalizations.errorWebAuthOperationalError);
348+
}
349+
}
350+
255351
Future<void> _tryInsertAccountAndNavigate({
256352
required String email,
257353
required String apiKey,
@@ -312,6 +408,26 @@ class _LoginPageState extends State<LoginPage> {
312408
assert(!PerAccountStoreWidget.debugExistsOf(context));
313409
final zulipLocalizations = ZulipLocalizations.of(context);
314410

411+
final externalAuthenticationMethods = widget.serverSettings.externalAuthenticationMethods;
412+
413+
final loginForm = Column(mainAxisAlignment: MainAxisAlignment.center, children: [
414+
_UsernamePasswordForm(loginPageState: this),
415+
if (externalAuthenticationMethods.isNotEmpty) ...[
416+
const OrDivider(),
417+
...externalAuthenticationMethods.map((method) {
418+
final icon = method.displayIcon;
419+
return OutlinedButton.icon(
420+
icon: icon != null
421+
? Image.network(icon, width: 24, height: 24)
422+
: null,
423+
onPressed: !_inProgress
424+
? () => _beginWebAuth(method)
425+
: null,
426+
label: Text(zulipLocalizations.signInWithFoo(method.displayName)));
427+
}),
428+
],
429+
]);
430+
315431
return Scaffold(
316432
appBar: AppBar(title: Text(zulipLocalizations.loginPageTitle),
317433
bottom: _inProgress
@@ -330,7 +446,7 @@ class _LoginPageState extends State<LoginPage> {
330446
// left or the right of this box
331447
child: ConstrainedBox(
332448
constraints: const BoxConstraints(maxWidth: 400),
333-
child: _UsernamePasswordForm(loginPageState: this)))))));
449+
child: loginForm))))));
334450
}
335451
}
336452

@@ -495,3 +611,31 @@ class _UsernamePasswordFormState extends State<_UsernamePasswordForm> {
495611
])));
496612
}
497613
}
614+
615+
// Loosely based on the corresponding element in the web app.
616+
class OrDivider extends StatelessWidget {
617+
const OrDivider({super.key});
618+
619+
@override
620+
Widget build(BuildContext context) {
621+
final zulipLocalizations = ZulipLocalizations.of(context);
622+
623+
const divider = Expanded(
624+
child: Divider(color: Color(0xffdedede), thickness: 2));
625+
626+
return Padding(
627+
padding: const EdgeInsets.symmetric(vertical: 10),
628+
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
629+
divider,
630+
Padding(
631+
padding: const EdgeInsets.symmetric(horizontal: 5),
632+
child: Text(zulipLocalizations.loginMethodDivider,
633+
textAlign: TextAlign.center,
634+
style: const TextStyle(
635+
color: Color(0xff575757),
636+
height: 1.5,
637+
).merge(weightVariableTextStyle(context, wght: 600)))),
638+
divider,
639+
]));
640+
}
641+
}

0 commit comments

Comments
 (0)