From 98b7b20f3f56e6dee82c0e0743dee17485d0c5b8 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 29 Sep 2026 14:59:52 +0200 Subject: [PATCH 1/2] fix: fetch service files through the Firebase CLI in `reconfigure` Reusing firebase.json read the refresh token straight out of ~/.config/configstore/firebase-tools.json, so it failed whenever the Firebase CLI was authenticated any other way (FIREBASE_TOKEN, application default credentials, XDG_CONFIG_HOME) with either a missing-file error or "type 'Null' is not a subtype of type 'Map'". Fixes #353 --- .../lib/src/commands/reconfigure.dart | 17 ++++++--- .../flutterfire_cli/lib/src/firebase.dart | 38 ------------------- 2 files changed, 12 insertions(+), 43 deletions(-) diff --git a/packages/flutterfire_cli/lib/src/commands/reconfigure.dart b/packages/flutterfire_cli/lib/src/commands/reconfigure.dart index e192ab16..1b3dbd6f 100644 --- a/packages/flutterfire_cli/lib/src/commands/reconfigure.dart +++ b/packages/flutterfire_cli/lib/src/commands/reconfigure.dart @@ -107,9 +107,6 @@ class Reconfigure extends FlutterFireCommand { Map configuration, String platform, ) async { - // We pass access token for CI as the token won't be present in the "firebase-tools.json" file unless you login - accessToken ??= await getAccessToken(); - final serviceFilePath = configuration[kFileOutput] as String; // ignore: cast_nullable_to_non_nullable final projectId = configuration[kProjectId] as String; @@ -117,8 +114,18 @@ class Reconfigure extends FlutterFireCommand { final appId = configuration[kAppId] as String; if (platform == kAndroid || platform == kIos || platform == kMacos) { - final serviceFileContent = - await getServiceFileContent(projectId, appId, accessToken!, platform); + // Go through the Firebase CLI so we use however it is authenticated + // (`firebase login`, `FIREBASE_TOKEN`, application default credentials). + // An access token is only passed explicitly for CI. + final serviceFileContent = accessToken != null + ? await getServiceFileContent( + projectId, + appId, + accessToken!, + platform, + ) + : (await getAppSdkConfig(appId: appId, platform: platform)) + .fileContents; final serviceFilePathAbsolute = File(path.join(flutterApp!.package.path, serviceFilePath)); diff --git a/packages/flutterfire_cli/lib/src/firebase.dart b/packages/flutterfire_cli/lib/src/firebase.dart index 537ddda0..e70e427e 100644 --- a/packages/flutterfire_cli/lib/src/firebase.dart +++ b/packages/flutterfire_cli/lib/src/firebase.dart @@ -20,7 +20,6 @@ import 'dart:io'; import 'package:ansi_styles/ansi_styles.dart'; import 'package:http/http.dart' as http; -import 'package:path/path.dart' as path; import 'common/global.dart'; import 'common/strings.dart'; @@ -512,43 +511,6 @@ Future createAppleApp({ }); } -Future getAccessToken() async { - // Use refresh token to get access token, cannot simply use access token found in "firebase-tools.json" - final homeDir = Platform.isWindows - ? Platform.environment['UserProfile']! - : Platform.environment['HOME']!; - // Path to 'firebase-tools.json' - final configPath = - path.join(homeDir, '.config', 'configstore', 'firebase-tools.json'); - final configFile = File(configPath); - if (!configFile.existsSync()) { - throw Exception( - 'Failed to find "firebase-tools.json" file, it should be located at "$configPath', - ); - } - final map = await configFile.readAsString(); - final configJson = jsonDecode(map) as Map; - final tokens = configJson['tokens'] as Map; - final refreshToken = tokens['refresh_token'] as String; - - final response = await http.post( - Uri.parse('https://oauth2.googleapis.com/token'), - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - // Values for obtaining the access token are taken from the Firebase CLI source code: https://github.com/firebase/firebase-tools/blob/b14b5f38fe23da6543778a588811b0e2391427c0/src/api.ts#L18 - body: - 'grant_type=refresh_token&client_id=563584335869-fgrhgmd47bqnekij5i8b5pr03ho849e6.apps.googleusercontent.com&client_secret=j9iVZfS8kkCEFUPaAeJV0sAi&refresh_token=$refreshToken', - ); - - if (response.statusCode == 200) { - final json = jsonDecode(response.body) as Map; - return json['access_token'] as String; - } else { - throw Exception( - 'Failed to obtain an access token for making Firebase Management REST API requests. Status code: ${response.statusCode}. Response body: ${response.body}', - ); - } -} - // Return string value of "GoogleService-Info.plist" or "google-services.json" file for relevant platform Future getServiceFileContent( String projectId, From ca0506ac01dc876efd3feefc9cc00b79decb0796 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 29 Sep 2026 15:27:26 +0200 Subject: [PATCH 2/2] test: cover `reconfigure` without a `firebase login` refresh token Runs the real command against a stub Firebase CLI on PATH with an empty HOME, both with no configstore file and with one that has no tokens. Both fail on main with the errors reported in #353. --- .../test/reconfigure_service_file_test.dart | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 packages/flutterfire_cli/test/reconfigure_service_file_test.dart diff --git a/packages/flutterfire_cli/test/reconfigure_service_file_test.dart b/packages/flutterfire_cli/test/reconfigure_service_file_test.dart new file mode 100644 index 00000000..b8f32689 --- /dev/null +++ b/packages/flutterfire_cli/test/reconfigure_service_file_test.dart @@ -0,0 +1,162 @@ +// The stub Firebase CLI below is a shell script. +@TestOn('mac-os || linux') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +const _appId = '1:123:android:abc'; +const _serviceFileContents = '{"project_info":{"project_id":"fake-project"}}'; + +/// Runs `flutterfire reconfigure` on an Android-only app, against a stub +/// `firebase` on the PATH that is authenticated without `firebase login`, +/// i.e. with no refresh token in the configstore. +/// +/// Returns the arguments each stub `firebase` call received. +Future> _reconfigure( + Directory root, { + required void Function(Directory home) setUpHome, +}) async { + final app = Directory(path.join(root.path, 'app'))..createSync(); + File(path.join(app.path, 'pubspec.yaml')).writeAsStringSync(''' +name: test_app +environment: + sdk: ">=3.0.0 <4.0.0" +dependencies: + flutter: + sdk: flutter +'''); + File(path.join(app.path, 'firebase.json')).writeAsStringSync( + jsonEncode({ + 'flutter': { + 'platforms': { + 'android': { + 'default': { + 'projectId': 'fake-project', + 'appId': _appId, + 'fileOutput': 'android/app/google-services.json', + }, + }, + }, + }, + }), + ); + // Gradle files that already apply the Google Services plugin, so + // reconfigure has nothing to change in them. + final android = Directory(path.join(app.path, 'android')); + Directory(path.join(android.path, 'app')).createSync(recursive: true); + File(path.join(android.path, 'build.gradle.kts')).writeAsStringSync(''); + File(path.join(android.path, 'settings.gradle.kts')).writeAsStringSync(''' +plugins { + id("com.android.application") version "8.7.0" apply false + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.3.15") apply false + // END: FlutterFire Configuration +} +'''); + File(path.join(android.path, 'app', 'build.gradle.kts')).writeAsStringSync(''' +plugins { + id("com.android.application") + // START: FlutterFire Configuration + id("com.google.gms.google-services") + // END: FlutterFire Configuration +} +'''); + + final bin = Directory(path.join(root.path, 'bin'))..createSync(); + final calls = File(path.join(root.path, 'calls.log')); + final sdkConfig = jsonEncode({ + 'status': 'success', + 'result': { + 'fileName': 'google-services.json', + 'fileContents': _serviceFileContents, + }, + }); + final firebase = File(path.join(bin.path, 'firebase'))..writeAsStringSync(''' +#!/bin/sh +echo "\$@" >> '${calls.path}' +case "\$1" in + --version) echo 14.0.0 ;; + apps:sdkconfig) printf '%s' '$sdkConfig' ;; + *) echo '{"status":"error","error":"unexpected command"}'; exit 1 ;; +esac +'''); + Process.runSync('chmod', ['+x', firebase.path]); + + final home = Directory(path.join(root.path, 'home'))..createSync(); + setUpHome(home); + + final result = await Process.run( + Platform.resolvedExecutable, + [ + path.join(Directory.current.path, 'bin', 'flutterfire.dart'), + 'reconfigure', + ], + workingDirectory: app.path, + environment: { + 'HOME': home.path, + 'PATH': '${bin.path}:${Platform.environment['PATH']}', + }, + ); + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); + + final serviceFile = + File(path.join(app.path, 'android', 'app', 'google-services.json')); + expect( + serviceFile.existsSync(), + isTrue, + reason: 'google-services.json was not written:\n${result.stdout}', + ); + expect(serviceFile.readAsStringSync(), _serviceFileContents); + + return calls.readAsLinesSync(); +} + +void main() { + late Directory root; + + setUp(() { + root = Directory.systemTemp.createTempSync('flutterfire_reconfigure'); + }); + + tearDown(() { + root.deleteSync(recursive: true); + }); + + test( + 'reconfigure fetches the service file through the Firebase CLI when there ' + 'is no configstore file', + () async { + final calls = await _reconfigure(root, setUpHome: (_) {}); + + expect(calls, contains('apps:sdkconfig android $_appId --json')); + }, + ); + + test( + 'reconfigure fetches the service file through the Firebase CLI when the ' + 'configstore has no refresh token', + () async { + final calls = await _reconfigure( + root, + setUpHome: (home) { + File( + path.join( + home.path, + '.config', + 'configstore', + 'firebase-tools.json', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('{}'); + }, + ); + + expect(calls, contains('apps:sdkconfig android $_appId --json')); + }, + ); +}