From 1667717f9250f9866d4c937f9bb2bc46c0e5e70c 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/6] 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 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 From 7c2e3dbb50e8c60969e491e72981e99ae7e04567 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/6] 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 8767888b2d651427751a115a67d58173acb968f7 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/6] 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 1031f1d4ba47ccb071ce35299abd9031473f0766 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/6] 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 71558bdd2469b3e07f8a558fefb0c2cbfcf04b2e 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/6] 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 326533d2..1070897f 100644 --- a/tests/android/app/build.gradle +++ b/tests/android/app/build.gradle @@ -44,3 +44,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 698b91da5d435d854ac675a936b93bf7a6621d75 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/6] 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 1070897f..b236c9ca 100644 --- a/tests/android/app/build.gradle +++ b/tests/android/app/build.gradle @@ -47,11 +47,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" } }