Skip to content

Commit

Permalink
login: Support web-based auth methods
Browse files Browse the repository at this point in the history
Fixes: #36
  • Loading branch information
chrisbobbe committed Mar 28, 2024
1 parent 08825f2 commit 6ac0536
Show file tree
Hide file tree
Showing 7 changed files with 362 additions and 5 deletions.
7 changes: 7 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
<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
19 changes: 19 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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."
Expand Down
13 changes: 13 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,21 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.zulip.flutter</string>
<key>CFBundleURLSchemes</key>
<array>
<string>zulip</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
Expand Down
88 changes: 88 additions & 0 deletions lib/api/model/web_auth.dart
Original file line number Diff line number Diff line change
@@ -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]));
}
25 changes: 24 additions & 1 deletion lib/widgets/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,30 @@ class ZulipApp extends StatefulWidget {
State<ZulipApp> createState() => _ZulipAppState();
}

class _ZulipAppState extends State<ZulipApp> {
class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
@override
Future<bool> 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(
Expand Down
137 changes: 134 additions & 3 deletions lib/widgets/login.dart
Original file line number Diff line number Diff line change
@@ -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<void> {
_LoginSequenceRoute({
Expand Down Expand Up @@ -176,7 +182,6 @@ class _AddAccountPageState extends State<AddAccountPage> {
return;
}

// TODO(#36): support login methods beyond username/password
Navigator.push(context,
LoginPage.buildRoute(serverSettings: serverSettings));
} finally {
Expand Down Expand Up @@ -238,11 +243,14 @@ class _AddAccountPageState extends State<AddAccountPage> {
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<LoginPageState>();

final GetServerSettingsResult serverSettings;

static Route<void> buildRoute({required GetServerSettingsResult serverSettings}) {
return _LoginSequenceRoute(
page: LoginPage(serverSettings: serverSettings));
page: LoginPage(serverSettings: serverSettings, key: lastBuiltKey));
}

@override
Expand All @@ -252,6 +260,83 @@ class LoginPage extends StatefulWidget {
class LoginPageState extends State<LoginPage> {
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<void> 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<void> _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<void> _tryInsertAccountAndNavigate({
required String email,
required String apiKey,
Expand Down Expand Up @@ -312,6 +397,8 @@ class LoginPageState extends State<LoginPage> {
assert(!PerAccountStoreWidget.debugExistsOf(context));
final zulipLocalizations = ZulipLocalizations.of(context);

final externalAuthenticationMethods = widget.serverSettings.externalAuthenticationMethods;

return Scaffold(
appBar: AppBar(title: Text(zulipLocalizations.loginPageTitle),
bottom: _inProgress
Expand All @@ -330,7 +417,23 @@ class LoginPageState extends State<LoginPage> {
// 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)));
}),
],
])))))));
}
}

Expand Down Expand Up @@ -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,
]));
}
}
Loading

0 comments on commit 6ac0536

Please sign in to comment.