From 642742c7a0d2bb3733a18cce3987dc1e88ed2144 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:29:43 +0100 Subject: [PATCH 1/7] fix(ui_oauth): replace desktop_webview_auth with flutter_web_auth_2 desktop_webview_auth is unmaintained and its archived repo cannot ship a Package.swift for iOS, which breaks Swift Package Manager builds for any app depending on firebase_ui_auth or firebase_ui_oauth. Rewires the desktop OAuth sign-in flow in firebase_ui_oauth to use flutter_web_auth_2 instead, vendoring the provider URL-building and callback-parsing logic that desktop_webview_auth previously supplied so the google/facebook/twitter provider packages need no changes. --- .../lib/firebase_ui_oauth.dart | 14 +- .../lib/src/oauth/auth_result.dart | 18 ++ .../lib/src/oauth/facebook_sign_in_args.dart | 37 ++++ .../lib/src/oauth/google_sign_in_args.dart | 44 ++++ .../lib/src/oauth/oauth_util.dart | 17 ++ .../lib/src/oauth/provider_args.dart | 48 +++++ .../lib/src/oauth/twitter_sign_in_args.dart | 198 ++++++++++++++++++ .../lib/src/platform_oauth_sign_in.dart | 57 +++-- packages/firebase_ui_oauth/pubspec.yaml | 4 +- 9 files changed, 409 insertions(+), 28 deletions(-) create mode 100644 packages/firebase_ui_oauth/lib/src/oauth/auth_result.dart create mode 100644 packages/firebase_ui_oauth/lib/src/oauth/facebook_sign_in_args.dart create mode 100644 packages/firebase_ui_oauth/lib/src/oauth/google_sign_in_args.dart create mode 100644 packages/firebase_ui_oauth/lib/src/oauth/oauth_util.dart create mode 100644 packages/firebase_ui_oauth/lib/src/oauth/provider_args.dart create mode 100644 packages/firebase_ui_oauth/lib/src/oauth/twitter_sign_in_args.dart 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..2fa9815e --- /dev/null +++ b/packages/firebase_ui_oauth/lib/src/oauth/facebook_sign_in_args.dart @@ -0,0 +1,37 @@ +// 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 _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, + }; + } +} 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..d9ca2d84 --- /dev/null +++ b/packages/firebase_ui_oauth/lib/src/oauth/oauth_util.dart @@ -0,0 +1,17 @@ +// 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 = '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz'; + 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..b118e5e7 --- /dev/null +++ b/packages/firebase_ui_oauth/lib/src/oauth/twitter_sign_in_args.dart @@ -0,0 +1,198 @@ +// 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; + + @override + Map buildQueryParameters() { + return {'oauth_token': token}; + } + + @override + Future buildSignInUri() async { + token = await getRequestToken(); + return super.buildSignInUri(); + } + + @override + Future authorizeFromCallback(String callbackUrl) async { + final parsed = Uri.parse(callbackUrl); + final oauthToken = parsed.queryParameters['oauth_token'] as String; + final oauthVerifier = parsed.queryParameters['oauth_verifier'] as String; + + final res = await _post(_accessTokenPath, { + 'oauth_token': oauthToken, + 'oauth_verifier': oauthVerifier, + }); + + 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 getRequestToken() async { + try { + final res = await _post(_requestTokenPath, { + 'oauth_callback': Uri.encodeFull(redirectUri), + }); + + if (res == null) throw Exception(); + + final body = Uri.splitQueryString(res); + + if (body.containsKey('oauth_token')) { + return body['oauth_token'] as String; + } else { + throw Exception(); + } + } on Exception catch (_) { + throw Exception("Couldn't get request token"); + } + } + + Future _post(String path, Map params) async { + final uri = Uri(scheme: 'https', host: host, path: path); + + final authorization = _buildAuthHeader( + method: 'POST', + uri: uri, + params: params, + ); + + 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, + }) { + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final nonce = generateNonce(); + final requestSecretKey = params['oauth_token_secret']; + + final signature = _createSignature( + method: method, + uri: uri, + timestamp: timestamp, + nonce: nonce, + params: params, + requestSecretKey: requestSecretKey ?? '', + ); + + final paramsClone = Map.from(params); + + 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 paramsClone.keys) + '$key="${Uri.encodeComponent(paramsClone[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..f74f3490 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,11 +2,13 @@ // 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 'package:firebase_auth/firebase_auth.dart' as fba; -import 'package:flutter/widgets.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'; /// {@template ui.oauth.platform_sign_in_mixin} @@ -36,22 +38,41 @@ 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 signInUri = await args.buildSignInUri(); + final redirectUri = Uri.parse(args.redirectUri); + + final callbackUrl = await FlutterWebAuth2.authenticate( + url: signInUri, + callbackUrlScheme: redirectUri.scheme, + options: FlutterWebAuth2Options( + httpsHost: redirectUri.host, + httpsPath: redirectUri.path, + ), + ); + + 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 b304c671..87a2d52b 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.1 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 From d47862952a0ae579999459ba5bf9d1f462413a5d Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:51:31 +0100 Subject: [PATCH 2/7] fix(ui_oauth): fix macOS callback matching and Twitter OAuth1.0a signing A fresh review of the flutter_web_auth_2 migration found two bugs. flutter_web_auth_2 only matches an https callback URL by host/path from macOS 14.4 onward; below that it completes on the first https navigation it sees, well before the OAuth provider's real redirect, so Facebook/Twitter desktop sign-in would silently break on older macOS. desktopSignIn now checks the OS version and fails loudly instead. The vendored Twitter OAuth1.0a access-token exchange also signed requests with an empty token secret, since the request token's secret was discarded instead of threaded through to the signing step, and a denied-consent callback crashed on an unsafe cast instead of cancelling cleanly. Both are fixed. --- .../lib/src/oauth/twitter_sign_in_args.dart | 45 ++++++++++++------- .../lib/src/platform_oauth_sign_in.dart | 29 ++++++++++++ 2 files changed, 59 insertions(+), 15 deletions(-) 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 index b118e5e7..3f90cf06 100644 --- 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 @@ -39,6 +39,7 @@ class TwitterSignInArgs extends ProviderArgs { }); late String token; + String _tokenSecret = ''; @override Map buildQueryParameters() { @@ -47,20 +48,26 @@ class TwitterSignInArgs extends ProviderArgs { @override Future buildSignInUri() async { - token = await getRequestToken(); + 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'] as String; - final oauthVerifier = parsed.queryParameters['oauth_verifier'] as String; + 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"); @@ -72,7 +79,7 @@ class TwitterSignInArgs extends ProviderArgs { ); } - Future getRequestToken() async { + Future<({String token, String secret})> getRequestToken() async { try { final res = await _post(_requestTokenPath, { 'oauth_callback': Uri.encodeFull(redirectUri), @@ -81,24 +88,34 @@ class TwitterSignInArgs extends ProviderArgs { if (res == null) throw Exception(); final body = Uri.splitQueryString(res); + final requestToken = body['oauth_token']; + final requestTokenSecret = body['oauth_token_secret']; - if (body.containsKey('oauth_token')) { - return body['oauth_token'] as String; - } else { + if (requestToken == null || requestTokenSecret == null) { throw Exception(); } + + return (token: requestToken, secret: requestTokenSecret); } on Exception catch (_) { throw Exception("Couldn't get request token"); } } - Future _post(String path, Map params) async { + /// [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}); @@ -114,10 +131,10 @@ class TwitterSignInArgs extends ProviderArgs { required String method, required Uri uri, required Map params, + required String requestSecretKey, }) { final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; final nonce = generateNonce(); - final requestSecretKey = params['oauth_token_secret']; final signature = _createSignature( method: method, @@ -125,11 +142,9 @@ class TwitterSignInArgs extends ProviderArgs { timestamp: timestamp, nonce: nonce, params: params, - requestSecretKey: requestSecretKey ?? '', + requestSecretKey: requestSecretKey, ); - final paramsClone = Map.from(params); - final authComponents = [ 'OAuth oauth_consumer_key="$apiKey"', 'oauth_nonce="$nonce"', @@ -137,8 +152,8 @@ class TwitterSignInArgs extends ProviderArgs { 'oauth_signature_method="$_kSignatureMethod"', 'oauth_timestamp="$timestamp"', 'oauth_version="$_kOAuthVersion"', - for (var key in paramsClone.keys) - '$key="${Uri.encodeComponent(paramsClone[key])}"', + for (var key in params.keys) + '$key="${Uri.encodeComponent(params[key]!)}"', ]; authComponents.sort(); 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 f74f3490..da2f41d9 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,7 +2,10 @@ // 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:io' show Platform; + import 'package:firebase_auth/firebase_auth.dart' as fba; +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'; @@ -11,6 +14,22 @@ 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} @@ -40,6 +59,15 @@ mixin PlatformSignInMixin { /// Handles authentication logic on desktop platforms void desktopSignIn(AuthAction action) async { try { + if (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 args = desktopSignInArgs; final signInUri = await args.buildSignInUri(); final redirectUri = Uri.parse(args.redirectUri); @@ -50,6 +78,7 @@ mixin PlatformSignInMixin { options: FlutterWebAuth2Options( httpsHost: redirectUri.host, httpsPath: redirectUri.path, + useWebview: true, ), ); From a1cbfc41d38ff189effc5a2159e8c8428fb8ff1b Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:51:39 +0100 Subject: [PATCH 3/7] fix(ui_oauth): bump example's macOS deployment target packages/firebase_ui_oauth/example/macos still targeted macOS 10.12, stale against the installed Flutter SDK's own CocoaPods requirements and below the minimum current Xcode toolchains support, so flutter build macos failed at pod install regardless of any other dependency. Bumped the Podfile and Xcode project deployment target to 12.0 to match. --- packages/firebase_ui_oauth/example/macos/Podfile | 2 +- .../example/macos/Runner.xcodeproj/project.pbxproj | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) 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; }; From 349de2bab6b2bf38104fd8cd51fdb1cd0acca6f9 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:59:07 +0100 Subject: [PATCH 4/7] fix(ui_oauth): validate Facebook OAuth state and fix nonce charset Addresses gemini-code-assist findings on #693: FacebookSignInArgs generated a state nonce but never checked it against the callback, leaving the flow open to CSRF; authorizeFromCallback now rejects a mismatched or missing state before accepting the result. Also fixes the nonce character set (was missing the letter W), and stops passing FlutterWebAuth2Options.httpsHost/httpsPath for a non-https redirectUri (e.g. Twitter's custom-scheme callback), which would otherwise pass HTTPS-only options for a URL that has neither. --- .../lib/src/oauth/facebook_sign_in_args.dart | 19 +++++++++++++++++++ .../lib/src/oauth/oauth_util.dart | 3 ++- .../lib/src/platform_oauth_sign_in.dart | 16 +++++++++++----- 3 files changed, 32 insertions(+), 6 deletions(-) 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 index 2fa9815e..e1e2e973 100644 --- 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 @@ -2,6 +2,7 @@ // 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'; @@ -34,4 +35,22 @@ class FacebookSignInArgs extends ProviderArgs { '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/oauth_util.dart b/packages/firebase_ui_oauth/lib/src/oauth/oauth_util.dart index d9ca2d84..b9832e1d 100644 --- a/packages/firebase_ui_oauth/lib/src/oauth/oauth_util.dart +++ b/packages/firebase_ui_oauth/lib/src/oauth/oauth_util.dart @@ -7,7 +7,8 @@ import 'dart:math'; /// Generates a cryptographically secure random nonce, to be included in a /// credential request. String generateNonce([int length = 32]) { - const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz'; + const chars = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; final random = Random.secure(); return List.generate( 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 da2f41d9..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 @@ -59,7 +59,12 @@ mixin PlatformSignInMixin { /// Handles authentication logic on desktop platforms void desktopSignIn(AuthAction action) async { try { - if (defaultTargetPlatform == TargetPlatform.macOS && + 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 ' @@ -68,16 +73,17 @@ mixin PlatformSignInMixin { ); } - final args = desktopSignInArgs; final signInUri = await args.buildSignInUri(); - final redirectUri = Uri.parse(args.redirectUri); final callbackUrl = await FlutterWebAuth2.authenticate( url: signInUri, callbackUrlScheme: redirectUri.scheme, options: FlutterWebAuth2Options( - httpsHost: redirectUri.host, - httpsPath: redirectUri.path, + // 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, ), ); From a92684b51071450c43c6cca61739182a7ea3cf3a Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:11:17 +0100 Subject: [PATCH 5/7] fix(ui_oauth): pin androidx.browser to fix android e2e, improve error message flutter_web_auth_2 pulls in androidx.browser:browser:1.9.0 on Android, which requires Android Gradle Plugin 8.9.1+. tests/android is intentionally pinned to AGP 8.7.3 pending a twitter_login release (see the compileSdk comment in tests/android/app/build.gradle), so bumping AGP isn't safe here. Force androidx.browser down to 1.8.0 instead, which doesn't carry that requirement. Also stops swallowing the underlying HTTP failure when Twitter's request-token exchange fails, per review feedback. --- .../lib/src/oauth/twitter_sign_in_args.dart | 31 ++++++++++--------- tests/android/app/build.gradle | 11 +++++++ 2 files changed, 27 insertions(+), 15 deletions(-) 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 index 3f90cf06..da660013 100644 --- 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 @@ -80,25 +80,26 @@ class TwitterSignInArgs extends ProviderArgs { } Future<({String token, String secret})> getRequestToken() async { - try { - final res = await _post(_requestTokenPath, { - 'oauth_callback': Uri.encodeFull(redirectUri), - }); + final res = await _post(_requestTokenPath, { + 'oauth_callback': Uri.encodeFull(redirectUri), + }); - if (res == null) throw Exception(); - - final body = Uri.splitQueryString(res); - final requestToken = body['oauth_token']; - final requestTokenSecret = body['oauth_token_secret']; + if (res == null) { + throw Exception("Couldn't get Twitter request token: empty response"); + } - if (requestToken == null || requestTokenSecret == null) { - throw Exception(); - } + final body = Uri.splitQueryString(res); + final requestToken = body['oauth_token']; + final requestTokenSecret = body['oauth_token_secret']; - return (token: requestToken, secret: requestTokenSecret); - } on Exception catch (_) { - throw Exception("Couldn't get request token"); + 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 diff --git a/tests/android/app/build.gradle b/tests/android/app/build.gradle index b495ca5b..fe678909 100644 --- a/tests/android/app/build.gradle +++ b/tests/android/app/build.gradle @@ -43,3 +43,14 @@ android { flutter { source = "../.." } + +configurations.all { + resolutionStrategy { + // flutter_web_auth_2 (used by firebase_ui_oauth) pulls in + // androidx.browser 1.9.0, which requires AGP 8.9.1+. Force an + // older, still-compatible version until AGP can be bumped (blocked + // on twitter_login releasing a fix, see the compileSdk comment + // above). + force "androidx.browser:browser:1.8.0" + } +} From 05573f2177d73bac9fa0351ba60a32443b91577c Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:30:56 +0100 Subject: [PATCH 6/7] fix(ui_oauth): pin remaining androidx transitive deps for android e2e Forcing androidx.browser alone wasn't enough: flutter_web_auth_2 also directly depends on androidx.activity:activity-ktx 1.10.1, which pulls in an androidx.core version that also requires AGP 8.9.1+. Confirmed twitter_login still has no pub.dev release with the AGP-compatibility fix (namespace declaration merged upstream in 2024, never published; pub.dev still serves 4.4.2), so AGP stays at 8.7.3. Force the whole androidx.browser/activity/core set down to older, mutually-compatible versions instead. --- tests/android/app/build.gradle | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/android/app/build.gradle b/tests/android/app/build.gradle index fe678909..4670d0aa 100644 --- a/tests/android/app/build.gradle +++ b/tests/android/app/build.gradle @@ -46,11 +46,16 @@ flutter { configurations.all { resolutionStrategy { - // flutter_web_auth_2 (used by firebase_ui_oauth) pulls in - // androidx.browser 1.9.0, which requires AGP 8.9.1+. Force an - // older, still-compatible version until AGP can be bumped (blocked - // on twitter_login releasing a fix, see the compileSdk comment - // above). + // 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" } } From 9b832776de09e1f4a927d792d8c12abb42a0978f Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:45:44 +0100 Subject: [PATCH 7/7] fix(ui_oauth): drop androidx version pins, superseded by #697 #697 removed twitter_login and bumped tests/android to AGP 8.9.1, which is what androidx.browser/activity/core actually needed. The resolutionStrategy.force block added here to work around the old AGP 8.7.3 pin is no longer necessary now that this branch is rebased on top of that change. --- tests/android/app/build.gradle | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/android/app/build.gradle b/tests/android/app/build.gradle index 4670d0aa..b495ca5b 100644 --- a/tests/android/app/build.gradle +++ b/tests/android/app/build.gradle @@ -43,19 +43,3 @@ 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" - } -}