Skip to content

Commit bb76802

Browse files
committed
login: Support web-based auth methods
Fixes: zulip#36
1 parent 2f9bedb commit bb76802

File tree

8 files changed

+453
-5
lines changed

8 files changed

+453
-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 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+
'realm': String realmStr,
27+
'email': String email,
28+
// 'user_id' handled below
29+
'otp_encrypted_api_key': String otpEncryptedApiKey,
30+
},
31+
)
32+
) {
33+
// TODO(server-5) require in queryParameters (new in FL 108)
34+
final userIdStr = url.queryParameters['user_id'];
35+
int? userId;
36+
if (userIdStr != null) {
37+
userId = int.tryParse(userIdStr, radix: 10);
38+
if (userId == null) throw const FormatException();
39+
}
40+
41+
final Uri? realm = Uri.tryParse(realmStr);
42+
if (realm == null) throw const FormatException();
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(

0 commit comments

Comments
 (0)