From abbe0cc6cc3152a3cc6759fe323044f73c60025b Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 18 Jun 2026 10:58:33 +0200 Subject: [PATCH] feat: fetch web reCAPTCHA site key for App Check --- .../lib/src/commands/config.dart | 2 + .../flutterfire_cli/lib/src/firebase.dart | 56 ++++++++++ .../src/firebase/firebase_dart_options.dart | 34 +++++- .../firebase/firebase_platform_options.dart | 4 + .../test/firebase_app_check_test.dart | 100 ++++++++++++++++++ .../test/firebase_dart_options_test.dart | 46 ++++++++ 6 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 packages/flutterfire_cli/test/firebase_app_check_test.dart diff --git a/packages/flutterfire_cli/lib/src/commands/config.dart b/packages/flutterfire_cli/lib/src/commands/config.dart index 5b2b5d19..5ae21f53 100644 --- a/packages/flutterfire_cli/lib/src/commands/config.dart +++ b/packages/flutterfire_cli/lib/src/commands/config.dart @@ -620,11 +620,13 @@ class ConfigCommand extends FlutterFireCommand { final fetchedFirebaseOptions = await fetchAllFirebaseOptions( flutterApp: flutterApp!, firebaseProjectId: selectedFirebaseProject.projectId, + firebaseProjectNumber: selectedFirebaseProject.projectNumber, firebaseAccount: accountEmail, androidApplicationId: androidApplicationId, iosBundleId: iosBundleId, macosBundleId: macosBundleId, token: token, + appCheckAccessToken: testAccessToken, serviceAccount: serviceAccount, webAppId: webAppId, windowsAppId: windowsAppId, diff --git a/packages/flutterfire_cli/lib/src/firebase.dart b/packages/flutterfire_cli/lib/src/firebase.dart index 537ddda0..497183d3 100644 --- a/packages/flutterfire_cli/lib/src/firebase.dart +++ b/packages/flutterfire_cli/lib/src/firebase.dart @@ -274,6 +274,62 @@ Future getAppSdkConfig({ ); } +Future getRecaptchaEnterpriseSiteKey({ + required String projectNumber, + required String appId, + String? accessToken, + http.Client? client, +}) async { + try { + accessToken ??= await getAccessToken(); + } catch (e) { + if (debugMode) { + logger.stdout( + 'Firebase App Check:`getRecaptchaEnterpriseSiteKey()`:getAccessToken: $e', + ); + } + return null; + } + + final httpClient = client ?? http.Client(); + late http.Response response; + try { + response = await httpClient.get( + Uri.https( + 'firebaseappcheck.googleapis.com', + '/v1/projects/$projectNumber/apps/$appId/recaptchaEnterpriseConfig', + ), + headers: {'Authorization': 'Bearer $accessToken'}, + ); + } catch (e) { + if (debugMode) { + logger.stdout( + 'Firebase App Check:`getRecaptchaEnterpriseSiteKey()`:http.get: $e', + ); + } + return null; + } finally { + if (client == null) { + httpClient.close(); + } + } + + if (response.statusCode == 200) { + final json = jsonDecode(response.body) as Map; + final siteKey = json['siteKey'] as String?; + return siteKey == null || siteKey.isEmpty ? null : siteKey; + } + + if (debugMode && response.statusCode != 404) { + logger.stdout( + 'Firebase App Check:`getRecaptchaEnterpriseSiteKey()`: ' + 'statusCode: ${response.statusCode}, response: ${response.body}', + ); + } + + return null; +} + void _assertFirebaseSupportedPlatform(String platformIdentifier) { if (![kAndroid, kWeb, kIos].contains(platformIdentifier)) { throw FirebasePlatformNotSupportedException(platformIdentifier); diff --git a/packages/flutterfire_cli/lib/src/firebase/firebase_dart_options.dart b/packages/flutterfire_cli/lib/src/firebase/firebase_dart_options.dart index edadd712..c31e445e 100644 --- a/packages/flutterfire_cli/lib/src/firebase/firebase_dart_options.dart +++ b/packages/flutterfire_cli/lib/src/firebase/firebase_dart_options.dart @@ -27,10 +27,12 @@ extension FirebaseDartOptions on FirebaseOptions { static Future forFlutterApp( FlutterApp flutterApp, { required String firebaseProjectId, + String? firebaseProjectNumber, String? firebaseAccount, String? webAppId, String platform = kWeb, required String? token, + String? appCheckAccessToken, required String? serviceAccount, }) async { final firebaseApp = await firebase.findOrCreateFirebaseApp( @@ -50,13 +52,26 @@ extension FirebaseDartOptions on FirebaseOptions { serviceAccount: serviceAccount, ); - return convertConfigToOptions(appSdkConfig, firebaseProjectId); + final recaptchaSiteKey = platform == kWeb && firebaseProjectNumber != null + ? await firebase.getRecaptchaEnterpriseSiteKey( + projectNumber: firebaseProjectNumber, + appId: firebaseApp.appId, + accessToken: appCheckAccessToken, + ) + : null; + + return convertConfigToOptions( + appSdkConfig, + firebaseProjectId, + recaptchaSiteKey: recaptchaSiteKey, + ); } static FirebaseOptions convertConfigToOptions( FirebaseAppSdkConfig appSdkConfig, - String firebaseProjectId, - ) { + String firebaseProjectId, { + String? recaptchaSiteKey, + }) { final jsonBodyRegex = RegExp( r'''firebase\.initializeApp\({(?[\S\s]*)}\);''', multiLine: true, @@ -65,14 +80,23 @@ extension FirebaseDartOptions on FirebaseOptions { var jsonBody = ''; if (match != null) { jsonBody = match.namedGroup('jsonBody')!; + final configMap = const JsonDecoder().convert('{$jsonBody}') as Map; return FirebaseOptions.fromMap( - const JsonDecoder().convert('{$jsonBody}') as Map, + { + ...configMap, + if (recaptchaSiteKey != null) 'recaptchaSiteKey': recaptchaSiteKey, + }, ); } else { // Handle new JSON format introduced in Firebase CLI v13.31.0 // The config is now returned as direct JSON instead of JavaScript format + final configMap = + const JsonDecoder().convert(appSdkConfig.fileContents) as Map; return FirebaseOptions.fromMap( - const JsonDecoder().convert(appSdkConfig.fileContents) as Map, + { + ...configMap, + if (recaptchaSiteKey != null) 'recaptchaSiteKey': recaptchaSiteKey, + }, ); } } diff --git a/packages/flutterfire_cli/lib/src/firebase/firebase_platform_options.dart b/packages/flutterfire_cli/lib/src/firebase/firebase_platform_options.dart index 1a9d50b4..0af7ca3e 100644 --- a/packages/flutterfire_cli/lib/src/firebase/firebase_platform_options.dart +++ b/packages/flutterfire_cli/lib/src/firebase/firebase_platform_options.dart @@ -26,6 +26,7 @@ class FirebasePlatformOptions { Future fetchAllFirebaseOptions({ required FlutterApp flutterApp, required String firebaseProjectId, + required String firebaseProjectNumber, required bool windows, required bool linux, required bool web, @@ -39,6 +40,7 @@ Future fetchAllFirebaseOptions({ String? macosBundleId, String? windowsAppId, String? token, + String? appCheckAccessToken, String? serviceAccount, }) async { FirebaseOptions? androidOptions; @@ -85,9 +87,11 @@ Future fetchAllFirebaseOptions({ webOptions = await FirebaseDartOptions.forFlutterApp( flutterApp, firebaseProjectId: firebaseProjectId, + firebaseProjectNumber: firebaseProjectNumber, firebaseAccount: firebaseAccount, webAppId: webAppId, token: token, + appCheckAccessToken: appCheckAccessToken, serviceAccount: serviceAccount, ); } diff --git a/packages/flutterfire_cli/test/firebase_app_check_test.dart b/packages/flutterfire_cli/test/firebase_app_check_test.dart new file mode 100644 index 00000000..7adc4e2e --- /dev/null +++ b/packages/flutterfire_cli/test/firebase_app_check_test.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +import 'package:flutterfire_cli/src/firebase.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +void main() { + group('getRecaptchaEnterpriseSiteKey', () { + test('returns siteKey from reCAPTCHA Enterprise config', () async { + late http.Request capturedRequest; + final client = MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({'siteKey': 'test-enterprise-site-key'}), + 200, + ); + }); + + final siteKey = await getRecaptchaEnterpriseSiteKey( + projectNumber: '1234567890', + appId: '1:1234567890:web:abcdef', + accessToken: 'test-access-token', + client: client, + ); + + expect(siteKey, 'test-enterprise-site-key'); + expect( + capturedRequest.url, + Uri.parse( + 'https://firebaseappcheck.googleapis.com/v1/projects/1234567890/apps/1:1234567890:web:abcdef/recaptchaEnterpriseConfig', + ), + ); + expect( + capturedRequest.headers['Authorization'], + 'Bearer test-access-token', + ); + }); + + test('returns null when config has no siteKey', () async { + final client = MockClient((request) async { + return http.Response(jsonEncode({}), 200); + }); + + final siteKey = await getRecaptchaEnterpriseSiteKey( + projectNumber: '1234567890', + appId: '1:1234567890:web:abcdef', + accessToken: 'test-access-token', + client: client, + ); + + expect(siteKey, isNull); + }); + + test('returns null when siteKey is empty', () async { + final client = MockClient((request) async { + return http.Response(jsonEncode({'siteKey': ''}), 200); + }); + + final siteKey = await getRecaptchaEnterpriseSiteKey( + projectNumber: '1234567890', + appId: '1:1234567890:web:abcdef', + accessToken: 'test-access-token', + client: client, + ); + + expect(siteKey, isNull); + }); + + test('returns null when config is not found', () async { + final client = MockClient((request) async { + return http.Response('not found', 404); + }); + + final siteKey = await getRecaptchaEnterpriseSiteKey( + projectNumber: '1234567890', + appId: '1:1234567890:web:abcdef', + accessToken: 'test-access-token', + client: client, + ); + + expect(siteKey, isNull); + }); + + test('returns null when request fails', () async { + final client = MockClient((request) async { + throw http.ClientException('network unavailable'); + }); + + final siteKey = await getRecaptchaEnterpriseSiteKey( + projectNumber: '1234567890', + appId: '1:1234567890:web:abcdef', + accessToken: 'test-access-token', + client: client, + ); + + expect(siteKey, isNull); + }); + }); +} diff --git a/packages/flutterfire_cli/test/firebase_dart_options_test.dart b/packages/flutterfire_cli/test/firebase_dart_options_test.dart index 967ec493..92c2c016 100644 --- a/packages/flutterfire_cli/test/firebase_dart_options_test.dart +++ b/packages/flutterfire_cli/test/firebase_dart_options_test.dart @@ -61,6 +61,52 @@ firebase.initializeApp({ expect(options.recaptchaSiteKey, 'test-web-recaptcha-site-key'); }); + test('adds fetched reCAPTCHA site key to JavaScript format', () { + final config = FirebaseAppSdkConfig( + fileName: 'firebase-config.js', + fileContents: ''' +firebase.initializeApp({ + "projectId": "test-project", + "appId": "1:1234567890:web:abcdef1234567890", + "apiKey": "test-api-key", + "authDomain": "test-project.firebaseapp.com", + "messagingSenderId": "1234567890", + "measurementId": "G-ABCDEF1234" +});''', + ); + + final options = FirebaseDartOptions.convertConfigToOptions( + config, + 'test-project', + recaptchaSiteKey: 'fetched-web-recaptcha-site-key', + ); + + expect(options.recaptchaSiteKey, 'fetched-web-recaptcha-site-key'); + }); + + test('adds fetched reCAPTCHA site key to JSON format', () { + final config = FirebaseAppSdkConfig( + fileName: 'firebase-config.json', + fileContents: ''' +{ + "projectId": "test-project", + "appId": "1:1234567890:web:abcdef1234567890", + "apiKey": "test-api-key", + "authDomain": "test-project.firebaseapp.com", + "messagingSenderId": "1234567890", + "measurementId": "G-ABCDEF1234" +}''', + ); + + final options = FirebaseDartOptions.convertConfigToOptions( + config, + 'test-project', + recaptchaSiteKey: 'fetched-web-recaptcha-site-key', + ); + + expect(options.recaptchaSiteKey, 'fetched-web-recaptcha-site-key'); + }); + test('throws FirebaseCommandException for invalid format', () { final config = FirebaseAppSdkConfig( fileName: 'invalid-config.txt',