Skip to content

Commit

Permalink
login: Support web-based auth methods
Browse files Browse the repository at this point in the history
Fixes: zulip#36
  • Loading branch information
chrisbobbe committed Mar 29, 2024
1 parent 2f9bedb commit bb76802
Show file tree
Hide file tree
Showing 8 changed files with 453 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": "Something went wrong",
"@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. 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
87 changes: 87 additions & 0 deletions lib/api/model/web_auth.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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: {
'realm': String realmStr,
'email': String email,
// 'user_id' handled below
'otp_encrypted_api_key': String otpEncryptedApiKey,
},
)
) {
// TODO(server-5) require in queryParameters (new in FL 108)
final userIdStr = url.queryParameters['user_id'];
int? userId;
if (userIdStr != null) {
userId = int.tryParse(userIdStr, radix: 10);
if (userId == null) throw const FormatException();
}

final Uri? realm = Uri.tryParse(realmStr);
if (realm == null) throw const FormatException();

if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(otpEncryptedApiKey)) {
throw const FormatException();
}

return WebAuthPayload._(
otpEncryptedApiKey: otpEncryptedApiKey,
email: email,
userId: userId,
realm: realm,
);
} else {
// TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
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.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
Loading

0 comments on commit bb76802

Please sign in to comment.