Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/flutterfire_cli/lib/src/commands/config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions packages/flutterfire_cli/lib/src/firebase.dart
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,62 @@ Future<FirebaseAppSdkConfig> getAppSdkConfig({
);
}

Future<String?> 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<String, dynamic>;
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ extension FirebaseDartOptions on FirebaseOptions {
static Future<FirebaseOptions> 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(
Expand All @@ -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\({(?<jsonBody>[\S\s]*)}\);''',
multiLine: true,
Expand All @@ -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,
},
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class FirebasePlatformOptions {
Future<FirebasePlatformOptions> fetchAllFirebaseOptions({
required FlutterApp flutterApp,
required String firebaseProjectId,
required String firebaseProjectNumber,
required bool windows,
required bool linux,
required bool web,
Expand All @@ -39,6 +40,7 @@ Future<FirebasePlatformOptions> fetchAllFirebaseOptions({
String? macosBundleId,
String? windowsAppId,
String? token,
String? appCheckAccessToken,
String? serviceAccount,
}) async {
FirebaseOptions? androidOptions;
Expand Down Expand Up @@ -85,9 +87,11 @@ Future<FirebasePlatformOptions> fetchAllFirebaseOptions({
webOptions = await FirebaseDartOptions.forFlutterApp(
flutterApp,
firebaseProjectId: firebaseProjectId,
firebaseProjectNumber: firebaseProjectNumber,
firebaseAccount: firebaseAccount,
webAppId: webAppId,
token: token,
appCheckAccessToken: appCheckAccessToken,
serviceAccount: serviceAccount,
);
}
Expand Down
100 changes: 100 additions & 0 deletions packages/flutterfire_cli/test/firebase_app_check_test.dart
Original file line number Diff line number Diff line change
@@ -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(<String, dynamic>{}), 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);
});
});
}
46 changes: 46 additions & 0 deletions packages/flutterfire_cli/test/firebase_dart_options_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading