diff --git a/packages/firebase_ui_oauth/example/macos/Podfile b/packages/firebase_ui_oauth/example/macos/Podfile index 22d9caad..0c76ccf5 100644 --- a/packages/firebase_ui_oauth/example/macos/Podfile +++ b/packages/firebase_ui_oauth/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.12' +platform :osx, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/packages/firebase_ui_oauth/example/macos/Runner.xcodeproj/project.pbxproj b/packages/firebase_ui_oauth/example/macos/Runner.xcodeproj/project.pbxproj index dfe4a6c5..4bafbe89 100644 --- a/packages/firebase_ui_oauth/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/firebase_ui_oauth/example/macos/Runner.xcodeproj/project.pbxproj @@ -410,7 +410,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -434,7 +434,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -492,7 +492,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -539,7 +539,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -563,7 +563,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -586,7 +586,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; diff --git a/packages/firebase_ui_oauth/lib/firebase_ui_oauth.dart b/packages/firebase_ui_oauth/lib/firebase_ui_oauth.dart index b555dd22..0da83faa 100644 --- a/packages/firebase_ui_oauth/lib/firebase_ui_oauth.dart +++ b/packages/firebase_ui_oauth/lib/firebase_ui_oauth.dart @@ -4,15 +4,11 @@ export 'package:firebase_auth/firebase_auth.dart' show OAuthCredential; -// Re-export Wasm-compatible libraries instead of `desktop_webview_auth`, -// which imports `dart:io`. -// ignore: implementation_imports -export 'package:desktop_webview_auth/src/auth_result.dart' show AuthResult; -// ignore: implementation_imports -export 'package:desktop_webview_auth/src/provider_args.dart' show ProviderArgs; -export 'package:desktop_webview_auth/google.dart'; -export 'package:desktop_webview_auth/facebook.dart'; -export 'package:desktop_webview_auth/twitter.dart'; +export './src/oauth/auth_result.dart'; +export './src/oauth/provider_args.dart'; +export './src/oauth/google_sign_in_args.dart'; +export './src/oauth/facebook_sign_in_args.dart'; +export './src/oauth/twitter_sign_in_args.dart'; export './src/oauth_provider.dart'; export './src/oauth_provider_button_base.dart'; diff --git a/packages/firebase_ui_oauth/lib/src/oauth/auth_result.dart b/packages/firebase_ui_oauth/lib/src/oauth/auth_result.dart new file mode 100644 index 00000000..c26f54d6 --- /dev/null +++ b/packages/firebase_ui_oauth/lib/src/oauth/auth_result.dart @@ -0,0 +1,18 @@ +// Copyright 2022, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// The result of a desktop OAuth sign-in flow. +class AuthResult { + final String? accessToken; + final String? idToken; + final String? tokenSecret; + + const AuthResult({this.accessToken, this.idToken, this.tokenSecret}); + + @override + String toString() { + return 'AuthResult(idToken: $idToken, accessToken: $accessToken, ' + 'tokenSecret: $tokenSecret)'; + } +} diff --git a/packages/firebase_ui_oauth/lib/src/oauth/facebook_sign_in_args.dart b/packages/firebase_ui_oauth/lib/src/oauth/facebook_sign_in_args.dart new file mode 100644 index 00000000..e1e2e973 --- /dev/null +++ b/packages/firebase_ui_oauth/lib/src/oauth/facebook_sign_in_args.dart @@ -0,0 +1,56 @@ +// Copyright 2022, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'auth_result.dart'; +import 'oauth_util.dart'; +import 'provider_args.dart'; + +const _responseType = 'token'; + +class FacebookSignInArgs extends ProviderArgs { + final String clientId; + + @override + final String redirectUri; + + @override + final host = 'www.facebook.com'; + + @override + final path = '/v12.0/dialog/oauth'; + + FacebookSignInArgs({required this.clientId, required this.redirectUri}); + + String state = ''; + + @override + Map buildQueryParameters() { + state = generateNonce(); + + return { + 'client_id': clientId, + 'redirect_uri': redirectUri, + 'state': state, + 'response_type': _responseType, + }; + } + + /// Validates the `state` echoed back by Facebook against the one sent in + /// [buildQueryParameters] before accepting the callback, to guard against + /// CSRF: an attacker tricking the app into completing a sign-in the user + /// never started. + @override + Future authorizeFromCallback(String callbackUrl) async { + final uri = Uri.parse(callbackUrl); + final args = usesFragment + ? Uri.splitQueryString(uri.fragment) + : uri.queryParameters; + + if (args['state'] != state) { + throw Exception('OAuth state mismatch, possible CSRF attempt'); + } + + return super.authorizeFromCallback(callbackUrl); + } +} diff --git a/packages/firebase_ui_oauth/lib/src/oauth/google_sign_in_args.dart b/packages/firebase_ui_oauth/lib/src/oauth/google_sign_in_args.dart new file mode 100644 index 00000000..af29b7b3 --- /dev/null +++ b/packages/firebase_ui_oauth/lib/src/oauth/google_sign_in_args.dart @@ -0,0 +1,44 @@ +// Copyright 2022, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'oauth_util.dart'; +import 'provider_args.dart'; + +const _defaultSignInScope = 'https://www.googleapis.com/auth/plus.login'; + +class GoogleSignInArgs extends ProviderArgs { + final String clientId; + final String scope; + final bool immediate; + final String responseType; + + @override + final String redirectUri; + + @override + final host = 'accounts.google.com'; + + @override + final path = '/o/oauth2/v2/auth'; + + GoogleSignInArgs({ + required this.clientId, + required this.redirectUri, + this.scope = _defaultSignInScope, + this.immediate = false, + this.responseType = 'token id_token', + }); + + @override + Map buildQueryParameters() { + return { + 'client_id': clientId, + 'scope': scope, + 'immediate': immediate.toString(), + 'response_type': responseType, + 'redirect_uri': redirectUri, + 'nonce': generateNonce(), + }; + } +} diff --git a/packages/firebase_ui_oauth/lib/src/oauth/oauth_util.dart b/packages/firebase_ui_oauth/lib/src/oauth/oauth_util.dart new file mode 100644 index 00000000..b9832e1d --- /dev/null +++ b/packages/firebase_ui_oauth/lib/src/oauth/oauth_util.dart @@ -0,0 +1,18 @@ +// Copyright 2022, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math'; + +/// Generates a cryptographically secure random nonce, to be included in a +/// credential request. +String generateNonce([int length = 32]) { + const chars = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + final random = Random.secure(); + + return List.generate( + length, + (_) => chars[random.nextInt(chars.length)], + ).join(); +} diff --git a/packages/firebase_ui_oauth/lib/src/oauth/provider_args.dart b/packages/firebase_ui_oauth/lib/src/oauth/provider_args.dart new file mode 100644 index 00000000..62dddef5 --- /dev/null +++ b/packages/firebase_ui_oauth/lib/src/oauth/provider_args.dart @@ -0,0 +1,48 @@ +// Copyright 2022, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'auth_result.dart'; + +/// Describes how to build the sign-in URL for a desktop OAuth flow, and how +/// to turn the resulting callback URL into an [AuthResult]. +abstract class ProviderArgs { + String get redirectUri; + String get host; + String get path; + + Map buildQueryParameters(); + + Future buildSignInUri() async { + final uri = Uri( + scheme: 'https', + host: host, + path: path, + queryParameters: buildQueryParameters(), + ); + + return uri.toString(); + } + + bool usesFragment = true; + + Future authorizeFromCallback(String callbackUrl) async { + final uri = Uri.parse(callbackUrl); + late Map args; + + if (usesFragment) { + args = Uri.splitQueryString(uri.fragment); + } else { + args = uri.queryParameters; + } + + if (args.containsKey('access_token') || args.containsKey('id_token')) { + return AuthResult( + accessToken: args['access_token'], + idToken: args['id_token'], + ); + } + + throw Exception('No access token found'); + } +} diff --git a/packages/firebase_ui_oauth/lib/src/oauth/twitter_sign_in_args.dart b/packages/firebase_ui_oauth/lib/src/oauth/twitter_sign_in_args.dart new file mode 100644 index 00000000..da660013 --- /dev/null +++ b/packages/firebase_ui_oauth/lib/src/oauth/twitter_sign_in_args.dart @@ -0,0 +1,214 @@ +// Copyright 2022, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; + +import 'auth_result.dart'; +import 'oauth_util.dart'; +import 'provider_args.dart'; + +const _requestTokenPath = '/oauth/request_token'; +const _accessTokenPath = '/oauth/access_token'; + +const _kSignatureMethod = 'HMAC-SHA1'; +const _kOAuthVersion = '1.0'; + +/// Builds the Twitter OAuth 1.0a sign-in URL and exchanges the callback for +/// an access token. +class TwitterSignInArgs extends ProviderArgs { + final String apiKey; + final String apiSecretKey; + + @override + final String redirectUri; + + @override + final host = 'api.twitter.com'; + + @override + final path = '/oauth/authorize'; + + TwitterSignInArgs({ + required this.apiKey, + required this.apiSecretKey, + required this.redirectUri, + }); + + late String token; + String _tokenSecret = ''; + + @override + Map buildQueryParameters() { + return {'oauth_token': token}; + } + + @override + Future buildSignInUri() async { + final requestToken = await getRequestToken(); + token = requestToken.token; + _tokenSecret = requestToken.secret; + return super.buildSignInUri(); + } + + @override + Future authorizeFromCallback(String callbackUrl) async { + final parsed = Uri.parse(callbackUrl); + final oauthToken = parsed.queryParameters['oauth_token']; + final oauthVerifier = parsed.queryParameters['oauth_verifier']; + + // The user denied consent (Twitter redirects with `denied=` and + // no `oauth_verifier` in that case), or the callback is malformed. + if (oauthToken == null || oauthVerifier == null) return null; + + final res = await _post(_accessTokenPath, { + 'oauth_token': oauthToken, + 'oauth_verifier': oauthVerifier, + }, tokenSecret: _tokenSecret); + + if (res == null) throw Exception("Couldn't authroize"); + + final decodedRes = Uri.splitQueryString(res); + + return AuthResult( + accessToken: decodedRes['oauth_token'], + tokenSecret: decodedRes['oauth_token_secret'], + ); + } + + Future<({String token, String secret})> getRequestToken() async { + final res = await _post(_requestTokenPath, { + 'oauth_callback': Uri.encodeFull(redirectUri), + }); + + if (res == null) { + throw Exception("Couldn't get Twitter request token: empty response"); + } + + final body = Uri.splitQueryString(res); + final requestToken = body['oauth_token']; + final requestTokenSecret = body['oauth_token_secret']; + + if (requestToken == null || requestTokenSecret == null) { + throw Exception( + "Couldn't get Twitter request token: response missing " + 'oauth_token/oauth_token_secret ($body)', + ); + } + + return (token: requestToken, secret: requestTokenSecret); + } + + /// [tokenSecret] is the OAuth 1.0a token secret used to derive the request + /// signing key. It must never be sent as a request parameter, so it's kept + /// separate from [params] rather than smuggled inside that map. + Future _post( + String path, + Map params, { + String tokenSecret = '', + }) async { + final uri = Uri(scheme: 'https', host: host, path: path); + + final authorization = _buildAuthHeader( + method: 'POST', + uri: uri, + params: params, + requestSecretKey: tokenSecret, + ); + + final res = await http.post(uri, headers: {'Authorization': authorization}); + + if (res.statusCode == 200) { + return res.body; + } else { + throw Exception('HttpCode: ${res.statusCode}, Body: ${res.body}'); + } + } + + String _buildAuthHeader({ + required String method, + required Uri uri, + required Map params, + required String requestSecretKey, + }) { + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final nonce = generateNonce(); + + final signature = _createSignature( + method: method, + uri: uri, + timestamp: timestamp, + nonce: nonce, + params: params, + requestSecretKey: requestSecretKey, + ); + + final authComponents = [ + 'OAuth oauth_consumer_key="$apiKey"', + 'oauth_nonce="$nonce"', + 'oauth_signature="$signature"', + 'oauth_signature_method="$_kSignatureMethod"', + 'oauth_timestamp="$timestamp"', + 'oauth_version="$_kOAuthVersion"', + for (var key in params.keys) + '$key="${Uri.encodeComponent(params[key]!)}"', + ]; + + authComponents.sort(); + + return authComponents.join(', '); + } + + // https://developer.twitter.com/en/docs/authentication/oauth-1-0a/creating-a-signature + String _createSignature({ + required String method, + required Uri uri, + required int timestamp, + required String nonce, + required Map params, + String requestSecretKey = '', + }) { + final signatureParams = { + ...params, + 'oauth_consumer_key': apiKey, + 'oauth_nonce': nonce, + 'oauth_signature_method': _kSignatureMethod, + 'oauth_timestamp': timestamp, + 'oauth_version': _kOAuthVersion, + }; + + var paramString = ''; + + final sortedKeys = signatureParams.keys.toList()..sort(); + + for (var key in sortedKeys) { + if (paramString.isNotEmpty) { + paramString += '&'; + } + + paramString += key; + paramString += '='; + paramString += Uri.encodeComponent(signatureParams[key]!.toString()); + } + + final encodedUri = Uri.encodeComponent(uri.toString()); + final encodedParamString = Uri.encodeComponent(paramString); + + final signatureBaseString = + '${method.toUpperCase()}&$encodedUri&$encodedParamString'; + + final encodedSecretKey = Uri.encodeComponent(apiSecretKey); + final encodedSecretRequestKey = Uri.encodeComponent(requestSecretKey); + + final signingKey = '$encodedSecretKey&$encodedSecretRequestKey'; + + final hmacSha1 = Hmac(sha1, signingKey.codeUnits); + final digest = hmacSha1.convert(signatureBaseString.codeUnits); + final signature = base64.encode(digest.bytes); + + return Uri.encodeComponent(signature); + } +} diff --git a/packages/firebase_ui_oauth/lib/src/platform_oauth_sign_in.dart b/packages/firebase_ui_oauth/lib/src/platform_oauth_sign_in.dart index 8ae84ecc..3bb790b7 100644 --- a/packages/firebase_ui_oauth/lib/src/platform_oauth_sign_in.dart +++ b/packages/firebase_ui_oauth/lib/src/platform_oauth_sign_in.dart @@ -2,13 +2,34 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:desktop_webview_auth/desktop_webview_auth.dart'; +import 'dart:io' show Platform; + import 'package:firebase_auth/firebase_auth.dart' as fba; -import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:firebase_ui_auth/firebase_ui_auth.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'oauth/auth_result.dart'; +import 'oauth/provider_args.dart'; import 'oauth_provider.dart'; +/// flutter_web_auth_2 can only match an `https` callback URL by host/path +/// (rather than completing on the first `https` navigation it sees) from +/// macOS 14.4 onward. Below that, desktop OAuth sign-in via the Firebase +/// hosted auth handler is not reliable. +/// See https://pub.dev/packages/flutter_web_auth_2. +bool _macOSSupportsHttpsCallbackMatching() { + final match = RegExp( + r'(\d+)\.(\d+)', + ).firstMatch(Platform.operatingSystemVersion); + if (match == null) return false; + + final major = int.parse(match.group(1)!); + final minor = int.parse(match.group(2)!); + return major > 14 || (major == 14 && minor >= 4); +} + /// {@template ui.oauth.platform_sign_in_mixin} /// A helper mixin that implements the platform-specific sign-in logic. /// {@endtemplate} @@ -36,22 +57,57 @@ mixin PlatformSignInMixin { } /// Handles authentication logic on desktop platforms - void desktopSignIn(AuthAction action) { - DesktopWebviewAuth.signIn(desktopSignInArgs) - .then((value) { - if (value == null) throw AuthCancelledException(); - - final oauthCredential = fromDesktopAuthResult(value); - onCredentialReceived(oauthCredential, action); - }) - .catchError((err) { - if (err is AuthCancelledException) { - authListener.onCanceled(); - return; - } - - authListener.onError(err); - }); + void desktopSignIn(AuthAction action) async { + try { + final args = desktopSignInArgs; + final redirectUri = Uri.parse(args.redirectUri); + final isHttpsCallback = redirectUri.scheme == 'https'; + + if (isHttpsCallback && + defaultTargetPlatform == TargetPlatform.macOS && + !_macOSSupportsHttpsCallbackMatching()) { + throw UnsupportedError( + 'Desktop OAuth sign-in requires macOS 14.4 or later. Below that ' + "version, flutter_web_auth_2 can't reliably match the OAuth " + 'callback URL and the sign-in flow would silently fail.', + ); + } + + final signInUri = await args.buildSignInUri(); + + final callbackUrl = await FlutterWebAuth2.authenticate( + url: signInUri, + callbackUrlScheme: redirectUri.scheme, + options: FlutterWebAuth2Options( + // httpsHost/httpsPath only apply to `https` callbacks (Universal + // Links); passing them for a custom-scheme redirectUri would send + // its (empty) host/path as if they were meaningful HTTPS values. + httpsHost: isHttpsCallback ? redirectUri.host : null, + httpsPath: isHttpsCallback ? redirectUri.path : null, + useWebview: true, + ), + ); + + final value = await args.authorizeFromCallback(callbackUrl); + if (value == null) throw AuthCancelledException(); + + final oauthCredential = fromDesktopAuthResult(value); + onCredentialReceived(oauthCredential, action); + } on PlatformException catch (err) { + if (err.code == 'CANCELED') { + authListener.onCanceled(); + return; + } + + authListener.onError(err); + } catch (err) { + if (err is AuthCancelledException) { + authListener.onCanceled(); + return; + } + + authListener.onError(err); + } } /// Handles authentication logic on mobile platforms. diff --git a/packages/firebase_ui_oauth/pubspec.yaml b/packages/firebase_ui_oauth/pubspec.yaml index 8c8efb94..1cc5c160 100644 --- a/packages/firebase_ui_oauth/pubspec.yaml +++ b/packages/firebase_ui_oauth/pubspec.yaml @@ -9,11 +9,13 @@ environment: sdk: ^3.9.0 dependencies: - desktop_webview_auth: ^0.0.16 + crypto: ^3.0.3 firebase_auth: ^6.5.4 firebase_ui_auth: ^3.1.0 firebase_ui_shared: ^1.5.0 flutter_svg: ^2.0.9 + flutter_web_auth_2: ^5.1.0 + http: ^1.6.0 flutter: sdk: flutter diff --git a/tests/android/app/build.gradle b/tests/android/app/build.gradle index 326533d2..b236c9ca 100644 --- a/tests/android/app/build.gradle +++ b/tests/android/app/build.gradle @@ -44,3 +44,19 @@ android { flutter { source = "../.." } + +configurations.all { + resolutionStrategy { + // flutter_web_auth_2 (used by firebase_ui_oauth) directly depends on + // androidx.browser 1.9.0 and androidx.activity:activity-ktx 1.10.1, + // both of which require AGP 8.9.1+ (as does the androidx.core they + // pull in transitively). Force the whole set down to older, + // mutually-compatible versions until AGP can be bumped (blocked on + // twitter_login releasing a fix, see the compileSdk comment above). + force "androidx.browser:browser:1.8.0" + force "androidx.activity:activity-ktx:1.9.3" + force "androidx.activity:activity:1.9.3" + force "androidx.core:core:1.13.1" + force "androidx.core:core-ktx:1.13.1" + } +}