diff --git a/packages/firebase_ui_auth/example/ios/Runner.xcodeproj/project.pbxproj b/packages/firebase_ui_auth/example/ios/Runner.xcodeproj/project.pbxproj index 175cb443..7604a069 100644 --- a/packages/firebase_ui_auth/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/firebase_ui_auth/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -11,7 +11,6 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 615AB19345F2CB9C4AFB55AE /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5CBE04B4787B566D8CAE0579 /* GoogleService-Info.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 83A0F86F233458219B2DC55F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 416FB58C991096C1726F437F /* Pods_Runner.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -43,6 +42,7 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 762BD74E83C8639023D97BD3 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -58,7 +58,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 83A0F86F233458219B2DC55F /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -69,6 +68,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -154,7 +154,6 @@ ); name = Runner; packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; @@ -185,7 +184,7 @@ ); mainGroup = 97C146E51CF9000F007C117D; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; @@ -236,10 +235,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -290,10 +293,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -374,7 +381,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -394,6 +401,7 @@ DEVELOPMENT_TEAM = YYX2P3XVJ7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -453,7 +461,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -502,7 +510,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -524,6 +532,7 @@ DEVELOPMENT_TEAM = YYX2P3XVJ7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -548,6 +557,7 @@ DEVELOPMENT_TEAM = YYX2P3XVJ7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -586,18 +596,11 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; /* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/packages/firebase_ui_auth/example/ios/Runner/Info.plist b/packages/firebase_ui_auth/example/ios/Runner/Info.plist index 5139a1be..d595ba76 100644 --- a/packages/firebase_ui_auth/example/ios/Runner/Info.plist +++ b/packages/firebase_ui_auth/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -20,10 +22,46 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.406099696497-65v1b9ffv6sgfqngfjab5ol5qdikh2rm + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + fb128693022464535 + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) + FacebookAppID + 128693022464535 + FacebookClientToken + 16dbbdf0cfb309034a6ad98ac2a21688 + FacebookDisplayName + Flutter Firebase UI Example + FlutterDeepLinkingEnabled + + GIDClientID + 406099696497-65v1b9ffv6sgfqngfjab5ol5qdikh2rm.apps.googleusercontent.com + LSApplicationQueriesSchemes + + fbapi + fb-messenger-share-api + LSRequiresIPhoneOS + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -43,24 +81,5 @@ UIViewControllerBasedStatusBarAppearance - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - FlutterDeepLinkingEnabled - - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - com.googleusercontent.apps.406099696497-65v1b9ffv6sgfqngfjab5ol5qdikh2rm - - - - GIDClientID - 406099696497-65v1b9ffv6sgfqngfjab5ol5qdikh2rm.apps.googleusercontent.com diff --git a/packages/firebase_ui_auth/example/pubspec.yaml b/packages/firebase_ui_auth/example/pubspec.yaml index e313d41c..6aad676a 100644 --- a/packages/firebase_ui_auth/example/pubspec.yaml +++ b/packages/firebase_ui_auth/example/pubspec.yaml @@ -40,7 +40,7 @@ dependencies: dev_dependencies: drive: ^1.0.0-1.0.nullsafety.5 firebase_ui_shared: ^1.4.1 - flutter_facebook_auth: ^6.0.3 + flutter_facebook_auth: ^7.1.2 flutter_driver: sdk: flutter flutter_test: @@ -59,6 +59,8 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + config: + enable-swift-package-manager: false # To add assets to your application, add an assets section, like this: assets: - assets/images/ @@ -83,4 +85,4 @@ flutter: # weight: 700 # # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages + # see https://flutter.dev/custom-fonts/#from-packages \ No newline at end of file diff --git a/packages/firebase_ui_oauth_facebook/lib/src/provider.dart b/packages/firebase_ui_oauth_facebook/lib/src/provider.dart index 37502a42..38eb5208 100644 --- a/packages/firebase_ui_oauth_facebook/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_facebook/lib/src/provider.dart @@ -2,11 +2,15 @@ // 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 'dart:math'; +import 'package:crypto/crypto.dart'; import 'package:firebase_auth/firebase_auth.dart' as fba; import 'package:flutter/foundation.dart'; import 'package:firebase_ui_oauth/firebase_ui_oauth.dart'; import 'package:flutter_facebook_auth/flutter_facebook_auth.dart'; import 'package:firebase_ui_oauth_facebook/firebase_ui_oauth_facebook.dart'; +import 'package:app_tracking_transparency/app_tracking_transparency.dart'; class FacebookProvider extends OAuthProvider { @override @@ -15,6 +19,7 @@ class FacebookProvider extends OAuthProvider { FacebookAuth provider = FacebookAuth.instance; final String clientId; final String? redirectUri; + String? _rawNonce; @override final style = const FacebookProviderButtonStyle(); @@ -30,11 +35,76 @@ class FacebookProvider extends OAuthProvider { this.redirectUri, }); + /// Generates a cryptographically secure random nonce for limited login + @visibleForTesting + String _generateNonce([int length = 32]) { + const charset = + '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._'; + final random = Random.secure(); + return List.generate(length, (_) => charset[random.nextInt(charset.length)]) + .join(); + } + + /// Returns the SHA256 hash of the given string + @visibleForTesting + String _sha256ofString(String input) { + final bytes = utf8.encode(input); + final digest = sha256.convert(bytes); + return digest.toString(); + } + + /// Checks if tracking permission has been granted on iOS + @visibleForTesting + Future _hasTrackingPermission() async { + // Only check on iOS + if (defaultTargetPlatform != TargetPlatform.iOS) { + return true; // Classic login available on Android + } + + try { + final status = await AppTrackingTransparency.trackingAuthorizationStatus; + return status == TrackingStatus.authorized; + } catch (e) { + // If there's an error checking permission, default to limited login + return false; + } + } + + @visibleForTesting void _handleResult(LoginResult result, AuthAction action) { switch (result.status) { case LoginStatus.success: - final token = result.accessToken!.token; - final credential = fba.FacebookAuthProvider.credential(token); + final accessToken = result.accessToken; + if (accessToken == null) { + authListener.onError(Exception('Access token is null')); + return; + } + + fba.OAuthCredential credential; + + // Check the token type to determine if it's classic or limited login + if (accessToken.type == AccessTokenType.classic) { + // Classic login - use access token + credential = + fba.FacebookAuthProvider.credential(accessToken.tokenString); + } else if (accessToken.type == AccessTokenType.limited) { + // Limited login - use ID token with nonce + if (_rawNonce == null) { + authListener.onError( + Exception('Nonce not generated for limited login'), + ); + return; + } + credential = fba.OAuthProvider(providerId).credential( + idToken: accessToken.tokenString, + rawNonce: _rawNonce, + ); + } else { + authListener.onError( + Exception('Unknown access token type: ${accessToken.type}'), + ); + return; + } onCredentialReceived(credential, action); break; @@ -69,11 +139,38 @@ class FacebookProvider extends OAuthProvider { } @override - void mobileSignIn(AuthAction action) { - final result = provider.login(); - result - .then((result) => _handleResult(result, action)) - .catchError(authListener.onError); + void mobileSignIn(AuthAction action) async { + try { + // Check if tracking permission is granted + final hasPermission = await _hasTrackingPermission(); + + // Determine login tracking mode + final loginTracking = + hasPermission ? LoginTracking.enabled : LoginTracking.limited; + + // Generate nonce for limited login + if (loginTracking == LoginTracking.limited) { + _rawNonce = _generateNonce(); + final hashedNonce = _sha256ofString(_rawNonce!); + + // Perform login with nonce + final result = await provider.login( + permissions: ['email', 'public_profile'], + loginTracking: loginTracking, + nonce: hashedNonce, + ); + _handleResult(result, action); + } else { + // Perform classic login without nonce + final result = await provider.login( + permissions: ['email', 'public_profile'], + loginTracking: loginTracking, + ); + _handleResult(result, action); + } + } catch (error) { + authListener.onError(error); + } } @override @@ -81,3 +178,26 @@ class FacebookProvider extends OAuthProvider { return true; } } + +// Extension to expose private methods and fields for testing +extension FacebookProviderTestExtension on FacebookProvider { + String generateNonceForTest([int length = 32]) { + return _generateNonce(length); + } + + String sha256ForTest(String input) { + return _sha256ofString(input); + } + + Future hasTrackingPermissionForTest() { + return _hasTrackingPermission(); + } + + void handleResultForTest(LoginResult result, AuthAction action) { + _handleResult(result, action); + } + + void setRawNonceForTest(String? nonce) { + _rawNonce = nonce; + } +} diff --git a/packages/firebase_ui_oauth_facebook/pubspec.yaml b/packages/firebase_ui_oauth_facebook/pubspec.yaml index 06984c8b..793e704a 100644 --- a/packages/firebase_ui_oauth_facebook/pubspec.yaml +++ b/packages/firebase_ui_oauth_facebook/pubspec.yaml @@ -12,7 +12,9 @@ dependencies: firebase_ui_oauth: ^2.0.0 flutter: sdk: flutter - flutter_facebook_auth: ^6.0.3 + flutter_facebook_auth: ^7.1.2 + app_tracking_transparency: ^2.0.6+1 + crypto: ^3.0.3 dev_dependencies: flutter_test: diff --git a/packages/firebase_ui_oauth_facebook/test/facebook_provider_test.dart b/packages/firebase_ui_oauth_facebook/test/facebook_provider_test.dart new file mode 100644 index 00000000..99af0794 --- /dev/null +++ b/packages/firebase_ui_oauth_facebook/test/facebook_provider_test.dart @@ -0,0 +1,404 @@ +// Copyright 2025, 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 'package:firebase_auth/firebase_auth.dart' as fba; +import 'package:firebase_ui_auth/firebase_ui_auth.dart'; +import 'package:firebase_ui_oauth/firebase_ui_oauth.dart'; +import 'package:firebase_ui_oauth_facebook/firebase_ui_oauth_facebook.dart'; +import 'package:firebase_ui_oauth_facebook/src/provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_facebook_auth/flutter_facebook_auth.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Manual mocks +class MockFirebaseAuth extends Fake implements fba.FirebaseAuth { + @override + fba.User? get currentUser => null; + + @override + Future signInWithCredential( + fba.AuthCredential credential, + ) async { + // Return a fake UserCredential + return MockUserCredential(); + } +} + +class MockUserCredential extends Fake implements fba.UserCredential { + @override + fba.User? get user => null; + + @override + fba.AuthCredential? get credential => null; +} + +class MockFacebookAuth extends Fake implements FacebookAuth { + LoginResult? loginResult; + bool logoutCalled = false; + + @override + Future login({ + List? permissions, + LoginBehavior? loginBehavior, + LoginTracking? loginTracking, + String? nonce, + }) async { + return loginResult ?? MockLoginResult(status: LoginStatus.failed); + } + + @override + Future logOut() async { + logoutCalled = true; + } +} + +class MockOAuthListener extends Fake implements OAuthListener { + final List errors = []; + final List receivedCredentials = []; + final List signedInResults = []; + bool beforeSignInCalled = false; + + @override + void onError(Object error) { + errors.add(error); + } + + @override + void onCredentialReceived(fba.AuthCredential credential) { + receivedCredentials.add(credential); + } + + @override + void onBeforeSignIn() { + beforeSignInCalled = true; + } + + @override + void onSignedIn(fba.UserCredential userCredential) { + signedInResults.add(userCredential); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FacebookProvider', () { + late FacebookProvider provider; + late MockFacebookAuth mockFacebookAuth; + late MockOAuthListener mockListener; + late MockFirebaseAuth mockFirebaseAuth; + + setUp(() { + mockFacebookAuth = MockFacebookAuth(); + mockListener = MockOAuthListener(); + mockFirebaseAuth = MockFirebaseAuth(); + + provider = FacebookProvider(clientId: 'test-client-id'); + provider.provider = mockFacebookAuth; + provider.authListener = mockListener; + provider.auth = mockFirebaseAuth; + }); + + group('Nonce generation', () { + test('generates nonce with correct length', () { + final nonce = provider.generateNonceForTest(); + expect(nonce.length, equals(32)); + }); + + test('generates different nonces on each call', () { + final nonce1 = provider.generateNonceForTest(); + final nonce2 = provider.generateNonceForTest(); + expect(nonce1, isNot(equals(nonce2))); + }); + + test('generates nonce with valid characters', () { + final nonce = provider.generateNonceForTest(); + final validChars = RegExp(r'^[0-9A-Za-z\-._]+$'); + expect(validChars.hasMatch(nonce), isTrue); + }); + }); + + group('SHA256 hashing', () { + test('generates consistent hash for same input', () { + const input = 'test-nonce-123'; + final hash1 = provider.sha256ForTest(input); + final hash2 = provider.sha256ForTest(input); + expect(hash1, equals(hash2)); + }); + + test('generates different hashes for different inputs', () { + final hash1 = provider.sha256ForTest('input1'); + final hash2 = provider.sha256ForTest('input2'); + expect(hash1, isNot(equals(hash2))); + }); + + test('generates valid SHA256 hash', () { + final hash = provider.sha256ForTest('test'); + // SHA256 hash should be 64 characters long (256 bits in hex) + expect(hash.length, equals(64)); + // Should only contain hex characters + expect(RegExp(r'^[0-9a-f]+$').hasMatch(hash), isTrue); + }); + }); + + group('Classic login (with tracking permission)', () { + test('handles classic login success', () async { + // Arrange + final mockAccessToken = MockAccessToken( + tokenString: 'test-access-token', + type: AccessTokenType.classic, + ); + final mockResult = MockLoginResult( + status: LoginStatus.success, + accessToken: mockAccessToken, + ); + + // Act - call the internal handler directly + provider.handleResultForTest(mockResult, AuthAction.signIn); + + // Wait for async operations to complete + await Future.delayed(Duration.zero); + + // Assert - signInWithCredential was called and completed successfully + expect(mockListener.signedInResults.length, equals(1)); + expect(mockListener.errors.isEmpty, isTrue); + }); + + test('uses classic login on Android', () async { + // Android should always use classic login + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final hasPermission = await provider.hasTrackingPermissionForTest(); + expect(hasPermission, isTrue); + + debugDefaultTargetPlatformOverride = null; + }); + }); + + group('Limited login (without tracking permission)', () { + test('handles limited login success with nonce', () async { + // Arrange + const rawNonce = 'test-raw-nonce'; + provider.setRawNonceForTest(rawNonce); + + final mockAccessToken = MockAccessToken( + tokenString: 'test-id-token', + type: AccessTokenType.limited, + ); + final mockResult = MockLoginResult( + status: LoginStatus.success, + accessToken: mockAccessToken, + ); + + // Act + provider.handleResultForTest(mockResult, AuthAction.signIn); + + // Wait for async operations to complete + await Future.delayed(Duration.zero); + + // Assert - signInWithCredential was called and completed successfully + expect(mockListener.signedInResults.length, equals(1)); + expect(mockListener.errors.isEmpty, isTrue); + }); + + test('returns error when nonce is missing for limited login', () { + // Arrange + provider.setRawNonceForTest(null); // Clear nonce + + final mockAccessToken = MockAccessToken( + tokenString: 'test-id-token', + type: AccessTokenType.limited, + ); + final mockResult = MockLoginResult( + status: LoginStatus.success, + accessToken: mockAccessToken, + ); + + // Act + provider.handleResultForTest(mockResult, AuthAction.signIn); + + // Assert + expect(mockListener.errors.length, equals(1)); + expect(mockListener.signedInResults.isEmpty, isTrue); + }); + }); + + group('Error handling', () { + test('handles login cancellation', () { + // Arrange + final mockResult = MockLoginResult( + status: LoginStatus.cancelled, + accessToken: null, + ); + + // Act + provider.handleResultForTest(mockResult, AuthAction.signIn); + + // Assert + expect(mockListener.errors.length, equals(1)); + expect(mockListener.errors.first, isA()); + }); + + test('handles login failure', () { + // Arrange + final mockResult = MockLoginResult( + status: LoginStatus.failed, + accessToken: null, + message: 'Login failed', + ); + + // Act + provider.handleResultForTest(mockResult, AuthAction.signIn); + + // Assert + expect(mockListener.errors.length, equals(1)); + }); + + test('handles operation in progress error', () { + // Arrange + final mockResult = MockLoginResult( + status: LoginStatus.operationInProgress, + accessToken: null, + ); + + // Act + provider.handleResultForTest(mockResult, AuthAction.signIn); + + // Assert + expect(mockListener.errors.length, equals(1)); + }); + + test('handles null access token', () { + // Arrange + final mockResult = MockLoginResult( + status: LoginStatus.success, + accessToken: null, + ); + + // Act + provider.handleResultForTest(mockResult, AuthAction.signIn); + + // Assert + expect(mockListener.errors.length, equals(1)); + }); + + // Note: Cannot test unknown token type as AccessTokenType is an enum + // with only classic and limited values + }); + + group('Provider configuration', () { + test('has correct provider ID', () { + expect(provider.providerId, equals('facebook.com')); + }); + + test('supports all platforms', () { + expect(provider.supportsPlatform(TargetPlatform.android), isTrue); + expect(provider.supportsPlatform(TargetPlatform.iOS), isTrue); + expect(provider.supportsPlatform(TargetPlatform.macOS), isTrue); + expect(provider.supportsPlatform(TargetPlatform.windows), isTrue); + expect(provider.supportsPlatform(TargetPlatform.linux), isTrue); + }); + + test('has correct style', () { + expect(provider.style, isA()); + }); + + test('configures desktop sign-in args', () { + final provider = FacebookProvider( + clientId: 'test-client-id', + redirectUri: 'https://example.com/callback', + ); + + expect(provider.desktopSignInArgs, isA()); + final args = provider.desktopSignInArgs as FacebookSignInArgs; + expect(args.clientId, equals('test-client-id')); + expect(args.redirectUri, equals('https://example.com/callback')); + }); + }); + + group('Logout', () { + test('calls logout on mobile platforms', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await provider.logOutProvider(); + + expect(mockFacebookAuth.logoutCalled, isTrue); + + debugDefaultTargetPlatformOverride = null; + }); + }); + }); +} + +// Mock classes +class MockAccessToken implements AccessToken { + @override + final String tokenString; + + @override + final AccessTokenType type; + + MockAccessToken({ + required this.tokenString, + required this.type, + }); + + @override + String get applicationId => 'test-app-id'; + + @override + String? get dataAccessExpirationTime => null; + + @override + List get declinedPermissions => []; + + @override + List get expiredPermissions => []; + + @override + DateTime get expires => DateTime.now().add(const Duration(hours: 1)); + + @override + String? get graphDomain => null; + + @override + bool get isExpired => false; + + @override + DateTime get lastRefresh => DateTime.now(); + + @override + List get grantedPermissions => ['email', 'public_profile']; + + @override + String get userId => 'test-user-id'; + + @override + Map toJson() => {}; + + @override + String get token => tokenString; + + // Add any other required fields from AccessToken interface +} + +class MockLoginResult implements LoginResult { + @override + final LoginStatus status; + + @override + final AccessToken? accessToken; + + @override + final String? message; + + MockLoginResult({ + required this.status, + this.accessToken, + this.message, + }); + + @override + Map toJson() => {}; +} diff --git a/tests/pubspec.yaml b/tests/pubspec.yaml index 82451def..7aca457a 100644 --- a/tests/pubspec.yaml +++ b/tests/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: firebase_ui_oauth_facebook: ^2.0.0 firebase_ui_oauth_google: ^2.0.0 firebase_ui_oauth: ^2.0.0 - flutter_facebook_auth: ^6.0.3 + flutter_facebook_auth: ^7.1.2 twitter_login: ^4.4.2 firebase_ui_oauth_twitter: ^2.0.0 cloud_firestore: ^6.0.0